@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.
- package/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/app-path-routes-manifest.json +2 -0
- package/server/.next/build-manifest.json +4 -4
- package/server/.next/prerender-manifest.json +3 -3
- package/server/.next/routes-manifest.json +16 -1
- package/server/.next/server/app/_global-error/page/build-manifest.json +2 -2
- 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 +2 -2
- 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/companion/[...path]/route/app-paths-manifest.json +3 -0
- package/server/.next/server/app/api/companion/[...path]/route/build-manifest.json +11 -0
- package/server/.next/server/app/api/companion/[...path]/route/server-reference-manifest.json +4 -0
- package/server/.next/server/app/api/companion/[...path]/route.js +7 -0
- package/server/.next/server/app/api/companion/[...path]/route.js.map +5 -0
- package/server/.next/server/app/api/companion/[...path]/route.js.nft.json +1 -0
- package/server/.next/server/app/api/companion/[...path]/route_client-reference-manifest.js +2 -0
- package/server/.next/server/app/api/docs/route.js +1 -1
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
- package/server/.next/server/app/api/file/route.js +1 -1
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/marks/route/app-paths-manifest.json +3 -0
- package/server/.next/server/app/api/marks/route/build-manifest.json +11 -0
- package/server/.next/server/app/api/marks/route/server-reference-manifest.json +4 -0
- package/server/.next/server/app/api/marks/route.js +8 -0
- package/server/.next/server/app/api/marks/route.js.map +5 -0
- package/server/.next/server/app/api/marks/route.js.nft.json +1 -0
- package/server/.next/server/app/api/marks/route_client-reference-manifest.js +2 -0
- package/server/.next/server/app/api/rispec/route.js +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js +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 +2 -2
- 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/app-paths-manifest.json +2 -0
- package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
- package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__8b8844cb._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__8d9e281e._.js +3 -0
- package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +2 -2
- package/server/.next/server/chunks/[root-of-the-server]__aedef6d1._.js +3 -0
- package/server/.next/server/chunks/bridge-protocol_dist_index_179d02a5.js +1 -1
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__b1b2bf74._.js → [root-of-the-server]__9e661f75._.js} +1 -1
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +7 -4
- package/server/.next/server/chunks/web__next-internal_server_app_api_companion_[___path]_route_actions_cfae35e7.js +3 -0
- package/server/.next/server/chunks/web__next-internal_server_app_api_marks_route_actions_6f18e4f5.js +3 -0
- package/server/.next/server/middleware-build-manifest.js +2 -2
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/server/server-reference-manifest.js +1 -1
- package/server/.next/server/server-reference-manifest.json +1 -1
- package/server/.next/static/chunks/0d24f3e3dbdef25e.css +2 -0
- package/server/.next/static/chunks/{5e1795a806bd6c51.js → d54b3ced5d4f7ae5.js} +1 -1
- package/server/.next/static/chunks/f03f883bab5194ab.js +14 -0
- package/server/.next/static/chunks/{turbopack-34fc7de1568e2a7f.js → turbopack-b3cae9131ae5086f.js} +1 -1
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/app/api/companion/[...path]/route.ts +112 -0
- package/server/src/app/api/config/route.ts +6 -0
- package/server/src/app/api/marks/route.ts +146 -0
- package/server/src/app/globals.css +94 -0
- package/server/src/app/page.tsx +13 -6
- package/server/src/components/erd/ErdWorkspace.tsx +7 -1
- package/server/src/components/sequence/SequenceReading.tsx +186 -0
- package/server/src/components/sequence/SequenceWorkspace.tsx +126 -13
- package/server/src/components/stage/CompanionLayer.tsx +373 -0
- package/server/src/components/stage/MachineStageLayer.tsx +56 -0
- package/server/src/components/stage/StageLayer.tsx +338 -0
- package/server/src/components/stage/StageProvider.tsx +257 -0
- package/server/src/lib/projectFile.ts +4 -2
- package/server/src/lib/runtimeConfig.ts +10 -1
- package/server/stateloom-web.json +2 -2
- package/server/.next/static/chunks/285279863965ba9b.js +0 -11
- package/server/.next/static/chunks/c86e96e4d6eeb632.css +0 -2
- /package/server/.next/static/{1_kqQZahUkpX4bIvXeeqX → HNER2j3kS6-hB98CL_HRG}/_buildManifest.js +0 -0
- /package/server/.next/static/{1_kqQZahUkpX4bIvXeeqX → HNER2j3kS6-hB98CL_HRG}/_clientMiddlewareManifest.json +0 -0
- /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
|
-
|
|
283
|
-
}, [def, view, layout, focusRequest,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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={() =>
|
|
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
|
-
|
|
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={() =>
|
|
883
|
+
<button className={button} onClick={() => reveal(selection!)}>
|
|
771
884
|
◎ Show
|
|
772
885
|
</button>
|
|
773
886
|
</span>
|