@miadi/stateloom-web 0.1.6 → 0.1.8

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 (44) 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/index.html +1 -1
  21. package/server/.next/server/app/index.rsc +3 -3
  22. package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  23. package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
  24. package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
  25. package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
  26. package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  27. package/server/.next/server/app/page.js.nft.json +1 -1
  28. package/server/.next/server/app/page_client-reference-manifest.js +1 -1
  29. package/server/.next/server/chunks/ssr/{[root-of-the-server]__df915e74._.js → [root-of-the-server]__46d45ef8._.js} +1 -1
  30. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +3 -3
  31. package/server/.next/server/pages/404.html +1 -1
  32. package/server/.next/server/pages/500.html +2 -2
  33. package/server/.next/static/chunks/8f6bd51bbcce6314.css +2 -0
  34. package/server/.next/static/chunks/a9e7748193d11ed7.js +10 -0
  35. package/server/package-lock.json +2 -2
  36. package/server/package.json +1 -1
  37. package/server/src/components/erd/ErdWorkspace.tsx +476 -167
  38. package/server/stateloom-web.json +2 -2
  39. package/server/tsconfig.tsbuildinfo +1 -1
  40. package/server/.next/static/chunks/3dc2436b75e43174.css +0 -2
  41. package/server/.next/static/chunks/e7adb06692216348.js +0 -10
  42. /package/server/.next/static/{6GrzDGxArqw_jZoajUQwk → XCT3lnw_1F5LD0GG4F94-}/_buildManifest.js +0 -0
  43. /package/server/.next/static/{6GrzDGxArqw_jZoajUQwk → XCT3lnw_1F5LD0GG4F94-}/_clientMiddlewareManifest.json +0 -0
  44. /package/server/.next/static/{6GrzDGxArqw_jZoajUQwk → XCT3lnw_1F5LD0GG4F94-}/_ssgManifest.js +0 -0
@@ -8,6 +8,13 @@
8
8
  * What it shares is everything underneath — the same `?doc=` parameter and file
9
9
  * API, the same hub room keyed by the resolved path, the same canvas package.
10
10
  *
11
+ * Layout. The board is the point, so it gets the screen: the header is one
12
+ * row, the view controls (zoom, fit) float on the board, and status is a line
13
+ * laid over its corner rather than a row of its own. On a phone the panel is a
14
+ * bottom sheet that starts closed, behind a three-tab dock — the same shape the
15
+ * state designer uses — and on a desktop it is the right column, which can be
16
+ * folded away.
17
+ *
11
18
  * Every edit is written to disk and then pushed to the room whole. Disk first,
12
19
  * because the agent's MCP tools read the file before each of their own edits:
13
20
  * a change that lived only in the browser would be overwritten by the next
