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