@miadi/stateloom-web 0.2.6 → 0.3.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 (99) hide show
  1. package/package.json +1 -1
  2. package/server/.next/BUILD_ID +1 -1
  3. package/server/.next/app-path-routes-manifest.json +2 -0
  4. package/server/.next/build-manifest.json +4 -4
  5. package/server/.next/prerender-manifest.json +3 -3
  6. package/server/.next/routes-manifest.json +16 -1
  7. package/server/.next/server/app/_global-error/page/build-manifest.json +2 -2
  8. package/server/.next/server/app/_global-error.html +2 -2
  9. package/server/.next/server/app/_global-error.rsc +1 -1
  10. package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/server/.next/server/app/_not-found/page/build-manifest.json +2 -2
  16. package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/server/.next/server/app/_not-found.html +1 -1
  18. package/server/.next/server/app/_not-found.rsc +2 -2
  19. package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  20. package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  21. package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  22. package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  23. package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  24. package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  25. package/server/.next/server/app/api/companion/[...path]/route/app-paths-manifest.json +3 -0
  26. package/server/.next/server/app/api/companion/[...path]/route/build-manifest.json +11 -0
  27. package/server/.next/server/app/api/companion/[...path]/route/server-reference-manifest.json +4 -0
  28. package/server/.next/server/app/api/companion/[...path]/route.js +7 -0
  29. package/server/.next/server/app/api/companion/[...path]/route.js.map +5 -0
  30. package/server/.next/server/app/api/companion/[...path]/route.js.nft.json +1 -0
  31. package/server/.next/server/app/api/companion/[...path]/route_client-reference-manifest.js +2 -0
  32. package/server/.next/server/app/api/docs/route.js +1 -1
  33. package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
  34. package/server/.next/server/app/api/file/route.js +1 -1
  35. package/server/.next/server/app/api/file/route.js.nft.json +1 -1
  36. package/server/.next/server/app/api/marks/route/app-paths-manifest.json +3 -0
  37. package/server/.next/server/app/api/marks/route/build-manifest.json +11 -0
  38. package/server/.next/server/app/api/marks/route/server-reference-manifest.json +4 -0
  39. package/server/.next/server/app/api/marks/route.js +8 -0
  40. package/server/.next/server/app/api/marks/route.js.map +5 -0
  41. package/server/.next/server/app/api/marks/route.js.nft.json +1 -0
  42. package/server/.next/server/app/api/marks/route_client-reference-manifest.js +2 -0
  43. package/server/.next/server/app/api/rispec/route.js +1 -1
  44. package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
  45. package/server/.next/server/app/api/watch/route.js +1 -1
  46. package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
  47. package/server/.next/server/app/index.html +1 -1
  48. package/server/.next/server/app/index.rsc +3 -3
  49. package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  50. package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
  51. package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
  52. package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
  53. package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  54. package/server/.next/server/app/page/build-manifest.json +2 -2
  55. package/server/.next/server/app/page.js.nft.json +1 -1
  56. package/server/.next/server/app/page_client-reference-manifest.js +1 -1
  57. package/server/.next/server/app-paths-manifest.json +2 -0
  58. package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +1 -1
  59. package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
  60. package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
  61. package/server/.next/server/chunks/[root-of-the-server]__8b8844cb._.js +1 -1
  62. package/server/.next/server/chunks/[root-of-the-server]__8d9e281e._.js +3 -0
  63. package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +2 -2
  64. package/server/.next/server/chunks/[root-of-the-server]__aedef6d1._.js +3 -0
  65. package/server/.next/server/chunks/bridge-protocol_dist_index_179d02a5.js +1 -1
  66. package/server/.next/server/chunks/ssr/{[root-of-the-server]__cf0b4d7f._.js → [root-of-the-server]__9e661f75._.js} +1 -1
  67. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +7 -4
  68. package/server/.next/server/chunks/web__next-internal_server_app_api_companion_[___path]_route_actions_cfae35e7.js +3 -0
  69. package/server/.next/server/chunks/web__next-internal_server_app_api_marks_route_actions_6f18e4f5.js +3 -0
  70. package/server/.next/server/middleware-build-manifest.js +2 -2
  71. package/server/.next/server/pages/404.html +1 -1
  72. package/server/.next/server/pages/500.html +2 -2
  73. package/server/.next/server/server-reference-manifest.js +1 -1
  74. package/server/.next/server/server-reference-manifest.json +1 -1
  75. package/server/.next/static/chunks/0d24f3e3dbdef25e.css +2 -0
  76. package/server/.next/static/chunks/{5e1795a806bd6c51.js → d54b3ced5d4f7ae5.js} +1 -1
  77. package/server/.next/static/chunks/ec729c54327cc597.js +14 -0
  78. package/server/.next/static/chunks/{turbopack-34fc7de1568e2a7f.js → turbopack-b3cae9131ae5086f.js} +1 -1
  79. package/server/package-lock.json +2 -2
  80. package/server/package.json +1 -1
  81. package/server/src/app/api/companion/[...path]/route.ts +112 -0
  82. package/server/src/app/api/config/route.ts +6 -0
  83. package/server/src/app/api/marks/route.ts +146 -0
  84. package/server/src/app/globals.css +94 -0
  85. package/server/src/app/page.tsx +13 -6
  86. package/server/src/components/erd/ErdWorkspace.tsx +7 -1
  87. package/server/src/components/sequence/SequenceWorkspace.tsx +10 -0
  88. package/server/src/components/stage/CompanionLayer.tsx +382 -0
  89. package/server/src/components/stage/MachineStageLayer.tsx +56 -0
  90. package/server/src/components/stage/StageLayer.tsx +338 -0
  91. package/server/src/components/stage/StageProvider.tsx +257 -0
  92. package/server/src/lib/projectFile.ts +4 -2
  93. package/server/src/lib/runtimeConfig.ts +10 -1
  94. package/server/stateloom-web.json +2 -2
  95. package/server/.next/static/chunks/4237fd3bb3fa88e5.js +0 -11
  96. package/server/.next/static/chunks/83083561effbd9ca.css +0 -2
  97. /package/server/.next/static/{bwSt2D1R7aQAKa8P4ErpF → pbn7-8jVWdRn8FLy4fHnU}/_buildManifest.js +0 -0
  98. /package/server/.next/static/{bwSt2D1R7aQAKa8P4ErpF → pbn7-8jVWdRn8FLy4fHnU}/_clientMiddlewareManifest.json +0 -0
  99. /package/server/.next/static/{bwSt2D1R7aQAKa8P4ErpF → pbn7-8jVWdRn8FLy4fHnU}/_ssgManifest.js +0 -0
