@miadi/stateloom-web 0.3.3 → 0.3.4

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 (78) hide show
  1. package/package.json +1 -1
  2. package/server/.next/BUILD_ID +1 -1
  3. package/server/.next/build-manifest.json +5 -5
  4. package/server/.next/server/app/_global-error/page/build-manifest.json +3 -3
  5. package/server/.next/server/app/_global-error/page.js.nft.json +1 -1
  6. package/server/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  7. package/server/.next/server/app/_global-error.html +2 -2
  8. package/server/.next/server/app/_global-error.rsc +1 -1
  9. package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  10. package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  12. package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/server/.next/server/app/_not-found/page/build-manifest.json +3 -3
  15. package/server/.next/server/app/_not-found/page.js.nft.json +1 -1
  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/docs/route.js.nft.json +1 -1
  26. package/server/.next/server/app/api/file/route.js.nft.json +1 -1
  27. package/server/.next/server/app/api/marks/route.js.nft.json +1 -1
  28. package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
  29. package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
  30. package/server/.next/server/app/index.html +1 -1
  31. package/server/.next/server/app/index.rsc +3 -3
  32. package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  33. package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
  34. package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
  35. package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
  36. package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  37. package/server/.next/server/app/page/build-manifest.json +3 -3
  38. package/server/.next/server/app/page.js.nft.json +1 -1
  39. package/server/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/server/.next/server/chunks/ssr/{2374f_next_dist_0c464f98._.js → 2374f_next_dist_491bc9ca._.js} +2 -2
  41. package/server/.next/server/chunks/ssr/{[root-of-the-server]__385c85a7._.js → [root-of-the-server]__0d719a8a._.js} +2 -2
  42. package/server/.next/server/chunks/ssr/[root-of-the-server]__199d50b6._.js +3 -0
  43. package/server/.next/server/chunks/ssr/[root-of-the-server]__5c28bab6._.js +3 -0
  44. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +6 -6
  45. package/server/.next/server/middleware-build-manifest.js +3 -3
  46. package/server/.next/server/pages/404.html +1 -1
  47. package/server/.next/server/pages/500.html +2 -2
  48. package/server/.next/static/chunks/33df24ad2d3a04e7.js +1 -0
  49. package/server/.next/static/chunks/6052c4bfdd66ef5c.css +2 -0
  50. package/server/.next/static/chunks/b0af2862e9bb3e18.js +14 -0
  51. package/server/.next/static/chunks/b71bf89a3bbb72e1.js +5 -0
  52. package/server/.next/static/chunks/{turbopack-b3cae9131ae5086f.js → turbopack-3a774f5a5a8a9023.js} +1 -1
  53. package/server/package-lock.json +2 -2
  54. package/server/package.json +1 -1
  55. package/server/src/app/globals.css +40 -2
  56. package/server/src/app/page.tsx +8 -6
  57. package/server/src/components/ChromeIcon.tsx +106 -0
  58. package/server/src/components/PropertiesPanel.tsx +3 -2
  59. package/server/src/components/Toolbar.tsx +37 -20
  60. package/server/src/components/erd/ErdWorkspace.tsx +2 -1
  61. package/server/src/components/sequence/SequenceWorkspace.tsx +10 -1
  62. package/server/src/components/stage/CompanionLayer.tsx +41 -6
  63. package/server/src/components/stage/StageLayer.tsx +206 -23
  64. package/server/src/components/stage/StageProvider.tsx +146 -25
  65. package/server/src/components/system/SystemWorkspace.tsx +26 -12
  66. package/server/src/lib/markRequest.ts +24 -0
  67. package/server/src/lib/useNarrow.ts +30 -0
  68. package/server/stateloom-web.json +2 -2
  69. package/server/tsconfig.tsbuildinfo +1 -1
  70. package/server/.next/server/chunks/ssr/[root-of-the-server]__a593e8b6._.js +0 -3
  71. package/server/.next/server/chunks/ssr/[root-of-the-server]__f25b51be._.js +0 -3
  72. package/server/.next/static/chunks/59a1ec89a1dc7e34.js +0 -14
  73. package/server/.next/static/chunks/662d1b0ce457c16b.js +0 -1
  74. package/server/.next/static/chunks/8f45e56f40c207bf.css +0 -2
  75. package/server/.next/static/chunks/96818f5620f039a1.js +0 -5
  76. /package/server/.next/static/{CZ4wRF1efsHnxjkcKHITP → ZoijkpoWD861NG-cIGJrO}/_buildManifest.js +0 -0
  77. /package/server/.next/static/{CZ4wRF1efsHnxjkcKHITP → ZoijkpoWD861NG-cIGJrO}/_clientMiddlewareManifest.json +0 -0
  78. /package/server/.next/static/{CZ4wRF1efsHnxjkcKHITP → ZoijkpoWD861NG-cIGJrO}/_ssgManifest.js +0 -0
