@staix/agent-console 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +354 -2
  4. package/bin/acon.js +8 -0
  5. package/package.json +38 -4
  6. package/shell/asset-viewer.css +5 -0
  7. package/shell/asset-viewer.html +11 -0
  8. package/shell/asset-viewer.js +23 -0
  9. package/shell/icon.svg +1 -0
  10. package/shell/index.html +53 -0
  11. package/shell/shell.css +534 -0
  12. package/shell/shell.js +2214 -0
  13. package/shell/space-view.js +164 -0
  14. package/shell/space.js +241 -0
  15. package/shell/space3d.js +364 -0
  16. package/shell/static.js +111 -0
  17. package/shell/vendor/BufferGeometryUtils.js +1523 -0
  18. package/shell/vendor/GLTFLoader.js +5006 -0
  19. package/shell/vendor/NOTICE +30 -0
  20. package/shell/vendor/SkeletonUtils.js +496 -0
  21. package/shell/vendor/manifest.json +42 -0
  22. package/shell/vendor/mermaid/NOTICE +25 -0
  23. package/shell/vendor/mermaid/manifest.json +19 -0
  24. package/shell/vendor/mermaid/mermaid.js +3580 -0
  25. package/shell/vendor/three.core.js +61162 -0
  26. package/shell/vendor/three.module.js +19537 -0
  27. package/shell/visual-frame.css +5 -0
  28. package/shell/visual-frame.html +14 -0
  29. package/shell/visual-frame.js +54 -0
  30. package/src/assets.ts +200 -0
  31. package/src/canonical.ts +16 -0
  32. package/src/cli.ts +246 -0
  33. package/src/conformance.ts +299 -0
  34. package/src/contract.ts +276 -0
  35. package/src/install.ts +248 -0
  36. package/src/pets.ts +63 -0
  37. package/src/plugins.ts +357 -0
  38. package/src/serve.ts +184 -0
  39. package/src/server.ts +462 -0
  40. package/src/setup.ts +74 -0
  41. package/src/space.ts +106 -0
  42. package/src/static.ts +102 -0
  43. package/test/fixtures/review/cases.json +726 -0
