From 078f3ac897207aaa05973bfd08de2fdc30582609 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:18:50 -0700 Subject: [PATCH 01/21] feat(ahbg): add fail-closed presentation snapshot contract --- ahbg/presentation/snapshot.py | 153 ++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 ahbg/presentation/snapshot.py diff --git a/ahbg/presentation/snapshot.py b/ahbg/presentation/snapshot.py new file mode 100644 index 0000000..7fb4b6c --- /dev/null +++ b/ahbg/presentation/snapshot.py @@ -0,0 +1,153 @@ +"""AHBG presentation snapshot — visual fields only. + +This is not plane state and not a mechanics contract. Optional motions are +traces of already-resolved unit relocation between presented tiles. +""" + +from __future__ import annotations + +import json +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" + + +class PresentationSnapshotError(ValueError): + """Fail-closed presentation snapshot error.""" + + +def load_snapshot(path: Path | None = None) -> Mapping[str, Any]: + """Load and validate a snapshot JSON document.""" + + 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 validate_snapshot(payload: Mapping[str, Any]) -> Mapping[str, Any]: + """Validate the presentation-only snapshot envelope and visual relations.""" + + if not isinstance(payload, Mapping): + raise PresentationSnapshotError("snapshot must be an object") + 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") + + 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() + coords: set[tuple[int, int]] = set() + for tile in tiles: + if not isinstance(tile, Mapping): + raise PresentationSnapshotError("each tile must be an object") + tile_id = tile.get("id") + q, r = tile.get("q"), tile.get("r") + 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 _plain_int(q) or not _plain_int(r): + raise PresentationSnapshotError(f"tile {tile_id} q,r must be ints") + if (q, r) in coords: + raise PresentationSnapshotError(f"tile axial coordinate repeats: {(q, r)}") + label = tile.get("label") + if label is not None and (not isinstance(label, str) or not label): + raise PresentationSnapshotError( + f"tile {tile_id} label must be exact non-empty text when present" + ) + ids.add(tile_id) + coords.add((q, r)) + + 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") + unit_id = unit.get("id") + tile_id = unit.get("tile") + label = unit.get("label") + 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 is not None and (not isinstance(label, str) or not 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) or not isinstance(item.get("text"), str) or not item["text"]: + raise PresentationSnapshotError("each feed item must have exact non-empty text") + item_turn = item.get("turn") + if item_turn is not None and (not _plain_int(item_turn) or item_turn < 0): + raise PresentationSnapshotError("feed turn must be a non-negative int when present") + + motions = payload.get("motions") + if motions is None: + return payload + 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") + 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 From aa9409157a937efc8e8a944c22e4ae5376d50cdc Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:19:40 -0700 Subject: [PATCH 02/21] feat(ahbg): add accessible fail-closed presentation board --- ahbg/presentation/board.js | 347 +++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 ahbg/presentation/board.js diff --git a/ahbg/presentation/board.js b/ahbg/presentation/board.js new file mode 100644 index 0000000..c30e449 --- /dev/null +++ b/ahbg/presentation/board.js @@ -0,0 +1,347 @@ +const EMBEDDED_SNAPSHOT = { + kind: "ahbg.presentation.snapshot", + standing: "not-mechanics", + plane_id: "plane-0", + turn: 1, + tiles: [ + { id: "c", q: 0, r: 0, label: "origin" }, + { id: "ne", q: 1, r: -1, label: "ne" }, + { id: "e", q: 1, r: 0, label: "e" }, + { id: "se", q: 0, r: 1, label: "se" }, + { id: "sw", q: -1, r: 1, label: "sw" }, + { id: "w", q: -1, r: 0, label: "w" }, + { id: "nw", q: 0, r: -1, label: "nw" }, + ], + units: [{ id: "A0", tile: "ne", label: "A0" }], + selected_tile: "ne", + motions: [{ unit: "A0", from: "c", to: "ne" }], + feed: [ + { turn: 0, text: "plane loaded; A0 at origin" }, + { turn: 1, text: "A0 trace origin to ne" }, + ], +}; + +const RADIUS = 64; +const TILE_POINT = 6; +const UNIT_RADIUS = 11; + +function axialToPixel(q, r) { + return { + x: RADIUS * (q + r / 2), + y: RADIUS * (Math.sqrt(3) / 2) * r, + }; +} + +function exactText(value) { + return typeof value === "string" && value.length > 0; +} + +function plainInteger(value) { + return Number.isInteger(value); +} + +function validateSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + throw new Error("snapshot must be an object"); + } + 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"); + } + if (!Array.isArray(snapshot.tiles) || snapshot.tiles.length === 0) { + throw new Error("tiles must be a non-empty list"); + } + + const ids = new Set(); + const coords = 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"); + } + 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 (!plainInteger(tile.q) || !plainInteger(tile.r)) { + throw new Error(`tile ${tile.id} q,r must be integers`); + } + const coord = `${tile.q},${tile.r}`; + if (coords.has(coord)) { + throw new Error(`tile coordinate repeats: ${coord}`); + } + 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); + coords.add(coord); + } + + 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"); + } + 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) || !exactText(item.text)) { + throw new Error("each feed item must have exact non-empty text"); + } + } + + const motionUnits = new Set(); + for (const motion of snapshot.motions || []) { + if (!motion || typeof motion !== "object" || Array.isArray(motion)) { + throw new Error("each motion must be an object"); + } + 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 pixels = snapshot.tiles.map((tile) => axialToPixel(tile.q, tile.r)); + const minX = Math.min(...pixels.map((p) => p.x)) - RADIUS * 1.2; + const minY = Math.min(...pixels.map((p) => p.y)) - RADIUS * 1.2; + const maxX = Math.max(...pixels.map((p) => p.x)) + RADIUS * 1.2; + const maxY = Math.max(...pixels.map((p) => p.y)) + RADIUS * 1.2; + svg.setAttribute("viewBox", `${minX} ${minY} ${maxX - minX} ${maxY - minY}`); + + 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; + + function paintInspect() { + const tile = byId[selected]; + const occupants = snapshot.units.filter((unit) => unit.tile === selected); + inspect.textContent = `tile ${tile.label || tile.id} center (${tile.q},${tile.r})${ + 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 tile = byId[selected]; + const { x, y } = axialToPixel(tile.q, tile.r); + selectionRing.setAttribute("cx", x); + selectionRing.setAttribute("cy", y); + } + paintInspect(); + } + + snapshot.tiles.forEach((tile) => { + const { x, y } = axialToPixel(tile.q, tile.r); + 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 = axialToPixel(byId[motion.from].q, byId[motion.from].r); + const to = axialToPixel(byId[motion.to].q, byId[motion.to].r); + 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 } = axialToPixel(tile.q, tile.r); + 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); + }); + + const motionByUnit = Object.fromEntries((snapshot.motions || []).map((motion) => [motion.unit, motion])); + const tileGroups = new Map(); + snapshot.units.forEach((unit) => { + const group = tileGroups.get(unit.tile) || []; + group.push(unit.id); + tileGroups.set(unit.tile, group); + }); + + function offsetFor(unit) { + const group = tileGroups.get(unit.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 spread = UNIT_RADIUS * 1.35; + return { x: Math.cos(angle) * spread, y: Math.sin(angle) * spread }; + } + + snapshot.units.forEach((unit) => { + const tile = byId[unit.tile]; + const offset = offsetFor(unit); + const center = axialToPixel(tile.q, tile.r); + const dest = { x: center.x + offset.x, y: center.y + offset.y }; + const motion = motionByUnit[unit.id]; + const fromCenter = motion ? axialToPixel(byId[motion.from].q, byId[motion.from].r) : center; + const origin = { x: fromCenter.x + offset.x, y: fromCenter.y + offset.y }; + + 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(); From a940e137456141d8b673a6751489d928e67faeee Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:19:56 -0700 Subject: [PATCH 03/21] style(ahbg): keep focus and selection visible --- ahbg/presentation/board.css | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 ahbg/presentation/board.css 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); } +} From 52a8622c2429c82d0989dbbb9d41d492a7cfef2f Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:20:04 -0700 Subject: [PATCH 04/21] docs(ahbg): add presentation board shell --- ahbg/presentation/board.html | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 ahbg/presentation/board.html diff --git a/ahbg/presentation/board.html b/ahbg/presentation/board.html new file mode 100644 index 0000000..e49858a --- /dev/null +++ b/ahbg/presentation/board.html @@ -0,0 +1,24 @@ + + + + + + AHBG presentation board + + + +
+
+

AHBG plane

+

Presentation only. Not mechanics. Each tile is a centerpoint; circles are Seed of Life geometry. Dashed traces show already-resolved motion. Click a center, or focus it and press Enter/Space, to inspect.

+ +
+ +
+ + + From 0e55b57af7c7c2416e00e6528de17680049316b1 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:20:16 -0700 Subject: [PATCH 05/21] feat(ahbg): add presentation-only Seed geometry helpers --- ahbg/presentation/geometry.py | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 ahbg/presentation/geometry.py diff --git a/ahbg/presentation/geometry.py b/ahbg/presentation/geometry.py new file mode 100644 index 0000000..1a7bf2d --- /dev/null +++ b/ahbg/presentation/geometry.py @@ -0,0 +1,43 @@ +"""Seed of Life presentation geometry. + +The tile is the centerpoint. Each circle has radius equal to the distance +between adjacent centers. This module renders geometry; it does not define game +movement or adjacency authority. +""" + +from __future__ import annotations + +import math +from typing import Sequence + + +def axial_to_xy(q: int, r: int, radius: float) -> tuple[float, float]: + """Map already-supplied axial coordinates into presentation pixels.""" + + if radius <= 0: + raise ValueError("radius must be positive") + return (radius * (q + r / 2), radius * (math.sqrt(3) / 2) * r) + + +def center_distance(left: tuple[float, float], right: tuple[float, float]) -> float: + return math.hypot(left[0] - right[0], left[1] - right[1]) + + +def visual_one_radius_pairs( + tiles: Sequence[tuple[int, int]], radius: float +) -> tuple[tuple[tuple[int, int], tuple[int, int]], ...]: + """Return center pairs one display radius apart; presentation use only.""" + + points = {item: axial_to_xy(item[0], item[1], radius) for item in tiles} + pairs = [] + items = list(tiles) + for index, left in enumerate(items): + for right in items[index + 1 :]: + if math.isclose( + center_distance(points[left], points[right]), + radius, + rel_tol=1e-9, + abs_tol=1e-9, + ): + pairs.append((left, right)) + return tuple(pairs) From de79e1ca9684d37449c722d159261a1b4f47b5b7 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:20:29 -0700 Subject: [PATCH 06/21] test(ahbg): add presentation sample snapshot --- ahbg/presentation/sample_snapshot.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 ahbg/presentation/sample_snapshot.json diff --git a/ahbg/presentation/sample_snapshot.json b/ahbg/presentation/sample_snapshot.json new file mode 100644 index 0000000..7a6c65e --- /dev/null +++ b/ahbg/presentation/sample_snapshot.json @@ -0,0 +1,26 @@ +{ + "kind": "ahbg.presentation.snapshot", + "standing": "not-mechanics", + "plane_id": "plane-0", + "turn": 1, + "tiles": [ + {"id": "c", "q": 0, "r": 0, "label": "origin"}, + {"id": "ne", "q": 1, "r": -1, "label": "ne"}, + {"id": "e", "q": 1, "r": 0, "label": "e"}, + {"id": "se", "q": 0, "r": 1, "label": "se"}, + {"id": "sw", "q": -1, "r": 1, "label": "sw"}, + {"id": "w", "q": -1, "r": 0, "label": "w"}, + {"id": "nw", "q": 0, "r": -1, "label": "nw"} + ], + "units": [ + {"id": "A0", "tile": "ne", "label": "A0"} + ], + "selected_tile": "ne", + "motions": [ + {"unit": "A0", "from": "c", "to": "ne"} + ], + "feed": [ + {"turn": 0, "text": "plane loaded; A0 at origin"}, + {"turn": 1, "text": "A0 trace origin to ne"} + ] +} From 3d2fdf4bf101a78250bd93db84029e3733035b87 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:20:48 -0700 Subject: [PATCH 07/21] feat(ahbg): add observation-to-presentation projector --- ahbg/presentation/project.py | 95 ++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 ahbg/presentation/project.py diff --git a/ahbg/presentation/project.py b/ahbg/presentation/project.py new file mode 100644 index 0000000..1be9d25 --- /dev/null +++ b/ahbg/presentation/project.py @@ -0,0 +1,95 @@ +"""Project a sanitized observation into an AHBG presentation snapshot. + +This is graphics. It does not decide adjacency, legality, or turn resolution. +Unknown observation fields are ignored; only the declared visual surface is +copied. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +from .snapshot import KIND, STANDING, PresentationSnapshotError, validate_snapshot + + +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 public tile/unit 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_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, + "q": tile.get("q"), + "r": tile.get("r"), + } + 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"), + } + ) + + 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"), + "tiles": tiles, + "units": units, + "selected_tile": selected_tile, + "feed": [dict(item) for item in feed], + } + if motions: + snapshot["motions"] = motions + return dict(validate_snapshot(snapshot)) From 7ab3057d94bdae4ad2c42a6cadb85ad9a58d84d6 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:21:12 -0700 Subject: [PATCH 08/21] docs(ahbg): document presentation-only boundary and usage --- ahbg/presentation/README.md | 73 +++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 ahbg/presentation/README.md diff --git a/ahbg/presentation/README.md b/ahbg/presentation/README.md new file mode 100644 index 0000000..825ed90 --- /dev/null +++ b/ahbg/presentation/README.md @@ -0,0 +1,73 @@ +# AHBG presentation + +Grok-owned graphics surface. It renders validated presentation snapshots and +already-resolved motion traces. It does **not** define game mechanics. + +## Boundary + +- Included: Seed-of-Life circle rendering, tile centerpoints, unit markers, + selection/inspection, feed text, and visual traces of already-resolved moves. +- Excluded: legal movement, adjacency authority, turns, War resolution, + construction, permissions, RNG, DM state, or agent policy. +- Snapshot standing is `ahbg.presentation.snapshot` / `not-mechanics`. +- `project.py` accepts already-sanitized observation data and resolved move + events. It never decides whether those moves were legal. +- `geometry.py` maps supplied coordinates into display positions only; visual + one-radius relations are not exported as game-law adjacency. + +## Usage guidance + +Validate the sample and projector: + +```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: + +```python +from ahbg.presentation.project import snapshot_from_observation + +observation = { + "turn": 1, + "tiles": [ + {"tile_id": "c", "q": 0, "r": 0}, + {"tile_id": "ne", "q": 1, "r": -1}, + ], + "units": [{"unit_id": "A0", "tile_id": "ne", "label": "A0"}], +} +move = { + "kind": "move", + "data": {"unit_id": "A0", "from_tile_id": "c", "to_tile_id": "ne"}, +} +snapshot = snapshot_from_observation( + observation, + plane_id="plane-0", + move_events=[move], +) +``` + +The validator fails closed on malformed/duplicate tile IDs or coordinates, +invalid unit references/labels, duplicate unit IDs, and motion destinations that +do not match the unit's presented final tile. + +The browser exposes tile inspection to pointer and keyboard input, separates +multiple unit markers sharing one tile, and renders the selection ring above the +unit layer so selection remains visible on occupied tiles. + +## hmmm + +- whether later Flower-of-Life rings belong on this presentation surface; +- the exact live engine-to-observation adapter 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. From e1bccc225b412b6052bac486f77a2707e9d84743 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:21:20 -0700 Subject: [PATCH 09/21] feat(ahbg): expose presentation snapshot helpers --- ahbg/presentation/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 ahbg/presentation/__init__.py diff --git a/ahbg/presentation/__init__.py b/ahbg/presentation/__init__.py new file mode 100644 index 0000000..c2e1106 --- /dev/null +++ b/ahbg/presentation/__init__.py @@ -0,0 +1,11 @@ +"""Presentation-only AHBG graphics boundary.""" + +from .project import snapshot_from_observation +from .snapshot import PresentationSnapshotError, load_snapshot, validate_snapshot + +__all__ = [ + "PresentationSnapshotError", + "load_snapshot", + "snapshot_from_observation", + "validate_snapshot", +] From 714a82d2dc10a8a6a872263c5d3881b42059008f Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:21:41 -0700 Subject: [PATCH 10/21] test(ahbg): cover presentation validation and projection --- ahbg/presentation/tests/test_presentation.py | 101 +++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 ahbg/presentation/tests/test_presentation.py diff --git a/ahbg/presentation/tests/test_presentation.py b/ahbg/presentation/tests/test_presentation.py new file mode 100644 index 0000000..7e505b2 --- /dev/null +++ b/ahbg/presentation/tests/test_presentation.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import copy +import json +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 axial_to_xy, center_distance +from ahbg.presentation.project import snapshot_from_observation +from ahbg.presentation.snapshot import PresentationSnapshotError, load_snapshot, validate_snapshot + + +class PresentationTests(unittest.TestCase): + def setUp(self) -> None: + self.sample = dict(load_snapshot()) + + def test_sample_is_valid(self) -> None: + self.assertEqual(self.sample["standing"], "not-mechanics") + self.assertEqual(self.sample["motions"][0]["to"], self.sample["units"][0]["tile"]) + + def test_adjacent_visual_centers_are_one_radius_apart(self) -> None: + radius = 64.0 + self.assertAlmostEqual( + center_distance(axial_to_xy(0, 0, radius), axial_to_xy(1, 0, radius)), + radius, + ) + + def test_duplicate_tile_id_and_coordinate_fail_closed(self) -> None: + for mutation in ("id", "coord"): + payload = copy.deepcopy(self.sample) + if mutation == "id": + payload["tiles"][1]["id"] = payload["tiles"][0]["id"] + else: + payload["tiles"][1]["q"] = payload["tiles"][0]["q"] + payload["tiles"][1]["r"] = payload["tiles"][0]["r"] + with self.assertRaises(PresentationSnapshotError): + validate_snapshot(payload) + + def test_bad_unit_tile_and_label_fail_closed(self) -> None: + 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"] = "e" + with self.assertRaisesRegex(PresentationSnapshotError, "does not match"): + validate_snapshot(payload) + + def test_projector_uses_package_relative_snapshot_and_validates(self) -> None: + observation = { + "turn": 2, + "tiles": [ + {"tile_id": "c", "q": 0, "r": 0}, + {"tile_id": "ne", "q": 1, "r": -1}, + ], + "units": [{"unit_id": "A0", "tile_id": "ne", "label": "A0"}], + "seed": "must-not-leak", + } + move = { + "kind": "move", + "data": {"unit_id": "A0", "from_tile_id": "c", "to_tile_id": "ne"}, + } + projected = snapshot_from_observation( + observation, plane_id="plane-0", move_events=[move] + ) + self.assertNotIn("seed", projected) + self.assertEqual(projected["motions"], [{"unit": "A0", "from": "c", "to": "ne"}]) + + def test_browser_source_contains_accessibility_and_layer_guards(self) -> None: + source = (ROOT / "ahbg" / "presentation" / "board.js").read_text(encoding="utf-8") + for phrase in ( + 'hit.setAttribute("tabindex", "0")', + 'event.key === "Enter" || event.key === " "', + "tile id repeats", + "q,r must be integers", + "motion destination for", + 'selectionRing.setAttribute("class", "selection-ring")', + "tileGroups", + ): + self.assertIn(phrase, source) + + 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() From b2f1da218bcc8c50ce5b20f52cfa0292f2b91b8e Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:21:51 -0700 Subject: [PATCH 11/21] ci(ahbg): gate presentation-only surface --- .github/workflows/ahbg-presentation.yml | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/ahbg-presentation.yml 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 From ad56d98105460cf4343e3bdcab867606535241fa Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:42:15 -0700 Subject: [PATCH 12/21] fix(ahbg): carry UCNS display positions and fail closed on unknown fields --- ahbg/presentation/snapshot.py | 102 +++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 21 deletions(-) diff --git a/ahbg/presentation/snapshot.py b/ahbg/presentation/snapshot.py index 7fb4b6c..1cd5f2d 100644 --- a/ahbg/presentation/snapshot.py +++ b/ahbg/presentation/snapshot.py @@ -1,7 +1,33 @@ +# === 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, _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. Optional motions are +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 @@ -15,6 +41,13 @@ 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.""" @@ -35,11 +68,18 @@ def _plain_int(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) +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]: """Validate the presentation-only snapshot envelope and visual relations.""" 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: @@ -49,31 +89,52 @@ def validate_snapshot(payload: Mapping[str, Any]) -> Mapping[str, Any]: 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() - coords: set[tuple[int, int]] = 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") - q, r = tile.get("q"), tile.get("r") + 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 _plain_int(q) or not _plain_int(r): - raise PresentationSnapshotError(f"tile {tile_id} q,r must be ints") - if (q, r) in coords: - raise PresentationSnapshotError(f"tile axial coordinate repeats: {(q, r)}") + 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 isinstance(x, bool) or not isinstance(x, (int, float)): + raise PresentationSnapshotError(f"tile {tile_id} x must be numeric and nonboolean") + if isinstance(y, bool) or not isinstance(y, (int, float)): + raise PresentationSnapshotError(f"tile {tile_id} y must be numeric and nonboolean") + position = (float(x), float(y)) + if position in positions: + raise PresentationSnapshotError(f"tile source position repeats: {position}") label = tile.get("label") if label is not None and (not isinstance(label, str) or not label): - raise PresentationSnapshotError( - f"tile {tile_id} label must be exact non-empty text when present" - ) + raise PresentationSnapshotError(f"tile {tile_id} label must be exact non-empty text when present") ids.add(tile_id) - coords.add((q, r)) + source_slots.add(source_slot) + positions.add(position) units = payload.get("units") if not isinstance(units, list): @@ -83,6 +144,7 @@ def validate_snapshot(payload: Mapping[str, Any]) -> Mapping[str, Any]: 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") label = unit.get("label") @@ -93,13 +155,9 @@ def validate_snapshot(payload: Mapping[str, Any]) -> Mapping[str, Any]: 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" - ) + raise PresentationSnapshotError(f"unit {unit_id} tile {tile_id!r} is not a presented tile") if label is not None and (not isinstance(label, str) or not label): - raise PresentationSnapshotError( - f"unit {unit_id} label must be exact non-empty text when present" - ) + 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 @@ -114,7 +172,10 @@ def validate_snapshot(payload: Mapping[str, Any]) -> Mapping[str, Any]: if not isinstance(feed, list): raise PresentationSnapshotError("feed must be a list") for item in feed: - if not isinstance(item, Mapping) or not isinstance(item.get("text"), str) or not item["text"]: + 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") item_turn = item.get("turn") if item_turn is not None and (not _plain_int(item_turn) or item_turn < 0): @@ -129,6 +190,7 @@ def validate_snapshot(payload: Mapping[str, Any]) -> Mapping[str, Any]: 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") @@ -146,8 +208,6 @@ def validate_snapshot(payload: Mapping[str, Any]) -> Mapping[str, Any]: 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}" - ) + 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 From cadb4fa1d8ee8fab01d1e9a23b5f632c511271fe Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:42:55 -0700 Subject: [PATCH 13/21] fix(ahbg): project only declared visual fields and supplied UCNS positions --- ahbg/presentation/project.py | 68 ++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/ahbg/presentation/project.py b/ahbg/presentation/project.py index 1be9d25..aae4583 100644 --- a/ahbg/presentation/project.py +++ b/ahbg/presentation/project.py @@ -1,8 +1,34 @@ +# === 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, or turn resolution. -Unknown observation fields are ignored; only the declared visual surface is -copied. +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 @@ -12,6 +38,17 @@ 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], *, @@ -20,12 +57,18 @@ def snapshot_from_observation( feed: Sequence[Mapping[str, Any]] = (), move_events: Sequence[Mapping[str, Any]] = (), ) -> dict[str, Any]: - """Map public tile/unit data and already-resolved moves to display data.""" + """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: @@ -40,8 +83,9 @@ def snapshot_from_observation( tile_id = tile.get("tile_id", tile.get("id")) presented: dict[str, Any] = { "id": tile_id, - "q": tile.get("q"), - "r": tile.get("r"), + "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") @@ -77,6 +121,15 @@ def snapshot_from_observation( } ) + 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"] @@ -85,10 +138,11 @@ def snapshot_from_observation( "standing": STANDING, "plane_id": plane_id, "turn": observation.get("turn"), + "geometry_source": geometry_source, "tiles": tiles, "units": units, "selected_tile": selected_tile, - "feed": [dict(item) for item in feed], + "feed": presented_feed, } if motions: snapshot["motions"] = motions From 90bbb2e49bbfd945077114dad2fa2fae9579c89a Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:43:29 -0700 Subject: [PATCH 14/21] fix(ahbg): stop reconstructing UCNS centers in presentation geometry --- ahbg/presentation/geometry.py | 77 +++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/ahbg/presentation/geometry.py b/ahbg/presentation/geometry.py index 1a7bf2d..e66d333 100644 --- a/ahbg/presentation/geometry.py +++ b/ahbg/presentation/geometry.py @@ -1,43 +1,58 @@ -"""Seed of Life presentation geometry. - -The tile is the centerpoint. Each circle has radius equal to the distance -between adjacent centers. This module renders geometry; it does not define game -movement or adjacency authority. +# === 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 -from typing import Sequence -def axial_to_xy(q: int, r: int, radius: float) -> tuple[float, float]: - """Map already-supplied axial coordinates into presentation pixels.""" +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 radius <= 0: - raise ValueError("radius must be positive") - return (radius * (q + r / 2), radius * (math.sqrt(3) / 2) * r) + 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: - return math.hypot(left[0] - right[0], left[1] - right[1]) + """Measure distance between already-supplied source/display points.""" - -def visual_one_radius_pairs( - tiles: Sequence[tuple[int, int]], radius: float -) -> tuple[tuple[tuple[int, int], tuple[int, int]], ...]: - """Return center pairs one display radius apart; presentation use only.""" - - points = {item: axial_to_xy(item[0], item[1], radius) for item in tiles} - pairs = [] - items = list(tiles) - for index, left in enumerate(items): - for right in items[index + 1 :]: - if math.isclose( - center_distance(points[left], points[right]), - radius, - rel_tol=1e-9, - abs_tol=1e-9, - ): - pairs.append((left, right)) - return tuple(pairs) + return math.hypot(left[0] - right[0], left[1] - right[1]) From 14a3b605a88c2511e704d9fd101dd535f13a6c95 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:44:50 -0700 Subject: [PATCH 15/21] fix(ahbg): render source-supplied UCNS positions and close browser validation gaps --- ahbg/presentation/board.js | 293 +++++++++++++++++-------------------- 1 file changed, 135 insertions(+), 158 deletions(-) diff --git a/ahbg/presentation/board.js b/ahbg/presentation/board.js index c30e449..231f474 100644 --- a/ahbg/presentation/board.js +++ b/ahbg/presentation/board.js @@ -1,153 +1,167 @@ +// === 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: "c", q: 0, r: 0, label: "origin" }, - { id: "ne", q: 1, r: -1, label: "ne" }, - { id: "e", q: 1, r: 0, label: "e" }, - { id: "se", q: 0, r: 1, label: "se" }, - { id: "sw", q: -1, r: 1, label: "sw" }, - { id: "w", q: -1, r: 0, label: "w" }, - { id: "nw", q: 0, r: -1, label: "nw" }, + { 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: "ne", label: "A0" }], - selected_tile: "ne", - motions: [{ unit: "A0", from: "c", to: "ne" }], + 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 origin to ne" }, + { 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 axialToPixel(q, r) { - return { - x: RADIUS * (q + r / 2), - y: RADIUS * (Math.sqrt(3) / 2) * r, - }; +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"); } - 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"); - } - if (!Array.isArray(snapshot.tiles) || snapshot.tiles.length === 0) { - throw new Error("tiles must be a non-empty list"); + 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 coords = 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"); - } - 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 (!plainInteger(tile.q) || !plainInteger(tile.r)) { - throw new Error(`tile ${tile.id} q,r must be integers`); - } - const coord = `${tile.q},${tile.r}`; - if (coords.has(coord)) { - throw new Error(`tile coordinate repeats: ${coord}`); - } - if (tile.label !== undefined && !exactText(tile.label)) { - throw new Error(`tile ${tile.id} label must be exact non-empty text when present`); - } + 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); - coords.add(coord); + sourceSlots.add(tile.source_slot); + positions.add(position); } - if (!Array.isArray(snapshot.units)) { - throw new Error("units must be a list"); - } + 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"); - } - 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`); - } + 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"); + 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) || !exactText(item.text)) { - throw new Error("each feed item must have exact non-empty text"); - } + 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 snapshot.motions || []) { - if (!motion || typeof motion !== "object" || Array.isArray(motion)) { - throw new Error("each motion must be an object"); - } - 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`); - } + 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; @@ -160,7 +174,7 @@ function render(snapshot) { svg.replaceChildren(); feed.replaceChildren(); - const pixels = snapshot.tiles.map((tile) => axialToPixel(tile.q, tile.r)); + const pixels = snapshot.tiles.map(sourceToPixel); const minX = Math.min(...pixels.map((p) => p.x)) - RADIUS * 1.2; const minY = Math.min(...pixels.map((p) => p.y)) - RADIUS * 1.2; const maxX = Math.max(...pixels.map((p) => p.x)) + RADIUS * 1.2; @@ -168,30 +182,23 @@ function render(snapshot) { svg.setAttribute("viewBox", `${minX} ${minY} ${maxX - minX} ${maxY - minY}`); 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; + let selected = snapshot.selected_tile && byId[snapshot.selected_tile] ? snapshot.selected_tile : snapshot.tiles[0].id; const hitByTile = new Map(); let selectionRing = null; function paintInspect() { const tile = byId[selected]; const occupants = snapshot.units.filter((unit) => unit.tile === selected); - inspect.textContent = `tile ${tile.label || tile.id} center (${tile.q},${tile.r})${ - occupants.length ? ` — ${occupants.map((unit) => unit.label || unit.id).join(", ")}` : "" - }`; + 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"); - } + for (const [tileId, node] of hitByTile.entries()) node.setAttribute("aria-pressed", tileId === selected ? "true" : "false"); if (selectionRing) { - const tile = byId[selected]; - const { x, y } = axialToPixel(tile.q, tile.r); + const { x, y } = sourceToPixel(byId[selected]); selectionRing.setAttribute("cx", x); selectionRing.setAttribute("cy", y); } @@ -199,7 +206,7 @@ function render(snapshot) { } snapshot.tiles.forEach((tile) => { - const { x, y } = axialToPixel(tile.q, tile.r); + const { x, y } = sourceToPixel(tile); const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); circle.setAttribute("cx", x); circle.setAttribute("cy", y); @@ -209,8 +216,8 @@ function render(snapshot) { }); (snapshot.motions || []).forEach((motion) => { - const from = axialToPixel(byId[motion.from].q, byId[motion.from].r); - const to = axialToPixel(byId[motion.to].q, byId[motion.to].r); + 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); @@ -221,7 +228,7 @@ function render(snapshot) { }); snapshot.tiles.forEach((tile) => { - const { x, y } = axialToPixel(tile.q, tile.r); + const { x, y } = sourceToPixel(tile); const point = document.createElementNS("http://www.w3.org/2000/svg", "circle"); point.setAttribute("cx", x); point.setAttribute("cy", y); @@ -239,16 +246,10 @@ function render(snapshot) { 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(); - }; + const selectTile = () => { selected = tile.id; paintSelection(); }; hit.addEventListener("click", selectTile); hit.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - selectTile(); - } + if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectTile(); } }); hitByTile.set(tile.id, hit); svg.appendChild(hit); @@ -274,62 +275,38 @@ function render(snapshot) { 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 spread = UNIT_RADIUS * 1.35; + 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 }; } snapshot.units.forEach((unit) => { - const tile = byId[unit.tile]; const offset = offsetFor(unit); - const center = axialToPixel(tile.q, tile.r); + const center = sourceToPixel(byId[unit.tile]); const dest = { x: center.x + offset.x, y: center.y + offset.y }; const motion = motionByUnit[unit.id]; - const fromCenter = motion ? axialToPixel(byId[motion.from].q, byId[motion.from].r) : center; + const fromCenter = motion ? sourceToPixel(byId[motion.from]) : center; const origin = { x: fromCenter.x + offset.x, y: fromCenter.y + offset.y }; 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"); + 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; + 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]) => { + [["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); + 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); - }); + 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(); } From bcb34744094cdcba5027f65e53ee8211b073b49e Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 31 Aug 2026 06:45:12 -0700 Subject: [PATCH 16/21] fix(ahbg): expose interactive tile descendants to assistive technology --- ahbg/presentation/board.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ahbg/presentation/board.html b/ahbg/presentation/board.html index e49858a..5ecc6c6 100644 --- a/ahbg/presentation/board.html +++ b/ahbg/presentation/board.html @@ -10,8 +10,8 @@

AHBG plane

-

Presentation only. Not mechanics. Each tile is a centerpoint; circles are Seed of Life geometry. Dashed traces show already-resolved motion. Click a center, or focus it and press Enter/Space, to inspect.

- +

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.

+