@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
@@ -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;
@@ -54,8 +72,22 @@ export interface StageValue {
54
72
  shown: StageShown | null;
55
73
  /** Play a tour from this page in its own voice — a companion's reply (Spec 84). */
56
74
  present: UseStage["present"];
75
+ /** A companion answers behind `/api/companion` (Spec 84): the mark cards say who answers. */
76
+ companion: boolean;
57
77
  pause: () => void;
58
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;
59
91
  }
60
92
 
61
93
  const StageCtx = createContext<StageValue | null>(null);
@@ -83,6 +115,24 @@ function writeLocal(key: string, value: string): void {
83
115
  }
84
116
  }
85
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
+
86
136
  /** Ask the open workspace to select and centre `focus` on the drawing already open. */
87
137
  function requestFocus(focus: string): void {
88
138
  const url = new URL(window.location.href);
@@ -120,6 +170,16 @@ export default function StageProvider({ children }: { children: ReactNode }) {
120
170
 
121
171
  const [lit, setLit] = useState<Lit | null>(null);
122
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
+ }, []);
123
183
 
124
184
  const show = useCallback((shown: StageShown | null) => {
125
185
  if (!shown) {
@@ -135,13 +195,14 @@ export default function StageProvider({ children }: { children: ReactNode }) {
135
195
  return;
136
196
  }
137
197
  if (step.doc && step.doc !== open) {
198
+ setOpening(step.doc);
138
199
  openInSystem({ doc: step.doc, system: step.system ?? undefined, focus: step.focus ?? null });
139
200
  } else if (step.focus) {
140
201
  if (step.system && urlParam("system") !== step.system && open) openInSystem({ doc: open, system: step.system, focus: step.focus });
141
202
  else requestFocus(step.focus);
142
203
  }
143
204
  setLit({ doc: step.doc, focus: step.focus, spotlight: step.spotlight, region: step.region, nonce: shown.nonce });
144
- }, []);
205
+ }, [setOpening]);
145
206
 
