@miadi/stateloom-web 0.1.7 → 0.1.9

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 (56) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/server/.next/BUILD_ID +1 -1
  4. package/server/.next/build-manifest.json +2 -2
  5. package/server/.next/server/app/_global-error.html +2 -2
  6. package/server/.next/server/app/_global-error.rsc +1 -1
  7. package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/server/.next/server/app/_not-found.html +1 -1
  14. package/server/.next/server/app/_not-found.rsc +2 -2
  15. package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  18. package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
  22. package/server/.next/server/app/api/file/route.js.nft.json +1 -1
  23. package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
  24. package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
  25. package/server/.next/server/app/index.html +1 -1
  26. package/server/.next/server/app/index.rsc +3 -3
  27. package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  28. package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
  29. package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
  30. package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
  31. package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  32. package/server/.next/server/app/page.js.nft.json +1 -1
  33. package/server/.next/server/app/page_client-reference-manifest.js +1 -1
  34. package/server/.next/server/chunks/ssr/{[root-of-the-server]__46d45ef8._.js → [root-of-the-server]__d5879c0c._.js} +1 -1
  35. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +3 -3
  36. package/server/.next/server/pages/404.html +1 -1
  37. package/server/.next/server/pages/500.html +2 -2
  38. package/server/.next/static/chunks/636e7eb4aeda72e0.css +2 -0
  39. package/server/.next/static/chunks/92628504da59dcc8.js +10 -0
  40. package/server/package-lock.json +2 -2
  41. package/server/package.json +1 -1
  42. package/server/src/app/page.tsx +17 -10
  43. package/server/src/components/IssueIcon.tsx +28 -0
  44. package/server/src/components/LockIcon.tsx +24 -0
  45. package/server/src/components/PropertiesPanel.tsx +10 -0
  46. package/server/src/components/SettingsPanel.tsx +11 -0
  47. package/server/src/components/erd/ErdWorkspace.tsx +564 -172
  48. package/server/src/lib/useSheetDrag.ts +95 -0
  49. package/server/src/types/definition.ts +4 -0
  50. package/server/stateloom-web.json +2 -2
  51. package/server/tsconfig.tsbuildinfo +1 -1
  52. package/server/.next/static/chunks/486bc3bc16e91024.css +0 -2
  53. package/server/.next/static/chunks/b3f3eb72c25c4c6b.js +0 -10
  54. /package/server/.next/static/{DPL_H0tteH9tpIBvvsIfq → 6dAVJYqnbe6oEAABQ1yMG}/_buildManifest.js +0 -0
  55. /package/server/.next/static/{DPL_H0tteH9tpIBvvsIfq → 6dAVJYqnbe6oEAABQ1yMG}/_clientMiddlewareManifest.json +0 -0
  56. /package/server/.next/static/{DPL_H0tteH9tpIBvvsIfq → 6dAVJYqnbe6oEAABQ1yMG}/_ssgManifest.js +0 -0
@@ -8,18 +8,27 @@
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
14
21
  * `add_entity`. The edits are the protocol's pure functions, the same ones the
15
22
  * MCP tools call, so a hand and an agent make identical changes.
16
23
  */
17
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
24
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
18
25
  import { EntityRelationshipCanvas } from "@miadi/stateloom-canvas";
19
26
  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,
