@miadi/stateloom-web 0.3.1 → 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 (79) 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 +60 -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 +287 -28
  64. package/server/src/components/stage/StageProvider.tsx +149 -25
  65. package/server/src/components/stage/useEpisodePresence.ts +71 -0
  66. package/server/src/components/system/SystemWorkspace.tsx +26 -12
  67. package/server/src/lib/markRequest.ts +24 -0
  68. package/server/src/lib/useNarrow.ts +30 -0
  69. package/server/stateloom-web.json +2 -2
  70. package/server/tsconfig.tsbuildinfo +1 -1
  71. package/server/.next/server/chunks/ssr/[root-of-the-server]__9e661f75._.js +0 -3
  72. package/server/.next/server/chunks/ssr/[root-of-the-server]__f25b51be._.js +0 -3
  73. package/server/.next/static/chunks/0d24f3e3dbdef25e.css +0 -2
  74. package/server/.next/static/chunks/662d1b0ce457c16b.js +0 -1
  75. package/server/.next/static/chunks/96818f5620f039a1.js +0 -5
  76. package/server/.next/static/chunks/ec729c54327cc597.js +0 -14
  77. /package/server/.next/static/{pbn7-8jVWdRn8FLy4fHnU → ZoijkpoWD861NG-cIGJrO}/_buildManifest.js +0 -0
  78. /package/server/.next/static/{pbn7-8jVWdRn8FLy4fHnU → ZoijkpoWD861NG-cIGJrO}/_clientMiddlewareManifest.json +0 -0
  79. /package/server/.next/static/{pbn7-8jVWdRn8FLy4fHnU → ZoijkpoWD861NG-cIGJrO}/_ssgManifest.js +0 -0
@@ -12,16 +12,54 @@
12
12
  * marks an area and what is inside it — and the composer asks what the agent
13
13
  * should know. Answer on the caption opens the composer on what is lit.
14
14
  * - What the person is looking at goes back to the agents through the stage.
