@miadi/stateloom-web 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +1 -1
  2. package/server/.next/BUILD_ID +1 -1
  3. package/server/.next/build-manifest.json +2 -2
  4. package/server/.next/prerender-manifest.json +3 -3
  5. package/server/.next/server/app/_global-error.html +2 -2
  6. package/server/.next/server/app/_global-error.rsc +1 -1
  7. package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/server/.next/server/app/_not-found.html +1 -1
  14. package/server/.next/server/app/_not-found.rsc +2 -2
  15. package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  18. package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
  22. package/server/.next/server/app/api/file/route.js.nft.json +1 -1
  23. package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
  24. package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
  25. package/server/.next/server/app/index.html +1 -1
  26. package/server/.next/server/app/index.rsc +3 -3
  27. package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  28. package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
  29. package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
  30. package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
  31. package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  32. package/server/.next/server/app/page.js.nft.json +1 -1
  33. package/server/.next/server/app/page_client-reference-manifest.js +1 -1
  34. package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +1 -1
  35. package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
  36. package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
  37. package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +2 -2
  38. package/server/.next/server/chunks/ssr/{[root-of-the-server]__2b35b711._.js → [root-of-the-server]__46d45ef8._.js} +1 -1
  39. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +3 -3
  40. package/server/.next/server/pages/404.html +1 -1
  41. package/server/.next/server/pages/500.html +2 -2
  42. package/server/.next/server/server-reference-manifest.js +1 -1
  43. package/server/.next/server/server-reference-manifest.json +1 -1
  44. package/server/.next/static/chunks/486bc3bc16e91024.css +2 -0
  45. package/server/.next/static/chunks/b3f3eb72c25c4c6b.js +10 -0
  46. package/server/package-lock.json +2 -2
  47. package/server/package.json +1 -1
  48. package/server/src/app/page.tsx +31 -1
  49. package/server/src/components/erd/ErdWorkspace.tsx +567 -0
  50. package/server/src/lib/projectFile.ts +5 -7
  51. package/server/stateloom-web.json +2 -2
  52. package/server/tsconfig.tsbuildinfo +1 -1
  53. package/server/.next/static/chunks/1cbe108679f9af04.css +0 -2
  54. package/server/.next/static/chunks/8ff30b68acab2d20.js +0 -10
  55. /package/server/.next/static/{MpwR1y8jNIjcY-QAij3pD → DPL_H0tteH9tpIBvvsIfq}/_buildManifest.js +0 -0
  56. /package/server/.next/static/{MpwR1y8jNIjcY-QAij3pD → DPL_H0tteH9tpIBvvsIfq}/_clientMiddlewareManifest.json +0 -0
  57. /package/server/.next/static/{MpwR1y8jNIjcY-QAij3pD → DPL_H0tteH9tpIBvvsIfq}/_ssgManifest.js +0 -0