@@ -23,20 +23,43 @@
23
23
  * the answer shows one line by the controls for a few seconds ("Mia answered
24
24
  * mark 3"), which opens the thread when tapped. Nothing takes focus and no
25
25
  * card opens by itself.
26
+ * - Pins: on opening a drawing with open marks and nothing lit, the view frames
27
+ * the marks (never below a legible scale); pins stay inside the board, fan out
28
+ * when they share a point, and never sit under the board's buttons or under the
29
+ * caption (the phone's sheet), where they could not be tapped.
30
+ * - On a phone the cards open in the sheet at the bottom of the board, in the
31
+ * caption's place (StageProvider); the Talk conversation and a card take turns.
32
+ * - Elements are named as the person sees them; a board that knows words only it
33
+ * can give (a sequence's messages) passes `labels`.
34
+ * - The Talk dock's list of marks opens a mark here (`requestMark`): the thread
35
+ * opens and the view frames its mark.
26
36
  *
27
37
  * The sidecar is written through /api/marks; the hub is told after.
28
38
  */
29
39
  import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
30
- import { MarkComposer, MarkIcon, MarkNotice, MarksIcon, MarkThread, MarksPanel, StageOverlay, useMarksRead, type MarkDraft } from "@miadi/stateloom-canvas";
40
+ import { createPortal } from "react-dom";
41
+ import {
42
+ fitLegibly,
43
+ markAnchor,
44
+ MarkComposer,
45
+ MarkIcon,
46
+ MarkNotice,
47
+ MarksIcon,
48
+ MarkThread,
49
+ MarksPanel,
50
+ StageOverlay,
51
+ useMarksRead,
52
+ type MarkDraft,
53
+ } from "@miadi/stateloom-canvas";
31
54
  import {
32
55
  agentWordIn,
33
56
  agentWordText,
34
57
  boxesOf,
35
58
  markCounts,
36
59
  unreadMarks,
37
- fitToBoxes,
38
60
  formatReview,
39
61
  screenToWorld,
62
+ type Box,
40
63
  type DocKind,
41
64
  type ElementBox,
42
65
  type Mark,
@@ -47,6 +70,7 @@ import { useStageContext } from "./StageProvider";
47
70
  import { episodeOf } from "./CompanionLayer";
48
71
  import { presenceLine, useEpisodePresence } from "./useEpisodePresence";
49
72
  import { urlParam } from "@/lib/systemParam";
73
+ import { MARK_REQUEST_EVENT, clearMarkRequest, markRequested } from "@/lib/markRequest";
50
74
 
51
75
  export interface StageLayerProps {
52
76
  /** The drawing this board shows, absolute. Null until the workspace has read it. */
@@ -65,13 +89,25 @@ export interface StageLayerProps {
65
89
  * level, a sequence shown as a list. The workspace brings it on.
66
90
  */
67
91
  onReveal?: (focuses: readonly string[]) => void;
92
+ /** Words for elements only this board can name (a sequence's messages), by focus key. */
93
+ labels?: Readonly<Record<string, string>>;
68
94
  }
69
95
 
70
96
  /** How long the line that says an agent answered stays by the controls. */
71
97
  const NOTICE_MS = 7000;
72
98
 
73
99
  const quietButton =
74
- "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";
100
+ "stage-quiet-btn 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";
101
+
102
+ /** How much of the board's bottom the caption, or the phone's sheet, covers. */
103
+ function coveredBottom(rect: DOMRect): number {
104
+ let top = Infinity;
105
+ for (const sel of [".stage-sheet", ".stage-caption-host .slc-stage-caption"]) {
106
+ const r = document.querySelector(sel)?.getBoundingClientRect();
107
+ if (r && r.height > 0 && r.top < rect.bottom) top = Math.min(top, r.top);
108
+ }
109
+ return top === Infinity ? 0 : Math.max(0, rect.bottom - top + 8);
110
+ }
75
111
 
76
112
  async function postMarks(docPath: string, body: unknown): Promise<{ marks: Mark[]; change: { action: "add" | "reply" | "resolve" | "reopen" | "remove"; id: string } }> {
77
113
  const r = await fetch(`/api/marks?doc=${encodeURIComponent(docPath)}`, {
@@ -84,7 +120,7 @@ async function postMarks(docPath: string, body: unknown): Promise<{ marks: Mark[
84
120
  return data;
85
121
  }
86
122
 
87
- export default function StageLayer({ docPath, kind, viewport, setViewport, boxes, boardRef, focus = null, level, onReveal }: StageLayerProps) {
123
+ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes, boardRef, focus = null, level, onReveal, labels }: StageLayerProps) {
88
124
  const stage = useStageContext();
89
125
  const [marks, setMarks] = useState<Mark[]>([]);
90
126
  const [marking, setMarking] = useState(false);
@@ -100,6 +136,26 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
100
136
  selectedRef.current = selected;
101
137
  const { read, markRead } = useMarksRead(docPath);
102
138
 
139
+ // ── The drawing on this board, for the caption ──────────────────────
140
+ const setBoard = stage?.setBoard;
141
+ useEffect(() => {
142
+ if (!setBoard || !docPath) return;
143
+ setBoard(docPath);
144
+ return () => setBoard(null);
145
+ }, [setBoard, docPath]);
146
+
147
+ /** Frame `targets` in what the caption (or the phone's sheet) leaves of the board, legibly. */
148
+ const frame = useCallback(
149
+ (targets: readonly Box[], opts: { floor?: number } = {}) => {
150
+ const rect = boardRef.current?.getBoundingClientRect();
151
+ if (!rect?.width || !targets.length) return;
152
+ const covered = coveredBottom(rect);
153
+ const height = Math.max(rect.height * 0.4, rect.height - covered);
154
+ setViewport(fitLegibly(targets, rect.width, height, { padding: 48, maxScale: 1, floor: opts.floor ?? 0.2 }));
155
+ },
156
+ [boardRef, setViewport],
157
+ );
158
+
103
159
  // ── This drawing's marks ─────────────────────────────────────────────
104
160
  const load = useCallback(() => {
105
161
  if (!docPath) return;
@@ -175,26 +231,62 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
175
231
  return;
176
232
  }
177
233
  fitted.current = litHere.nonce;
178
- const fit = (): void => {
179
- const rect = boardRef.current?.getBoundingClientRect();
180
- if (!rect?.width) return;
181
- // The caption covers the bottom of the board: fit into what it leaves.
182
- const caption = document.querySelector(".stage-caption-host .slc-stage-caption")?.getBoundingClientRect();
183
- const covered = caption && caption.top < rect.bottom ? Math.max(0, rect.bottom - caption.top + 8) : 0;
184
- const height = Math.max(rect.height * 0.4, rect.height - covered);
185
- setViewport(fitToBoxes(targets, rect.width, height, { padding: 48, maxScale: 1 }));
186
- };
234
+ // The caption covers the bottom of the board: fit into what it leaves.
235
+ const fit = (): void => frame(targets);
187
236
  fit();
188
237
  // Again after the workspace's own fit or centring has landed. Not cleared
189
238
  // when the board's boxes move under it (a live update arrives just after a
190
239
  // drawing opens) — only when the layer goes.
191
240
  if (refit.current) clearTimeout(refit.current);
192
241
  refit.current = setTimeout(fit, 520);
193
- }, [litHere, litBoxes, region, spotlight, boardRef, setViewport, onReveal]);
242
+ }, [litHere, litBoxes, region, spotlight, frame, onReveal]);
194
243
  useEffect(() => () => {
195
244
  if (refit.current) clearTimeout(refit.current);
196
245
  }, []);
197
246
 
247
+ // ── Opening a drawing with marks: the view frames them ──────────────
248
+ // Once per drawing, when nothing is lit here (a lit step decides the view): what the marks
249
+ // point at on this level, and the element in hand. Looked at again after the workspace's
250
+ // own first view and this browser's remembered view have landed, and framed again only
251
+ // if that view hides some of them.
252
+ const marksFramed = useRef<string | null>(null);
253
+ const marksRefit = useRef<ReturnType<typeof setTimeout>[]>([]);
254
+ const viewportRef = useRef(viewport);
255
+ viewportRef.current = viewport;
256
+ useEffect(() => {
257
+ if (!docPath || marksFramed.current === docPath) return;
258
+ if (litHere) {
259
+ marksFramed.current = docPath;
260
+ return;
261
+ }
262
+ const open = marks.filter((m) => m.status !== "resolved" && (!m.level || !level || m.level === level));
263
+ if (!open.length || !boxes.length) return;
264
+ // Marks that name elements wait for the board to draw them (a board still showing the
265
+ // layout it had before this drawing arrived draws none of them); an area stands alone.
266
+ const named = open.filter((m) => m.elements?.length);
267
+ const drawn = named.filter((m) => boxesOf(boxes, m.elements!).length > 0);
268
+ if (named.length && !drawn.length) return;
269
+ const anchors = [...drawn, ...open.filter((m) => !m.elements?.length)].map((m) => markAnchor(m, boxes)).filter((b): b is Box => !!b);
270
+ marksFramed.current = docPath;
271
+ if (!anchors.length) return;
272
+ const targets = [...anchors, ...(focus ? boxesOf(boxes, [focus]) : [])];
273
+ const fitIfHidden = (): void => {
274
+ const rect = boardRef.current?.getBoundingClientRect();
275
+ if (!rect?.width) return;
276
+ const vp = viewportRef.current;
277
+ const a = screenToWorld(vp, { x: 0, y: 0 });
278
+ const b = screenToWorld(vp, { x: rect.width, y: rect.height - coveredBottom(rect) });
279
+ const seen = targets.every((t) => t.x >= a.x && t.y >= a.y && t.x + t.width <= b.x && t.y + t.height <= b.y);
280
+ if (!seen) frame(targets, { floor: 0.55 });
281
+ };
282
+ fitIfHidden();
283
+ for (const t of marksRefit.current) clearTimeout(t);
284
+ marksRefit.current = [setTimeout(fitIfHidden, 520), setTimeout(fitIfHidden, 1400)];
285
+ }, [docPath, marks, boxes, litHere, level, focus, frame, boardRef]);
286
+ useEffect(() => () => {
287
+ for (const t of marksRefit.current) clearTimeout(t);
288
+ }, []);
289
+
198
290
  // Answer on the caption: a composer on what is lit (or the element in hand).
199
291
  const answered = useRef(stage?.answerNonce ?? 0);
200
292
  useEffect(() => {
@@ -246,13 +338,101 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
246
338
  if (selectedMark) markRead(selectedMark);
247
339
  // eslint-disable-next-line react-hooks/exhaustive-deps
248
340
  }, [selectedMark?.id, selectedReplies, markRead]);
249
- const openThread = (id: string): void => {
341
+ const openThread = useCallback((id: string): void => {
250
342
  setDraft(null);
251
343
  setPanel(false);
252
344
  setMarking(false);
253
345
  setSelected(id);
254
346
  setNotice(null);
255
- };
347
+ }, []);
348
+
349
+ // ── A mark asked for from outside the board (the Talk dock's list) ──
350
+ useEffect(() => {
351
+ const take = (): void => {
352
+ const id = markRequested(docPath);
353
+ const mark = id ? marks.find((m) => m.id === id) : null;
354
+ if (!mark) return;
355
+ clearMarkRequest();
356
+ openThread(mark.id);
357
+ const at = markAnchor(mark, boxes);
358
+ if (at) setTimeout(() => frame([at], { floor: 0.55 }), 60);
359
+ };
360
+ take();
361
+ window.addEventListener(MARK_REQUEST_EVENT, take);
362
+ return () => window.removeEventListener(MARK_REQUEST_EVENT, take);
363
+ }, [docPath, marks, boxes, openThread, frame]);
364
+
365
+ // ── The phone's sheet: a card holds it in the caption's place ───────
366
+ const cardOpen = !!(draft || selectedMark || panel);
367
+ const claimSheet = stage?.claimSheet;
368
+ const releaseSheet = stage?.releaseSheet;
369
+ useEffect(() => {
370
+ if (cardOpen) claimSheet?.("card");
371
+ else releaseSheet?.("card");
372
+ }, [cardOpen, claimSheet, releaseSheet]);
373
+ useEffect(() => () => releaseSheet?.("card"), [releaseSheet]);
374
+ // The conversation took the sheet: the card gives way to it.
375
+ const narrow = !!stage?.narrow;
376
+ const sheetOwner = stage?.sheet ?? null;
377
+ const [seenOwner, setSeenOwner] = useState(sheetOwner);
378
+ if (seenOwner !== sheetOwner) {
379
+ setSeenOwner(sheetOwner);
380
+ if (narrow && sheetOwner === "talk") {
381
+ setDraft(null);
382
+ setSelected(null);
383
+ setPanel(false);
384
+ }
385
+ }
386
+
387
+ // ── The board's buttons: no pin sits under them ─────────────────────
388
+ const controlsRef = useRef<HTMLDivElement | null>(null);
389
+ const [controlsBox, setControlsBox] = useState<Box | null>(null);
390
+ useEffect(() => {
391
+ const el = controlsRef.current;
392
+ if (!el) return;
393
+ const measure = (): void => {
394
+ const next = { x: el.offsetLeft, y: el.offsetTop, width: el.offsetWidth, height: el.offsetHeight };
395
+ setControlsBox((b) => (b && b.x === next.x && b.y === next.y && b.width === next.width && b.height === next.height ? b : next));
396
+ };
397
+ measure();
398
+ window.addEventListener("resize", measure);
399
+ if (typeof ResizeObserver === "undefined") return () => window.removeEventListener("resize", measure);
400
+ const watch = new ResizeObserver(measure);
401
+ watch.observe(el);
402
+ return () => {
403
+ watch.disconnect();
404
+ window.removeEventListener("resize", measure);
405
+ };
406
+ }, [docPath]);
407
+ // ── And none under the caption, or the phone's sheet: what it covers of the board ──
408
+ const [coverBox, setCoverBox] = useState<Box | null>(null);
409
+ const shownNow = stage?.shown ?? null;
410
+ useEffect(() => {
411
+ const board = boardRef.current;
412
+ if (!board) return;
413
+ const find = () => document.querySelector(".stage-sheet") ?? document.querySelector(".stage-caption-host .slc-stage-caption");
414
+ let frameId = 0;
415
+ const measure = (): void => {
416
+ cancelAnimationFrame(frameId);
417
+ frameId = requestAnimationFrame(() => {
418
+ const b = board.getBoundingClientRect();
419
+ const r = find()?.getBoundingClientRect();
420
+ const next = r && r.height > 0 && r.top < b.bottom ? { x: r.left - b.left, y: r.top - b.top, width: r.width, height: Math.min(r.bottom, b.bottom) - r.top } : null;
421
+ setCoverBox((c) => (c && next && c.x === next.x && c.y === next.y && c.width === next.width && c.height === next.height ? c : next));
422
+ });
423
+ };
424
+ measure();
425
+ const el = find();
426
+ const watch = el && typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
427
+ if (el) watch?.observe(el);
428
+ window.addEventListener("resize", measure);
429
+ return () => {
430
+ cancelAnimationFrame(frameId);
431
+ watch?.disconnect();
432
+ window.removeEventListener("resize", measure);
433
+ };
434
+ }, [boardRef, shownNow, cardOpen, sheetOwner, narrow, docPath]);
435
+ const avoid = useMemo(() => [controlsBox, coverBox].filter((b): b is Box => !!b), [controlsBox, coverBox]);
256
436
 
257
437
  if (!docPath) return null;
258
438
 
@@ -280,9 +460,10 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
280
460
  setDraft(d);
281
461
  }}
282
462
  draft={draft}
463
+ avoid={avoid}
283
464
  />
284
465
 
285
- <div className="stage-board-controls">
466
+ <div className="stage-board-controls" ref={controlsRef}>
286
467
  {notice && (
287
468
  <MarkNotice className="stage-board-notice" onOpen={() => openThread(notice.id)} onDismiss={() => setNotice(null)}>
288
469
  {notice.text}
@@ -330,9 +511,8 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
330
511
  </div>
331
512
  )}
332
513
 
333
- {(draft || selectedMark || panel) && (
334
- <div className="stage-board-card">
335
- {draft ? (
514
+ {cardOpen && (() => {
515
+ const card = draft ? (
336
516
  <MarkComposer
337
517
  key={`${draft.elements.join(",")}|${draft.region ? JSON.stringify(draft.region) : ""}`}
338
518
  elements={draft.elements}
@@ -344,6 +524,7 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
344
524
  busy={busy}
345
525
  error={error}
346
526
  note={note}
527
+ labels={labels}
347
528
  onCancel={() => {
348
529
  setDraft(null);
349
530
  setError(null);
@@ -365,6 +546,7 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
365
546
  busy={busy}
366
547
  error={error}
367
548
  note={note}
549
+ labels={labels}
368
550
  onClose={() => setSelected(null)}
369
551
  onReply={(text) => void write({ action: "reply", id: selectedMark.id, text, author: { name: stage?.author || "someone" } })}
370
552
  onResolve={() => void write({ action: "resolve", id: selectedMark.id })}
@@ -385,11 +567,12 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
385
567
  onCopy={copy}
386
568
  copied={copied}
387
569
  note={note}
570
+ labels={labels}
388
571
  onClose={() => setPanel(false)}
389
572
  />
390
- )}
391
- </div>
392
- )}
573
+ );
574
+ return narrow && stage?.sheetBody ? createPortal(card, stage.sheetBody) : <div className="stage-board-card">{card}</div>;
575
+ })()}
393
576
  </>
394
577
  );
395
578
  }
@@ -15,14 +15,29 @@
15
15
  * voice the person may turn on, the ears a mark may be spoken into, the name
16
16
  * marks are signed with, and the line back to the agents — what the person is
17
17
  * looking at, and that a drawing's marks changed.
18
+ *
19
+ * The caption's heading is the step's own title. When the step is about a
20
+ * drawing that is not the one on the board (a page opened on another drawing
21
+ * caught up with a tour the stage holds), the caption names that drawing,
22
+ * offers "Open and play", and has no Answer: a mark would land on the wrong
23
+ * drawing.
24
+ *
25
+ * On a phone (below 600 px) the bottom of the board holds one sheet: the Talk
26
+ * dock's bar as its handle, then one of the caption, a mark's card (composer,
27
+ * thread, list) or the Talk conversation. A card or the conversation takes the
28
+ * caption's place while it is open, and the caption comes back when it closes.
29
+ * Layers claim the sheet (`claimSheet`) and render into its slots
30
+ * (`sheetHandle`, `sheetBody`); on a wider screen there is no sheet and each
31
+ * keeps its own corner.
18
32
  */
19
33
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
20
34
  import { browserDictation, browserVoice, useStage, type StageShown, type UseStage } from "@miadi/stateloom-react";
21
35
  import { StageCaption, type ListenerLike } from "@miadi/stateloom-canvas";
22
- import { DEFAULT_STAGE, isStageName, type Box, type MarksEnvelope, type StageContext } from "@miadi/stateloom-protocol";
36
+ import { DEFAULT_STAGE, isStageName, isSysdfPath, type Box, type MarksEnvelope, type StageContext } from "@miadi/stateloom-protocol";
23
37
  import { loadRuntimeConfig } from "@/lib/runtimeConfig";
24
38
  import { requestedDoc } from "@/lib/docParam";
25
39
  import { FOCUS_REQUEST_EVENT, openInSystem, urlParam } from "@/lib/systemParam";
40
+ import { useNarrow } from "@/lib/useNarrow";
26
41
  import CompanionLayer from "./CompanionLayer";
27
42
 
28
43
  /** What the step on stage lights inside its drawing. `doc` absent: whatever drawing is open. */
@@ -37,6 +52,9 @@ export interface Lit {
37
52
  /** What a layer reports about the board it sits on. */
38
53
  export type BoardReport = Omit<StageContext, "stage" | "at" | "tour" | "who">;
39
54
 
55
+ /** Who holds the phone's sheet in place of the caption: a mark's card, or the Talk conversation. */
56
+ export type SheetOwner = "card" | "talk";
57
+
40
58
  export interface StageValue {
41
59
  stage: string;
42
60
  connected: boolean;
@@ -58,6 +76,18 @@ export interface StageValue {
58
76
  companion: boolean;
59
77
  pause: () => void;
60
78
  stop: () => void;
79
+ /** The drawing on the board now (absolute), as its layer says; null on the system map. */
80
+ board: string | null;
81
+ setBoard: (doc: string | null) => void;
82
+ /** Below 600 px: the caption, the cards and the conversation share one sheet. */
83
+ narrow: boolean;
84
+ /** Who holds the sheet instead of the caption. */
85
+ sheet: SheetOwner | null;
86
+ claimSheet: (owner: SheetOwner) => void;
87
+ releaseSheet: (owner: SheetOwner) => void;
88
+ /** The sheet's slots on a phone: the handle (the Talk bar), and the body (a card, the conversation). */
89
+ sheetHandle: HTMLElement | null;
90
+ sheetBody: HTMLElement | null;
61
91
  }
62
92
 
63
93
  const StageCtx = createContext<StageValue | null>(null);
@@ -85,6 +115,24 @@ function writeLocal(key: string, value: string): void {
85
115
  }
86
116
  }
87
117
 
118
+ /** The drawing's own name (settings.name), read once per drawing; its file name meanwhile. */
119
+ const drawingTitles = new Map<string, Promise<string | null>>();
120
+ function drawingTitle(doc: string): Promise<string | null> {
121
+ let found = drawingTitles.get(doc);
122
+ if (!found) {
123
+ found = fetch(`/api/file?doc=${encodeURIComponent(doc)}`, { cache: "no-store" })
124
+ .then((r) => (r.ok ? r.json() : null))
125
+ .then((data: { content?: string } | null) => {
126
+ const def = data?.content ? JSON.parse(data.content) : null;
127
+ const name = (def?.settings ?? def?.stateMachine?.settings)?.name;
128
+ return typeof name === "string" && name ? name : null;
129
+ })
130
+ .catch(() => null);
131
+ drawingTitles.set(doc, found);
132
+ }
133
+ return found;
134
+ }
135
+
88
136
  /** Ask the open workspace to select and centre `focus` on the drawing already open. */
89
137
  function requestFocus(focus: string): void {
90
138
  const url = new URL(window.location.href);
@@ -122,6 +170,16 @@ export default function StageProvider({ children }: { children: ReactNode }) {
122
170
 
123
171
  const [lit, setLit] = useState<Lit | null>(null);
124
172
  const marksListeners = useRef(new Set<(e: MarksEnvelope) => void>());
173
+ // The drawing on the board, and the one the stage just asked the page to open — asked
174
+ // while `from` was on the board, so it counts only until the board changes.
175
+ const [board, setBoardState] = useState<string | null>(null);
176
+ const [opening, setOpeningState] = useState<{ doc: string; from: string | null } | null>(null);
177
+ const boardNow = useRef<string | null>(null);
178
+ const setOpening = useCallback((doc: string) => setOpeningState({ doc, from: boardNow.current }), []);
179
+ const setBoard = useCallback((doc: string | null) => {
180
+ boardNow.current = doc;
181
+ setBoardState(doc);
182
+ }, []);
125
183
 
126
184
  const show = useCallback((shown: StageShown | null) => {
127
185
  if (!shown) {
@@ -137,13 +195,14 @@ export default function StageProvider({ children }: { children: ReactNode }) {
137
195
  return;
138
196
  }
139
197
  if (step.doc && step.doc !== open) {
198
+ setOpening(step.doc);
140
199
  openInSystem({ doc: step.doc, system: step.system ?? undefined, focus: step.focus ?? null });
141
200
  } else if (step.focus) {
142
201
  if (step.system && urlParam("system") !== step.system && open) openInSystem({ doc: open, system: step.system, focus: step.focus });
143
202
  else requestFocus(step.focus);
144
203
  }
145
204
  setLit({ doc: step.doc, focus: step.focus, spotlight: step.spotlight, region: step.region, nonce: shown.nonce });
146
- }, []);
205
+ }, [setOpening]);
147
206
 
148
207
  const stage = useStage({
149
208
  url: config.bridgeUrl ?? "",
@@ -180,8 +239,49 @@ export default function StageProvider({ children }: { children: ReactNode }) {
180
239
 
181
240
  const [answerNonce, setAnswerNonce] = useState(0);
182
241
 
183
- // How tall the caption stands, for what must sit above it on a phone (the Talk dock).
184
- const captionUp = !!stage.shown;
242
+ // ── The phone's sheet ────────────────────────────────────────────────
243
+ const narrow = useNarrow();
244
+ const [sheet, setSheet] = useState<SheetOwner | null>(null);
245
+ const claimSheet = useCallback((owner: SheetOwner) => setSheet(owner), []);
246
+ const releaseSheet = useCallback((owner: SheetOwner) => setSheet((s) => (s === owner ? null : s)), []);
247
+ const [sheetHandle, setSheetHandle] = useState<HTMLElement | null>(null);
248
+ const [sheetBody, setSheetBody] = useState<HTMLElement | null>(null);
249
+
250
+ // ── A step about a drawing that is not on the board ─────────────────
251
+ const shownStep = stage.shown?.step ?? null;
252
+ // Until the board's layer has said which drawing it shows, the page's `?doc=` stands for it.
253
+ const asked = urlParam("doc");
254
+ const onBoard = board ?? (asked && !isSysdfPath(asked) ? asked : null);
255
+ const openingDoc = opening && opening.from === board ? opening.doc : null;
256
+ const heldDoc = shownStep?.doc && shownStep.doc !== onBoard && shownStep.doc !== openingDoc ? shownStep.doc : null;
257
+ const [heldTitle, setHeldTitle] = useState<{ doc: string; title: string | null } | null>(null);
258
+ useEffect(() => {
259
+ if (!heldDoc) return;
260
+ let live = true;
261
+ void drawingTitle(heldDoc).then((title) => live && setHeldTitle({ doc: heldDoc, title }));
262
+ return () => {
263
+ live = false;
264
+ };
265
+ }, [heldDoc]);
266
+ const play = stage.play;
267
+ const held = useMemo(() => {
268
+ if (!heldDoc || !shownStep) return null;
269
+ const title = heldTitle?.doc === heldDoc ? heldTitle.title : null;
270
+ const drawing = title ?? (heldDoc.split("/").pop() ?? heldDoc).replace(/\.(smdf|erdf|sqdf|sysdf)\.json$/i, "").replace(/\.json$/i, "");
271
+ const inTour = stage.shown?.index !== null && stage.shown?.index !== undefined;
272
+ return {
273
+ drawing,
274
+ label: inTour ? "Open and play" : "Open the drawing",
275
+ onOpen: () => {
276
+ setOpening(heldDoc);
277
+ openInSystem({ doc: heldDoc, system: shownStep.system ?? undefined, focus: shownStep.focus ?? null });
278
+ if (inTour) play();
279
+ },
280
+ };
281
+ }, [heldDoc, heldTitle, shownStep, stage.shown?.index, play, setOpening]);
282
+
283
+ // How tall the caption stands, for what must sit above it beside the board (the Talk dock).
284
+ const captionUp = !!stage.shown && !narrow;
185
285
  useEffect(() => {
186
286
  const root = document.documentElement;
187
287
  const caption = captionUp ? document.querySelector<HTMLElement>(".stage-caption-host .slc-stage-caption") : null;
@@ -225,34 +325,55 @@ export default function StageProvider({ children }: { children: ReactNode }) {
225
325
  companion: config.companion,
226
326
  pause: stage.pause,
227
327
  stop: stage.stop,
328
+ board,
329
+ setBoard,
330
+ narrow,
331
+ sheet,
332
+ claimSheet,
333
+ releaseSheet,
334
+ sheetHandle,
335
+ sheetBody,
228
336
  }),
229
- [config.stage, config.companion, stage.status, stage.shown, stage.sendMarks, stage.present, stage.pause, stage.stop, lit, author, listener, answerNonce, report],
337
+ [
338
+ config.stage, config.companion, stage.status, stage.shown, stage.sendMarks, stage.present, stage.pause, stage.stop, lit, author, listener, answerNonce, report,
339
+ board, setBoard, narrow, sheet, claimSheet, releaseSheet, sheetHandle, sheetBody,
340
+ ],
230
341
  );
231
342
 
343
+ const caption = stage.shown ? (
344
+ <StageCaption
345
+ shown={stage.shown}
346
+ playing={stage.playing}
347
+ speaking={stage.speaking}
348
+ voice={stage.ownVoice ? undefined : voiceOn}
349
+ onToggleVoice={() => {
350
+ const next = !voiceOn;
351
+ setVoiceOn(next);
352
+ writeLocal(VOICE_KEY, next ? "1" : "0");
353
+ }}
354
+ onPlay={stage.play}
355
+ onPause={stage.pause}
356
+ onNext={stage.next}
357
+ onPrev={stage.prev}
358
+ onGoto={stage.goto}
359
+ onClose={stage.dismiss}
360
+ onAnswer={() => setAnswerNonce((n) => n + 1)}
361
+ held={held}
362
+ />
363
+ ) : null;
364
+
232
365
  return (
233
366
  <StageCtx.Provider value={value}>
234
367
  {children}
235
- {stage.shown && (
236
- <div className="stage-caption-host">
237
- <StageCaption
238
- shown={stage.shown}
239
- playing={stage.playing}
240
- speaking={stage.speaking}
241
- voice={stage.ownVoice ? undefined : voiceOn}
242
- onToggleVoice={() => {
243
- const next = !voiceOn;
244
- setVoiceOn(next);
245
- writeLocal(VOICE_KEY, next ? "1" : "0");
246
- }}
247
- onPlay={stage.play}
248
- onPause={stage.pause}
249
- onNext={stage.next}
250
- onPrev={stage.prev}
251
- onGoto={stage.goto}
252
- onClose={stage.dismiss}
253
- onAnswer={() => setAnswerNonce((n) => n + 1)}
254
- />
368
+ {narrow ? (
369
+ // One sheet at the bottom of the board: the handle, then the caption or what took its place.
370
+ <div className="stage-sheet">
371
+ <div className="stage-sheet-handle" ref={setSheetHandle} />
372
+ <div className="stage-sheet-body" ref={setSheetBody} />
373
+ {!sheet && caption && <div className="stage-sheet-caption">{caption}</div>}
255
374
  </div>
375
+ ) : (
376
+ caption && <div className="stage-caption-host">{caption}</div>
256
377
  )}
257
378
  {config.companion && <CompanionLayer stage={value} defaultEpisode={config.companionEpisode} />}
258
379
  </StageCtx.Provider>
@@ -9,7 +9,9 @@
9
9
  * A card opens its member inside the system (`?doc=<member>&system=<this>`),
10
10
  * where the strip keeps every drawing one tap away.
11
11
  *
12
- * The panel: Members (what each is, and the links between each pair), Checks
12
+ * The panel: Members (what each is, and the links between each pair; a member's
13
+ * note shows when it has words, and an empty one waits behind "Add a note" so the
14
+ * panel opens on what the system holds, not on empty boxes), Checks
13
15
  * (`checkSystem` grouped by rule, errors first, then the replay of every path
14
16
  * of every scenario, and the reconcile mode with the changes the scenarios
15
17
  * imply), and Notes. Same shell as the other workspaces: one header row, the
@@ -109,6 +111,8 @@ export default function SystemWorkspace() {
109
111
  const [menuOpen, setMenuOpen] = useState(false);
110
112
  const [panelHidden, setPanelHidden] = useState(false);
111
113
  const [pair, setPair] = useState<string | null>(null);
114
+ // Members whose empty note the person asked to write.
115
+ const [noting, setNoting] = useState<ReadonlySet<string>>(() => new Set());
112
116
  const [status, setStatusState] = useState<{ text: string; tone: "ok" | "warn" }>({ text: "loading…", tone: "ok" });
113
117
  const setStatus = useCallback((text: string, tone: "ok" | "warn" = "ok") => setStatusState({ text, tone }), []);
114
118
  const [busy, setBusy] = useState(false);
@@ -530,16 +534,26 @@ export default function SystemWorkspace() {
530
534
  </span>
531
535
  <span className="shrink-0 text-[11px] text-gray-500">open ›</span>
532
536
  </button>
533
- {/* The system's note about this member — kept in the .sysdf.json, not in the member. */}
534
- <div className="px-2 pb-2 [&_label]:mt-0">
535
- <NotesField
536
- key={`member:${m}`}
537
- label="Note on this member, in the system"
538
- value={note ?? ""}
539
- placeholder={`Why ${memberTitle(system, m)} is part of this system, what is open…`}
540
- onSave={(notes) => void writeSystem(updateMember(def, m, { notes }), "saved")}
541
- />
542
- </div>
537
+ {/* The system's note about this member — kept in the .sysdf.json, not in the member.
538
+ Shown when it has words; an empty one opens on request. */}
539
+ {note || noting.has(m) ? (
540
+ <div className="px-2 pb-2 [&_label]:mt-0">
541
+ <NotesField
542
+ key={`member:${m}`}
543
+ label="Note on this member, in the system"
544
+ value={note ?? ""}
545
+ placeholder={`Why ${memberTitle(system, m)} is part of this system, what is open…`}
546
+ onSave={(notes) => void writeSystem(updateMember(def, m, { notes }), "saved")}
547
+ />
548
+ </div>
549
+ ) : (
550
+ <button
551
+ className="mx-2 mb-1.5 text-[11px] text-gray-500 hover:text-gray-300"
552
+ onClick={() => setNoting((n) => new Set(n).add(m))}
553
+ >
554
+ Add a note on this member
555
+ </button>
556
+ )}
543
557
  </div>
544
558
  );
545
559
  })}
@@ -553,7 +567,7 @@ export default function SystemWorkspace() {
553
567
  onClick={() => setPair(pair === key ? null : key)}
554
568
  >
555
569
  <span className="truncate">
556
- {memberTitle(system, p.a)} ↔ {memberTitle(system, p.b)}
570
+ {memberTitle(system, p.a)} {"\u2194\uFE0E"} {memberTitle(system, p.b)}
557
571
  </span>
558
572
  <span className="shrink-0 text-gray-500">{p.links.length}</span>
559
573
  </button>