@@ -20,6 +27,8 @@ import { createBridgeSession, type BridgeSession } from "@miadi/stateloom-react"
20
27
  import {
21
28
  IDENTITY_VIEWPORT,
22
29
  ERD_CARDINALITIES,
30
+ fitToBoxes,
31
+ zoomAt,
23
32
  addAttribute,
24
33
  addEntity,
25
34
  addRelationship,
@@ -32,9 +41,11 @@ import {
32
41
  removeEntity,
33
42
  removeRelationship,
34
43
  renderMermaidEr,
44
+ updateEntity,
35
45
  validateErd,
36
46
  type EntityRelationshipDefinition,
37
47
  type ErdCardinality,
48
+ type ErdNotation,
38
49
  type ErdValidationError,
39
50
  type LayoutBox,
40
51
  type StateMachineDefinition,
@@ -45,18 +56,33 @@ import { docQuery, navigateToDoc, useRequestedDoc } from "@/lib/docParam";
45
56
  import { loadRuntimeConfig } from "@/lib/runtimeConfig";
46
57
 
47
58
  type Moved = Record<string, { x: number; y: number }>;
59
+ type Tab = "entities" | "relations" | "problems";
48
60
  type Edit = (def: EntityRelationshipDefinition) => EntityRelationshipDefinition;
49
61
 
50
- const layoutKey = (docPath: string): string => `stateloom:erd-layout:${docPath}`;
62
+ // The two notations give an entity different footprints, so each keeps its own
63
+ // dragged arrangement. Crow's foot keeps the key it has always had.
64
+ const layoutKey = (docPath: string, notation: ErdNotation): string =>
65
+ notation === "chen" ? `stateloom:erd-layout:chen:${docPath}` : `stateloom:erd-layout:${docPath}`;
66
+
67
+ const NOTATION_KEY = "stateloom:erd-notation";
51
68
 
52
- function readMoved(docPath: string): Moved {
69
+ function readMoved(docPath: string, notation: ErdNotation): Moved {
53
70
  try {
54
- return JSON.parse(localStorage.getItem(layoutKey(docPath)) ?? "{}") as Moved;
71
+ return JSON.parse(localStorage.getItem(layoutKey(docPath, notation)) ?? "{}") as Moved;
55
72
  } catch {
56
73
  return {};
57
74
  }
58
75
  }
59
76
 
77
+ /** The viewer's own preference — kept in this browser, never in the document. */
78
+ function readNotation(): ErdNotation {
79
+ try {
80
+ return localStorage.getItem(NOTATION_KEY) === "chen" ? "chen" : "crowsfoot";
81
+ } catch {
82
+ return "crowsfoot";
83
+ }
84
+ }
85
+
60
86
  interface Machine {
61
87
  path: string;
62
88
  def: StateMachineDefinition;
@@ -93,12 +119,32 @@ export default function ErdWorkspace() {
93
119
  const [docPath, setDocPath] = useState("");
94
120
  const [def, setDef] = useState<EntityRelationshipDefinition | null>(null);
95
121
  const [moved, setMoved] = useState<Moved>({});
122
+ const [notation, setNotation] = useState<ErdNotation>("crowsfoot");
96
123
  const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
97
124
  const [fitKey, setFitKey] = useState(0);
98
125
  const [selection, setSelection] = useState<string | null>(null);
99
- const [status, setStatus] = useState("loading…");
126
+ const [status, setStatusState] = useState<{ text: string; tone: "ok" | "warn" }>({ text: "loading…", tone: "ok" });
127
+ const setStatus = useCallback((text: string, tone: "ok" | "warn" = "ok") => setStatusState({ text, tone }), []);
128
+ // Routine news ("saved", "updated live") steps back to its dot after a moment,
129
+ // so it is never what covers a corner of the diagram. A warning stays.
130
+ const [statusQuiet, setStatusQuiet] = useState(false);
131
+ useEffect(() => {
132
+ const timer = setTimeout(() => setStatusQuiet(true), 4000);
133
+ return () => {
134
+ clearTimeout(timer);
135
+ setStatusQuiet(false);
136
+ };
137
+ }, [status]);
138
+ const [tab, setTab] = useState<Tab>("entities");
139
+ // Phone only: the sheet starts closed so the board owns the screen.
140
+ const [sheetOpen, setSheetOpen] = useState(false);
141
+ const [menuOpen, setMenuOpen] = useState(false);
142
+ // Desktop only: the right column can be folded away.
143
+ const [panelHidden, setPanelHidden] = useState(false);
144
+ const boardRef = useRef<HTMLDivElement | null>(null);
100
145
  const [peers, setPeers] = useState(0);
101
- const [linkReport, setLinkReport] = useState<ErdValidationError[] | null>(null);
146
+ // null: not checked. "none": checked, and no state machine sits beside the document.
147
+ const [linkReport, setLinkReport] = useState<ErdValidationError[] | "none" | null>(null);
102
148
  const sessionRef = useRef<BridgeSession | null>(null);
103
149
 
104
150
  // Load the document, then join its room.
@@ -108,7 +154,7 @@ export default function ErdWorkspace() {
108
154
  (async () => {
109
155
  const body = await (await fetch(`/api/file${docQuery(requested)}`, { cache: "no-store" })).json();
110
156
  if (cancelled) return;
111
- if (body.error) return setStatus(body.error);
157
+ if (body.error) return setStatus(body.error, "warn");
112
158
  const path: string = body.path ?? "";
113
159
  let loaded: EntityRelationshipDefinition | null = null;
114
160
  try {
@@ -117,11 +163,13 @@ export default function ErdWorkspace() {
117
163
  } catch {
118
164
  // Falls through to the unreadable-document message below.
119
165
  }
120
- if (body.exists && !loaded) return setStatus(`${path} is not a readable ERD definition`);
166
+ if (body.exists && !loaded) return setStatus(`${path} is not a readable ERD definition`, "warn");
121
167
  const name = (path.split("/").pop() ?? "Untitled").replace(/\.erdf\.json$/i, "");
168
+ const preferred = readNotation();
122
169
  setDocPath(path);
123
170
  setDef(loaded ?? emptyErd("Default", name));
124
- setMoved(readMoved(path));
171
+ setNotation(preferred);
172
+ setMoved(readMoved(path, preferred));
125
173
  setSelection(null);
126
174
  setLinkReport(null);
127
175
  setFitKey((k) => k + 1);
@@ -146,22 +194,22 @@ export default function ErdWorkspace() {
146
194
  });
147
195
  sessionRef.current = session;
148
196
  session.connect();
149
- })().catch((e) => !cancelled && setStatus(String(e)));
197
+ })().catch((e) => !cancelled && setStatus(String(e), "warn"));
150
198
  return () => {
151
199
  cancelled = true;
152
200
  sessionRef.current = null;
153
201
  session?.disconnect();
154
202
  };
155
- }, [requested]);
203
+ }, [requested, setStatus]);
156
204
 