@@ -0,0 +1,567 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The designer, when the document is an ERD (`.erdf.json`, Spec 80).
5
+ *
6
+ * It is a separate workspace rather than a mode of the state designer because
7
+ * nothing in `useDesignerStore` applies: no state tree, no events, no PatchOps.
8
+ * What it shares is everything underneath — the same `?doc=` parameter and file
9
+ * API, the same hub room keyed by the resolved path, the same canvas package.
10
+ *
11
+ * Every edit is written to disk and then pushed to the room whole. Disk first,
12
+ * because the agent's MCP tools read the file before each of their own edits:
13
+ * a change that lived only in the browser would be overwritten by the next
14
+ * `add_entity`. The edits are the protocol's pure functions, the same ones the
15
+ * MCP tools call, so a hand and an agent make identical changes.
16
+ */
17
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18
+ import { EntityRelationshipCanvas } from "@miadi/stateloom-canvas";
19
+ import { createBridgeSession, type BridgeSession } from "@miadi/stateloom-react";
20
+ import {
21
+ IDENTITY_VIEWPORT,
22
+ ERD_CARDINALITIES,
23
+ addAttribute,
24
+ addEntity,
25
+ addRelationship,
26
+ checkLinks,
27
+ checkStateOf,
28
+ emptyErd,
29
+ erdAutoLayout,
30
+ isErdDefinition,
31
+ removeAttribute,
32
+ removeEntity,
33
+ removeRelationship,
34
+ renderMermaidEr,
35
+ updateEntity,
36
+ validateErd,
37
+ type EntityRelationshipDefinition,
38
+ type ErdCardinality,
39
+ type ErdNotation,
40
+ type ErdValidationError,
41
+ type LayoutBox,
42
+ type StateMachineDefinition,
43
+ type Viewport,
44
+ } from "@miadi/stateloom-protocol";
45
+ import DocSwitcher from "@/components/DocSwitcher";
46
+ import { docQuery, navigateToDoc, useRequestedDoc } from "@/lib/docParam";
47
+ import { loadRuntimeConfig } from "@/lib/runtimeConfig";
48
+
49
+ type Moved = Record<string, { x: number; y: number }>;
50
+ type Edit = (def: EntityRelationshipDefinition) => EntityRelationshipDefinition;
51
+
52
+ // The two notations give an entity different footprints, so each keeps its own
53
+ // dragged arrangement. Crow's foot keeps the key it has always had.
54
+ const layoutKey = (docPath: string, notation: ErdNotation): string =>
55
+ notation === "chen" ? `stateloom:erd-layout:chen:${docPath}` : `stateloom:erd-layout:${docPath}`;
56
+
57
+ const NOTATION_KEY = "stateloom:erd-notation";
58
+
59
+ function readMoved(docPath: string, notation: ErdNotation): Moved {
60
+ try {
61
+ return JSON.parse(localStorage.getItem(layoutKey(docPath, notation)) ?? "{}") as Moved;
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ /** The viewer's own preference — kept in this browser, never in the document. */
68
+ function readNotation(): ErdNotation {
69
+ try {
70
+ return localStorage.getItem(NOTATION_KEY) === "chen" ? "chen" : "crowsfoot";
71
+ } catch {
72
+ return "crowsfoot";
73
+ }
74
+ }
75
+
76
+ interface Machine {
77
+ path: string;
78
+ def: StateMachineDefinition;
79
+ }
80
+
81
+ /** The state machines in the same directory as `docPath` — the ERD's siblings. */
82
+ async function siblingMachines(docPath: string): Promise<Machine[]> {
83
+ const dir = docPath.slice(0, docPath.lastIndexOf("/"));
84
+ const listing = (await (await fetch("/api/docs", { cache: "no-store" })).json()) as { docs?: { path: string }[] };
85
+ const paths = (listing.docs ?? [])
86
+ .map((d) => d.path)
87
+ .filter((p) => p.endsWith(".smdf.json") && p.slice(0, p.lastIndexOf("/")) === dir);
88
+ const machines: Machine[] = [];
89
+ for (const path of paths) {
90
+ try {
91
+ const body = await (await fetch(`/api/file${docQuery(path)}`, { cache: "no-store" })).json();
92
+ const parsed = JSON.parse(body.content ?? "null");
93
+ const def = parsed?.stateMachine ?? parsed?.StateMachine ?? parsed;
94
+ if (def?.state) machines.push({ path, def });
95
+ } catch {
96
+ // An unreadable sibling is skipped; it is not this document's problem.
97
+ }
98
+ }
99
+ return machines;
100
+ }
101
+
102
+ const input =
103
+ "w-full rounded border border-gray-700 bg-gray-950 px-2 py-1 text-xs text-gray-200 placeholder:text-gray-600";
104
+ const button = "rounded border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-200 hover:bg-gray-700";
105
+ const heading = "mb-1 mt-3 text-[11px] font-semibold uppercase tracking-wide text-gray-500";
106
+
107
+ export default function ErdWorkspace() {
108
+ const requested = useRequestedDoc();
109
+ const [docPath, setDocPath] = useState("");
110
+ const [def, setDef] = useState<EntityRelationshipDefinition | null>(null);
111
+ const [moved, setMoved] = useState<Moved>({});
112
+ const [notation, setNotation] = useState<ErdNotation>("crowsfoot");
113
+ const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
114
+ const [fitKey, setFitKey] = useState(0);
115
+ const [selection, setSelection] = useState<string | null>(null);
116
+ const [status, setStatus] = useState("loading…");
117
+ const [peers, setPeers] = useState(0);
118
+ const [linkReport, setLinkReport] = useState<ErdValidationError[] | null>(null);
119
+ const sessionRef = useRef<BridgeSession | null>(null);
120
+
121
+ // Load the document, then join its room.
122
+ useEffect(() => {
123
+ let cancelled = false;
124
+ let session: BridgeSession | null = null;
125
+ (async () => {
126
+ const body = await (await fetch(`/api/file${docQuery(requested)}`, { cache: "no-store" })).json();
127
+ if (cancelled) return;
128
+ if (body.error) return setStatus(body.error);
129
+ const path: string = body.path ?? "";
130
+ let loaded: EntityRelationshipDefinition | null = null;
131
+ try {
132
+ const parsed = body.content ? JSON.parse(body.content) : null;
133
+ if (isErdDefinition(parsed)) loaded = { ...parsed, relationships: parsed.relationships ?? [] };
134
+ } catch {
135
+ // Falls through to the unreadable-document message below.
136
+ }
137
+ if (body.exists && !loaded) return setStatus(`${path} is not a readable ERD definition`);
138
+ const name = (path.split("/").pop() ?? "Untitled").replace(/\.erdf\.json$/i, "");
139
+ const preferred = readNotation();
140
+ setDocPath(path);
141
+ setDef(loaded ?? emptyErd("Default", name));
142
+ setNotation(preferred);
143
+ setMoved(readMoved(path, preferred));
144
+ setSelection(null);
145
+ setLinkReport(null);
146
+ setFitKey((k) => k + 1);
147
+ setStatus(body.exists ? "loaded" : "new document — the first edit writes it");
148
+
149
+ const { bridgeUrl } = await loadRuntimeConfig();
150
+ if (cancelled || !bridgeUrl) return;
151
+ session = createBridgeSession({
152
+ url: bridgeUrl,
153
+ role: "web",
154
+ docId: path,
155
+ name: "web-designer",
156
+ onFull: (e) => {
157
+ const incoming: unknown = e.def;
158
+ if (!isErdDefinition(incoming)) return;
159
+ setDef({ ...incoming, relationships: incoming.relationships ?? [] });
160
+ setStatus("updated live");
161
+ },
162
+ // An ERD has no PatchOp vocabulary; anything arriving as a patch is not for this board.
163
+ onPatch: () => {},
164
+ onPresence: (list) => setPeers(list.length),
165
+ });
166
+ sessionRef.current = session;
167
+ session.connect();
168
+ })().catch((e) => !cancelled && setStatus(String(e)));
169
+ return () => {
170
+ cancelled = true;
171
+ sessionRef.current = null;
172
+ session?.disconnect();
173
+ };
174
+ }, [requested]);
175
+
176
+ const positions = useMemo<Record<string, LayoutBox>>(() => {
177
+ if (!def) return {};
178
+ const derived = erdAutoLayout(def, { notation });
179
+ for (const [name, at] of Object.entries(moved)) {
180
+ if (derived[name]) derived[name] = { ...derived[name], ...at };
181
+ }
182
+ return derived;
183
+ }, [def, moved, notation]);
184
+
185
+ const problems = useMemo(() => (def ? validateErd(def) : []), [def]);
186
+ const errorEntities = useMemo(
187
+ () => problems.map((p) => (p.element ?? "").split(/[.[]/)[0]).filter(Boolean),
188
+ [problems],
189
+ );
190
+
191
+ const apply = useCallback(
192
+ async (edit: Edit) => {
193
+ if (!def || !docPath) return;
194
+ let next: EntityRelationshipDefinition;
195
+ try {
196
+ next = edit(def);
197
+ } catch (e) {
198
+ return setStatus(e instanceof Error ? e.message : String(e));
199
+ }
200
+ setDef(next);
201
+ setLinkReport(null);
202
+ try {
203
+ const r = await fetch(`/api/file${docQuery(docPath)}`, {
204
+ method: "PUT",
205
+ headers: { "content-type": "application/json" },
206
+ body: JSON.stringify({ content: JSON.stringify(next, null, 2) + "\n" }),
207
+ });
208
+ if (!r.ok) throw new Error((await r.json())?.error ?? `HTTP ${r.status}`);
209
+ sessionRef.current?.emitFull(next as unknown as StateMachineDefinition);
210
+ setStatus("saved");
211
+ } catch (e) {
212
+ setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`);
213
+ }
214
+ },
215
+ [def, docPath],
216
+ );
217
+
218
+ const moveEntity = useCallback(
219
+ (name: string, box: LayoutBox) => {
220
+ setMoved((prev) => {
221
+ const next = { ...prev, [name]: { x: Math.round(box.x), y: Math.round(box.y) } };
222
+ try {
223
+ localStorage.setItem(layoutKey(docPath, notation), JSON.stringify(next));
224
+ } catch {
225
+ // A full or disabled store costs the arrangement, not the document.
226
+ }
227
+ return next;
228
+ });
229
+ },
230
+ [docPath, notation],
231
+ );
232
+
233
+ const chooseNotation = (next: ErdNotation): void => {
234
+ if (next === notation) return;
235
+ try {
236
+ localStorage.setItem(NOTATION_KEY, next);
237
+ } catch {
238
+ // The choice then lasts for this visit only.
239
+ }
240
+ setNotation(next);
241
+ setMoved(readMoved(docPath, next));
242
+ setFitKey((k) => k + 1);
243
+ };
244
+
245
+ const arrange = (): void => {
246
+ setMoved({});
247
+ try {
248
+ localStorage.removeItem(layoutKey(docPath, notation));
249
+ } catch {
250
+ /* as above */
251
+ }
252
+ setFitKey((k) => k + 1);
253
+ };
254
+
255
+ const runLinkCheck = async (): Promise<void> => {
256
+ if (!def) return;
257
+ setStatus("checking links…");
258
+ const machines = await siblingMachines(docPath);
259
+ if (machines.length === 0) {
260
+ setLinkReport([]);
261
+ return setStatus("no .smdf.json beside this document to check against");
262
+ }
263
+ const found = machines.flatMap((m) =>
264
+ checkLinks(m.def, def).map((e) => ({ ...e, message: `${m.def.settings?.name ?? m.path}: ${e.message}` })),
265
+ );
266
+ found.push(...checkStateOf(def, machines.map((m) => m.def.settings?.name ?? "")));
267
+ setLinkReport(found);
268
+ setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`);
269
+ };
270
+
271
+ const openMachine = async (machine: string): Promise<void> => {
272
+ const hit = (await siblingMachines(docPath)).find((m) => m.def.settings?.name === machine);
273
+ if (hit) navigateToDoc(hit.path);
274
+ else setStatus(`no .smdf.json beside this document defines '${machine}'`);
275
+ };
276
+
277
+ const selected = def?.entities.find((e) => e.name === selection) ?? null;
278
+ const fileName = docPath.split("/").pop() || "…";
279
+
280
+ return (
281
+ <div className="app-shell flex flex-col overflow-hidden bg-gray-950 text-gray-200">
282
+ <header className="flex flex-wrap items-center gap-2 border-b border-gray-800 bg-gray-900 px-3 py-2 text-xs">
283
+ <span className="font-semibold text-blue-300">ERD</span>
284
+ <DocSwitcher />
285
+ <span className="text-gray-400" title={docPath}>
286
+ {def?.settings.name ?? ""} · {fileName}
287
+ </span>
288
+ <span className="ml-auto text-gray-500">
289
+ {status}
290
+ {peers > 1 ? ` · ${peers} in the room` : ""}
291
+ </span>
292
+ {/* 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">
294
+ {(
295
+ [
296
+ ["crowsfoot", "▤", "Crow's foot — attributes listed inside the entity box"],
297
+ ["chen", "◇", "Chen — attributes as ovals, relationships as diamonds"],
298
+ ] as const
299
+ ).map(([id, glyph, title]) => (
300
+ <button
301
+ key={id}
302
+ title={title}
303
+ aria-label={title}
304
+ aria-pressed={notation === id}
305
+ onClick={() => chooseNotation(id)}
306
+ className={`px-2 py-1 text-sm leading-none ${
307
+ notation === id ? "bg-blue-900/60 text-blue-200" : "bg-gray-800 text-gray-400 hover:bg-gray-700"
308
+ }`}
309
+ >
310
+ {glyph}
311
+ </button>
312
+ ))}
313
+ </span>
314
+ <button className={button} onClick={arrange} title="Forget the dragged positions and lay the board out again">
315
+ ⤢ Arrange
316
+ </button>
317
+ <button className={button} onClick={() => setFitKey((k) => k + 1)}>
318
+ Fit
319
+ </button>
320
+ <button className={button} onClick={runLinkCheck} title="Check the state machines beside this document against it">
321
+ Check links
322
+ </button>
323
+ <button
324
+ className={button}
325
+ onClick={() => def && navigator.clipboard?.writeText(renderMermaidEr(def)).then(() => setStatus("mermaid copied"))}
326
+ >
327
+ Copy mermaid
328
+ </button>
329
+ </header>
330
+
331
+ <div className="flex min-h-0 flex-1 flex-col md:flex-row">
332
+ <div className="min-h-[45dvh] flex-1 overflow-hidden">
333
+ {def && (
334
+ <EntityRelationshipCanvas
335
+ definition={def}
336
+ notation={notation}
337
+ positions={positions}
338
+ viewport={viewport}
339
+ onViewportChange={setViewport}
340
+ selection={selection}
341
+ errorElements={errorEntities}
342
+ fitKey={fitKey}
343
+ onSelect={setSelection}
344
+ onClearSelection={() => setSelection(null)}
345
+ onEntityMove={moveEntity}
346
+ onOpenMachine={openMachine}
347
+ emptyHint="No entities yet — add one in the panel, or let an agent call add_entity."
348
+ />
349
+ )}
350
+ </div>
351
+
352
+ {def && (
353
+ <aside className="touch-targets w-full overflow-y-auto border-t border-gray-800 bg-gray-900 p-3 md:w-80 md:border-l md:border-t-0">
354
+ <div className={heading} style={{ marginTop: 0 }}>
355
+ Entities
356
+ </div>
357
+ <div className="flex flex-wrap gap-1">
358
+ {def.entities.map((e) => (
359
+ <button
360
+ key={e.name}
361
+ className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
362
+ onClick={() => setSelection(e.name)}
363
+ >
364
+ {e.name}
365
+ </button>
366
+ ))}
367
+ </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
+
377
+ {selected && (
378
+ <>
379
+ <div className={heading}>{selected.name}</div>
380
+ <label
381
+ className="mb-1 flex items-center gap-2 text-[11px] text-gray-400"
382
+ title="Exists only through another entity (an order line, without its order). Chen draws it as a double rectangle."
383
+ >
384
+ <input
385
+ type="checkbox"
386
+ checked={!!selected.weak}
387
+ onChange={(e) => apply((d) => updateEntity(d, selected.name, { weak: e.target.checked }))}
388
+ />
389
+ weak entity
390
+ </label>
391
+ {(selected.attributes ?? []).map((a) => (
392
+ <div key={a.name} className="flex items-center justify-between gap-2 py-0.5 font-mono text-[11px]">
393
+ <span className={a.stateOf ? "text-blue-300" : "text-gray-300"}>
394
+ {a.name} : {a.type}
395
+ <span className="text-gray-500">
396
+ {a.key ? ` ${a.key.toUpperCase()}` : ""}
397
+ {a.references ? ` → ${a.references}` : ""}
398
+ {a.stateOf ? ` ◉ ${[a.stateOf].flat().join(", ")}` : ""}
399
+ </span>
400
+ </span>
401
+ <button
402
+ className="text-gray-500 hover:text-red-400"
403
+ aria-label={`Remove ${a.name}`}
404
+ onClick={() => apply((d) => removeAttribute(d, selected.name, a.name))}
405
+ >
406
+ ✕
407
+ </button>
408
+ </div>
409
+ ))}
410
+ <InlineForm
411
+ fields={[
412
+ { name: "name", placeholder: "attribute" },
413
+ { name: "type", placeholder: "type", initial: "string" },
414
+ { name: "key", options: ["", "pk", "uk"] },
415
+ { name: "references", placeholder: "references Entity.attr" },
416
+ { name: "stateOf", placeholder: "stateOf Machine" },
417
+ ]}
418
+ submit="+ Attribute"
419
+ onSubmit={(v) =>
420
+ apply((d) =>
421
+ addAttribute(d, selected.name, {
422
+ name: v.name.trim(),
423
+ type: v.type.trim() || "string",
424
+ key: v.key === "pk" || v.key === "uk" ? v.key : undefined,
425
+ references: v.references.trim() || undefined,
426
+ stateOf: v.stateOf.trim() || undefined,
427
+ }),
428
+ )
429
+ }
430
+ />
431
+ <button
432
+ className={`${button} mt-2 text-red-300`}
433
+ onClick={() => {
434
+ apply((d) => removeEntity(d, selected.name));
435
+ setSelection(null);
436
+ }}
437
+ >
438
+ 🗑 Delete {selected.name}
439
+ </button>
440
+ </>
441
+ )}
442
+
443
+ <div className={heading}>Relationships</div>
444
+ {def.relationships.map((r, i) => (
445
+ <div key={i} className="flex items-center justify-between gap-2 py-0.5 text-[11px] text-gray-300">
446
+ <span>
447
+ {r.from} <span className="text-gray-500">{r.cardinality}</span> {r.to}
448
+ {r.label ? <span className="text-gray-500"> : {r.label}</span> : null}
449
+ </span>
450
+ <button
451
+ className="text-gray-500 hover:text-red-400"
452
+ aria-label="Remove relationship"
453
+ onClick={() => apply((d) => removeRelationship(d, r.from, r.to, r.label))}
454
+ >
455
+ ✕
456
+ </button>
457
+ </div>
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
+ ))}
495
+ </aside>
496
+ )}
497
+ </div>
498
+ </div>
499
+ );
500
+ }
501
+
502
+ interface Field {
503
+ name: string;
504
+ placeholder?: string;
505
+ options?: string[];
506
+ initial?: string;
507
+ }
508
+
509
+ /** A row of inputs and one submit. Clears its text fields after a submit; keeps the selects. */
510
+ function InlineForm({
511
+ fields,
512
+ submit,
513
+ onSubmit,
514
+ }: {
515
+ fields: Field[];
516
+ submit: string;
517
+ onSubmit: (values: Record<string, string>) => void;
518
+ }) {
519
+ const initial = (): Record<string, string> =>
520
+ Object.fromEntries(fields.map((f) => [f.name, f.initial ?? f.options?.[0] ?? ""]));
521
+ const [values, setValues] = useState(initial);
522
+ const first = fields[0];
523
+ return (
524
+ <form
525
+ className="mt-1 grid grid-cols-2 gap-1"
526
+ onSubmit={(e) => {
527
+ e.preventDefault();
528
+ const merged = { ...initial(), ...values };
529
+ if (!merged[first.name]?.trim()) return;
530
+ onSubmit(merged);
531
+ setValues((prev) =>
532
+ Object.fromEntries(fields.map((f) => [f.name, f.options ? (prev[f.name] ?? "") : (f.initial ?? "")])),
533
+ );
534
+ }}
535
+ >
536
+ {fields.map((f) =>
537
+ f.options ? (
538
+ <select
539
+ key={f.name}
540
+ className={input}
541
+ aria-label={f.name}
542
+ value={values[f.name] ?? ""}
543
+ onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
544
+ >
545
+ {f.options.map((o) => (
546
+ <option key={o} value={o}>
547
+ {o || `(${f.name})`}
548
+ </option>
549
+ ))}
550
+ </select>
551
+ ) : (
552
+ <input
553
+ key={f.name}
554
+ className={input}
555
+ aria-label={f.name}
556
+ placeholder={f.placeholder}
557
+ value={values[f.name] ?? ""}
558
+ onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
559
+ />
560
+ ),
561
+ )}
562
+ <button type="submit" className={`${button} col-span-2`}>
563
+ {submit}
564
+ </button>
565
+ </form>
566
+ );
567
+ }
@@ -162,13 +162,11 @@ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; tr
162
162
 
163
163
  for (const root of allowedDocRoots()) walk(root, 0);
164
164
 
165
- // `.smdf.json` first — the rest are `.json` files the allowlist tolerates but
166
- // nobody came here looking for — then most recently touched.
167
- docs.sort((a, b) => {
168
- const aSmdf = a.name.endsWith(".smdf.json") ? 0 : 1;
169
- const bSmdf = b.name.endsWith(".smdf.json") ? 0 : 1;
170
- return aSmdf - bSmdf || b.mtime - a.mtime || a.path.localeCompare(b.path);
171
- });
165
+ // The loom's own documents first (`.smdf.json` machines, `.erdf.json` ERDs) —
166
+ // the rest are `.json` files the allowlist tolerates but nobody came here
167
+ // looking for — then most recently touched.
168
+ const rank = (name: string): number => (/\.(smdf|erdf)\.json$/i.test(name) ? 0 : 1);
169
+ docs.sort((a, b) => rank(a.name) - rank(b.name) || b.mtime - a.mtime || a.path.localeCompare(b.path));
172
170
 
173
171
  return { docs, truncated };
174
172
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.1.5",
3
- "designer": "0.1.5",
2
+ "package": "0.1.7",
3
+ "designer": "0.1.7",
4
4
  "next": "16.1.6"
5
5
  }