@miadi/stateloom-web 0.2.5 → 0.3.0

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 (100) hide show
  1. package/package.json +1 -1
  2. package/server/.next/BUILD_ID +1 -1
  3. package/server/.next/app-path-routes-manifest.json +2 -0
  4. package/server/.next/build-manifest.json +4 -4
  5. package/server/.next/prerender-manifest.json +3 -3
  6. package/server/.next/routes-manifest.json +16 -1
  7. package/server/.next/server/app/_global-error/page/build-manifest.json +2 -2
  8. package/server/.next/server/app/_global-error.html +2 -2
  9. package/server/.next/server/app/_global-error.rsc +1 -1
  10. package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/server/.next/server/app/_not-found/page/build-manifest.json +2 -2
  16. package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/server/.next/server/app/_not-found.html +1 -1
  18. package/server/.next/server/app/_not-found.rsc +2 -2
  19. package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  20. package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  21. package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  22. package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  23. package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  24. package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  25. package/server/.next/server/app/api/companion/[...path]/route/app-paths-manifest.json +3 -0
  26. package/server/.next/server/app/api/companion/[...path]/route/build-manifest.json +11 -0
  27. package/server/.next/server/app/api/companion/[...path]/route/server-reference-manifest.json +4 -0
  28. package/server/.next/server/app/api/companion/[...path]/route.js +7 -0
  29. package/server/.next/server/app/api/companion/[...path]/route.js.map +5 -0
  30. package/server/.next/server/app/api/companion/[...path]/route.js.nft.json +1 -0
  31. package/server/.next/server/app/api/companion/[...path]/route_client-reference-manifest.js +2 -0
  32. package/server/.next/server/app/api/docs/route.js +1 -1
  33. package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
  34. package/server/.next/server/app/api/file/route.js +1 -1
  35. package/server/.next/server/app/api/file/route.js.nft.json +1 -1
  36. package/server/.next/server/app/api/marks/route/app-paths-manifest.json +3 -0
  37. package/server/.next/server/app/api/marks/route/build-manifest.json +11 -0
  38. package/server/.next/server/app/api/marks/route/server-reference-manifest.json +4 -0
  39. package/server/.next/server/app/api/marks/route.js +8 -0
  40. package/server/.next/server/app/api/marks/route.js.map +5 -0
  41. package/server/.next/server/app/api/marks/route.js.nft.json +1 -0
  42. package/server/.next/server/app/api/marks/route_client-reference-manifest.js +2 -0
  43. package/server/.next/server/app/api/rispec/route.js +1 -1
  44. package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
  45. package/server/.next/server/app/api/watch/route.js +1 -1
  46. package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
  47. package/server/.next/server/app/index.html +1 -1
  48. package/server/.next/server/app/index.rsc +3 -3
  49. package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  50. package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
  51. package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
  52. package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
  53. package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  54. package/server/.next/server/app/page/build-manifest.json +2 -2
  55. package/server/.next/server/app/page.js.nft.json +1 -1
  56. package/server/.next/server/app/page_client-reference-manifest.js +1 -1
  57. package/server/.next/server/app-paths-manifest.json +2 -0
  58. package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +1 -1
  59. package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
  60. package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
  61. package/server/.next/server/chunks/[root-of-the-server]__8b8844cb._.js +1 -1
  62. package/server/.next/server/chunks/[root-of-the-server]__8d9e281e._.js +3 -0
  63. package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +2 -2
  64. package/server/.next/server/chunks/[root-of-the-server]__aedef6d1._.js +3 -0
  65. package/server/.next/server/chunks/bridge-protocol_dist_index_179d02a5.js +1 -1
  66. package/server/.next/server/chunks/ssr/{[root-of-the-server]__b1b2bf74._.js → [root-of-the-server]__9e661f75._.js} +1 -1
  67. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +7 -4
  68. package/server/.next/server/chunks/web__next-internal_server_app_api_companion_[___path]_route_actions_cfae35e7.js +3 -0
  69. package/server/.next/server/chunks/web__next-internal_server_app_api_marks_route_actions_6f18e4f5.js +3 -0
  70. package/server/.next/server/middleware-build-manifest.js +2 -2
  71. package/server/.next/server/pages/404.html +1 -1
  72. package/server/.next/server/pages/500.html +2 -2
  73. package/server/.next/server/server-reference-manifest.js +1 -1
  74. package/server/.next/server/server-reference-manifest.json +1 -1
  75. package/server/.next/static/chunks/0d24f3e3dbdef25e.css +2 -0
  76. package/server/.next/static/chunks/{5e1795a806bd6c51.js → d54b3ced5d4f7ae5.js} +1 -1
  77. package/server/.next/static/chunks/f03f883bab5194ab.js +14 -0
  78. package/server/.next/static/chunks/{turbopack-34fc7de1568e2a7f.js → turbopack-b3cae9131ae5086f.js} +1 -1
  79. package/server/package-lock.json +2 -2
  80. package/server/package.json +1 -1
  81. package/server/src/app/api/companion/[...path]/route.ts +112 -0
  82. package/server/src/app/api/config/route.ts +6 -0
  83. package/server/src/app/api/marks/route.ts +146 -0
  84. package/server/src/app/globals.css +94 -0
  85. package/server/src/app/page.tsx +13 -6
  86. package/server/src/components/erd/ErdWorkspace.tsx +7 -1
  87. package/server/src/components/sequence/SequenceReading.tsx +186 -0
  88. package/server/src/components/sequence/SequenceWorkspace.tsx +126 -13
  89. package/server/src/components/stage/CompanionLayer.tsx +373 -0
  90. package/server/src/components/stage/MachineStageLayer.tsx +56 -0
  91. package/server/src/components/stage/StageLayer.tsx +338 -0
  92. package/server/src/components/stage/StageProvider.tsx +257 -0
  93. package/server/src/lib/projectFile.ts +4 -2
  94. package/server/src/lib/runtimeConfig.ts +10 -1
  95. package/server/stateloom-web.json +2 -2
  96. package/server/.next/static/chunks/285279863965ba9b.js +0 -11
  97. package/server/.next/static/chunks/c86e96e4d6eeb632.css +0 -2
  98. /package/server/.next/static/{1_kqQZahUkpX4bIvXeeqX → HNER2j3kS6-hB98CL_HRG}/_buildManifest.js +0 -0
  99. /package/server/.next/static/{1_kqQZahUkpX4bIvXeeqX → HNER2j3kS6-hB98CL_HRG}/_clientMiddlewareManifest.json +0 -0
  100. /package/server/.next/static/{1_kqQZahUkpX4bIvXeeqX → HNER2j3kS6-hB98CL_HRG}/_ssgManifest.js +0 -0
