@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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +2 -2
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__46d45ef8._.js → [root-of-the-server]__d5879c0c._.js} +1 -1
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +3 -3
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/static/chunks/636e7eb4aeda72e0.css +2 -0
- package/server/.next/static/chunks/92628504da59dcc8.js +10 -0
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/app/page.tsx +17 -10
- package/server/src/components/IssueIcon.tsx +28 -0
- package/server/src/components/LockIcon.tsx +24 -0
- package/server/src/components/PropertiesPanel.tsx +10 -0
- package/server/src/components/SettingsPanel.tsx +11 -0
- package/server/src/components/erd/ErdWorkspace.tsx +564 -172
- package/server/src/lib/useSheetDrag.ts +95 -0
- package/server/src/types/definition.ts +4 -0
- package/server/stateloom-web.json +2 -2
- package/server/tsconfig.tsbuildinfo +1 -1
- package/server/.next/static/chunks/486bc3bc16e91024.css +0 -2
- package/server/.next/static/chunks/b3f3eb72c25c4c6b.js +0 -10
- /package/server/.next/static/{DPL_H0tteH9tpIBvvsIfq → 6dAVJYqnbe6oEAABQ1yMG}/_buildManifest.js +0 -0
- /package/server/.next/static/{DPL_H0tteH9tpIBvvsIfq → 6dAVJYqnbe6oEAABQ1yMG}/_clientMiddlewareManifest.json +0 -0
- /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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
282
|
-
|
|
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 ??
|
|
287
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
332
|
-
<div className="min-
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
<div className={heading}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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=
|
|
404
|
-
onClick={() => apply((d) =>
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
{
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
{
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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>
|