@@ -0,0 +1,338 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The stage on one board (Spec 83): mounted by each workspace beside its
5
+ * canvas, told the board's viewport and where its elements are.
6
+ *
7
+ * - What the step on stage lights in this drawing is drawn here, and the view
8
+ * fits it (after the workspace has centred the step's focus, so the wider
9
+ * view wins).
10
+ * - This drawing's marks: pinned on the board, listed in a panel, read and
11
+ * answered in a thread. ✎ turns marking on — a tap marks an element, a drag
12
+ * marks an area and what is inside it — and the composer asks what the agent
13
+ * should know. Answer on the caption opens the composer on what is lit.
14
+ * - What the person is looking at goes back to the agents through the stage.
15
+ *
16
+ * The sidecar is written through /api/marks; the hub is told after.
17
+ */
18
+ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
19
+ import { MarkComposer, MarkIcon, MarksIcon, MarkThread, MarksPanel, StageOverlay, type MarkDraft } from "@miadi/stateloom-canvas";
20
+ import {
21
+ boxesOf,
22
+ fitToBoxes,
23
+ formatReview,
24
+ screenToWorld,
25
+ type DocKind,
26
+ type ElementBox,
27
+ type Mark,
28
+ type MarkKind,
29
+ type Viewport,
30
+ } from "@miadi/stateloom-protocol";
31
+ import { useStageContext } from "./StageProvider";
32
+ import { urlParam } from "@/lib/systemParam";
33
+
34
+ export interface StageLayerProps {
35
+ /** The drawing this board shows, absolute. Null until the workspace has read it. */
36
+ docPath: string | null;
37
+ kind: DocKind;
38
+ viewport: Viewport;
39
+ setViewport: (vp: Viewport) => void;
40
+ boxes: readonly ElementBox[];
41
+ boardRef: RefObject<HTMLElement | null>;
42
+ /** The element in hand (`focus=`). */
43
+ focus?: string | null;
44
+ /** The machine level on the board: the state whose children are drawn. */
45
+ level?: string;
46
+ /**
47
+ * The step lights something this board cannot see yet — a state on another
48
+ * level, a sequence shown as a list. The workspace brings it on.
49
+ */
50
+ onReveal?: (focuses: readonly string[]) => void;
51
+ }
52
+
53
+ const quietButton =
54
+ "flex h-9 min-w-9 items-center justify-center gap-1 rounded-lg border border-gray-700 bg-gray-900/90 px-2 text-sm text-gray-200 shadow-lg backdrop-blur hover:bg-gray-800";
55
+
56
+ async function postMarks(docPath: string, body: unknown): Promise<{ marks: Mark[]; change: { action: "add" | "reply" | "resolve" | "reopen" | "remove"; id: string } }> {
57
+ const r = await fetch(`/api/marks?doc=${encodeURIComponent(docPath)}`, {
58
+ method: "POST",
59
+ headers: { "content-type": "application/json" },
60
+ body: JSON.stringify(body),
61
+ });
62
+ const data = await r.json().catch(() => ({}));
63
+ if (!r.ok) throw new Error(data?.error ?? `the marks could not be written (${r.status})`);
64
+ return data;
65
+ }
66
+
67
+ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes, boardRef, focus = null, level, onReveal }: StageLayerProps) {
68
+ const stage = useStageContext();
69
+ const [marks, setMarks] = useState<Mark[]>([]);
70
+ const [marking, setMarking] = useState(false);
71
+ const [draft, setDraft] = useState<(MarkDraft & { listen?: boolean }) | null>(null);
72
+ const [selected, setSelected] = useState<string | null>(null);
73
+ const [panel, setPanel] = useState(false);
74
+ const [busy, setBusy] = useState(false);
75
+ const [error, setError] = useState<string | null>(null);
76
+ const [copied, setCopied] = useState(false);
77
+
78
+ // ── This drawing's marks ─────────────────────────────────────────────
79
+ const load = useCallback(() => {
80
+ if (!docPath) return;
81
+ fetch(`/api/marks?doc=${encodeURIComponent(docPath)}`, { cache: "no-store" })
82
+ .then((r) => (r.ok ? r.json() : null))
83
+ .then((data) => data && Array.isArray(data.marks) && setMarks(data.marks))
84
+ .catch(() => {});
85
+ }, [docPath]);
86
+ useEffect(() => {
87
+ setMarks([]);
88
+ setSelected(null);
89
+ setDraft(null);
90
+ load();
91
+ window.addEventListener("focus", load);
92
+ return () => window.removeEventListener("focus", load);
93
+ }, [load]);
94
+ useEffect(
95
+ () =>
96
+ stage?.onMarks((e) => {
97
+ if (docPath && e.docId === docPath && Array.isArray(e.marks)) setMarks(e.marks);
98
+ }),
99
+ [stage, docPath],
100
+ );
101
+
102
+ const write = async (body: unknown): Promise<boolean> => {
103
+ if (!docPath) return false;
104
+ setBusy(true);
105
+ setError(null);
106
+ try {
107
+ const r = await postMarks(docPath, body);
108
+ setMarks(r.marks);
109
+ stage?.sendMarks({ docId: docPath, marks: r.marks, change: r.change });
110
+ return true;
111
+ } catch (e) {
112
+ setError(e instanceof Error ? e.message : String(e));
113
+ return false;
114
+ } finally {
115
+ setBusy(false);
116
+ }
117
+ };
118
+
119
+ // ── What the step lights here ────────────────────────────────────────
120
+ const lit = stage?.lit ?? null;
121
+ const litHere = lit && (!lit.doc || lit.doc === docPath) ? lit : null;
122
+ const spotlight = litHere?.spotlight;
123
+ const region = litHere?.region ?? null;
124
+ const fitted = useRef(-1);
125
+ const revealed = useRef(-1);
126
+ const refit = useRef<ReturnType<typeof setTimeout> | null>(null);
127
+ const litBoxes = useMemo(() => (spotlight?.length ? boxesOf(boxes, spotlight) : []), [boxes, spotlight]);
128
+ useEffect(() => {
129
+ if (!litHere || fitted.current === litHere.nonce) return;
130
+ const targets = [...litBoxes, ...(region ? [region] : [])];
131
+ if (!targets.length) {
132
+ // Lit, but not drawn on this board as it stands: ask the workspace once.
133
+ if (spotlight?.length && revealed.current !== litHere.nonce) {
134
+ revealed.current = litHere.nonce;
135
+ onReveal?.(spotlight);
136
+ }
137
+ return;
138
+ }
139
+ fitted.current = litHere.nonce;
140
+ const fit = (): void => {
141
+ const rect = boardRef.current?.getBoundingClientRect();
142
+ if (!rect?.width) return;
143
+ // The caption covers the bottom of the board: fit into what it leaves.
144
+ const caption = document.querySelector(".stage-caption-host .slc-stage-caption")?.getBoundingClientRect();
145
+ const covered = caption && caption.top < rect.bottom ? Math.max(0, rect.bottom - caption.top + 8) : 0;
146
+ const height = Math.max(rect.height * 0.4, rect.height - covered);
147
+ setViewport(fitToBoxes(targets, rect.width, height, { padding: 48, maxScale: 1 }));
148
+ };
149
+ fit();
150
+ // Again after the workspace's own fit or centring has landed. Not cleared
151
+ // when the board's boxes move under it (a live update arrives just after a
152
+ // drawing opens) — only when the layer goes.
153
+ if (refit.current) clearTimeout(refit.current);
154
+ refit.current = setTimeout(fit, 520);
155
+ }, [litHere, litBoxes, region, spotlight, boardRef, setViewport, onReveal]);
156
+ useEffect(() => () => {
157
+ if (refit.current) clearTimeout(refit.current);
158
+ }, []);
159
+
160
+ // Answer on the caption: a composer on what is lit (or the element in hand).
161
+ const answered = useRef(stage?.answerNonce ?? 0);
162
+ useEffect(() => {
163
+ if (!stage || stage.answerNonce === answered.current) return;
164
+ answered.current = stage.answerNonce;
165
+ const elements = spotlight?.length ? [...spotlight] : litHere?.focus ? [litHere.focus] : focus ? [focus] : [];
166
+ setPanel(false);
167
+ setSelected(null);
168
+ setDraft({ elements, region: region ?? undefined, listen: !!stage.listener });
169
+ }, [stage, spotlight, litHere, focus, region]);
170
+
171
+ // ── What the person is looking at ────────────────────────────────────
172
+ const report = stage?.report;
173
+ useEffect(() => {
174
+ if (!report || !docPath) return;
175
+ const rect = boardRef.current?.getBoundingClientRect();
176
+ const a = screenToWorld(viewport, { x: 0, y: 0 });
177
+ const b = rect ? screenToWorld(viewport, { x: rect.width, y: rect.height }) : a;
178
+ report({
179
+ doc: docPath,
180
+ kind,
181
+ system: urlParam("system") ?? undefined,
182
+ focus,
183
+ ...(rect?.width ? { visible: { x: Math.round(a.x), y: Math.round(a.y), width: Math.round(b.x - a.x), height: Math.round(b.y - a.y) } } : {}),
184
+ });
185
+ }, [report, docPath, kind, focus, viewport, boardRef]);
186
+
187
+ const copy = (): void => {
188
+ if (!docPath) return;
189
+ const text = formatReview(stage?.lastReport() ?? null, [{ doc: docPath, marks: marks.filter((m) => m.status !== "resolved") }]);
190
+ navigator.clipboard?.writeText(text).then(() => {
191
+ setCopied(true);
192
+ setTimeout(() => setCopied(false), 2000);
193
+ });
194
+ };
195
+
196
+ if (!docPath) return null;
197
+ const waiting = marks.filter((m) => m.status === "open" && m.author.role === "person").length;
198
+ const questions = marks.filter((m) => m.status === "open" && m.author.role === "agent").length;
199
+ const selectedMark = marks.find((m) => m.id === selected) ?? null;
200
+
201
+ return (
202
+ <>
203
+ <StageOverlay
204
+ viewport={viewport}
205
+ boxes={boxes}
206
+ spotlight={spotlight}
207
+ region={region}
208
+ marks={marks}
209
+ selectedMark={selected}
210
+ onSelectMark={(id) => {
211
+ setDraft(null);
212
+ setPanel(false);
213
+ setSelected((s) => (s === id ? null : id));
214
+ }}
215
+ marking={marking}
216
+ onDraft={(d) => {
217
+ setMarking(false);
218
+ setSelected(null);
219
+ setPanel(false);
220
+ setDraft(d);
221
+ }}
222
+ draft={draft}
223
+ />
224
+
225
+ <div className="stage-board-controls">
226
+ <button
227
+ type="button"
228
+ className={`${quietButton} ${marking ? "!border-amber-400 !text-amber-300" : ""}`}
229
+ aria-pressed={marking}
230
+ onClick={() => {
231
+ setDraft(null);
232
+ setMarking((m) => !m);
233
+ }}
234
+ title={marking ? "Marking: tap an element, or drag around several. Tap ✎ again to stop." : "Mark something for the agent — tap an element or drag around several"}
235
+ >
236
+ <MarkIcon />
237
+ {marking && <span className="text-xs">marking</span>}
238
+ </button>
239
+ <button
240
+ type="button"
241
+ className={quietButton}
242
+ aria-expanded={panel}
243
+ onClick={() => {
244
+ setDraft(null);
245
+ setSelected(null);
246
+ setPanel((p) => !p);
247
+ }}
248
+ title="Marks on this drawing"
249
+ >
250
+ <MarksIcon />
251
+ {waiting + questions > 0 && <span className="text-xs text-amber-300">{waiting + questions}</span>}
252
+ </button>
253
+ </div>
254
+
255
+ {marking && !draft && (
256
+ <div className="stage-board-hint" role="status">
257
+ Tap an element, or drag around several, to say something about it.
258
+ </div>
259
+ )}
260
+
261
+ {(draft || selectedMark || panel) && (
262
+ <div className="stage-board-card">
263
+ {draft ? (
264
+ <MarkComposer
265
+ key={`${draft.elements.join(",")}|${draft.region ? JSON.stringify(draft.region) : ""}`}
266
+ elements={draft.elements}
267
+ region={draft.region}
268
+ author={stage?.author ?? ""}
269
+ onAuthorChange={stage?.setAuthor}
270
+ listener={stage?.listener}
271
+ listenAtOnce={!!draft.listen}
272
+ busy={busy}
273
+ error={error}
274
+ onCancel={() => {
275
+ setDraft(null);
276
+ setError(null);
277
+ }}
278
+ onSubmit={async ({ text, kind: markKind, author }: { text: string; kind: MarkKind; author: string }) => {
279
+ const name = author || "someone";
280
+ const ok = await write({
281
+ action: "add",
282
+ mark: { text, kind: markKind, elements: draft.elements, region: draft.region, level, author: { name } },
283
+ });
284
+ if (ok) setDraft(null);
285
+ }}
286
+ />
287
+ ) : selectedMark ? (
288
+ <MarkThread
289
+ mark={selectedMark}
290
+ number={marks.indexOf(selectedMark) + 1}
291
+ listener={stage?.listener}
292
+ busy={busy}
293
+ error={error}
294
+ onClose={() => setSelected(null)}
295
+ onReply={(text) => void write({ action: "reply", id: selectedMark.id, text, author: { name: stage?.author || "someone" } })}
296
+ onResolve={() => void write({ action: "resolve", id: selectedMark.id })}
297
+ onReopen={() => void write({ action: "reopen", id: selectedMark.id })}
298
+ onRemove={async () => {
299
+ if (await write({ action: "remove", id: selectedMark.id })) setSelected(null);
300
+ }}
301
+ />
302
+ ) : (
303
+ <MarksPanel
304
+ marks={marks}
305
+ selected={selected}
306
+ onSelect={(id) => {
307
+ setPanel(false);
308
+ setSelected(id);
309
+ }}
310
+ onCopy={copy}
311
+ copied={copied}
312
+ onClose={() => setPanel(false)}
313
+ />
314
+ )}
315
+ </div>
316
+ )}
317
+ </>
318
+ );
319
+ }
320
+
321
+ /**
322
+ * For a board that is not drawn right now (the sequence's list view): when the
323
+ * step on stage lights something in this drawing — a spotlight or an area, not
324
+ * a focus, which the list shows by itself — ask for the drawing.
325
+ */
326
+ export function StageReveal({ docPath, onReveal }: { docPath: string | null; onReveal: () => void }) {
327
+ const stage = useStageContext();
328
+ const lit = stage?.lit ?? null;
329
+ const done = useRef(-1);
330
+ useEffect(() => {
331
+ if (!lit || done.current === lit.nonce) return;
332
+ if (lit.doc && lit.doc !== docPath) return;
333
+ if (!lit.spotlight?.length && !lit.region) return;
334
+ done.current = lit.nonce;
335
+ onReveal();
336
+ }, [lit, docPath, onReveal]);
337
+ return null;
338
+ }
@@ -0,0 +1,257 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The page's stage (Spec 83): one connection to the stage this designer
5
+ * presents on, whichever workspace is open.
6
+ *
7
+ * It joins `?stage=` (else the server's `STATELOOM_STAGE`, else `default`) and
8
+ * does what a shown step asks of the page as a whole: open the step's drawing
9
+ * (inside its system, when it names one) and focus its element — the same
10
+ * `openInSystem` an agent's `show` uses. What is lit inside the drawing — the
11
+ * spotlight, the area — is handed to whichever `StageLayer` the open
12
+ * workspace mounts, because only it knows where its elements are.
13
+ *
14
+ * It also holds what every layer shares: the caption and its controls, the
15
+ * voice the person may turn on, the ears a mark may be spoken into, the name
16
+ * marks are signed with, and the line back to the agents — what the person is
17
+ * looking at, and that a drawing's marks changed.
18
+ */
19
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
20
+ import { browserDictation, browserVoice, useStage, type StageShown, type UseStage } from "@miadi/stateloom-react";
21
+ import { StageCaption, type ListenerLike } from "@miadi/stateloom-canvas";
22
+ import { DEFAULT_STAGE, isStageName, type Box, type MarksEnvelope, type StageContext } from "@miadi/stateloom-protocol";
23
+ import { loadRuntimeConfig } from "@/lib/runtimeConfig";
24
+ import { requestedDoc } from "@/lib/docParam";
25
+ import { FOCUS_REQUEST_EVENT, openInSystem, urlParam } from "@/lib/systemParam";
26
+ import CompanionLayer from "./CompanionLayer";
27
+
28
+ /** What the step on stage lights inside its drawing. `doc` absent: whatever drawing is open. */
29
+ export interface Lit {
30
+ doc?: string;
31
+ focus?: string;
32
+ spotlight?: string[];
33
+ region?: Box;
34
+ nonce: number;
35
+ }
36
+
37
+ /** What a layer reports about the board it sits on. */
38
+ export type BoardReport = Omit<StageContext, "stage" | "at" | "tour" | "who">;
39
+
40
+ export interface StageValue {
41
+ stage: string;
42
+ connected: boolean;
43
+ lit: Lit | null;
44
+ author: string;
45
+ setAuthor: (name: string) => void;
46
+ listener: ListenerLike | null;
47
+ /** A layer asks to mark what is lit: moves when the person presses Answer on the caption. */
48
+ answerNonce: number;
49
+ report: (board: BoardReport) => void;
50
+ lastReport: () => StageContext | null;
51
+ sendMarks: (env: Omit<MarksEnvelope, "origin" | "stage">) => void;
52
+ onMarks: (cb: (e: MarksEnvelope) => void) => () => void;
53
+ /** What is on stage now: a live step, or a tour's step with its place. */
54
+ shown: StageShown | null;
55
+ /** Play a tour from this page in its own voice — a companion's reply (Spec 84). */
56
+ present: UseStage["present"];
57
+ pause: () => void;
58
+ stop: () => void;
59
+ }
60
+
61
+ const StageCtx = createContext<StageValue | null>(null);
62
+
63
+ export function useStageContext(): StageValue | null {
64
+ return useContext(StageCtx);
65
+ }
66
+
67
+ const VOICE_KEY = "stateloom.stage.voice";
68
+ const AUTHOR_KEY = "stateloom.author";
69
+
70
+ function readLocal(key: string): string {
71
+ try {
72
+ return window.localStorage.getItem(key) ?? "";
73
+ } catch {
74
+ return "";
75
+ }
76
+ }
77
+
78
+ function writeLocal(key: string, value: string): void {
79
+ try {
80
+ window.localStorage.setItem(key, value);
81
+ } catch {
82
+ // Private mode: the choice holds for this page only.
83
+ }
84
+ }
85
+
86
+ /** Ask the open workspace to select and centre `focus` on the drawing already open. */
87
+ function requestFocus(focus: string): void {
88
+ const url = new URL(window.location.href);
89
+ url.searchParams.set("focus", focus);
90
+ window.history.replaceState(window.history.state, "", url);
91
+ window.dispatchEvent(new Event(FOCUS_REQUEST_EVENT));
92
+ }
93
+
94
+ export default function StageProvider({ children }: { children: ReactNode }) {
95
+ const [config, setConfig] = useState<{ bridgeUrl: string | null; stage: string; companion: boolean; companionEpisode: string | null }>({
96
+ bridgeUrl: null,
97
+ stage: DEFAULT_STAGE,
98
+ companion: false,
99
+ companionEpisode: null,
100
+ });
101
+ useEffect(() => {
102
+ let live = true;
103
+ loadRuntimeConfig().then((c) => {
104
+ if (!live) return;
105
+ const asked = urlParam("stage");
106
+ const stage = isStageName(asked) ? asked : isStageName(c.stage) ? c.stage : DEFAULT_STAGE;
107
+ setConfig({ bridgeUrl: c.bridgeUrl, stage, companion: c.companion, companionEpisode: c.companionEpisode });
108
+ });
109
+ return () => {
110
+ live = false;
111
+ };
112
+ }, []);
113
+
114
+ // Read once from this browser. The provider mounts only after the page has
115
+ // read its URL (never in the server's render), so there is nothing to hydrate.
116
+ const [voiceOn, setVoiceOn] = useState(() => typeof window !== "undefined" && readLocal(VOICE_KEY) === "1");
117
+ const [author, setAuthorState] = useState(() => (typeof window !== "undefined" ? readLocal(AUTHOR_KEY) : ""));
118
+ const voice = useMemo(() => (voiceOn ? browserVoice() : null), [voiceOn]);
119
+ const listener = useMemo<ListenerLike | null>(() => (typeof window === "undefined" ? null : browserDictation()), []);
120
+
121
+ const [lit, setLit] = useState<Lit | null>(null);
122
+ const marksListeners = useRef(new Set<(e: MarksEnvelope) => void>());
123
+
124
+ const show = useCallback((shown: StageShown | null) => {
125
+ if (!shown) {
126
+ setLit(null);
127
+ return;
128
+ }
129
+ const { step } = shown;
130
+ const open = requestedDoc();
131
+ // A page opened on a drawing keeps it when it only catches up with what the stage held:
132
+ // the caption says what waits, and ▶ opens it. A step sent now always opens its drawing.
133
+ if (shown.caught && urlParam("doc")) {
134
+ setLit({ doc: step.doc, focus: step.focus, spotlight: step.spotlight, region: step.region, nonce: shown.nonce });
135
+ return;
136
+ }
137
+ if (step.doc && step.doc !== open) {
138
+ openInSystem({ doc: step.doc, system: step.system ?? undefined, focus: step.focus ?? null });
139
+ } else if (step.focus) {
140
+ if (step.system && urlParam("system") !== step.system && open) openInSystem({ doc: open, system: step.system, focus: step.focus });
141
+ else requestFocus(step.focus);
142
+ }
143
+ setLit({ doc: step.doc, focus: step.focus, spotlight: step.spotlight, region: step.region, nonce: shown.nonce });
144
+ }, []);
145
+
146
+ const stage = useStage({
147
+ url: config.bridgeUrl ?? "",
148
+ stage: config.stage,
149
+ name: "web-stage",
150
+ voice,
151
+ enabled: !!config.bridgeUrl,
152
+ onShow: show,
153
+ onMarks: (e) => {
154
+ for (const cb of [...marksListeners.current]) cb(e);
155
+ },
156
+ });
157
+
158
+ // What the person is looking at, told to the agents a moment after it settles.
159
+ const last = useRef<StageContext | null>(null);
160
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
161
+ const sendContext = stage.sendContext;
162
+ const report = useCallback(
163
+ (board: BoardReport) => {
164
+ const ctx: StageContext = { ...board, stage: config.stage, who: author || undefined, at: new Date().toISOString() };
165
+ last.current = ctx;
166
+ if (timer.current) clearTimeout(timer.current);
167
+ timer.current = setTimeout(() => sendContext({ ...board, who: author || undefined }), 400);
168
+ },
169
+ [config.stage, author, sendContext],
170
+ );
171
+ useEffect(() => () => {
172
+ if (timer.current) clearTimeout(timer.current);
173
+ }, []);
174
+ // A tour moving is news too: say it again with the tour's place.
175
+ useEffect(() => {
176
+ if (last.current) sendContext({ ...last.current, who: author || undefined });
177
+ }, [stage.index, stage.playing, stage.tour, sendContext, author]);
178
+
179
+ const [answerNonce, setAnswerNonce] = useState(0);
180
+
181
+ // How tall the caption stands, for what must sit above it on a phone (the Talk dock).
182
+ const captionUp = !!stage.shown;
183
+ useEffect(() => {
184
+ const root = document.documentElement;
185
+ const caption = captionUp ? document.querySelector<HTMLElement>(".stage-caption-host .slc-stage-caption") : null;
186
+ if (!caption || typeof ResizeObserver === "undefined") {
187
+ root.style.removeProperty("--stage-caption-h");
188
+ return;
189
+ }
190
+ const measure = () => root.style.setProperty("--stage-caption-h", `${Math.ceil(caption.getBoundingClientRect().height) + 12}px`);
191
+ const watch = new ResizeObserver(measure);
192
+ watch.observe(caption);
193
+ measure();
194
+ return () => {
195
+ watch.disconnect();
196
+ root.style.removeProperty("--stage-caption-h");
197
+ };
198
+ }, [captionUp]);
199
+
200
+ const value = useMemo<StageValue>(
201
+ () => ({
202
+ stage: config.stage,
203
+ connected: stage.status === "connected",
204
+ lit,
205
+ author,
206
+ setAuthor: (name: string) => {
207
+ setAuthorState(name);
208
+ writeLocal(AUTHOR_KEY, name);
209
+ },
210
+ listener,
211
+ answerNonce,
212
+ report,
213
+ lastReport: () => last.current,
214
+ sendMarks: stage.sendMarks,
215
+ onMarks: (cb) => {
216
+ marksListeners.current.add(cb);
217
+ return () => {
218
+ marksListeners.current.delete(cb);
219
+ };
220
+ },
221
+ shown: stage.shown,
222
+ present: stage.present,
223
+ pause: stage.pause,
224
+ stop: stage.stop,
225
+ }),
226
+ [config.stage, stage.status, stage.shown, stage.sendMarks, stage.present, stage.pause, stage.stop, lit, author, listener, answerNonce, report],
227
+ );
228
+
229
+ return (
230
+ <StageCtx.Provider value={value}>
231
+ {children}
232
+ {stage.shown && (
233
+ <div className="stage-caption-host">
234
+ <StageCaption
235
+ shown={stage.shown}
236
+ playing={stage.playing}
237
+ speaking={stage.speaking}
238
+ voice={stage.ownVoice ? undefined : voiceOn}
239
+ onToggleVoice={() => {
240
+ const next = !voiceOn;
241
+ setVoiceOn(next);
242
+ writeLocal(VOICE_KEY, next ? "1" : "0");
243
+ }}
244
+ onPlay={stage.play}
245
+ onPause={stage.pause}
246
+ onNext={stage.next}
247
+ onPrev={stage.prev}
248
+ onGoto={stage.goto}
249
+ onClose={stage.dismiss}
250
+ onAnswer={() => setAnswerNonce((n) => n + 1)}
251
+ />
252
+ </div>
253
+ )}
254
+ {config.companion && <CompanionLayer stage={value} defaultEpisode={config.companionEpisode} />}
255
+ </StageCtx.Provider>
256
+ );
257
+ }
@@ -1,6 +1,6 @@
1
1
  import { closeSync, constants, lstatSync, openSync, readdirSync, readlinkSync, realpathSync, statSync, writeSync } from "fs";
2
2
  import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
3
- import { envAlias } from "@miadi/stateloom-protocol";
3
+ import { envAlias, isLoomSidecar } from "@miadi/stateloom-protocol";
4
4
 
5
5
  export function getProjectFilePath(): string {
6
6
  return resolve(envAlias("PROJECT_FILE") ?? "./statemachine.smdf.json");
@@ -262,6 +262,8 @@ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; tr
262
262
  continue;
263
263
  }
264
264
  if (!entry.endsWith(".json") || !real.endsWith(".json")) continue;
265
+ // A drawing's marks and a tour are the loom's, but they are not drawings (Spec 83).
266
+ if (isLoomSidecar(entry)) continue;
265
267
  if (docs.length >= limit) {
266
268
  truncated = true;
267
269
  return;
@@ -298,7 +300,7 @@ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; tr
298
300
  continue; // no diagrams/ folder: nothing of this episode is admitted
299
301
  }
300
302
  for (const file of files) {
301
- if (file.startsWith(".") || !file.endsWith(".json")) continue;
303
+ if (file.startsWith(".") || !file.endsWith(".json") || isLoomSidecar(file)) continue;
302
304
  const full = join(dir, file);
303
305
  let real: string;
304
306
  let stat;
@@ -17,6 +17,12 @@
17
17
  export interface RuntimeConfig {
18
18
  bridgeUrl: string | null;
19
19
  projectFile: string | null;
20
+ /** The stage agents present on (Spec 83), when the server names one. */
21
+ stage: string | null;
22
+ /** A companion answers behind `/api/companion` (Spec 84). */
23
+ companion: boolean;
24
+ /** The episode the Talk dock speaks in when the page names none. */
25
+ companionEpisode: string | null;
20
26
  }
21
27
 
22
28
  /**
@@ -47,12 +53,15 @@ export function loadRuntimeConfig(): Promise<RuntimeConfig> {
47
53
  // build — it just has nothing to add.
48
54
  bridgeUrl: body.bridgeUrl || fallback,
49
55
  projectFile: body.projectFile ?? null,
56
+ stage: body.stage ?? null,
57
+ companion: body.companion === true,
58
+ companionEpisode: body.companionEpisode ?? null,
50
59
  };
51
60
  }
52
61
  } catch {
53
62
  // Older server, or none reachable: fall back to whatever the build knew.
54
63
  }
55
- return { bridgeUrl: fallback, projectFile: null };
64
+ return { bridgeUrl: fallback, projectFile: null, stage: null, companion: false, companionEpisode: null };
56
65
  })();
57
66
  return pending;
58
67
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.2.6",
3
- "designer": "0.2.6",
2
+ "package": "0.3.1",
3
+ "designer": "0.3.1",
4
4
  "next": "16.1.6"
5
5
  }