@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
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The designer, when the document is a system (`.sysdf.json`, Spec 82).
|
|
5
|
+
*
|
|
6
|
+
* The board is the system map: one card per member, by kind, with what it
|
|
7
|
+
* holds; a line between two members for every name that crosses between them
|
|
8
|
+
* (`systemLinks`), labelled with how many; and the actors the scenarios share.
|
|
9
|
+
* A card opens its member inside the system (`?doc=<member>&system=<this>`),
|
|
10
|
+
* where the strip keeps every drawing one tap away.
|
|
11
|
+
*
|
|
12
|
+
* The panel: Members (what each is, and the links between each pair), Checks
|
|
13
|
+
* (`checkSystem` grouped by rule, errors first, then the replay of every path
|
|
14
|
+
* of every scenario, and the reconcile mode with the changes the scenarios
|
|
15
|
+
* imply), and Notes. Same shell as the other workspaces: one header row, the
|
|
16
|
+
* panel a bottom sheet behind a dock on a phone, a right column on a desktop.
|
|
17
|
+
*
|
|
18
|
+
* Members load through the file API, so the document allowlist decides what a
|
|
19
|
+
* system may reach; a refused member is drawn as a card that says why.
|
|
20
|
+
*/
|
|
21
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
22
|
+
import {
|
|
23
|
+
RECONCILE_MODES,
|
|
24
|
+
checkSystem,
|
|
25
|
+
elementFocus,
|
|
26
|
+
membersByKind,
|
|
27
|
+
reconcileModeOf,
|
|
28
|
+
reconcileSystem,
|
|
29
|
+
setReconcileMode,
|
|
30
|
+
summarizeReconcile,
|
|
31
|
+
systemLinks,
|
|
32
|
+
updateMember,
|
|
33
|
+
updateSystemSettings,
|
|
34
|
+
type DocKind,
|
|
35
|
+
type EntityRelationshipDefinition,
|
|
36
|
+
type ElementRef,
|
|
37
|
+
type ReconcileMode,
|
|
38
|
+
type SequenceDefinition,
|
|
39
|
+
type StateMachineDefinition,
|
|
40
|
+
type SystemIssue,
|
|
41
|
+
type SystemLink,
|
|
42
|
+
} from "@miadi/stateloom-protocol";
|
|
43
|
+
import BoardBoundary from "@/components/BoardBoundary";
|
|
44
|
+
import DocSwitcher from "@/components/DocSwitcher";
|
|
45
|
+
import IssueIcon from "@/components/IssueIcon";
|
|
46
|
+
import { NotesField } from "@/components/erd/ErdWorkspace";
|
|
47
|
+
import { panelButton as button, panelHeading as heading } from "@/components/forms";
|
|
48
|
+
import KindIcon from "@/components/system/KindIcon";
|
|
49
|
+
import { memberTitle } from "@/components/system/SystemStrip";
|
|
50
|
+
import { useRequestedDoc } from "@/lib/docParam";
|
|
51
|
+
import { useSheetDrag } from "@/lib/useSheetDrag";
|
|
52
|
+
import { openInSystem, urlParam } from "@/lib/systemParam";
|
|
53
|
+
import { safely } from "@/lib/safely";
|
|
54
|
+
import { DocConflict, notifySystemChanged, readDoc, reconcileNow, useSystem, writeDoc, type LoadedSystem } from "@/lib/systemLoad";
|
|
55
|
+
|
|
56
|
+
type Tab = "members" | "checks" | "notes";
|
|
57
|
+
|
|
58
|
+
const KIND_LABEL: Record<DocKind, string> = { erd: "ERD", machine: "STATE MACHINE", sequence: "SEQUENCE", system: "SYSTEM" };
|
|
59
|
+
|
|
60
|
+
function countsOf(kind: DocKind, def: unknown, short = false): string {
|
|
61
|
+
if (!def) return "";
|
|
62
|
+
if (kind === "erd") {
|
|
63
|
+
const d = def as EntityRelationshipDefinition;
|
|
64
|
+
return `${d.entities?.length ?? 0} entities · ${d.relationships?.length ?? 0} relationships`;
|
|
65
|
+
}
|
|
66
|
+
if (kind === "sequence") {
|
|
67
|
+
const d = def as SequenceDefinition;
|
|
68
|
+
const f = d.fragments?.length ?? 0;
|
|
69
|
+
return `${d.participants?.length ?? 0} participants · ${d.messages?.length ?? 0} messages${f && !short ? ` · ${f} fragment${f > 1 ? "s" : ""}` : ""}`;
|
|
70
|
+
}
|
|
71
|
+
const d = def as StateMachineDefinition;
|
|
72
|
+
let states = 0;
|
|
73
|
+
const walk = (s: StateMachineDefinition["state"] | undefined): void => {
|
|
74
|
+
if (!s) return;
|
|
75
|
+
states += 1;
|
|
76
|
+
(s.states ?? []).forEach(walk);
|
|
77
|
+
(s.parallel?.states ?? []).forEach(walk);
|
|
78
|
+
};
|
|
79
|
+
// The root holds the machine; it is not one of its states (as Miadi's scalar layer counts).
|
|
80
|
+
(d.state?.states ?? []).forEach(walk);
|
|
81
|
+
(d.state?.parallel?.states ?? []).forEach(walk);
|
|
82
|
+
const events = (d.events ?? []).reduce((n, src) => n + (src?.events?.length ?? 0) + (src?.timers?.length ?? 0), 0);
|
|
83
|
+
return `${states} states · ${events} events`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pairKey = (a: string, b: string): string => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
|
|
87
|
+
|
|
88
|
+
function linkLine(l: SystemLink): string {
|
|
89
|
+
const from = l.from.kind === "message" ? `message ${l.from.name}` : `${l.from.kind} ${l.from.name}`;
|
|
90
|
+
return `${from} ${l.text}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default function SystemWorkspace() {
|
|
94
|
+
const requested = useRequestedDoc();
|
|
95
|
+
// No `?doc=`: the default document is this system; ask the server its path.
|
|
96
|
+
const [path, setPath] = useState<string | null>(requested);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
let live = true;
|
|
99
|
+
if (requested) setPath(requested);
|
|
100
|
+
// The first render has not read the URL yet: only a page with no `?doc=` asks for the default.
|
|
101
|
+
else if (!urlParam("doc")) readDoc(null).then((r) => live && setPath(r.path || null));
|
|
102
|
+
return () => {
|
|
103
|
+
live = false;
|
|
104
|
+
};
|
|
105
|
+
}, [requested]);
|
|
106
|
+
const { system, reload } = useSystem(path);
|
|
107
|
+
const [tab, setTab] = useState<Tab>("members");
|
|
108
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
109
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
110
|
+
const [panelHidden, setPanelHidden] = useState(false);
|
|
111
|
+
const [pair, setPair] = useState<string | null>(null);
|
|
112
|
+
const [status, setStatusState] = useState<{ text: string; tone: "ok" | "warn" }>({ text: "loading…", tone: "ok" });
|
|
113
|
+
const setStatus = useCallback((text: string, tone: "ok" | "warn" = "ok") => setStatusState({ text, tone }), []);
|
|
114
|
+
const [busy, setBusy] = useState(false);
|
|
115
|
+
const sheet = useSheetDrag({ onClose: () => setSheetOpen(false), restVh: 62, tallVh: 92 });
|
|
116
|
+
const boardRef = useRef<HTMLDivElement | null>(null);
|
|
117
|
+
const [portrait, setPortrait] = useState(false);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const el = boardRef.current;
|
|
121
|
+
if (!el) return;
|
|
122
|
+
const observe = () => {
|
|
123
|
+
const r = el.getBoundingClientRect();
|
|
124
|
+
if (r.width) setPortrait(r.height > r.width * 1.1);
|
|
125
|
+
};
|
|
126
|
+
observe();
|
|
127
|
+
const ro = new ResizeObserver(observe);
|
|
128
|
+
ro.observe(el);
|
|
129
|
+
return () => ro.disconnect();
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
// Said once; a reload after an edit keeps whatever the edit said.
|
|
133
|
+
const announced = useRef(false);
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!system) return;
|
|
136
|
+
if (system.error) setStatus(system.error, "warn");
|
|
137
|
+
else if (!announced.current) setStatus(`${system.members.length} member${system.members.length === 1 ? "" : "s"} loaded`);
|
|
138
|
+
announced.current = true;
|
|
139
|
+
}, [system, setStatus]);
|
|
140
|
+
const [statusQuiet, setStatusQuiet] = useState(false);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const timer = setTimeout(() => setStatusQuiet(true), 4000);
|
|
143
|
+
return () => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
setStatusQuiet(false);
|
|
146
|
+
};
|
|
147
|
+
}, [status]);
|
|
148
|
+
|
|
149
|
+
// A member in a shape the checks do not expect costs the checks, not the map.
|
|
150
|
+
const check = useMemo(() => (system?.def ? safely(() => checkSystem(system.def, system.members), null) : null), [system]);
|
|
151
|
+
const links = useMemo(() => (system ? safely(() => systemLinks(system.members), []) : []), [system]);
|
|
152
|
+
const reconcile = useMemo(() => (system?.def ? safely(() => reconcileSystem(system.def, system.members), null) : null), [system]);
|
|
153
|
+
const mode: ReconcileMode = reconcileModeOf(system?.def);
|
|
154
|
+
|
|
155
|
+
const pairs = useMemo(() => {
|
|
156
|
+
const map = new Map<string, { a: string; b: string; links: SystemLink[] }>();
|
|
157
|
+
for (const l of links) {
|
|
158
|
+
if (l.from.member === l.to.member) continue;
|
|
159
|
+
const key = pairKey(l.from.member, l.to.member);
|
|
160
|
+
const entry = map.get(key) ?? { a: key.split("\u0000")[0], b: key.split("\u0000")[1], links: [] };
|
|
161
|
+
entry.links.push(l);
|
|
162
|
+
map.set(key, entry);
|
|
163
|
+
}
|
|
164
|
+
return map;
|
|
165
|
+
}, [links]);
|
|
166
|
+
|
|
167
|
+
const issuesOf = (member: string) => (check?.issues ?? []).filter((i) => i.member === member);
|
|
168
|
+
const errors = (check?.issues ?? []).filter((i) => i.severity === "error");
|
|
169
|
+
const warnings = (check?.issues ?? []).filter((i) => i.severity === "warning");
|
|
170
|
+
|
|
171
|
+
const openMember = (member: string, focus?: string | null): void => {
|
|
172
|
+
if (!system) return;
|
|
173
|
+
openInSystem({ doc: system.absOf[member] ?? member, system: requested ?? system.path, focus: focus ?? null });
|
|
174
|
+
};
|
|
175
|
+
const openRef = (ref: ElementRef): void => openMember(ref.member, elementFocus(ref));
|
|
176
|
+
const openIssue = (i: SystemIssue): void => {
|
|
177
|
+
if (!i.member) return;
|
|
178
|
+
openMember(i.member, i.element && /^[a-z]+:/.test(i.element) ? i.element : null);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const writeSystem = async (next: NonNullable<LoadedSystem["def"]>, said: string): Promise<void> => {
|
|
182
|
+
if (!system) return;
|
|
183
|
+
try {
|
|
184
|
+
// Only over the file this page read: an agent's edit since is reloaded, not overwritten.
|
|
185
|
+
await writeDoc(system.path, next, system.mtimes[system.path]);
|
|
186
|
+
setStatus(said);
|
|
187
|
+
notifySystemChanged();
|
|
188
|
+
} catch (e) {
|
|
189
|
+
if (e instanceof DocConflict) {
|
|
190
|
+
setStatus("the system changed on disk — reloaded; make the change again", "warn");
|
|
191
|
+
notifySystemChanged();
|
|
192
|
+
} else setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`, "warn");
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const chooseMode = (next: ReconcileMode): void => {
|
|
197
|
+
if (!system?.def || next === mode) return;
|
|
198
|
+
void writeSystem(setReconcileMode(system.def, next), `reconcile mode: ${next}`);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const applyChanges = async (): Promise<void> => {
|
|
202
|
+
if (!system || !reconcile || !reconcile.count) return;
|
|
203
|
+
setBusy(true);
|
|
204
|
+
// Computed again from the disk as it is now, not from what this page loaded.
|
|
205
|
+
const { result, written, failed, conflicts } = await reconcileNow(system.path);
|
|
206
|
+
setBusy(false);
|
|
207
|
+
if (failed.length) setStatus(`not all written: ${failed.join("; ")}`, "warn");
|
|
208
|
+
else if (conflicts.length) setStatus(`${conflicts.join(", ")} kept changing on disk — not written; apply again`, "warn");
|
|
209
|
+
else if (!result?.count) setStatus("nothing to apply: the drawings on disk already agree");
|
|
210
|
+
else setStatus(`${summarizeReconcile(result, "propose").replace(/ proposed/, " applied")} — ${written.length} file(s) written`);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const openTab = (next: Tab): void => {
|
|
214
|
+
if (sheetOpen && tab === next) sheet.close();
|
|
215
|
+
else setSheetOpen(true);
|
|
216
|
+
setTab(next);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// ── The map's geometry, in its own coordinates; the SVG scales it to the board.
|
|
220
|
+
const W = portrait ? 420 : 760;
|
|
221
|
+
const H = portrait ? 640 : 520;
|
|
222
|
+
const cardW = portrait ? 196 : 196;
|
|
223
|
+
const cardH = 92;
|
|
224
|
+
const memberPaths = system?.def?.members?.filter((m) => m?.path).map((m) => m.path) ?? [];
|
|
225
|
+
const actors = system?.def?.actors ?? [];
|
|
226
|
+
const actorRow = actors.length ? 64 : 0;
|
|
227
|
+
const cards = useMemo(() => {
|
|
228
|
+
const n = memberPaths.length;
|
|
229
|
+
const cx = W / 2;
|
|
230
|
+
const cy = (H - actorRow) / 2 + 6;
|
|
231
|
+
const rx = Math.max(0, W / 2 - cardW / 2 - 16);
|
|
232
|
+
const ry = Math.max(0, (H - actorRow) / 2 - cardH / 2 - 18);
|
|
233
|
+
const at = new Map<string, { x: number; y: number }>();
|
|
234
|
+
memberPaths.forEach((m, i) => {
|
|
235
|
+
if (n === 1) return at.set(m, { x: cx, y: cy });
|
|
236
|
+
// A phone held upright: a zigzag down the screen, so no two cards share a
|
|
237
|
+
// row and every line has room for its count.
|
|
238
|
+
if (portrait) {
|
|
239
|
+
const top = cardH / 2 + 16;
|
|
240
|
+
const bottom = H - actorRow - cardH / 2 - 16;
|
|
241
|
+
return at.set(m, { x: i % 2 ? W * 0.67 : W * 0.33, y: top + ((bottom - top) * i) / (n - 1) });
|
|
242
|
+
}
|
|
243
|
+
if (n === 2) return at.set(m, portrait ? { x: cx, y: i ? cy + ry : cy - ry } : { x: i ? cx + rx : cx - rx, y: cy });
|
|
244
|
+
const angle = -Math.PI / 2 + (i * 2 * Math.PI) / n;
|
|
245
|
+
at.set(m, { x: cx + rx * Math.cos(angle), y: cy + ry * Math.sin(angle) });
|
|
246
|
+
});
|
|
247
|
+
return at;
|
|
248
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
249
|
+
}, [memberPaths.join("|"), W, H, cardW, actorRow, portrait]);
|
|
250
|
+
|
|
251
|
+
// Which scenarios each actor takes part in.
|
|
252
|
+
const actorUse = useMemo(() => {
|
|
253
|
+
const use = new Map<string, string[]>();
|
|
254
|
+
for (const { member, def } of membersByKind(system?.members ?? []).sequences) {
|
|
255
|
+
for (const p of def.participants ?? []) {
|
|
256
|
+
if (!p?.actor) continue;
|
|
257
|
+
const list = use.get(p.actor) ?? [];
|
|
258
|
+
if (!list.includes(member)) list.push(member);
|
|
259
|
+
use.set(p.actor, list);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return use;
|
|
263
|
+
}, [system]);
|
|
264
|
+
|
|
265
|
+
const def = system?.def;
|
|
266
|
+
const fileName = (system?.path ?? path ?? "").split("/").pop() || "…";
|
|
267
|
+
const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
|
|
268
|
+
{ id: "members", label: "Members", icon: <KindIcon kind="system" /> },
|
|
269
|
+
{ id: "checks", label: "Checks", icon: <IssueIcon />, badge: errors.length + warnings.length || undefined },
|
|
270
|
+
{ id: "notes", label: "Notes", icon: "✎" },
|
|
271
|
+
];
|
|
272
|
+
const selectedPair = pair ? pairs.get(pair) : undefined;
|
|
273
|
+
|
|
274
|
+
// Issues grouped by rule: errors first, then warnings.
|
|
275
|
+
const grouped = useMemo(() => {
|
|
276
|
+
const out: { key: string; ruleId: string; severity: "error" | "warning"; issues: SystemIssue[] }[] = [];
|
|
277
|
+
for (const severity of ["error", "warning"] as const) {
|
|
278
|
+
const byRule = new Map<string, SystemIssue[]>();
|
|
279
|
+
for (const i of check?.issues ?? []) {
|
|
280
|
+
if (i.severity !== severity) continue;
|
|
281
|
+
byRule.set(i.ruleId, [...(byRule.get(i.ruleId) ?? []), i]);
|
|
282
|
+
}
|
|
283
|
+
for (const [ruleId, issues] of [...byRule.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
284
|
+
out.push({ key: `${severity}:${ruleId}`, ruleId, severity, issues });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}, [check]);
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<div className="app-shell dock-offset flex flex-col overflow-hidden bg-gray-950 text-gray-200" onClick={() => menuOpen && setMenuOpen(false)}>
|
|
292
|
+
<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">
|
|
293
|
+
<span className="flex items-center gap-1 font-semibold text-blue-300">
|
|
294
|
+
<KindIcon kind="system" size={14} />
|
|
295
|
+
SYS
|
|
296
|
+
</span>
|
|
297
|
+
<DocSwitcher />
|
|
298
|
+
<span className="min-w-0 flex-1 truncate text-gray-400" title={system?.path}>
|
|
299
|
+
{def?.settings?.name ?? fileName}
|
|
300
|
+
<span className="hidden text-gray-600 lg:inline"> · {fileName}</span>
|
|
301
|
+
</span>
|
|
302
|
+
<div className="hidden shrink-0 items-center gap-2 md:flex">
|
|
303
|
+
<button className={button} onClick={reload} title="Read the system and every member again">
|
|
304
|
+
Re-check
|
|
305
|
+
</button>
|
|
306
|
+
<button className={button} onClick={() => setPanelHidden((h) => !h)} aria-pressed={!panelHidden}>
|
|
307
|
+
{panelHidden ? "⟨ Panel" : "Panel ⟩"}
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
310
|
+
<div className="relative shrink-0 md:hidden">
|
|
311
|
+
<button
|
|
312
|
+
className="rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm leading-none text-gray-200"
|
|
313
|
+
aria-label="More actions"
|
|
314
|
+
aria-expanded={menuOpen}
|
|
315
|
+
onClick={(e) => {
|
|
316
|
+
e.stopPropagation();
|
|
317
|
+
setMenuOpen((o) => !o);
|
|
318
|
+
}}
|
|
319
|
+
>
|
|
320
|
+
⋯
|
|
321
|
+
</button>
|
|
322
|
+
{menuOpen && (
|
|
323
|
+
<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">
|
|
324
|
+
<button
|
|
325
|
+
className="block w-full px-4 py-3 text-left text-sm text-gray-200 hover:bg-gray-800"
|
|
326
|
+
onClick={() => {
|
|
327
|
+
setMenuOpen(false);
|
|
328
|
+
reload();
|
|
329
|
+
}}
|
|
330
|
+
>
|
|
331
|
+
Re-check the system
|
|
332
|
+
</button>
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
</header>
|
|
337
|
+
|
|
338
|
+
<div className="relative flex min-h-0 flex-1">
|
|
339
|
+
<div ref={boardRef} className="relative min-w-0 flex-1 overflow-hidden bg-[#030712]">
|
|
340
|
+
{system && def && (
|
|
341
|
+
<BoardBoundary what="system map" resetKey={system}>
|
|
342
|
+
<svg className="h-full w-full" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet" role="img" aria-label={`System map of ${def.settings?.name}`}>
|
|
343
|
+
{/* Lines between members: one per pair, labelled with its count. */}
|
|
344
|
+
{[...pairs.entries()].map(([key, p]) => {
|
|
345
|
+
const a = cards.get(p.a);
|
|
346
|
+
const b = cards.get(p.b);
|
|
347
|
+
if (!a || !b) return null;
|
|
348
|
+
const mx = (a.x + b.x) / 2;
|
|
349
|
+
const my = (a.y + b.y) / 2;
|
|
350
|
+
const label = `${p.links.length} link${p.links.length === 1 ? "" : "s"}`;
|
|
351
|
+
const w = label.length * 6.4 + 16;
|
|
352
|
+
const on = pair === key;
|
|
353
|
+
return (
|
|
354
|
+
<g
|
|
355
|
+
key={key}
|
|
356
|
+
className="cursor-pointer"
|
|
357
|
+
onClick={() => {
|
|
358
|
+
setPair(on ? null : key);
|
|
359
|
+
setTab("members");
|
|
360
|
+
setSheetOpen(true);
|
|
361
|
+
}}
|
|
362
|
+
>
|
|
363
|
+
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="transparent" strokeWidth={18} />
|
|
364
|
+
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={on ? "#3b82f6" : "#475569"} strokeWidth={on ? 2.5 : 1.6} />
|
|
365
|
+
<rect x={mx - w / 2} y={my - 11} width={w} height={22} rx={11} fill="#0f172a" stroke={on ? "#3b82f6" : "#334155"} />
|
|
366
|
+
<text x={mx} y={my + 4} textAnchor="middle" fontSize={11.5} fill={on ? "#93c5fd" : "#cbd5e1"}>
|
|
367
|
+
{label}
|
|
368
|
+
</text>
|
|
369
|
+
</g>
|
|
370
|
+
);
|
|
371
|
+
})}
|
|
372
|
+
|
|
373
|
+
{/* Actors along the bottom, joined to the scenarios they take part in. */}
|
|
374
|
+
{actors.map((actor, i) => {
|
|
375
|
+
const x = ((i + 1) * W) / (actors.length + 1);
|
|
376
|
+
const y = H - 30;
|
|
377
|
+
return (
|
|
378
|
+
<g key={actor.name}>
|
|
379
|
+
{(actorUse.get(actor.name) ?? []).map((m) => {
|
|
380
|
+
const c = cards.get(m);
|
|
381
|
+
return c ? <line key={m} x1={x} y1={y - 14} x2={c.x} y2={c.y + cardH / 2} stroke="#334155" strokeDasharray="3 4" /> : null;
|
|
382
|
+
})}
|
|
383
|
+
<title>{[`${actor.kind}`, actor.description ?? "", actor.wheelNode ?? ""].filter(Boolean).join(" — ")}</title>
|
|
384
|
+
<rect x={x - 62} y={y - 14} width={124} height={28} rx={14} fill="#0c1020" stroke="#6366f1" />
|
|
385
|
+
<g transform={`translate(${x - 54} ${y - 8})`} color="#a5b4fc">
|
|
386
|
+
<KindIcon kind="actor" size={16} />
|
|
387
|
+
</g>
|
|
388
|
+
<text x={x - 34} y={y - 1} fontSize={11.5} fontWeight={600} fill="#e2e8f0">
|
|
389
|
+
{actor.name.length > 12 ? `${actor.name.slice(0, 11)}…` : actor.name}
|
|
390
|
+
</text>
|
|
391
|
+
<text x={x - 34} y={y + 9} fontSize={8.5} fill="#94a3b8">
|
|
392
|
+
{actor.kind}
|
|
393
|
+
</text>
|
|
394
|
+
</g>
|
|
395
|
+
);
|
|
396
|
+
})}
|
|
397
|
+
|
|
398
|
+
{/* The members. */}
|
|
399
|
+
{memberPaths.map((m) => {
|
|
400
|
+
const at = cards.get(m);
|
|
401
|
+
const loaded = system.members.find((x) => x.path === m);
|
|
402
|
+
if (!at || !loaded) return null;
|
|
403
|
+
const kind = loaded.kind;
|
|
404
|
+
const mine = issuesOf(m);
|
|
405
|
+
const e = mine.filter((i) => i.severity === "error").length;
|
|
406
|
+
const w = mine.filter((i) => i.severity === "warning").length;
|
|
407
|
+
const x = at.x - cardW / 2;
|
|
408
|
+
const y = at.y - cardH / 2;
|
|
409
|
+
const title = memberTitle(system, m);
|
|
410
|
+
const inPair = selectedPair && (selectedPair.a === m || selectedPair.b === m);
|
|
411
|
+
return (
|
|
412
|
+
<g key={m} className="cursor-pointer" onClick={() => openMember(m)}>
|
|
413
|
+
<title>{`${m}${loaded.error ? ` — cannot be read: ${loaded.error}` : ""}\nTap to open it inside the system.`}</title>
|
|
414
|
+
<rect
|
|
415
|
+
x={x}
|
|
416
|
+
y={y}
|
|
417
|
+
width={cardW}
|
|
418
|
+
height={cardH}
|
|
419
|
+
rx={10}
|
|
420
|
+
fill="#0f172a"
|
|
421
|
+
stroke={loaded.error ? "#ef4444" : inPair ? "#3b82f6" : e ? "#b91c1c" : "#475569"}
|
|
422
|
+
strokeWidth={inPair ? 2.2 : 1.5}
|
|
423
|
+
strokeDasharray={loaded.error ? "5 4" : undefined}
|
|
424
|
+
/>
|
|
425
|
+
<g transform={`translate(${x + 10} ${y + 9})`} color="#93c5fd">
|
|
426
|
+
<KindIcon kind={kind} size={16} />
|
|
427
|
+
</g>
|
|
428
|
+
<text x={x + 32} y={y + 21} fontSize={9} fontWeight={700} letterSpacing="0.06em" fill="#64748b">
|
|
429
|
+
{KIND_LABEL[kind]}
|
|
430
|
+
</text>
|
|
431
|
+
<text x={x + 10} y={y + 43} fontSize={14} fontWeight={600} fill="#e2e8f0">
|
|
432
|
+
{title.length > 22 ? `${title.slice(0, 21)}…` : title}
|
|
433
|
+
</text>
|
|
434
|
+
<text x={x + 10} y={y + 60} fontSize={10.5} fill="#94a3b8">
|
|
435
|
+
{loaded.error ? "cannot be read" : countsOf(kind, loaded.def, true)}
|
|
436
|
+
</text>
|
|
437
|
+
<text x={x + 10} y={y + 79} fontSize={10.5} fill={e ? "#fca5a5" : w ? "#fcd34d" : "#4ade80"}>
|
|
438
|
+
{loaded.error
|
|
439
|
+
? (loaded.error.length > 30 ? `${loaded.error.slice(0, 29)}…` : loaded.error)
|
|
440
|
+
: e || w
|
|
441
|
+
? [e ? `${e} error${e > 1 ? "s" : ""}` : "", w ? `${w} warning${w > 1 ? "s" : ""}` : ""].filter(Boolean).join(" · ")
|
|
442
|
+
: "checks pass"}
|
|
443
|
+
</text>
|
|
444
|
+
<text x={x + cardW - 10} y={y + 79} fontSize={10.5} textAnchor="end" fill="#64748b">
|
|
445
|
+
open ›
|
|
446
|
+
</text>
|
|
447
|
+
</g>
|
|
448
|
+
);
|
|
449
|
+
})}
|
|
450
|
+
</svg>
|
|
451
|
+
</BoardBoundary>
|
|
452
|
+
)}
|
|
453
|
+
{system && !def && (
|
|
454
|
+
<div className="absolute inset-0 flex items-center justify-center p-6 text-center text-sm text-amber-300">{system.error}</div>
|
|
455
|
+
)}
|
|
456
|
+
|
|
457
|
+
<div
|
|
458
|
+
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] ${
|
|
459
|
+
status.tone === "warn" ? "text-amber-300" : "text-gray-500"
|
|
460
|
+
}`}
|
|
461
|
+
role="status"
|
|
462
|
+
>
|
|
463
|
+
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${status.tone === "warn" ? "bg-amber-400" : "bg-emerald-500"}`} />
|
|
464
|
+
<span className={`truncate transition-opacity duration-500 ${statusQuiet && status.tone === "ok" ? "opacity-0" : "opacity-100"}`}>
|
|
465
|
+
{status.text}
|
|
466
|
+
</span>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
|
|
470
|
+
{sheetOpen && <div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={sheet.close} aria-hidden="true" />}
|
|
471
|
+
|
|
472
|
+
{system && def && (
|
|
473
|
+
<aside
|
|
474
|
+
style={sheet.sheetStyle}
|
|
475
|
+
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 ${
|
|
476
|
+
sheetOpen ? "translate-y-0" : "pointer-events-none translate-y-full"
|
|
477
|
+
} md:pointer-events-auto md:static md:z-auto md:h-auto md:w-96 md:translate-y-0 md:rounded-none md:border-l md:border-t-0 md:shadow-none md:transition-none ${
|
|
478
|
+
panelHidden ? "md:hidden" : ""
|
|
479
|
+
}`}
|
|
480
|
+
>
|
|
481
|
+
<div className="md:hidden" {...sheet.zoneProps}>
|
|
482
|
+
<div className="flex justify-center pb-1 pt-2">
|
|
483
|
+
<span className="h-1.5 w-12 rounded-full bg-gray-600" />
|
|
484
|
+
</div>
|
|
485
|
+
<div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
|
|
486
|
+
<h2 className="text-sm font-semibold text-gray-300">{tabs.find((t) => t.id === tab)?.label}</h2>
|
|
487
|
+
<button onClick={sheet.close} className="-mr-2 px-3 py-2 text-lg text-gray-400" aria-label="Close panel">
|
|
488
|
+
✕
|
|
489
|
+
</button>
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
<div className="hidden border-b border-gray-800 md:flex">
|
|
493
|
+
{tabs.map((t) => (
|
|
494
|
+
<button
|
|
495
|
+
key={t.id}
|
|
496
|
+
onClick={() => setTab(t.id)}
|
|
497
|
+
className={`relative flex-1 px-1 py-2 text-center text-xs transition-colors ${
|
|
498
|
+
tab === t.id ? "border-b-2 border-blue-400 bg-gray-800/50 text-blue-400" : "text-gray-500 hover:text-gray-300"
|
|
499
|
+
}`}
|
|
500
|
+
>
|
|
501
|
+
{t.label}
|
|
502
|
+
{t.badge !== undefined && (
|
|
503
|
+
<span className="absolute right-1 top-1 min-w-[14px] rounded-full bg-red-900 px-1 text-[9px] text-red-100">{t.badge}</span>
|
|
504
|
+
)}
|
|
505
|
+
</button>
|
|
506
|
+
))}
|
|
507
|
+
</div>
|
|
508
|
+
|
|
509
|
+
<div className="touch-targets dock-offset flex-1 overflow-y-auto overscroll-contain p-3">
|
|
510
|
+
{/* ── Members ──────────────────────────────────────────────── */}
|
|
511
|
+
<section className={tab === "members" ? "" : "hidden"}>
|
|
512
|
+
<div className={heading} style={{ marginTop: 0 }}>
|
|
513
|
+
Members
|
|
514
|
+
</div>
|
|
515
|
+
{memberPaths.map((m) => {
|
|
516
|
+
const loaded = system.members.find((x) => x.path === m);
|
|
517
|
+
const note = def.members.find((x) => x.path === m)?.notes;
|
|
518
|
+
return (
|
|
519
|
+
<div key={m} className="mb-1 rounded border border-gray-800">
|
|
520
|
+
<button onClick={() => openMember(m)} className="flex w-full items-start gap-2 p-2 text-left hover:bg-gray-800">
|
|
521
|
+
<span className="mt-0.5 text-blue-300">
|
|
522
|
+
<KindIcon kind={loaded?.kind ?? "machine"} />
|
|
523
|
+
</span>
|
|
524
|
+
<span className="min-w-0 flex-1">
|
|
525
|
+
<span className="block truncate text-[12px] font-semibold text-gray-200">{memberTitle(system, m)}</span>
|
|
526
|
+
<span className="block truncate text-[10.5px] text-gray-500">{m}</span>
|
|
527
|
+
<span className={`block text-[11px] ${loaded?.error ? "text-red-300" : "text-gray-400"}`}>
|
|
528
|
+
{loaded?.error ? `cannot be read: ${loaded.error}` : countsOf(loaded?.kind ?? "machine", loaded?.def)}
|
|
529
|
+
</span>
|
|
530
|
+
</span>
|
|
531
|
+
<span className="shrink-0 text-[11px] text-gray-500">open ›</span>
|
|
532
|
+
</button>
|
|
533
|
+
{/* The system's note about this member — kept in the .sysdf.json, not in the member. */}
|
|
534
|
+
<div className="px-2 pb-2 [&_label]:mt-0">
|
|
535
|
+
<NotesField
|
|
536
|
+
key={`member:${m}`}
|
|
537
|
+
label="Note on this member, in the system"
|
|
538
|
+
value={note ?? ""}
|
|
539
|
+
placeholder={`Why ${memberTitle(system, m)} is part of this system, what is open…`}
|
|
540
|
+
onSave={(notes) => void writeSystem(updateMember(def, m, { notes }), "saved")}
|
|
541
|
+
/>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
);
|
|
545
|
+
})}
|
|
546
|
+
|
|
547
|
+
<div className={heading}>Links between members</div>
|
|
548
|
+
{pairs.size === 0 && <div className="py-0.5 text-[11px] text-gray-600">No name crosses between the members yet.</div>}
|
|
549
|
+
{[...pairs.entries()].map(([key, p]) => (
|
|
550
|
+
<div key={key} className="mb-1">
|
|
551
|
+
<button
|
|
552
|
+
className={`flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-[11px] ${pair === key ? "bg-blue-950/60 text-blue-200" : "text-gray-300 hover:bg-gray-800"}`}
|
|
553
|
+
onClick={() => setPair(pair === key ? null : key)}
|
|
554
|
+
>
|
|
555
|
+
<span className="truncate">
|
|
556
|
+
{memberTitle(system, p.a)} ↔ {memberTitle(system, p.b)}
|
|
557
|
+
</span>
|
|
558
|
+
<span className="shrink-0 text-gray-500">{p.links.length}</span>
|
|
559
|
+
</button>
|
|
560
|
+
{pair === key && (
|
|
561
|
+
<div className="ml-2 border-l border-gray-800 pl-2">
|
|
562
|
+
{p.links.map((l, i) => (
|
|
563
|
+
<button
|
|
564
|
+
key={i}
|
|
565
|
+
className="block w-full truncate py-0.5 text-left text-[11px] text-gray-400 hover:text-gray-200"
|
|
566
|
+
title={`${linkLine(l)} — ${l.rule}`}
|
|
567
|
+
onClick={() => openRef(l.from)}
|
|
568
|
+
>
|
|
569
|
+
<span className="text-gray-600">{l.rule}</span> {linkLine(l)}
|
|
570
|
+
</button>
|
|
571
|
+
))}
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
574
|
+
</div>
|
|
575
|
+
))}
|
|
576
|
+
|
|
577
|
+
{actors.length > 0 && (
|
|
578
|
+
<>
|
|
579
|
+
<div className={heading}>Actors</div>
|
|
580
|
+
{actors.map((a) => (
|
|
581
|
+
<div key={a.name} className="py-0.5 text-[11px] text-gray-300">
|
|
582
|
+
{a.name} <span className="text-gray-500">· {a.kind}</span>
|
|
583
|
+
{a.description ? <span className="text-gray-500"> — {a.description}</span> : null}
|
|
584
|
+
</div>
|
|
585
|
+
))}
|
|
586
|
+
</>
|
|
587
|
+
)}
|
|
588
|
+
</section>
|
|
589
|
+
|
|
590
|
+
{/* ── Checks ───────────────────────────────────────────────── */}
|
|
591
|
+
<section className={tab === "checks" ? "" : "hidden"}>
|
|
592
|
+
<div className={heading} style={{ marginTop: 0 }}>
|
|
593
|
+
Checks
|
|
594
|
+
</div>
|
|
595
|
+
<div className={`text-[11px] ${errors.length ? "text-red-300" : warnings.length ? "text-amber-300" : "text-green-400"}`}>
|
|
596
|
+
{errors.length} error{errors.length === 1 ? "" : "s"} · {warnings.length} warning{warnings.length === 1 ? "" : "s"}
|
|
597
|
+
</div>
|
|
598
|
+
{grouped.map((g) => (
|
|
599
|
+
<div key={g.key} className="mt-2">
|
|
600
|
+
<div className={`text-[11px] font-semibold ${g.severity === "error" ? "text-red-300" : "text-amber-300"}`}>
|
|
601
|
+
{g.ruleId} · {g.issues.length} {g.severity}
|
|
602
|
+
{g.issues.length === 1 ? "" : "s"}
|
|
603
|
+
</div>
|
|
604
|
+
{g.issues.map((i, n) => (
|
|
605
|
+
<button
|
|
606
|
+
key={n}
|
|
607
|
+
className="block w-full py-0.5 pl-2 text-left text-[11px] text-gray-300 hover:text-white"
|
|
608
|
+
onClick={() => openIssue(i)}
|
|
609
|
+
title={i.member ? `Open ${i.member}${i.element ? ` at ${i.element}` : ""}` : undefined}
|
|
610
|
+
>
|
|
611
|
+
{i.message}
|
|
612
|
+
{i.member && <span className="text-gray-500"> — {memberTitle(system, i.member)}</span>}
|
|
613
|
+
</button>
|
|
614
|
+
))}
|
|
615
|
+
</div>
|
|
616
|
+
))}
|
|
617
|
+
|
|
618
|
+
<div className={heading}>Reconcile</div>
|
|
619
|
+
<p className="mb-1 text-[11px] text-gray-500">
|
|
620
|
+
What the scenarios imply for the machines and the data. <b className="text-gray-400">propose</b> lists it here to apply
|
|
621
|
+
by hand; <b className="text-gray-400">auto</b> writes it each time a scenario is saved.
|
|
622
|
+
</p>
|
|
623
|
+
<span className="inline-flex overflow-hidden rounded border border-gray-700" role="group" aria-label="Reconcile mode">
|
|
624
|
+
{RECONCILE_MODES.map((m) => (
|
|
625
|
+
<button
|
|
626
|
+
key={m}
|
|
627
|
+
aria-pressed={mode === m}
|
|
628
|
+
onClick={() => chooseMode(m)}
|
|
629
|
+
className={`px-3 py-1 text-xs ${mode === m ? "bg-blue-900/60 text-blue-200" : "bg-gray-800 text-gray-400 hover:bg-gray-700"}`}
|
|
630
|
+
>
|
|
631
|
+
{m}
|
|
632
|
+
</button>
|
|
633
|
+
))}
|
|
634
|
+
</span>
|
|
635
|
+
<div className="mt-2 text-[11px] font-semibold text-gray-400">Scenario changes</div>
|
|
636
|
+
{reconcile && (
|
|
637
|
+
<>
|
|
638
|
+
<div className="text-[11px] text-gray-500">{summarizeReconcile(reconcile, "propose")}</div>
|
|
639
|
+
{reconcile.machines.map((m) => (
|
|
640
|
+
<div key={m.member} className="mt-1">
|
|
641
|
+
<div className="text-[11px] text-gray-300">
|
|
642
|
+
{m.machine} <span className="text-gray-500">· {m.member}</span>
|
|
643
|
+
</div>
|
|
644
|
+
{m.reasons.map((r, i) => (
|
|
645
|
+
<div key={i} className="pl-2 text-[11px] text-gray-400">
|
|
646
|
+
+ {r}
|
|
647
|
+
</div>
|
|
648
|
+
))}
|
|
649
|
+
</div>
|
|
650
|
+
))}
|
|
651
|
+
{reconcile.erds.map((e) => (
|
|
652
|
+
<div key={e.member} className="mt-1">
|
|
653
|
+
<div className="text-[11px] text-gray-300">
|
|
654
|
+
{e.def.settings?.name ?? e.member} <span className="text-gray-500">· {e.member}</span>
|
|
655
|
+
</div>
|
|
656
|
+
{e.reasons.map((r, i) => (
|
|
657
|
+
<div key={i} className="pl-2 text-[11px] text-gray-400">
|
|
658
|
+
+ {r}
|
|
659
|
+
</div>
|
|
660
|
+
))}
|
|
661
|
+
</div>
|
|
662
|
+
))}
|
|
663
|
+
{reconcile.system && (
|
|
664
|
+
<div className="mt-1">
|
|
665
|
+
<div className="text-[11px] text-gray-300">the system</div>
|
|
666
|
+
{reconcile.system.reasons.map((r, i) => (
|
|
667
|
+
<div key={i} className="pl-2 text-[11px] text-gray-400">
|
|
668
|
+
+ {r}
|
|
669
|
+
</div>
|
|
670
|
+
))}
|
|
671
|
+
</div>
|
|
672
|
+
)}
|
|
673
|
+
{reconcile.unresolved.map((u, i) => (
|
|
674
|
+
<div key={i} className="mt-1 text-[11px] text-amber-300">
|
|
675
|
+
left open: {u}
|
|
676
|
+
</div>
|
|
677
|
+
))}
|
|
678
|
+
<button className={`${button} mt-2 disabled:opacity-40`} disabled={!reconcile.count || busy} onClick={applyChanges}>
|
|
679
|
+
{busy ? "Applying…" : `Apply${reconcile.count ? ` ${reconcile.count} change${reconcile.count === 1 ? "" : "s"}` : ""}`}
|
|
680
|
+
</button>
|
|
681
|
+
</>
|
|
682
|
+
)}
|
|
683
|
+
|
|
684
|
+
{(check?.replays ?? []).map(({ member, report }) => (
|
|
685
|
+
<div key={member}>
|
|
686
|
+
<div className={heading}>
|
|
687
|
+
Replay · {memberTitle(system, member)}
|
|
688
|
+
</div>
|
|
689
|
+
<div className="mb-1 text-[11px] text-gray-500">against {report.machines.join(", ") || "no machine"}</div>
|
|
690
|
+
{report.paths.map((p) => (
|
|
691
|
+
<div key={p.name} className="mb-2">
|
|
692
|
+
<div className={`text-[11px] font-semibold ${p.accepted ? "text-green-400" : "text-amber-300"}`}>
|
|
693
|
+
{p.name} — {p.accepted ? "accepted" : "stops"}
|
|
694
|
+
</div>
|
|
695
|
+
{p.steps
|
|
696
|
+
.filter((s) => s.result !== "data")
|
|
697
|
+
.map((s) => (
|
|
698
|
+
<button
|
|
699
|
+
key={`${p.name}:${s.ref}`}
|
|
700
|
+
className={`block w-full truncate py-0.5 pl-2 text-left font-mono text-[10.5px] ${
|
|
701
|
+
s.result === "moved" || s.result === "stayed" ? "text-gray-400" : "text-amber-300"
|
|
702
|
+
}`}
|
|
703
|
+
title={s.note ?? s.result}
|
|
704
|
+
onClick={() => openMember(member, `message:${s.ref}`)}
|
|
705
|
+
>
|
|
706
|
+
{s.ref}. {s.event ?? ""} {s.from.join("|") || "·"} → {s.to.join("|") || "·"} {s.result}
|
|
707
|
+
</button>
|
|
708
|
+
))}
|
|
709
|
+
</div>
|
|
710
|
+
))}
|
|
711
|
+
</div>
|
|
712
|
+
))}
|
|
713
|
+
</section>
|
|
714
|
+
|
|
715
|
+
{/* ── Notes ────────────────────────────────────────────────── */}
|
|
716
|
+
<section className={tab === "notes" ? "" : "hidden"}>
|
|
717
|
+
<div className={heading} style={{ marginTop: 0 }}>
|
|
718
|
+
Notes
|
|
719
|
+
</div>
|
|
720
|
+
<p className="mb-1 text-[11px] text-gray-600">Saved in the system file, for whoever opens it next.</p>
|
|
721
|
+
<NotesField
|
|
722
|
+
key="system"
|
|
723
|
+
label="On this system"
|
|
724
|
+
value={def.settings?.notes ?? ""}
|
|
725
|
+
placeholder="What the system is for, what is still open…"
|
|
726
|
+
onSave={(notes) => void writeSystem(updateSystemSettings(def, { notes }), "saved")}
|
|
727
|
+
/>
|
|
728
|
+
{def.settings?.description && (
|
|
729
|
+
<>
|
|
730
|
+
<div className={heading}>Description</div>
|
|
731
|
+
<p className="text-[11px] text-gray-400">{def.settings.description}</p>
|
|
732
|
+
</>
|
|
733
|
+
)}
|
|
734
|
+
</section>
|
|
735
|
+
</div>
|
|
736
|
+
</aside>
|
|
737
|
+
)}
|
|
738
|
+
</div>
|
|
739
|
+
|
|
740
|
+
<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">
|
|
741
|
+
{tabs.map((t) => {
|
|
742
|
+
const current = sheetOpen && tab === t.id;
|
|
743
|
+
return (
|
|
744
|
+
<button
|
|
745
|
+
key={t.id}
|
|
746
|
+
onClick={() => openTab(t.id)}
|
|
747
|
+
aria-pressed={current}
|
|
748
|
+
className={`relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-colors ${
|
|
749
|
+
current ? "text-blue-400" : "text-gray-500"
|
|
750
|
+
}`}
|
|
751
|
+
>
|
|
752
|
+
<span className="flex h-4 items-center text-base leading-none">{t.icon}</span>
|
|
753
|
+
<span>{t.label}</span>
|
|
754
|
+
{t.badge !== undefined && (
|
|
755
|
+
<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">
|
|
756
|
+
{t.badge}
|
|
757
|
+
</span>
|
|
758
|
+
)}
|
|
759
|
+
{current && <span className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-blue-400" />}
|
|
760
|
+
</button>
|
|
761
|
+
);
|
|
762
|
+
})}
|
|
763
|
+
</nav>
|
|
764
|
+
</div>
|
|
765
|
+
);
|
|
766
|
+
}
|