@miadi/stateloom-web 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +1 -1
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/static/chunks/8f6bd51bbcce6314.css +2 -0
- package/server/.next/static/chunks/{b3f3eb72c25c4c6b.js → a9e7748193d11ed7.js} +4 -4
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/components/erd/ErdWorkspace.tsx +416 -172
- 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/{DPL_H0tteH9tpIBvvsIfq → XCT3lnw_1F5LD0GG4F94-}/_buildManifest.js +0 -0
- /package/server/.next/static/{DPL_H0tteH9tpIBvvsIfq → XCT3lnw_1F5LD0GG4F94-}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{DPL_H0tteH9tpIBvvsIfq → XCT3lnw_1F5LD0GG4F94-}/_ssgManifest.js +0 -0
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* What it shares is everything underneath — the same `?doc=` parameter and file
|
|
9
9
|
* API, the same hub room keyed by the resolved path, the same canvas package.
|
|
10
10
|
*
|
|
11
|
+
* Layout. The board is the point, so it gets the screen: the header is one
|
|
12
|
+
* row, the view controls (zoom, fit) float on the board, and status is a line
|
|
13
|
+
* laid over its corner rather than a row of its own. On a phone the panel is a
|
|
14
|
+
* bottom sheet that starts closed, behind a three-tab dock — the same shape the
|
|
15
|
+
* state designer uses — and on a desktop it is the right column, which can be
|
|
16
|
+
* folded away.
|
|
17
|
+
*
|
|
11
18
|
* Every edit is written to disk and then pushed to the room whole. Disk first,
|
|
12
19
|
* because the agent's MCP tools read the file before each of their own edits:
|
|
13
20
|
* a change that lived only in the browser would be overwritten by the next
|
|
@@ -20,6 +27,8 @@ import { createBridgeSession, type BridgeSession } from "@miadi/stateloom-react"
|
|
|
20
27
|
import {
|
|
21
28
|
IDENTITY_VIEWPORT,
|
|
22
29
|
ERD_CARDINALITIES,
|
|
30
|
+
fitToBoxes,
|
|
31
|
+
zoomAt,
|
|
23
32
|
addAttribute,
|
|
24
33
|
addEntity,
|
|
25
34
|
addRelationship,
|
|
@@ -47,6 +56,7 @@ import { docQuery, navigateToDoc, useRequestedDoc } from "@/lib/docParam";
|
|
|
47
56
|
import { loadRuntimeConfig } from "@/lib/runtimeConfig";
|
|
48
57
|
|
|
49
58
|
type Moved = Record<string, { x: number; y: number }>;
|
|
59
|
+
type Tab = "entities" | "relations" | "problems";
|
|
50
60
|
type Edit = (def: EntityRelationshipDefinition) => EntityRelationshipDefinition;
|
|
51
61
|
|
|
52
62
|
// The two notations give an entity different footprints, so each keeps its own
|
|
@@ -113,9 +123,28 @@ export default function ErdWorkspace() {
|
|
|
113
123
|
const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
|
|
114
124
|
const [fitKey, setFitKey] = useState(0);
|
|
115
125
|
const [selection, setSelection] = useState<string | null>(null);
|
|
116
|
-
const [status,
|
|
126
|
+
const [status, setStatusState] = useState<{ text: string; tone: "ok" | "warn" }>({ text: "loading…", tone: "ok" });
|
|
127
|
+
const setStatus = useCallback((text: string, tone: "ok" | "warn" = "ok") => setStatusState({ text, tone }), []);
|
|
128
|
+
// Routine news ("saved", "updated live") steps back to its dot after a moment,
|
|
129
|
+
// so it is never what covers a corner of the diagram. A warning stays.
|
|
130
|
+
const [statusQuiet, setStatusQuiet] = useState(false);
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const timer = setTimeout(() => setStatusQuiet(true), 4000);
|
|
133
|
+
return () => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
setStatusQuiet(false);
|
|
136
|
+
};
|
|
137
|
+
}, [status]);
|
|
138
|
+
const [tab, setTab] = useState<Tab>("entities");
|
|
139
|
+
// Phone only: the sheet starts closed so the board owns the screen.
|
|
140
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
141
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
142
|
+
// Desktop only: the right column can be folded away.
|
|
143
|
+
const [panelHidden, setPanelHidden] = useState(false);
|
|
144
|
+
const boardRef = useRef<HTMLDivElement | null>(null);
|
|
117
145
|
const [peers, setPeers] = useState(0);
|
|
118
|
-
|
|
146
|
+
// null: not checked. "none": checked, and no state machine sits beside the document.
|
|
147
|
+
const [linkReport, setLinkReport] = useState<ErdValidationError[] | "none" | null>(null);
|
|
119
148
|
const sessionRef = useRef<BridgeSession | null>(null);
|
|
120
149
|
|
|
121
150
|
// Load the document, then join its room.
|
|
@@ -125,7 +154,7 @@ export default function ErdWorkspace() {
|
|
|
125
154
|
(async () => {
|
|
126
155
|
const body = await (await fetch(`/api/file${docQuery(requested)}`, { cache: "no-store" })).json();
|
|
127
156
|
if (cancelled) return;
|
|
128
|
-
if (body.error) return setStatus(body.error);
|
|
157
|
+
if (body.error) return setStatus(body.error, "warn");
|
|
129
158
|
const path: string = body.path ?? "";
|
|
130
159
|
let loaded: EntityRelationshipDefinition | null = null;
|
|
131
160
|
try {
|
|
@@ -134,7 +163,7 @@ export default function ErdWorkspace() {
|
|
|
134
163
|
} catch {
|
|
135
164
|
// Falls through to the unreadable-document message below.
|
|
136
165
|
}
|
|
137
|
-
if (body.exists && !loaded) return setStatus(`${path} is not a readable ERD definition
|
|
166
|
+
if (body.exists && !loaded) return setStatus(`${path} is not a readable ERD definition`, "warn");
|
|
138
167
|
const name = (path.split("/").pop() ?? "Untitled").replace(/\.erdf\.json$/i, "");
|
|
139
168
|
const preferred = readNotation();
|
|
140
169
|
setDocPath(path);
|
|
@@ -165,13 +194,13 @@ export default function ErdWorkspace() {
|
|
|
165
194
|
});
|
|
166
195
|
sessionRef.current = session;
|
|
167
196
|
session.connect();
|
|
168
|
-
})().catch((e) => !cancelled && setStatus(String(e)));
|
|
197
|
+
})().catch((e) => !cancelled && setStatus(String(e), "warn"));
|
|
169
198
|
return () => {
|
|
170
199
|
cancelled = true;
|
|
171
200
|
sessionRef.current = null;
|
|
172
201
|
session?.disconnect();
|
|
173
202
|
};
|
|
174
|
-
}, [requested]);
|
|
203
|
+
}, [requested, setStatus]);
|
|
175
204
|
|
|
176
205
|
const positions = useMemo<Record<string, LayoutBox>>(() => {
|
|
177
206
|
if (!def) return {};
|
|
@@ -195,7 +224,7 @@ export default function ErdWorkspace() {
|
|
|
195
224
|
try {
|
|
196
225
|
next = edit(def);
|
|
197
226
|
} catch (e) {
|
|
198
|
-
return setStatus(e instanceof Error ? e.message : String(e));
|
|
227
|
+
return setStatus(e instanceof Error ? e.message : String(e), "warn");
|
|
199
228
|
}
|
|
200
229
|
setDef(next);
|
|
201
230
|
setLinkReport(null);
|
|
@@ -209,10 +238,10 @@ export default function ErdWorkspace() {
|
|
|
209
238
|
sessionRef.current?.emitFull(next as unknown as StateMachineDefinition);
|
|
210
239
|
setStatus("saved");
|
|
211
240
|
} catch (e) {
|
|
212
|
-
setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}
|
|
241
|
+
setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`, "warn");
|
|
213
242
|
}
|
|
214
243
|
},
|
|
215
|
-
[def, docPath],
|
|
244
|
+
[def, docPath, setStatus],
|
|
216
245
|
);
|
|
217
246
|
|
|
218
247
|
const moveEntity = useCallback(
|
|
@@ -257,40 +286,87 @@ export default function ErdWorkspace() {
|
|
|
257
286
|
setStatus("checking links…");
|
|
258
287
|
const machines = await siblingMachines(docPath);
|
|
259
288
|
if (machines.length === 0) {
|
|
260
|
-
setLinkReport(
|
|
261
|
-
|
|
289
|
+
setLinkReport("none");
|
|
290
|
+
setTab("problems");
|
|
291
|
+
setSheetOpen(true);
|
|
292
|
+
return setStatus("no .smdf.json beside this document to check against", "warn");
|
|
262
293
|
}
|
|
263
294
|
const found = machines.flatMap((m) =>
|
|
264
295
|
checkLinks(m.def, def).map((e) => ({ ...e, message: `${m.def.settings?.name ?? m.path}: ${e.message}` })),
|
|
265
296
|
);
|
|
266
297
|
found.push(...checkStateOf(def, machines.map((m) => m.def.settings?.name ?? "")));
|
|
267
298
|
setLinkReport(found);
|
|
268
|
-
|
|
299
|
+
// The answer lives in the Problems tab, so go there — on a phone it is otherwise behind a closed sheet.
|
|
300
|
+
setTab("problems");
|
|
301
|
+
setSheetOpen(true);
|
|
302
|
+
setPanelHidden(false);
|
|
303
|
+
setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`, found.length ? "warn" : "ok");
|
|
269
304
|
};
|
|
270
305
|
|
|
271
306
|
const openMachine = async (machine: string): Promise<void> => {
|
|
272
307
|
const hit = (await siblingMachines(docPath)).find((m) => m.def.settings?.name === machine);
|
|
273
308
|
if (hit) navigateToDoc(hit.path);
|
|
274
|
-
else setStatus(`no .smdf.json beside this document defines '${machine}'
|
|
309
|
+
else setStatus(`no .smdf.json beside this document defines '${machine}'`, "warn");
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const boardSize = (): { width: number; height: number } => {
|
|
313
|
+
const rect = boardRef.current?.getBoundingClientRect();
|
|
314
|
+
return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const zoomBy = (factor: number): void => {
|
|
318
|
+
const { width, height } = boardSize();
|
|
319
|
+
setViewport((vp) => zoomAt(vp, factor, { x: width / 2, y: height / 2 }));
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** Bring one entity to the middle of the board at a size its rows can be read at. */
|
|
323
|
+
const showOnBoard = (name: string): void => {
|
|
324
|
+
const box = positions[name];
|
|
325
|
+
const { width, height } = boardSize();
|
|
326
|
+
if (!box || !width) return;
|
|
327
|
+
setViewport(fitToBoxes([box], width, height, { padding: 28, maxScale: 1.25 }));
|
|
328
|
+
setSelection(name);
|
|
329
|
+
setSheetOpen(false);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const openTab = (next: Tab): void => {
|
|
333
|
+
// Tapping the tab you are already reading closes the sheet, so the dock is
|
|
334
|
+
// both switcher and dismiss control.
|
|
335
|
+
setSheetOpen((wasOpen) => !(wasOpen && tab === next));
|
|
336
|
+
setTab(next);
|
|
275
337
|
};
|
|
276
338
|
|
|
277
339
|
const selected = def?.entities.find((e) => e.name === selection) ?? null;
|
|
278
340
|
const fileName = docPath.split("/").pop() || "…";
|
|
341
|
+
const linkProblems = Array.isArray(linkReport) ? linkReport : [];
|
|
342
|
+
const problemCount = problems.length + linkProblems.length;
|
|
343
|
+
const tabs: { id: Tab; label: string; icon: string; badge?: number }[] = [
|
|
344
|
+
{ id: "entities", label: "Entities", icon: "▭" },
|
|
345
|
+
{ id: "relations", label: "Relations", icon: "⟷" },
|
|
346
|
+
{ id: "problems", label: "Problems", icon: "⚠", badge: problemCount || undefined },
|
|
347
|
+
];
|
|
348
|
+
const shown = (id: Tab): string => (tab === id ? "" : "hidden md:block");
|
|
349
|
+
|
|
350
|
+
const copyMermaid = (): void => {
|
|
351
|
+
if (def) navigator.clipboard?.writeText(renderMermaidEr(def)).then(() => setStatus("mermaid copied"));
|
|
352
|
+
};
|
|
279
353
|
|
|
280
354
|
return (
|
|
281
|
-
<div
|
|
282
|
-
|
|
355
|
+
<div
|
|
356
|
+
className="app-shell dock-offset flex flex-col overflow-hidden bg-gray-950 text-gray-200"
|
|
357
|
+
onClick={() => menuOpen && setMenuOpen(false)}
|
|
358
|
+
>
|
|
359
|
+
{/* One row at every width. What does not fit a phone goes behind ⋯, never onto a second line. */}
|
|
360
|
+
<header className="relative z-40 flex items-center gap-2 border-b border-gray-800 bg-gray-900 px-2 py-1.5 text-xs md:px-3 md:py-2">
|
|
283
361
|
<span className="font-semibold text-blue-300">ERD</span>
|
|
284
362
|
<DocSwitcher />
|
|
285
|
-
<span className="text-gray-400" title={docPath}>
|
|
286
|
-
{def?.settings.name ??
|
|
287
|
-
|
|
288
|
-
<span className="ml-auto text-gray-500">
|
|
289
|
-
{status}
|
|
290
|
-
{peers > 1 ? ` · ${peers} in the room` : ""}
|
|
363
|
+
<span className="min-w-0 flex-1 truncate text-gray-400" title={docPath}>
|
|
364
|
+
{def?.settings.name ?? fileName}
|
|
365
|
+
<span className="hidden text-gray-600 lg:inline"> · {fileName}</span>
|
|
291
366
|
</span>
|
|
367
|
+
|
|
292
368
|
{/* Same document, two drawings. The choice is this browser's; it is never written to the file. */}
|
|
293
|
-
<span className="inline-flex overflow-hidden rounded border border-gray-700" role="group" aria-label="Notation">
|
|
369
|
+
<span className="inline-flex shrink-0 overflow-hidden rounded border border-gray-700" role="group" aria-label="Notation">
|
|
294
370
|
{(
|
|
295
371
|
[
|
|
296
372
|
["crowsfoot", "▤", "Crow's foot — attributes listed inside the entity box"],
|
|
@@ -303,7 +379,7 @@ export default function ErdWorkspace() {
|
|
|
303
379
|
aria-label={title}
|
|
304
380
|
aria-pressed={notation === id}
|
|
305
381
|
onClick={() => chooseNotation(id)}
|
|
306
|
-
className={`px-2 py-1 text-sm leading-none ${
|
|
382
|
+
className={`px-2.5 py-1.5 text-sm leading-none md:px-2 md:py-1 ${
|
|
307
383
|
notation === id ? "bg-blue-900/60 text-blue-200" : "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
|
308
384
|
}`}
|
|
309
385
|
>
|
|
@@ -311,25 +387,66 @@ export default function ErdWorkspace() {
|
|
|
311
387
|
</button>
|
|
312
388
|
))}
|
|
313
389
|
</span>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
390
|
+
|
|
391
|
+
<div className="hidden shrink-0 items-center gap-2 md:flex">
|
|
392
|
+
<button className={button} onClick={arrange} title="Forget the dragged positions and lay the board out again">
|
|
393
|
+
⤢ Arrange
|
|
394
|
+
</button>
|
|
395
|
+
<button className={button} onClick={runLinkCheck} title="Check the state machines beside this document against it">
|
|
396
|
+
Check links
|
|
397
|
+
</button>
|
|
398
|
+
<button className={button} onClick={copyMermaid}>
|
|
399
|
+
Copy mermaid
|
|
400
|
+
</button>
|
|
401
|
+
<button
|
|
402
|
+
className={button}
|
|
403
|
+
onClick={() => setPanelHidden((h) => !h)}
|
|
404
|
+
aria-pressed={!panelHidden}
|
|
405
|
+
title={panelHidden ? "Show the panel" : "Hide the panel — the board takes the full width"}
|
|
406
|
+
>
|
|
407
|
+
{panelHidden ? "⟨ Panel" : "Panel ⟩"}
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<div className="relative shrink-0 md:hidden">
|
|
412
|
+
<button
|
|
413
|
+
className="rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm leading-none text-gray-200"
|
|
414
|
+
aria-label="More actions"
|
|
415
|
+
aria-expanded={menuOpen}
|
|
416
|
+
onClick={(e) => {
|
|
417
|
+
e.stopPropagation();
|
|
418
|
+
setMenuOpen((o) => !o);
|
|
419
|
+
}}
|
|
420
|
+
>
|
|
421
|
+
⋯
|
|
422
|
+
</button>
|
|
423
|
+
{menuOpen && (
|
|
424
|
+
<div className="absolute right-0 top-full z-50 mt-1 w-56 overflow-hidden rounded-lg border border-gray-700 bg-gray-900 py-1 shadow-xl">
|
|
425
|
+
{(
|
|
426
|
+
[
|
|
427
|
+
["⤢ Arrange the board", arrange],
|
|
428
|
+
["Check links to the machines", runLinkCheck],
|
|
429
|
+
["Copy as mermaid", copyMermaid],
|
|
430
|
+
] as const
|
|
431
|
+
).map(([label, run]) => (
|
|
432
|
+
<button
|
|
433
|
+
key={label}
|
|
434
|
+
className="block w-full px-4 py-3 text-left text-sm text-gray-200 hover:bg-gray-800"
|
|
435
|
+
onClick={() => {
|
|
436
|
+
setMenuOpen(false);
|
|
437
|
+
run();
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
{label}
|
|
441
|
+
</button>
|
|
442
|
+
))}
|
|
443
|
+
</div>
|
|
444
|
+
)}
|
|
445
|
+
</div>
|
|
329
446
|
</header>
|
|
330
447
|
|
|
331
|
-
<div className="flex min-h-0 flex-1
|
|
332
|
-
<div className="min-
|
|
448
|
+
<div className="relative flex min-h-0 flex-1">
|
|
449
|
+
<div ref={boardRef} className="relative min-w-0 flex-1 overflow-hidden">
|
|
333
450
|
{def && (
|
|
334
451
|
<EntityRelationshipCanvas
|
|
335
452
|
definition={def}
|
|
@@ -347,154 +464,281 @@ export default function ErdWorkspace() {
|
|
|
347
464
|
emptyHint="No entities yet — add one in the panel, or let an agent call add_entity."
|
|
348
465
|
/>
|
|
349
466
|
)}
|
|
467
|
+
|
|
468
|
+
{/* Status costs no layout: it is a line over the board's corner. */}
|
|
469
|
+
<div
|
|
470
|
+
className={`pointer-events-none absolute left-2 top-2 flex max-w-[75%] items-center gap-1.5 rounded bg-gray-950/70 px-1.5 py-0.5 text-[11px] ${
|
|
471
|
+
status.tone === "warn" ? "text-amber-300" : "text-gray-500"
|
|
472
|
+
}`}
|
|
473
|
+
role="status"
|
|
474
|
+
>
|
|
475
|
+
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${status.tone === "warn" ? "bg-amber-400" : "bg-emerald-500"}`} />
|
|
476
|
+
<span
|
|
477
|
+
className={`truncate transition-opacity duration-500 ${
|
|
478
|
+
statusQuiet && status.tone === "ok" ? "opacity-0" : "opacity-100"
|
|
479
|
+
}`}
|
|
480
|
+
>
|
|
481
|
+
{status.text}
|
|
482
|
+
{peers > 1 ? ` · ${peers} in the room` : ""}
|
|
483
|
+
</span>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
{/* View controls live on the board they act on. */}
|
|
487
|
+
<div className="absolute bottom-3 right-3 flex flex-col overflow-hidden rounded-lg border border-gray-700 bg-gray-900/90 shadow-lg backdrop-blur">
|
|
488
|
+
{(
|
|
489
|
+
[
|
|
490
|
+
["+", "Zoom in", () => zoomBy(1.25)],
|
|
491
|
+
["−", "Zoom out", () => zoomBy(0.8)],
|
|
492
|
+
["⛶", "Fit the whole diagram", () => setFitKey((k) => k + 1)],
|
|
493
|
+
] as const
|
|
494
|
+
).map(([glyph, title, run]) => (
|
|
495
|
+
<button
|
|
496
|
+
key={title}
|
|
497
|
+
title={title}
|
|
498
|
+
aria-label={title}
|
|
499
|
+
onClick={run}
|
|
500
|
+
className="h-10 w-10 border-b border-gray-800 text-base leading-none text-gray-200 last:border-b-0 hover:bg-gray-800 md:h-8 md:w-8 md:text-sm"
|
|
501
|
+
>
|
|
502
|
+
{glyph}
|
|
503
|
+
</button>
|
|
504
|
+
))}
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
{/* Phone: the way from a tapped entity to its details, without the sheet opening by itself over the board. */}
|
|
508
|
+
{selected && !sheetOpen && (
|
|
509
|
+
<button
|
|
510
|
+
className="absolute bottom-3 left-1/2 max-w-[60%] -translate-x-1/2 truncate rounded-full border border-blue-500/60 bg-gray-900/95 px-4 py-2 text-sm text-blue-200 shadow-lg md:hidden"
|
|
511
|
+
onClick={() => {
|
|
512
|
+
setTab("entities");
|
|
513
|
+
setSheetOpen(true);
|
|
514
|
+
}}
|
|
515
|
+
>
|
|
516
|
+
✎ {selected.name}
|
|
517
|
+
</button>
|
|
518
|
+
)}
|
|
350
519
|
</div>
|
|
351
520
|
|
|
521
|
+
{sheetOpen && (
|
|
522
|
+
<div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={() => setSheetOpen(false)} aria-hidden="true" />
|
|
523
|
+
)}
|
|
524
|
+
|
|
352
525
|
{def && (
|
|
353
|
-
<aside
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
526
|
+
<aside
|
|
527
|
+
className={`safe-x fixed inset-x-0 bottom-0 z-30 flex h-[62dvh] flex-col overflow-hidden rounded-t-2xl border-t border-gray-800 bg-gray-900 shadow-2xl transition-transform duration-200 ease-out ${
|
|
528
|
+
sheetOpen ? "translate-y-0" : "pointer-events-none translate-y-full"
|
|
529
|
+
} md:pointer-events-auto md:static md:z-auto md:h-auto md:w-80 md:translate-y-0 md:rounded-none md:border-l md:border-t-0 md:shadow-none md:transition-none ${
|
|
530
|
+
panelHidden ? "md:hidden" : ""
|
|
531
|
+
}`}
|
|
532
|
+
>
|
|
533
|
+
<div className="md:hidden">
|
|
534
|
+
<div className="flex justify-center pb-1 pt-2">
|
|
535
|
+
<span className="h-1 w-10 rounded-full bg-gray-700" />
|
|
536
|
+
</div>
|
|
537
|
+
<div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
|
|
538
|
+
<h2 className="text-sm font-semibold text-gray-300">{tabs.find((t) => t.id === tab)?.label}</h2>
|
|
539
|
+
<button onClick={() => setSheetOpen(false)} className="-mr-2 px-3 py-2 text-lg text-gray-400" aria-label="Close panel">
|
|
540
|
+
✕
|
|
365
541
|
</button>
|
|
366
|
-
|
|
542
|
+
</div>
|
|
367
543
|
</div>
|
|
368
|
-
<InlineForm
|
|
369
|
-
fields={[{ name: "name", placeholder: "new entity" }]}
|
|
370
|
-
submit="+ Entity"
|
|
371
|
-
onSubmit={(v) => {
|
|
372
|
-
apply((d) => addEntity(d, { name: v.name.trim() }));
|
|
373
|
-
setSelection(v.name.trim());
|
|
374
|
-
}}
|
|
375
|
-
/>
|
|
376
544
|
|
|
377
|
-
|
|
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
|
-
|
|
545
|
+
<div className="touch-targets dock-offset flex-1 overflow-y-auto overscroll-contain p-3">
|
|
546
|
+
<section className={shown("entities")}>
|
|
547
|
+
<div className={heading} style={{ marginTop: 0 }}>
|
|
548
|
+
Entities
|
|
549
|
+
</div>
|
|
550
|
+
<div className="flex flex-wrap gap-1">
|
|
551
|
+
{def.entities.map((e) => (
|
|
552
|
+
<button
|
|
553
|
+
key={e.name}
|
|
554
|
+
className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
|
|
555
|
+
onClick={() => setSelection(e.name)}
|
|
556
|
+
>
|
|
557
|
+
{e.name}
|
|
558
|
+
</button>
|
|
559
|
+
))}
|
|
560
|
+
</div>
|
|
561
|
+
<InlineForm
|
|
562
|
+
fields={[{ name: "name", placeholder: "new entity" }]}
|
|
563
|
+
submit="+ Entity"
|
|
564
|
+
onSubmit={(v) => {
|
|
565
|
+
apply((d) => addEntity(d, { name: v.name.trim() }));
|
|
566
|
+
setSelection(v.name.trim());
|
|
567
|
+
}}
|
|
568
|
+
/>
|
|
569
|
+
|
|
570
|
+
{selected && (
|
|
571
|
+
<>
|
|
572
|
+
<div className={`${heading} flex items-center justify-between gap-2`}>
|
|
573
|
+
<span className="truncate text-sm normal-case tracking-normal text-gray-200">{selected.name}</span>
|
|
574
|
+
<button
|
|
575
|
+
className={`${button} normal-case tracking-normal`}
|
|
576
|
+
onClick={() => showOnBoard(selected.name)}
|
|
577
|
+
title="Bring this entity to the middle of the board at a readable size"
|
|
578
|
+
>
|
|
579
|
+
◎ Show on board
|
|
580
|
+
</button>
|
|
581
|
+
</div>
|
|
582
|
+
<label
|
|
583
|
+
className="mb-1 flex items-center gap-2 text-[11px] text-gray-400"
|
|
584
|
+
title="Exists only through another entity (an order line, without its order). Chen draws it as a double rectangle."
|
|
585
|
+
>
|
|
586
|
+
<input
|
|
587
|
+
type="checkbox"
|
|
588
|
+
checked={!!selected.weak}
|
|
589
|
+
onChange={(e) => apply((d) => updateEntity(d, selected.name, { weak: e.target.checked }))}
|
|
590
|
+
/>
|
|
591
|
+
weak entity
|
|
592
|
+
</label>
|
|
593
|
+
{(selected.attributes ?? []).map((a) => (
|
|
594
|
+
<div key={a.name} className="flex items-center justify-between gap-2 py-0.5 font-mono text-[11px]">
|
|
595
|
+
<span className={a.stateOf ? "text-blue-300" : "text-gray-300"}>
|
|
596
|
+
{a.name} : {a.type}
|
|
597
|
+
<span className="text-gray-500">
|
|
598
|
+
{a.key ? ` ${a.key.toUpperCase()}` : ""}
|
|
599
|
+
{a.references ? ` → ${a.references}` : ""}
|
|
600
|
+
{a.stateOf ? ` ◉ ${[a.stateOf].flat().join(", ")}` : ""}
|
|
601
|
+
</span>
|
|
602
|
+
</span>
|
|
603
|
+
<button
|
|
604
|
+
className="text-gray-500 hover:text-red-400"
|
|
605
|
+
aria-label={`Remove ${a.name}`}
|
|
606
|
+
onClick={() => apply((d) => removeAttribute(d, selected.name, a.name))}
|
|
607
|
+
>
|
|
608
|
+
✕
|
|
609
|
+
</button>
|
|
610
|
+
</div>
|
|
611
|
+
))}
|
|
612
|
+
<InlineForm
|
|
613
|
+
fields={[
|
|
614
|
+
{ name: "name", placeholder: "attribute" },
|
|
615
|
+
{ name: "type", placeholder: "type", initial: "string" },
|
|
616
|
+
{ name: "key", options: ["", "pk", "uk"] },
|
|
617
|
+
{ name: "references", placeholder: "references Entity.attr" },
|
|
618
|
+
{ name: "stateOf", placeholder: "stateOf Machine" },
|
|
619
|
+
]}
|
|
620
|
+
submit="+ Attribute"
|
|
621
|
+
onSubmit={(v) =>
|
|
622
|
+
apply((d) =>
|
|
623
|
+
addAttribute(d, selected.name, {
|
|
624
|
+
name: v.name.trim(),
|
|
625
|
+
type: v.type.trim() || "string",
|
|
626
|
+
key: v.key === "pk" || v.key === "uk" ? v.key : undefined,
|
|
627
|
+
references: v.references.trim() || undefined,
|
|
628
|
+
stateOf: v.stateOf.trim() || undefined,
|
|
629
|
+
}),
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
/>
|
|
633
|
+
<button
|
|
634
|
+
className={`${button} mt-2 text-red-300`}
|
|
635
|
+
onClick={() => {
|
|
636
|
+
apply((d) => removeEntity(d, selected.name));
|
|
637
|
+
setSelection(null);
|
|
638
|
+
}}
|
|
639
|
+
>
|
|
640
|
+
🗑 Delete {selected.name}
|
|
641
|
+
</button>
|
|
642
|
+
</>
|
|
643
|
+
)}
|
|
644
|
+
</section>
|
|
645
|
+
|
|
646
|
+
<section className={shown("relations")}>
|
|
647
|
+
<div className={`${heading} max-md:mt-0`}>Relationships</div>
|
|
648
|
+
{def.relationships.length === 0 && <div className="py-0.5 text-[11px] text-gray-600">None yet.</div>}
|
|
649
|
+
{def.relationships.map((r, i) => (
|
|
650
|
+
<div key={i} className="flex items-center justify-between gap-2 py-0.5 text-[11px] text-gray-300">
|
|
651
|
+
<span>
|
|
652
|
+
{r.from} <span className="text-gray-500">{r.cardinality}</span> {r.to}
|
|
653
|
+
{r.label ? <span className="text-gray-500"> : {r.label}</span> : null}
|
|
400
654
|
</span>
|
|
401
655
|
<button
|
|
402
656
|
className="text-gray-500 hover:text-red-400"
|
|
403
|
-
aria-label=
|
|
404
|
-
onClick={() => apply((d) =>
|
|
657
|
+
aria-label="Remove relationship"
|
|
658
|
+
onClick={() => apply((d) => removeRelationship(d, r.from, r.to, r.label))}
|
|
405
659
|
>
|
|
406
660
|
✕
|
|
407
661
|
</button>
|
|
408
662
|
</div>
|
|
409
663
|
))}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
{
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
className="text-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
</
|
|
458
|
-
|
|
459
|
-
{def.entities.length > 0 && (
|
|
460
|
-
<InlineForm
|
|
461
|
-
fields={[
|
|
462
|
-
{ name: "from", options: def.entities.map((e) => e.name) },
|
|
463
|
-
{ name: "cardinality", options: [...ERD_CARDINALITIES], initial: "1:N" },
|
|
464
|
-
{ name: "to", options: def.entities.map((e) => e.name) },
|
|
465
|
-
{ name: "label", placeholder: "label (a verb)" },
|
|
466
|
-
]}
|
|
467
|
-
submit="+ Relationship"
|
|
468
|
-
onSubmit={(v) =>
|
|
469
|
-
apply((d) =>
|
|
470
|
-
addRelationship(d, {
|
|
471
|
-
from: v.from,
|
|
472
|
-
to: v.to,
|
|
473
|
-
cardinality: v.cardinality as ErdCardinality,
|
|
474
|
-
label: v.label.trim() || undefined,
|
|
475
|
-
}),
|
|
476
|
-
)
|
|
477
|
-
}
|
|
478
|
-
/>
|
|
479
|
-
)}
|
|
480
|
-
|
|
481
|
-
{(problems.length > 0 || linkReport) && <div className={heading}>Problems</div>}
|
|
482
|
-
{problems.map((p, i) => (
|
|
483
|
-
<div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
|
|
484
|
-
[{p.ruleId}] {p.message}
|
|
485
|
-
</div>
|
|
486
|
-
))}
|
|
487
|
-
{linkReport?.length === 0 && problems.length === 0 && (
|
|
488
|
-
<div className="py-0.5 text-[11px] text-green-400">Every name the machines use resolves.</div>
|
|
489
|
-
)}
|
|
490
|
-
{linkReport?.map((p, i) => (
|
|
491
|
-
<div key={`l${i}`} className="py-0.5 text-[11px] text-amber-300">
|
|
492
|
-
[{p.ruleId}] {p.message}
|
|
493
|
-
</div>
|
|
494
|
-
))}
|
|
664
|
+
{def.entities.length > 0 && (
|
|
665
|
+
<InlineForm
|
|
666
|
+
fields={[
|
|
667
|
+
{ name: "from", options: def.entities.map((e) => e.name) },
|
|
668
|
+
{ name: "cardinality", options: [...ERD_CARDINALITIES], initial: "1:N" },
|
|
669
|
+
{ name: "to", options: def.entities.map((e) => e.name) },
|
|
670
|
+
{ name: "label", placeholder: "label (a verb)" },
|
|
671
|
+
]}
|
|
672
|
+
submit="+ Relationship"
|
|
673
|
+
onSubmit={(v) =>
|
|
674
|
+
apply((d) =>
|
|
675
|
+
addRelationship(d, {
|
|
676
|
+
from: v.from,
|
|
677
|
+
to: v.to,
|
|
678
|
+
cardinality: v.cardinality as ErdCardinality,
|
|
679
|
+
label: v.label.trim() || undefined,
|
|
680
|
+
}),
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
/>
|
|
684
|
+
)}
|
|
685
|
+
</section>
|
|
686
|
+
|
|
687
|
+
<section className={shown("problems")}>
|
|
688
|
+
<div className={`${heading} max-md:mt-0 ${problemCount === 0 && !linkReport ? "md:hidden" : ""}`}>Problems</div>
|
|
689
|
+
{problems.map((p, i) => (
|
|
690
|
+
<div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
|
|
691
|
+
[{p.ruleId}] {p.message}
|
|
692
|
+
</div>
|
|
693
|
+
))}
|
|
694
|
+
{linkProblems.map((p, i) => (
|
|
695
|
+
<div key={`l${i}`} className="py-0.5 text-[11px] text-amber-300">
|
|
696
|
+
[{p.ruleId}] {p.message}
|
|
697
|
+
</div>
|
|
698
|
+
))}
|
|
699
|
+
{linkReport === "none" && (
|
|
700
|
+
<div className="py-0.5 text-[11px] text-amber-300">
|
|
701
|
+
No state machine (.smdf.json) sits beside this document, so there was nothing to check it against.
|
|
702
|
+
</div>
|
|
703
|
+
)}
|
|
704
|
+
{problemCount === 0 && linkReport !== "none" && (
|
|
705
|
+
<div className={`py-0.5 text-[11px] ${linkReport ? "text-green-400" : "text-gray-600 md:hidden"}`}>
|
|
706
|
+
{linkReport
|
|
707
|
+
? "Every name the machines use resolves."
|
|
708
|
+
: "The document is valid. “Check links” (⋯) holds the state machines beside it against it."}
|
|
709
|
+
</div>
|
|
710
|
+
)}
|
|
711
|
+
</section>
|
|
712
|
+
</div>
|
|
495
713
|
</aside>
|
|
496
714
|
)}
|
|
497
715
|
</div>
|
|
716
|
+
|
|
717
|
+
{/* Tab dock (phone). Three tabs one thumb-tap away, and the problem count is on screen at all times. */}
|
|
718
|
+
<nav className="dock-safe safe-x fixed inset-x-0 bottom-0 z-40 flex border-t border-gray-800 bg-gray-900/95 backdrop-blur-md md:hidden">
|
|
719
|
+
{tabs.map((t) => {
|
|
720
|
+
const current = sheetOpen && tab === t.id;
|
|
721
|
+
return (
|
|
722
|
+
<button
|
|
723
|
+
key={t.id}
|
|
724
|
+
onClick={() => openTab(t.id)}
|
|
725
|
+
aria-pressed={current}
|
|
726
|
+
className={`relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-colors ${
|
|
727
|
+
current ? "text-blue-400" : "text-gray-500"
|
|
728
|
+
}`}
|
|
729
|
+
>
|
|
730
|
+
<span className="text-base leading-none">{t.icon}</span>
|
|
731
|
+
<span>{t.label}</span>
|
|
732
|
+
{t.badge !== undefined && (
|
|
733
|
+
<span className="absolute right-1/2 top-1 min-w-[16px] translate-x-5 rounded-full bg-red-600 px-1 text-[10px] leading-4 text-white">
|
|
734
|
+
{t.badge}
|
|
735
|
+
</span>
|
|
736
|
+
)}
|
|
737
|
+
{current && <span className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-blue-400" />}
|
|
738
|
+
</button>
|
|
739
|
+
);
|
|
740
|
+
})}
|
|
741
|
+
</nav>
|
|
498
742
|
</div>
|
|
499
743
|
);
|
|
500
744
|
}
|
|
@@ -559,7 +803,7 @@ function InlineForm({
|
|
|
559
803
|
/>
|
|
560
804
|
),
|
|
561
805
|
)}
|
|
562
|
-
<button type="submit" className={`${button} col-span-2`}>
|
|
806
|
+
<button type="submit" className={`${button} ${fields.length === 1 ? "" : "col-span-2"}`}>
|
|
563
807
|
{submit}
|
|
564
808
|
</button>
|
|
565
809
|
</form>
|