157
205
  const positions = useMemo<Record<string, LayoutBox>>(() => {
158
206
  if (!def) return {};
159
- const derived = erdAutoLayout(def);
207
+ const derived = erdAutoLayout(def, { notation });
160
208
  for (const [name, at] of Object.entries(moved)) {
161
209
  if (derived[name]) derived[name] = { ...derived[name], ...at };
162
210
  }
163
211
  return derived;
164
- }, [def, moved]);
212
+ }, [def, moved, notation]);
165
213
 
166
214
  const problems = useMemo(() => (def ? validateErd(def) : []), [def]);
167
215
  const errorEntities = useMemo(
@@ -176,7 +224,7 @@ export default function ErdWorkspace() {
176
224
  try {
177
225
  next = edit(def);
178
226
  } catch (e) {
179
- return setStatus(e instanceof Error ? e.message : String(e));
227
+ return setStatus(e instanceof Error ? e.message : String(e), "warn");
180
228
  }
181
229
  setDef(next);
182
230
  setLinkReport(null);
@@ -190,10 +238,10 @@ export default function ErdWorkspace() {
190
238
  sessionRef.current?.emitFull(next as unknown as StateMachineDefinition);
191
239
  setStatus("saved");
192
240
  } catch (e) {
193
- setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`);
241
+ setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`, "warn");
194
242
  }
195
243
  },
196
- [def, docPath],
244
+ [def, docPath, setStatus],
197
245
  );
198
246
 
199
247
  const moveEntity = useCallback(
@@ -201,20 +249,32 @@ export default function ErdWorkspace() {
201
249
  setMoved((prev) => {
202
250
  const next = { ...prev, [name]: { x: Math.round(box.x), y: Math.round(box.y) } };
203
251
  try {
204
- localStorage.setItem(layoutKey(docPath), JSON.stringify(next));
252
+ localStorage.setItem(layoutKey(docPath, notation), JSON.stringify(next));
205
253
  } catch {
206
254
  // A full or disabled store costs the arrangement, not the document.
207
255
  }
208
256
  return next;
209
257
  });
210
258
  },
211
- [docPath],
259
+ [docPath, notation],
212
260
  );
213
261
 
262
+ const chooseNotation = (next: ErdNotation): void => {
263
+ if (next === notation) return;
264
+ try {
265
+ localStorage.setItem(NOTATION_KEY, next);
266
+ } catch {
267
+ // The choice then lasts for this visit only.
268
+ }
269
+ setNotation(next);
270
+ setMoved(readMoved(docPath, next));
271
+ setFitKey((k) => k + 1);
272
+ };
273
+
214
274
  const arrange = (): void => {
215
275
  setMoved({});
216
276
  try {
217
- localStorage.removeItem(layoutKey(docPath));
277
+ localStorage.removeItem(layoutKey(docPath, notation));
218
278
  } catch {
219
279
  /* as above */
220
280
  }
@@ -226,60 +286,171 @@ export default function ErdWorkspace() {
226
286
  setStatus("checking links…");
227
287
  const machines = await siblingMachines(docPath);
228
288
  if (machines.length === 0) {
229
- setLinkReport([]);
230
- return setStatus("no .smdf.json beside this document to check against");
289
+ setLinkReport("none");
290
+ setTab("problems");
291
+ setSheetOpen(true);
292
+ return setStatus("no .smdf.json beside this document to check against", "warn");
231
293
  }
232
294
  const found = machines.flatMap((m) =>
233
295
  checkLinks(m.def, def).map((e) => ({ ...e, message: `${m.def.settings?.name ?? m.path}: ${e.message}` })),
234
296
  );
235
297
  found.push(...checkStateOf(def, machines.map((m) => m.def.settings?.name ?? "")));
236
298
  setLinkReport(found);
237
- setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`);
299
+ // The answer lives in the Problems tab, so go there — on a phone it is otherwise behind a closed sheet.
300
+ setTab("problems");
301
+ setSheetOpen(true);
302
+ setPanelHidden(false);
303
+ setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`, found.length ? "warn" : "ok");
238
304
  };
239
305
 
240
306
  const openMachine = async (machine: string): Promise<void> => {
241
307
  const hit = (await siblingMachines(docPath)).find((m) => m.def.settings?.name === machine);
242
308
  if (hit) navigateToDoc(hit.path);
243
- else setStatus(`no .smdf.json beside this document defines '${machine}'`);
309
+ else setStatus(`no .smdf.json beside this document defines '${machine}'`, "warn");
310
+ };
311
+
312
+ const boardSize = (): { width: number; height: number } => {
313
+ const rect = boardRef.current?.getBoundingClientRect();
314
+ return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
315
+ };
316
+
317
+ const zoomBy = (factor: number): void => {
318
+ const { width, height } = boardSize();
319
+ setViewport((vp) => zoomAt(vp, factor, { x: width / 2, y: height / 2 }));
320
+ };
321
+
322
+ /** Bring one entity to the middle of the board at a size its rows can be read at. */
323
+ const showOnBoard = (name: string): void => {
324
+ const box = positions[name];
325
+ const { width, height } = boardSize();
326
+ if (!box || !width) return;
327
+ setViewport(fitToBoxes([box], width, height, { padding: 28, maxScale: 1.25 }));
328
+ setSelection(name);
329
+ setSheetOpen(false);
330
+ };
331
+
332
+ const openTab = (next: Tab): void => {
333
+ // Tapping the tab you are already reading closes the sheet, so the dock is
334
+ // both switcher and dismiss control.
335
+ setSheetOpen((wasOpen) => !(wasOpen && tab === next));
336
+ setTab(next);
244
337
  };
