@miadi/stateloom-web 0.1.9 → 0.2.1

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 (70) hide show
  1. package/package.json +1 -1
  2. package/server/.next/BUILD_ID +1 -1
  3. package/server/.next/build-manifest.json +2 -2
  4. package/server/.next/server/app/_global-error.html +2 -2
  5. package/server/.next/server/app/_global-error.rsc +1 -1
  6. package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/server/.next/server/app/_not-found.html +1 -1
  13. package/server/.next/server/app/_not-found.rsc +2 -2
  14. package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  15. package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  16. package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  17. package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  18. package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  19. package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  20. package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
  21. package/server/.next/server/app/api/file/route.js.nft.json +1 -1
  22. package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
  23. package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
  24. package/server/.next/server/app/index.html +1 -1
  25. package/server/.next/server/app/index.rsc +3 -3
  26. package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  27. package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
  28. package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
  29. package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
  30. package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  31. package/server/.next/server/app/page.js.nft.json +1 -1
  32. package/server/.next/server/app/page_client-reference-manifest.js +1 -1
  33. package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +1 -1
  34. package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
  35. package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
  36. package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +3 -3
  37. package/server/.next/server/chunks/ssr/{[root-of-the-server]__d5879c0c._.js → [root-of-the-server]__b1b2bf74._.js} +2 -2
  38. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +4 -3
  39. package/server/.next/server/pages/404.html +1 -1
  40. package/server/.next/server/pages/500.html +2 -2
  41. package/server/.next/static/chunks/9390437c8b41a90e.js +11 -0
  42. package/server/.next/static/chunks/a8a37c487ef91a19.js +1 -0
  43. package/server/.next/static/chunks/c86e96e4d6eeb632.css +2 -0
  44. package/server/package-lock.json +2 -2
  45. package/server/package.json +1 -1
  46. package/server/src/app/api/file/route.ts +57 -12
  47. package/server/src/app/globals.css +15 -0
  48. package/server/src/app/page.tsx +52 -10
  49. package/server/src/components/BoardBoundary.tsx +46 -0
  50. package/server/src/components/PropertiesPanel.tsx +10 -0
  51. package/server/src/components/erd/ErdWorkspace.tsx +32 -3
  52. package/server/src/components/forms.tsx +129 -0
  53. package/server/src/components/sequence/SequenceWorkspace.tsx +1024 -0
  54. package/server/src/components/system/KindIcon.tsx +71 -0
  55. package/server/src/components/system/SystemStrip.tsx +285 -0
  56. package/server/src/components/system/SystemWorkspace.tsx +766 -0
  57. package/server/src/lib/docParam.ts +4 -1
  58. package/server/src/lib/machineFocus.ts +126 -0
  59. package/server/src/lib/projectFile.ts +180 -20
  60. package/server/src/lib/safely.ts +16 -0
  61. package/server/src/lib/systemLoad.ts +362 -0
  62. package/server/src/lib/systemParam.ts +110 -0
  63. package/server/src/types/definition.ts +2 -0
  64. package/server/stateloom-web.json +2 -2
  65. package/server/.next/static/chunks/636e7eb4aeda72e0.css +0 -2
  66. package/server/.next/static/chunks/92628504da59dcc8.js +0 -10
  67. package/server/.next/static/chunks/aea5b1716c68391c.js +0 -1
  68. /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → eIqAtQaBkU80e3XWuVwR8}/_buildManifest.js +0 -0
  69. /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → eIqAtQaBkU80e3XWuVwR8}/_clientMiddlewareManifest.json +0 -0
  70. /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → eIqAtQaBkU80e3XWuVwR8}/_ssgManifest.js +0 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The loom's document kinds, and a link, drawn as outlines in the current text
