@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.
- package/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +5 -5
- package/server/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/server/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/server/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/server/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/marks/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page/build-manifest.json +3 -3
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/ssr/{2374f_next_dist_0c464f98._.js → 2374f_next_dist_491bc9ca._.js} +2 -2
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__385c85a7._.js → [root-of-the-server]__0d719a8a._.js} +2 -2
- package/server/.next/server/chunks/ssr/[root-of-the-server]__199d50b6._.js +3 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__5c28bab6._.js +3 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +6 -6
- package/server/.next/server/middleware-build-manifest.js +3 -3
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/static/chunks/33df24ad2d3a04e7.js +1 -0
- package/server/.next/static/chunks/6052c4bfdd66ef5c.css +2 -0
- package/server/.next/static/chunks/b0af2862e9bb3e18.js +14 -0
- package/server/.next/static/chunks/b71bf89a3bbb72e1.js +5 -0
- package/server/.next/static/chunks/{turbopack-b3cae9131ae5086f.js → turbopack-3a774f5a5a8a9023.js} +1 -1
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/app/globals.css +60 -2
- package/server/src/app/page.tsx +8 -6
- package/server/src/components/ChromeIcon.tsx +106 -0
- package/server/src/components/PropertiesPanel.tsx +3 -2
- package/server/src/components/Toolbar.tsx +37 -20
- package/server/src/components/erd/ErdWorkspace.tsx +2 -1
- package/server/src/components/sequence/SequenceWorkspace.tsx +10 -1
- package/server/src/components/stage/CompanionLayer.tsx +41 -6
- package/server/src/components/stage/StageLayer.tsx +287 -28
- package/server/src/components/stage/StageProvider.tsx +149 -25
- package/server/src/components/stage/useEpisodePresence.ts +71 -0
- package/server/src/components/system/SystemWorkspace.tsx +26 -12
- package/server/src/lib/markRequest.ts +24 -0
- package/server/src/lib/useNarrow.ts +30 -0
- package/server/stateloom-web.json +2 -2
- package/server/tsconfig.tsbuildinfo +1 -1
- package/server/.next/server/chunks/ssr/[root-of-the-server]__9e661f75._.js +0 -3
- package/server/.next/server/chunks/ssr/[root-of-the-server]__f25b51be._.js +0 -3
- package/server/.next/static/chunks/0d24f3e3dbdef25e.css +0 -2
- package/server/.next/static/chunks/662d1b0ce457c16b.js +0 -1
- package/server/.next/static/chunks/96818f5620f039a1.js +0 -5
- package/server/.next/static/chunks/ec729c54327cc597.js +0 -14
- /package/server/.next/static/{pbn7-8jVWdRn8FLy4fHnU → ZoijkpoWD861NG-cIGJrO}/_buildManifest.js +0 -0
- /package/server/.next/static/{pbn7-8jVWdRn8FLy4fHnU → ZoijkpoWD861NG-cIGJrO}/_clientMiddlewareManifest.json +0 -0
- /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
|
-
//
|
|
182
|
-
const
|
|
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
|
-
[
|
|
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
|
-
{
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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)}
|
|
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
|
+
}
|