@@ -0,0 +1,164 @@
1
+ // View controller shared by the CSS room and the optional, lazily imported WebGL view.
2
+ const STORAGE_KEY = "acon.space.view";
3
+ const radians = (degrees) => degrees * Math.PI / 180;
4
+ export function roomTransform(position, width, height, camera) {
5
+ const { yaw, pitch } = camera;
6
+ return `translate3d(${position.x + width / 2}px, ${position.y + height / 2}px, 2px) rotateZ(${-yaw}deg) rotateX(${-pitch}deg) translate(${-width / 2}px, ${-height}px)`;
7
+ }
8
+ export function createSpaceViewController(host) {
9
+ const { root, plane, scroll, h } = host;
10
+ const stage = h("div", { class: "space-stage" });
11
+ plane.before(stage); stage.append(plane);
12
+ const mount = h("div", { class: "space-webgl", "aria-hidden": "true", hidden: true });
13
+ stage.prepend(mount);
14
+ const fallback = h("p", { class: "notice space-view-notice", role: "status", hidden: true });
15
+ const viewLabel = h("span", { text: "View" });
16
+ const choices = h("div", { class: "space-view-choices", role: "group", "aria-label": "Space view" }, viewLabel);
17
+ const controls = h("div", { class: "space-camera", role: "group", "aria-label": "Room camera" });
18
+ const bar = h("div", { class: "space-view-bar" }, choices, controls);
19
+ const selection = h("p", { class: "space-selection sub", hidden: true });
20
+ root.querySelector(".space-content").before(bar, selection, fallback);
21
+ let available = false;
22
+ try { const probe = document.createElement("canvas"); const context = probe.getContext("webgl2"); available = Boolean(context); context?.getExtension("WEBGL_lose_context")?.loseContext(); } catch { /* Room is always available. */ }
23
+ let view = "flat", saved = "flat";
24
+ try { saved = localStorage.getItem(STORAGE_KEY) ?? "flat"; } catch { /* Storage may be disabled. */ }
25
+ let state = null, webgl = null, lastReady = null, generation = 0, destroyed = false;
26
+ let camera = { yaw: 45, pitch: 60, zoom: 1 };
27
+ let cameraTouched = false;
28
+ const buttons = new Map();
29
+ for (const [id, label] of [["flat", "Flat"], ["room", "Room"], ...(available ? [["3d", "3D"]] : [])]) {
30
+ const button = h("button", { type: "button", class: "btn", text: label, "data-space-view": id, "aria-pressed": "false", onclick: () => select(id) });
31
+ choices.append(button); buttons.set(id, button);
32
+ }
33
+ const rotate = (step) => { cameraTouched = true; camera = { ...camera, yaw: camera.yaw + step }; apply(); };
34
+ const zoom = (step) => { cameraTouched = true; camera = { ...camera, zoom: Math.max(.65, Math.min(1.35, Number((camera.zoom + step).toFixed(2)))) }; apply(); };
35
+ controls.append(h("button", { type: "button", class: "btn", text: "Rotate left", onclick: () => rotate(-90) }),
36
+ h("button", { type: "button", class: "btn", text: "Rotate right", onclick: () => rotate(90) }),
37
+ h("button", { type: "button", class: "btn", text: "Zoom out", onclick: () => zoom(-.35) }),
38
+ h("button", { type: "button", class: "btn", text: "Zoom in", onclick: () => zoom(.35) }));
39
+ const onKey = (event) => {
40
+ if (view === "flat" || event.target !== scroll) return;
41
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); rotate(event.key === "ArrowLeft" ? -90 : 90); }
42
+ if (event.key === "+" || event.key === "-") { event.preventDefault(); zoom(event.key === "+" ? .35 : -.35); }
43
+ };
44
+ scroll.addEventListener("keydown", onKey);
45
+ const describe = (event) => {
46
+ const button = event.target.closest?.(".space-actor,.space-card");
47
+ if (!button || view === "flat") return;
48
+ selection.textContent = button.getAttribute("aria-label"); selection.hidden = false;
49
+ };
50
+ const clearDescription = (event) => {
51
+ const active = document.activeElement;
52
+ if (plane.contains(active) && active.matches(".space-actor,.space-card")) return;
53
+ if (!event.relatedTarget || !plane.contains(event.relatedTarget)) selection.hidden = true;
54
+ };
55
+ plane.addEventListener("focusin", describe); plane.addEventListener("pointerover", describe);
56
+ plane.addEventListener("focusout", clearDescription); plane.addEventListener("pointerout", clearDescription);
57
+ let lastProjection = null;
58
+ const project = (projection) => {
59
+ if (view !== "3d" || !state) return;
60
+ lastProjection = projection;
61
+ for (const kind of ["actors", "cards"]) for (const point of projection[kind]) {
62
+ const item = state[kind].find((row) => row.id === point.id);
63
+ if (!item) continue;
64
+ item.el.style.transform = `translate(${point.x - item.el.offsetWidth / 2}px, ${point.y - item.el.offsetHeight / 2}px)`;
65
+ item.el.dataset.projectedX = String(point.x); item.el.dataset.projectedY = String(point.y);
66
+ }
67
+ scroll.scrollLeft = 0; scroll.scrollTop = 0;
68
+ };
69
+ function apply() {
70
+ if (!state || destroyed) return;
71
+ root.dataset.spaceView = view; plane.dataset.view = view;
72
+ for (const [id, button] of buttons) button.setAttribute("aria-pressed", String(id === view));
73
+ controls.hidden = view === "flat"; mount.hidden = view !== "3d";
74
+ scroll.setAttribute("aria-label", view === "flat" ? "Collaboration scene, scroll to explore" : "Collaboration scene, arrow keys rotate, plus and minus zoom");
75
+ scroll.style.removeProperty("perspective");
76
+ plane.style.removeProperty("transform"); stage.style.removeProperty("width"); stage.style.removeProperty("height");
77
+ if (view === "flat") {
78
+ for (const kind of ["actors", "cards", "zones"]) for (const item of state[kind]) {
79
+ const position = state.layout[kind].get(item.id);
80
+ item.el.style.transform = `translate(${position.x}px, ${position.y}px)`;
81
+ }
82
+ return;
83
+ }
84
+ if (view === "room") {
85
+ const width = state.layout.width, height = state.layout.height;
86
+ const scale = camera.zoom, pad = 24;
87
+ scroll.style.perspective = `${Math.max(4000, Math.hypot(width, height) * 12)}px`;
88
+ plane.style.width = `${width}px`; plane.style.minWidth = `${width}px`;
89
+ // Frame the camera on what it actually shows: project the plane corners and every billboard's corners
90
+ // through the same transforms (the long perspective is close to orthographic), then offset the plane so
91
+ // nothing lands above or left of the scroll box, where it could not be scrolled to.
92
+ const camera3d = `scale(${scale}) rotateX(${camera.pitch}deg) rotateZ(${camera.yaw}deg) translate(${-width / 2}px, ${-height}px)`;
93
+ const planeMatrix = new DOMMatrix(camera3d);
94
+ const xs = [], ys = [];
95
+ const add = (matrix, w, hgt) => { for (const [x, y] of [[0, 0], [w, 0], [0, hgt], [w, hgt]]) { const p = matrix.transformPoint(new DOMPoint(x, y, 0, 1)); xs.push(p.x / (p.w || 1)); ys.push(p.y / (p.w || 1)); } };
96
+ add(planeMatrix, width, height);
97
+ for (const kind of ["actors", "cards"]) for (const item of state[kind]) {
98
+ const transform = roomTransform(state.layout[kind].get(item.id), item.width, item.height, camera);
99
+ item.el.style.transform = transform;
100
+ add(planeMatrix.multiply(new DOMMatrix(transform)), item.width, item.height);
101
+ }
102
+ const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
103
+ const stageWidth = Math.max(scroll.clientWidth - 2, maxX - minX + pad * 2), stageHeight = maxY - minY + pad * 2;
104
+ const offsetX = (stageWidth - (maxX - minX)) / 2 - minX, offsetY = pad - minY;
105
+ stage.style.width = `${stageWidth}px`; stage.style.height = `${stageHeight}px`;
106
+ plane.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) ${camera3d}`;
107
+ for (const item of state.zones) { const p = state.layout.zones.get(item.id); item.el.style.transform = `translate3d(${p.x}px, ${p.y}px, 1px)`; }
108
+ scroll.scrollLeft = Math.max(0, (stageWidth - scroll.clientWidth) / 2);
109
+ scroll.scrollTop = 0;
110
+ return;
111
+ }
112
+ scroll.scrollLeft = 0; scroll.scrollTop = 0;
113
+ // The stage never outgrows the scroll box (max-height 70vh): in 3D the box clips instead of scrolling.
114
+ const cap = parseFloat(getComputedStyle(scroll).maxHeight);
115
+ const width = Math.max(280, scroll.clientWidth - 2);
116
+ const height = Math.max(200, Math.min(Number.isFinite(cap) ? cap - 2 : Infinity, Math.max(480, Math.min(720, window.innerHeight * .65))));
117
+ stage.style.width = `${width}px`; stage.style.height = `${height}px`;
118
+ plane.style.width = `${width}px`; plane.style.minWidth = "0"; plane.style.height = `${height}px`;
119
+ if (webgl) {
120
+ // On a portrait canvas the wide layout fits best with its long axis running down the screen.
121
+ if (!cameraTouched) camera = { ...camera, yaw: width < height ? 90 : 45 };
122
+ const input = { ...state, width, height, theme: getComputedStyle(root).color };
123
+ webgl.update(input); webgl.setCamera(camera);
124
+ // A forced refresh with unchanged facts resets the buttons to flat coordinates, but the renderer then draws
125
+ // nothing new, so put them back on the last projection now; a fresh draw will overwrite it if one follows.
126
+ if (lastProjection) project(lastProjection);
127
+ const current = webgl, turn = generation;
128
+ const ready = current.ready;
129
+ if (ready !== lastReady) { lastReady = ready; delete mount.dataset.ready; }
130
+ ready.then(() => { if (!destroyed && current === webgl && turn === generation && ready === current.ready && view === "3d") mount.dataset.ready = "true"; });
131
+ }
132
+ }
133
+ async function select(next, message = "") {
134
+ const id = ++generation;
135
+ view = next === "3d" && !available ? "room" : next; selection.hidden = true;
136
+ try { localStorage.setItem(STORAGE_KEY, view); } catch { /* Optional persistence. */ }
137
+ if (next === "3d" && !available) message = "3D is unavailable. Room view is active.";
138
+ fallback.hidden = !message; fallback.textContent = message;
139
+ if (view !== "3d") { webgl?.destroy(); webgl = null; lastReady = null; lastProjection = null; mount.replaceChildren(); delete mount.dataset.ready; }
140
+ plane.style.removeProperty("width"); plane.style.removeProperty("min-width");
141
+ if (state) plane.style.height = `${state.layout.height}px`;
142
+ apply();
143
+ if (view === "3d" && !webgl) {
144
+ try {
145
+ const { createSpace3D } = await import("/space3d.js");
146
+ if (id !== generation || destroyed) return;
147
+ const created = await createSpace3D({ mount, onProjection: project, onCamera: (next) => { cameraTouched = true; camera = next; }, onFallback: () => select("room", "3D is unavailable. Room view is active.") });
148
+ if (id !== generation || destroyed) { created.destroy(); return; }
149
+ webgl = created; apply();
150
+ } catch (error) {
151
+ host.onError?.(error);
152
+ if (id === generation && !destroyed) select("room", "3D is unavailable. Room view is active.");
153
+ }
154
+ }
155
+ }
156
+ const resize = new ResizeObserver(() => { if (view !== "flat") apply(); }); resize.observe(scroll);
157
+ const theme = new MutationObserver(() => { if (view === "3d") apply(); }); theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
158
+ return {
159
+ update(next) { state = next; if (!root.dataset.spaceView) select(["flat", "room", "3d"].includes(saved) ? saved : "flat"); else apply(); },
160
+ select,
161
+ get stats() { return { view, renderCount: webgl?.renderCount ?? 0, pendingFrames: webgl?.pendingFrames ?? 0, modelCount: webgl?.modelCount ?? 0, boxCount: webgl?.boxCount ?? 0 }; },
162
+ destroy() { destroyed = true; generation += 1; resize.disconnect(); theme.disconnect(); scroll.removeEventListener("keydown", onKey); webgl?.destroy(); plane.removeEventListener("focusin", describe); plane.removeEventListener("pointerover", describe); plane.removeEventListener("focusout", clearDescription); plane.removeEventListener("pointerout", clearDescription); bar.remove(); selection.remove(); fallback.remove(); }
163
+ };
164
+ }
package/shell/space.js ADDED
@@ -0,0 +1,241 @@
1
+ import { createSpaceViewController } from "./space-view.js";
2
+
3
+ // Private shell module. Public collaboration facts enter through the space snapshot allowlist.
4
+ export const SPACE_CAPS = { actors: 32, cards: 200, links: 64 };
5
+ const ACTOR_WIDTH = 180, ACTOR_STEP = 200, ZONE_WIDTH = 234, ZONE_STEP = 250;
6
+
7
+ export function mergeSpaceScenes(entries) {
8
+ const model = { actors: [], zones: [], cards: [], links: [], dropped: { actors: 0, cards: 0, links: 0 }, failed: [], received: 0, sampledAt: 0, samples: new Map() };
9
+ for (const [tool, entry] of [...entries].sort(([a], [b]) => a.localeCompare(b))) {
10
+ if (entry.failed) model.failed.push(tool);
11
+ const scene = entry.scene;
12
+ if (!scene) continue;
13
+ model.received += 1;
14
+ model.samples.set(tool, entry.sampledAt ?? 0);
15
+ model.sampledAt = Math.max(model.sampledAt, entry.sampledAt ?? 0);
16
+ const key = (id) => `${tool}:${id}`;
17
+ const target = (link) => ({ tool, panel: link.panel, ...(link.params ? { params: link.params } : {}) });
18
+ model.actors.push(...scene.actors.map((a) => ({ id: key(a.id), name: a.name, state: a.state, stale: Boolean(entry.failed),
19
+ ...(a.pet ? { pet: a.pet } : {}), ...(a.card ? { card: key(a.card) } : {}),
20
+ marks: (a.marks ?? []).map((m) => ({ kind: m.kind, text: m.text })), link: target(a.link) })));
21
+ model.zones.push(...scene.zones.map((z) => ({ id: key(z.id), title: z.title, kind: z.kind, count: z.count, tool })));
22
+ model.cards.push(...scene.cards.map((c) => ({ id: key(c.id), title: c.title, zone: key(c.zone), owner: c.owner ? key(c.owner) : undefined, tone: c.tone, link: target(c.link) })));
23
+ model.links.push(...(scene.links ?? []).map((l) => ({ kind: l.kind, from: key(l.from), to: key(l.to), label: l.label, at: l.at })));
24
+ for (const k of Object.keys(model.dropped)) model.dropped[k] += entry.dropped?.[k] ?? 0;
25
+ }
26
+ for (const k of Object.keys(SPACE_CAPS)) {
27
+ model.dropped[k] += Math.max(0, model[k].length - SPACE_CAPS[k]);
28
+ model[k] = model[k].slice(0, SPACE_CAPS[k]);
29
+ }
30
+ const actors = new Set(model.actors.map((a) => a.id));
31
+ const cards = new Set(model.cards.map((c) => c.id));
32
+ for (const actor of model.actors) if (actor.card && !cards.has(actor.card)) delete actor.card;
33
+ for (const card of model.cards) if (card.owner && !actors.has(card.owner)) delete card.owner;
34
+ const linkCount = model.links.length;
35
+ model.links = model.links.filter((l) => actors.has(l.from) && actors.has(l.to));
36
+ model.dropped.links += linkCount - model.links.length;
37
+ return model;
38
+ }
39
+
40
+ /** Pure layout with sticky actor slots. Measured text heights keep 200% text from overlapping. */
41
+ export function spaceLayout(model, previous, now, sizes = {}) {
42
+ const slots = new Map(previous?.slots ?? []);
43
+ const active = new Set(model.actors.map((a) => a.id));
44
+ // Keep a bounded history for returning actors, without ever moving an actor that remains present.
45
+ for (const key of slots.keys()) if (slots.size > SPACE_CAPS.actors && !active.has(key)) slots.delete(key);
46
+ const used = new Set(slots.values());
47
+ for (const actor of model.actors) if (!slots.has(actor.id)) {
48
+ if (slots.size >= SPACE_CAPS.actors) {
49
+ const vacant = [...slots].find(([id]) => !active.has(id));
50
+ if (vacant) { slots.delete(vacant[0]); used.delete(vacant[1]); }
51
+ }
52
+ let slot = 0; while (used.has(slot)) slot += 1;
53
+ slots.set(actor.id, slot); used.add(slot);
54
+ }
55
+ const rowHeights = [...(previous?.rowHeights ?? [])];
56
+ for (const actor of model.actors) {
57
+ const row = Math.floor(slots.get(actor.id) / 5);
58
+ rowHeights[row] = Math.max(rowHeights[row] ?? 0, sizes.actors?.get(actor.id) ?? 120);
59
+ }
60
+ const rowTops = []; let y = 12;
61
+ for (let i = 0; i < rowHeights.length; i += 1) { rowTops[i] = y; y += (rowHeights[i] ?? 120) + 24; }
62
+ const actors = new Map(model.actors.map((a) => [a.id, { x: 10 + (slots.get(a.id) % 5) * ACTOR_STEP, y: rowTops[Math.floor(slots.get(a.id) / 5)] ?? 12 }]));
63
+ const zoneTop = Math.max(176, y + 16);
64
+ const zoneHeights = model.zones.map((z) => (sizes.zones?.get(z.id) ?? 60) + 24 + model.cards.filter((c) => c.zone === z.id).reduce((sum, c) => sum + (sizes.cards?.get(c.id) ?? 72) + 8, 0));
65
+ const zoneRowHeights = [];
66
+ zoneHeights.forEach((height, i) => { const row = Math.floor(i / 4); zoneRowHeights[row] = Math.max(zoneRowHeights[row] ?? 0, height); });
67
+ const zoneRowTops = []; y = zoneTop;
68
+ zoneRowHeights.forEach((height, i) => { zoneRowTops[i] = y; y += height + 24; });
69
+ const zones = new Map(), cards = new Map();
70
+ model.zones.forEach((z, i) => {
71
+ const x = 8 + (i % 4) * ZONE_STEP, top = zoneRowTops[Math.floor(i / 4)];
72
+ zones.set(z.id, { x, y: top, height: zoneHeights[i] });
73
+ let cy = top + (sizes.zones?.get(z.id) ?? 60) + 12;
74
+ for (const c of model.cards.filter((c) => c.zone === z.id)) { cards.set(c.id, { x: x + 10, y: cy }); cy += (sizes.cards?.get(c.id) ?? 72) + 8; }
75
+ });
76
+ const signatures = new Map();
77
+ for (const k of ["actors", "zones", "cards", "links"]) model[k].forEach((v, i) => signatures.set(`${k}:${v.id ?? i}`, JSON.stringify(v)));
78
+ let changed = 0;
79
+ for (const [k, v] of signatures) if (previous?.signatures.get(k) !== v) changed += 1;
80
+ for (const k of previous?.signatures.keys() ?? []) if (!signatures.has(k)) changed += 1;
81
+ return { slots, rowHeights, actors, zones, cards, signatures, received: model.received ?? 1, at: now, settle: !previous || previous.received === 0 || now - previous.at > 3000 || changed > 6, height: Math.max(y, zoneTop + 80), width: 1000 };
82
+ }
83
+
84
+ const stateText = { idle: "idle", working: "working", waiting: "waiting", blocked: "blocked", away: "away" };
85
+ const stateGlyph = { idle: "○", working: "▶", waiting: "?", blocked: "!", away: "-" };
86
+ const poseFor = { idle: "idle", working: "working", waiting: "waiting", blocked: "failed", away: "idle" };
87
+ const hash = (text) => [...text].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 0);
88
+
89
+ /** Keyed DOM renderer; update() does no DOM writes when its public model and size are unchanged. */
90
+ export function createSpaceRenderer(root, host) {
91
+ const { h } = host;
92
+ const notice = h("p", { class: "notice", role: "status", hidden: true });
93
+ const plane = h("div", { class: "space-plane" });
94
+ const wall = h("div", { class: "space-wall", "aria-hidden": "true" });
95
+ plane.append(wall);
96
+ const scroll = h("div", { class: "space-scroll", tabindex: "0", role: "region", "aria-label": "Collaboration scene, scroll to explore" }, plane);
97
+ const roster = h("section", { class: "space-roster", "aria-label": "Space roster" });
98
+ const empty = h("p", { class: "sub", text: "No actors or work items are reported yet.", hidden: true });
99
+ root.replaceChildren(h("h1", { class: "lead", id: "space-lead", text: "Space" }), notice, h("div", { class: "space-content" }, scroll, roster), empty);
100
+ const views = createSpaceViewController({ root, plane, scroll, h, onError: host.onError });
101
+ const actorNodes = new Map(), cardNodes = new Map(), zoneNodes = new Map(), linkNodes = new Map(), rosterNodes = new Map();
102
+ const entries = new Map();
103
+ const seenMessages = new Map(), samples = new Map();
104
+ let signature = "", layout = null, lastModel = null;
105
+ const setStyle = (el, name, value) => { if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value); };
106
+ const setText = (el, value) => { if (el.textContent !== value) el.textContent = value; };
107
+ const target = (key) => { const entry = entries.get(key); if (entry) host.navigate(entry.link.tool, entry.link.panel, entry.link.params); };
108
+ const image = (el, variable, pack, name) => setStyle(el, variable, pack?.images.includes(name) ? `url("${location.origin}/assets/${pack.id}/${name}")` : "none");
109
+ const prune = (nodes, keys) => { for (const [key, entry] of nodes) if (!keys.has(key)) { entry.roster?.remove(); (entry.el ?? entry).remove(); nodes.delete(key); } };
110
+ const place = (el, position) => setStyle(el, "transform", `translate(${position.x}px, ${position.y}px)`);
111
+ const sprite = (actor) => {
112
+ const pets = host.pets();
113
+ const pet = pets.find((p) => p.id === actor.pet) ?? (pets.length ? pets[hash(actor.id) % pets.length] : null);
114
+ return pet ? host.petSprite(pet, actor.stale ? "idle" : poseFor[actor.state]) : h("span", { class: "space-token", "aria-hidden": "true", text: actor.name.slice(0, 1).toUpperCase() });
115
+ };
116
+ function update(model, force = false) {
117
+ lastModel = model;
118
+ const pack = host.packs()[0];
119
+ const { sampledAt, samples: receivedSamples, ...facts } = model;
120
+ let sourceGap = false, firstSource = false;
121
+ for (const tool of samples.keys()) if (!receivedSamples.has(tool)) samples.delete(tool);
122
+ for (const [tool, at] of receivedSamples) {
123
+ if (!samples.has(tool)) firstSource = true;
124
+ else if (at - samples.get(tool) > 3000) sourceGap = true;
125
+ samples.set(tool, at);
126
+ }
127
+ const sig = JSON.stringify([facts, pack, host.pets().map((p) => p.id)]);
128
+ if (!force && sig === signature) { if (layout) layout.at = Math.max(layout.at, sampledAt); return; }
129
+ const factChange = sig !== signature;
130
+ const freshCards = new Set();
131
+ signature = sig;
132
+ image(plane, "--space-floor", pack, "floor"); image(wall, "--space-wall", pack, "wall");
133
+ plane.dataset.pack = pack?.id ?? "fallback";
134
+ if (pack) setStyle(plane, "background-size", `${pack.tile[0]}px ${pack.tile[1]}px`);
135
+ else setStyle(plane, "background-size", "64px 32px");
136
+ const hidden = Object.entries(model.dropped).filter(([, n]) => n > 0).map(([k, n]) => `${n} ${k} omitted`);
137
+ const message = [...(model.failed.length ? ["Some space data could not refresh. Last reported states are marked stale; details are in System."] : []), ...hidden].join(" ");
138
+ if (notice.hidden !== !message) notice.hidden = !message;
139
+ setText(notice, message);
140
+ const nothing = !model.actors.length && !model.cards.length;
141
+ if (empty.hidden !== !nothing) empty.hidden = !nothing;
142
+ entries.clear();
143
+ for (const actor of model.actors) {
144
+ entries.set(`actor:${actor.id}`, actor);
145
+ let n = actorNodes.get(actor.id);
146
+ if (!n) {
147
+ const name = h("strong"), status = h("span", { class: "row-sub" }), marks = h("span", { class: "space-marks" }), visual = h("span", { class: "space-visual" });
148
+ const el = h("button", { type: "button", class: "space-actor", "data-key": actor.id, onclick: () => target(`actor:${actor.id}`) }, visual, name, status, marks);
149
+ plane.append(el); n = { el, name, status, marks, visual, sig: "" }; actorNodes.set(actor.id, n);
150
+ }
151
+ const card = model.cards.find((c) => c.id === actor.card);
152
+ const displayName = `${actor.link.tool} / ${actor.name}`;
153
+ const text = `${stateGlyph[actor.state]} ${stateText[actor.state]}${actor.stale ? " (last reported, stale)" : ""}${card ? ` on ${card.title}` : ""}`;
154
+ const ownSig = JSON.stringify([actor, card?.title, host.pets().map((p) => p.id), pack?.id]);
155
+ if (ownSig !== n.sig) {
156
+ n.sig = ownSig; n.el.dataset.state = actor.state; n.el.dataset.stale = String(actor.stale);
157
+ setText(n.name, displayName); setText(n.status, text);
158
+ n.el.setAttribute("aria-label", `${displayName}: ${text}${actor.marks.length ? `; ${actor.marks.map((m) => m.text).join(", ")}` : ""}`);
159
+ n.marks.replaceChildren(...actor.marks.map((m) => h("span", { class: "chip", text: `${m.kind}: ${m.text}` })));
160
+ n.visual.replaceChildren(sprite(actor), h("span", { class: "space-desk", "aria-hidden": "true" }));
161
+ image(n.visual, "--space-desk", pack, "desk");
162
+ }
163
+ let r = rosterNodes.get(`actor:${actor.id}`);
164
+ if (!r) { r = h("button", { type: "button", class: "space-roster-actor btn", "data-key": actor.id, onclick: () => target(`actor:${actor.id}`) }); roster.append(r); rosterNodes.set(`actor:${actor.id}`, r); }
165
+ setText(r, `${displayName}: ${text}`);
166
+ }
167
+ for (const zone of model.zones) {
168
+ let n = zoneNodes.get(zone.id);
169
+ if (!n) { const title = h("h2"), el = h("section", { class: "space-zone", "data-key": zone.id }, title); plane.append(el); n = { el, title }; zoneNodes.set(zone.id, n); }
170
+ setText(n.title, `${zone.tool} / ${zone.title} (${zone.count})`); n.el.dataset.kind = zone.kind ?? "zone";
171
+ image(n.el, "--space-furniture", pack, zone.kind === "done" ? "shelf" : "board");
172
+ const key = `zone:${zone.id}`;
173
+ let r = rosterNodes.get(key);
174
+ if (!r) { r = h("p", { class: "space-roster-zone", "data-key": zone.id }); roster.append(r); rosterNodes.set(key, r); }
175
+ setText(r, `${zone.tool} / ${zone.title}: ${zone.count}`);
176
+ }
177
+ for (const card of model.cards) {
178
+ entries.set(`card:${card.id}`, card);
179
+ let n = cardNodes.get(card.id);
180
+ if (!n) { n = h("button", { type: "button", class: "space-card", "data-key": card.id, onclick: () => target(`card:${card.id}`) }, h("span", { class: "space-card-label" })); n.style.setProperty("transition", "none"); plane.append(n); cardNodes.set(card.id, n); freshCards.add(card.id); }
181
+ setText(n.firstElementChild, `${card.link.tool} / ${card.title}`); n.setAttribute("aria-label", `${card.link.tool}: ${card.title}`); n.dataset.tone = card.tone ?? "neutral";
182
+ }
183
+ prune(actorNodes, new Set(model.actors.map((a) => a.id))); prune(cardNodes, new Set(model.cards.map((c) => c.id))); prune(zoneNodes, new Set(model.zones.map((z) => z.id)));
184
+ prune(rosterNodes, new Set([...model.actors.map((a) => `actor:${a.id}`), ...model.zones.map((z) => `zone:${z.id}`)]));
185
+ root.dataset.spaceMeasuring = "true";
186
+ const sizes = { actors: new Map([...actorNodes].map(([k, n]) => [k, n.el.offsetHeight])), cards: new Map([...cardNodes].map(([k, n]) => [k, n.offsetHeight])), zones: new Map([...zoneNodes].map(([k, n]) => [k, n.title.offsetHeight])) };
187
+ const actorMetrics = [...actorNodes].map(([id, n]) => ({ id, el: n.el, width: n.el.offsetWidth, height: n.el.offsetHeight }));
188
+ const cardMetrics = [...cardNodes].map(([id, el]) => ({ id, el, width: el.offsetWidth, height: el.offsetHeight }));
189
+ delete root.dataset.spaceMeasuring;
190
+ const next = spaceLayout(model, layout, sampledAt, sizes);
191
+ const settle = force || !factChange || next.settle || sourceGap || firstSource || model.failed.length > 0;
192
+ if (plane.dataset.settle !== String(settle)) plane.dataset.settle = String(settle);
193
+ setStyle(plane, "height", `${next.height}px`);
194
+ for (const [key, p] of next.actors) place(actorNodes.get(key).el, p);
195
+ for (const [key, p] of next.zones) { const el = zoneNodes.get(key).el; place(el, p); setStyle(el, "height", `${p.height}px`); }
196
+ for (const [key, p] of next.cards) place(cardNodes.get(key), p);
197
+ for (const key of freshCards) { const el = cardNodes.get(key); el.getBoundingClientRect(); el.style.removeProperty("transition"); }
198
+ const linkKeys = new Set();
199
+ const currentMessages = new Set(model.links.filter((l) => l.kind === "message").map((l) => JSON.stringify(l)));
200
+ const observedAt = Date.now();
201
+ for (const [key, at] of seenMessages) if (!currentMessages.has(key) && observedAt - at > 10000) seenMessages.delete(key);
202
+ for (const l of model.links) {
203
+ const key = JSON.stringify(l);
204
+ if (l.kind === "message") {
205
+ const seen = seenMessages.has(key);
206
+ if (!seen) seenMessages.set(key, observedAt);
207
+ // A suppressed historical message is consumed too; unrelated changes cannot replay it.
208
+ if (settle || (l.at && observedAt - Date.parse(l.at) > 10000) || (seen && !linkNodes.has(key))) continue;
209
+ }
210
+ linkKeys.add(key);
211
+ const from = model.actors.find((a) => a.id === l.from), to = model.actors.find((a) => a.id === l.to);
212
+ const text = `${from.link.tool} / ${from.name} → ${to.link.tool} / ${to.name}: ${l.kind}${l.label ? `, ${l.label}` : ""}`;
213
+ let n = linkNodes.get(key);
214
+ if (!n) {
215
+ const el = h("span", { class: "space-link", "data-kind": l.kind, "aria-hidden": "true" }, h("span", { class: "space-link-label", text: l.label ?? l.kind }));
216
+ const description = h("p", { class: "space-roster-link", "data-kind": l.kind, text });
217
+ plane.append(el); roster.append(description); n = { el, roster: description }; linkNodes.set(key, n);
218
+ }
219
+ setText(n.roster, text);
220
+ const a = next.actors.get(l.from), b = next.actors.get(l.to);
221
+ if (!a || !b) continue;
222
+ const x = a.x + ACTOR_WIDTH / 2, y = a.y + 30, dx = b.x - a.x, dy = b.y - a.y, angle = Math.atan2(dy, dx);
223
+ setStyle(n.el, "width", `${Math.hypot(dx, dy)}px`); setStyle(n.el, "transform", `translate(${x}px, ${y}px) rotate(${angle}rad)`);
224
+ }
225
+ prune(linkNodes, linkKeys);
226
+ layout = next;
227
+ views.update({ model, layout, pack, pets: host.pets(), plane, scroll,
228
+ actors: actorMetrics,
229
+ cards: cardMetrics,
230
+ zones: [...zoneNodes].map(([id, n]) => ({ id, el: n.el, width: n.el.offsetWidth, height: n.el.offsetHeight })) });
231
+ }
232
+ let geometry = "";
233
+ const observer = new ResizeObserver(() => {
234
+ const next = `${root.clientWidth}:${getComputedStyle(root).fontSize}`;
235
+ if (next === geometry) return;
236
+ geometry = next;
237
+ if (lastModel && !root.hidden) update(lastModel, true);
238
+ });
239
+ observer.observe(root);
240
+ return { update, get viewStats() { return views.stats; }, destroy() { observer.disconnect(); views.destroy(); root.replaceChildren(); } };
241
+ }