3
+ * colour — for the same reason as IssueIcon: typed symbols turn into colour
4
+ * emoji on iOS, and the chrome is kept quiet.
5
+ */
6
+ import type { DocKind } from "@miadi/stateloom-protocol";
7
+
8
+ type IconName = DocKind | "link" | "actor";
9
+
10
+ export default function KindIcon({ kind, size = 16 }: { kind: IconName; size?: number }) {
11
+ return (
12
+ <svg
13
+ width={size}
14
+ height={size}
15
+ viewBox="0 0 24 24"
16
+ fill="none"
17
+ stroke="currentColor"
18
+ strokeWidth="1.6"
19
+ strokeLinecap="round"
20
+ strokeLinejoin="round"
21
+ aria-hidden="true"
22
+ className="shrink-0"
23
+ >
24
+ {kind === "erd" && (
25
+ // A table: a header over rows.
26
+ <>
27
+ <rect x="4" y="4.5" width="16" height="15" rx="1.5" />
28
+ <path d="M4 9h16M8 13h8M8 16.5h6" />
29
+ </>
30
+ )}
31
+ {kind === "machine" && (
32
+ // Two states and the transition between them.
33
+ <>
34
+ <rect x="2.5" y="4" width="8" height="6" rx="2.5" />
35
+ <rect x="13.5" y="14" width="8" height="6" rx="2.5" />
36
+ <path d="M6.5 10v4.5a2.5 2.5 0 0 0 2.5 2.5h4.5" />
37
+ <path d="M11.5 15l2 2-2 2" />
38
+ </>
39
+ )}
40
+ {kind === "sequence" && (
41
+ // Two lifelines and a message.
42
+ <>
43
+ <path d="M6 4v16M18 4v16" strokeDasharray="2 2.2" />
44
+ <path d="M6 9h11M15 7l2 2-2 2" />
45
+ <path d="M18 15H7M9 13l-2 2 2 2" />
46
+ </>
47
+ )}
48
+ {kind === "system" && (
49
+ // Three members and what joins them.
50
+ <>
51
+ <rect x="3" y="3.5" width="7" height="6" rx="1.5" />
52
+ <rect x="14" y="3.5" width="7" height="6" rx="1.5" />
53
+ <rect x="8.5" y="14.5" width="7" height="6" rx="1.5" />
54
+ <path d="M10 6.5h4M7 9.5l3 5M17 9.5l-3 5" />
55
+ </>
56
+ )}
57
+ {kind === "link" && (
58
+ <>
59
+ <path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2" />
60
+ <path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2" />
61
+ </>
62
+ )}
63
+ {kind === "actor" && (
64
+ <>
65
+ <circle cx="12" cy="7.5" r="3.5" />
66
+ <path d="M5 20a7 7 0 0 1 14 0" />
67
+ </>
68
+ )}
69
+ </svg>
70
+ );
71
+ }
@@ -0,0 +1,285 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The system strip (Spec 82): one compact row above whichever workspace is
5
+ * open, when the URL says the open document is read as a member of a system
6
+ * (`?system=<.sysdf.json>`).
7
+ *
8
+ * - The system's name: back to the map.
9
+ * - One tab per member, drawn by kind: switch drawings without losing the system.
10
+ * - Links: what the element in hand (`focus=`) is joined to in the other
11
+ * drawings — `linksOf(systemLinks(members), member, focus)` — each one a way
12
+ * to that element, opened and focused. With nothing in hand, the elements of
13
+ * this drawing that have links, to pick one.
14
+ *
15
+ * It also joins the system file's hub room with its own client: a `view:show`
16
+ * from an agent opens the member it names, focuses the element, and says the
17
+ * agent's note here as one short line. Notes from this page (an `auto`
18
+ * reconcile) come through the same line. Never a second row on a phone: the
19
+ * line lays itself over the board under the strip, then fades.
20
+ */
21
+ import { useEffect, useMemo, useRef, useState } from "react";
22
+ import {
23
+ elementFocus,
24
+ linksOf,
25
+ parseFocus,
26
+ resolveMemberPath,
27
+ systemLinks,
28
+ type DocKind,
29
+ type ElementRef,
30
+ type SystemLink,
31
+ type ViewEnvelope,
32
+ } from "@miadi/stateloom-protocol";
33
+ import { createBridgeClient } from "@miadi/stateloom-client";
34
+ import KindIcon from "@/components/system/KindIcon";
35
+ import { loadRuntimeConfig } from "@/lib/runtimeConfig";
36
+ import { safely } from "@/lib/safely";
37
+ import { STRIP_NOTE_EVENT, openInSystem, useUrlParam } from "@/lib/systemParam";
38
+ import { memberOfDoc, notifySystemChanged, stayJoined, useSystem, type LoadedSystem } from "@/lib/systemLoad";
39
+
40
+ /** What a member is called on a tab: its own name when it has one, else its file's. */
41
+ export function memberTitle(system: LoadedSystem | null, member: string): string {
42
+ const def = system?.members.find((m) => m.path === member)?.def as { settings?: { name?: string } } | null | undefined;
43
+ return def?.settings?.name || (member.split("/").pop() ?? member).replace(/\.(smdf|erdf|sqdf|sysdf)\.json$|\.json$/i, "");
44
+ }
45
+
46
+ const kindOf = (system: LoadedSystem | null, member: string): DocKind =>
47
+ system?.members.find((m) => m.path === member)?.kind ?? "machine";
48
+
49
+ function elementLabel(ref: Pick<ElementRef, "kind" | "name">): string {
50
+ return ref.kind === "message" ? `message ${ref.name}` : `${ref.kind} ${ref.name}`;
51
+ }
52
+
53
+ /** The elements of `member` that some link touches, with how many links each. */
54
+ function linkedElements(links: readonly SystemLink[], member: string | null): [string, number][] {
55
+ if (!member) return [];
56
+ const seen = new Map<string, number>();
57
+ for (const l of links) {
58
+ for (const end of [l.from, l.to]) {
59
+ if (end.member !== member) continue;
60
+ const key = elementFocus(end);
61
+ seen.set(key, (seen.get(key) ?? 0) + 1);
62
+ }
63
+ }
64
+ return [...seen.entries()].sort((a, b) => a[0].localeCompare(b[0]));
65
+ }
66
+
67
+ export default function SystemStrip({ systemPath }: { systemPath: string }) {
68
+ const doc = useUrlParam("doc");
69
+ const focus = useUrlParam("focus");
70
+ const { system } = useSystem(systemPath);
71
+ const [linksOpen, setLinksOpen] = useState(false);
72
+ const [note, setNote] = useState<{ text: string; origin?: string; at: number } | null>(null);
73
+ // The hub's handler outlives renders; it reads the system as it is now.
74
+ const systemRef = useRef(system);
75
+ useEffect(() => {
76
+ systemRef.current = system;
77
+ }, [system]);
78
+
79
+ const links = useMemo(() => (system ? safely(() => systemLinks(system.members), []) : []), [system]);
80
+ const member = memberOfDoc(system, doc);
81
+ const onMap = !!system && (doc === system.path || doc === systemPath);
82
+ const focused = member && focus ? linksOf(links, member, focus) : [];
83
+ // With nothing in hand: this drawing's elements that have links, to pick one.
84
+ const linkedHere = linkedElements(links, member);
85
+
86
+ // Notes from this page (an auto reconcile) and from agents.
87
+ useEffect(() => {
88
+ const onNote = (e: Event) => {
89
+ const detail = (e as CustomEvent<{ text: string; origin?: string }>).detail;
90
+ if (detail?.text) setNote({ ...detail, at: Date.now() });
91
+ };
92
+ window.addEventListener(STRIP_NOTE_EVENT, onNote);
93
+ return () => window.removeEventListener(STRIP_NOTE_EVENT, onNote);
94
+ }, []);
95
+ useEffect(() => {
96
+ if (!note) return;
97
+ const timer = setTimeout(() => setNote(null), 9000);
98
+ return () => clearTimeout(timer);
99
+ }, [note]);
100
+
101
+ // The system's room: an agent's `view:show` lands here.
102
+ const roomPath = system?.path;
103
+ useEffect(() => {
104
+ if (!roomPath) return;
105
+ let live = true;
106
+ let client: ReturnType<typeof createBridgeClient> | null = null;
107
+ let stopJoining: (() => void) | null = null;
108
+ (async () => {
109
+ const { bridgeUrl } = await loadRuntimeConfig();
110
+ if (!live || !bridgeUrl) return;
111
+ client = createBridgeClient({ url: bridgeUrl, role: "web", docId: roomPath, name: "web-system-strip" });
112
+ client.on("view", (env: ViewEnvelope) => {
113
+ const sys = systemRef.current;
114
+ if (!sys) return;
115
+ // Only the system's own drawings: a member named as the system writes it,
116
+ // or by its absolute path. Anything else is not this strip's to open.
117
+ const member = env.member
118
+ ? sys.absOf[env.member] !== undefined
119
+ ? env.member
120
+ : Object.keys(sys.absOf).find((m) => sys.absOf[m] === env.member)
121
+ : undefined;
122
+ if (env.member && !member) {
123
+ setNote({ text: `ignored a view of ${env.member.split("/").pop()}: it is not a member of this system`, origin: env.origin, at: Date.now() });
124
+ return;
125
+ }
126
+ const target = member ? sys.absOf[member] : roomPath;
127
+ openInSystem({ doc: target, system: systemPath, focus: env.focus ?? null });
128
+ // The agent's words are the news; an open Links list would cover them.
129
+ setLinksOpen(false);
130
+ const what = [member ? memberTitle(sys, member) : "the system map", env.focus].filter(Boolean).join(" · ");
131
+ setNote({ text: env.note ? env.note : `showing ${what}`, origin: env.origin, at: Date.now() });
132
+ });
133
+ // The system file itself was written elsewhere (an agent's add_member): read it again.
134
+ client.on("full", () => notifySystemChanged());
135
+ // Joined again after every reconnect: a restarted hub has forgotten the
136
+ // room, and a strip that never re-joins never hears an agent again. No hub,
137
+ // or a refusal: the strip still switches drawings, it just hears no agent.
138
+ stopJoining = stayJoined(client);
139
+ })();
140
+ return () => {
141
+ live = false;
142
+ stopJoining?.();
143
+ client?.disconnect();
144
+ };
145
+ }, [roomPath, systemPath]);
146
+
147
+ const go = (target: { member?: string; focus?: string | null }): void => {
148
+ setLinksOpen(false);
149
+ if (!system) return;
150
+ const docPath = target.member ? (system.absOf[target.member] ?? resolveMemberPath(system.path, target.member)) : system.path;
151
+ openInSystem({ doc: docPath, system: systemPath, focus: target.focus ?? null });
152
+ };
153
+
154
+ const members = system?.def?.members?.filter((m) => m?.path).map((m) => m.path) ?? [];
155
+ const name = system?.def?.settings?.name ?? (systemPath.split("/").pop() ?? "system").replace(/\.sysdf\.json$/i, "");
156
+ const count = focused.length;
157
+
158
+ return (
159
+ <div className="system-strip relative z-50 border-b border-gray-800 bg-gray-950 text-xs text-gray-300">
160
+ <div className="flex h-[var(--strip-h)] items-center gap-1 px-1.5 md:px-2">
161
+ <button
162
+ onClick={() => go({})}
163
+ className={`flex min-w-0 max-w-[30%] shrink-0 items-center gap-1 rounded px-1.5 py-1 md:max-w-[16rem] ${
164
+ onMap ? "bg-blue-950/60 text-blue-200" : "text-gray-300 hover:bg-gray-800"
165
+ }`}
166
+ title={`${systemPath} — the system map`}
167
+ >
168
+ <KindIcon kind="system" size={15} />
169
+ <span className="truncate font-semibold">{name}</span>
170
+ </button>
171
+ <span className="h-4 w-px shrink-0 bg-gray-800" />
172
+
173
+ <nav className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto [scrollbar-width:none]" aria-label="Members">
174
+ {members.map((m) => {
175
+ const current = m === member;
176
+ const failed = system?.members.find((x) => x.path === m)?.error;
177
+ return (
178
+ <button
179
+ key={m}
180
+ onClick={() => go({ member: m })}
181
+ aria-current={current ? "page" : undefined}
182
+ title={`${memberTitle(system, m)} — ${m}${failed ? ` (cannot be read: ${failed})` : ""}`}
183
+ className={`flex shrink-0 items-center gap-1 rounded px-1.5 py-1 ${
184
+ current ? "bg-blue-950/60 text-blue-200" : failed ? "text-red-400 hover:bg-gray-800" : "text-gray-400 hover:bg-gray-800 hover:text-gray-200"
185
+ }`}
186
+ >
187
+ <KindIcon kind={kindOf(system, m)} size={15} />
188
+ <span className={`max-w-[7.5rem] truncate md:max-w-[11rem] ${current ? "" : "hidden md:inline"}`}>{memberTitle(system, m)}</span>
189
+ </button>
190
+ );
191
+ })}
192
+ {!system && <span className="px-1 text-gray-600">loading…</span>}
193
+ {system?.error && <span className="truncate px-1 text-amber-300">{system.error}</span>}
194
+ </nav>
195
+
196
+ <button
197
+ onClick={() => setLinksOpen((o) => !o)}
198
+ disabled={!member}
199
+ aria-expanded={linksOpen}
200
+ title={member ? (focus ? `What ${focus} links to in the other drawings` : "Elements of this drawing that link to the others") : "Open a member to follow its links"}
201
+ className={`flex shrink-0 items-center gap-1 rounded px-1.5 py-1 disabled:opacity-40 ${
202
+ linksOpen ? "bg-blue-950/60 text-blue-200" : "text-gray-300 hover:bg-gray-800"
203
+ }`}
204
+ >
205
+ <KindIcon kind="link" size={15} />
206
+ <span className="hidden sm:inline">Links</span>
207
+ {count > 0 && <span className="rounded-full border border-gray-700 px-1 text-[10px] leading-4 text-gray-300">{count}</span>}
208
+ </button>
209
+ </div>
210
+
211
+ {/* The one-line note: over the board, never a row of its own. */}
212
+ {note && !linksOpen && (
213
+ <div
214
+ className="pointer-events-none absolute inset-x-0 top-full flex justify-center px-2 pt-1"
215
+ role="status"
216
+ >
217
+ <span className="max-w-full truncate rounded border border-gray-700 bg-gray-900/95 px-2 py-0.5 text-[11px] text-gray-300 shadow">
218
+ {note.origin ? <span className="text-blue-300">{note.origin}: </span> : null}
219
+ {note.text}
220
+ </span>
221
+ </div>
222
+ )}
223
+
224
+ {linksOpen && member && (
225
+ <>
226
+ <div className="fixed inset-0 z-40" onClick={() => setLinksOpen(false)} aria-hidden="true" />
227
+ <div className="absolute right-1 top-full z-50 mt-1 max-h-[60vh] w-[min(24rem,calc(100vw-0.5rem))] overflow-y-auto rounded-lg border border-gray-700 bg-gray-900 p-2 shadow-xl">
228
+ {focus && parseFocus(focus) ? (
229
+ <>
230
+ <div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-gray-500">
231
+ <span className="truncate">
232
+ <span className="text-gray-200">{elementLabel(parseFocus(focus)!)}</span> in {memberTitle(system, member)}
233
+ </span>
234
+ <button className="shrink-0 text-gray-500 hover:text-gray-200" onClick={() => openInSystem({ doc: doc!, focus: null })}>
235
+ clear
236
+ </button>
237
+ </div>
238
+ {focused.length === 0 && <div className="py-1 text-[11px] text-gray-500">Nothing in the other drawings names it.</div>}
239
+ {focused.map((l, i) => (
240
+ <button
241
+ key={i}
242
+ onClick={() => go({ member: l.other.member, focus: elementFocus(l.other) })}
243
+ className="flex w-full items-start gap-2 rounded px-1.5 py-1.5 text-left hover:bg-gray-800"
244
+ >
245
+ <span className="mt-0.5 text-gray-400">
246
+ <KindIcon kind={kindOf(system, l.other.member)} size={14} />
247
+ </span>
248
+ <span className="min-w-0 flex-1">
249
+ <span className="block text-[12px] text-gray-200">{l.text}</span>
250
+ <span className="block truncate text-[10.5px] text-gray-500">
251
+ {elementLabel(l.other)} · {memberTitle(system, l.other.member)} · {l.rule}
252
+ </span>
253
+ </span>
254
+ </button>
255
+ ))}
256
+ </>
257
+ ) : null}
258
+ {(!focus || !parseFocus(focus)) && (
259
+ <>
260
+ <div className="mb-1 text-[11px] text-gray-500">
261
+ Elements of {memberTitle(system, member)} that the other drawings name — pick one to follow it.
262
+ </div>
263
+ {linkedHere.length === 0 && <div className="py-1 text-[11px] text-gray-500">None.</div>}
264
+ <div className="flex flex-wrap gap-1">
265
+ {linkedHere.map(([key, n]) => (
266
+ <button
267
+ key={key}
268
+ onClick={() => {
269
+ setLinksOpen(true);
270
+ openInSystem({ doc: doc!, focus: key });
271
+ }}
272
+ className="rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-[11px] text-gray-200 hover:bg-gray-700"
273
+ >
274
+ {key} <span className="text-gray-500">{n}</span>
275
+ </button>
276
+ ))}
277
+ </div>
278
+ </>
279
+ )}
280
+ </div>
281
+ </>
282
+ )}
283
+ </div>
284
+ );
285
+ }