@@ -33,6 +42,7 @@ import {
33
42
  removeRelationship,
34
43
  renderMermaidEr,
35
44
  updateEntity,
45
+ updateErdSettings,
36
46
  validateErd,
37
47
  type EntityRelationshipDefinition,
38
48
  type ErdCardinality,
@@ -43,10 +53,14 @@ import {
43
53
  type Viewport,
44
54
  } from "@miadi/stateloom-protocol";
45
55
  import DocSwitcher from "@/components/DocSwitcher";
56
+ import IssueIcon from "@/components/IssueIcon";
57
+ import LockIcon from "@/components/LockIcon";
46
58
  import { docQuery, navigateToDoc, useRequestedDoc } from "@/lib/docParam";
47
59
  import { loadRuntimeConfig } from "@/lib/runtimeConfig";
60
+ import { useSheetDrag } from "@/lib/useSheetDrag";
48
61
 
49
62
  type Moved = Record<string, { x: number; y: number }>;
63
+ type Tab = "entities" | "relations" | "notes" | "issues";
50
64
  type Edit = (def: EntityRelationshipDefinition) => EntityRelationshipDefinition;
51
65
 
52
66
  // The two notations give an entity different footprints, so each keeps its own
@@ -55,6 +69,22 @@ const layoutKey = (docPath: string, notation: ErdNotation): string =>
55
69
  notation === "chen" ? `stateloom:erd-layout:chen:${docPath}` : `stateloom:erd-layout:${docPath}`;
56
70
 
57
71
  const NOTATION_KEY = "stateloom:erd-notation";
72
+ const LOCK_KEY = "stateloom:erd-lock";
73
+
74
+ /**
75
+ * Whether shapes start locked in place. A stored choice wins; without one, a
76
+ * touch screen starts locked — there a drag is almost always "move the view",
77
+ * and a shape that slides away under the thumb is the accident, not the intent.
78
+ */
79
+ function readLocked(): boolean {
80
+ try {
81
+ const stored = localStorage.getItem(LOCK_KEY);
82
+ if (stored !== null) return stored === "1";
83
+ return window.matchMedia("(pointer: coarse)").matches;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
58
88
 
59
89
  function readMoved(docPath: string, notation: ErdNotation): Moved {
60
90
  try {
@@ -110,12 +140,35 @@ export default function ErdWorkspace() {
110
140
  const [def, setDef] = useState<EntityRelationshipDefinition | null>(null);
111
141
  const [moved, setMoved] = useState<Moved>({});
112
142
  const [notation, setNotation] = useState<ErdNotation>("crowsfoot");
143
+ // Locked: a drag that starts on a shape pans the board instead of moving the shape. A tap still selects.
144
+ const [locked, setLocked] = useState(false);
113
145
  const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
114
146
  const [fitKey, setFitKey] = useState(0);
115
147
  const [selection, setSelection] = useState<string | null>(null);
116
- const [status, setStatus] = useState("loading…");
148
+ const [status, setStatusState] = useState<{ text: string; tone: "ok" | "warn" }>({ text: "loading…", tone: "ok" });
149
+ const setStatus = useCallback((text: string, tone: "ok" | "warn" = "ok") => setStatusState({ text, tone }), []);
150
+ // Routine news ("saved", "updated live") steps back to its dot after a moment,
151
+ // so it is never what covers a corner of the diagram. A warning stays.
152
+ const [statusQuiet, setStatusQuiet] = useState(false);
153
+ useEffect(() => {
154
+ const timer = setTimeout(() => setStatusQuiet(true), 4000);
155
+ return () => {
156
+ clearTimeout(timer);
157
+ setStatusQuiet(false);
158
+ };
159
+ }, [status]);
160
+ const [tab, setTab] = useState<Tab>("entities");
161
+ // Phone only: the sheet starts closed so the board owns the screen.
162
+ const [sheetOpen, setSheetOpen] = useState(false);
163
+ const [menuOpen, setMenuOpen] = useState(false);
164
+ // Desktop only: the right column can be folded away.
165
+ const [panelHidden, setPanelHidden] = useState(false);
166
+ const boardRef = useRef<HTMLDivElement | null>(null);
167
+ // The sheet's handle answers a thumb: pull down to close, up to grow, tap to toggle.
168
+ const sheet = useSheetDrag({ onClose: () => setSheetOpen(false), restVh: 62, tallVh: 92 });
117
169
  const [peers, setPeers] = useState(0);
118
- const [linkReport, setLinkReport] = useState<ErdValidationError[] | null>(null);
170
+ // null: not checked. "none": checked, and no state machine sits beside the document.
171
+ const [linkReport, setLinkReport] = useState<ErdValidationError[] | "none" | null>(null);
119
172
  const sessionRef = useRef<BridgeSession | null>(null);
120
173
 
121
174
  // Load the document, then join its room.
@@ -125,7 +178,7 @@ export default function ErdWorkspace() {
125
178
  (async () => {
126
179
  const body = await (await fetch(`/api/file${docQuery(requested)}`, { cache: "no-store" })).json();
127
180
  if (cancelled) return;
128
- if (body.error) return setStatus(body.error);
181
+ if (body.error) return setStatus(body.error, "warn");
129
182
  const path: string = body.path ?? "";
130
183
  let loaded: EntityRelationshipDefinition | null = null;
131
184
  try {
@@ -134,12 +187,13 @@ export default function ErdWorkspace() {
134
187
  } catch {
135
188
  // Falls through to the unreadable-document message below.
136
189
  }
137
- if (body.exists && !loaded) return setStatus(`${path} is not a readable ERD definition`);
190
+ if (body.exists && !loaded) return setStatus(`${path} is not a readable ERD definition`, "warn");
138
191
  const name = (path.split("/").pop() ?? "Untitled").replace(/\.erdf\.json$/i, "");
139
192
  const preferred = readNotation();
140
193
  setDocPath(path);
141
194
  setDef(loaded ?? emptyErd("Default", name));
142
195
  setNotation(preferred);
196
+ setLocked(readLocked());
143
197
  setMoved(readMoved(path, preferred));
144
198
  setSelection(null);
145
199
  setLinkReport(null);
@@ -165,13 +219,13 @@ export default function ErdWorkspace() {
165
219
  });
166
220
  sessionRef.current = session;
167
221
  session.connect();
168
- })().catch((e) => !cancelled && setStatus(String(e)));
222
+ })().catch((e) => !cancelled && setStatus(String(e), "warn"));
169
223
  return () => {
170
224
  cancelled = true;
171
225
  sessionRef.current = null;
172
226
  session?.disconnect();
173
227
  };
174
- }, [requested]);
228
+ }, [requested, setStatus]);
175
229
 
176
230
  const positions = useMemo<Record<string, LayoutBox>>(() => {
177
231
  if (!def) return {};
@@ -195,7 +249,7 @@ export default function ErdWorkspace() {
195
249
  try {
196
250
  next = edit(def);
197
251
  } catch (e) {
198
- return setStatus(e instanceof Error ? e.message : String(e));
252
+ return setStatus(e instanceof Error ? e.message : String(e), "warn");
199
253
  }
200
254
  setDef(next);
201
255
  setLinkReport(null);
@@ -209,10 +263,10 @@ export default function ErdWorkspace() {
209
263
  sessionRef.current?.emitFull(next as unknown as StateMachineDefinition);
210
264
  setStatus("saved");
211
265
  } catch (e) {
212
- setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`);
266
+ setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`, "warn");
213
267
  }
214
268
  },
215
- [def, docPath],
269
+ [def, docPath, setStatus],
216
270
  );
217
271
 
218
272
  const moveEntity = useCallback(
@@ -257,40 +311,100 @@ export default function ErdWorkspace() {
257
311
  setStatus("checking links…");
258
312
  const machines = await siblingMachines(docPath);
259
313
  if (machines.length === 0) {
260
- setLinkReport([]);
261
- return setStatus("no .smdf.json beside this document to check against");
314
+ setLinkReport("none");
315
+ setTab("issues");
316
+ setSheetOpen(true);
317
+ return setStatus("no .smdf.json beside this document to check against", "warn");
262
318
  }
263
319
  const found = machines.flatMap((m) =>
264
320
  checkLinks(m.def, def).map((e) => ({ ...e, message: `${m.def.settings?.name ?? m.path}: ${e.message}` })),
265
321
  );
266
322
  found.push(...checkStateOf(def, machines.map((m) => m.def.settings?.name ?? "")));
267
323
  setLinkReport(found);
268
- setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`);
324
+ // The answer lives in the Issues tab, so go there — on a phone it is otherwise behind a closed sheet.
325
+ setTab("issues");
326
+ setSheetOpen(true);
327
+ setPanelHidden(false);
328
+ setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`, found.length ? "warn" : "ok");
269
329
  };
270
330
 
271
331
  const openMachine = async (machine: string): Promise<void> => {
272
332
  const hit = (await siblingMachines(docPath)).find((m) => m.def.settings?.name === machine);
273
333
  if (hit) navigateToDoc(hit.path);
274
- else setStatus(`no .smdf.json beside this document defines '${machine}'`);
334
+ else setStatus(`no .smdf.json beside this document defines '${machine}'`, "warn");
335
+ };
336
+
337
+ const boardSize = (): { width: number; height: number } => {
338
+ const rect = boardRef.current?.getBoundingClientRect();
339
+ return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
340
+ };
341
+
342
+ const toggleLock = (): void => {
343
+ const next = !locked;
344
+ setLocked(next);
345
+ try {
346
+ localStorage.setItem(LOCK_KEY, next ? "1" : "0");
347
+ } catch {
348
+ // The choice then lasts for this visit only.
349
+ }
350
+ setStatus(next ? "shapes locked — a drag anywhere moves the view" : "shapes unlocked — drag one to move it");
351
+ };
352
+
353
+ const zoomBy = (factor: number): void => {
354
+ const { width, height } = boardSize();
355
+ setViewport((vp) => zoomAt(vp, factor, { x: width / 2, y: height / 2 }));
356
+ };
357
+
358
+ /** Bring one entity to the middle of the board at a size its rows can be read at. */
359
+ const showOnBoard = (name: string): void => {
360
+ const box = positions[name];
361
+ const { width, height } = boardSize();
362
+ if (!box || !width) return;
363
+ setViewport(fitToBoxes([box], width, height, { padding: 28, maxScale: 1.25 }));
364
+ setSelection(name);
365
+ sheet.close();
366
+ };
367
+
368
+ const openTab = (next: Tab): void => {
369
+ // Tapping the tab you are already reading closes the sheet, so the dock is
370
+ // both switcher and dismiss control.
371
+ if (sheetOpen && tab === next) sheet.close();
372
+ else setSheetOpen(true);
373
+ setTab(next);
275
374
  };
276
375
 
277
376
  const selected = def?.entities.find((e) => e.name === selection) ?? null;
278
377
  const fileName = docPath.split("/").pop() || "…";
378
+ const linkProblems = Array.isArray(linkReport) ? linkReport : [];
379
+ const problemCount = problems.length + linkProblems.length;
380
+ const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
381
+ { id: "entities", label: "Entities", icon: "▭" },
382
+ { id: "relations", label: "Relations", icon: "⟷" },
383
+ { id: "notes", label: "Notes", icon: "✎", badge: undefined },
384
+ { id: "issues", label: "Issues", icon: <IssueIcon />, badge: problemCount || undefined },
385
+ ];
386
+ const shown = (id: Tab): string => (tab === id ? "" : "hidden md:block");
387
+
388
+ const copyMermaid = (): void => {
389
+ if (def) navigator.clipboard?.writeText(renderMermaidEr(def)).then(() => setStatus("mermaid copied"));
390
+ };
279
391
 
