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()