245
338
 
246
339
  const selected = def?.entities.find((e) => e.name === selection) ?? null;
247
340
  const fileName = docPath.split("/").pop() || "…";
341
+ const linkProblems = Array.isArray(linkReport) ? linkReport : [];
342
+ const problemCount = problems.length + linkProblems.length;
343
+ const tabs: { id: Tab; label: string; icon: string; badge?: number }[] = [
344
+ { id: "entities", label: "Entities", icon: "▭" },
345
+ { id: "relations", label: "Relations", icon: "⟷" },
346
+ { id: "problems", label: "Problems", icon: "⚠", badge: problemCount || undefined },
347
+ ];
348
+ const shown = (id: Tab): string => (tab === id ? "" : "hidden md:block");
349
+
350
+ const copyMermaid = (): void => {
351
+ if (def) navigator.clipboard?.writeText(renderMermaidEr(def)).then(() => setStatus("mermaid copied"));
352
+ };
248
353
 
249
354
  return (
250
- <div className="app-shell flex flex-col overflow-hidden bg-gray-950 text-gray-200">
251
- <header className="flex flex-wrap items-center gap-2 border-b border-gray-800 bg-gray-900 px-3 py-2 text-xs">
355
+ <div
356
+ className="app-shell dock-offset flex flex-col overflow-hidden bg-gray-950 text-gray-200"
357
+ onClick={() => menuOpen && setMenuOpen(false)}
358
+ >
359
+ {/* One row at every width. What does not fit a phone goes behind ⋯, never onto a second line. */}
360
+ <header className="relative z-40 flex items-center gap-2 border-b border-gray-800 bg-gray-900 px-2 py-1.5 text-xs md:px-3 md:py-2">
252
361
  <span className="font-semibold text-blue-300">ERD</span>
253
362
  <DocSwitcher />
254
- <span className="text-gray-400" title={docPath}>
255
- {def?.settings.name ?? ""} · {fileName}
363
+ <span className="min-w-0 flex-1 truncate text-gray-400" title={docPath}>
364
+ {def?.settings.name ?? fileName}
365
+ <span className="hidden text-gray-600 lg:inline"> · {fileName}</span>
256
366
  </span>
257
- <span className="ml-auto text-gray-500">
258
- {status}
259
- {peers > 1 ? ` · ${peers} in the room` : ""}
367
+
368
+ {/* Same document, two drawings. The choice is this browser's; it is never written to the file. */}
369
+ <span className="inline-flex shrink-0 overflow-hidden rounded border border-gray-700" role="group" aria-label="Notation">
370
+ {(
371
+ [
372
+ ["crowsfoot", "▤", "Crow's foot — attributes listed inside the entity box"],
373
+ ["chen", "◇", "Chen — attributes as ovals, relationships as diamonds"],
374
+ ] as const
375
+ ).map(([id, glyph, title]) => (
376
+ <button
377
+ key={id}
378
+ title={title}
379
+ aria-label={title}
380
+ aria-pressed={notation === id}
381
+ onClick={() => chooseNotation(id)}
382
+ className={`px-2.5 py-1.5 text-sm leading-none md:px-2 md:py-1 ${
383
+ notation === id ? "bg-blue-900/60 text-blue-200" : "bg-gray-800 text-gray-400 hover:bg-gray-700"
384
+ }`}
385
+ >
386
+ {glyph}
387
+ </button>
388
+ ))}
260
389
  </span>
261
- <button className={button} onClick={arrange} title="Forget the dragged positions and lay the board out again">
262
- ⤢ Arrange
263
- </button>
264
- <button className={button} onClick={() => setFitKey((k) => k + 1)}>
265
- Fit
266
- </button>
267
- <button className={button} onClick={runLinkCheck} title="Check the state machines beside this document against it">
268
- Check links
269
- </button>
270
- <button
271
- className={button}
272
- onClick={() => def && navigator.clipboard?.writeText(renderMermaidEr(def)).then(() => setStatus("mermaid copied"))}
273
- >
274
- Copy mermaid
275
- </button>
390
+
391
+ <div className="hidden shrink-0 items-center gap-2 md:flex">
392
+ <button className={button} onClick={arrange} title="Forget the dragged positions and lay the board out again">
393
+ ⤢ Arrange
394
+ </button>
395
+ <button className={button} onClick={runLinkCheck} title="Check the state machines beside this document against it">
396
+ Check links
397
+ </button>
398
+ <button className={button} onClick={copyMermaid}>
399
+ Copy mermaid
400
+ </button>
401
+ <button
402
+ className={button}
403
+ onClick={() => setPanelHidden((h) => !h)}
404
+ aria-pressed={!panelHidden}
405
+ title={panelHidden ? "Show the panel" : "Hide the panel — the board takes the full width"}
406
+ >
407
+ {panelHidden ? "⟨ Panel" : "Panel ⟩"}
408
+ </button>
409
+ </div>
410
+
411
+ <div className="relative shrink-0 md:hidden">
412
+ <button
413
+ className="rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm leading-none text-gray-200"
414
+ aria-label="More actions"
415
+ aria-expanded={menuOpen}
416
+ onClick={(e) => {
417
+ e.stopPropagation();
418
+ setMenuOpen((o) => !o);
419
+ }}
420
+ >
421
+ ⋯
422
+ </button>
423
+ {menuOpen && (
424
+ <div className="absolute right-0 top-full z-50 mt-1 w-56 overflow-hidden rounded-lg border border-gray-700 bg-gray-900 py-1 shadow-xl">
425
+ {(
426
+ [
427
+ ["⤢ Arrange the board", arrange],
428
+ ["Check links to the machines", runLinkCheck],
429
+ ["Copy as mermaid", copyMermaid],
430
+ ] as const
431
+ ).map(([label, run]) => (
432
+ <button
433
+ key={label}
434
+ className="block w-full px-4 py-3 text-left text-sm text-gray-200 hover:bg-gray-800"
435
+ onClick={() => {
436
+ setMenuOpen(false);
437
+ run();
438
+ }}
439
+ >
440
+ {label}
441
+ </button>
442
+ ))}
443
+ </div>
444
+ )}
445
+ </div>
276
446
  </header>
277
447
 
278
- <div className="flex min-h-0 flex-1 flex-col md:flex-row">
279
- <div className="min-h-[45dvh] flex-1 overflow-hidden">
448
+ <div className="relative flex min-h-0 flex-1">
449
+ <div ref={boardRef} className="relative min-w-0 flex-1 overflow-hidden">
280
450
  {def && (
281
451
  <EntityRelationshipCanvas
282
452
  definition={def}
453
+ notation={notation}
283
454
  positions={positions}
284
455
  viewport={viewport}
285
456
  onViewportChange={setViewport}
@@ -293,143 +464,281 @@ export default function ErdWorkspace() {
293
464
  emptyHint="No entities yet — add one in the panel, or let an agent call add_entity."
294
465
  />
295
466
  )}
467
+
468
+ {/* Status costs no layout: it is a line over the board's corner. */}
469
+ <div
470
+ 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] ${
471
+ status.tone === "warn" ? "text-amber-300" : "text-gray-500"
472
+ }`}
473
+ role="status"
474
+ >
475
+ <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${status.tone === "warn" ? "bg-amber-400" : "bg-emerald-500"}`} />
476
+ <span
477
+ className={`truncate transition-opacity duration-500 ${
478
+ statusQuiet && status.tone === "ok" ? "opacity-0" : "opacity-100"
479
+ }`}
480
+ >
481
+ {status.text}
482
+ {peers > 1 ? ` · ${peers} in the room` : ""}
483
+ </span>
484
+ </div>
485
+
486
+ {/* View controls live on the board they act on. */}
487
+ <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">
488
+ {(
489
+ [
490
+ ["+", "Zoom in", () => zoomBy(1.25)],
491
+ ["−", "Zoom out", () => zoomBy(0.8)],
492
+ ["⛶", "Fit the whole diagram", () => setFitKey((k) => k + 1)],
493
+ ] as const
494
+ ).map(([glyph, title, run]) => (
495
+ <button
496
+ key={title}
497
+ title={title}
498
+ aria-label={title}
499
+ onClick={run}
500
+ className="h-10 w-10 border-b border-gray-800 text-base leading-none text-gray-200 last:border-b-0 hover:bg-gray-800 md:h-8 md:w-8 md:text-sm"
501
+ >
502
+ {glyph}
503
+ </button>
504
+ ))}
505
+ </div>
506
+
507
+ {/* Phone: the way from a tapped entity to its details, without the sheet opening by itself over the board. */}
508
+ {selected && !sheetOpen && (
509
+ <button
510
+ className="absolute bottom-3 left-1/2 max-w-[60%] -translate-x-1/2 truncate rounded-full border border-blue-500/60 bg-gray-900/95 px-4 py-2 text-sm text-blue-200 shadow-lg md:hidden"
511
+ onClick={() => {
512
+ setTab("entities");
513
+ setSheetOpen(true);
514
+ }}
515
+ >
516
+ ✎ {selected.name}
517
+ </button>
518
+ )}
296
519
  </div>