146
207
  const stage = useStage({
147
208
  url: config.bridgeUrl ?? "",
@@ -178,8 +239,49 @@ export default function StageProvider({ children }: { children: ReactNode }) {
178
239
 
179
240
  const [answerNonce, setAnswerNonce] = useState(0);
180
241
 
181
- // How tall the caption stands, for what must sit above it on a phone (the Talk dock).
182
- 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;
183
285
  useEffect(() => {
184
286
  const root = document.documentElement;
185
287
  const caption = captionUp ? document.querySelector<HTMLElement>(".stage-caption-host .slc-stage-caption") : null;
@@ -220,36 +322,58 @@ export default function StageProvider({ children }: { children: ReactNode }) {
220
322
  },
221
323
  shown: stage.shown,
222
324
  present: stage.present,
325
+ companion: config.companion,
223
326
  pause: stage.pause,
224
327
  stop: stage.stop,
328
+ board,
329
+ setBoard,
330
+ narrow,
331
+ sheet,
332
+ claimSheet,
333
+ releaseSheet,
334
+ sheetHandle,
335
+ sheetBody,
225
336
  }),
226
- [config.stage, 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
+ ],
227
341
  );
228
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
+
229
365
  return (
230
366
  <StageCtx.Provider value={value}>
231
367
  {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
- />
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>}
252
374
  </div>
375
+ ) : (
376
+ caption && <div className="stage-caption-host">{caption}</div>
253
377
  )}
254
378
  {config.companion && <CompanionLayer stage={value} defaultEpisode={config.companionEpisode} />}
255
379
  </StageCtx.Provider>
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Whether a Mia seat listens in the episode a drawing belongs to (Spec 83, Spec 84),
5
+ * for the line the mark cards show under what a person writes and reads.
6
+ *
7
+ * Read from the companion's thread through this designer's proxy
8
+ * (`/api/companion/thread?episode=`), the same presence the Talk dock shows: listening,
9
+ * answering, answered, away. Polled every 10 s while a mark card is open and the page is
10
+ * visible; what was last read is kept while the cards are closed, so a card opens with
11
+ * its line. Null when there is no episode, before the first answer, and when the
12
+ * companion does not answer.
13
+ */
14
+ import { useEffect, useState } from "react";
15
+ import type { CompanionPresence } from "@miadi/stateloom-react";
16
+
17
+ export type PresenceState = CompanionPresence["state"];
18
+
19
+ const EVERY_MS = 10_000;
20
+
21
+ export function useEpisodePresence(episode: string | null, active: boolean): PresenceState | null {
22
+ const [seen, setSeen] = useState<{ episode: string; state: PresenceState | null } | null>(null);
23
+
24
+ useEffect(() => {
25
+ if (!episode || !active) return;
26
+ let live = true;
27
+ const read = async (): Promise<void> => {
28
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
29
+ try {
30
+ const r = await fetch(`/api/companion/thread?episode=${encodeURIComponent(episode)}`, { cache: "no-store" });
31
+ const body = (await r.json()) as { success?: boolean; presence?: CompanionPresence | null };
32
+ if (live) setSeen({ episode, state: body.success ? (body.presence?.state ?? null) : null });
33
+ } catch {
34
+ if (live) setSeen({ episode, state: null });
35
+ }
36
+ };
37
+ void read();
38
+ const t = setInterval(() => void read(), EVERY_MS);
39
+ return () => {
40
+ live = false;
41
+ clearInterval(t);
42
+ };
43
+ }, [episode, active]);
44
+
45
+ return seen && seen.episode === episode ? seen.state : null;
46
+ }
47
+
48
+ /** "2026-10-19-episode-412-the-loom" → 412. */
49
+ export function episodeNumber(episode: string): number | null {
50
+ const m = episode.match(/-episode-(\d+)-/);
51
+ return m ? Number(m[1]) : null;
52
+ }
53
+
54
+ /** The line under the mark cards: who answers a mark left on a drawing of `episode`. */
55
+ export function presenceLine(state: PresenceState | null, episode: string | null): string | null {
56
+ if (!state || !episode) return null;
57
+ const n = episodeNumber(episode);
58
+ const where = n === null ? "this episode" : `episode ${n}`;
59
+ switch (state) {
60
+ case "listening":
61
+ return `Mia is listening in ${where}: she answers here`;
62
+ case "answering":
63
+ return "Mia is answering";
64
+ case "answered":
65
+ return `Mia has answered in ${where}: your mark waits until she listens again`;
66
+ case "away":
67
+ return `No seat is listening in ${where}: your mark waits until one does`;
68
+ default:
69
+ return null;
70
+ }
71
+ }
@@ -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>
@@ -0,0 +1,24 @@
1
+ "use client";
2
+
3
+ /**
4
+ * "Open that mark": something outside the board (the Talk dock's list of marks) asks the
5
+ * board showing `doc` to open the thread of mark `id`. The request waits here until that
6
+ * board has read its marks — the drawing may still be opening — and is taken once.
7
+ */
8
+ export const MARK_REQUEST_EVENT = "stateloom:mark-request";
9
+
10
+ let pending: { doc: string; id: string } | null = null;
11
+
12
+ export function requestMark(doc: string, id: string): void {
13
+ pending = { doc, id };
14
+ window.dispatchEvent(new Event(MARK_REQUEST_EVENT));
15
+ }
16
+
17
+ /** The mark asked for on `doc`, if any; it stays asked until `clearMarkRequest`. */
18
+ export function markRequested(doc: string | null): string | null {
19
+ return doc && pending?.doc === doc ? pending.id : null;
20
+ }
21
+
22
+ export function clearMarkRequest(): void {
23
+ pending = null;
24
+ }
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ /**
6
+ * A phone held upright: below 600 px the stage's caption, a mark's card and the Talk
7
+ * conversation share one sheet at the bottom of the board (Spec 83, lane U finding U2).
8
+ */
9
+ export const NARROW_QUERY = "(max-width: 599px)";
10
+
11
+ function matches(query: string): boolean {
12
+ try {
13
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(query).matches;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ export function useNarrow(query = NARROW_QUERY): boolean {
20
+ const [narrow, setNarrow] = useState(() => matches(query));
21
+ useEffect(() => {
22
+ if (typeof window.matchMedia !== "function") return;
23
+ const mq = window.matchMedia(query);
24
+ const update = () => setNarrow(mq.matches);
25
+ update();
26
+ mq.addEventListener("change", update);
27
+ return () => mq.removeEventListener("change", update);
28
+ }, [query]);
29
+ return narrow;
30
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.3.1",
3
- "designer": "0.3.1",
2
+ "package": "0.3.4",
3
+ "designer": "0.3.4",
4
4
  "next": "16.1.6"
5
5
  }