@miadi/stateloom-web 0.1.7 → 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 (41) 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_client-reference-manifest.js +1 -1
  28. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +1 -1
  29. package/server/.next/server/pages/404.html +1 -1
  30. package/server/.next/server/pages/500.html +2 -2
  31. package/server/.next/static/chunks/8f6bd51bbcce6314.css +2 -0
  32. package/server/.next/static/chunks/{b3f3eb72c25c4c6b.js → a9e7748193d11ed7.js} +4 -4
  33. package/server/package-lock.json +2 -2
  34. package/server/package.json +1 -1
  35. package/server/src/components/erd/ErdWorkspace.tsx +416 -172
  36. package/server/stateloom-web.json +2 -2
  37. package/server/tsconfig.tsbuildinfo +1 -1
  38. package/server/.next/static/chunks/486bc3bc16e91024.css +0 -2
  39. /package/server/.next/static/{DPL_H0tteH9tpIBvvsIfq → XCT3lnw_1F5LD0GG4F94-}/_buildManifest.js +0 -0
  40. /package/server/.next/static/{DPL_H0tteH9tpIBvvsIfq → XCT3lnw_1F5LD0GG4F94-}/_clientMiddlewareManifest.json +0 -0
  41. /package/server/.next/static/{DPL_H0tteH9tpIBvvsIfq → 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,
@@ -47,6 +56,7 @@ import { docQuery, navigateToDoc, useRequestedDoc } from "@/lib/docParam";
47
56
  import { loadRuntimeConfig } from "@/lib/runtimeConfig";
48
57
 
49
58
  type Moved = Record<string, { x: number; y: number }>;
59
+ type Tab = "entities" | "relations" | "problems";
50
60
  type Edit = (def: EntityRelationshipDefinition) => EntityRelationshipDefinition;
51
61
 
52
62
  // The two notations give an entity different footprints, so each keeps its own
@@ -113,9 +123,28 @@ export default function ErdWorkspace() {
113
123
  const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
114
124
  const [fitKey, setFitKey] = useState(0);
115
125
  const [selection, setSelection] = useState<string | null>(null);
116
- 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);
117
145
  const [peers, setPeers] = useState(0);
118
- 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);
119
148
  const sessionRef = useRef<BridgeSession | null>(null);
120
149
 
121
150
  // Load the document, then join its room.
@@ -125,7 +154,7 @@ export default function ErdWorkspace() {
125
154
  (async () => {
126
155
  const body = await (await fetch(`/api/file${docQuery(requested)}`, { cache: "no-store" })).json();
127
156
  if (cancelled) return;
128
- if (body.error) return setStatus(body.error);
157
+ if (body.error) return setStatus(body.error, "warn");
129
158
  const path: string = body.path ?? "";
130
159
  let loaded: EntityRelationshipDefinition | null = null;
131
160
  try {
@@ -134,7 +163,7 @@ export default function ErdWorkspace() {
134
163
  } catch {
135
164
  // Falls through to the unreadable-document message below.
136
165
  }
137
- 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");
138
167
  const name = (path.split("/").pop() ?? "Untitled").replace(/\.erdf\.json$/i, "");
139
168
  const preferred = readNotation();
140
169
  setDocPath(path);
@@ -165,13 +194,13 @@ export default function ErdWorkspace() {
165
194
  });
166
195
  sessionRef.current = session;
167
196
  session.connect();
168
- })().catch((e) => !cancelled && setStatus(String(e)));
197
+ })().catch((e) => !cancelled && setStatus(String(e), "warn"));
169
198
  return () => {
170
199
  cancelled = true;
171
200
  sessionRef.current = null;
172
201
  session?.disconnect();
173
202
  };
174
- }, [requested]);
203
+ }, [requested, setStatus]);
175
204
 
176
205
  const positions = useMemo<Record<string, LayoutBox>>(() => {
177
206
  if (!def) return {};
@@ -195,7 +224,7 @@ export default function ErdWorkspace() {
195
224
  try {
196
225
  next = edit(def);
197
226
  } catch (e) {
198
- return setStatus(e instanceof Error ? e.message : String(e));
227
+ return setStatus(e instanceof Error ? e.message : String(e), "warn");
199
228
  }
200
229
  setDef(next);
201
230
  setLinkReport(null);
@@ -209,10 +238,10 @@ export default function ErdWorkspace() {
209
238
  sessionRef.current?.emitFull(next as unknown as StateMachineDefinition);
210
239
  setStatus("saved");
211
240
  } catch (e) {
212
- setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`);
241
+ setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`, "warn");
213
242
  }
214
243
  },
215
- [def, docPath],
244
+ [def, docPath, setStatus],
216
245
  );
217
246
 