280
392
  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">
393
+ <div
394
+ className="app-shell dock-offset flex flex-col overflow-hidden bg-gray-950 text-gray-200"
395
+ onClick={() => menuOpen && setMenuOpen(false)}
396
+ >
397
+ {/* One row at every width. What does not fit a phone goes behind ⋯, never onto a second line. */}
398
+ <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
399
  <span className="font-semibold text-blue-300">ERD</span>
284
400
  <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` : ""}
401
+ <span className="min-w-0 flex-1 truncate text-gray-400" title={docPath}>
402
+ {def?.settings.name ?? fileName}
403
+ <span className="hidden text-gray-600 lg:inline"> · {fileName}</span>
291
404
  </span>
405
+
292
406
  {/* 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">
407
+ <span className="inline-flex shrink-0 overflow-hidden rounded border border-gray-700" role="group" aria-label="Notation">
294
408
  {(
295
409
  [
296
410
  ["crowsfoot", "▤", "Crow's foot — attributes listed inside the entity box"],
@@ -303,7 +417,7 @@ export default function ErdWorkspace() {
303
417
  aria-label={title}
304
418
  aria-pressed={notation === id}
305
419
  onClick={() => chooseNotation(id)}
306
- className={`px-2 py-1 text-sm leading-none ${
420
+ className={`px-2.5 py-1.5 text-sm leading-none md:px-2 md:py-1 ${
307
421
  notation === id ? "bg-blue-900/60 text-blue-200" : "bg-gray-800 text-gray-400 hover:bg-gray-700"
308
422
  }`}
309
423
  >
@@ -311,25 +425,66 @@ export default function ErdWorkspace() {
311
425
  </button>
312
426
  ))}