@@ -0,0 +1,186 @@
1
+ "use client";
2
+
3
+ /**
4
+ * A sequence read as a list — the view a phone opens on (Spec 81).
5
+ *
6
+ * A sequence with seven participants is wider than a phone at any size its
7
+ * words can be read at: the drawing shows two lifelines, and a message between
8
+ * two others is a line whose label sits off the screen (found 2026-10-01 on
9
+ * DiscussionFromChart). Here every message is a row in story order: who sends
10
+ * it to whom, what is said, and what it does (the event it fires, on which
11
+ * machine, the state it leaves, what it carries). Each fragment is a framed
12
+ * group under its heading. Nothing is cut, nothing needs panning, and each
13
+ * participant keeps one colour so a reader can follow who speaks.
14
+ *
15
+ * Read-only: tapping a row selects it, exactly as tapping it in the drawing
16
+ * does, and the host's panel edits it.
17
+ */
18
+ import type { SequenceDefinition, SqdMessage } from "@miadi/stateloom-protocol";
19
+ import { allMessages } from "@miadi/stateloom-protocol";
20
+ import type { SequenceSelection } from "@miadi/stateloom-canvas";
21
+
22
+ export interface ReadingIssue {
23
+ severity: "error" | "warning";
24
+ text: string;
25
+ }
26
+
27
+ /** Light tints, each above 4.5:1 on gray-900 and gray-950. */
28
+ const SPEAKER_COLOURS = [
29
+ "text-sky-300",
30
+ "text-emerald-300",
31
+ "text-amber-300",
32
+ "text-rose-300",
33
+ "text-violet-300",
34
+ "text-cyan-300",
35
+ "text-lime-300",
36
+ "text-orange-300",
37
+ ];
38
+
39
+ /** The element id a row carries, so a focus can scroll to it. */
40
+ export const readingRowId = (target: SequenceSelection): string => `seq-read-${target.kind}-${target.id}`;
41
+
42
+ export default function SequenceReading({
43
+ definition,
44
+ selection,
45
+ issues,
46
+ onSelect,
47
+ onShowInDiagram,
48
+ }: {
49
+ definition: SequenceDefinition;
50
+ selection: SequenceSelection | null;
51
+ /** Check findings by focus string (`message:9`, `participant:Chart`). */
52
+ issues: ReadonlyMap<string, ReadingIssue[]>;
53
+ onSelect: (target: SequenceSelection) => void;
54
+ onShowInDiagram: (target: SequenceSelection) => void;
55
+ }) {
56
+ const colourOf = new Map(definition.participants.map((p, i) => [p.name, SPEAKER_COLOURS[i % SPEAKER_COLOURS.length]]));
57
+ const messages = allMessages(definition);
58
+ const main = messages.filter((m) => m.fragment === null);
59
+ const fragments = (definition.fragments ?? []).map((f, fi) => ({ f, fi, rows: messages.filter((m) => m.fragment === fi) }));
60
+ const isSelected = (kind: SequenceSelection["kind"], id: string) => selection?.kind === kind && selection.id === id;
61
+
62
+ const name = (who: string) => <span className={`font-semibold ${colourOf.get(who) ?? "text-gray-300"}`}>{who || "?"}</span>;
63
+
64
+ const row = (ref: string, m: SqdMessage) => {
65
+ const target: SequenceSelection = { kind: "message", id: ref };
66
+ const found = issues.get(`message:${ref}`) ?? [];
67
+ const worst = found.some((i) => i.severity === "error") ? "error" : found.length ? "warning" : null;
68
+ const selected = isSelected("message", ref);
69
+ return (
70
+ <li key={ref} id={readingRowId(target)} className="scroll-mt-16 scroll-mb-24">
71
+ <div
72
+ role="button"
73
+ tabIndex={0}
74
+ aria-pressed={selected}
75
+ onClick={() => onSelect(target)}
76
+ onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), onSelect(target))}
77
+ className={`flex gap-3 rounded-lg border-l-2 px-3 py-2.5 ${
78
+ selected ? "bg-blue-950/60 ring-1 ring-blue-500/60" : "hover:bg-gray-900"
79
+ } ${worst === "error" ? "border-red-500" : worst === "warning" ? "border-amber-400" : "border-transparent"}`}
80
+ >
81
+ <span className="mt-0.5 flex h-6 min-w-6 shrink-0 items-center justify-center rounded-full border border-gray-700 bg-gray-900 px-1 font-mono text-[11px] text-gray-300">
82
+ {ref}
83
+ </span>
84
+ <div className="min-w-0 flex-1">
85
+ <div className="text-[13px] leading-5 text-gray-400">
86
+ {name(m.from)}
87
+ {m.from === m.to ? (
88
+ <span> to itself</span>
89
+ ) : (
90
+ <>
91
+ <span className="px-1.5 text-gray-500">{m.reply ? "⇠" : "→"}</span>
92
+ {name(m.to)}
93
+ </>
94
+ )}
95
+ {m.reply && <span className="ml-2 text-[11px] text-gray-500">reply</span>}
96
+ {m.optional && <span className="ml-2 rounded border border-gray-700 px-1 text-[11px] text-gray-400">optional</span>}
97
+ </div>
98
+ <div className={`mt-0.5 break-words text-[15px] leading-6 ${m.reply ? "text-gray-300" : "text-gray-100"}`}>
99
+ {m.label || <span className="text-gray-500">no label</span>}
100
+ </div>
101
+ {(m.event || m.state || m.carries) && (
102
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] leading-5">
103
+ {m.event && (
104
+ <span className="rounded border border-blue-500/50 bg-blue-950/50 px-1.5 font-mono text-blue-200">{m.event}</span>
105
+ )}
106
+ {m.event && m.machine && <span className="text-gray-400">on {m.machine}</span>}
107
+ {m.state && <span className="text-emerald-300">→ {m.state}</span>}
108
+ {m.carries && <span className="text-gray-400">carries {m.carries}</span>}
109
+ </div>
110
+ )}
111
+ {found.map((i, n) => (
112
+ <p key={n} className={`mt-1.5 text-[12px] leading-5 ${i.severity === "error" ? "text-red-300" : "text-amber-300"}`}>
113
+ {i.text}
114
+ </p>
115
+ ))}
116
+ {selected && (
117
+ <button
118
+ className="mt-2 rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-[12px] text-gray-200"
119
+ onClick={(e) => {
120
+ e.stopPropagation();
121
+ onShowInDiagram(target);
122
+ }}
123
+ >
124
+ Show in the diagram
125
+ </button>
126
+ )}
127
+ </div>
128
+ </div>
129
+ </li>
130
+ );
131
+ };
132
+
133
+ return (
134
+ <div className="h-full overflow-y-auto overscroll-contain">
135
+ <div className="mx-auto max-w-2xl px-4 pb-28 pt-10">
136
+ {definition.participants.length > 0 && (
137
+ <ul className="mb-4 flex flex-wrap gap-2" aria-label="Participants">
138
+ {definition.participants.map((p) => {
139
+ const target: SequenceSelection = { kind: "participant", id: p.name };
140
+ const kind = p.actor ? "actor" : p.object ? "object" : p.service ? "service" : "";
141
+ const found = issues.get(`participant:${p.name}`) ?? [];
142
+ return (
143
+ <li key={p.name} id={readingRowId(target)}>
144
+ <button
145
+ onClick={() => onSelect(target)}
146
+ aria-pressed={isSelected("participant", p.name)}
147
+ title={found.map((i) => i.text).join("\n") || undefined}
148
+ className={`rounded-full border px-3 py-1 text-[13px] ${
149
+ isSelected("participant", p.name) ? "border-blue-500 bg-blue-950/60" : "border-gray-700 bg-gray-900"
150
+ } ${found.length ? "ring-1 ring-amber-400/70" : ""}`}
151
+ >
152
+ {name(p.name)}
153
+ {kind && <span className="ml-1.5 text-[11px] text-gray-400">{kind}</span>}
154
+ </button>
155
+ </li>
156
+ );
157
+ })}
158
+ </ul>
159
+ )}
160
+
161
+ {messages.length === 0 && (
162
+ <p className="text-sm text-gray-400">No messages yet — add them in the panel, or let an agent write the scenario.</p>
163
+ )}
164
+
165
+ <ol className="flex flex-col gap-1">{main.map(({ ref, message }) => row(ref, message))}</ol>
166
+
167
+ {fragments.map(({ f, fi, rows }) => (
168
+ <section key={fi} className="mt-5 rounded-xl border border-indigo-500/50 bg-indigo-950/20 p-1.5">
169
+ <header className="px-3 pb-1.5 pt-2">
170
+ <div className="text-[12px] text-indigo-200">
171
+ <span className="font-semibold uppercase tracking-wide">{f.kind}</span>
172
+ <span className="text-indigo-300"> · after {f.after}</span>
173
+ </div>
174
+ <div className="mt-0.5 break-words text-[14px] leading-5 text-gray-100">{f.label}</div>
175
+ </header>
176
+ {rows.length === 0 ? (
177
+ <p className="px-3 pb-2 text-[12px] text-gray-400">No messages in this fragment.</p>
178
+ ) : (
179
+ <ol className="flex flex-col gap-1">{rows.map(({ ref, message }) => row(ref, message))}</ol>
180
+ )}
181
+ </section>
182
+ ))}
183
+ </div>
184
+ </div>
185
+ );
186
+ }
@@ -10,6 +10,10 @@
10
10
  * controls on the board, the panel a bottom sheet behind a dock on a phone and