297
520
 
521
+ {sheetOpen && (
522
+ <div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={() => setSheetOpen(false)} aria-hidden="true" />
523
+ )}
524
+
298
525
  {def && (
299
- <aside className="touch-targets w-full overflow-y-auto border-t border-gray-800 bg-gray-900 p-3 md:w-80 md:border-l md:border-t-0">
300
- <div className={heading} style={{ marginTop: 0 }}>
301
- Entities
302
- </div>
303
- <div className="flex flex-wrap gap-1">
304
- {def.entities.map((e) => (
305
- <button
306
- key={e.name}
307
- className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
308
- onClick={() => setSelection(e.name)}
309
- >
310
- {e.name}
526
+ <aside
527
+ className={`safe-x fixed inset-x-0 bottom-0 z-30 flex h-[62dvh] flex-col overflow-hidden rounded-t-2xl border-t border-gray-800 bg-gray-900 shadow-2xl transition-transform duration-200 ease-out ${
528
+ sheetOpen ? "translate-y-0" : "pointer-events-none translate-y-full"
529
+ } md:pointer-events-auto md:static md:z-auto md:h-auto md:w-80 md:translate-y-0 md:rounded-none md:border-l md:border-t-0 md:shadow-none md:transition-none ${
530
+ panelHidden ? "md:hidden" : ""
531
+ }`}
532
+ >
533
+ <div className="md:hidden">
534
+ <div className="flex justify-center pb-1 pt-2">
535
+ <span className="h-1 w-10 rounded-full bg-gray-700" />
536
+ </div>
537
+ <div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
538
+ <h2 className="text-sm font-semibold text-gray-300">{tabs.find((t) => t.id === tab)?.label}</h2>
539
+ <button onClick={() => setSheetOpen(false)} className="-mr-2 px-3 py-2 text-lg text-gray-400" aria-label="Close panel">
540
+ ✕
311
541
  </button>
312
- ))}
542
+ </div>
313
543
  </div>
314
- <InlineForm
315
- fields={[{ name: "name", placeholder: "new entity" }]}
316
- submit="+ Entity"
317
- onSubmit={(v) => {
318
- apply((d) => addEntity(d, { name: v.name.trim() }));
319
- setSelection(v.name.trim());
320
- }}
321
- />
322
544
 
323
- {selected && (
324
- <>
325
- <div className={heading}>{selected.name}</div>
326
- {(selected.attributes ?? []).map((a) => (
327
- <div key={a.name} className="flex items-center justify-between gap-2 py-0.5 font-mono text-[11px]">
328
- <span className={a.stateOf ? "text-blue-300" : "text-gray-300"}>
329
- {a.name} : {a.type}
330
- <span className="text-gray-500">
331
- {a.key ? ` ${a.key.toUpperCase()}` : ""}
332
- {a.references ? ` → ${a.references}` : ""}
333
- {a.stateOf ? ` ◉ ${[a.stateOf].flat().join(", ")}` : ""}
334
- </span>
545
+ <div className="touch-targets dock-offset flex-1 overflow-y-auto overscroll-contain p-3">
546
+ <section className={shown("entities")}>
547
+ <div className={heading} style={{ marginTop: 0 }}>
548
+ Entities
549
+ </div>
550
+ <div className="flex flex-wrap gap-1">
551
+ {def.entities.map((e) => (
552
+ <button
553
+ key={e.name}
554
+ className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
555
+ onClick={() => setSelection(e.name)}
556
+ >
557
+ {e.name}
558
+ </button>
559
+ ))}
560
+ </div>
561
+ <InlineForm
562
+ fields={[{ name: "name", placeholder: "new entity" }]}
563
+ submit="+ Entity"
564
+ onSubmit={(v) => {
565
+ apply((d) => addEntity(d, { name: v.name.trim() }));
566
+ setSelection(v.name.trim());
567
+ }}
568
+ />
569
+
570
+ {selected && (
571
+ <>
572
+ <div className={`${heading} flex items-center justify-between gap-2`}>
573
+ <span className="truncate text-sm normal-case tracking-normal text-gray-200">{selected.name}</span>
574
+ <button
575
+ className={`${button} normal-case tracking-normal`}
576
+ onClick={() => showOnBoard(selected.name)}
577
+ title="Bring this entity to the middle of the board at a readable size"
578
+ >
579
+ ◎ Show on board
580
+ </button>
581
+ </div>
582
+ <label
583
+ className="mb-1 flex items-center gap-2 text-[11px] text-gray-400"
584
+ title="Exists only through another entity (an order line, without its order). Chen draws it as a double rectangle."
585
+ >
586
+ <input
587
+ type="checkbox"
588
+ checked={!!selected.weak}
589
+ onChange={(e) => apply((d) => updateEntity(d, selected.name, { weak: e.target.checked }))}
590
+ />
591
+ weak entity
592
+ </label>
593
+ {(selected.attributes ?? []).map((a) => (
594
+ <div key={a.name} className="flex items-center justify-between gap-2 py-0.5 font-mono text-[11px]">
595
+ <span className={a.stateOf ? "text-blue-300" : "text-gray-300"}>
596
+ {a.name} : {a.type}
597
+ <span className="text-gray-500">
598
+ {a.key ? ` ${a.key.toUpperCase()}` : ""}
599
+ {a.references ? ` → ${a.references}` : ""}
600
+ {a.stateOf ? ` ◉ ${[a.stateOf].flat().join(", ")}` : ""}
601
+ </span>
602
+ </span>
603
+ <button
604
+ className="text-gray-500 hover:text-red-400"
605
+ aria-label={`Remove ${a.name}`}
606
+ onClick={() => apply((d) => removeAttribute(d, selected.name, a.name))}
607
+ >
608
+ ✕
609
+ </button>
610
+ </div>
611
+ ))}
612
+ <InlineForm
613
+ fields={[
614
+ { name: "name", placeholder: "attribute" },
615
+ { name: "type", placeholder: "type", initial: "string" },
616
+ { name: "key", options: ["", "pk", "uk"] },
617
+ { name: "references", placeholder: "references Entity.attr" },
618
+ { name: "stateOf", placeholder: "stateOf Machine" },
619
+ ]}
620
+ submit="+ Attribute"
621
+ onSubmit={(v) =>
622
+ apply((d) =>
623
+ addAttribute(d, selected.name, {
624
+ name: v.name.trim(),
625
+ type: v.type.trim() || "string",
626
+ key: v.key === "pk" || v.key === "uk" ? v.key : undefined,
627
+ references: v.references.trim() || undefined,
628
+ stateOf: v.stateOf.trim() || undefined,
629
+ }),
630
+ )
631
+ }
632
+ />
633
+ <button
634
+ className={`${button} mt-2 text-red-300`}
635
+ onClick={() => {
636
+ apply((d) => removeEntity(d, selected.name));
637
+ setSelection(null);
638
+ }}
639
+ >
640
+ 🗑 Delete {selected.name}
641
+ </button>
642
+ </>
643
+ )}
644
+ </section>
645
+
646
+ <section className={shown("relations")}>
647
+ <div className={`${heading} max-md:mt-0`}>Relationships</div>
648
+ {def.relationships.length === 0 && <div className="py-0.5 text-[11px] text-gray-600">None yet.</div>}
649
+ {def.relationships.map((r, i) => (
650
+ <div key={i} className="flex items-center justify-between gap-2 py-0.5 text-[11px] text-gray-300">
651
+ <span>
652
+ {r.from} <span className="text-gray-500">{r.cardinality}</span> {r.to}
653
+ {r.label ? <span className="text-gray-500"> : {r.label}</span> : null}
335
654
  </span>
336
655
  <button
337
656
  className="text-gray-500 hover:text-red-400"
338
- aria-label={`Remove ${a.name}`}
339
- onClick={() => apply((d) => removeAttribute(d, selected.name, a.name))}
657
+ aria-label="Remove relationship"
658
+ onClick={() => apply((d) => removeRelationship(d, r.from, r.to, r.label))}
340
659
  >
341
660
  ✕
342
661
  </button>
343
662
  </div>
344
663
  ))}
345
- <InlineForm
346
- fields={[
347
- { name: "name", placeholder: "attribute" },
348
- { name: "type", placeholder: "type", initial: "string" },
349
- { name: "key", options: ["", "pk", "uk"] },
350
- { name: "references", placeholder: "references Entity.attr" },
351
- { name: "stateOf", placeholder: "stateOf Machine" },
352
- ]}
353
- submit="+ Attribute"
354
- onSubmit={(v) =>
355
- apply((d) =>
356
- addAttribute(d, selected.name, {
357
- name: v.name.trim(),
358
- type: v.type.trim() || "string",
359
- key: v.key === "pk" || v.key === "uk" ? v.key : undefined,
360
- references: v.references.trim() || undefined,
361
- stateOf: v.stateOf.trim() || undefined,
362
- }),
363
- )
364
- }
365
- />
366
- <button
367
- className={`${button} mt-2 text-red-300`}
368
- onClick={() => {
369
- apply((d) => removeEntity(d, selected.name));
370
- setSelection(null);
371
- }}
372
- >
373
- 🗑 Delete {selected.name}
374
- </button>
375
- </>
376
- )}
377
-
378
- <div className={heading}>Relationships</div>
379
- {def.relationships.map((r, i) => (
380
- <div key={i} className="flex items-center justify-between gap-2 py-0.5 text-[11px] text-gray-300">
381
- <span>
382
- {r.from} <span className="text-gray-500">{r.cardinality}</span> {r.to}
383
- {r.label ? <span className="text-gray-500"> : {r.label}</span> : null}
384
- </span>
385
- <button
386
- className="text-gray-500 hover:text-red-400"
387
- aria-label="Remove relationship"
388
- onClick={() => apply((d) => removeRelationship(d, r.from, r.to, r.label))}
389
- >
390
- ✕
391
- </button>
392
- </div>
393
- ))}
394
- {def.entities.length > 0 && (
395
- <InlineForm
396
- fields={[
397
- { name: "from", options: def.entities.map((e) => e.name) },
398
- { name: "cardinality", options: [...ERD_CARDINALITIES], initial: "1:N" },
399
- { name: "to", options: def.entities.map((e) => e.name) },
400
- { name: "label", placeholder: "label (a verb)" },
401
- ]}
402
- submit="+ Relationship"
403
- onSubmit={(v) =>
404
- apply((d) =>
405
- addRelationship(d, {
406
- from: v.from,
407
- to: v.to,
408
- cardinality: v.cardinality as ErdCardinality,
409
- label: v.label.trim() || undefined,
410
- }),
411
- )
412
- }
413
- />
414
- )}
415
-
416
- {(problems.length > 0 || linkReport) && <div className={heading}>Problems</div>}
417
- {problems.map((p, i) => (
418
- <div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
419
- [{p.ruleId}] {p.message}
420
- </div>
421
- ))}
422
- {linkReport?.length === 0 && problems.length === 0 && (
423
- <div className="py-0.5 text-[11px] text-green-400">Every name the machines use resolves.</div>
424
- )}
425
- {linkReport?.map((p, i) => (
426
- <div key={`l${i}`} className="py-0.5 text-[11px] text-amber-300">
427
- [{p.ruleId}] {p.message}
428
- </div>
429
- ))}
664
+ {def.entities.length > 0 && (
665
+ <InlineForm
666
+ fields={[
667
+ { name: "from", options: def.entities.map((e) => e.name) },
668
+ { name: "cardinality", options: [...ERD_CARDINALITIES], initial: "1:N" },
669
+ { name: "to", options: def.entities.map((e) => e.name) },
670
+ { name: "label", placeholder: "label (a verb)" },
671
+ ]}
672
+ submit="+ Relationship"
673
+ onSubmit={(v) =>
674
+ apply((d) =>
675
+ addRelationship(d, {
676
+ from: v.from,
677
+ to: v.to,
678
+ cardinality: v.cardinality as ErdCardinality,
679
+ label: v.label.trim() || undefined,
680
+ }),
681
+ )
682
+ }
683
+ />
684
+ )}
685
+ </section>
686
+
687
+ <section className={shown("problems")}>
688
+ <div className={`${heading} max-md:mt-0 ${problemCount === 0 && !linkReport ? "md:hidden" : ""}`}>Problems</div>
689
+ {problems.map((p, i) => (
690
+ <div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
691
+ [{p.ruleId}] {p.message}
692
+ </div>
693
+ ))}
694
+ {linkProblems.map((p, i) => (
695
+ <div key={`l${i}`} className="py-0.5 text-[11px] text-amber-300">
696
+ [{p.ruleId}] {p.message}
697
+ </div>
698
+ ))}
699
+ {linkReport === "none" && (
700
+ <div className="py-0.5 text-[11px] text-amber-300">
701
+ No state machine (.smdf.json) sits beside this document, so there was nothing to check it against.
702
+ </div>
703
+ )}
704
+ {problemCount === 0 && linkReport !== "none" && (
705
+ <div className={`py-0.5 text-[11px] ${linkReport ? "text-green-400" : "text-gray-600 md:hidden"}`}>
706
+ {linkReport
707
+ ? "Every name the machines use resolves."
708
+ : "The document is valid. “Check links” (⋯) holds the state machines beside it against it."}
709
+ </div>
710
+ )}
711
+ </section>
712
+ </div>
430
713
  </aside>
431
714
  )}
432
715
  </div>
716
+
717
+ {/* Tab dock (phone). Three tabs one thumb-tap away, and the problem count is on screen at all times. */}
718
+ <nav className="dock-safe safe-x fixed inset-x-0 bottom-0 z-40 flex border-t border-gray-800 bg-gray-900/95 backdrop-blur-md md:hidden">
719
+ {tabs.map((t) => {
720
+ const current = sheetOpen && tab === t.id;
721
+ return (
722
+ <button
723
+ key={t.id}
724
+ onClick={() => openTab(t.id)}
725
+ aria-pressed={current}
726
+ className={`relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-colors ${
727
+ current ? "text-blue-400" : "text-gray-500"
728
+ }`}
729
+ >
730
+ <span className="text-base leading-none">{t.icon}</span>
731
+ <span>{t.label}</span>
732
+ {t.badge !== undefined && (
733
+ <span className="absolute right-1/2 top-1 min-w-[16px] translate-x-5 rounded-full bg-red-600 px-1 text-[10px] leading-4 text-white">
734
+ {t.badge}
735
+ </span>
736
+ )}
737
+ {current && <span className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-blue-400" />}
738
+ </button>
739
+ );
740
+ })}
741
+ </nav>
433
742
  </div>
434
743
  );
435
744
  }
@@ -494,7 +803,7 @@ function InlineForm({
494
803
  />
495
804
  ),
496
805
  )}
497
- <button type="submit" className={`${button} col-span-2`}>
806
+ <button type="submit" className={`${button} ${fields.length === 1 ? "" : "col-span-2"}`}>
498
807
  {submit}
499
808
  </button>
500
809
  </form>