@miadi/stateloom-web 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) 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/[root-of-the-server]__13c3d2a8._.js +1 -1
  35. package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
  36. package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
  37. package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +3 -3
  38. package/server/.next/server/chunks/ssr/{[root-of-the-server]__46d45ef8._.js → [root-of-the-server]__b1b2bf74._.js} +2 -2
  39. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +4 -3
  40. package/server/.next/server/pages/404.html +1 -1
  41. package/server/.next/server/pages/500.html +2 -2
  42. package/server/.next/static/chunks/8458f462f4687e07.js +11 -0
  43. package/server/.next/static/chunks/a8a37c487ef91a19.js +1 -0
  44. package/server/.next/static/chunks/c86e96e4d6eeb632.css +2 -0
  45. package/server/package-lock.json +2 -2
  46. package/server/package.json +1 -1
  47. package/server/src/app/api/file/route.ts +57 -12
  48. package/server/src/app/globals.css +15 -0
  49. package/server/src/app/page.tsx +69 -20
  50. package/server/src/components/BoardBoundary.tsx +46 -0
  51. package/server/src/components/IssueIcon.tsx +28 -0
  52. package/server/src/components/LockIcon.tsx +24 -0
  53. package/server/src/components/PropertiesPanel.tsx +10 -0
  54. package/server/src/components/SettingsPanel.tsx +11 -0
  55. package/server/src/components/erd/ErdWorkspace.tsx +198 -21
  56. package/server/src/components/forms.tsx +129 -0
  57. package/server/src/components/sequence/SequenceWorkspace.tsx +1024 -0
  58. package/server/src/components/system/KindIcon.tsx +71 -0
  59. package/server/src/components/system/SystemStrip.tsx +285 -0
  60. package/server/src/components/system/SystemWorkspace.tsx +766 -0
  61. package/server/src/lib/docParam.ts +4 -1
  62. package/server/src/lib/machineFocus.ts +126 -0
  63. package/server/src/lib/projectFile.ts +180 -20
  64. package/server/src/lib/safely.ts +16 -0
  65. package/server/src/lib/systemLoad.ts +362 -0
  66. package/server/src/lib/systemParam.ts +110 -0
  67. package/server/src/lib/useSheetDrag.ts +95 -0
  68. package/server/src/types/definition.ts +4 -0
  69. package/server/stateloom-web.json +2 -2
  70. package/server/tsconfig.tsbuildinfo +1 -1
  71. package/server/.next/static/chunks/8f6bd51bbcce6314.css +0 -2
  72. package/server/.next/static/chunks/a9e7748193d11ed7.js +0 -10
  73. package/server/.next/static/chunks/aea5b1716c68391c.js +0 -1
  74. /package/server/.next/static/{XCT3lnw_1F5LD0GG4F94- → KjjOvPCIL-ta0AZ8r8SFk}/_buildManifest.js +0 -0
  75. /package/server/.next/static/{XCT3lnw_1F5LD0GG4F94- → KjjOvPCIL-ta0AZ8r8SFk}/_clientMiddlewareManifest.json +0 -0
  76. /package/server/.next/static/{XCT3lnw_1F5LD0GG4F94- → KjjOvPCIL-ta0AZ8r8SFk}/_ssgManifest.js +0 -0
@@ -21,7 +21,7 @@
21
21
  * `add_entity`. The edits are the protocol's pure functions, the same ones the
22
22
  * MCP tools call, so a hand and an agent make identical changes.
23
23
  */
24
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
24
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
25
25
  import { EntityRelationshipCanvas } from "@miadi/stateloom-canvas";
26
26
  import { createBridgeSession, type BridgeSession } from "@miadi/stateloom-react";