313
427
  </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>
428
+
429
+ <div className="hidden shrink-0 items-center gap-2 md:flex">
430
+ <button className={button} onClick={arrange} title="Forget the dragged positions and lay the board out again">
431
+ ⤢ Arrange
432
+ </button>
433
+ <button className={button} onClick={runLinkCheck} title="Check the state machines beside this document against it">
434
+ Check links
435
+ </button>
436
+ <button className={button} onClick={copyMermaid}>
437
+ Copy mermaid
438
+ </button>
439
+ <button
440
+ className={button}
441
+ onClick={() => setPanelHidden((h) => !h)}
442
+ aria-pressed={!panelHidden}
443
+ title={panelHidden ? "Show the panel" : "Hide the panel — the board takes the full width"}
444
+ >
445
+ {panelHidden ? "⟨ Panel" : "Panel ⟩"}
446
+ </button>
447
+ </div>
448
+
449
+ <div className="relative shrink-0 md:hidden">
450
+ <button
451
+ className="rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm leading-none text-gray-200"
452
+ aria-label="More actions"
453
+ aria-expanded={menuOpen}
454
+ onClick={(e) => {
455
+ e.stopPropagation();
456
+ setMenuOpen((o) => !o);
457
+ }}
458
+ >
459
+ ⋯
460
+ </button>
461
+ {menuOpen && (
462
+ <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">
463
+ {(
464
+ [
465
+ ["⤢ Arrange the board", arrange],
466
+ ["Check links to the machines", runLinkCheck],
467
+ ["Copy as mermaid", copyMermaid],
468
+ ] as const
469
+ ).map(([label, run]) => (
470
+ <button
471
+ key={label}
472
+ className="block w-full px-4 py-3 text-left text-sm text-gray-200 hover:bg-gray-800"
473
+ onClick={() => {
474
+ setMenuOpen(false);
475
+ run();
476
+ }}
477
+ >
478
+ {label}
479
+ </button>
480
+ ))}
481
+ </div>
482
+ )}
483
+ </div>
329
484
  </header>
