@miadi/stateloom-web 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +2 -2
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__46d45ef8._.js → [root-of-the-server]__d5879c0c._.js} +1 -1
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +3 -3
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/static/chunks/636e7eb4aeda72e0.css +2 -0
- package/server/.next/static/chunks/92628504da59dcc8.js +10 -0
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/app/page.tsx +17 -10
- package/server/src/components/IssueIcon.tsx +28 -0
- package/server/src/components/LockIcon.tsx +24 -0
- package/server/src/components/PropertiesPanel.tsx +10 -0
- package/server/src/components/SettingsPanel.tsx +11 -0
- package/server/src/components/erd/ErdWorkspace.tsx +167 -19
- 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/{XCT3lnw_1F5LD0GG4F94- → 6dAVJYqnbe6oEAABQ1yMG}/_buildManifest.js +0 -0
- /package/server/.next/static/{XCT3lnw_1F5LD0GG4F94- → 6dAVJYqnbe6oEAABQ1yMG}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{XCT3lnw_1F5LD0GG4F94- → 6dAVJYqnbe6oEAABQ1yMG}/_ssgManifest.js +0 -0
package/server/src/app/page.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState, type CSSProperties } from "react";
|
|
3
|
+
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
4
4
|
import Canvas from "@/components/Canvas";
|
|
5
5
|
import PropertiesPanel from "@/components/PropertiesPanel";
|
|
6
6
|
import EventsPanel from "@/components/EventsPanel";
|
|
@@ -12,9 +12,11 @@ import DesignBridge from "@/components/DesignBridge";
|
|
|
12
12
|
import UiScale from "@/components/UiScale";
|
|
13
13
|
import { useDesignerStore } from "@/store/useDesignerStore";
|
|
14
14
|
import ErdWorkspace from "@/components/erd/ErdWorkspace";
|
|
15
|
+
import IssueIcon from "@/components/IssueIcon";
|
|
15
16
|
import { isErdfPath } from "@miadi/stateloom-protocol";
|
|
16
17
|
import { useRequestedDoc } from "@/lib/docParam";
|
|
17
18
|
import { loadRuntimeConfig } from "@/lib/runtimeConfig";
|
|
19
|
+
import { useSheetDrag } from "@/lib/useSheetDrag";
|
|
18
20
|
|
|
19
21
|
type Tab = "properties" | "events" | "settings" | "validation";
|
|
20
22
|
|
|
@@ -78,6 +80,8 @@ function MachineDesigner() {
|
|
|
78
80
|
// the entire screen, and at `md` and up the responsive classes pin it open as
|
|
79
81
|
// the 320px right sidebar it has always been — this flag stops mattering.
|
|
80
82
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
83
|
+
// The sheet's handle answers a thumb: pull down to close, up to grow, tap to toggle.
|
|
84
|
+
const sheet = useSheetDrag({ onClose: () => setSheetOpen(false), restVh: 70, tallVh: 92 });
|
|
81
85
|
const errors = useDesignerStore((s) => s.errors);
|
|
82
86
|
const contextMenu = useDesignerStore((s) => s.contextMenu);
|
|
83
87
|
const hideContextMenu = useDesignerStore((s) => s.hideContextMenu);
|
|
@@ -86,11 +90,12 @@ function MachineDesigner() {
|
|
|
86
90
|
const setDrawMode = useDesignerStore((s) => s.setDrawMode);
|
|
87
91
|
const setDrawSource = useDesignerStore((s) => s.setDrawSource);
|
|
88
92
|
|
|
89
|
-
const tabs: { id: Tab; label: string; icon:
|
|
93
|
+
const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
|
|
90
94
|
{ id: "properties", label: "Properties", icon: "🎛" },
|
|
91
95
|
{ id: "events", label: "Events", icon: "⚡" },
|
|
92
96
|
{ id: "settings", label: "Settings", icon: "⚙" },
|
|
93
|
-
|
|
97
|
+
// Drawn, not typed: "⚠" is a bright yellow colour emoji on iOS. See IssueIcon.
|
|
98
|
+
{ id: "validation", label: "Errors", icon: <IssueIcon />, badge: errors.length || undefined },
|
|
94
99
|
];
|
|
95
100
|
|
|
96
101
|
const activeLabel = tabs.find((t) => t.id === activeTab)?.label ?? "";
|
|
@@ -99,7 +104,8 @@ function MachineDesigner() {
|
|
|
99
104
|
// both switcher and dismiss control and no screen height is spent on a
|
|
100
105
|
// separate close affordance.
|
|
101
106
|
const toggleTab = (id: Tab) => {
|
|
102
|
-
|
|
107
|
+
if (sheetOpen && activeTab === id) sheet.close();
|
|
108
|
+
else setSheetOpen(true);
|
|
103
109
|
setActiveTab(id);
|
|
104
110
|
};
|
|
105
111
|
|
|
@@ -125,7 +131,7 @@ function MachineDesigner() {
|
|
|
125
131
|
{sheetOpen && (
|
|
126
132
|
<div
|
|
127
133
|
className="fixed inset-0 z-20 bg-black/50 md:hidden"
|
|
128
|
-
onClick={
|
|
134
|
+
onClick={sheet.close}
|
|
129
135
|
aria-hidden="true"
|
|
130
136
|
/>
|
|
131
137
|
)}
|
|
@@ -136,21 +142,22 @@ function MachineDesigner() {
|
|
|
136
142
|
declaration — position, height, width, transform, radius, border
|
|
137
143
|
side — which is why the class list is long. */}
|
|
138
144
|
<aside
|
|
139
|
-
|
|
145
|
+
style={sheet.sheetStyle}
|
|
146
|
+
className={`safe-x fixed inset-x-0 bottom-0 z-30 h-[var(--sheet-h)] flex flex-col overflow-hidden rounded-t-2xl border-t border-gray-800 bg-gray-900 shadow-2xl transition-[transform,height] duration-200 ease-out ${
|
|
140
147
|
sheetOpen ? "translate-y-0" : "translate-y-full pointer-events-none"
|
|
141
148
|
} md:static md:z-auto md:h-auto md:w-80 md:translate-y-0 md:pointer-events-auto md:rounded-none md:border-t-0 md:border-l md:shadow-none md:transition-none`}
|
|
142
149
|
>
|
|
143
150
|
{/* Sheet header (phone). The grab handle reads as "this thing is a
|
|
144
151
|
sheet"; the dock below already names the tabs, so repeating the
|
|
145
152
|
tab bar here would only cost height. */}
|
|
146
|
-
<div className="md:hidden">
|
|
153
|
+
<div className="md:hidden" {...sheet.zoneProps}>
|
|
147
154
|
<div className="flex justify-center pt-2 pb-1">
|
|
148
|
-
<span className="h-1 w-
|
|
155
|
+
<span className="h-1.5 w-12 rounded-full bg-gray-600" />
|
|
149
156
|
</div>
|
|
150
157
|
<div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
|
|
151
158
|
<h2 className="text-sm font-semibold text-gray-300">{activeLabel}</h2>
|
|
152
159
|
<button
|
|
153
|
-
onClick={
|
|
160
|
+
onClick={sheet.close}
|
|
154
161
|
className="-mr-2 px-3 py-2 text-lg text-gray-400"
|
|
155
162
|
aria-label="Close panel"
|
|
156
163
|
>
|
|
@@ -215,7 +222,7 @@ function MachineDesigner() {
|
|
|
215
222
|
current ? "text-blue-400" : "text-gray-500"
|
|
216
223
|
}`}
|
|
217
224
|
>
|
|
218
|
-
<span className="text-base leading-none">{tab.icon}</span>
|
|
225
|
+
<span className="flex h-4 items-center text-base leading-none">{tab.icon}</span>
|
|
219
226
|
<span>{tab.label}</span>
|
|
220
227
|
{tab.badge !== undefined && tab.badge > 0 && (
|
|
221
228
|
<span className="absolute top-1 right-1/2 translate-x-4 rounded-full bg-red-600 px-1 text-[10px] leading-4 text-white min-w-[16px]">
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A warning triangle drawn as an outline in the current text colour.
|
|
3
|
+
*
|
|
4
|
+
* The "⚠" character is a full-colour emoji on iOS: a saturated yellow sign in
|
|
5
|
+
* a dock of grey glyphs, pulling the eye whether or not there is anything to
|
|
6
|
+
* report. Drawn here instead, it is as quiet as its neighbours and takes the
|
|
7
|
+
* tab's own colour — grey at rest, the accent when its tab is open. The count
|
|
8
|
+
* badge beside it is what says there is something to look at.
|
|
9
|
+
*/
|
|
10
|
+
export default function IssueIcon({ size = 16 }: { size?: number }) {
|
|
11
|
+
return (
|
|
12
|
+
<svg
|
|
13
|
+
width={size}
|
|
14
|
+
height={size}
|
|
15
|
+
viewBox="0 0 24 24"
|
|
16
|
+
fill="none"
|
|
17
|
+
stroke="currentColor"
|
|
18
|
+
strokeWidth="1.6"
|
|
19
|
+
strokeLinecap="round"
|
|
20
|
+
strokeLinejoin="round"
|
|
21
|
+
aria-hidden="true"
|
|
22
|
+
>
|
|
23
|
+
<path d="M12 4.5 21 19.5H3Z" />
|
|
24
|
+
<path d="M12 10v4.5" />
|
|
25
|
+
<path d="M12 17.3v.01" />
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A padlock drawn as an outline in the current text colour — closed or open.
|
|
3
|
+
* Drawn rather than typed for the same reason as IssueIcon: the padlock
|
|
4
|
+
* characters are full-colour emoji on iOS, a gold lock on a board of grey.
|
|
5
|
+
*/
|
|
6
|
+
export default function LockIcon({ locked, size = 16 }: { locked: boolean; size?: number }) {
|
|
7
|
+
return (
|
|
8
|
+
<svg
|
|
9
|
+
width={size}
|
|
10
|
+
height={size}
|
|
11
|
+
viewBox="0 0 24 24"
|
|
12
|
+
fill="none"
|
|
13
|
+
stroke="currentColor"
|
|
14
|
+
strokeWidth="1.6"
|
|
15
|
+
strokeLinecap="round"
|
|
16
|
+
strokeLinejoin="round"
|
|
17
|
+
aria-hidden="true"
|
|
18
|
+
>
|
|
19
|
+
<rect x="5" y="11" width="14" height="9" rx="2" />
|
|
20
|
+
{/* The shackle: seated in the body when locked, swung clear of it when not. */}
|
|
21
|
+
<path d={locked ? "M8 11V8a4 4 0 0 1 8 0v3" : "M8 11V8a4 4 0 0 1 7.5-1.9"} />
|
|
22
|
+
</svg>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -116,6 +116,16 @@ export default function PropertiesPanel() {
|
|
|
116
116
|
onChange={(e) => updateState(selection.id!, { description: e.target.value || undefined })}
|
|
117
117
|
/>
|
|
118
118
|
</label>
|
|
119
|
+
{/* Notes: what was said about this state, kept in the document for whoever opens it next. */}
|
|
120
|
+
<label className="block">
|
|
121
|
+
<span className="text-xs text-gray-500">Notes</span>
|
|
122
|
+
<textarea
|
|
123
|
+
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 h-20"
|
|
124
|
+
placeholder="An open question, a decision, something to tell the agent…"
|
|
125
|
+
value={state.notes ?? ""}
|
|
126
|
+
onChange={(e) => updateState(selection.id!, { notes: e.target.value || undefined })}
|
|
127
|
+
/>
|
|
128
|
+
</label>
|
|
119
129
|
|
|
120
130
|
{/* onEntry actions */}
|
|
121
131
|
<ActionEditor
|
|
@@ -38,6 +38,17 @@ export default function SettingsPanel() {
|
|
|
38
38
|
/>
|
|
39
39
|
</label>
|
|
40
40
|
|
|
41
|
+
{/* Notes about the whole diagram, kept in the document for whoever opens it next. */}
|
|
42
|
+
<label className="block">
|
|
43
|
+
<span className="text-xs text-gray-500">Notes on this diagram</span>
|
|
44
|
+
<textarea
|
|
45
|
+
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 h-24"
|
|
46
|
+
placeholder="What was discussed, what is still open, what to tell the agent…"
|
|
47
|
+
value={settings.notes ?? ""}
|
|
48
|
+
onChange={(e) => updateSettings({ notes: e.target.value || undefined })}
|
|
49
|
+
/>
|
|
50
|
+
</label>
|
|
51
|
+
|
|
41
52
|
<label className="flex items-center gap-2 text-sm text-gray-300">
|
|
42
53
|
<input
|
|
43
54
|
type="checkbox"
|
|
@@ -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 {
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
removeRelationship,
|
|
43
43
|
renderMermaidEr,
|
|
44
44
|
updateEntity,
|
|
45
|
+
updateErdSettings,
|
|
45
46
|
validateErd,
|
|
46
47
|
type EntityRelationshipDefinition,
|
|
47
48
|
type ErdCardinality,
|
|
@@ -52,11 +53,14 @@ import {
|
|
|
52
53
|
type Viewport,
|
|
53
54
|
} from "@miadi/stateloom-protocol";
|
|
54
55
|
import DocSwitcher from "@/components/DocSwitcher";
|
|
56
|
+
import IssueIcon from "@/components/IssueIcon";
|
|
57
|
+
import LockIcon from "@/components/LockIcon";
|
|
55
58
|
import { docQuery, navigateToDoc, useRequestedDoc } from "@/lib/docParam";
|
|
56
59
|
import { loadRuntimeConfig } from "@/lib/runtimeConfig";
|
|
60
|
+
import { useSheetDrag } from "@/lib/useSheetDrag";
|
|
57
61
|
|
|
58
62
|
type Moved = Record<string, { x: number; y: number }>;
|
|
59
|
-
type Tab = "entities" | "relations" | "
|
|
63
|
+
type Tab = "entities" | "relations" | "notes" | "issues";
|
|
60
64
|
type Edit = (def: EntityRelationshipDefinition) => EntityRelationshipDefinition;
|
|
61
65
|
|
|
62
66
|
// The two notations give an entity different footprints, so each keeps its own
|
|
@@ -65,6 +69,22 @@ const layoutKey = (docPath: string, notation: ErdNotation): string =>
|
|
|
65
69
|
notation === "chen" ? `stateloom:erd-layout:chen:${docPath}` : `stateloom:erd-layout:${docPath}`;
|
|
66
70
|
|
|
67
71
|
const NOTATION_KEY = "stateloom:erd-notation";
|
|
72
|
+
const LOCK_KEY = "stateloom:erd-lock";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether shapes start locked in place. A stored choice wins; without one, a
|
|
76
|
+
* touch screen starts locked — there a drag is almost always "move the view",
|
|
77
|
+
* and a shape that slides away under the thumb is the accident, not the intent.
|
|
78
|
+
*/
|
|
79
|
+
function readLocked(): boolean {
|
|
80
|
+
try {
|
|
81
|
+
const stored = localStorage.getItem(LOCK_KEY);
|
|
82
|
+
if (stored !== null) return stored === "1";
|
|
83
|
+
return window.matchMedia("(pointer: coarse)").matches;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
68
88
|
|
|
69
89
|
function readMoved(docPath: string, notation: ErdNotation): Moved {
|
|
70
90
|
try {
|
|
@@ -120,6 +140,8 @@ export default function ErdWorkspace() {
|
|
|
120
140
|
const [def, setDef] = useState<EntityRelationshipDefinition | null>(null);
|
|
121
141
|
const [moved, setMoved] = useState<Moved>({});
|
|
122
142
|
const [notation, setNotation] = useState<ErdNotation>("crowsfoot");
|
|
143
|
+
// Locked: a drag that starts on a shape pans the board instead of moving the shape. A tap still selects.
|
|
144
|
+
const [locked, setLocked] = useState(false);
|
|
123
145
|
const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
|
|
124
146
|
const [fitKey, setFitKey] = useState(0);
|
|
125
147
|
const [selection, setSelection] = useState<string | null>(null);
|
|
@@ -142,6 +164,8 @@ export default function ErdWorkspace() {
|
|
|
142
164
|
// Desktop only: the right column can be folded away.
|
|
143
165
|
const [panelHidden, setPanelHidden] = useState(false);
|
|
144
166
|
const boardRef = useRef<HTMLDivElement | null>(null);
|
|
167
|
+
// The sheet's handle answers a thumb: pull down to close, up to grow, tap to toggle.
|
|
168
|
+
const sheet = useSheetDrag({ onClose: () => setSheetOpen(false), restVh: 62, tallVh: 92 });
|
|
145
169
|
const [peers, setPeers] = useState(0);
|
|
146
170
|
// null: not checked. "none": checked, and no state machine sits beside the document.
|
|
147
171
|
const [linkReport, setLinkReport] = useState<ErdValidationError[] | "none" | null>(null);
|
|
@@ -169,6 +193,7 @@ export default function ErdWorkspace() {
|
|
|
169
193
|
setDocPath(path);
|
|
170
194
|
setDef(loaded ?? emptyErd("Default", name));
|
|
171
195
|
setNotation(preferred);
|
|
196
|
+
setLocked(readLocked());
|
|
172
197
|
setMoved(readMoved(path, preferred));
|
|
173
198
|
setSelection(null);
|
|
174
199
|
setLinkReport(null);
|
|
@@ -287,7 +312,7 @@ export default function ErdWorkspace() {
|
|
|
287
312
|
const machines = await siblingMachines(docPath);
|
|
288
313
|
if (machines.length === 0) {
|
|
289
314
|
setLinkReport("none");
|
|
290
|
-
setTab("
|
|
315
|
+
setTab("issues");
|
|
291
316
|
setSheetOpen(true);
|
|
292
317
|
return setStatus("no .smdf.json beside this document to check against", "warn");
|
|
293
318
|
}
|
|
@@ -296,8 +321,8 @@ export default function ErdWorkspace() {
|
|
|
296
321
|
);
|
|
297
322
|
found.push(...checkStateOf(def, machines.map((m) => m.def.settings?.name ?? "")));
|
|
298
323
|
setLinkReport(found);
|
|
299
|
-
// The answer lives in the
|
|
300
|
-
setTab("
|
|
324
|
+
// The answer lives in the Issues tab, so go there — on a phone it is otherwise behind a closed sheet.
|
|
325
|
+
setTab("issues");
|
|
301
326
|
setSheetOpen(true);
|
|
302
327
|
setPanelHidden(false);
|
|
303
328
|
setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`, found.length ? "warn" : "ok");
|
|
@@ -314,6 +339,17 @@ export default function ErdWorkspace() {
|
|
|
314
339
|
return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
|
|
315
340
|
};
|
|
316
341
|
|
|
342
|
+
const toggleLock = (): void => {
|
|
343
|
+
const next = !locked;
|
|
344
|
+
setLocked(next);
|
|
345
|
+
try {
|
|
346
|
+
localStorage.setItem(LOCK_KEY, next ? "1" : "0");
|
|
347
|
+
} catch {
|
|
348
|
+
// The choice then lasts for this visit only.
|
|
349
|
+
}
|
|
350
|
+
setStatus(next ? "shapes locked — a drag anywhere moves the view" : "shapes unlocked — drag one to move it");
|
|
351
|
+
};
|
|
352
|
+
|
|
317
353
|
const zoomBy = (factor: number): void => {
|
|
318
354
|
const { width, height } = boardSize();
|
|
319
355
|
setViewport((vp) => zoomAt(vp, factor, { x: width / 2, y: height / 2 }));
|
|
@@ -326,13 +362,14 @@ export default function ErdWorkspace() {
|
|
|
326
362
|
if (!box || !width) return;
|
|
327
363
|
setViewport(fitToBoxes([box], width, height, { padding: 28, maxScale: 1.25 }));
|
|
328
364
|
setSelection(name);
|
|
329
|
-
|
|
365
|
+
sheet.close();
|
|
330
366
|
};
|
|
331
367
|
|
|
332
368
|
const openTab = (next: Tab): void => {
|
|
333
369
|
// Tapping the tab you are already reading closes the sheet, so the dock is
|
|
334
370
|
// both switcher and dismiss control.
|
|
335
|
-
|
|
371
|
+
if (sheetOpen && tab === next) sheet.close();
|
|
372
|
+
else setSheetOpen(true);
|
|
336
373
|
setTab(next);
|
|
337
374
|
};
|
|
338
375
|
|
|
@@ -340,10 +377,11 @@ export default function ErdWorkspace() {
|
|
|
340
377
|
const fileName = docPath.split("/").pop() || "…";
|
|
341
378
|
const linkProblems = Array.isArray(linkReport) ? linkReport : [];
|
|
342
379
|
const problemCount = problems.length + linkProblems.length;
|
|
343
|
-
const tabs: { id: Tab; label: string; icon:
|
|
380
|
+
const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
|
|
344
381
|
{ id: "entities", label: "Entities", icon: "▭" },
|
|
345
382
|
{ id: "relations", label: "Relations", icon: "⟷" },
|
|
346
|
-
{ id: "
|
|
383
|
+
{ id: "notes", label: "Notes", icon: "✎", badge: undefined },
|
|
384
|
+
{ id: "issues", label: "Issues", icon: <IssueIcon />, badge: problemCount || undefined },
|
|
347
385
|
];
|
|
348
386
|
const shown = (id: Tab): string => (tab === id ? "" : "hidden md:block");
|
|
349
387
|
|
|
@@ -455,6 +493,7 @@ export default function ErdWorkspace() {
|
|
|
455
493
|
viewport={viewport}
|
|
456
494
|
onViewportChange={setViewport}
|
|
457
495
|
selection={selection}
|
|
496
|
+
readOnly={locked}
|
|
458
497
|
errorElements={errorEntities}
|
|
459
498
|
fitKey={fitKey}
|
|
460
499
|
onSelect={setSelection}
|
|
@@ -485,6 +524,17 @@ export default function ErdWorkspace() {
|
|
|
485
524
|
|
|
486
525
|
{/* View controls live on the board they act on. */}
|
|
487
526
|
<div className="absolute bottom-3 right-3 flex flex-col overflow-hidden rounded-lg border border-gray-700 bg-gray-900/90 shadow-lg backdrop-blur">
|
|
527
|
+
<button
|
|
528
|
+
title={locked ? "Shapes are locked: dragging moves the view. Tap to unlock." : "Lock the shapes in place, so dragging only moves the view"}
|
|
529
|
+
aria-label="Lock shapes in place"
|
|
530
|
+
aria-pressed={locked}
|
|
531
|
+
onClick={toggleLock}
|
|
532
|
+
className={`flex h-10 w-10 items-center justify-center border-b border-gray-800 hover:bg-gray-800 md:h-8 md:w-8 ${
|
|
533
|
+
locked ? "bg-blue-950/60 text-blue-300" : "text-gray-400"
|
|
534
|
+
}`}
|
|
535
|
+
>
|
|
536
|
+
<LockIcon locked={locked} />
|
|
537
|
+
</button>
|
|
488
538
|
{(
|
|
489
539
|
[
|
|
490
540
|
["+", "Zoom in", () => zoomBy(1.25)],
|
|
@@ -519,24 +569,25 @@ export default function ErdWorkspace() {
|
|
|
519
569
|
</div>
|
|
520
570
|
|
|
521
571
|
{sheetOpen && (
|
|
522
|
-
<div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={
|
|
572
|
+
<div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={sheet.close} aria-hidden="true" />
|
|
523
573
|
)}
|
|
524
574
|
|
|
525
575
|
{def && (
|
|
526
576
|
<aside
|
|
527
|
-
|
|
577
|
+
style={sheet.sheetStyle}
|
|
578
|
+
className={`safe-x fixed inset-x-0 bottom-0 z-30 flex h-[var(--sheet-h)] flex-col overflow-hidden rounded-t-2xl border-t border-gray-800 bg-gray-900 shadow-2xl transition-[transform,height] duration-200 ease-out ${
|
|
528
579
|
sheetOpen ? "translate-y-0" : "pointer-events-none translate-y-full"
|
|
529
580
|
} md:pointer-events-auto md:static md:z-auto md:h-auto md:w-80 md:translate-y-0 md:rounded-none md:border-l md:border-t-0 md:shadow-none md:transition-none ${
|
|
530
581
|
panelHidden ? "md:hidden" : ""
|
|
531
582
|
}`}
|
|
532
583
|
>
|
|
533
|
-
<div className="md:hidden">
|
|
584
|
+
<div className="md:hidden" {...sheet.zoneProps}>
|
|
534
585
|
<div className="flex justify-center pb-1 pt-2">
|
|
535
|
-
<span className="h-1 w-
|
|
586
|
+
<span className="h-1.5 w-12 rounded-full bg-gray-600" />
|
|
536
587
|
</div>
|
|
537
588
|
<div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
|
|
538
589
|
<h2 className="text-sm font-semibold text-gray-300">{tabs.find((t) => t.id === tab)?.label}</h2>
|
|
539
|
-
<button onClick={
|
|
590
|
+
<button onClick={sheet.close} className="-mr-2 px-3 py-2 text-lg text-gray-400" aria-label="Close panel">
|
|
540
591
|
✕
|
|
541
592
|
</button>
|
|
542
593
|
</div>
|
|
@@ -684,8 +735,51 @@ export default function ErdWorkspace() {
|
|
|
684
735
|
)}
|
|
685
736
|
</section>
|
|
686
737
|
|
|
687
|
-
<section className={shown("
|
|
688
|
-
<div className={`${heading} max-md:mt-0
|
|
738
|
+
<section className={shown("notes")}>
|
|
739
|
+
<div className={`${heading} max-md:mt-0`}>Notes</div>
|
|
740
|
+
<p className="mb-1 text-[11px] text-gray-600">
|
|
741
|
+
Saved in the document, for whoever opens it next — you, or an agent (it reads them with get_notes).
|
|
742
|
+
</p>
|
|
743
|
+
<NotesField
|
|
744
|
+
key="diagram"
|
|
745
|
+
label="On this diagram"
|
|
746
|
+
value={def.settings.notes ?? ""}
|
|
747
|
+
placeholder="What was discussed, what is still open…"
|
|
748
|
+
onSave={(notes) => apply((d) => updateErdSettings(d, { notes }))}
|
|
749
|
+
/>
|
|
750
|
+
{selected ? (
|
|
751
|
+
<NotesField
|
|
752
|
+
key={`entity:${selected.name}`}
|
|
753
|
+
label={`On ${selected.name}`}
|
|
754
|
+
value={selected.notes ?? ""}
|
|
755
|
+
placeholder={`A question or a decision about ${selected.name}…`}
|
|
756
|
+
onSave={(notes) => apply((d) => updateEntity(d, selected.name, { notes }))}
|
|
757
|
+
/>
|
|
758
|
+
) : (
|
|
759
|
+
<p className="mt-2 text-[11px] text-gray-600">Select an entity to leave a note on it.</p>
|
|
760
|
+
)}
|
|
761
|
+
{def.entities.some((e) => e.notes?.trim()) && (
|
|
762
|
+
<>
|
|
763
|
+
<div className={heading}>Entities with notes</div>
|
|
764
|
+
<div className="flex flex-wrap gap-1">
|
|
765
|
+
{def.entities
|
|
766
|
+
.filter((e) => e.notes?.trim())
|
|
767
|
+
.map((e) => (
|
|
768
|
+
<button
|
|
769
|
+
key={e.name}
|
|
770
|
+
className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
|
|
771
|
+
onClick={() => setSelection(e.name)}
|
|
772
|
+
>
|
|
773
|
+
{e.name}
|
|
774
|
+
</button>
|
|
775
|
+
))}
|
|
776
|
+
</div>
|
|
777
|
+
</>
|
|
778
|
+
)}
|
|
779
|
+
</section>
|
|
780
|
+
|
|
781
|
+
<section className={shown("issues")}>
|
|
782
|
+
<div className={`${heading} max-md:mt-0 ${problemCount === 0 && !linkReport ? "md:hidden" : ""}`}>Issues</div>
|
|
689
783
|
{problems.map((p, i) => (
|
|
690
784
|
<div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
|
|
691
785
|
[{p.ruleId}] {p.message}
|
|
@@ -714,7 +808,7 @@ export default function ErdWorkspace() {
|
|
|
714
808
|
)}
|
|
715
809
|
</div>
|
|
716
810
|
|
|
717
|
-
{/* Tab dock (phone). Three tabs one thumb-tap away, and the
|
|
811
|
+
{/* Tab dock (phone). Three tabs one thumb-tap away, and the issue count is on screen at all times. */}
|
|
718
812
|
<nav className="dock-safe safe-x fixed inset-x-0 bottom-0 z-40 flex border-t border-gray-800 bg-gray-900/95 backdrop-blur-md md:hidden">
|
|
719
813
|
{tabs.map((t) => {
|
|
720
814
|
const current = sheetOpen && tab === t.id;
|
|
@@ -727,10 +821,10 @@ export default function ErdWorkspace() {
|
|
|
727
821
|
current ? "text-blue-400" : "text-gray-500"
|
|
728
822
|
}`}
|
|
729
823
|
>
|
|
730
|
-
<span className="text-base leading-none">{t.icon}</span>
|
|
824
|
+
<span className="flex h-4 items-center text-base leading-none">{t.icon}</span>
|
|
731
825
|
<span>{t.label}</span>
|
|
732
826
|
{t.badge !== undefined && (
|
|
733
|
-
<span className="absolute right-1/2 top-1 min-w-[16px] translate-x-5 rounded-full bg-red-
|
|
827
|
+
<span className="absolute right-1/2 top-1 min-w-[16px] translate-x-5 rounded-full border border-red-900/80 bg-red-950/80 px-1 text-[10px] leading-4 text-red-200">
|
|
734
828
|
{t.badge}
|
|
735
829
|
</span>
|
|
736
830
|
)}
|
|
@@ -743,6 +837,60 @@ export default function ErdWorkspace() {
|
|
|
743
837
|
);
|
|
744
838
|
}
|
|
745
839
|
|
|
840
|
+
/**
|
|
841
|
+
* A notes box that saves by itself: shortly after the typing stops, and when
|
|
842
|
+
* focus leaves it. Each save is a whole-document write, so it is not done per
|
|
843
|
+
* keystroke. When the saved text changes underneath — an agent's set_notes,
|
|
844
|
+
* for instance — the box takes it, unless someone is typing in it: a save of
|
|
845
|
+
* their own words coming back must never pull the text out from under them.
|
|
846
|
+
*/
|
|
847
|
+
function NotesField({
|
|
848
|
+
label,
|
|
849
|
+
value,
|
|
850
|
+
placeholder,
|
|
851
|
+
onSave,
|
|
852
|
+
}: {
|
|
853
|
+
label: string;
|
|
854
|
+
value: string;
|
|
855
|
+
placeholder: string;
|
|
856
|
+
onSave: (notes: string) => void;
|
|
857
|
+
}) {
|
|
858
|
+
const [draft, setDraft] = useState(value);
|
|
859
|
+
const [seen, setSeen] = useState(value);
|
|
860
|
+
const [focused, setFocused] = useState(false);
|
|
861
|
+
if (value !== seen) {
|
|
862
|
+
setSeen(value);
|
|
863
|
+
if (!focused) setDraft(value);
|
|
864
|
+
}
|
|
865
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
866
|
+
const save = (text: string): void => {
|
|
867
|
+
if (timer.current) clearTimeout(timer.current);
|
|
868
|
+
timer.current = null;
|
|
869
|
+
if (text !== value) onSave(text);
|
|
870
|
+
};
|
|
871
|
+
return (
|
|
872
|
+
<label className="mt-2 block">
|
|
873
|
+
<span className="text-[11px] text-gray-500">{label}</span>
|
|
874
|
+
<textarea
|
|
875
|
+
className={`${input} h-24 resize-y leading-snug`}
|
|
876
|
+
placeholder={placeholder}
|
|
877
|
+
value={draft}
|
|
878
|
+
onChange={(e) => {
|
|
879
|
+
const text = e.target.value;
|
|
880
|
+
setDraft(text);
|
|
881
|
+
if (timer.current) clearTimeout(timer.current);
|
|
882
|
+
timer.current = setTimeout(() => save(text), 900);
|
|
883
|
+
}}
|
|
884
|
+
onFocus={() => setFocused(true)}
|
|
885
|
+
onBlur={() => {
|
|
886
|
+
setFocused(false);
|
|
887
|
+
save(draft);
|
|
888
|
+
}}
|
|
889
|
+
/>
|
|
890
|
+
</label>
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
|
|
746
894
|
interface Field {
|
|
747
895
|
name: string;
|
|
748
896
|
placeholder?: string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The phone sheet's grab handle, made to do what it looks like it does.
|
|
5
|
+
*
|
|
6
|
+
* The handle used to be decoration: it read as "this thing is a sheet" and
|
|
7
|
+
* answered no gesture, so a thumb pulling it down — the one thing a handle
|
|
8
|
+
* invites — got nothing. This hook gives the zone around it three answers:
|
|
9
|
+
*
|
|
10
|
+
* drag down the sheet follows the finger; past a threshold it closes
|
|
11
|
+
* (from the tall size, a short pull returns to the resting size)
|
|
12
|
+
* drag up the sheet grows with the finger; past a threshold it stays tall
|
|
13
|
+
* tap toggles between the resting and the tall size
|
|
14
|
+
*
|
|
15
|
+
* Height is handed to the sheet as the `--sheet-h` custom property rather than
|
|
16
|
+
* an inline `height`, so the desktop rule (`md:h-auto`) still wins where the
|
|
17
|
+
* sheet is a sidebar and none of this applies. A press that starts on a button
|
|
18
|
+
* inside the zone (the ✕) is left to the button.
|
|
19
|
+
*/
|
|
20
|
+
import { useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
|
|
21
|
+
|
|
22
|
+
const CLOSE_AFTER = 80;
|
|
23
|
+
const CLOSE_FROM_TALL_AFTER = 260;
|
|
24
|
+
const GROW_AFTER = 50;
|
|
25
|
+
const TAP_SLOP = 6;
|
|
26
|
+
|
|
27
|
+
export interface SheetDrag {
|
|
28
|
+
/** Spread on the element that holds the handle. */
|
|
29
|
+
zoneProps: {
|
|
30
|
+
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
|
|
31
|
+
onPointerMove: (e: ReactPointerEvent<HTMLElement>) => void;
|
|
32
|
+
onPointerUp: (e: ReactPointerEvent<HTMLElement>) => void;
|
|
33
|
+
onPointerCancel: (e: ReactPointerEvent<HTMLElement>) => void;
|
|
34
|
+
style: CSSProperties;
|
|
35
|
+
};
|
|
36
|
+
/** Spread on the sheet itself, which sizes itself with `h-[var(--sheet-h)]`. */
|
|
37
|
+
sheetStyle: CSSProperties;
|
|
38
|
+
/** Close the sheet and return it to its resting size for next time. */
|
|
39
|
+
close: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useSheetDrag(opts: { onClose: () => void; restVh: number; tallVh: number }): SheetDrag {
|
|
43
|
+
const { onClose, restVh, tallVh } = opts;
|
|
44
|
+
const [tall, setTall] = useState(false);
|
|
45
|
+
const [dy, setDy] = useState<number | null>(null);
|
|
46
|
+
const press = useRef<{ y: number; id: number } | null>(null);
|
|
47
|
+
|
|
48
|
+
const close = (): void => {
|
|
49
|
+
setTall(false);
|
|
50
|
+
onClose();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const release = (e: ReactPointerEvent<HTMLElement>): void => {
|
|
54
|
+
const p = press.current;
|
|
55
|
+
if (!p || p.id !== e.pointerId) return;
|
|
56
|
+
press.current = null;
|
|
57
|
+
const d = e.clientY - p.y;
|
|
58
|
+
setDy(null);
|
|
59
|
+
if (Math.abs(d) < TAP_SLOP) return setTall((t) => !t);
|
|
60
|
+
if (d <= -GROW_AFTER) return setTall(true);
|
|
61
|
+
if (d >= CLOSE_AFTER) {
|
|
62
|
+
if (tall && d < CLOSE_FROM_TALL_AFTER) return setTall(false);
|
|
63
|
+
close();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const vh = tall ? tallVh : restVh;
|
|
68
|
+
const dragging = dy !== null;
|
|
69
|
+
const sheetStyle: CSSProperties & Record<"--sheet-h", string> = {
|
|
70
|
+
"--sheet-h": dragging && dy < 0 ? `min(calc(${vh}dvh + ${-dy}px), ${tallVh}dvh)` : `${vh}dvh`,
|
|
71
|
+
...(dragging ? { transition: "none" } : {}),
|
|
72
|
+
...(dragging && dy > 0 ? { transform: `translateY(${dy}px)` } : {}),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
zoneProps: {
|
|
77
|
+
onPointerDown: (e) => {
|
|
78
|
+
if ((e.target as HTMLElement).closest("button")) return;
|
|
79
|
+
press.current = { y: e.clientY, id: e.pointerId };
|
|
80
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
81
|
+
setDy(0);
|
|
82
|
+
},
|
|
83
|
+
onPointerMove: (e) => {
|
|
84
|
+
const p = press.current;
|
|
85
|
+
if (p && p.id === e.pointerId) setDy(e.clientY - p.y);
|
|
86
|
+
},
|
|
87
|
+
onPointerUp: release,
|
|
88
|
+
onPointerCancel: release,
|
|
89
|
+
// The zone is a gesture surface: the browser must not scroll or pan with it.
|
|
90
|
+
style: { touchAction: "none", cursor: "grab" },
|
|
91
|
+
},
|
|
92
|
+
sheetStyle,
|
|
93
|
+
close,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -21,6 +21,8 @@ export interface SettingsModel {
|
|
|
21
21
|
namespace: string;
|
|
22
22
|
name?: string;
|
|
23
23
|
description?: string;
|
|
24
|
+
/** Working notes about the whole diagram — saved in the document, ignored by engines and codegen. */
|
|
25
|
+
notes?: string;
|
|
24
26
|
asynchronous: boolean;
|
|
25
27
|
/**
|
|
26
28
|
* Objects the generated context is constructed with. `ObjectRef` is what
|
|
@@ -97,6 +99,8 @@ export interface StateDef {
|
|
|
97
99
|
name: string;
|
|
98
100
|
kind?: StateKindType;
|
|
99
101
|
description?: string;
|
|
102
|
+
/** Working notes about this state: the conversation about it, where `description` says what it is. */
|
|
103
|
+
notes?: string;
|
|
100
104
|
onEntry?: { actions: ActionDef[] };
|
|
101
105
|
onExit?: { actions: ActionDef[] };
|
|
102
106
|
transitions?: TransitionDef[];
|