@miadi/stateloom-web 0.1.8 → 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 +167 -19
  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/8f6bd51bbcce6314.css +0 -2
  53. package/server/.next/static/chunks/a9e7748193d11ed7.js +0 -10
  54. /package/server/.next/static/{XCT3lnw_1F5LD0GG4F94- → 6dAVJYqnbe6oEAABQ1yMG}/_buildManifest.js +0 -0
  55. /package/server/.next/static/{XCT3lnw_1F5LD0GG4F94- → 6dAVJYqnbe6oEAABQ1yMG}/_clientMiddlewareManifest.json +0 -0
  56. /package/server/.next/static/{XCT3lnw_1F5LD0GG4F94- → 6dAVJYqnbe6oEAABQ1yMG}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState, type CSSProperties } from "react";
3
+ import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
4
4
  import Canvas from "@/components/Canvas";
5
5
  import PropertiesPanel from "@/components/PropertiesPanel";
6
6
  import EventsPanel from "@/components/EventsPanel";
@@ -12,9 +12,11 @@ import DesignBridge from "@/components/DesignBridge";
12
12
  import UiScale from "@/components/UiScale";
13
13
  import { useDesignerStore } from "@/store/useDesignerStore";
14
14
  import ErdWorkspace from "@/components/erd/ErdWorkspace";
15
+ import IssueIcon from "@/components/IssueIcon";
15
16
  import { isErdfPath } from "@miadi/stateloom-protocol";
16
17
  import { useRequestedDoc } from "@/lib/docParam";
17
18
  import { loadRuntimeConfig } from "@/lib/runtimeConfig";
19
+ import { useSheetDrag } from "@/lib/useSheetDrag";
18
20
 
19
21
  type Tab = "properties" | "events" | "settings" | "validation";
20
22
 