27
27
  import {
@@ -37,11 +37,13 @@ import {
37
37
  emptyErd,
38
38
  erdAutoLayout,
39
39
  isErdDefinition,
40
+ parseFocus,
40
41
  removeAttribute,
41
42
  removeEntity,
42
43
  removeRelationship,
43
44
  renderMermaidEr,
44
45
  updateEntity,
46
+ updateErdSettings,
45
47
  validateErd,
46
48
  type EntityRelationshipDefinition,
47
49
  type ErdCardinality,
@@ -51,12 +53,18 @@ import {
51
53
  type StateMachineDefinition,
52
54
  type Viewport,
53
55
  } from "@miadi/stateloom-protocol";
56
+ import BoardBoundary from "@/components/BoardBoundary";
54
57
  import DocSwitcher from "@/components/DocSwitcher";
58
+ import IssueIcon from "@/components/IssueIcon";
59
+ import LockIcon from "@/components/LockIcon";
55
60
  import { docQuery, navigateToDoc, useRequestedDoc } from "@/lib/docParam";
56
61
  import { loadRuntimeConfig } from "@/lib/runtimeConfig";
62
+ import { useSheetDrag } from "@/lib/useSheetDrag";
63
+ import { urlParam, useFocusRequest, writeFocus } from "@/lib/systemParam";
64
+ import { safely } from "@/lib/safely";
57
65
 
58
66
  type Moved = Record<string, { x: number; y: number }>;
59
- type Tab = "entities" | "relations" | "problems";
67
+ type Tab = "entities" | "relations" | "notes" | "issues";
60
68
  type Edit = (def: EntityRelationshipDefinition) => EntityRelationshipDefinition;
61
69
 
62
70
  // The two notations give an entity different footprints, so each keeps its own
@@ -65,6 +73,22 @@ const layoutKey = (docPath: string, notation: ErdNotation): string =>
65
73
  notation === "chen" ? `stateloom:erd-layout:chen:${docPath}` : `stateloom:erd-layout:${docPath}`;
66
74
 
67
75
  const NOTATION_KEY = "stateloom:erd-notation";
76
+ const LOCK_KEY = "stateloom:erd-lock";
77
+
78
+ /**
79
+ * Whether shapes start locked in place. A stored choice wins; without one, a
80
+ * touch screen starts locked — there a drag is almost always "move the view",
81
+ * and a shape that slides away under the thumb is the accident, not the intent.
82
+ */
83
+ function readLocked(): boolean {
84
+ try {
85
+ const stored = localStorage.getItem(LOCK_KEY);
86
+ if (stored !== null) return stored === "1";
87
+ return window.matchMedia("(pointer: coarse)").matches;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
68
92
 
69
93
  function readMoved(docPath: string, notation: ErdNotation): Moved {
70
94
  try {
@@ -120,6 +144,8 @@ export default function ErdWorkspace() {
120
144
  const [def, setDef] = useState<EntityRelationshipDefinition | null>(null);
121
145
  const [moved, setMoved] = useState<Moved>({});
122
146
  const [notation, setNotation] = useState<ErdNotation>("crowsfoot");
147
+ // Locked: a drag that starts on a shape pans the board instead of moving the shape. A tap still selects.
148
+ const [locked, setLocked] = useState(false);
123
149
  const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
124
150
  const [fitKey, setFitKey] = useState(0);
125
151
  const [selection, setSelection] = useState<string | null>(null);
@@ -142,6 +168,8 @@ export default function ErdWorkspace() {
142
168
  // Desktop only: the right column can be folded away.
143
169
  const [panelHidden, setPanelHidden] = useState(false);
144
170
  const boardRef = useRef<HTMLDivElement | null>(null);
171
+ // The sheet's handle answers a thumb: pull down to close, up to grow, tap to toggle.
172
+ const sheet = useSheetDrag({ onClose: () => setSheetOpen(false), restVh: 62, tallVh: 92 });
145
173
  const [peers, setPeers] = useState(0);
146
174
  // null: not checked. "none": checked, and no state machine sits beside the document.
147
175
  const [linkReport, setLinkReport] = useState<ErdValidationError[] | "none" | null>(null);
@@ -169,6 +197,7 @@ export default function ErdWorkspace() {
169
197
  setDocPath(path);
170
198
  setDef(loaded ?? emptyErd("Default", name));
171
199
  setNotation(preferred);
200
+ setLocked(readLocked());
172
201
  setMoved(readMoved(path, preferred));
173
202
  setSelection(null);
174
203
  setLinkReport(null);
@@ -204,14 +233,20 @@ export default function ErdWorkspace() {
204
233
 
205
234
  const positions = useMemo<Record<string, LayoutBox>>(() => {
206
235
  if (!def) return {};
207
- const derived = erdAutoLayout(def, { notation });
236
+ // A malformed document lays out as nothing rather than taking the page down; the board says why.
237
+ const derived = safely<Record<string, LayoutBox>>(() => erdAutoLayout(def, { notation }), {});
208
238
  for (const [name, at] of Object.entries(moved)) {
209
239
  if (derived[name]) derived[name] = { ...derived[name], ...at };
210
240
  }
211
241
  return derived;
212
242
  }, [def, moved, notation]);
213
243
 
214
- const problems = useMemo(() => (def ? validateErd(def) : []), [def]);
244
+ const problems = useMemo<ErdValidationError[]>(() => {
245
+ if (!def) return [];
246
+ let failure = "";
247
+ const found = safely<ErdValidationError[] | null>(() => validateErd(def), null, (m) => (failure = m));
248
+ return found ?? [{ ruleId: "E000", message: `The document could not be checked (${failure}): a field is not the shape Spec 80 gives it` }];
249
+ }, [def]);
215
250
  const errorEntities = useMemo(
216
251
  () => problems.map((p) => (p.element ?? "").split(/[.[]/)[0]).filter(Boolean),
217
252
  [problems],
@@ -287,7 +322,7 @@ export default function ErdWorkspace() {
287
322
  const machines = await siblingMachines(docPath);
288
323
  if (machines.length === 0) {
289
324
  setLinkReport("none");
290
- setTab("problems");
325
+ setTab("issues");
291
326
  setSheetOpen(true);
292
327
  return setStatus("no .smdf.json beside this document to check against", "warn");
293
328
  }
@@ -296,8 +331,8 @@ export default function ErdWorkspace() {
296
331
  );
297
332
  found.push(...checkStateOf(def, machines.map((m) => m.def.settings?.name ?? "")));
298
333
  setLinkReport(found);
299
- // The answer lives in the Problems tab, so go there — on a phone it is otherwise behind a closed sheet.
300
- setTab("problems");
334
+ // The answer lives in the Issues tab, so go there — on a phone it is otherwise behind a closed sheet.
335
+ setTab("issues");
301
336
  setSheetOpen(true);
302
337
  setPanelHidden(false);
303
338
  setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`, found.length ? "warn" : "ok");
@@ -314,6 +349,17 @@ export default function ErdWorkspace() {
314
349
  return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
315
350
  };
316
351
 
352
+ const toggleLock = (): void => {
353
+ const next = !locked;
354
+ setLocked(next);
355
+ try {
356
+ localStorage.setItem(LOCK_KEY, next ? "1" : "0");
357
+ } catch {
358
+ // The choice then lasts for this visit only.
359
+ }
360
+ setStatus(next ? "shapes locked — a drag anywhere moves the view" : "shapes unlocked — drag one to move it");
361
+ };
362
+
317
363
  const zoomBy = (factor: number): void => {
318
364
  const { width, height } = boardSize();
319
365
  setViewport((vp) => zoomAt(vp, factor, { x: width / 2, y: height / 2 }));
@@ -326,13 +372,31 @@ export default function ErdWorkspace() {
326
372
  if (!box || !width) return;
327
373
  setViewport(fitToBoxes([box], width, height, { padding: 28, maxScale: 1.25 }));
328
374
  setSelection(name);
329
- setSheetOpen(false);
375
+ sheet.close();
330
376
  };
331
377
 
378
+ // A focus from the URL, the system strip or an agent (Spec 82): select it and
379
+ // show it. `attribute:E.a` shows its entity. A person's selection becomes the
380
+ // focus in turn, so the strip can list what it links to.
381
+ const focusRequest = useFocusRequest();
382
+ const appliedFocus = useRef(-1);
383
+ useEffect(() => {
384
+ if (!def || appliedFocus.current === focusRequest.nonce) return;
385
+ appliedFocus.current = focusRequest.nonce;
386
+ const f = focusRequest.focus ? parseFocus(focusRequest.focus) : null;
387
+ const name = f?.kind === "entity" ? f.name : f?.kind === "attribute" ? f.name.split(".")[0] : null;
388
+ if (name && positions[name]) showOnBoard(name);
389
+ // eslint-disable-next-line react-hooks/exhaustive-deps
390
+ }, [def, focusRequest, positions]);
391
+ useEffect(() => {
392
+ if (selection && !urlParam("focus")?.startsWith(`attribute:${selection}.`)) writeFocus(`entity:${selection}`);
393
+ }, [selection]);
394
+
332
395
  const openTab = (next: Tab): void => {
333
396
  // Tapping the tab you are already reading closes the sheet, so the dock is
334
397
  // both switcher and dismiss control.
335
- setSheetOpen((wasOpen) => !(wasOpen && tab === next));
398
+ if (sheetOpen && tab === next) sheet.close();
399
+ else setSheetOpen(true);
336
400
  setTab(next);
337
401
  };
338
402
 
@@ -340,10 +404,11 @@ export default function ErdWorkspace() {
340
404
  const fileName = docPath.split("/").pop() || "…";
341
405
  const linkProblems = Array.isArray(linkReport) ? linkReport : [];
342
406
  const problemCount = problems.length + linkProblems.length;
343
- const tabs: { id: Tab; label: string; icon: string; badge?: number }[] = [
407
+ const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
344
408
  { id: "entities", label: "Entities", icon: "▭" },
345
409
  { id: "relations", label: "Relations", icon: "⟷" },
346
- { id: "problems", label: "Problems", icon: "⚠", badge: problemCount || undefined },
410
+ { id: "notes", label: "Notes", icon: "✎", badge: undefined },
411
+ { id: "issues", label: "Issues", icon: <IssueIcon />, badge: problemCount || undefined },
347
412
  ];
348
413
  const shown = (id: Tab): string => (tab === id ? "" : "hidden md:block");
349
414
 
@@ -448,6 +513,7 @@ export default function ErdWorkspace() {
448
513
  <div className="relative flex min-h-0 flex-1">
449
514
  <div ref={boardRef} className="relative min-w-0 flex-1 overflow-hidden">
450
515
  {def && (
516
+ <BoardBoundary what="diagram" resetKey={def}>
451
517
  <EntityRelationshipCanvas
452
518
  definition={def}
453
519
  notation={notation}
@@ -455,6 +521,7 @@ export default function ErdWorkspace() {
455
521
  viewport={viewport}
456
522
  onViewportChange={setViewport}
457
523
  selection={selection}
524
+ readOnly={locked}
458
525
  errorElements={errorEntities}
459
526
  fitKey={fitKey}
460
527
  onSelect={setSelection}
@@ -463,6 +530,7 @@ export default function ErdWorkspace() {
463
530
  onOpenMachine={openMachine}
464
531
  emptyHint="No entities yet — add one in the panel, or let an agent call add_entity."
465
532
  />
533
+ </BoardBoundary>
466
534
  )}
467
535
 
468
536
  {/* Status costs no layout: it is a line over the board's corner. */}
@@ -485,6 +553,17 @@ export default function ErdWorkspace() {
485
553
 
486
554
  {/* View controls live on the board they act on. */}
487
555
  <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">
556
+ <button
557
+ title={locked ? "Shapes are locked: dragging moves the view. Tap to unlock." : "Lock the shapes in place, so dragging only moves the view"}
558
+ aria-label="Lock shapes in place"
559
+ aria-pressed={locked}
560
+ onClick={toggleLock}
561
+ 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 ${
562
+ locked ? "bg-blue-950/60 text-blue-300" : "text-gray-400"
563
+ }`}
564
+ >
565
+ <LockIcon locked={locked} />
566
+ </button>
488
567
  {(
489
568
  [
490
569
  ["+", "Zoom in", () => zoomBy(1.25)],
@@ -519,24 +598,25 @@ export default function ErdWorkspace() {
519
598
  </div>
520
599
 
521
600
  {sheetOpen && (
522
- <div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={() => setSheetOpen(false)} aria-hidden="true" />
601
+ <div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={sheet.close} aria-hidden="true" />
523
602
  )}
524
603
 
525
604
  {def && (
526
605
  <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 ${
606
+ style={sheet.sheetStyle}
607
+ 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 ${
528
608
  sheetOpen ? "translate-y-0" : "pointer-events-none translate-y-full"
529
609
  } 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
610
  panelHidden ? "md:hidden" : ""
531
611
  }`}
532
612
  >
533
- <div className="md:hidden">
613
+ <div className="md:hidden" {...sheet.zoneProps}>
534
614
  <div className="flex justify-center pb-1 pt-2">
535
- <span className="h-1 w-10 rounded-full bg-gray-700" />
615
+ <span className="h-1.5 w-12 rounded-full bg-gray-600" />
536
616
  </div>
537
617
  <div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
538
618
  <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">
619
+ <button onClick={sheet.close} className="-mr-2 px-3 py-2 text-lg text-gray-400" aria-label="Close panel">
540
620
  ✕
541
621
  </button>
542
622
  </div>
@@ -684,8 +764,51 @@ export default function ErdWorkspace() {
684
764
  )}
685
765
  </section>
686
766
 
687
- <section className={shown("problems")}>
688
- <div className={`${heading} max-md:mt-0 ${problemCount === 0 && !linkReport ? "md:hidden" : ""}`}>Problems</div>
767
+ <section className={shown("notes")}>
768
+ <div className={`${heading} max-md:mt-0`}>Notes</div>
769
+ <p className="mb-1 text-[11px] text-gray-600">
770
+ Saved in the document, for whoever opens it next — you, or an agent (it reads them with get_notes).
771
+ </p>
772
+ <NotesField
773
+ key="diagram"
774
+ label="On this diagram"
775
+ value={def.settings.notes ?? ""}
776
+ placeholder="What was discussed, what is still open…"
777
+ onSave={(notes) => apply((d) => updateErdSettings(d, { notes }))}
778
+ />
779
+ {selected ? (
780
+ <NotesField
781
+ key={`entity:${selected.name}`}
782
+ label={`On ${selected.name}`}
783
+ value={selected.notes ?? ""}
784
+ placeholder={`A question or a decision about ${selected.name}…`}
785
+ onSave={(notes) => apply((d) => updateEntity(d, selected.name, { notes }))}
786
+ />
787
+ ) : (
788
+ <p className="mt-2 text-[11px] text-gray-600">Select an entity to leave a note on it.</p>
789
+ )}
790
+ {def.entities.some((e) => e.notes?.trim()) && (
791
+ <>
792
+ <div className={heading}>Entities with notes</div>
793
+ <div className="flex flex-wrap gap-1">
794
+ {def.entities
795
+ .filter((e) => e.notes?.trim())
796
+ .map((e) => (
797
+ <button
798
+ key={e.name}
799
+ className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
800
+ onClick={() => setSelection(e.name)}
801
+ >
802
+ {e.name}
803
+ </button>
804
+ ))}
805
+ </div>
806
+ </>
807
+ )}
808
+ </section>
809
+
810
+ <section className={shown("issues")}>
811
+ <div className={`${heading} max-md:mt-0 ${problemCount === 0 && !linkReport ? "md:hidden" : ""}`}>Issues</div>
689
812
  {problems.map((p, i) => (
690
813
  <div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
691
814
  [{p.ruleId}] {p.message}
@@ -714,7 +837,7 @@ export default function ErdWorkspace() {
714
837
  )}
715
838
  </div>
716
839
 
717
- {/* Tab dock (phone). Three tabs one thumb-tap away, and the problem count is on screen at all times. */}
840
+ {/* Tab dock (phone). Three tabs one thumb-tap away, and the issue count is on screen at all times. */}
718
841
  <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
842
  {tabs.map((t) => {
720
843
  const current = sheetOpen && tab === t.id;
@@ -727,10 +850,10 @@ export default function ErdWorkspace() {
727
850
  current ? "text-blue-400" : "text-gray-500"
728
851
  }`}
729
852
  >
730
- <span className="text-base leading-none">{t.icon}</span>
853
+ <span className="flex h-4 items-center text-base leading-none">{t.icon}</span>
731
854
  <span>{t.label}</span>
732
855
  {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">
856
+ <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">
734
857
  {t.badge}
735
858
  </span>
736
859
  )}
@@ -743,6 +866,60 @@ export default function ErdWorkspace() {
743
866
  );
744
867
  }
745
868
 
869
+ /**
870
+ * A notes box that saves by itself: shortly after the typing stops, and when
871
+ * focus leaves it. Each save is a whole-document write, so it is not done per
872
+ * keystroke. When the saved text changes underneath — an agent's set_notes,
873
+ * for instance — the box takes it, unless someone is typing in it: a save of
874
+ * their own words coming back must never pull the text out from under them.
875
+ */
876
+ export function NotesField({
877
+ label,
878
+ value,
879
+ placeholder,
880
+ onSave,
881
+ }: {
882
+ label: string;
883
+ value: string;
884
+ placeholder: string;
885
+ onSave: (notes: string) => void;
886
+ }) {
887
+ const [draft, setDraft] = useState(value);
888
+ const [seen, setSeen] = useState(value);
889
+ const [focused, setFocused] = useState(false);
890
+ if (value !== seen) {
891
+ setSeen(value);
892
+ if (!focused) setDraft(value);
893
+ }
894
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
895
+ const save = (text: string): void => {
896
+ if (timer.current) clearTimeout(timer.current);
897
+ timer.current = null;
898
+ if (text !== value) onSave(text);
899
+ };
900
+ return (
901
+ <label className="mt-2 block">
902
+ <span className="text-[11px] text-gray-500">{label}</span>
903
+ <textarea
904
+ className={`${input} h-24 resize-y leading-snug`}
905
+ placeholder={placeholder}
906
+ value={draft}
907
+ onChange={(e) => {
908
+ const text = e.target.value;
909
+ setDraft(text);
910
+ if (timer.current) clearTimeout(timer.current);
911
+ timer.current = setTimeout(() => save(text), 900);
912
+ }}
913
+ onFocus={() => setFocused(true)}
914
+ onBlur={() => {
915
+ setFocused(false);
916
+ save(draft);
917
+ }}
918
+ />
919
+ </label>
920
+ );
921
+ }
922
+
746
923
  interface Field {
747
924
  name: string;
748
925
  placeholder?: string;
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ /**
4
+ * A small form for the panels of the sequence and system workspaces: text,
5
+ * select and checkbox fields, each with an initial value, and one submit.
6
+ *
7
+ * Used both to add (`reset` clears the text fields after a submit, keeping the
8
+ * selects, as the ERD panel's form does) and to edit (the host keys the form
9
+ * by the element it edits, so a changed element re-seeds it).
10
+ */
11
+ import { useState } from "react";
12
+
13
+ export const panelInput =
14
+ "w-full rounded border border-gray-700 bg-gray-950 px-2 py-1 text-xs text-gray-200 placeholder:text-gray-600";
15
+ export const panelButton = "rounded border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-200 hover:bg-gray-700";
16
+ export const panelHeading = "mb-1 mt-3 text-[11px] font-semibold uppercase tracking-wide text-gray-500";
17
+
18
+ export interface FormField {
19
+ name: string;
20
+ label?: string;
21
+ placeholder?: string;
22
+ /** A select. `""` is shown as `(label)`. */
23
+ options?: readonly string[];
24
+ /** A text field that suggests these (a datalist); any text is still accepted. */
25
+ suggest?: readonly string[];
26
+ checkbox?: boolean;
27
+ initial?: string | boolean;
28
+ /** Takes the whole row. */
29
+ wide?: boolean;
30
+ }
31
+
32
+ export type FormValues = Record<string, string | boolean>;
33
+
34
+ export function FieldForm({
35
+ id,
36
+ fields,
37
+ submit,
38
+ onSubmit,
39
+ reset = false,
40
+ required,
41
+ }: {
42
+ /** Prefix for datalist ids — unique per form on the page. */
43
+ id: string;
44
+ fields: FormField[];
45
+ submit: string;
46
+ onSubmit: (values: FormValues) => void;
47
+ reset?: boolean;
48
+ /** Fields that must not be empty for the submit to go through. */
49
+ required?: string[];
50
+ }) {
51
+ const initial = (): FormValues =>
52
+ Object.fromEntries(
53
+ fields.map((f) => [f.name, f.initial ?? (f.checkbox ? false : f.options ? (f.options[0] ?? "") : "")]),
54
+ );
55
+ const [values, setValues] = useState<FormValues>(initial);
56
+ const set = (name: string, value: string | boolean) => setValues((v) => ({ ...v, [name]: value }));
57
+ return (
58
+ <form
59
+ className="mt-1 grid grid-cols-2 gap-1"
60
+ onSubmit={(e) => {
61
+ e.preventDefault();
62
+ const merged = { ...initial(), ...values };
63
+ if ((required ?? []).some((n) => !String(merged[n] ?? "").trim())) return;
64
+ onSubmit(merged);
65
+ if (reset) {
66
+ setValues((prev) =>
67
+ Object.fromEntries(
68
+ fields.map((f) => [f.name, f.options || f.checkbox ? (prev[f.name] ?? "") : (f.initial ?? "")]),
69
+ ),
70
+ );
71
+ }
72
+ }}
73
+ >
74
+ {fields.map((f) => {
75
+ const title = f.label ?? f.name;
76
+ if (f.checkbox) {
77
+ return (
78
+ <label key={f.name} className="flex items-center gap-2 text-[11px] text-gray-400">
79
+ <input type="checkbox" checked={!!values[f.name]} onChange={(e) => set(f.name, e.target.checked)} />
80
+ {title}
81
+ </label>
82
+ );
83
+ }
84
+ if (f.options) {
85
+ return (
86
+ <select
87
+ key={f.name}
88
+ className={`${panelInput} ${f.wide ? "col-span-2" : ""}`}
89
+ aria-label={title}
90
+ title={title}
91
+ value={String(values[f.name] ?? "")}
92
+ onChange={(e) => set(f.name, e.target.value)}
93
+ >
94
+ {f.options.map((o) => (
95
+ <option key={o} value={o}>
96
+ {o || `(${title})`}
97
+ </option>
98
+ ))}
99
+ </select>
100
+ );
101
+ }
102
+ const listId = f.suggest?.length ? `${id}-${f.name}` : undefined;
103
+ return (
104
+ <span key={f.name} className={f.wide ? "col-span-2" : ""}>
105
+ <input
106
+ className={panelInput}
107
+ aria-label={title}
108
+ title={title}
109
+ placeholder={f.placeholder ?? title}
110
+ list={listId}
111
+ value={String(values[f.name] ?? "")}
112
+ onChange={(e) => set(f.name, e.target.value)}
113
+ />
114
+ {listId && (
115
+ <datalist id={listId}>
116
+ {f.suggest!.map((s) => (
117
+ <option key={s} value={s} />
118
+ ))}
119
+ </datalist>
120
+ )}
121
+ </span>
122
+ );
123
+ })}
124
+ <button type="submit" className={`${panelButton} col-span-2`}>
125
+ {submit}
126
+ </button>
127
+ </form>
128
+ );
129
+ }