330
485
 
331
- <div className="flex min-h-0 flex-1 flex-col md:flex-row">
332
- <div className="min-h-[45dvh] flex-1 overflow-hidden">
486
+ <div className="relative flex min-h-0 flex-1">
487
+ <div ref={boardRef} className="relative min-w-0 flex-1 overflow-hidden">
333
488
  {def && (
334
489
  <EntityRelationshipCanvas
335
490
  definition={def}
@@ -338,6 +493,7 @@ export default function ErdWorkspace() {
338
493
  viewport={viewport}
339
494
  onViewportChange={setViewport}
340
495
  selection={selection}
496
+ readOnly={locked}
341
497
  errorElements={errorEntities}
342
498
  fitKey={fitKey}
343
499
  onSelect={setSelection}
@@ -347,158 +503,394 @@ export default function ErdWorkspace() {
347
503
  emptyHint="No entities yet — add one in the panel, or let an agent call add_entity."
348
504
  />
349
505
  )}
506
+
507
+ {/* Status costs no layout: it is a line over the board's corner. */}
508
+ <div
509
+ 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] ${
510
+ status.tone === "warn" ? "text-amber-300" : "text-gray-500"
511
+ }`}
512
+ role="status"
513
+ >
514
+ <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${status.tone === "warn" ? "bg-amber-400" : "bg-emerald-500"}`} />
515
+ <span
516
+ className={`truncate transition-opacity duration-500 ${
517
+ statusQuiet && status.tone === "ok" ? "opacity-0" : "opacity-100"
518
+ }`}
519
+ >
520
+ {status.text}
521
+ {peers > 1 ? ` · ${peers} in the room` : ""}
522
+ </span>
523
+ </div>
524
+
525
+ {/* View controls live on the board they act on. */}
526
+ <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">
527
+ <button
528
+ title={locked ? "Shapes are locked: dragging moves the view. Tap to unlock." : "Lock the shapes in place, so dragging only moves the view"}
529
+ aria-label="Lock shapes in place"
530
+ aria-pressed={locked}
531
+ onClick={toggleLock}
532
+ className={`flex h-10 w-10 items-center justify-center border-b border-gray-800 hover:bg-gray-800 md:h-8 md:w-8 ${
533
+ locked ? "bg-blue-950/60 text-blue-300" : "text-gray-400"
534
+ }`}
535
+ >
536
+ <LockIcon locked={locked} />
537
+ </button>
538
+ {(
539
+ [
540
+ ["+", "Zoom in", () => zoomBy(1.25)],
541
+ ["−", "Zoom out", () => zoomBy(0.8)],
542
+ ["⛶", "Fit the whole diagram", () => setFitKey((k) => k + 1)],
543
+ ] as const
544
+ ).map(([glyph, title, run]) => (
545
+ <button
546
+ key={title}
547
+ title={title}
548
+ aria-label={title}
549
+ onClick={run}
550
+ 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"
551
+ >
552
+ {glyph}
553
+ </button>
554
+ ))}
555
+ </div>
556
+
557
+ {/* Phone: the way from a tapped entity to its details, without the sheet opening by itself over the board. */}
558
+ {selected && !sheetOpen && (
559
+ <button
560
+ 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"
561
+ onClick={() => {
562
+ setTab("entities");
563
+ setSheetOpen(true);
564
+ }}
565
+ >
566
+ ✎ {selected.name}
567
+ </button>
568
+ )}
350
569
  </div>
351
570
 
571
+ {sheetOpen && (
572
+ <div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={sheet.close} aria-hidden="true" />
573
+ )}
574
+
352
575
  {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}