@@ -78,6 +80,8 @@ function MachineDesigner() {
78
80
  // the entire screen, and at `md` and up the responsive classes pin it open as
79
81
  // the 320px right sidebar it has always been — this flag stops mattering.
80
82
  const [sheetOpen, setSheetOpen] = useState(false);
83
+ // The sheet's handle answers a thumb: pull down to close, up to grow, tap to toggle.
84
+ const sheet = useSheetDrag({ onClose: () => setSheetOpen(false), restVh: 70, tallVh: 92 });
81
85
  const errors = useDesignerStore((s) => s.errors);
82
86
  const contextMenu = useDesignerStore((s) => s.contextMenu);
83
87
  const hideContextMenu = useDesignerStore((s) => s.hideContextMenu);
@@ -86,11 +90,12 @@ function MachineDesigner() {
86
90
  const setDrawMode = useDesignerStore((s) => s.setDrawMode);
87
91
  const setDrawSource = useDesignerStore((s) => s.setDrawSource);
88
92
 
89
- const tabs: { id: Tab; label: string; icon: string; badge?: number }[] = [
93
+ const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
90
94
  { id: "properties", label: "Properties", icon: "🎛" },
91
95
  { id: "events", label: "Events", icon: "⚡" },
92
96
  { id: "settings", label: "Settings", icon: "⚙" },
93
- { id: "validation", label: "Errors", icon: "⚠", badge: errors.length || undefined },
97
+ // Drawn, not typed: "⚠" is a bright yellow colour emoji on iOS. See IssueIcon.
98
+ { id: "validation", label: "Errors", icon: <IssueIcon />, badge: errors.length || undefined },
94
99
  ];
95
100
 
96
101
  const activeLabel = tabs.find((t) => t.id === activeTab)?.label ?? "";
@@ -99,7 +104,8 @@ function MachineDesigner() {
99
104
  // both switcher and dismiss control and no screen height is spent on a
100
105
  // separate close affordance.
101
106
  const toggleTab = (id: Tab) => {
102
- setSheetOpen((wasOpen) => !(wasOpen && activeTab === id));
107
+ if (sheetOpen && activeTab === id) sheet.close();
108
+ else setSheetOpen(true);
103
109
  setActiveTab(id);
104
110
  };
105
111
 
@@ -125,7 +131,7 @@ function MachineDesigner() {
125
131
  {sheetOpen && (
126
132
  <div
127
133
  className="fixed inset-0 z-20 bg-black/50 md:hidden"
128
- onClick={() => setSheetOpen(false)}
134
+ onClick={sheet.close}
129
135
  aria-hidden="true"
130
136
  />
131
137
  )}
@@ -136,21 +142,22 @@ function MachineDesigner() {
136
142
  declaration — position, height, width, transform, radius, border
137
143
  side — which is why the class list is long. */}
138
144
  <aside
139
- className={`safe-x fixed inset-x-0 bottom-0 z-30 h-[70dvh] flex flex-col overflow-hidden rounded-t-2xl border-t border-gray-800 bg-gray-900 shadow-2xl transition-transform duration-200 ease-out ${
145
+ style={sheet.sheetStyle}
146
+ className={`safe-x fixed inset-x-0 bottom-0 z-30 h-[var(--sheet-h)] flex flex-col overflow-hidden rounded-t-2xl border-t border-gray-800 bg-gray-900 shadow-2xl transition-[transform,height] duration-200 ease-out ${
140
147
  sheetOpen ? "translate-y-0" : "translate-y-full pointer-events-none"
141
148
  } md:static md:z-auto md:h-auto md:w-80 md:translate-y-0 md:pointer-events-auto md:rounded-none md:border-t-0 md:border-l md:shadow-none md:transition-none`}
142
149
  >
143
150
  {/* Sheet header (phone). The grab handle reads as "this thing is a
144
151
  sheet"; the dock below already names the tabs, so repeating the
145
152
  tab bar here would only cost height. */}
146
- <div className="md:hidden">
153
+ <div className="md:hidden" {...sheet.zoneProps}>
147
154
  <div className="flex justify-center pt-2 pb-1">
148
- <span className="h-1 w-10 rounded-full bg-gray-700" />
155
+ <span className="h-1.5 w-12 rounded-full bg-gray-600" />
149
156
  </div>
150
157
  <div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
151
158
  <h2 className="text-sm font-semibold text-gray-300">{activeLabel}</h2>
152
159
  <button
153
- onClick={() => setSheetOpen(false)}
160
+ onClick={sheet.close}
154
161
  className="-mr-2 px-3 py-2 text-lg text-gray-400"
155
162
  aria-label="Close panel"
156
163
  >
@@ -215,7 +222,7 @@ function MachineDesigner() {
215
222
  current ? "text-blue-400" : "text-gray-500"
216
223
  }`}
217
224
  >
218
- <span className="text-base leading-none">{tab.icon}</span>
225
+ <span className="flex h-4 items-center text-base leading-none">{tab.icon}</span>
219
226
  <span>{tab.label}</span>
220
227
  {tab.badge !== undefined && tab.badge > 0 && (
221
228
  <span className="absolute top-1 right-1/2 translate-x-4 rounded-full bg-red-600 px-1 text-[10px] leading-4 text-white min-w-[16px]">
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A warning triangle drawn as an outline in the current text colour.
3
+ *
4
+ * The "⚠" character is a full-colour emoji on iOS: a saturated yellow sign in
5
+ * a dock of grey glyphs, pulling the eye whether or not there is anything to
6
+ * report. Drawn here instead, it is as quiet as its neighbours and takes the
7
+ * tab's own colour — grey at rest, the accent when its tab is open. The count
8
+ * badge beside it is what says there is something to look at.
9
+ */
10
+ export default function IssueIcon({ size = 16 }: { size?: number }) {
11
+ return (
12
+ <svg
13
+ width={size}
14
+ height={size}
15
+ viewBox="0 0 24 24"
16
+ fill="none"
17
+ stroke="currentColor"
18
+ strokeWidth="1.6"
19
+ strokeLinecap="round"
20
+ strokeLinejoin="round"
21
+ aria-hidden="true"
22
+ >
23
+ <path d="M12 4.5 21 19.5H3Z" />
24
+ <path d="M12 10v4.5" />
25
+ <path d="M12 17.3v.01" />
26
+ </svg>
27
+ );
28
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A padlock drawn as an outline in the current text colour — closed or open.
3
+ * Drawn rather than typed for the same reason as IssueIcon: the padlock
4
+ * characters are full-colour emoji on iOS, a gold lock on a board of grey.
5
+ */
6
+ export default function LockIcon({ locked, size = 16 }: { locked: boolean; size?: number }) {
7
+ return (
8
+ <svg
9
+ width={size}
10
+ height={size}
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ strokeWidth="1.6"
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ aria-hidden="true"
18
+ >
19
+ <rect x="5" y="11" width="14" height="9" rx="2" />
20
+ {/* The shackle: seated in the body when locked, swung clear of it when not. */}
21
+ <path d={locked ? "M8 11V8a4 4 0 0 1 8 0v3" : "M8 11V8a4 4 0 0 1 7.5-1.9"} />
22
+ </svg>
23
+ );
24
+ }
@@ -116,6 +116,16 @@ export default function PropertiesPanel() {
116
116
  onChange={(e) => updateState(selection.id!, { description: e.target.value || undefined })}
117
117
  />
118
118
  </label>
119
+ {/* Notes: what was said about this state, kept in the document for whoever opens it next. */}
120
+ <label className="block">
121
+ <span className="text-xs text-gray-500">Notes</span>
122
+ <textarea
123
+ className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 h-20"
124
+ placeholder="An open question, a decision, something to tell the agent…"
125
+ value={state.notes ?? ""}
126
+ onChange={(e) => updateState(selection.id!, { notes: e.target.value || undefined })}
127
+ />
128
+ </label>
119
129
 
120
130
  {/* onEntry actions */}
121
131
  <ActionEditor
@@ -38,6 +38,17 @@ export default function SettingsPanel() {
38
38
  />
39
39
  </label>
40
40
 
41
+ {/* Notes about the whole diagram, kept in the document for whoever opens it next. */}
42
+ <label className="block">
43
+ <span className="text-xs text-gray-500">Notes on this diagram</span>
44
+ <textarea
45
+ className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 h-24"
46
+ placeholder="What was discussed, what is still open, what to tell the agent…"
47
+ value={settings.notes ?? ""}
48
+ onChange={(e) => updateSettings({ notes: e.target.value || undefined })}
49
+ />
50
+ </label>
51
+
41
52
  <label className="flex items-center gap-2 text-sm text-gray-300">
42
53
  <input
43
54
  type="checkbox"
@@ -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 {
@@ -42,6 +42,7 @@ import {
42
42
  removeRelationship,
43
43
  renderMermaidEr,
44
44
  updateEntity,
45
+ updateErdSettings,
45
46
  validateErd,
46
47
  type EntityRelationshipDefinition,
47
48
  type ErdCardinality,
@@ -52,11 +53,14 @@ import {
52
53
  type Viewport,
53
54
  } from "@miadi/stateloom-protocol";
54
55
  import DocSwitcher from "@/components/DocSwitcher";
56
+ import IssueIcon from "@/components/IssueIcon";
57
+ import LockIcon from "@/components/LockIcon";
55
58
  import { docQuery, navigateToDoc, useRequestedDoc } from "@/lib/docParam";
56
59
  import { loadRuntimeConfig } from "@/lib/runtimeConfig";
60
+ import { useSheetDrag } from "@/lib/useSheetDrag";
57
61
 
58
62
  type Moved = Record<string, { x: number; y: number }>;
59
- type Tab = "entities" | "relations" | "problems";
63
+ type Tab = "entities" | "relations" | "notes" | "issues";
60
64
  type Edit = (def: EntityRelationshipDefinition) => EntityRelationshipDefinition;
61
65
 
62
66
  // The two notations give an entity different footprints, so each keeps its own
@@ -65,6 +69,22 @@ const layoutKey = (docPath: string, notation: ErdNotation): string =>
65
69
  notation === "chen" ? `stateloom:erd-layout:chen:${docPath}` : `stateloom:erd-layout:${docPath}`;
66
70
 
67
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
+ }
68
88
 
69
89
  function readMoved(docPath: string, notation: ErdNotation): Moved {
70
90
  try {
@@ -120,6 +140,8 @@ export default function ErdWorkspace() {
120
140
  const [def, setDef] = useState<EntityRelationshipDefinition | null>(null);
121
141
  const [moved, setMoved] = useState<Moved>({});
122
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);
123
145
  const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
124
146
  const [fitKey, setFitKey] = useState(0);
125
147
  const [selection, setSelection] = useState<string | null>(null);
@@ -142,6 +164,8 @@ export default function ErdWorkspace() {
142
164
  // Desktop only: the right column can be folded away.
143
165
  const [panelHidden, setPanelHidden] = useState(false);
144
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 });
145
169
  const [peers, setPeers] = useState(0);
146
170
  // null: not checked. "none": checked, and no state machine sits beside the document.
147
171
  const [linkReport, setLinkReport] = useState<ErdValidationError[] | "none" | null>(null);
@@ -169,6 +193,7 @@ export default function ErdWorkspace() {
169
193
  setDocPath(path);
170
194
  setDef(loaded ?? emptyErd("Default", name));
171
195
  setNotation(preferred);
196
+ setLocked(readLocked());
172
197
  setMoved(readMoved(path, preferred));
173
198
  setSelection(null);
174
199
  setLinkReport(null);
@@ -287,7 +312,7 @@ export default function ErdWorkspace() {
287
312
  const machines = await siblingMachines(docPath);
288
313
  if (machines.length === 0) {
289
314
  setLinkReport("none");
290
- setTab("problems");
315
+ setTab("issues");
291
316
  setSheetOpen(true);
292
317
  return setStatus("no .smdf.json beside this document to check against", "warn");
293
318
  }
@@ -296,8 +321,8 @@ export default function ErdWorkspace() {
296
321
  );
297
322
  found.push(...checkStateOf(def, machines.map((m) => m.def.settings?.name ?? "")));
298
323
  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");
324
+ // The answer lives in the Issues tab, so go there — on a phone it is otherwise behind a closed sheet.
325
+ setTab("issues");
301
326
  setSheetOpen(true);
302
327
  setPanelHidden(false);
303
328
  setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`, found.length ? "warn" : "ok");
@@ -314,6 +339,17 @@ export default function ErdWorkspace() {
314
339
  return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
315
340
  };
316
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
+
317
353
  const zoomBy = (factor: number): void => {
318
354
  const { width, height } = boardSize();
319
355
  setViewport((vp) => zoomAt(vp, factor, { x: width / 2, y: height / 2 }));
@@ -326,13 +362,14 @@ export default function ErdWorkspace() {
326
362
  if (!box || !width) return;
327
363
  setViewport(fitToBoxes([box], width, height, { padding: 28, maxScale: 1.25 }));
328
364
  setSelection(name);
329
- setSheetOpen(false);
365
+ sheet.close();
330
366
  };
331
367
 
332
368
  const openTab = (next: Tab): void => {
333
369
  // Tapping the tab you are already reading closes the sheet, so the dock is
334
370
  // both switcher and dismiss control.
335
- setSheetOpen((wasOpen) => !(wasOpen && tab === next));
371
+ if (sheetOpen && tab === next) sheet.close();
372
+ else setSheetOpen(true);
336
373
  setTab(next);
337
374
  };
338
375
 
@@ -340,10 +377,11 @@ export default function ErdWorkspace() {
340
377
  const fileName = docPath.split("/").pop() || "…";
341
378
  const linkProblems = Array.isArray(linkReport) ? linkReport : [];
342
379
  const problemCount = problems.length + linkProblems.length;
343
- const tabs: { id: Tab; label: string; icon: string; badge?: number }[] = [
380
+ const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
344
381
  { id: "entities", label: "Entities", icon: "▭" },
345
382
  { id: "relations", label: "Relations", icon: "⟷" },
346
- { id: "problems", label: "Problems", icon: "⚠", badge: problemCount || undefined },
383
+ { id: "notes", label: "Notes", icon: "✎", badge: undefined },
384
+ { id: "issues", label: "Issues", icon: <IssueIcon />, badge: problemCount || undefined },
347
385
  ];
348
386
  const shown = (id: Tab): string => (tab === id ? "" : "hidden md:block");
349
387
 
@@ -455,6 +493,7 @@ export default function ErdWorkspace() {
455
493
  viewport={viewport}
456
494
  onViewportChange={setViewport}
457
495
  selection={selection}
496
+ readOnly={locked}
458
497
  errorElements={errorEntities}
459
498
  fitKey={fitKey}
460
499
  onSelect={setSelection}
@@ -485,6 +524,17 @@ export default function ErdWorkspace() {
485
524
 
486
525
  {/* View controls live on the board they act on. */}
487
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>
488
538
  {(
489
539
  [
490
540
  ["+", "Zoom in", () => zoomBy(1.25)],
@@ -519,24 +569,25 @@ export default function ErdWorkspace() {
519
569
  </div>
520
570
 
521
571
  {sheetOpen && (
522
- <div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={() => setSheetOpen(false)} aria-hidden="true" />
572
+ <div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={sheet.close} aria-hidden="true" />
523
573
  )}
524
574
 
525
575
  {def && (
526
576
  <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 ${
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 ${
528
579
  sheetOpen ? "translate-y-0" : "pointer-events-none translate-y-full"
529
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 ${
530
581
  panelHidden ? "md:hidden" : ""
531
582
  }`}
532
583
  >
533
- <div className="md:hidden">
584
+ <div className="md:hidden" {...sheet.zoneProps}>
534
585
  <div className="flex justify-center pb-1 pt-2">
535
- <span className="h-1 w-10 rounded-full bg-gray-700" />
586
+ <span className="h-1.5 w-12 rounded-full bg-gray-600" />
536
587
  </div>
537
588
  <div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
538
589
  <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">
590
+ <button onClick={sheet.close} className="-mr-2 px-3 py-2 text-lg text-gray-400" aria-label="Close panel">
540
591
  ✕
541
592
  </button>
542
593
  </div>
@@ -684,8 +735,51 @@ export default function ErdWorkspace() {
684
735
  )}
685
736
  </section>
686
737
 
687
- <section className={shown("problems")}>
688
- <div className={`${heading} max-md:mt-0 ${problemCount === 0 && !linkReport ? "md:hidden" : ""}`}>Problems</div>
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 }))}
749
+ />
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>
689
783
  {problems.map((p, i) => (
690
784
  <div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
691
785
  [{p.ruleId}] {p.message}
@@ -714,7 +808,7 @@ export default function ErdWorkspace() {
714
808
  )}
715
809
  </div>
716
810
 
717
- {/* Tab dock (phone). Three tabs one thumb-tap away, and the problem count is on screen at all times. */}
811
+ {/* Tab dock (phone). Three tabs one thumb-tap away, and the issue count is on screen at all times. */}
718
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">
719
813
  {tabs.map((t) => {
720
814
  const current = sheetOpen && tab === t.id;
@@ -727,10 +821,10 @@ export default function ErdWorkspace() {
727
821
  current ? "text-blue-400" : "text-gray-500"
728
822
  }`}
729
823
  >
730
- <span className="text-base leading-none">{t.icon}</span>
824
+ <span className="flex h-4 items-center text-base leading-none">{t.icon}</span>
731
825
  <span>{t.label}</span>
732
826
  {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">
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">
734
828
  {t.badge}
735
829
  </span>
736
830
  )}
@@ -743,6 +837,60 @@ export default function ErdWorkspace() {
743
837
  );
744
838
  }
745
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
+
746
894
  interface Field {
747
895
  name: string;
748
896
  placeholder?: string;
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The phone sheet's grab handle, made to do what it looks like it does.
5
+ *
6
+ * The handle used to be decoration: it read as "this thing is a sheet" and
7
+ * answered no gesture, so a thumb pulling it down — the one thing a handle
8
+ * invites — got nothing. This hook gives the zone around it three answers:
9
+ *
10
+ * drag down the sheet follows the finger; past a threshold it closes
11
+ * (from the tall size, a short pull returns to the resting size)
12
+ * drag up the sheet grows with the finger; past a threshold it stays tall
13
+ * tap toggles between the resting and the tall size
14
+ *
15
+ * Height is handed to the sheet as the `--sheet-h` custom property rather than
16
+ * an inline `height`, so the desktop rule (`md:h-auto`) still wins where the
17
+ * sheet is a sidebar and none of this applies. A press that starts on a button
18
+ * inside the zone (the ✕) is left to the button.
19
+ */
20
+ import { useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
21
+
22
+ const CLOSE_AFTER = 80;
23
+ const CLOSE_FROM_TALL_AFTER = 260;
24
+ const GROW_AFTER = 50;
25
+ const TAP_SLOP = 6;
26
+
27
+ export interface SheetDrag {
28
+ /** Spread on the element that holds the handle. */
29
+ zoneProps: {
30
+ onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
31
+ onPointerMove: (e: ReactPointerEvent<HTMLElement>) => void;
32
+ onPointerUp: (e: ReactPointerEvent<HTMLElement>) => void;
33
+ onPointerCancel: (e: ReactPointerEvent<HTMLElement>) => void;
34
+ style: CSSProperties;
35
+ };
36
+ /** Spread on the sheet itself, which sizes itself with `h-[var(--sheet-h)]`. */
37
+ sheetStyle: CSSProperties;
38
+ /** Close the sheet and return it to its resting size for next time. */
39
+ close: () => void;
40
+ }
41
+
42
+ export function useSheetDrag(opts: { onClose: () => void; restVh: number; tallVh: number }): SheetDrag {
43
+ const { onClose, restVh, tallVh } = opts;
44
+ const [tall, setTall] = useState(false);
45
+ const [dy, setDy] = useState<number | null>(null);
46
+ const press = useRef<{ y: number; id: number } | null>(null);
47
+
48
+ const close = (): void => {
49
+ setTall(false);
50
+ onClose();
51
+ };
52
+
53
+ const release = (e: ReactPointerEvent<HTMLElement>): void => {
54
+ const p = press.current;
55
+ if (!p || p.id !== e.pointerId) return;
56
+ press.current = null;
57
+ const d = e.clientY - p.y;
58
+ setDy(null);
59
+ if (Math.abs(d) < TAP_SLOP) return setTall((t) => !t);
60
+ if (d <= -GROW_AFTER) return setTall(true);
61
+ if (d >= CLOSE_AFTER) {
62
+ if (tall && d < CLOSE_FROM_TALL_AFTER) return setTall(false);
63
+ close();
64
+ }
65
+ };
66
+
67
+ const vh = tall ? tallVh : restVh;
68
+ const dragging = dy !== null;
69
+ const sheetStyle: CSSProperties & Record<"--sheet-h", string> = {
70
+ "--sheet-h": dragging && dy < 0 ? `min(calc(${vh}dvh + ${-dy}px), ${tallVh}dvh)` : `${vh}dvh`,
71
+ ...(dragging ? { transition: "none" } : {}),
72
+ ...(dragging && dy > 0 ? { transform: `translateY(${dy}px)` } : {}),
73
+ };
74
+
75
+ return {
76
+ zoneProps: {
77
+ onPointerDown: (e) => {
78
+ if ((e.target as HTMLElement).closest("button")) return;
79
+ press.current = { y: e.clientY, id: e.pointerId };
80
+ e.currentTarget.setPointerCapture(e.pointerId);
81
+ setDy(0);
82
+ },
83
+ onPointerMove: (e) => {
84
+ const p = press.current;
85
+ if (p && p.id === e.pointerId) setDy(e.clientY - p.y);
86
+ },
87
+ onPointerUp: release,
88
+ onPointerCancel: release,
89
+ // The zone is a gesture surface: the browser must not scroll or pan with it.
90
+ style: { touchAction: "none", cursor: "grab" },
91
+ },
92
+ sheetStyle,
93
+ close,
94
+ };
95
+ }
@@ -21,6 +21,8 @@ export interface SettingsModel {
21
21
  namespace: string;
22
22
  name?: string;
23
23
  description?: string;
24
+ /** Working notes about the whole diagram — saved in the document, ignored by engines and codegen. */
25
+ notes?: string;
24
26
  asynchronous: boolean;
25
27
  /**
26
28
  * Objects the generated context is constructed with. `ObjectRef` is what
@@ -97,6 +99,8 @@ export interface StateDef {
97
99
  name: string;
98
100
  kind?: StateKindType;
99
101
  description?: string;
102
+ /** Working notes about this state: the conversation about it, where `description` says what it is. */
103
+ notes?: string;
100
104
  onEntry?: { actions: ActionDef[] };
101
105
  onExit?: { actions: ActionDef[] };
102
106
  transitions?: TransitionDef[];
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.1.8",
3
- "designer": "0.1.8",
2
+ "package": "0.1.9",
3
+ "designer": "0.1.9",
4
4
  "next": "16.1.6"
5
5
  }