11
11
  * a right column on a desktop.
12
12
  *
13
+ * On a phone it opens as a list (`SequenceReading`): a sequence wider than the
14
+ * screen cannot be read as a drawing there. List and Diagram switch in the
15
+ * header, and the choice is remembered in this browser.
16
+ *
13
17
  * Inside a system (`?system=` in the URL), the Issues tab adds the system's
14
18
  * findings for this scenario — the names it uses that no member defines, and
15
19
  * the replay of each path against the machines — and the fields that name an
@@ -65,14 +69,19 @@ import IssueIcon from "@/components/IssueIcon";
65
69
  import { NotesField } from "@/components/erd/ErdWorkspace";
66
70
  import { FieldForm, panelButton as button, panelHeading as heading, type FormValues } from "@/components/forms";
67
71
  import KindIcon from "@/components/system/KindIcon";
72
+ import SequenceReading, { readingRowId, type ReadingIssue } from "@/components/sequence/SequenceReading";
68
73
  import { docQuery, useRequestedDoc } from "@/lib/docParam";
69
74
  import { loadRuntimeConfig } from "@/lib/runtimeConfig";
70
75
  import { useSheetDrag } from "@/lib/useSheetDrag";
71
76
  import { safely } from "@/lib/safely";
72
77
  import { stripNote, useFocusRequest, useUrlParam, writeFocus } from "@/lib/systemParam";