576
+ <aside
577
+ style={sheet.sheetStyle}
578
+ className={`safe-x fixed inset-x-0 bottom-0 z-30 flex h-[var(--sheet-h)] flex-col overflow-hidden rounded-t-2xl border-t border-gray-800 bg-gray-900 shadow-2xl transition-[transform,height] duration-200 ease-out ${
579
+ sheetOpen ? "translate-y-0" : "pointer-events-none translate-y-full"
580
+ } 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 ${
581
+ panelHidden ? "md:hidden" : ""
582
+ }`}
583
+ >
584
+ <div className="md:hidden" {...sheet.zoneProps}>
585
+ <div className="flex justify-center pb-1 pt-2">
586
+ <span className="h-1.5 w-12 rounded-full bg-gray-600" />
587
+ </div>
588
+ <div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
589
+ <h2 className="text-sm font-semibold text-gray-300">{tabs.find((t) => t.id === tab)?.label}</h2>
590
+ <button onClick={sheet.close} className="-mr-2 px-3 py-2 text-lg text-gray-400" aria-label="Close panel">
591
+ ✕
365
592
  </button>
366
- ))}
593
+ </div>
367
594
  </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
595
 
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>
596
+ <div className="touch-targets dock-offset flex-1 overflow-y-auto overscroll-contain p-3">
597
+ <section className={shown("entities")}>
598
+ <div className={heading} style={{ marginTop: 0 }}>
599
+ Entities
600
+ </div>
601
+ <div className="flex flex-wrap gap-1">
602
+ {def.entities.map((e) => (
603
+ <button
604
+ key={e.name}
605
+ className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
606
+ onClick={() => setSelection(e.name)}
607
+ >
608
+ {e.name}
609
+ </button>
610
+ ))}
611
+ </div>
612
+ <InlineForm
613
+ fields={[{ name: "name", placeholder: "new entity" }]}
614
+ submit="+ Entity"
615
+ onSubmit={(v) => {
616
+ apply((d) => addEntity(d, { name: v.name.trim() }));
617
+ setSelection(v.name.trim());
618
+ }}
619
+ />
620
+
621
+ {selected && (
622
+ <>
623
+ <div className={`${heading} flex items-center justify-between gap-2`}>
624
+ <span className="truncate text-sm normal-case tracking-normal text-gray-200">{selected.name}</span>
625
+ <button
626
+ className={`${button} normal-case tracking-normal`}
627
+ onClick={() => showOnBoard(selected.name)}
628
+ title="Bring this entity to the middle of the board at a readable size"
629
+ >
630
+ ◎ Show on board
631
+ </button>
632
+ </div>
633
+ <label
634
+ className="mb-1 flex items-center gap-2 text-[11px] text-gray-400"
635
+ title="Exists only through another entity (an order line, without its order). Chen draws it as a double rectangle."
636
+ >
637
+ <input
638
+ type="checkbox"
639
+ checked={!!selected.weak}
640
+ onChange={(e) => apply((d) => updateEntity(d, selected.name, { weak: e.target.checked }))}
641
+ />
642
+ weak entity
643
+ </label>
644
+ {(selected.attributes ?? []).map((a) => (
645
+ <div key={a.name} className="flex items-center justify-between gap-2 py-0.5 font-mono text-[11px]">
646
+ <span className={a.stateOf ? "text-blue-300" : "text-gray-300"}>
647
+ {a.name} : {a.type}
648
+ <span className="text-gray-500">
649
+ {a.key ? ` ${a.key.toUpperCase()}` : ""}
650
+ {a.references ? ` → ${a.references}` : ""}
651
+ {a.stateOf ? ` ◉ ${[a.stateOf].flat().join(", ")}` : ""}
652
+ </span>
653
+ </span>
654
+ <button
655
+ className="text-gray-500 hover:text-red-400"
656
+ aria-label={`Remove ${a.name}`}
657
+ onClick={() => apply((d) => removeAttribute(d, selected.name, a.name))}
658
+ >
659
+ ✕
660
+ </button>
661
+ </div>
662
+ ))}
663
+ <InlineForm
664
+ fields={[
665
+ { name: "name", placeholder: "attribute" },
666
+ { name: "type", placeholder: "type", initial: "string" },
667
+ { name: "key", options: ["", "pk", "uk"] },
668
+ { name: "references", placeholder: "references Entity.attr" },
669
+ { name: "stateOf", placeholder: "stateOf Machine" },
670
+ ]}
671
+ submit="+ Attribute"
672
+ onSubmit={(v) =>
673
+ apply((d) =>
674
+ addAttribute(d, selected.name, {
675
+ name: v.name.trim(),
676
+ type: v.type.trim() || "string",
677
+ key: v.key === "pk" || v.key === "uk" ? v.key : undefined,
678
+ references: v.references.trim() || undefined,
679
+ stateOf: v.stateOf.trim() || undefined,
680
+ }),
681
+ )
682
+ }
683
+ />
684
+ <button
685
+ className={`${button} mt-2 text-red-300`}
686
+ onClick={() => {
687
+ apply((d) => removeEntity(d, selected.name));
688
+ setSelection(null);
689
+ }}
690
+ >
691
+ 🗑 Delete {selected.name}
692
+ </button>
693
+ </>
694
+ )}
695
+ </section>
696
+
697
+ <section className={shown("relations")}>
698
+ <div className={`${heading} max-md:mt-0`}>Relationships</div>
699
+ {def.relationships.length === 0 && <div className="py-0.5 text-[11px] text-gray-600">None yet.</div>}
700
+ {def.relationships.map((r, i) => (
701
+ <div key={i} className="flex items-center justify-between gap-2 py-0.5 text-[11px] text-gray-300">
702
+ <span>
703
+ {r.from} <span className="text-gray-500">{r.cardinality}</span> {r.to}
704
+ {r.label ? <span className="text-gray-500"> : {r.label}</span> : null}
400
705
  </span>
401
706
  <button
402
707
  className="text-gray-500 hover:text-red-400"
403
- aria-label={`Remove ${a.name}`}
404
- onClick={() => apply((d) => removeAttribute(d, selected.name, a.name))}
708
+ aria-label="Remove relationship"
709
+ onClick={() => apply((d) => removeRelationship(d, r.from, r.to, r.label))}
405
710
  >
406
711
  ✕
407
712
  </button>
408
713
  </div>
409
714
  ))}
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
- }
715
+ {def.entities.length > 0 && (
716
+ <InlineForm
717
+ fields={[
718
+ { name: "from", options: def.entities.map((e) => e.name) },
719
+ { name: "cardinality", options: [...ERD_CARDINALITIES], initial: "1:N" },
720
+ { name: "to", options: def.entities.map((e) => e.name) },
721
+ { name: "label", placeholder: "label (a verb)" },
722
+ ]}
723
+ submit="+ Relationship"
724
+ onSubmit={(v) =>
725
+ apply((d) =>
726
+ addRelationship(d, {
727
+ from: v.from,
728
+ to: v.to,
729
+ cardinality: v.cardinality as ErdCardinality,
730
+ label: v.label.trim() || undefined,
731
+ }),
732
+ )
733
+ }
734
+ />
735
+ )}
736
+ </section>
737
+
738
+ <section className={shown("notes")}>
739
+ <div className={`${heading} max-md:mt-0`}>Notes</div>
740
+ <p className="mb-1 text-[11px] text-gray-600">
741
+ Saved in the document, for whoever opens it next — you, or an agent (it reads them with get_notes).
742
+ </p>
743
+ <NotesField
744
+ key="diagram"
745
+ label="On this diagram"
746
+ value={def.settings.notes ?? ""}
747
+ placeholder="What was discussed, what is still open…"
748
+ onSave={(notes) => apply((d) => updateErdSettings(d, { notes }))}
430
749
  />
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
- ))}
750
+ {selected ? (
751
+ <NotesField
752
+ key={`entity:${selected.name}`}
753
+ label={`On ${selected.name}`}
754
+ value={selected.notes ?? ""}
755
+ placeholder={`A question or a decision about ${selected.name}…`}
756
+ onSave={(notes) => apply((d) => updateEntity(d, selected.name, { notes }))}
757
+ />
758
+ ) : (
759
+ <p className="mt-2 text-[11px] text-gray-600">Select an entity to leave a note on it.</p>
760
+ )}
761
+ {def.entities.some((e) => e.notes?.trim()) && (
762
+ <>
763
+ <div className={heading}>Entities with notes</div>
764
+ <div className="flex flex-wrap gap-1">
765
+ {def.entities
766
+ .filter((e) => e.notes?.trim())
767
+ .map((e) => (
768
+ <button
769
+ key={e.name}
770
+ className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
771
+ onClick={() => setSelection(e.name)}
772
+ >
773
+ {e.name}
774
+ </button>
775
+ ))}
776
+ </div>
777
+ </>
778
+ )}
779
+ </section>
780
+
781
+ <section className={shown("issues")}>
782
+ <div className={`${heading} max-md:mt-0 ${problemCount === 0 && !linkReport ? "md:hidden" : ""}`}>Issues</div>
783
+ {problems.map((p, i) => (
784
+ <div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
785
+ [{p.ruleId}] {p.message}
786
+ </div>
787
+ ))}
788
+ {linkProblems.map((p, i) => (
789
+ <div key={`l${i}`} className="py-0.5 text-[11px] text-amber-300">
790
+ [{p.ruleId}] {p.message}
791
+ </div>
792
+ ))}
793
+ {linkReport === "none" && (
794
+ <div className="py-0.5 text-[11px] text-amber-300">
795
+ No state machine (.smdf.json) sits beside this document, so there was nothing to check it against.
796
+ </div>
797
+ )}
798
+ {problemCount === 0 && linkReport !== "none" && (
799
+ <div className={`py-0.5 text-[11px] ${linkReport ? "text-green-400" : "text-gray-600 md:hidden"}`}>
800
+ {linkReport
801
+ ? "Every name the machines use resolves."
802
+ : "The document is valid. “Check links” (⋯) holds the state machines beside it against it."}
803
+ </div>
804
+ )}
805
+ </section>
806
+ </div>
495
807
  </aside>