218
247
  const moveEntity = useCallback(
@@ -257,40 +286,87 @@ export default function ErdWorkspace() {
257
286
  setStatus("checking links…");
258
287
  const machines = await siblingMachines(docPath);
259
288
  if (machines.length === 0) {
260
- setLinkReport([]);
261
- 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");
262
293
  }
263
294
  const found = machines.flatMap((m) =>
264
295
  checkLinks(m.def, def).map((e) => ({ ...e, message: `${m.def.settings?.name ?? m.path}: ${e.message}` })),
265
296
  );
266
297
  found.push(...checkStateOf(def, machines.map((m) => m.def.settings?.name ?? "")));
267
298
  setLinkReport(found);
268
- 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");
269
304
  };
270
305
 
271
306
  const openMachine = async (machine: string): Promise<void> => {
272
307
  const hit = (await siblingMachines(docPath)).find((m) => m.def.settings?.name === machine);
273
308
  if (hit) navigateToDoc(hit.path);
274
- 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);
275
337
  };
276
338
 
277
339
  const selected = def?.entities.find((e) => e.name === selection) ?? null;
278
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
+ };
279
353
 
280
354
  return (
281
- <div className="app-shell flex flex-col overflow-hidden bg-gray-950 text-gray-200">
282
- <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">
283
361
  <span className="font-semibold text-blue-300">ERD</span>
284
362
  <DocSwitcher />
285
- <span className="text-gray-400" title={docPath}>
286
- {def?.settings.name ?? ""} · {fileName}
287
- </span>
288
- <span className="ml-auto text-gray-500">
289
- {status}
290
- {peers > 1 ? ` · ${peers} in the room` : ""}
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>
291
366
  </span>
367
+
292
368
  {/* Same document, two drawings. The choice is this browser's; it is never written to the file. */}
293
- <span className="inline-flex overflow-hidden rounded border border-gray-700" role="group" aria-label="Notation">
369
+ <span className="inline-flex shrink-0 overflow-hidden rounded border border-gray-700" role="group" aria-label="Notation">
294
370
  {(
295
371
  [
296
372
  ["crowsfoot", "▤", "Crow's foot — attributes listed inside the entity box"],
@@ -303,7 +379,7 @@ export default function ErdWorkspace() {
303
379
  aria-label={title}
304
380
  aria-pressed={notation === id}
305
381
  onClick={() => chooseNotation(id)}
306
- className={`px-2 py-1 text-sm leading-none ${
382
+ className={`px-2.5 py-1.5 text-sm leading-none md:px-2 md:py-1 ${
307
383
  notation === id ? "bg-blue-900/60 text-blue-200" : "bg-gray-800 text-gray-400 hover:bg-gray-700"
308
384
  }`}
309
385
  >
@@ -311,25 +387,66 @@ export default function ErdWorkspace() {
311
387
  </button>
312
388
  ))}
313
389
  </span>
314
- <button className={button} onClick={arrange} title="Forget the dragged positions and lay the board out again">
315
- ⤢ Arrange
316
- </button>
317
- <button className={button} onClick={() => setFitKey((k) => k + 1)}>
318
- Fit
319
- </button>
320
- <button className={button} onClick={runLinkCheck} title="Check the state machines beside this document against it">
321
- Check links
322
- </button>
323
- <button
324
- className={button}
325
- onClick={() => def && navigator.clipboard?.writeText(renderMermaidEr(def)).then(() => setStatus("mermaid copied"))}
326
- >
327
- Copy mermaid
328
- </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>
329
446
  </header>
330
447
 
331
- <div className="flex min-h-0 flex-1 flex-col md:flex-row">
332
- <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">
333
450
  {def && (
334
451
  <EntityRelationshipCanvas
335
452
  definition={def}
@@ -347,154 +464,281 @@ export default function ErdWorkspace() {
347
464
  emptyHint="No entities yet — add one in the panel, or let an agent call add_entity."
348
465
  />
349
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
+ )}
350
519
  </div>
351
520
 
521
+ {sheetOpen && (
522
+ <div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={() => setSheetOpen(false)} aria-hidden="true" />
523
+ )}
524
+
352
525
  {def && (
353
- <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">
354
- <div className={heading} style={{ marginTop: 0 }}>
355
- Entities
356
- </div>
357
- <div className="flex flex-wrap gap-1">
358
- {def.entities.map((e) => (
359
- <button
360
- key={e.name}
361
- className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
362
- onClick={() => setSelection(e.name)}
363
- >
364
- {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
+ ✕
365
541
  </button>
366
- ))}
542
+ </div>
367
543
  </div>
368
- <InlineForm
369
- fields={[{ name: "name", placeholder: "new entity" }]}
370
- submit="+ Entity"
371
- onSubmit={(v) => {
372
- apply((d) => addEntity(d, { name: v.name.trim() }));
373
- setSelection(v.name.trim());
374
- }}
375
- />
376
544
 
377
- {selected && (
378
- <>
379
- <div className={heading}>{selected.name}</div>
380
- <label
381
- className="mb-1 flex items-center gap-2 text-[11px] text-gray-400"
382
- title="Exists only through another entity (an order line, without its order). Chen draws it as a double rectangle."
383
- >
384
- <input
385
- type="checkbox"
386
- checked={!!selected.weak}
387
- onChange={(e) => apply((d) => updateEntity(d, selected.name, { weak: e.target.checked }))}
388
- />
389
- weak entity
390
- </label>
391
- {(selected.attributes ?? []).map((a) => (
392
- <div key={a.name} className="flex items-center justify-between gap-2 py-0.5 font-mono text-[11px]">
393
- <span className={a.stateOf ? "text-blue-300" : "text-gray-300"}>
394
- {a.name} : {a.type}
395
- <span className="text-gray-500">
396
- {a.key ? ` ${a.key.toUpperCase()}` : ""}
397
- {a.references ? ` → ${a.references}` : ""}
398
- {a.stateOf ? ` ◉ ${[a.stateOf].flat().join(", ")}` : ""}
399
- </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}
400
654
  </span>
401
655
  <button
402
656
  className="text-gray-500 hover:text-red-400"
403
- aria-label={`Remove ${a.name}`}
404
- 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))}
405
659
  >
406
660
  ✕
407
661
  </button>
408
662
  </div>
409
663
  ))}
410
- <InlineForm
411
- fields={[
412
- { name: "name", placeholder: "attribute" },
413
- { name: "type", placeholder: "type", initial: "string" },
414
- { name: "key", options: ["", "pk", "uk"] },
415
- { name: "references", placeholder: "references Entity.attr" },
416
- { name: "stateOf", placeholder: "stateOf Machine" },
417
- ]}
418
- submit="+ Attribute"
419
- onSubmit={(v) =>
420
- apply((d) =>
421
- addAttribute(d, selected.name, {
422
- name: v.name.trim(),
423
- type: v.type.trim() || "string",
424
- key: v.key === "pk" || v.key === "uk" ? v.key : undefined,
425
- references: v.references.trim() || undefined,
426
- stateOf: v.stateOf.trim() || undefined,
427
- }),
428
- )
429
- }
430
- />
431
- <button
432
- className={`${button} mt-2 text-red-300`}
433
- onClick={() => {
434
- apply((d) => removeEntity(d, selected.name));
435
- setSelection(null);
436
- }}
437
- >
438
- 🗑 Delete {selected.name}
439
- </button>
440
- </>
441
- )}
442
-
443
- <div className={heading}>Relationships</div>
444
- {def.relationships.map((r, i) => (
445
- <div key={i} className="flex items-center justify-between gap-2 py-0.5 text-[11px] text-gray-300">
446
- <span>
447
- {r.from} <span className="text-gray-500">{r.cardinality}</span> {r.to}
448
- {r.label ? <span className="text-gray-500"> : {r.label}</span> : null}
449
- </span>
450
- <button
451
- className="text-gray-500 hover:text-red-400"
452
- aria-label="Remove relationship"
453
- onClick={() => apply((d) => removeRelationship(d, r.from, r.to, r.label))}
454
- >
455
- ✕
456
- </button>
457
- </div>
458
- ))}
459
- {def.entities.length > 0 && (
460
- <InlineForm
461
- fields={[
462
- { name: "from", options: def.entities.map((e) => e.name) },
463
- { name: "cardinality", options: [...ERD_CARDINALITIES], initial: "1:N" },
464
- { name: "to", options: def.entities.map((e) => e.name) },
465
- { name: "label", placeholder: "label (a verb)" },
466
- ]}
467
- submit="+ Relationship"
468
- onSubmit={(v) =>
469
- apply((d) =>
470
- addRelationship(d, {
471
- from: v.from,
472
- to: v.to,
473
- cardinality: v.cardinality as ErdCardinality,
474
- label: v.label.trim() || undefined,
475
- }),
476
- )
477
- }
478
- />
479
- )}
480
-
481
- {(problems.length > 0 || linkReport) && <div className={heading}>Problems</div>}
482
- {problems.map((p, i) => (
483
- <div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
484
- [{p.ruleId}] {p.message}
485
- </div>
486
- ))}
487
- {linkReport?.length === 0 && problems.length === 0 && (
488
- <div className="py-0.5 text-[11px] text-green-400">Every name the machines use resolves.</div>
489
- )}
490
- {linkReport?.map((p, i) => (
491
- <div key={`l${i}`} className="py-0.5 text-[11px] text-amber-300">
492
- [{p.ruleId}] {p.message}
493
- </div>
494
- ))}
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>
495
713
  </aside>
496
714
  )}
497
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>
498
742
  </div>
499
743
  );
500
744
  }
@@ -559,7 +803,7 @@ function InlineForm({
559
803
  />
560
804
  ),
561
805
  )}
562
- <button type="submit" className={`${button} col-span-2`}>
806
+ <button type="submit" className={`${button} ${fields.length === 1 ? "" : "col-span-2"}`}>
563
807
  {submit}
564
808
  </button>
565
809
  </form>