78
+ import StageLayer, { StageReveal } from "@/components/stage/StageLayer";
79
+ import { sequenceElementBoxes } from "@miadi/stateloom-protocol";
73
80
  import { memberOfDoc, notifySystemChanged, reconcileNow, systemVocabulary, useSystem } from "@/lib/systemLoad";
74
81
 
75
82
  type Tab = "participants" | "messages" | "notes" | "issues";
83
+ type ViewMode = "diagram" | "list";
84
+ const VIEW_KEY = "stateloom.sequence.view";
76
85
  type Edit = (def: SequenceDefinition) => SequenceDefinition;
77
86
 
78
87
  const KINDS = ["", "actor", "service", "object"] as const;
@@ -124,6 +133,30 @@ export default function SequenceWorkspace() {
124
133
  const [sheetOpen, setSheetOpen] = useState(false);
125
134
  const [menuOpen, setMenuOpen] = useState(false);
126
135
  const [panelHidden, setPanelHidden] = useState(false);
136
+ // Diagram until the browser says otherwise: a remembered choice, else a list on a phone.
137
+ const [mode, setModeState] = useState<ViewMode>("diagram");
138
+ const modeRef = useRef<ViewMode>("diagram");
139
+ useEffect(() => {
140
+ let remembered: string | null = null;
141
+ try {
142
+ remembered = window.localStorage.getItem(VIEW_KEY);
143
+ } catch {
144
+ // Storage refused (a private window): fall back to the screen width.
145
+ }
146
+ const next: ViewMode =
147
+ remembered === "list" || remembered === "diagram" ? remembered : window.matchMedia("(max-width: 767px)").matches ? "list" : "diagram";
148
+ modeRef.current = next;
149
+ setModeState(next);
150
+ }, []);
151
+ const setMode = useCallback((next: ViewMode) => {
152
+ modeRef.current = next;
153
+ setModeState(next);
154
+ try {
155
+ window.localStorage.setItem(VIEW_KEY, next);
156
+ } catch {
157
+ // Not remembered; the switch still works.
158
+ }
159
+ }, []);
127
160
  const boardRef = useRef<HTMLDivElement | null>(null);
128
161
  const sheet = useSheetDrag({ onClose: () => setSheetOpen(false), restVh: 62, tallVh: 92 });
129
162
  const [peers, setPeers] = useState(0);
@@ -184,6 +217,8 @@ export default function SequenceWorkspace() {
184
217
  // shows what it can). Edits still go through the protocol on `def` itself.
185
218
  const view = useMemo(() => (def ? drawableSequence(def) : null), [def]);
186
219
  const layout = useMemo(() => (view ? sequenceLayout(view) : null), [view]);
220
+ const stageBoxes = useMemo(() => (view && layout ? safely(() => sequenceElementBoxes(view, layout), []) : []), [view, layout]);
221
+ const stageFocus = useUrlParam("focus");
187
222
  // A malformed document is reported as one issue, not a crash.
188
223
  const problems = useMemo<SqdValidationError[]>(() => {
189
224
  if (!def) return [];
@@ -224,6 +259,24 @@ export default function SequenceWorkspace() {
224
259
  [memberIssues],
225
260
  );
226
261
 
262
+ // The same findings, by focus string, for the list to print under each row.
263
+ const readingIssues = useMemo(() => {
264
+ const out = new Map<string, ReadingIssue[]>();
265
+ const add = (element: string | undefined, issue: ReadingIssue) => {
266
+ const key = !element
267
+ ? ""
268
+ : /^(message|participant):/.test(element)
269
+ ? element
270
+ : view?.participants.some((p) => p.name === element)
271
+ ? `participant:${element}`
272
+ : "";
273
+ if (key) out.set(key, [...(out.get(key) ?? []), issue]);
274
+ };
275
+ problems.forEach((p) => add(p.element, { severity: "error", text: p.message }));
276
+ memberIssues.forEach((i) => add(i.element, { severity: i.severity === "error" ? "error" : "warning", text: i.message }));
277
+ return out;
278
+ }, [problems, memberIssues, view]);
279
+
227
280
  const boardSize = (): { width: number; height: number } => {
228
281
  const rect = boardRef.current?.getBoundingClientRect();
229
282
  return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
@@ -262,6 +315,17 @@ export default function SequenceWorkspace() {
262
315
  [layout, centreOn],
263
316
  );
264
317
 
318
+ /** Bring a target into view in whichever view is showing. */
319
+ const reveal = useCallback(
320
+ (target: SequenceSelection) => {
321
+ if (modeRef.current === "diagram") return showSelection(target);
322
+ requestAnimationFrame(() =>
323
+ document.getElementById(readingRowId(target))?.scrollIntoView({ block: "center", behavior: "smooth" }),
324
+ );
325
+ },
326
+ [showSelection],
327
+ );
328
+
265
329
  const select = useCallback((target: SequenceSelection | null) => {
266
330
  setSelection(target);
267
331
  if (target) writeFocus(`${target.kind}:${target.id}`);
@@ -279,21 +343,21 @@ export default function SequenceWorkspace() {
279
343
  if (!target) return setStatus(`${focusRequest.focus} is not in this sequence`, "warn");
280
344
  firstView.current = false;
281
345
  setSelection(target);
282
- showSelection(target);
283
- }, [def, view, layout, focusRequest, showSelection, setStatus]);
346
+ reveal(target);
347
+ }, [def, view, layout, focusRequest, reveal, setStatus]);
284
348
 
285
349
  // The first view: the whole drawing when it can be read that way, else its
286
350
  // top-left corner at a readable size (a phone showing seven lifelines at a
287
351
  // quarter of their size shows nothing).
288
352
  useEffect(() => {
289
- if (!layout || !firstView.current) return;
353
+ if (!layout || !firstView.current || mode !== "diagram") return;
290
354
  const { width, height } = boardSize();
291
355
  if (!width) return;
292
356
  firstView.current = false;
293
357
  const fit = Math.min((width - 32) / layout.width, (height - 32) / layout.height);
294
358
  if (fit >= 0.5 || layout.participants.length === 0) setFitKey((k) => (k ?? 0) + 1);
295
359
  else setViewport({ x: 8, y: 8, scale: 0.6 });
296
- }, [layout]);
360
+ }, [layout, mode]);
297
361
 
298
362
  const apply = useCallback(
299
363
  async (edit: Edit) => {
@@ -361,7 +425,7 @@ export default function SequenceWorkspace() {
361
425
  if (!f || (f.kind !== "message" && f.kind !== "participant")) return;
362
426
  const target: SequenceSelection = { kind: f.kind, id: f.name };
363
427
  select(target);
364
- showSelection(target);
428
+ reveal(target);
365
429
  sheet.close();
366
430
  };
367
431
 
@@ -420,6 +484,29 @@ export default function SequenceWorkspace() {
420
484
  {def?.settings.name ?? fileName}
421
485
  <span className="hidden text-gray-600 lg:inline"> · {fileName}</span>
422
486
  </span>
487
+ <div className="flex shrink-0 overflow-hidden rounded border border-gray-700 text-[12px] leading-none" role="group" aria-label="View">
488
+ {(
489
+ [
490
+ ["list", "List", "Read the messages in order, one under the other"],
491
+ ["diagram", "Diagram", "The lifelines and arrows"],
492
+ ] as const
493
+ ).map(([id, label, title]) => (
494
+ <button
495
+ key={id}
496
+ title={title}
497
+ aria-pressed={mode === id}
498
+ onClick={() => {
499
+ if (mode === id) return;
500
+ if (selection) firstView.current = false;
501
+ setMode(id);
502
+ if (selection) requestAnimationFrame(() => reveal(selection));
503
+ }}
504
+ className={`px-2.5 py-1.5 ${mode === id ? "bg-gray-700 text-gray-100" : "bg-gray-800 text-gray-400 hover:text-gray-200"}`}
505
+ >
506
+ {label}
507
+ </button>
508
+ ))}
509
+ </div>
423
510
  <div className="hidden shrink-0 items-center gap-2 md:flex">
424
511
  <button className={button} onClick={copyMermaid}>
425
512
  Copy mermaid
@@ -463,7 +550,23 @@ export default function SequenceWorkspace() {
463
550
 
464
551
  <div className="relative flex min-h-0 flex-1">
465
552
  <div ref={boardRef} className="relative min-w-0 flex-1 overflow-hidden">
466
- {def && layout && (
553
+ {def && layout && mode === "list" && view && (
554
+ <BoardBoundary what="sequence" resetKey={def}>
555
+ <SequenceReading
556
+ definition={view}
557
+ selection={selection}
558
+ issues={readingIssues}
559
+ onSelect={(target) => select(target)}
560
+ onShowInDiagram={(target) => {
561
+ // The message is the first view, not the drawing's corner.
562
+ firstView.current = false;
563
+ setMode("diagram");
564
+ requestAnimationFrame(() => showSelection(target));
565
+ }}
566
+ />
567
+ </BoardBoundary>
568
+ )}
569
+ {def && layout && mode === "diagram" && (
467
570
  <BoardBoundary what="sequence" resetKey={def}>
468
571
  <SequenceCanvas
469
572
  definition={view!}
@@ -480,11 +583,17 @@ export default function SequenceWorkspace() {
480
583
  />
481
584
  </BoardBoundary>
482
585
  )}
586
+ {/* The stage (Spec 83). In the list view nothing is drawn to light, so a
587
+ step that lights something here brings the diagram back. */}
588
+ {def && layout && mode === "diagram" && (
589
+ <StageLayer docPath={docPath || null} kind="sequence" viewport={viewport} setViewport={setViewport} boxes={stageBoxes} boardRef={boardRef} focus={stageFocus} />
590
+ )}
591
+ {def && layout && mode === "list" && <StageReveal docPath={docPath} onReveal={() => setMode("diagram")} />}
483
592
 
484
593
  <div
485
- className={`pointer-events-none absolute left-2 top-2 flex max-w-[75%] items-center gap-1.5 rounded bg-gray-950/70 px-1.5 py-0.5 text-[11px] ${
594
+ className={`pointer-events-none absolute left-2 top-2 max-w-[75%] items-center gap-1.5 rounded bg-gray-950/70 px-1.5 py-0.5 text-[11px] ${
486
595
  status.tone === "warn" ? "text-amber-300" : "text-gray-500"
487
- }`}
596
+ } ${mode === "list" && statusQuiet && status.tone === "ok" ? "hidden" : "flex"}`}
488
597
  role="status"
489
598
  >
490
599
  <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${status.tone === "warn" ? "bg-amber-400" : "bg-emerald-500"}`} />
@@ -494,7 +603,11 @@ export default function SequenceWorkspace() {
494
603
  </span>
495
604
  </div>
496
605
 
497
- <div className="absolute bottom-3 right-3 flex flex-col overflow-hidden rounded-lg border border-gray-700 bg-gray-900/90 shadow-lg backdrop-blur">
606
+ <div
607
+ className={`absolute bottom-3 right-3 flex-col overflow-hidden rounded-lg border border-gray-700 bg-gray-900/90 shadow-lg backdrop-blur ${
608
+ mode === "diagram" ? "flex" : "hidden"
609
+ }`}
610
+ >
498
611
  {(
499
612
  [
500
613
  ["+", "Zoom in", () => zoomBy(1.25)],
@@ -582,7 +695,7 @@ export default function SequenceWorkspace() {
582
695
  className={`${button} ${selection?.kind === "participant" && selection.id === p.name ? "border-blue-500 text-blue-300" : ""}`}
583
696
  onClick={() => {
584
697
  select({ kind: "participant", id: p.name });
585
- showSelection({ kind: "participant", id: p.name });
698
+ reveal({ kind: "participant", id: p.name });
586
699
  }}
587
700
  title={participantKind(p) || "participant"}
588
701
  >
@@ -620,7 +733,7 @@ export default function SequenceWorkspace() {
620
733
  <>
621
734
  <div className={`${heading} flex items-center justify-between gap-2`}>
622
735
  <span className="truncate text-sm normal-case tracking-normal text-gray-200">{selectedParticipant.name}</span>
623
- <button className={`${button} normal-case tracking-normal`} onClick={() => showSelection({ kind: "participant", id: selectedParticipant.name })}>
736
+ <button className={`${button} normal-case tracking-normal`} onClick={() => reveal({ kind: "participant", id: selectedParticipant.name })}>
624
737
  ◎ Show on board
625
738
  </button>
626
739
  </div>
@@ -720,7 +833,7 @@ export default function SequenceWorkspace() {
720
833
  }`}
721
834
  onClick={() => {
722
835
  select({ kind: "message", id: ref });
723
- showSelection({ kind: "message", id: ref });
836
+ reveal({ kind: "message", id: ref });
724
837
  }}
725
838
  title={`${m.from} → ${m.to}: ${m.label}`}
726
839
  >
@@ -767,7 +880,7 @@ export default function SequenceWorkspace() {
767
880
  </button>
768
881
  </>
769
882
  )}
770
- <button className={button} onClick={() => showSelection(selection!)}>
883
+ <button className={button} onClick={() => reveal(selection!)}>
771
884
  ◎ Show
772
885
  </button>
773
886
  </span>