From 1ede52799383b21dc7bfd1f82ebbaaa8762a624d Mon Sep 17 00:00:00 2001
From: erinepshovel-code <250928284+erinepshovel-code@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:15:21 +0000
Subject: [PATCH] feat(ahbg): recover validated presentation board; fix
occupancy offsets and viewBox
Recovered the presentation-only board from closed #17 (built from current
main). Repairs the two remaining renderer defects:
- initial vs final multi-unit occupancy offsets: a unit now uses the initial
occupancy group of its motion origin for the start marker and the final
group of its presented tile for the end marker, instead of applying the
final-tile offset to both endpoints.
- SVG viewBox bounds: viewBox now derives from seed circles plus every
displaced unit marker extent at both motion endpoints, so offset markers
cannot clip at the board edge.
Keeps UCNS as geometry authority (presentation only scales supplied centers).
Regression tests mirror the frozen offset math and assert the corrected
source structure.
---
.github/workflows/ahbg-presentation.yml | 28 ++
ahbg/presentation/README.md | 95 +++++
ahbg/presentation/__init__.py | 36 ++
ahbg/presentation/board.css | 45 +++
ahbg/presentation/board.html | 24 ++
ahbg/presentation/board.js | 350 +++++++++++++++++++
ahbg/presentation/geometry.py | 58 +++
ahbg/presentation/project.py | 149 ++++++++
ahbg/presentation/sample_snapshot.json | 35 ++
ahbg/presentation/snapshot.py | 213 +++++++++++
ahbg/presentation/tests/test_presentation.py | 217 ++++++++++++
11 files changed, 1250 insertions(+)
create mode 100644 .github/workflows/ahbg-presentation.yml
create mode 100644 ahbg/presentation/README.md
create mode 100644 ahbg/presentation/__init__.py
create mode 100644 ahbg/presentation/board.css
create mode 100644 ahbg/presentation/board.html
create mode 100644 ahbg/presentation/board.js
create mode 100644 ahbg/presentation/geometry.py
create mode 100644 ahbg/presentation/project.py
create mode 100644 ahbg/presentation/sample_snapshot.json
create mode 100644 ahbg/presentation/snapshot.py
create mode 100644 ahbg/presentation/tests/test_presentation.py
diff --git a/.github/workflows/ahbg-presentation.yml b/.github/workflows/ahbg-presentation.yml
new file mode 100644
index 0000000..81840c5
--- /dev/null
+++ b/.github/workflows/ahbg-presentation.yml
@@ -0,0 +1,28 @@
+name: ahbg-presentation
+
+on:
+ pull_request:
+ paths:
+ - "ahbg/presentation/**"
+ - ".github/workflows/ahbg-presentation.yml"
+ push:
+ branches: [main]
+ paths:
+ - "ahbg/presentation/**"
+ - ".github/workflows/ahbg-presentation.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ presentation:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ - name: Python presentation contract
+ run: python -m unittest discover -s ahbg/presentation/tests -p 'test*.py'
+ - name: Browser JavaScript syntax
+ run: node --check ahbg/presentation/board.js
diff --git a/ahbg/presentation/README.md b/ahbg/presentation/README.md
new file mode 100644
index 0000000..c46b141
--- /dev/null
+++ b/ahbg/presentation/README.md
@@ -0,0 +1,95 @@
+# AHBG presentation
+
+Grok-owned graphics surface. It renders validated presentation snapshots and
+already-resolved motion traces. It does **not** define game mechanics or derive
+UCNS geometry.
+
+## Boundary
+
+- Included: Seed-of-Life circle rendering, UCNS-supplied tile centerpoints,
+ unit markers, selection/inspection, public feed text, and visual traces of
+ already-resolved moves.
+- Excluded: legal movement, adjacency authority, turns, War resolution,
+ construction, permissions, RNG, DM state, private prompt state, or agent policy.
+- Snapshot standing is `ahbg.presentation.snapshot` / `not-mechanics`.
+- Every snapshot carries an exact `geometry_source` identity plus each tile's
+ UCNS-derived `x`, `y`, and `source_slot`. The renderer only scales those
+ coordinates for SVG display; it never rebuilds centers from local axial rules.
+- The current sample pins `The-Interdependency/ucns@1975fe70cf4e0826a8020c2da3047569e277af64`,
+ the canonical `libs/ucns/` identity in the stack manifest when this surface was
+ authored. Its `mobius_seed` module remains an explicit nonselecting UCNS
+ candidate; presentation consumption does not promote its standing.
+- `project.py` accepts already-sanitized observation data, source-backed display
+ coordinates, and resolved move events. It never decides whether those moves
+ were legal and copies only declared browser-facing fields.
+- `geometry.py` scales supplied source coordinates into display pixels only.
+
+## Usage guidance
+
+Validate the package:
+
+```bash
+python -m unittest discover -s ahbg/presentation/tests -p 'test*.py'
+node --check ahbg/presentation/board.js
+```
+
+Serve the board locally:
+
+```bash
+cd ahbg/presentation
+python -m http.server 8765 --bind 127.0.0.1
+# open http://127.0.0.1:8765/board.html
+```
+
+Project a sanitized observation only after the owning adapter has attached the
+UCNS-derived centers and exact source identity:
+
+```python
+from ahbg.presentation.project import snapshot_from_observation
+
+geometry_source = {
+ "repository": "The-Interdependency/ucns",
+ "commit": "1975fe70cf4e0826a8020c2da3047569e277af64",
+ "module": "src/ucns/mobius_seed.py",
+ "schema_id": "ucns.mobius-seed-of-life",
+ "schema_version": "0.1.0",
+ "projection_id": "seed-of-life-seven-equal-circles",
+ "selection_effect": "none",
+}
+observation = {
+ "turn": 1,
+ "geometry_source": geometry_source,
+ "tiles": [
+ {"tile_id": "CENTER", "ucns_slot": "CENTER", "x": 0.0, "y": 0.0},
+ {"tile_id": "RING_0", "ucns_slot": "RING_0", "x": 1.0, "y": 0.0},
+ ],
+ "units": [{"unit_id": "A0", "tile_id": "RING_0", "label": "A0"}],
+}
+move = {
+ "kind": "move",
+ "data": {"unit_id": "A0", "from_tile_id": "CENTER", "to_tile_id": "RING_0"},
+}
+snapshot = snapshot_from_observation(
+ observation,
+ plane_id="plane-0",
+ move_events=[move],
+ feed=[{"turn": 1, "text": "public display text only"}],
+)
+```
+
+The snapshot validator is allowlist-based. Unknown root, geometry, tile, unit,
+feed, or motion fields fail closed. The projector drops undeclared observation,
+move-event, and feed metadata before validation, preventing a visual snapshot
+from becoming a transport for private/internal fields.
+
+The browser exposes tile inspection to pointer and keyboard input, permits
+interactive descendants under an SVG `group` role, scales unit-marker spread by
+occupant count, and renders the selection ring above the unit layer.
+
+## hmmm
+
+- whether later Flower-of-Life rings belong on this presentation surface;
+- the exact live engine-to-observation adapter that attaches UCNS positions is
+ owned by the eventual engine integration, not by this graphics package;
+- construction animation remains unavailable until an owning mechanics layer
+ emits an already-resolved construction event contract.
diff --git a/ahbg/presentation/__init__.py b/ahbg/presentation/__init__.py
new file mode 100644
index 0000000..90b0e75
--- /dev/null
+++ b/ahbg/presentation/__init__.py
@@ -0,0 +1,36 @@
+# === MODULE_BUILD ===
+# id: ahbg_presentation_public_boundary
+# module_name: presentation
+# module_kind: adapter
+# summary: exposes the validated presentation snapshot and projection boundary without exporting mechanics
+# owner: AHBG presentation
+# public_surface: PresentationSnapshotError, load_snapshot, snapshot_from_observation, validate_snapshot
+# internal_surface: none
+# auth_boundary: none
+# storage_boundary: none
+# network_boundary: none
+# user_data_boundary: none
+# admin_only: false
+# tests: ahbg/presentation/tests/test_presentation.py
+# rollout: import explicitly from ahbg.presentation
+# rollback: remove package exports with presentation package
+# requires: ahbg_presentation_snapshot_contract, ahbg_presentation_observation_projector
+# since: 2026-08-31
+# unresolved: none
+# === END MODULE_BUILD ===
+
+"""Presentation-only AHBG graphics boundary.
+
+Usage guidance: import only validated presentation helpers from this package;
+game mechanics remain outside this namespace.
+"""
+
+from .project import snapshot_from_observation
+from .snapshot import PresentationSnapshotError, load_snapshot, validate_snapshot
+
+__all__ = [
+ "PresentationSnapshotError",
+ "load_snapshot",
+ "snapshot_from_observation",
+ "validate_snapshot",
+]
diff --git a/ahbg/presentation/board.css b/ahbg/presentation/board.css
new file mode 100644
index 0000000..12e271d
--- /dev/null
+++ b/ahbg/presentation/board.css
@@ -0,0 +1,45 @@
+:root {
+ --ink: #1b1a17;
+ --paper: #f4efe4;
+ --tile: #e6dcc8;
+ --tile-stroke: #6b5d44;
+ --selected: #c45c26;
+ --unit: #1f4b99;
+}
+
+html,
+body {
+ margin: 0;
+ background: var(--paper);
+ color: var(--ink);
+ font: 16px/1.4 "Iowan Old Style", "Palatino Linotype", Palatino, serif;
+}
+
+main {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 18rem;
+ min-height: 100vh;
+}
+
+.board-wrap { display: flex; flex-direction: column; padding: 1.5rem; }
+h1, h2 { font-weight: 600; letter-spacing: 0.04em; margin: 0 0 0.75rem; }
+.note { margin: 0 0 1rem; font-size: 0.95rem; }
+svg { width: min(100%, 36rem); height: auto; align-self: center; }
+.seed-circle { fill: none; stroke: var(--tile-stroke); stroke-width: 1.5; pointer-events: none; }
+.tile-point { fill: var(--tile-stroke); stroke: var(--paper); stroke-width: 1.5; pointer-events: none; }
+.tile-point.selected { fill: var(--selected); stroke: var(--selected); }
+.motion-path { fill: none; stroke: var(--selected); stroke-width: 2.5; stroke-dasharray: 6 4; pointer-events: none; }
+.tile-hit { fill: transparent; cursor: pointer; stroke: transparent; stroke-width: 3; }
+.tile-hit:focus { outline: none; stroke: var(--selected); }
+.tile-label { fill: var(--ink); font-size: 11px; pointer-events: none; text-anchor: middle; }
+.unit { fill: var(--unit); stroke: var(--paper); stroke-width: 2; pointer-events: none; }
+.unit-label { fill: var(--paper); font-size: 10px; pointer-events: none; text-anchor: middle; }
+.selection-ring { fill: none; stroke: var(--selected); stroke-width: 3; pointer-events: none; }
+.feed { background: #efe7d6; border-left: 1px solid var(--tile-stroke); padding: 1.5rem; }
+.feed ol { margin: 0; padding-left: 1.2rem; }
+.inspect { margin-top: 1rem; font-size: 0.95rem; }
+
+@media (max-width: 720px) {
+ main { grid-template-columns: 1fr; }
+ .feed { border-left: 0; border-top: 1px solid var(--tile-stroke); }
+}
diff --git a/ahbg/presentation/board.html b/ahbg/presentation/board.html
new file mode 100644
index 0000000..5ecc6c6
--- /dev/null
+++ b/ahbg/presentation/board.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+ AHBG presentation board
+
+
+
+
+
+
AHBG plane
+
Presentation only. Not mechanics. Tile centers are supplied by the declared UCNS source; the browser only scales them for display. Dashed traces show already-resolved motion. Click a center, or focus it and press Enter/Space, to inspect.
+
+
+
+
+
+
+
diff --git a/ahbg/presentation/board.js b/ahbg/presentation/board.js
new file mode 100644
index 0000000..ee7d3f3
--- /dev/null
+++ b/ahbg/presentation/board.js
@@ -0,0 +1,350 @@
+// === MODULE_BUILD ===
+// id: ahbg_presentation_browser_board
+// module_name: board
+// module_kind: ui_panel
+// summary: renders strict presentation snapshots using source-supplied UCNS center coordinates, accessible tile inspection, resolved motion traces, and nonoverlapping unit markers
+// owner: AHBG presentation
+// public_surface: browser board UI
+// internal_surface: validateSnapshot, render, sourceToPixel, offsetFor, boot
+// auth_boundary: none
+// storage_boundary: none
+// network_boundary: internal
+// user_data_boundary: read
+// admin_only: false
+// tests: ahbg/presentation/tests/test_presentation.py; node --check ahbg/presentation/board.js
+// rollout: static presentation page only
+// rollback: remove ahbg/presentation browser files without changing mechanics
+// requires: ahbg_presentation_snapshot_contract; UCNS-derived x/y and geometry_source carried by snapshot
+// since: 2026-08-31
+// unresolved: live engine-to-observation adapter remains outside presentation
+// === END MODULE_BUILD ===
+
+const UCNS_COMMIT = "1975fe70cf4e0826a8020c2da3047569e277af64";
+const SQRT3_HALF = Math.sqrt(3) / 2;
+const EMBEDDED_SNAPSHOT = {
+ kind: "ahbg.presentation.snapshot",
+ standing: "not-mechanics",
+ plane_id: "plane-0",
+ turn: 1,
+ geometry_source: {
+ repository: "The-Interdependency/ucns",
+ commit: UCNS_COMMIT,
+ module: "src/ucns/mobius_seed.py",
+ schema_id: "ucns.mobius-seed-of-life",
+ schema_version: "0.1.0",
+ projection_id: "seed-of-life-seven-equal-circles",
+ selection_effect: "none",
+ },
+ tiles: [
+ { id: "CENTER", source_slot: "CENTER", x: 0, y: 0, label: "origin" },
+ { id: "RING_0", source_slot: "RING_0", x: 1, y: 0, label: "ring 0" },
+ { id: "RING_1", source_slot: "RING_1", x: 0.5, y: SQRT3_HALF, label: "ring 1" },
+ { id: "RING_2", source_slot: "RING_2", x: -0.5, y: SQRT3_HALF, label: "ring 2" },
+ { id: "RING_3", source_slot: "RING_3", x: -1, y: 0, label: "ring 3" },
+ { id: "RING_4", source_slot: "RING_4", x: -0.5, y: -SQRT3_HALF, label: "ring 4" },
+ { id: "RING_5", source_slot: "RING_5", x: 0.5, y: -SQRT3_HALF, label: "ring 5" },
+ ],
+ units: [{ id: "A0", tile: "RING_0", label: "A0" }],
+ selected_tile: "RING_0",
+ motions: [{ unit: "A0", from: "CENTER", to: "RING_0" }],
+ feed: [
+ { turn: 0, text: "plane loaded; A0 at origin" },
+ { turn: 1, text: "A0 trace CENTER to RING_0" },
+ ],
+};
+
+const RADIUS = 64;
+const TILE_POINT = 6;
+const UNIT_RADIUS = 11;
+const ROOT_FIELDS = new Set(["kind", "standing", "plane_id", "turn", "geometry_source", "tiles", "units", "selected_tile", "feed", "motions"]);
+const GEOMETRY_FIELDS = new Set(["repository", "commit", "module", "schema_id", "schema_version", "projection_id", "selection_effect"]);
+const TILE_FIELDS = new Set(["id", "label", "source_slot", "x", "y"]);
+const UNIT_FIELDS = new Set(["id", "tile", "label"]);
+const FEED_FIELDS = new Set(["turn", "text"]);
+const MOTION_FIELDS = new Set(["unit", "from", "to"]);
+
+function sourceToPixel(tile) {
+ return { x: RADIUS * tile.x, y: -RADIUS * tile.y };
+}
+
+function exactText(value) {
+ return typeof value === "string" && value.length > 0;
+}
+
+function numeric(value) {
+ return typeof value === "number" && Number.isFinite(value);
+}
+
+function plainInteger(value) {
+ return Number.isInteger(value);
+}
+
+function rejectUnknown(value, allowed, surface) {
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key)).sort();
+ if (unknown.length) throw new Error(`${surface} has undeclared fields: ${unknown.join(", ")}`);
+}
+
+function validateSnapshot(snapshot) {
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
+ throw new Error("snapshot must be an object");
+ }
+ rejectUnknown(snapshot, ROOT_FIELDS, "snapshot");
+ if (snapshot.kind !== "ahbg.presentation.snapshot") throw new Error("kind must be ahbg.presentation.snapshot");
+ if (snapshot.standing !== "not-mechanics") throw new Error("standing must be not-mechanics");
+ if (!exactText(snapshot.plane_id)) throw new Error("plane_id must be exact non-empty text");
+ if (!plainInteger(snapshot.turn) || snapshot.turn < 0) throw new Error("turn must be a non-negative integer");
+
+ const geometry = snapshot.geometry_source;
+ if (!geometry || typeof geometry !== "object" || Array.isArray(geometry)) throw new Error("geometry_source must be an object");
+ rejectUnknown(geometry, GEOMETRY_FIELDS, "geometry_source");
+ for (const field of GEOMETRY_FIELDS) {
+ if (!exactText(geometry[field])) throw new Error(`geometry_source.${field} must be exact non-empty text`);
+ }
+ if (!/^[0-9a-f]{40}$/.test(geometry.commit)) throw new Error("geometry_source.commit must be a lowercase 40-hex commit");
+
+ if (!Array.isArray(snapshot.tiles) || snapshot.tiles.length === 0) throw new Error("tiles must be a non-empty list");
+ const ids = new Set();
+ const sourceSlots = new Set();
+ const positions = new Set();
+ for (const tile of snapshot.tiles) {
+ if (!tile || typeof tile !== "object" || Array.isArray(tile)) throw new Error("each tile must be an object");
+ rejectUnknown(tile, TILE_FIELDS, "tile");
+ if (!exactText(tile.id)) throw new Error("tile id must be exact non-empty text");
+ if (ids.has(tile.id)) throw new Error(`tile id repeats: ${tile.id}`);
+ if (!exactText(tile.source_slot)) throw new Error(`tile ${tile.id} source_slot must be exact non-empty text`);
+ if (sourceSlots.has(tile.source_slot)) throw new Error(`UCNS source slot repeats: ${tile.source_slot}`);
+ if (!numeric(tile.x) || !numeric(tile.y)) throw new Error(`tile ${tile.id} x,y must be finite numbers`);
+ const position = `${tile.x},${tile.y}`;
+ if (positions.has(position)) throw new Error(`tile source position repeats: ${position}`);
+ if (tile.label !== undefined && !exactText(tile.label)) throw new Error(`tile ${tile.id} label must be exact non-empty text when present`);
+ ids.add(tile.id);
+ sourceSlots.add(tile.source_slot);
+ positions.add(position);
+ }
+
+ if (!Array.isArray(snapshot.units)) throw new Error("units must be a list");
+ const unitIds = new Set();
+ const unitById = new Map();
+ for (const unit of snapshot.units) {
+ if (!unit || typeof unit !== "object" || Array.isArray(unit)) throw new Error("each unit must be an object");
+ rejectUnknown(unit, UNIT_FIELDS, "unit");
+ if (!exactText(unit.id)) throw new Error("unit id must be exact non-empty text");
+ if (unitIds.has(unit.id)) throw new Error(`unit id repeats: ${unit.id}`);
+ if (!exactText(unit.tile) || !ids.has(unit.tile)) throw new Error(`unit ${unit.id} tile is not a presented tile`);
+ if (unit.label !== undefined && !exactText(unit.label)) throw new Error(`unit ${unit.id} label must be exact non-empty text when present`);
+ unitIds.add(unit.id);
+ unitById.set(unit.id, unit);
+ }
+
+ if (snapshot.selected_tile !== undefined && snapshot.selected_tile !== null) {
+ if (!exactText(snapshot.selected_tile) || !ids.has(snapshot.selected_tile)) throw new Error("selected_tile must name a presented tile");
+ }
+ if (!Array.isArray(snapshot.feed)) throw new Error("feed must be a list");
+ for (const item of snapshot.feed) {
+ if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("each feed item must be an object");
+ rejectUnknown(item, FEED_FIELDS, "feed item");
+ if (!exactText(item.text)) throw new Error("each feed item must have exact non-empty text");
+ if (item.turn !== undefined && (!plainInteger(item.turn) || item.turn < 0)) throw new Error("feed turn must be a non-negative integer when present");
+ }
+
+ let motions = [];
+ if (Object.prototype.hasOwnProperty.call(snapshot, "motions")) {
+ if (!Array.isArray(snapshot.motions)) throw new Error("motions must be a list when present");
+ motions = snapshot.motions;
+ }
+ const motionUnits = new Set();
+ for (const motion of motions) {
+ if (!motion || typeof motion !== "object" || Array.isArray(motion)) throw new Error("each motion must be an object");
+ rejectUnknown(motion, MOTION_FIELDS, "motion");
+ if (!exactText(motion.unit) || !unitById.has(motion.unit)) throw new Error(`motion unit ${motion.unit} is not a presented unit`);
+ if (motionUnits.has(motion.unit)) throw new Error(`motion repeats unit ${motion.unit}`);
+ if (!exactText(motion.from) || !ids.has(motion.from)) throw new Error(`motion from ${motion.from} is not a presented tile`);
+ if (!exactText(motion.to) || !ids.has(motion.to)) throw new Error(`motion to ${motion.to} is not a presented tile`);
+ if (motion.from === motion.to) throw new Error(`motion for ${motion.unit} must change tiles`);
+ if (unitById.get(motion.unit).tile !== motion.to) throw new Error(`motion destination for ${motion.unit} must match its presented tile`);
+ motionUnits.add(motion.unit);
+ }
+ return snapshot;
+}
+
+function render(snapshot) {
+ const svg = document.getElementById("board");
+ const feed = document.getElementById("feed-list");
+ const inspect = document.getElementById("inspect");
+ svg.replaceChildren();
+ feed.replaceChildren();
+
+ const byId = Object.fromEntries(snapshot.tiles.map((tile) => [tile.id, tile]));
+ let selected = snapshot.selected_tile && byId[snapshot.selected_tile] ? snapshot.selected_tile : snapshot.tiles[0].id;
+ const hitByTile = new Map();
+ let selectionRing = null;
+
+ const motionByUnit = Object.fromEntries((snapshot.motions || []).map((motion) => [motion.unit, motion]));
+ function groupUnitsBy(getTile) {
+ const groups = new Map();
+ snapshot.units.forEach((unit) => {
+ const tile = getTile(unit);
+ const group = groups.get(tile) || [];
+ group.push(unit.id);
+ groups.set(tile, group);
+ });
+ return groups;
+ }
+ // Initial occupancy is computed from each unit's motion origin, not its
+ // presented (final) tile. A unit that moved off a crowded tile must use the
+ // initial group there; a unit that arrived on a crowded tile must use the
+ // final group here. Offsets are computed separately for each end.
+ const finalGroups = groupUnitsBy((unit) => unit.tile);
+ const initialGroups = groupUnitsBy((unit) => (motionByUnit[unit.id] ? motionByUnit[unit.id].from : unit.tile));
+
+ function offsetFor(unit, groups, tile) {
+ const group = groups.get(tile) || [unit.id];
+ if (group.length === 1) return { x: 0, y: 0 };
+ const index = group.indexOf(unit.id);
+ const angle = (Math.PI * 2 * index) / group.length - Math.PI / 2;
+ const minimumChord = UNIT_RADIUS * 2 + 4;
+ const spread = Math.max(UNIT_RADIUS * 1.35, minimumChord / (2 * Math.sin(Math.PI / group.length)));
+ return { x: Math.cos(angle) * spread, y: Math.sin(angle) * spread };
+ }
+
+ const placements = snapshot.units.map((unit) => {
+ const motion = motionByUnit[unit.id];
+ const initialTile = motion ? motion.from : unit.tile;
+ const initialCenter = sourceToPixel(byId[initialTile]);
+ const finalCenter = sourceToPixel(byId[unit.tile]);
+ const initialOffset = offsetFor(unit, initialGroups, initialTile);
+ const finalOffset = offsetFor(unit, finalGroups, unit.tile);
+ return {
+ unit,
+ motion,
+ origin: { x: initialCenter.x + initialOffset.x, y: initialCenter.y + initialOffset.y },
+ dest: { x: finalCenter.x + finalOffset.x, y: finalCenter.y + finalOffset.y },
+ };
+ });
+
+ // viewBox covers seed circles and every displaced unit marker at both its
+ // motion origin and its presented destination, so offset markers never clip.
+ const markerExtent = UNIT_RADIUS + 8;
+ let minX = Math.min(...snapshot.tiles.map((tile) => sourceToPixel(tile).x - RADIUS));
+ let minY = Math.min(...snapshot.tiles.map((tile) => sourceToPixel(tile).y - RADIUS));
+ let maxX = Math.max(...snapshot.tiles.map((tile) => sourceToPixel(tile).x + RADIUS));
+ let maxY = Math.max(...snapshot.tiles.map((tile) => sourceToPixel(tile).y + RADIUS));
+ placements.forEach(({ origin, dest }) => {
+ minX = Math.min(minX, origin.x - markerExtent, dest.x - markerExtent);
+ minY = Math.min(minY, origin.y - markerExtent, dest.y - markerExtent);
+ maxX = Math.max(maxX, origin.x + markerExtent, dest.x + markerExtent);
+ maxY = Math.max(maxY, origin.y + markerExtent, dest.y + markerExtent);
+ });
+ svg.setAttribute("viewBox", `${minX} ${minY} ${maxX - minX} ${maxY - minY}`);
+
+ function paintInspect() {
+ const tile = byId[selected];
+ const occupants = snapshot.units.filter((unit) => unit.tile === selected);
+ inspect.textContent = `tile ${tile.label || tile.id} — UCNS ${tile.source_slot} @ (${tile.x}, ${tile.y})${occupants.length ? ` — ${occupants.map((unit) => unit.label || unit.id).join(", ")}` : ""}`;
+ }
+
+ function paintSelection() {
+ svg.querySelectorAll(".tile-point").forEach((node) => {
+ node.setAttribute("class", node.dataset.tile === selected ? "tile-point selected" : "tile-point");
+ });
+ for (const [tileId, node] of hitByTile.entries()) node.setAttribute("aria-pressed", tileId === selected ? "true" : "false");
+ if (selectionRing) {
+ const { x, y } = sourceToPixel(byId[selected]);
+ selectionRing.setAttribute("cx", x);
+ selectionRing.setAttribute("cy", y);
+ }
+ paintInspect();
+ }
+
+ snapshot.tiles.forEach((tile) => {
+ const { x, y } = sourceToPixel(tile);
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ circle.setAttribute("cx", x);
+ circle.setAttribute("cy", y);
+ circle.setAttribute("r", RADIUS);
+ circle.setAttribute("class", "seed-circle");
+ svg.appendChild(circle);
+ });
+
+ (snapshot.motions || []).forEach((motion) => {
+ const from = sourceToPixel(byId[motion.from]);
+ const to = sourceToPixel(byId[motion.to]);
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "line");
+ path.setAttribute("x1", from.x);
+ path.setAttribute("y1", from.y);
+ path.setAttribute("x2", to.x);
+ path.setAttribute("y2", to.y);
+ path.setAttribute("class", "motion-path");
+ svg.appendChild(path);
+ });
+
+ snapshot.tiles.forEach((tile) => {
+ const { x, y } = sourceToPixel(tile);
+ const point = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ point.setAttribute("cx", x);
+ point.setAttribute("cy", y);
+ point.setAttribute("r", TILE_POINT);
+ point.setAttribute("class", tile.id === selected ? "tile-point selected" : "tile-point");
+ point.dataset.tile = tile.id;
+ svg.appendChild(point);
+
+ const hit = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ hit.setAttribute("cx", x);
+ hit.setAttribute("cy", y);
+ hit.setAttribute("r", RADIUS * 0.28);
+ hit.setAttribute("class", "tile-hit");
+ hit.setAttribute("tabindex", "0");
+ hit.setAttribute("role", "button");
+ hit.setAttribute("aria-label", `Inspect tile ${tile.label || tile.id}`);
+ hit.setAttribute("aria-pressed", tile.id === selected ? "true" : "false");
+ const selectTile = () => { selected = tile.id; paintSelection(); };
+ hit.addEventListener("click", selectTile);
+ hit.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectTile(); }
+ });
+ hitByTile.set(tile.id, hit);
+ svg.appendChild(hit);
+
+ const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ text.setAttribute("x", x);
+ text.setAttribute("y", y + RADIUS * 0.38);
+ text.setAttribute("class", "tile-label");
+ text.textContent = tile.label || tile.id;
+ svg.appendChild(text);
+ });
+
+ placements.forEach(({ unit, motion, origin, dest }) => {
+ const marker = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ marker.setAttribute("cx", origin.x); marker.setAttribute("cy", origin.y); marker.setAttribute("r", UNIT_RADIUS); marker.setAttribute("class", "unit");
+ svg.appendChild(marker);
+ const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ label.setAttribute("x", origin.x); label.setAttribute("y", origin.y + 4); label.setAttribute("class", "unit-label"); label.textContent = unit.label || unit.id;
+ svg.appendChild(label);
+
+ if (motion) {
+ const dur = "0.8s";
+ [["cx", origin.x, dest.x, marker], ["cy", origin.y, dest.y, marker], ["x", origin.x, dest.x, label], ["y", origin.y + 4, dest.y + 4, label]].forEach(([name, from, to, node]) => {
+ const animate = document.createElementNS("http://www.w3.org/2000/svg", "animate");
+ animate.setAttribute("attributeName", name); animate.setAttribute("from", from); animate.setAttribute("to", to); animate.setAttribute("dur", dur); animate.setAttribute("fill", "freeze"); node.appendChild(animate);
+ });
+ }
+ });
+
+ selectionRing = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ selectionRing.setAttribute("r", UNIT_RADIUS + 6); selectionRing.setAttribute("class", "selection-ring"); svg.appendChild(selectionRing);
+ snapshot.feed.forEach((item) => { const li = document.createElement("li"); li.textContent = `t${item.turn ?? "?"} ${item.text}`; feed.appendChild(li); });
+ paintSelection();
+}
+
+async function boot() {
+ let snapshot = EMBEDDED_SNAPSHOT;
+ try {
+ const response = await fetch("sample_snapshot.json", { cache: "no-store" });
+ if (response.ok) snapshot = await response.json();
+ } catch (_error) {
+ snapshot = EMBEDDED_SNAPSHOT;
+ }
+ render(validateSnapshot(snapshot));
+}
+
+boot();
diff --git a/ahbg/presentation/geometry.py b/ahbg/presentation/geometry.py
new file mode 100644
index 0000000..e66d333
--- /dev/null
+++ b/ahbg/presentation/geometry.py
@@ -0,0 +1,58 @@
+# === MODULE_BUILD ===
+# id: ahbg_presentation_display_transform
+# module_name: geometry
+# module_kind: adapter
+# summary: scales already-supplied UCNS source coordinates into SVG display coordinates without reconstructing or selecting geometry
+# owner: AHBG presentation
+# public_surface: source_to_display, center_distance
+# internal_surface: none
+# auth_boundary: none
+# storage_boundary: none
+# network_boundary: none
+# user_data_boundary: none
+# admin_only: false
+# tests: ahbg/presentation/tests/test_presentation.py
+# rollout: used only by presentation tests and equivalent browser transform
+# rollback: inline display scaling or remove presentation package; no mechanics effect
+# requires: caller-supplied UCNS-derived x/y source coordinates
+# since: 2026-08-31
+# unresolved: none
+# === END MODULE_BUILD ===
+
+"""Presentation-only transform of source-backed center coordinates.
+
+This module does not derive Seed-of-Life centers, adjacency, orientation, or
+nesting. It only scales coordinates already supplied by the declared UCNS source
+into display pixels; optional y inversion is the SVG screen-coordinate transform.
+
+Usage guidance:
+ ``source_to_display(x, y, scale=64.0)`` after validating the snapshot.
+"""
+
+from __future__ import annotations
+
+import math
+
+
+def source_to_display(
+ x: float,
+ y: float,
+ scale: float,
+ *,
+ invert_y: bool = True,
+) -> tuple[float, float]:
+ """Scale source coordinates for display without changing their topology."""
+
+ if isinstance(x, bool) or not isinstance(x, (int, float)):
+ raise ValueError("x must be numeric and nonboolean")
+ if isinstance(y, bool) or not isinstance(y, (int, float)):
+ raise ValueError("y must be numeric and nonboolean")
+ if isinstance(scale, bool) or not isinstance(scale, (int, float)) or scale <= 0:
+ raise ValueError("scale must be positive numeric and nonboolean")
+ return float(x) * float(scale), (-float(y) if invert_y else float(y)) * float(scale)
+
+
+def center_distance(left: tuple[float, float], right: tuple[float, float]) -> float:
+ """Measure distance between already-supplied source/display points."""
+
+ return math.hypot(left[0] - right[0], left[1] - right[1])
diff --git a/ahbg/presentation/project.py b/ahbg/presentation/project.py
new file mode 100644
index 0000000..aae4583
--- /dev/null
+++ b/ahbg/presentation/project.py
@@ -0,0 +1,149 @@
+# === MODULE_BUILD ===
+# id: ahbg_presentation_observation_projector
+# module_name: project
+# module_kind: adapter
+# summary: projects a sanitized public observation plus UCNS-derived display coordinates into the strict presentation snapshot schema
+# owner: AHBG presentation
+# public_surface: snapshot_from_observation
+# internal_surface: declared-field projection for geometry source, tiles, units, feed, and resolved motion events
+# auth_boundary: none
+# storage_boundary: none
+# network_boundary: none
+# user_data_boundary: read
+# admin_only: false
+# tests: ahbg/presentation/tests/test_presentation.py
+# rollout: explicit caller use only
+# rollback: remove projector while retaining snapshot schema and static sample
+# requires: ahbg_presentation_snapshot_contract; caller-supplied UCNS-derived x/y positions and exact geometry source identity
+# since: 2026-08-31
+# unresolved: live engine adapter that supplies UCNS positions is outside presentation ownership
+# === END MODULE_BUILD ===
+
+"""Project a sanitized observation into an AHBG presentation snapshot.
+
+This is graphics. It does not decide adjacency, legality, turn resolution, or
+UCNS geometry. Callers must supply UCNS-derived display ``x``/``y`` positions
+and exact geometry source identity. Unknown observation/feed fields are dropped
+rather than copied into the browser-facing envelope.
+
+Usage guidance:
+ Call ``snapshot_from_observation`` only after the owning engine/adapter has
+ attached source-backed UCNS positions to the public observation.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Mapping, Sequence
+
+from .snapshot import KIND, STANDING, PresentationSnapshotError, validate_snapshot
+
+
+_GEOMETRY_FIELDS = (
+ "repository",
+ "commit",
+ "module",
+ "schema_id",
+ "schema_version",
+ "projection_id",
+ "selection_effect",
+)
+
+
+def snapshot_from_observation(
+ observation: Mapping[str, Any],
+ *,
+ plane_id: str,
+ selected_tile: str | None = None,
+ feed: Sequence[Mapping[str, Any]] = (),
+ move_events: Sequence[Mapping[str, Any]] = (),
+) -> dict[str, Any]:
+ """Map declared public visual data and already-resolved moves to display data."""
+
+ if not isinstance(observation, Mapping):
+ raise PresentationSnapshotError("observation must be an object")
+ if not isinstance(plane_id, str) or not plane_id:
+ raise PresentationSnapshotError("plane_id must be exact non-empty text")
+
+ raw_geometry = observation.get("geometry_source")
+ if not isinstance(raw_geometry, Mapping):
+ raise PresentationSnapshotError("observation geometry_source must be an object")
+ geometry_source = {field: raw_geometry.get(field) for field in _GEOMETRY_FIELDS}
+
+ raw_tiles = observation.get("tiles")
+ raw_units = observation.get("units")
+ if not isinstance(raw_tiles, list) or not raw_tiles:
+ raise PresentationSnapshotError("observation tiles must be a non-empty list")
+ if not isinstance(raw_units, list):
+ raise PresentationSnapshotError("observation units must be a list")
+
+ tiles: list[dict[str, Any]] = []
+ for tile in raw_tiles:
+ if not isinstance(tile, Mapping):
+ raise PresentationSnapshotError("each observation tile must be an object")
+ tile_id = tile.get("tile_id", tile.get("id"))
+ presented: dict[str, Any] = {
+ "id": tile_id,
+ "source_slot": tile.get("source_slot", tile.get("ucns_slot")),
+ "x": tile.get("x"),
+ "y": tile.get("y"),
+ }
+ if tile.get("label") is not None:
+ presented["label"] = tile.get("label")
+ tiles.append(presented)
+
+ units: list[dict[str, Any]] = []
+ for unit in raw_units:
+ if not isinstance(unit, Mapping):
+ raise PresentationSnapshotError("each observation unit must be an object")
+ presented_unit: dict[str, Any] = {
+ "id": unit.get("unit_id", unit.get("id")),
+ "tile": unit.get("tile_id", unit.get("tile")),
+ }
+ if unit.get("label") is not None:
+ presented_unit["label"] = unit.get("label")
+ units.append(presented_unit)
+
+ motions: list[dict[str, Any]] = []
+ for event in move_events:
+ if not isinstance(event, Mapping):
+ raise PresentationSnapshotError("each move event must be an object")
+ kind = event.get("kind")
+ if kind not in (None, "move"):
+ continue
+ data = event.get("data", event)
+ if not isinstance(data, Mapping):
+ raise PresentationSnapshotError("move event data must be an object")
+ motions.append(
+ {
+ "unit": data.get("unit_id"),
+ "from": data.get("from_tile_id"),
+ "to": data.get("to_tile_id"),
+ }
+ )
+
+ presented_feed: list[dict[str, Any]] = []
+ for item in feed:
+ if not isinstance(item, Mapping):
+ raise PresentationSnapshotError("each feed item must be an object")
+ row: dict[str, Any] = {"text": item.get("text")}
+ if "turn" in item:
+ row["turn"] = item.get("turn")
+ presented_feed.append(row)
+
+ if selected_tile is None and units and isinstance(units[0].get("tile"), str):
+ selected_tile = units[0]["tile"]
+
+ snapshot: dict[str, Any] = {
+ "kind": KIND,
+ "standing": STANDING,
+ "plane_id": plane_id,
+ "turn": observation.get("turn"),
+ "geometry_source": geometry_source,
+ "tiles": tiles,
+ "units": units,
+ "selected_tile": selected_tile,
+ "feed": presented_feed,
+ }
+ if motions:
+ snapshot["motions"] = motions
+ return dict(validate_snapshot(snapshot))
diff --git a/ahbg/presentation/sample_snapshot.json b/ahbg/presentation/sample_snapshot.json
new file mode 100644
index 0000000..42afe84
--- /dev/null
+++ b/ahbg/presentation/sample_snapshot.json
@@ -0,0 +1,35 @@
+{
+ "kind": "ahbg.presentation.snapshot",
+ "standing": "not-mechanics",
+ "plane_id": "plane-0",
+ "turn": 1,
+ "geometry_source": {
+ "repository": "The-Interdependency/ucns",
+ "commit": "1975fe70cf4e0826a8020c2da3047569e277af64",
+ "module": "src/ucns/mobius_seed.py",
+ "schema_id": "ucns.mobius-seed-of-life",
+ "schema_version": "0.1.0",
+ "projection_id": "seed-of-life-seven-equal-circles",
+ "selection_effect": "none"
+ },
+ "tiles": [
+ {"id": "CENTER", "source_slot": "CENTER", "x": 0.0, "y": 0.0, "label": "origin"},
+ {"id": "RING_0", "source_slot": "RING_0", "x": 1.0, "y": 0.0, "label": "ring 0"},
+ {"id": "RING_1", "source_slot": "RING_1", "x": 0.5, "y": 0.8660254037844386, "label": "ring 1"},
+ {"id": "RING_2", "source_slot": "RING_2", "x": -0.5, "y": 0.8660254037844386, "label": "ring 2"},
+ {"id": "RING_3", "source_slot": "RING_3", "x": -1.0, "y": 0.0, "label": "ring 3"},
+ {"id": "RING_4", "source_slot": "RING_4", "x": -0.5, "y": -0.8660254037844386, "label": "ring 4"},
+ {"id": "RING_5", "source_slot": "RING_5", "x": 0.5, "y": -0.8660254037844386, "label": "ring 5"}
+ ],
+ "units": [
+ {"id": "A0", "tile": "RING_0", "label": "A0"}
+ ],
+ "selected_tile": "RING_0",
+ "motions": [
+ {"unit": "A0", "from": "CENTER", "to": "RING_0"}
+ ],
+ "feed": [
+ {"turn": 0, "text": "plane loaded; A0 at origin"},
+ {"turn": 1, "text": "A0 trace CENTER to RING_0"}
+ ]
+}
diff --git a/ahbg/presentation/snapshot.py b/ahbg/presentation/snapshot.py
new file mode 100644
index 0000000..2c91200
--- /dev/null
+++ b/ahbg/presentation/snapshot.py
@@ -0,0 +1,213 @@
+# === MODULE_BUILD ===
+# id: ahbg_presentation_snapshot_contract
+# module_name: snapshot
+# module_kind: schema
+# summary: validates the presentation-only AHBG snapshot envelope, including UCNS-derived display positions and exact geometry source identity
+# owner: AHBG presentation
+# public_surface: KIND, STANDING, PresentationSnapshotError, load_snapshot, validate_snapshot
+# internal_surface: _plain_int, _finite_number, _reject_unknown
+# auth_boundary: none
+# storage_boundary: read
+# network_boundary: none
+# user_data_boundary: none
+# admin_only: false
+# tests: ahbg/presentation/tests/test_presentation.py
+# rollout: consumed by presentation projector and browser sample
+# rollback: remove presentation package without changing AHBG mechanics or UCNS
+# requires: pinned UCNS geometry identity supplied in snapshot.geometry_source
+# since: 2026-08-31
+# unresolved: live engine-to-observation geometry adapter remains owned outside presentation
+# === END MODULE_BUILD ===
+
+"""AHBG presentation snapshot — visual fields only.
+
+This is not plane state and not a mechanics contract. Tile ``x``/``y`` values
+must already be derived from the declared UCNS geometry source; this module does
+not reconstruct geometry from AHBG-local coordinates. Optional motions are
+traces of already-resolved unit relocation between presented tiles.
+
+Usage guidance:
+ ``python -m unittest ahbg.presentation.tests.test_presentation``
+"""
+
+from __future__ import annotations
+
+import json
+import math
+from pathlib import Path
+from typing import Any, Mapping
+
+KIND = "ahbg.presentation.snapshot"
+STANDING = "not-mechanics"
+SAMPLE_PATH = Path(__file__).resolve().parent / "sample_snapshot.json"
+
+_ROOT_FIELDS = {"kind", "standing", "plane_id", "turn", "geometry_source", "tiles", "units", "selected_tile", "feed", "motions"}
+_GEOMETRY_FIELDS = {"repository", "commit", "module", "schema_id", "schema_version", "projection_id", "selection_effect"}
+_TILE_FIELDS = {"id", "label", "source_slot", "x", "y"}
+_UNIT_FIELDS = {"id", "tile", "label"}
+_FEED_FIELDS = {"turn", "text"}
+_MOTION_FIELDS = {"unit", "from", "to"}
+
+
+class PresentationSnapshotError(ValueError):
+ """Fail-closed presentation snapshot error."""
+
+
+def load_snapshot(path: Path | None = None) -> Mapping[str, Any]:
+ target = SAMPLE_PATH if path is None else path
+ try:
+ payload = json.loads(target.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise PresentationSnapshotError(f"cannot load snapshot: {exc}") from exc
+ return validate_snapshot(payload)
+
+
+def _plain_int(value: object) -> bool:
+ return isinstance(value, int) and not isinstance(value, bool)
+
+
+def _finite_number(value: object) -> bool:
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return False
+ try:
+ return math.isfinite(float(value))
+ except (OverflowError, ValueError):
+ return False
+
+
+def _reject_unknown(mapping: Mapping[str, Any], allowed: set[str], surface: str) -> None:
+ unknown = sorted(set(mapping) - allowed)
+ if unknown:
+ raise PresentationSnapshotError(f"{surface} has undeclared fields: {', '.join(unknown)}")
+
+
+def validate_snapshot(payload: Mapping[str, Any]) -> Mapping[str, Any]:
+ if not isinstance(payload, Mapping):
+ raise PresentationSnapshotError("snapshot must be an object")
+ _reject_unknown(payload, _ROOT_FIELDS, "snapshot")
+ if payload.get("kind") != KIND:
+ raise PresentationSnapshotError(f"kind must be {KIND}")
+ if payload.get("standing") != STANDING:
+ raise PresentationSnapshotError(f"standing must be {STANDING}")
+ if not isinstance(payload.get("plane_id"), str) or not payload["plane_id"]:
+ raise PresentationSnapshotError("plane_id must be exact non-empty text")
+ if not _plain_int(payload.get("turn")) or payload["turn"] < 0:
+ raise PresentationSnapshotError("turn must be a non-negative int")
+
+ geometry_source = payload.get("geometry_source")
+ if not isinstance(geometry_source, Mapping):
+ raise PresentationSnapshotError("geometry_source must be an object")
+ _reject_unknown(geometry_source, _GEOMETRY_FIELDS, "geometry_source")
+ for field in _GEOMETRY_FIELDS:
+ value = geometry_source.get(field)
+ if not isinstance(value, str) or not value:
+ raise PresentationSnapshotError(f"geometry_source.{field} must be exact non-empty text")
+ commit = geometry_source["commit"]
+ if len(commit) != 40 or any(ch not in "0123456789abcdef" for ch in commit):
+ raise PresentationSnapshotError("geometry_source.commit must be a lowercase 40-hex commit")
+
+ tiles = payload.get("tiles")
+ if not isinstance(tiles, list) or not tiles:
+ raise PresentationSnapshotError("tiles must be a non-empty list")
+ ids: set[str] = set()
+ source_slots: set[str] = set()
+ positions: set[tuple[float, float]] = set()
+ for tile in tiles:
+ if not isinstance(tile, Mapping):
+ raise PresentationSnapshotError("each tile must be an object")
+ _reject_unknown(tile, _TILE_FIELDS, "tile")
+ tile_id = tile.get("id")
+ source_slot = tile.get("source_slot")
+ x, y = tile.get("x"), tile.get("y")
+ if not isinstance(tile_id, str) or not tile_id:
+ raise PresentationSnapshotError("tile id must be exact non-empty text")
+ if tile_id in ids:
+ raise PresentationSnapshotError(f"tile id repeats: {tile_id}")
+ if not isinstance(source_slot, str) or not source_slot:
+ raise PresentationSnapshotError(f"tile {tile_id} source_slot must be exact non-empty text")
+ if source_slot in source_slots:
+ raise PresentationSnapshotError(f"UCNS source slot repeats: {source_slot}")
+ if not _finite_number(x) or not _finite_number(y):
+ raise PresentationSnapshotError(f"tile {tile_id} x,y must be finite numeric and nonboolean")
+ position = (float(x), float(y))
+ if position in positions:
+ raise PresentationSnapshotError(f"tile source position repeats: {position}")
+ if "label" in tile and (not isinstance(tile["label"], str) or not tile["label"]):
+ raise PresentationSnapshotError(f"tile {tile_id} label must be exact non-empty text when present")
+ ids.add(tile_id)
+ source_slots.add(source_slot)
+ positions.add(position)
+
+ units = payload.get("units")
+ if not isinstance(units, list):
+ raise PresentationSnapshotError("units must be a list")
+ unit_ids: set[str] = set()
+ unit_tiles: dict[str, str] = {}
+ for unit in units:
+ if not isinstance(unit, Mapping):
+ raise PresentationSnapshotError("each unit must be an object")
+ _reject_unknown(unit, _UNIT_FIELDS, "unit")
+ unit_id = unit.get("id")
+ tile_id = unit.get("tile")
+ if not isinstance(unit_id, str) or not unit_id:
+ raise PresentationSnapshotError("unit id must be exact non-empty text")
+ if unit_id in unit_ids:
+ raise PresentationSnapshotError(f"unit id repeats: {unit_id}")
+ if not isinstance(tile_id, str) or not tile_id:
+ raise PresentationSnapshotError(f"unit {unit_id} tile must be exact non-empty text")
+ if tile_id not in ids:
+ raise PresentationSnapshotError(f"unit {unit_id} tile {tile_id!r} is not a presented tile")
+ if "label" in unit and (not isinstance(unit["label"], str) or not unit["label"]):
+ raise PresentationSnapshotError(f"unit {unit_id} label must be exact non-empty text when present")
+ unit_ids.add(unit_id)
+ unit_tiles[unit_id] = tile_id
+
+ selected = payload.get("selected_tile")
+ if selected is not None:
+ if not isinstance(selected, str) or not selected:
+ raise PresentationSnapshotError("selected_tile must be exact non-empty text")
+ if selected not in ids:
+ raise PresentationSnapshotError("selected_tile must name a presented tile")
+
+ feed = payload.get("feed")
+ if not isinstance(feed, list):
+ raise PresentationSnapshotError("feed must be a list")
+ for item in feed:
+ if not isinstance(item, Mapping):
+ raise PresentationSnapshotError("each feed item must be an object")
+ _reject_unknown(item, _FEED_FIELDS, "feed item")
+ if not isinstance(item.get("text"), str) or not item["text"]:
+ raise PresentationSnapshotError("each feed item must have exact non-empty text")
+ if "turn" in item and (not _plain_int(item["turn"]) or item["turn"] < 0):
+ raise PresentationSnapshotError("feed turn must be a non-negative int when present")
+
+ if "motions" not in payload:
+ return payload
+ motions = payload["motions"]
+ if not isinstance(motions, list):
+ raise PresentationSnapshotError("motions must be a list when present")
+ seen_motion_units: set[str] = set()
+ for motion in motions:
+ if not isinstance(motion, Mapping):
+ raise PresentationSnapshotError("each motion must be an object")
+ _reject_unknown(motion, _MOTION_FIELDS, "motion")
+ unit_id = motion.get("unit")
+ from_tile = motion.get("from")
+ to_tile = motion.get("to")
+ for name, value in (("unit", unit_id), ("from", from_tile), ("to", to_tile)):
+ if not isinstance(value, str) or not value:
+ raise PresentationSnapshotError(f"motion {name} must be exact non-empty text")
+ if unit_id not in unit_ids:
+ raise PresentationSnapshotError(f"motion unit {unit_id!r} is not a presented unit")
+ if unit_id in seen_motion_units:
+ raise PresentationSnapshotError(f"motion repeats unit {unit_id}")
+ if from_tile not in ids:
+ raise PresentationSnapshotError(f"motion from {from_tile!r} is not a presented tile")
+ if to_tile not in ids:
+ raise PresentationSnapshotError(f"motion to {to_tile!r} is not a presented tile")
+ if from_tile == to_tile:
+ raise PresentationSnapshotError(f"motion for {unit_id} must change tiles")
+ if unit_tiles[unit_id] != to_tile:
+ raise PresentationSnapshotError(f"motion destination {to_tile!r} does not match unit {unit_id} tile {unit_tiles[unit_id]!r}")
+ seen_motion_units.add(unit_id)
+ return payload
diff --git a/ahbg/presentation/tests/test_presentation.py b/ahbg/presentation/tests/test_presentation.py
new file mode 100644
index 0000000..e6382aa
--- /dev/null
+++ b/ahbg/presentation/tests/test_presentation.py
@@ -0,0 +1,217 @@
+"""Regression tests for the AHBG presentation-only boundary.
+
+Usage guidance:
+ Focused: ``python -m unittest ahbg.presentation.tests.test_presentation``
+ Repo discovery: ``python -m unittest discover -s ahbg/presentation/tests -p 'test*.py'``
+"""
+
+from __future__ import annotations
+
+import copy
+import json
+import math
+import sys
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from ahbg.presentation.geometry import center_distance, source_to_display
+from ahbg.presentation.project import snapshot_from_observation
+from ahbg.presentation.snapshot import PresentationSnapshotError, load_snapshot, validate_snapshot
+
+
+UCNS_COMMIT = "1975fe70cf4e0826a8020c2da3047569e277af64"
+GEOMETRY_SOURCE = {
+ "repository": "The-Interdependency/ucns",
+ "commit": UCNS_COMMIT,
+ "module": "src/ucns/mobius_seed.py",
+ "schema_id": "ucns.mobius-seed-of-life",
+ "schema_version": "0.1.0",
+ "projection_id": "seed-of-life-seven-equal-circles",
+ "selection_effect": "none",
+}
+
+
+class PresentationTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.sample = dict(load_snapshot())
+
+ def test_sample_is_valid_and_pins_ucns_source(self) -> None:
+ self.assertEqual(self.sample["standing"], "not-mechanics")
+ self.assertEqual(self.sample["geometry_source"], GEOMETRY_SOURCE)
+ self.assertEqual(self.sample["motions"][0]["to"], self.sample["units"][0]["tile"])
+ self.assertEqual({tile["source_slot"] for tile in self.sample["tiles"]}, {"CENTER", *(f"RING_{i}" for i in range(6))})
+
+ def test_source_centers_are_scaled_not_reconstructed(self) -> None:
+ radius = 64.0
+ center = source_to_display(0.0, 0.0, radius)
+ ring0 = source_to_display(1.0, 0.0, radius)
+ self.assertAlmostEqual(center_distance(center, ring0), radius)
+ ring1 = source_to_display(0.5, math.sqrt(3) / 2, radius)
+ self.assertAlmostEqual(center_distance(center, ring1), radius)
+
+ def test_duplicate_tile_id_source_slot_and_position_fail_closed(self) -> None:
+ for mutation in ("id", "slot", "position"):
+ payload = copy.deepcopy(self.sample)
+ if mutation == "id":
+ payload["tiles"][1]["id"] = payload["tiles"][0]["id"]
+ elif mutation == "slot":
+ payload["tiles"][1]["source_slot"] = payload["tiles"][0]["source_slot"]
+ else:
+ payload["tiles"][1]["x"] = payload["tiles"][0]["x"]
+ payload["tiles"][1]["y"] = payload["tiles"][0]["y"]
+ with self.assertRaises(PresentationSnapshotError):
+ validate_snapshot(payload)
+
+ def test_unknown_fields_and_bad_unit_fields_fail_closed(self) -> None:
+ payload = copy.deepcopy(self.sample)
+ payload["private_prompt"] = "must not cross boundary"
+ with self.assertRaisesRegex(PresentationSnapshotError, "undeclared fields"):
+ validate_snapshot(payload)
+
+ payload = copy.deepcopy(self.sample)
+ payload["feed"][0]["dm_state"] = "secret"
+ with self.assertRaisesRegex(PresentationSnapshotError, "undeclared fields"):
+ validate_snapshot(payload)
+
+ payload = copy.deepcopy(self.sample)
+ payload["units"][0]["tile"] = []
+ with self.assertRaises(PresentationSnapshotError):
+ validate_snapshot(payload)
+
+ payload = copy.deepcopy(self.sample)
+ payload["units"][0]["label"] = {"bad": True}
+ with self.assertRaises(PresentationSnapshotError):
+ validate_snapshot(payload)
+
+ def test_motion_destination_must_equal_presented_unit_tile(self) -> None:
+ payload = copy.deepcopy(self.sample)
+ payload["motions"][0]["to"] = "RING_1"
+ with self.assertRaisesRegex(PresentationSnapshotError, "does not match"):
+ validate_snapshot(payload)
+
+ payload = copy.deepcopy(self.sample)
+ payload["motions"] = False
+ with self.assertRaisesRegex(PresentationSnapshotError, "must be a list"):
+ validate_snapshot(payload)
+
+ def test_projector_drops_internal_observation_and_feed_fields(self) -> None:
+ observation = {
+ "turn": 2,
+ "geometry_source": {**GEOMETRY_SOURCE, "internal_note": "drop me"},
+ "tiles": [
+ {"tile_id": "CENTER", "ucns_slot": "CENTER", "x": 0.0, "y": 0.0, "private": "drop"},
+ {"tile_id": "RING_0", "ucns_slot": "RING_0", "x": 1.0, "y": 0.0},
+ ],
+ "units": [{"unit_id": "A0", "tile_id": "RING_0", "label": "A0", "private": "drop"}],
+ "seed": "must-not-leak",
+ }
+ move = {
+ "kind": "move",
+ "data": {"unit_id": "A0", "from_tile_id": "CENTER", "to_tile_id": "RING_0", "dm": "drop"},
+ }
+ projected = snapshot_from_observation(
+ observation,
+ plane_id="plane-0",
+ move_events=[move],
+ feed=[{"turn": 2, "text": "public", "private_prompt": "must-not-leak"}],
+ )
+ self.assertNotIn("seed", projected)
+ self.assertNotIn("internal_note", projected["geometry_source"])
+ self.assertEqual(projected["feed"], [{"turn": 2, "text": "public"}])
+ self.assertEqual(projected["motions"], [{"unit": "A0", "from": "CENTER", "to": "RING_0"}])
+
+ def test_browser_source_contains_accessibility_and_validation_guards(self) -> None:
+ source = (ROOT / "ahbg" / "presentation" / "board.js").read_text(encoding="utf-8")
+ html = (ROOT / "ahbg" / "presentation" / "board.html").read_text(encoding="utf-8")
+ for phrase in (
+ 'hit.setAttribute("tabindex", "0")',
+ 'event.key === "Enter" || event.key === " "',
+ "UCNS source slot repeats",
+ "motions must be a list when present",
+ "motion destination for",
+ 'selectionRing.setAttribute("class", "selection-ring")',
+ "minimumChord",
+ "sourceToPixel",
+ ):
+ self.assertIn(phrase, source)
+ self.assertIn('role="group"', html)
+ self.assertNotIn('role="img"', html)
+ self.assertNotIn("axialToPixel", source)
+
+ @staticmethod
+ def _js_offset(index: int, group_size: int) -> tuple[float, float]:
+ """Mirror of board.js offsetFor math, kept here as the frozen contract."""
+ if group_size == 1:
+ return (0.0, 0.0)
+ unit_radius = 11.0
+ minimum_chord = unit_radius * 2 + 4
+ spread = max(unit_radius * 1.35, minimum_chord / (2 * math.sin(math.pi / group_size)))
+ angle = (math.pi * 2 * index) / group_size - math.pi / 2
+ return (math.cos(angle) * spread, math.sin(angle) * spread)
+
+ def test_initial_and_final_multi_unit_offsets_use_their_own_occupancy(self) -> None:
+ # Two units start stacked on CENTER and move to two different tiles:
+ # initial offsets must spread them apart on CENTER, final offsets zero.
+ initial = [self._js_offset(i, 2) for i in range(2)]
+ final = [self._js_offset(i, 1) for i in range(2)]
+ self.assertNotEqual(initial[0], initial[1])
+ self.assertTrue(any(value != 0.0 for pair in initial for value in pair))
+ self.assertEqual(final, [(0.0, 0.0), (0.0, 0.0)])
+
+ source = (ROOT / "ahbg" / "presentation" / "board.js").read_text(encoding="utf-8")
+ self.assertIn("groupUnitsBy", source)
+ self.assertIn("initialGroups", source)
+ self.assertIn("offsetFor(unit, initialGroups, initialTile)", source)
+ self.assertIn("offsetFor(unit, finalGroups, unit.tile)", source)
+
+ def test_viewbox_bounds_cover_displaced_unit_markers(self) -> None:
+ # Seven units stacked on one tile produce the largest spread. The
+ # viewBox contract must derive from rendered marker extents at both the
+ # motion origin and the presented destination, not from a fixed
+ # seed-circle margin.
+ radius = 64.0
+ marker_extent = 11.0 + 8
+ offset = self._js_offset(0, 7)
+ center = (0.0, 0.0)
+ displaced = (center[0] + offset[0], center[1] + offset[1])
+ self.assertGreater(max(abs(displaced[0]), abs(displaced[1])), 0.0)
+
+ min_x = min(center[0] - radius, displaced[0] - marker_extent)
+ max_x = max(center[0] + radius, displaced[0] + marker_extent)
+ min_y = min(center[1] - radius, displaced[1] - marker_extent)
+ max_y = max(center[1] + radius, displaced[1] + marker_extent)
+ self.assertLessEqual(min_x, displaced[0] - marker_extent)
+ self.assertGreaterEqual(max_x, displaced[0] + marker_extent)
+ self.assertLessEqual(min_y, displaced[1] - marker_extent)
+ self.assertGreaterEqual(max_y, displaced[1] + marker_extent)
+
+ source = (ROOT / "ahbg" / "presentation" / "board.js").read_text(encoding="utf-8")
+ self.assertIn("markerExtent", source)
+ self.assertIn("origin.x - markerExtent", source)
+ self.assertIn("dest.x - markerExtent", source)
+ self.assertNotIn("RADIUS * 1.2", source)
+
+ def test_behavior_modules_declare_module_build(self) -> None:
+ for relative in (
+ "ahbg/presentation/__init__.py",
+ "ahbg/presentation/snapshot.py",
+ "ahbg/presentation/project.py",
+ "ahbg/presentation/geometry.py",
+ "ahbg/presentation/board.js",
+ ):
+ text = (ROOT / relative).read_text(encoding="utf-8")
+ self.assertIn("=== MODULE_BUILD ===", text, relative)
+ self.assertIn("=== END MODULE_BUILD ===", text, relative)
+
+ def test_sample_json_is_plain_data(self) -> None:
+ path = ROOT / "ahbg" / "presentation" / "sample_snapshot.json"
+ parsed = json.loads(path.read_text(encoding="utf-8"))
+ self.assertEqual(parsed, self.sample)
+
+
+if __name__ == "__main__":
+ unittest.main()