496
808
  )}
497
809
  </div>
810
+
811
+ {/* Tab dock (phone). Three tabs one thumb-tap away, and the issue count is on screen at all times. */}
812
+ <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">
813
+ {tabs.map((t) => {
814
+ const current = sheetOpen && tab === t.id;
815
+ return (
816
+ <button
817
+ key={t.id}
818
+ onClick={() => openTab(t.id)}
819
+ aria-pressed={current}
820
+ className={`relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-colors ${
821
+ current ? "text-blue-400" : "text-gray-500"
822
+ }`}
823
+ >
824
+ <span className="flex h-4 items-center text-base leading-none">{t.icon}</span>
825
+ <span>{t.label}</span>
826
+ {t.badge !== undefined && (
827
+ <span className="absolute right-1/2 top-1 min-w-[16px] translate-x-5 rounded-full border border-red-900/80 bg-red-950/80 px-1 text-[10px] leading-4 text-red-200">
828
+ {t.badge}
829
+ </span>
830
+ )}
831
+ {current && <span className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-blue-400" />}
832
+ </button>
833
+ );
834
+ })}
835
+ </nav>
498
836
  </div>
499
837
  );
500
838
  }
501
839
 
840
+ /**
841
+ * A notes box that saves by itself: shortly after the typing stops, and when
842
+ * focus leaves it. Each save is a whole-document write, so it is not done per
843
+ * keystroke. When the saved text changes underneath — an agent's set_notes,
844
+ * for instance — the box takes it, unless someone is typing in it: a save of
845
+ * their own words coming back must never pull the text out from under them.
846
+ */
847
+ function NotesField({
848
+ label,
849
+ value,
850
+ placeholder,
851
+ onSave,
852
+ }: {
853
+ label: string;
854
+ value: string;
855
+ placeholder: string;
856
+ onSave: (notes: string) => void;
857
+ }) {
858
+ const [draft, setDraft] = useState(value);
859
+ const [seen, setSeen] = useState(value);
860
+ const [focused, setFocused] = useState(false);
861
+ if (value !== seen) {
862
+ setSeen(value);
863
+ if (!focused) setDraft(value);
864
+ }
865
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
866
+ const save = (text: string): void => {
867
+ if (timer.current) clearTimeout(timer.current);
868
+ timer.current = null;
869
+ if (text !== value) onSave(text);
870
+ };
871
+ return (
872
+ <label className="mt-2 block">
873
+ <span className="text-[11px] text-gray-500">{label}</span>
874
+ <textarea
875
+ className={`${input} h-24 resize-y leading-snug`}
876
+ placeholder={placeholder}
877
+ value={draft}
878
+ onChange={(e) => {
879
+ const text = e.target.value;
880
+ setDraft(text);
881
+ if (timer.current) clearTimeout(timer.current);
882
+ timer.current = setTimeout(() => save(text), 900);
883
+ }}
884
+ onFocus={() => setFocused(true)}
885
+ onBlur={() => {
886
+ setFocused(false);
887
+ save(draft);
888
+ }}
889
+ />
890
+ </label>
891
+ );
892
+ }
893
+
502
894
  interface Field {
503
895
  name: string;
504
896
  placeholder?: string;
@@ -559,7 +951,7 @@ function InlineForm({
559
951
  />
560
952
  ),
561
953
  )}
562
- <button type="submit" className={`${button} col-span-2`}>
954
+ <button type="submit" className={`${button} ${fields.length === 1 ? "" : "col-span-2"}`}>
563
955
  {submit}
564
956
  </button>
565
957
  </form>