15
+ * - Who answers a mark: under the composer, the panel, and a thread whose last
16
+ * word is the person's, one line says whether a Mia seat listens in the
17
+ * drawing's chronicle episode (Spec 84's presence). No line when the drawing
18
+ * is not in an episode or no companion is configured.
19
+ * - When an agent answers: the speech bubble counts what waits on the person
20
+ * ("1 for you") before what waits on the agent (quiet); a thread with an
21
+ * agent's word not yet opened is "new" on its pin and first in the list
22
+ * (read state per drawing in this browser); and a `marks:changed` carrying
23
+ * the answer shows one line by the controls for a few seconds ("Mia answered
24
+ * mark 3"), which opens the thread when tapped. Nothing takes focus and no
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.
15
36
  *
16
37
  * The sidecar is written through /api/marks; the hub is told after.
17
38
  */
18
39
  import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
19
- import { MarkComposer, MarkIcon, MarksIcon, MarkThread, MarksPanel, StageOverlay, type MarkDraft } from "@miadi/stateloom-canvas";
40
+ import { createPortal } from "react-dom";
20
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";
54
+ import {
55
+ agentWordIn,
56
+ agentWordText,
21
57
  boxesOf,
22
- fitToBoxes,
58
+ markCounts,
59
+ unreadMarks,
23
60
  formatReview,
24
61
  screenToWorld,
62
+ type Box,
25
63
  type DocKind,
26
64
  type ElementBox,
27
65
  type Mark,
@@ -29,7 +67,10 @@ import {
29
67
  type Viewport,
30
68
  } from "@miadi/stateloom-protocol";
31
69
  import { useStageContext } from "./StageProvider";
70
+ import { episodeOf } from "./CompanionLayer";
71
+ import { presenceLine, useEpisodePresence } from "./useEpisodePresence";
32
72
  import { urlParam } from "@/lib/systemParam";
73
+ import { MARK_REQUEST_EVENT, clearMarkRequest, markRequested } from "@/lib/markRequest";
33
74
 
34
75
  export interface StageLayerProps {
35
76
  /** The drawing this board shows, absolute. Null until the workspace has read it. */
@@ -48,10 +89,25 @@ export interface StageLayerProps {
48
89
  * level, a sequence shown as a list. The workspace brings it on.
49
90
  */
50
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>>;
51
94
  }
52
95
 
96
+ /** How long the line that says an agent answered stays by the controls. */
97
+ const NOTICE_MS = 7000;
98
+
53
99
  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";
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
+ }
55
111
 
56
112
  async function postMarks(docPath: string, body: unknown): Promise<{ marks: Mark[]; change: { action: "add" | "reply" | "resolve" | "reopen" | "remove"; id: string } }> {
57
113
  const r = await fetch(`/api/marks?doc=${encodeURIComponent(docPath)}`, {
@@ -64,7 +120,7 @@ async function postMarks(docPath: string, body: unknown): Promise<{ marks: Mark[
64
120
  return data;
65
121
  }
66
122
 
67
- 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) {
68
124
  const stage = useStageContext();
69
125
  const [marks, setMarks] = useState<Mark[]>([]);
70
126
  const [marking, setMarking] = useState(false);
@@ -74,6 +130,31 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
74
130
  const [busy, setBusy] = useState(false);
75
131
  const [error, setError] = useState<string | null>(null);
76
132
  const [copied, setCopied] = useState(false);
133
+ const [notice, setNotice] = useState<{ id: string; text: string } | null>(null);
134
+ const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
135
+ const selectedRef = useRef<string | null>(null);
136
+ selectedRef.current = selected;
137
+ const { read, markRead } = useMarksRead(docPath);
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
+ );
77
158
 
78
159
  // ── This drawing's marks ─────────────────────────────────────────────
79
160
  const load = useCallback(() => {
@@ -94,10 +175,23 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
94
175
  useEffect(
95
176
  () =>
96
177
  stage?.onMarks((e) => {
97
- if (docPath && e.docId === docPath && Array.isArray(e.marks)) setMarks(e.marks);
178
+ if (!docPath || e.docId !== docPath || !Array.isArray(e.marks)) return;
179
+ setMarks(e.marks);
180
+ // An agent's answer (or question) reaches a person who is not reading that thread: one line, for a while.
181
+ const word = agentWordIn(e);
182
+ if (!word || word.mark.id === selectedRef.current) return;
183
+ setNotice({ id: word.mark.id, text: agentWordText(word) });
184
+ if (noticeTimer.current) clearTimeout(noticeTimer.current);
185
+ noticeTimer.current = setTimeout(() => setNotice(null), NOTICE_MS);
98
186
  }),
99
187
  [stage, docPath],
100
188
  );
189
+ useEffect(() => {
190
+ setNotice(null);
191
+ return () => {
192
+ if (noticeTimer.current) clearTimeout(noticeTimer.current);
193
+ };
194
+ }, [docPath]);
101
195
 
102
196
  const write = async (body: unknown): Promise<boolean> => {
103
197
  if (!docPath) return false;
@@ -137,26 +231,62 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
137
231
  return;
138
232
  }
139
233
  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
- };
234
+ // The caption covers the bottom of the board: fit into what it leaves.
235
+ const fit = (): void => frame(targets);
149
236
  fit();
150
237
  // Again after the workspace's own fit or centring has landed. Not cleared
151
238
  // when the board's boxes move under it (a live update arrives just after a
152
239
  // drawing opens) — only when the layer goes.
153
240
  if (refit.current) clearTimeout(refit.current);
154
241
  refit.current = setTimeout(fit, 520);
155
- }, [litHere, litBoxes, region, spotlight, boardRef, setViewport, onReveal]);
242
+ }, [litHere, litBoxes, region, spotlight, frame, onReveal]);
156
243
  useEffect(() => () => {
157
244
  if (refit.current) clearTimeout(refit.current);
158
245
  }, []);
159
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
+
160
290
  // Answer on the caption: a composer on what is lit (or the element in hand).
161
291
  const answered = useRef(stage?.answerNonce ?? 0);
162
292
  useEffect(() => {
@@ -193,10 +323,118 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
193
323
  });
194
324
  };
195
325
 
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;
326
+ // ── Who answers a mark ───────────────────────────────────────────────
327
+ const episode = stage?.companion ? episodeOf(docPath) : null;
328
+ const presence = useEpisodePresence(episode, !!(draft || selected || panel));
329
+ const note = presenceLine(presence, episode);
330
+
331
+ // ── What waits on whom, and what is new ─────────────────────────────
332
+ const unread = useMemo(() => unreadMarks(marks, read).map((m) => m.id), [marks, read]);
333
+ const counts = markCounts(marks, read);
199
334
  const selectedMark = marks.find((m) => m.id === selected) ?? null;
335
+ // A thread on screen is read, and stays read as replies land in it.
336
+ const selectedReplies = selectedMark?.replies?.length ?? 0;
337
+ useEffect(() => {
338
+ if (selectedMark) markRead(selectedMark);
339
+ // eslint-disable-next-line react-hooks/exhaustive-deps
340
+ }, [selectedMark?.id, selectedReplies, markRead]);
341
+ const openThread = useCallback((id: string): void => {
342
+ setDraft(null);
343
+ setPanel(false);
344
+ setMarking(false);
345
+ setSelected(id);
346
+ setNotice(null);
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]);
436
+
437
+ if (!docPath) return null;
200
438
 
201
439
  return (
202
440
  <>
@@ -206,6 +444,7 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
206
444
  spotlight={spotlight}
207
445
  region={region}
208
446
  marks={marks}
447
+ unread={unread}
209
448
  selectedMark={selected}
210
449
  onSelectMark={(id) => {
211
450
  setDraft(null);
@@ -217,12 +456,19 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
217
456
  setMarking(false);
218
457
  setSelected(null);
219
458
  setPanel(false);
459
+ setNotice(null);
220
460
  setDraft(d);
221
461
  }}
222
462
  draft={draft}
463
+ avoid={avoid}
223
464
  />
224
465
 
225
- <div className="stage-board-controls">
466
+ <div className="stage-board-controls" ref={controlsRef}>
467
+ {notice && (
468
+ <MarkNotice className="stage-board-notice" onOpen={() => openThread(notice.id)} onDismiss={() => setNotice(null)}>
469
+ {notice.text}
470
+ </MarkNotice>
471
+ )}
226
472
  <button
227
473
  type="button"
228
474
  className={`${quietButton} ${marking ? "!border-amber-400 !text-amber-300" : ""}`}
@@ -243,24 +489,30 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
243
489
  onClick={() => {
244
490
  setDraft(null);
245
491
  setSelected(null);
492
+ setNotice(null);
246
493
  setPanel((p) => !p);
247
494
  }}
248
- title="Marks on this drawing"
495
+ title={`Marks on this drawing${counts.forYou ? ` · ${counts.forYou} for you` : ""}${counts.forAgent ? ` · ${counts.forAgent} waiting on the agent` : ""}`}
496
+ aria-label={`Marks on this drawing: ${counts.forYou} for you${counts.unread ? ` (${counts.unread} new)` : ""}, ${counts.forAgent} waiting on the agent`}
249
497
  >
250
498
  <MarksIcon />
251
- {waiting + questions > 0 && <span className="text-xs text-amber-300">{waiting + questions}</span>}
499
+ {counts.forYou > 0 && (
500
+ <span className={`stage-for-you${counts.unread ? " stage-for-you--new" : ""}`}>
501
+ {counts.forYou} for you
502
+ </span>
503
+ )}
504
+ {counts.forAgent > 0 && <span className="text-xs text-gray-400">{counts.forAgent}</span>}
252
505
  </button>
253
506
  </div>
254
507
 
255
- {marking && !draft && (
508
+ {marking && !draft && !notice && (
256
509
  <div className="stage-board-hint" role="status">
257
510
  Tap an element, or drag around several, to say something about it.
258
511
  </div>
259
512
  )}
260
513
 
261
- {(draft || selectedMark || panel) && (
262
- <div className="stage-board-card">
263
- {draft ? (
514
+ {cardOpen && (() => {
515
+ const card = draft ? (
264
516
  <MarkComposer
265
517
  key={`${draft.elements.join(",")}|${draft.region ? JSON.stringify(draft.region) : ""}`}
266
518
  elements={draft.elements}
@@ -271,6 +523,8 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
271
523
  listenAtOnce={!!draft.listen}
272
524
  busy={busy}
273
525
  error={error}
526
+ note={note}
527
+ labels={labels}
274
528
  onCancel={() => {
275
529
  setDraft(null);
276
530
  setError(null);
@@ -291,6 +545,8 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
291
545
  listener={stage?.listener}
292
546
  busy={busy}
293
547
  error={error}
548
+ note={note}
549
+ labels={labels}
294
550
  onClose={() => setSelected(null)}
295
551
  onReply={(text) => void write({ action: "reply", id: selectedMark.id, text, author: { name: stage?.author || "someone" } })}
296
552
  onResolve={() => void write({ action: "resolve", id: selectedMark.id })}
@@ -302,6 +558,7 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
302
558
  ) : (
303
559
  <MarksPanel
304
560
  marks={marks}
561
+ unread={unread}
305
562
  selected={selected}
306
563
  onSelect={(id) => {
307
564
  setPanel(false);
@@ -309,11 +566,13 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
309
566
  }}
310
567
  onCopy={copy}
311
568
  copied={copied}
569
+ note={note}
570
+ labels={labels}
312
571
  onClose={() => setPanel(false)}
313
572
  />
314
- )}
315
- </div>
316
- )}
573
+ );
574
+ return narrow && stage?.sheetBody ? createPortal(card, stage.sheetBody) : <div className="stage-board-card">{card}</div>;
575
+ })()}
317
576
  </>
318
577
  );
319
578
  }