@glyphlab/node-panel-react 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Subtubes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,26 @@
1
- # Temporary Holding Version
1
+ # @glyphlab/node-panel-react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The node panel for a `@glyphlab/diagram-core` controller: the open node's Markdown content, its decision log, its code links and its Systems design answers, with editing in place, autosave, and a conflict prompt when the node changes under an edit. Peer dependencies: `react`, `zustand`, `@glyphlab/diagram-core`.
4
+
5
+ ```tsx
6
+ import { createDiagramController } from '@glyphlab/diagram-core'
7
+ import { DiagramCanvas, DiagramProvider, createDiagramInstance } from '@glyphlab/diagram-react'
8
+ import { NodePanel, createNodePanelStore } from '@glyphlab/node-panel-react'
9
+ import '@glyphlab/diagram-react/theme.css'
10
+ import '@glyphlab/diagram-react/diagram.css'
11
+ import '@glyphlab/node-panel-react/node-panel.css'
12
+
13
+ const controller = createDiagramController()
14
+ const instance = createDiagramInstance(controller)
15
+ const panel = createNodePanelStore(controller) // one per diagram
16
+
17
+ <DiagramProvider instance={instance} slots={{ renderMarkdown, openNodeContent: (id, tab) => panel.getState().open(id, tab) }}>
18
+ <DiagramCanvas title="Orders" />
19
+ </DiagramProvider>
20
+ <NodePanel controller={controller} store={panel} renderMarkdown={renderMarkdown} />
21
+ ```
22
+
23
+ - **Edits are changes to the diagram**: saving is one `controller.apply({ source: 'user' })`, so a host sees it in `controller.onChange` and can undo it.
24
+ - **Props**: `renderMarkdown(source)` draws Markdown (plain text without it). `code` supplies the Code tab (`reveal(link)`, and optionally `problem`, `onRefresh`, `checking` for a link check). `systems={false}` hides the Systems design tab. `height` and `resizer` let the host size the panel. `renderConfirm` replaces the discard-edits dialog. `legacyAnswers` moves answers a host kept elsewhere onto the node, once.
25
+ - **Opening a node**: `panel.getState().open(id, tab?)`, `close()`. The canvas's `openNodeContent` slot is the usual caller.
26
+ - Call `panel.dispose()` when the controller outlives the panel.
package/index.d.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { StoreApi } from 'zustand/vanilla';
2
+ import { DiagramController, NodeDef } from '@glyphlab/diagram-core';
3
+ import * as react from 'react';
4
+ import { ReactNode } from 'react';
5
+
6
+ interface Draft {
7
+ title: string;
8
+ body: string;
9
+ decisions: string;
10
+ baseTitle: string;
11
+ baseBody: string;
12
+ baseDecisions: string;
13
+ }
14
+ type Latest = {
15
+ title: string;
16
+ body: string;
17
+ decisions: string;
18
+ };
19
+ type NodePanelTab = 'content' | 'decisions' | 'systems' | 'code';
20
+ interface NodePanelState {
21
+ nodeId: string | null;
22
+ tab: NodePanelTab;
23
+ setTab: (tab: NodePanelTab) => void;
24
+ draft: Draft | null;
25
+ pending: {
26
+ nodeId: string | null;
27
+ } | null;
28
+ error: string;
29
+ conflict: Latest | null;
30
+ open: (id: string, tab?: NodePanelTab) => void;
31
+ close: () => void;
32
+ discardAndContinue: () => void;
33
+ keepEditing: () => void;
34
+ edit: () => void;
35
+ change: (patch: Partial<Pick<Draft, 'title' | 'body' | 'decisions'>>) => void;
36
+ cancel: () => void;
37
+ save: (options?: {
38
+ stayEditing?: boolean;
39
+ overwrite?: boolean;
40
+ }) => boolean;
41
+ takeLatest: () => void;
42
+ }
43
+ type NodePanelStore = StoreApi<NodePanelState> & {
44
+ dispose: () => void;
45
+ };
46
+ declare function createNodePanelStore(controller: DiagramController): NodePanelStore;
47
+ declare function bindNodePanelAutosave(store: NodePanelStore, delay?: number): () => void;
48
+
49
+ type NodeLink = NonNullable<NodeDef['links']>[number];
50
+ interface DiscardConfirm {
51
+ title: string;
52
+ body: string;
53
+ confirmLabel: string;
54
+ onConfirm: () => void;
55
+ onCancel: () => void;
56
+ }
57
+ interface NodePanelCode {
58
+ reveal: (link: NodeLink) => void;
59
+ problem?: (nodeId: string, index: number) => {
60
+ status: string;
61
+ message: string;
62
+ } | undefined;
63
+ onRefresh?: () => void;
64
+ checking?: boolean;
65
+ }
66
+ interface NodePanelProps {
67
+ controller: DiagramController;
68
+ store: NodePanelStore;
69
+ renderMarkdown?: (source: string) => ReactNode;
70
+ code?: NodePanelCode;
71
+ systems?: boolean;
72
+ legacyAnswers?: (nodeId: string) => Record<string, string> | null;
73
+ height?: number;
74
+ resizer?: ReactNode;
75
+ renderConfirm?: (confirm: DiscardConfirm) => ReactNode;
76
+ }
77
+ declare function NodePanel(props: NodePanelProps): react.JSX.Element | null;
78
+
79
+ interface ChecklistProps {
80
+ controller: DiagramController;
81
+ nodeId: string;
82
+ legacyAnswers?: (nodeId: string) => Record<string, string> | null;
83
+ }
84
+ declare function Checklist({ controller, nodeId, legacyAnswers }: ChecklistProps): react.JSX.Element;
85
+
86
+ export { Checklist, NodePanel, bindNodePanelAutosave, createNodePanelStore };
87
+ export type { ChecklistProps, DiscardConfirm, NodeLink, NodePanelCode, NodePanelProps, NodePanelState, NodePanelStore, NodePanelTab };
package/index.js ADDED
@@ -0,0 +1,391 @@
1
+ "use client";
2
+
3
+ // packages/node-panel-react/src/store.ts
4
+ import { createStore } from "zustand/vanilla";
5
+ var isDirty = (d) => d.title !== d.baseTitle || d.body !== d.baseBody || d.decisions !== d.baseDecisions;
6
+ var CLOSED = { nodeId: null, draft: null, pending: null, error: "", conflict: null };
7
+ var EDITED_ELSEWHERE = "This node was edited elsewhere. Autosave is paused. Select Current or Disk changed; no changes will be merged.";
8
+ function createNodePanelStore(controller) {
9
+ let applyingContent = false;
10
+ const nodeOf = (id) => id ? controller.stores.nodes.getState().byId[id] : void 0;
11
+ const store = createStore()((set, get) => {
12
+ const request = (nodeId) => {
13
+ const { draft } = get();
14
+ if (draft && isDirty(draft)) {
15
+ set({ pending: { nodeId } });
16
+ } else {
17
+ set({ nodeId, tab: "content", draft: null, pending: null, error: "", conflict: null });
18
+ }
19
+ };
20
+ return {
21
+ nodeId: null,
22
+ tab: "content",
23
+ setTab: (tab) => set({ tab }),
24
+ draft: null,
25
+ pending: null,
26
+ error: "",
27
+ conflict: null,
28
+ open: (id, tab) => {
29
+ if (get().nodeId === id) {
30
+ if (tab) set({ tab });
31
+ return;
32
+ }
33
+ if (!nodeOf(id)) {
34
+ set({ error: "That node no longer exists." });
35
+ return;
36
+ }
37
+ request(id);
38
+ if (tab && get().nodeId === id) set({ tab });
39
+ },
40
+ close: () => request(null),
41
+ discardAndContinue: () => {
42
+ const { pending } = get();
43
+ if (pending) set({ nodeId: pending.nodeId, tab: "content", draft: null, pending: null, error: "", conflict: null });
44
+ },
45
+ keepEditing: () => set({ pending: null }),
46
+ edit: () => {
47
+ const node = nodeOf(get().nodeId);
48
+ if (!node) {
49
+ set({ error: "That node no longer exists." });
50
+ return;
51
+ }
52
+ const decisions = node.decisions ?? "";
53
+ set({ draft: { title: node.title, body: node.body, decisions, baseTitle: node.title, baseBody: node.body, baseDecisions: decisions }, error: "", conflict: null });
54
+ },
55
+ change: (patch) => set((s) => s.draft ? { draft: { ...s.draft, ...patch }, error: "" } : s),
56
+ cancel: () => set({ draft: null, error: "", conflict: null }),
57
+ takeLatest: () => get().edit(),
58
+ save: ({ stayEditing = false, overwrite = false } = {}) => {
59
+ const { nodeId, draft, conflict } = get();
60
+ if (!nodeId || !draft) {
61
+ set({ error: "Open a node in edit mode before saving." });
62
+ return false;
63
+ }
64
+ if (conflict && !overwrite) return false;
65
+ const node = nodeOf(nodeId);
66
+ if (!node) {
67
+ set({ error: "That node no longer exists." });
68
+ return false;
69
+ }
70
+ const title = draft.title.trim();
71
+ if (!title) {
72
+ set({ error: "A node title cannot be empty." });
73
+ return false;
74
+ }
75
+ const expectedTitle = overwrite && conflict ? conflict.title : draft.baseTitle;
76
+ const expectedBody = overwrite && conflict ? conflict.body : draft.baseBody;
77
+ const expectedDecisions = overwrite && conflict ? conflict.decisions : draft.baseDecisions;
78
+ if (node.title !== expectedTitle || node.body !== expectedBody || (node.decisions ?? "") !== expectedDecisions) {
79
+ set({ conflict: { title: node.title, body: node.body, decisions: node.decisions ?? "" }, error: EDITED_ELSEWHERE });
80
+ return false;
81
+ }
82
+ const patch = {};
83
+ if (title !== node.title) patch.title = title;
84
+ if (draft.body !== node.body) patch.body = draft.body;
85
+ if (draft.decisions !== (node.decisions ?? "")) patch.decisions = draft.decisions;
86
+ if (Object.keys(patch).length > 0) {
87
+ applyingContent = true;
88
+ let result;
89
+ try {
90
+ result = controller.apply({ ops: [{ op: "updateNode", id: nodeId, patch }], summary: `Edit node "${node.title}"`, source: "user" });
91
+ } finally {
92
+ applyingContent = false;
93
+ }
94
+ if (!result.ok) {
95
+ set({ error: result.message });
96
+ return false;
97
+ }
98
+ }
99
+ set({ draft: stayEditing ? { title, body: draft.body, decisions: draft.decisions, baseTitle: title, baseBody: draft.body, baseDecisions: draft.decisions } : null, error: "", conflict: null });
100
+ return true;
101
+ }
102
+ };
103
+ });
104
+ const offLoad = controller.onLoad(() => store.setState({ ...CLOSED }));
105
+ const offChange = controller.onChange(() => {
106
+ if (applyingContent) return;
107
+ const { nodeId, draft, conflict } = store.getState();
108
+ const node = nodeOf(nodeId);
109
+ if (nodeId && !node) {
110
+ store.setState({ ...CLOSED });
111
+ } else if (node && draft && !conflict && (node.title !== draft.baseTitle || node.body !== draft.baseBody || (node.decisions ?? "") !== draft.baseDecisions)) {
112
+ if (!isDirty(draft)) {
113
+ store.getState().edit();
114
+ } else {
115
+ store.setState({ conflict: { title: node.title, body: node.body, decisions: node.decisions ?? "" }, error: EDITED_ELSEWHERE });
116
+ }
117
+ }
118
+ });
119
+ return Object.assign(store, { dispose: () => {
120
+ offLoad();
121
+ offChange();
122
+ } });
123
+ }
124
+ function bindNodePanelAutosave(store, delay = 750) {
125
+ let timer;
126
+ const sync = () => {
127
+ clearTimeout(timer);
128
+ const { draft, pending, error, conflict } = store.getState();
129
+ if (!draft || pending || error || conflict || !isDirty(draft)) return;
130
+ timer = setTimeout(() => store.getState().save({ stayEditing: true }), delay);
131
+ };
132
+ const unsubscribe = store.subscribe(sync);
133
+ sync();
134
+ return () => {
135
+ clearTimeout(timer);
136
+ unsubscribe();
137
+ };
138
+ }
139
+
140
+ // packages/node-panel-react/src/NodePanel.tsx
141
+ import { useEffect as useEffect2, useRef as useRef2 } from "react";
142
+ import { useStore as useStore2 } from "zustand";
143
+ import { LIMITS } from "@glyphlab/diagram-core";
144
+
145
+ // packages/node-panel-react/src/Checklist.tsx
146
+ import { useEffect, useRef, useState } from "react";
147
+ import { useStore } from "zustand";
148
+ import { CHECKLIST_ANSWER_MAX, CHECKLIST_TOTAL, DESIGN_CHECKLIST } from "@glyphlab/diagram-core";
149
+ import { jsx, jsxs } from "react/jsx-runtime";
150
+ var columns = (() => {
151
+ const left = [];
152
+ let count = 0;
153
+ for (const group of DESIGN_CHECKLIST) {
154
+ if (count < CHECKLIST_TOTAL / 2 - 1) {
155
+ left.push(group);
156
+ count += group.items.length;
157
+ }
158
+ }
159
+ return [left, DESIGN_CHECKLIST.filter((g) => !left.includes(g))];
160
+ })();
161
+ var save = (controller, nodeId, title, answers) => controller.apply({ ops: [{ op: "updateNode", id: nodeId, patch: { systemsDesign: answers } }], summary: `Edit Systems design of "${title}"`, source: "user" });
162
+ function Checklist({ controller, nodeId, legacyAnswers }) {
163
+ const node = useStore(controller.stores.nodes, (s) => s.byId[nodeId]);
164
+ const values = node?.systemsDesign;
165
+ const done = Object.values(values ?? {}).filter((v) => v.trim()).length;
166
+ useEffect(() => {
167
+ const current = controller.stores.nodes.getState().byId[nodeId];
168
+ if (!current || current.systemsDesign || !legacyAnswers) return;
169
+ const legacy = legacyAnswers(nodeId);
170
+ if (legacy) save(controller, nodeId, current.title, legacy);
171
+ }, [nodeId]);
172
+ return /* @__PURE__ */ jsxs("aside", { className: "node-checklist", "aria-label": "Systems design checklist", children: [
173
+ /* @__PURE__ */ jsxs("h3", { children: [
174
+ "Systems design ",
175
+ /* @__PURE__ */ jsxs("span", { role: "status", children: [
176
+ done,
177
+ "/",
178
+ CHECKLIST_TOTAL,
179
+ " filled"
180
+ ] })
181
+ ] }),
182
+ /* @__PURE__ */ jsx("div", { className: "node-checklist-columns", children: columns.map((groups, column) => /* @__PURE__ */ jsx("div", { className: "node-checklist-column", children: groups.map((group) => /* @__PURE__ */ jsxs("fieldset", { children: [
183
+ /* @__PURE__ */ jsx("legend", { children: group.title }),
184
+ group.items.map((item) => /* @__PURE__ */ jsxs("label", { children: [
185
+ /* @__PURE__ */ jsx("strong", { children: item.label }),
186
+ /* @__PURE__ */ jsx("small", { children: item.question }),
187
+ /* @__PURE__ */ jsx(Answer, { controller, nodeId, itemId: item.id, saved: values?.[item.id] ?? "" })
188
+ ] }, item.id))
189
+ ] }, group.title)) }, column)) })
190
+ ] });
191
+ }
192
+ function Answer({ controller, nodeId, itemId, saved }) {
193
+ const [text, setText] = useState(saved);
194
+ const typing = useRef(false);
195
+ const timer = useRef(void 0);
196
+ const latest = useRef(text);
197
+ useEffect(() => {
198
+ if (!typing.current) {
199
+ setText(saved);
200
+ latest.current = saved;
201
+ }
202
+ }, [saved]);
203
+ const commit = () => {
204
+ clearTimeout(timer.current);
205
+ typing.current = false;
206
+ const node = controller.stores.nodes.getState().byId[nodeId];
207
+ if (!node || latest.current === (node.systemsDesign?.[itemId] ?? "")) return;
208
+ save(controller, nodeId, node.title, { [itemId]: latest.current });
209
+ };
210
+ useEffect(() => () => {
211
+ if (typing.current) commit();
212
+ }, []);
213
+ return /* @__PURE__ */ jsx(
214
+ "input",
215
+ {
216
+ type: "text",
217
+ value: text,
218
+ maxLength: CHECKLIST_ANSWER_MAX,
219
+ onChange: (e) => {
220
+ typing.current = true;
221
+ latest.current = e.target.value;
222
+ setText(e.target.value);
223
+ clearTimeout(timer.current);
224
+ timer.current = setTimeout(commit, 600);
225
+ },
226
+ onBlur: commit
227
+ }
228
+ );
229
+ }
230
+
231
+ // packages/node-panel-react/src/NodePanel.tsx
232
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
233
+ var PencilIcon = () => /* @__PURE__ */ jsx2("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" }) });
234
+ var CloseIcon = () => /* @__PURE__ */ jsx2("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M6 6l12 12M18 6 6 18" }) });
235
+ var plain = (source) => /* @__PURE__ */ jsx2("p", { className: "node-panel-plain", children: source });
236
+ var DEFAULT_CONFIRM = (c) => /* @__PURE__ */ jsx2("div", { className: "node-panel-dialog", role: "alertdialog", "aria-modal": "true", "aria-label": c.title, children: /* @__PURE__ */ jsxs2("div", { className: "node-panel-dialog-box", children: [
237
+ /* @__PURE__ */ jsx2("h3", { children: c.title }),
238
+ /* @__PURE__ */ jsx2("p", { children: c.body }),
239
+ /* @__PURE__ */ jsxs2("div", { children: [
240
+ /* @__PURE__ */ jsx2("button", { className: "node-panel-btn", type: "button", onClick: c.onCancel, children: "Keep editing" }),
241
+ /* @__PURE__ */ jsx2("button", { className: "node-panel-btn primary", type: "button", onClick: c.onConfirm, children: c.confirmLabel })
242
+ ] })
243
+ ] }) });
244
+ function NodePanel(props) {
245
+ const { controller, store } = props;
246
+ useEffect2(() => bindNodePanelAutosave(store), [store]);
247
+ const nodeId = useStore2(store, (s) => s.nodeId);
248
+ const node = useStore2(controller.stores.nodes, (s) => nodeId ? s.byId[nodeId] : void 0);
249
+ if (!node) return null;
250
+ return /* @__PURE__ */ jsx2(Content, { ...props, node }, node.id);
251
+ }
252
+ function Content({ controller, store, node, renderMarkdown = plain, code, systems = true, legacyAnswers, height, resizer, renderConfirm = DEFAULT_CONFIRM }) {
253
+ const { draft, pending, error, conflict, tab, close, discardAndContinue, keepEditing, edit, change, cancel, save: save2, takeLatest, setTab } = useStore2(store);
254
+ const nodeId = node.id;
255
+ const decisions = node.decisions ?? "";
256
+ const links = node.links ?? [];
257
+ const ref = useRef2(null);
258
+ const titleRef = useRef2(null);
259
+ const editing = draft !== null;
260
+ const dirty = draft !== null && (draft.title !== draft.baseTitle || draft.body !== draft.baseBody || draft.decisions !== draft.baseDecisions);
261
+ const shown = tab === "systems" && !systems ? "content" : tab;
262
+ useEffect2(() => {
263
+ ref.current?.focus();
264
+ return () => {
265
+ document.querySelector(`[data-node-id="${CSS.escape(nodeId)}"] .node-expand`)?.focus({ preventScroll: true });
266
+ };
267
+ }, [nodeId]);
268
+ useEffect2(() => {
269
+ if (editing) titleRef.current?.focus();
270
+ }, [editing]);
271
+ useEffect2(() => {
272
+ if (!dirty) return;
273
+ const warn = (e) => e.preventDefault();
274
+ window.addEventListener("beforeunload", warn);
275
+ return () => window.removeEventListener("beforeunload", warn);
276
+ }, [dirty]);
277
+ const tabButton = (id, label) => /* @__PURE__ */ jsx2("button", { type: "button", role: "tab", id: `node-tab-${id}`, "aria-selected": shown === id, "aria-controls": "node-panel", onClick: () => setTab(id), children: label });
278
+ return /* @__PURE__ */ jsxs2(
279
+ "section",
280
+ {
281
+ ref,
282
+ className: "node-content-drawer",
283
+ "aria-label": "Node content",
284
+ tabIndex: -1,
285
+ style: height === void 0 ? void 0 : { height },
286
+ onKeyDown: (e) => {
287
+ if (e.key === "Escape") {
288
+ e.stopPropagation();
289
+ close();
290
+ }
291
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && draft) {
292
+ e.preventDefault();
293
+ save2();
294
+ }
295
+ },
296
+ children: [
297
+ resizer,
298
+ /* @__PURE__ */ jsxs2("header", { className: "node-content-head", children: [
299
+ /* @__PURE__ */ jsx2("h2", { children: node.title }),
300
+ /* @__PURE__ */ jsx2("span", { role: "status", children: draft ? conflict ? "Autosave paused" : error ? "Unsaved" : dirty ? "Autosave pending" : "Saved" : "Node content" }),
301
+ draft ? /* @__PURE__ */ jsxs2(Fragment, { children: [
302
+ /* @__PURE__ */ jsx2("button", { className: "node-panel-btn", type: "button", onClick: cancel, disabled: !!conflict, children: "Cancel editing" }),
303
+ /* @__PURE__ */ jsx2("button", { className: "node-panel-btn primary", type: "submit", form: "node-content-form", disabled: !!conflict, children: "Save node" })
304
+ ] }) : /* @__PURE__ */ jsxs2("button", { className: "node-panel-btn", type: "button", onClick: edit, children: [
305
+ /* @__PURE__ */ jsx2(PencilIcon, {}),
306
+ " Edit content"
307
+ ] }),
308
+ /* @__PURE__ */ jsx2("button", { className: "node-panel-icon-btn", type: "button", "aria-label": "Close node content", onClick: close, children: /* @__PURE__ */ jsx2(CloseIcon, {}) })
309
+ ] }),
310
+ error && /* @__PURE__ */ jsx2("p", { className: "node-content-error", role: "alert", children: error }),
311
+ conflict && /* @__PURE__ */ jsxs2("div", { className: "node-content-error", role: "group", "aria-label": "Resolve node conflict", children: [
312
+ /* @__PURE__ */ jsx2("span", { children: "The saved design content changed. Current keeps your draft; Disk changed loads the latest saved content." }),
313
+ /* @__PURE__ */ jsx2("button", { className: "node-panel-btn", type: "button", onClick: () => save2({ overwrite: true, stayEditing: true }), children: "Current" }),
314
+ /* @__PURE__ */ jsx2("button", { className: "node-panel-btn", type: "button", onClick: takeLatest, children: "Disk changed" })
315
+ ] }),
316
+ /* @__PURE__ */ jsxs2("div", { className: "node-content-tabs", role: "tablist", "aria-label": "Node content sections", children: [
317
+ tabButton("content", "Content"),
318
+ tabButton("decisions", /* @__PURE__ */ jsxs2(Fragment, { children: [
319
+ "Decisions",
320
+ (draft ? draft.decisions : decisions).trim() && /* @__PURE__ */ jsx2("span", { className: "node-tab-dot", role: "img", "aria-label": "has a decision log" })
321
+ ] })),
322
+ tabButton("code", /* @__PURE__ */ jsxs2(Fragment, { children: [
323
+ "Code",
324
+ links.length > 0 && /* @__PURE__ */ jsx2("span", { className: "node-tab-dot", role: "img", "aria-label": `${links.length} code links` })
325
+ ] })),
326
+ systems && tabButton("systems", "Systems design")
327
+ ] }),
328
+ /* @__PURE__ */ jsx2("div", { className: "node-content-main", id: "node-panel", role: "tabpanel", "aria-labelledby": `node-tab-${shown}`, children: shown === "systems" ? /* @__PURE__ */ jsx2(Checklist, { controller, nodeId, legacyAnswers }) : shown === "code" ? /* @__PURE__ */ jsx2("div", { className: "node-content-pane", children: /* @__PURE__ */ jsx2("div", { className: "node-content-view", role: "region", "aria-label": "Code links", children: links.length === 0 ? /* @__PURE__ */ jsx2("p", { className: "node-content-empty", children: "No code linked. Ask the assistant to link the files and lines this node describes." }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
329
+ code?.onRefresh && /* @__PURE__ */ jsxs2("p", { className: "node-code-refresh", children: [
330
+ /* @__PURE__ */ jsx2("button", { type: "button", className: "node-panel-btn", disabled: code.checking, onClick: code.onRefresh, children: code.checking ? "Checking\u2026" : "Refresh" }),
331
+ /* @__PURE__ */ jsx2("small", { children: "Compares each link with the code now. It only reports; it changes no link." })
332
+ ] }),
333
+ /* @__PURE__ */ jsx2("ul", { className: "node-code-links", children: links.map((link, i) => {
334
+ const problem = code?.problem?.(nodeId, i);
335
+ return /* @__PURE__ */ jsxs2("li", { children: [
336
+ /* @__PURE__ */ jsxs2("button", { type: "button", className: "node-panel-btn", onClick: () => code?.reveal(link), children: [
337
+ link.repo ? `${link.repo}:` : "",
338
+ link.path,
339
+ link.lines ? `:${link.lines[0] === link.lines[1] ? link.lines[0] : `${link.lines[0]}-${link.lines[1]}`}` : ""
340
+ ] }),
341
+ link.symbol && /* @__PURE__ */ jsx2("code", { children: link.symbol }),
342
+ link.hash && /* @__PURE__ */ jsxs2("small", { children: [
343
+ "hash ",
344
+ link.hash
345
+ ] }),
346
+ problem && /* @__PURE__ */ jsx2("small", { className: `node-code-status link-${problem.status}`, role: "status", children: problem.message })
347
+ ] }, `${link.path}:${i}`);
348
+ }) })
349
+ ] }) }) }) : shown === "decisions" ? /* @__PURE__ */ jsx2("div", { className: "node-content-pane", children: draft ? /* @__PURE__ */ jsx2("form", { id: "node-content-form", className: "node-content-edit", onSubmit: (e) => {
350
+ e.preventDefault();
351
+ save2();
352
+ }, children: /* @__PURE__ */ jsx2("div", { className: "node-content-fields", children: /* @__PURE__ */ jsxs2("label", { className: "node-content-source", children: [
353
+ "Decisions (Markdown)",
354
+ /* @__PURE__ */ jsx2("textarea", { "aria-label": "Decision log (Markdown)", value: draft.decisions, maxLength: LIMITS.body, spellCheck: false, placeholder: "Why this choice? For example: Lambda over Fargate, ECS or Kubernetes.\n\n## Considered\n- Fargate: ...\n- ECS: ...", onChange: (e) => change({ decisions: e.target.value }) })
355
+ ] }) }) }) : /* @__PURE__ */ jsxs2("div", { className: "node-content-view", role: "region", "aria-label": "Decision log", children: [
356
+ renderMarkdown(decisions),
357
+ !decisions && /* @__PURE__ */ jsx2("p", { className: "node-content-empty", children: "No decisions recorded. This is optional: choose Edit content to note why this was chosen and what else was considered." })
358
+ ] }) }) : /* @__PURE__ */ jsx2("div", { className: "node-content-pane", children: draft ? /* @__PURE__ */ jsx2("form", { id: "node-content-form", className: "node-content-edit", onSubmit: (e) => {
359
+ e.preventDefault();
360
+ save2();
361
+ }, children: /* @__PURE__ */ jsxs2("div", { className: "node-content-fields", children: [
362
+ /* @__PURE__ */ jsxs2("label", { children: [
363
+ "Title",
364
+ /* @__PURE__ */ jsx2("input", { ref: titleRef, "aria-label": "Full node title", value: draft.title, maxLength: LIMITS.title, required: true, onChange: (e) => change({ title: e.target.value }) })
365
+ ] }),
366
+ /* @__PURE__ */ jsxs2("label", { className: "node-content-source", children: [
367
+ "Markdown",
368
+ /* @__PURE__ */ jsx2("textarea", { "aria-label": "Full node content (Markdown)", value: draft.body, maxLength: LIMITS.body, spellCheck: false, onChange: (e) => change({ body: e.target.value }) })
369
+ ] })
370
+ ] }) }) : /* @__PURE__ */ jsxs2("div", { className: "node-content-view", role: "region", "aria-label": "Full Markdown content", children: [
371
+ renderMarkdown(node.body),
372
+ !node.body && /* @__PURE__ */ jsx2("p", { className: "node-content-empty", children: "This node has no content yet. Choose Edit content to add Markdown." })
373
+ ] }) }) }),
374
+ pending && renderConfirm({
375
+ title: "Discard node edits?",
376
+ body: "Your unsaved title, Markdown and decision changes will be discarded.",
377
+ confirmLabel: "Discard edits",
378
+ onCancel: keepEditing,
379
+ onConfirm: discardAndContinue
380
+ })
381
+ ]
382
+ }
383
+ );
384
+ }
385
+ export {
386
+ Checklist,
387
+ NodePanel,
388
+ bindNodePanelAutosave,
389
+ createNodePanelStore
390
+ };
391
+ //# sourceMappingURL=index.js.map
package/index.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/store.ts","../src/NodePanel.tsx","../src/Checklist.tsx"],"sourcesContent":["import { createStore } from 'zustand/vanilla'\nimport type { StoreApi } from 'zustand/vanilla'\nimport type { DiagramController } from '@glyphlab/diagram-core'\n\ninterface Draft {\n title: string\n body: string\n decisions: string\n baseTitle: string\n baseBody: string\n baseDecisions: string\n}\n\nconst isDirty = (d: Draft) => d.title !== d.baseTitle || d.body !== d.baseBody || d.decisions !== d.baseDecisions\n\ntype Latest = { title: string; body: string; decisions: string }\n\nexport type NodePanelTab = 'content' | 'decisions' | 'systems' | 'code'\n\nexport interface NodePanelState {\n nodeId: string | null\n // The section of the panel that shows.\n tab: NodePanelTab\n setTab: (tab: NodePanelTab) => void\n draft: Draft | null\n pending: { nodeId: string | null } | null\n error: string\n conflict: Latest | null\n open: (id: string, tab?: NodePanelTab) => void\n close: () => void\n discardAndContinue: () => void\n keepEditing: () => void\n edit: () => void\n change: (patch: Partial<Pick<Draft, 'title' | 'body' | 'decisions'>>) => void\n cancel: () => void\n save: (options?: { stayEditing?: boolean; overwrite?: boolean }) => boolean\n takeLatest: () => void\n}\n\nexport type NodePanelStore = StoreApi<NodePanelState> & { dispose: () => void }\n\nconst CLOSED = { nodeId: null, draft: null, pending: null, error: '', conflict: null } as const\nconst EDITED_ELSEWHERE = 'This node was edited elsewhere. Autosave is paused. Select Current or Disk changed; no changes will be merged.'\n\n// The state of the node panel for one controller: which node is open, the draft being edited, and what happens when the node changes\n// under it. One per diagram, like a DiagramInstance. A change the person makes is one `controller.apply({ source: 'user' })`.\nexport function createNodePanelStore(controller: DiagramController): NodePanelStore {\n let applyingContent = false\n const nodeOf = (id: string | null) => (id ? controller.stores.nodes.getState().byId[id] : undefined)\n\n const store = createStore<NodePanelState>()((set, get) => {\n const request = (nodeId: string | null) => {\n const { draft } = get()\n if (draft && isDirty(draft)) {\n set({ pending: { nodeId } })\n } else {\n set({ nodeId, tab: 'content', draft: null, pending: null, error: '', conflict: null })\n }\n }\n return {\n nodeId: null,\n tab: 'content',\n setTab: (tab) => set({ tab }),\n draft: null,\n pending: null,\n error: '',\n conflict: null,\n open: (id, tab) => {\n if (get().nodeId === id) {\n if (tab) set({ tab })\n return\n }\n if (!nodeOf(id)) {\n set({ error: 'That node no longer exists.' })\n return\n }\n request(id)\n if (tab && get().nodeId === id) set({ tab })\n },\n close: () => request(null),\n discardAndContinue: () => {\n const { pending } = get()\n if (pending) set({ nodeId: pending.nodeId, tab: 'content', draft: null, pending: null, error: '', conflict: null })\n },\n keepEditing: () => set({ pending: null }),\n edit: () => {\n const node = nodeOf(get().nodeId)\n if (!node) {\n set({ error: 'That node no longer exists.' })\n return\n }\n const decisions = node.decisions ?? ''\n set({ draft: { title: node.title, body: node.body, decisions, baseTitle: node.title, baseBody: node.body, baseDecisions: decisions }, error: '', conflict: null })\n },\n change: (patch) => set((s) => s.draft ? { draft: { ...s.draft, ...patch }, error: '' } : s),\n cancel: () => set({ draft: null, error: '', conflict: null }),\n takeLatest: () => get().edit(),\n save: ({ stayEditing = false, overwrite = false } = {}) => {\n const { nodeId, draft, conflict } = get()\n if (!nodeId || !draft) {\n set({ error: 'Open a node in edit mode before saving.' })\n return false\n }\n if (conflict && !overwrite) return false\n const node = nodeOf(nodeId)\n if (!node) {\n set({ error: 'That node no longer exists.' })\n return false\n }\n const title = draft.title.trim()\n if (!title) {\n set({ error: 'A node title cannot be empty.' })\n return false\n }\n const expectedTitle = overwrite && conflict ? conflict.title : draft.baseTitle\n const expectedBody = overwrite && conflict ? conflict.body : draft.baseBody\n const expectedDecisions = overwrite && conflict ? conflict.decisions : draft.baseDecisions\n if (node.title !== expectedTitle || node.body !== expectedBody || (node.decisions ?? '') !== expectedDecisions) {\n set({ conflict: { title: node.title, body: node.body, decisions: node.decisions ?? '' }, error: EDITED_ELSEWHERE })\n return false\n }\n const patch: { title?: string; body?: string; decisions?: string } = {}\n if (title !== node.title) patch.title = title\n if (draft.body !== node.body) patch.body = draft.body\n if (draft.decisions !== (node.decisions ?? '')) patch.decisions = draft.decisions\n if (Object.keys(patch).length > 0) {\n applyingContent = true\n let result: ReturnType<typeof controller.apply>\n try {\n result = controller.apply({ ops: [{ op: 'updateNode', id: nodeId, patch }], summary: `Edit node \"${node.title}\"`, source: 'user' })\n } finally {\n applyingContent = false\n }\n if (!result.ok) {\n set({ error: result.message })\n return false\n }\n }\n set({ draft: stayEditing ? { title, body: draft.body, decisions: draft.decisions, baseTitle: title, baseBody: draft.body, baseDecisions: draft.decisions } : null, error: '', conflict: null })\n return true\n },\n }\n })\n\n const offLoad = controller.onLoad(() => store.setState({ ...CLOSED }))\n const offChange = controller.onChange(() => {\n if (applyingContent) return\n const { nodeId, draft, conflict } = store.getState()\n const node = nodeOf(nodeId)\n if (nodeId && !node) {\n store.setState({ ...CLOSED })\n } else if (node && draft && !conflict && (node.title !== draft.baseTitle || node.body !== draft.baseBody || (node.decisions ?? '') !== draft.baseDecisions)) {\n if (!isDirty(draft)) {\n store.getState().edit()\n } else {\n store.setState({ conflict: { title: node.title, body: node.body, decisions: node.decisions ?? '' }, error: EDITED_ELSEWHERE })\n }\n }\n })\n return Object.assign(store, { dispose: () => { offLoad(); offChange() } })\n}\n\n// Saves the draft a moment after the last change, unless something needs the person's attention first. Returns the function that stops it.\nexport function bindNodePanelAutosave(store: NodePanelStore, delay = 750) {\n let timer: ReturnType<typeof setTimeout> | undefined\n const sync = () => {\n clearTimeout(timer)\n const { draft, pending, error, conflict } = store.getState()\n if (!draft || pending || error || conflict || !isDirty(draft)) return\n timer = setTimeout(() => store.getState().save({ stayEditing: true }), delay)\n }\n const unsubscribe = store.subscribe(sync)\n sync()\n return () => { clearTimeout(timer); unsubscribe() }\n}\n","import { useEffect, useRef } from 'react'\nimport type { ReactNode } from 'react'\nimport { useStore } from 'zustand'\nimport { LIMITS } from '@glyphlab/diagram-core'\nimport type { DiagramController, NodeDef } from '@glyphlab/diagram-core'\nimport { Checklist } from './Checklist'\nimport { bindNodePanelAutosave } from './store'\nimport type { NodePanelStore } from './store'\n\nexport type NodeLink = NonNullable<NodeDef['links']>[number]\n\nexport interface DiscardConfirm {\n title: string\n body: string\n confirmLabel: string\n onConfirm: () => void\n onCancel: () => void\n}\n\nexport interface NodePanelCode {\n // Shows the code a link points at (opens a file, a viewer, a URL: the host decides).\n reveal: (link: NodeLink) => void\n // What a check of the links found about one of them, when the host runs such a check.\n problem?: (nodeId: string, index: number) => { status: string; message: string } | undefined\n // Shows a refresh button for the check, and calls this when it is pressed.\n onRefresh?: () => void\n checking?: boolean\n}\n\nexport interface NodePanelProps {\n controller: DiagramController\n store: NodePanelStore\n // Draws Markdown; plain text without it.\n renderMarkdown?: (source: string) => ReactNode\n // The Code tab. Without it the tab lists the links and does nothing when one is pressed.\n code?: NodePanelCode\n // Whether the Systems design tab is there. On unless false.\n systems?: boolean\n // See Checklist.\n legacyAnswers?: (nodeId: string) => Record<string, string> | null\n // How tall the panel is, and the handle that resizes it (the panel does not know about layout).\n height?: number\n resizer?: ReactNode\n // The question asked before edits are thrown away; a plain dialog without it.\n renderConfirm?: (confirm: DiscardConfirm) => ReactNode\n}\n\nconst PencilIcon = () => (\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z\" />\n </svg>\n)\nconst CloseIcon = () => (\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" aria-hidden=\"true\">\n <path d=\"M6 6l12 12M18 6 6 18\" />\n </svg>\n)\n\nconst plain = (source: string) => <p className=\"node-panel-plain\">{source}</p>\n\nconst DEFAULT_CONFIRM = (c: DiscardConfirm) => (\n <div className=\"node-panel-dialog\" role=\"alertdialog\" aria-modal=\"true\" aria-label={c.title}>\n <div className=\"node-panel-dialog-box\">\n <h3>{c.title}</h3>\n <p>{c.body}</p>\n <div>\n <button className=\"node-panel-btn\" type=\"button\" onClick={c.onCancel}>Keep editing</button>\n <button className=\"node-panel-btn primary\" type=\"button\" onClick={c.onConfirm}>{c.confirmLabel}</button>\n </div>\n </div>\n </div>\n)\n\n// The node panel: the open node's content, decision log, code links and Systems design answers, with editing, autosave and conflict handling.\n// Open a node with `store.getState().open(id, tab?)` (the canvas's `openNodeContent` slot is the usual caller); it shows nothing when none is open.\nexport function NodePanel(props: NodePanelProps) {\n const { controller, store } = props\n useEffect(() => bindNodePanelAutosave(store), [store])\n const nodeId = useStore(store, (s) => s.nodeId)\n const node = useStore(controller.stores.nodes, (s) => (nodeId ? s.byId[nodeId] : undefined))\n if (!node) return null\n return <Content key={node.id} {...props} node={node} />\n}\n\nfunction Content({ controller, store, node, renderMarkdown = plain, code, systems = true, legacyAnswers, height, resizer, renderConfirm = DEFAULT_CONFIRM }: NodePanelProps & { node: NodeDef }) {\n const { draft, pending, error, conflict, tab, close, discardAndContinue, keepEditing, edit, change, cancel, save, takeLatest, setTab } = useStore(store)\n const nodeId = node.id\n const decisions = node.decisions ?? ''\n const links = node.links ?? []\n const ref = useRef<HTMLElement>(null)\n const titleRef = useRef<HTMLInputElement>(null)\n const editing = draft !== null\n const dirty = draft !== null && (draft.title !== draft.baseTitle || draft.body !== draft.baseBody || draft.decisions !== draft.baseDecisions)\n const shown = tab === 'systems' && !systems ? 'content' : tab\n useEffect(() => {\n ref.current?.focus()\n return () => {\n document.querySelector<HTMLButtonElement>(`[data-node-id=\"${CSS.escape(nodeId)}\"] .node-expand`)?.focus({ preventScroll: true })\n }\n }, [nodeId])\n useEffect(() => {\n if (editing) titleRef.current?.focus()\n }, [editing])\n useEffect(() => {\n if (!dirty) return\n const warn = (e: BeforeUnloadEvent) => e.preventDefault()\n window.addEventListener('beforeunload', warn)\n return () => window.removeEventListener('beforeunload', warn)\n }, [dirty])\n const tabButton = (id: typeof tab, label: ReactNode) => (\n <button type=\"button\" role=\"tab\" id={`node-tab-${id}`} aria-selected={shown === id} aria-controls=\"node-panel\" onClick={() => setTab(id)}>{label}</button>\n )\n return (\n <section ref={ref} className=\"node-content-drawer\" aria-label=\"Node content\" tabIndex={-1} style={height === undefined ? undefined : { height }}\n onKeyDown={(e) => {\n if (e.key === 'Escape') { e.stopPropagation(); close() }\n if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && draft) { e.preventDefault(); save() }\n }}>\n {resizer}\n <header className=\"node-content-head\">\n <h2>{node.title}</h2>\n <span role=\"status\">{draft ? conflict ? 'Autosave paused' : error ? 'Unsaved' : dirty ? 'Autosave pending' : 'Saved' : 'Node content'}</span>\n {draft ? <>\n <button className=\"node-panel-btn\" type=\"button\" onClick={cancel} disabled={!!conflict}>Cancel editing</button>\n <button className=\"node-panel-btn primary\" type=\"submit\" form=\"node-content-form\" disabled={!!conflict}>Save node</button>\n </> : <button className=\"node-panel-btn\" type=\"button\" onClick={edit}><PencilIcon /> Edit content</button>}\n <button className=\"node-panel-icon-btn\" type=\"button\" aria-label=\"Close node content\" onClick={close}><CloseIcon /></button>\n </header>\n {error && <p className=\"node-content-error\" role=\"alert\">{error}</p>}\n {conflict && <div className=\"node-content-error\" role=\"group\" aria-label=\"Resolve node conflict\">\n <span>The saved design content changed. Current keeps your draft; Disk changed loads the latest saved content.</span>\n <button className=\"node-panel-btn\" type=\"button\" onClick={() => save({ overwrite: true, stayEditing: true })}>Current</button>\n <button className=\"node-panel-btn\" type=\"button\" onClick={takeLatest}>Disk changed</button>\n </div>}\n <div className=\"node-content-tabs\" role=\"tablist\" aria-label=\"Node content sections\">\n {tabButton('content', 'Content')}\n {tabButton('decisions', <>Decisions{(draft ? draft.decisions : decisions).trim() && <span className=\"node-tab-dot\" role=\"img\" aria-label=\"has a decision log\" />}</>)}\n {tabButton('code', <>Code{links.length > 0 && <span className=\"node-tab-dot\" role=\"img\" aria-label={`${links.length} code links`} />}</>)}\n {systems && tabButton('systems', 'Systems design')}\n </div>\n <div className=\"node-content-main\" id=\"node-panel\" role=\"tabpanel\" aria-labelledby={`node-tab-${shown}`}>\n {shown === 'systems' ? (\n <Checklist controller={controller} nodeId={nodeId} legacyAnswers={legacyAnswers} />\n ) : shown === 'code' ? (\n <div className=\"node-content-pane\">\n <div className=\"node-content-view\" role=\"region\" aria-label=\"Code links\">\n {links.length === 0 ? <p className=\"node-content-empty\">No code linked. Ask the assistant to link the files and lines this node describes.</p> : (<>\n {code?.onRefresh && <p className=\"node-code-refresh\">\n <button type=\"button\" className=\"node-panel-btn\" disabled={code.checking} onClick={code.onRefresh}>{code.checking ? 'Checking…' : 'Refresh'}</button>\n <small>Compares each link with the code now. It only reports; it changes no link.</small>\n </p>}\n <ul className=\"node-code-links\">\n {links.map((link, i) => {\n const problem = code?.problem?.(nodeId, i)\n return (\n <li key={`${link.path}:${i}`}>\n <button type=\"button\" className=\"node-panel-btn\" onClick={() => code?.reveal(link)}>\n {link.repo ? `${link.repo}:` : ''}{link.path}{link.lines ? `:${link.lines[0] === link.lines[1] ? link.lines[0] : `${link.lines[0]}-${link.lines[1]}`}` : ''}\n </button>\n {link.symbol && <code>{link.symbol}</code>}\n {link.hash && <small>hash {link.hash}</small>}\n {problem && <small className={`node-code-status link-${problem.status}`} role=\"status\">{problem.message}</small>}\n </li>\n )\n })}\n </ul>\n </>)}\n </div>\n </div>\n ) : shown === 'decisions' ? (\n <div className=\"node-content-pane\">\n {draft ? (\n <form id=\"node-content-form\" className=\"node-content-edit\" onSubmit={(e) => { e.preventDefault(); save() }}>\n <div className=\"node-content-fields\">\n <label className=\"node-content-source\">Decisions (Markdown)<textarea aria-label=\"Decision log (Markdown)\" value={draft.decisions} maxLength={LIMITS.body} spellCheck={false} placeholder={'Why this choice? For example: Lambda over Fargate, ECS or Kubernetes.\\n\\n## Considered\\n- Fargate: ...\\n- ECS: ...'} onChange={(e) => change({ decisions: e.target.value })} /></label>\n </div>\n </form>\n ) : <div className=\"node-content-view\" role=\"region\" aria-label=\"Decision log\">\n {renderMarkdown(decisions)}\n {!decisions && <p className=\"node-content-empty\">No decisions recorded. This is optional: choose Edit content to note why this was chosen and what else was considered.</p>}\n </div>}\n </div>\n ) : (\n <div className=\"node-content-pane\">\n {draft ? (\n <form id=\"node-content-form\" className=\"node-content-edit\" onSubmit={(e) => { e.preventDefault(); save() }}>\n <div className=\"node-content-fields\">\n <label>Title<input ref={titleRef} aria-label=\"Full node title\" value={draft.title} maxLength={LIMITS.title} required onChange={(e) => change({ title: e.target.value })} /></label>\n <label className=\"node-content-source\">Markdown<textarea aria-label=\"Full node content (Markdown)\" value={draft.body} maxLength={LIMITS.body} spellCheck={false} onChange={(e) => change({ body: e.target.value })} /></label>\n </div>\n </form>\n ) : <div className=\"node-content-view\" role=\"region\" aria-label=\"Full Markdown content\">\n {renderMarkdown(node.body)}\n {!node.body && <p className=\"node-content-empty\">This node has no content yet. Choose Edit content to add Markdown.</p>}\n </div>}\n </div>\n )}\n </div>\n {pending && renderConfirm({\n title: 'Discard node edits?',\n body: 'Your unsaved title, Markdown and decision changes will be discarded.',\n confirmLabel: 'Discard edits',\n onCancel: keepEditing,\n onConfirm: discardAndContinue,\n })}\n </section>\n )\n}\n","import { useEffect, useRef, useState } from 'react'\nimport { useStore } from 'zustand'\nimport { CHECKLIST_ANSWER_MAX, CHECKLIST_TOTAL, DESIGN_CHECKLIST } from '@glyphlab/diagram-core'\nimport type { DiagramController } from '@glyphlab/diagram-core'\n\n// The groups in two columns, in order: the first column takes groups until it holds about half of the questions.\nconst columns = (() => {\n const left: typeof DESIGN_CHECKLIST = []\n let count = 0\n for (const group of DESIGN_CHECKLIST) {\n if (count < CHECKLIST_TOTAL / 2 - 1) { left.push(group); count += group.items.length }\n }\n return [left, DESIGN_CHECKLIST.filter((g) => !left.includes(g))]\n})()\n\n// The answers are part of the node (and so of the design). A change is made with the same operation an agent uses, so it can be undone\n// and the agent sees it.\nconst save = (controller: DiagramController, nodeId: string, title: string, answers: Record<string, string>) =>\n controller.apply({ ops: [{ op: 'updateNode', id: nodeId, patch: { systemsDesign: answers } }], summary: `Edit Systems design of \"${title}\"`, source: 'user' })\n\nexport interface ChecklistProps {\n controller: DiagramController\n nodeId: string\n // Answers a host kept elsewhere before they lived on the node: read once for a node that has none, then put on it.\n legacyAnswers?: (nodeId: string) => Record<string, string> | null\n}\n\nexport function Checklist({ controller, nodeId, legacyAnswers }: ChecklistProps) {\n const node = useStore(controller.stores.nodes, (s) => s.byId[nodeId])\n const values = node?.systemsDesign\n const done = Object.values(values ?? {}).filter((v) => v.trim()).length\n\n useEffect(() => {\n const current = controller.stores.nodes.getState().byId[nodeId]\n if (!current || current.systemsDesign || !legacyAnswers) return\n const legacy = legacyAnswers(nodeId)\n if (legacy) save(controller, nodeId, current.title, legacy)\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n }, [nodeId])\n\n return (\n <aside className=\"node-checklist\" aria-label=\"Systems design checklist\">\n <h3>Systems design <span role=\"status\">{done}/{CHECKLIST_TOTAL} filled</span></h3>\n <div className=\"node-checklist-columns\">\n {columns.map((groups, column) => (\n <div key={column} className=\"node-checklist-column\">\n {groups.map((group) => (\n <fieldset key={group.title}>\n <legend>{group.title}</legend>\n {group.items.map((item) => (\n <label key={item.id}>\n <strong>{item.label}</strong>\n <small>{item.question}</small>\n <Answer controller={controller} nodeId={nodeId} itemId={item.id} saved={values?.[item.id] ?? ''} />\n </label>\n ))}\n </fieldset>\n ))}\n </div>\n ))}\n </div>\n </aside>\n )\n}\n\n// One answer. It is typed into locally and saved a moment after the last key (or when the box is left), so typing is not one undo step\n// per letter. A change made elsewhere (an agent, an undo) shows up unless the person is in the middle of typing.\nfunction Answer({ controller, nodeId, itemId, saved }: { controller: DiagramController; nodeId: string; itemId: string; saved: string }) {\n const [text, setText] = useState(saved)\n const typing = useRef(false)\n const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n const latest = useRef(text)\n\n useEffect(() => {\n if (!typing.current) {\n setText(saved)\n latest.current = saved\n }\n }, [saved])\n\n const commit = () => {\n clearTimeout(timer.current)\n typing.current = false\n const node = controller.stores.nodes.getState().byId[nodeId]\n if (!node || latest.current === (node.systemsDesign?.[itemId] ?? '')) return\n save(controller, nodeId, node.title, { [itemId]: latest.current })\n }\n // Leaving the tab or the node while a change is waiting saves it.\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n useEffect(() => () => { if (typing.current) commit() }, [])\n\n return (\n <input\n type=\"text\"\n value={text}\n maxLength={CHECKLIST_ANSWER_MAX}\n onChange={(e) => {\n typing.current = true\n latest.current = e.target.value\n setText(e.target.value)\n clearTimeout(timer.current)\n timer.current = setTimeout(commit, 600)\n }}\n onBlur={commit}\n />\n )\n}\n"],"mappings":"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","names":["useEffect","useRef","useStore","jsx","jsxs","useEffect","useStore","save","useRef"]}
package/node-panel.css ADDED
@@ -0,0 +1,304 @@
1
+ /* Styles for @glyphlab/node-panel-react. They read the canvas theme's variables: --bg, --surface, --surface-2, --border, --text, --muted, --accent, --mono. */
2
+ .node-content-drawer {
3
+ position: relative;
4
+ display: flex;
5
+ flex-direction: column;
6
+ flex: 0 1 auto;
7
+ min-height: 140px;
8
+ max-height: 80%;
9
+ border-top: 1px solid var(--border);
10
+ background: var(--surface);
11
+ outline: none;
12
+ }
13
+
14
+ .node-content-head {
15
+ display: flex;
16
+ flex: none;
17
+ align-items: center;
18
+ gap: 8px;
19
+ padding: 8px 12px;
20
+ border-bottom: 1px solid var(--border);
21
+ }
22
+
23
+ .node-content-head h2 {
24
+ flex: 1;
25
+ min-width: 0;
26
+ margin: 0;
27
+ overflow: hidden;
28
+ text-overflow: ellipsis;
29
+ white-space: nowrap;
30
+ font-size: 14px;
31
+ }
32
+
33
+ .node-content-head > span,
34
+ .node-content-empty {
35
+ font-size: 12px;
36
+ color: var(--muted);
37
+ }
38
+
39
+ .node-panel-btn {
40
+ display: inline-flex;
41
+ align-items: center;
42
+ gap: 6px;
43
+ padding: 5px 8px;
44
+ color: var(--text);
45
+ background: var(--surface-2);
46
+ border: 1px solid var(--border);
47
+ border-radius: 6px;
48
+ font: inherit;
49
+ font-size: 12.5px;
50
+ white-space: nowrap;
51
+ cursor: pointer;
52
+ }
53
+
54
+ .node-panel-btn.primary {
55
+ color: #fff;
56
+ background: var(--accent);
57
+ border-color: var(--accent);
58
+ }
59
+
60
+ .node-panel-btn:disabled {
61
+ opacity: 0.5;
62
+ cursor: not-allowed;
63
+ }
64
+
65
+ .node-panel-icon-btn {
66
+ display: inline-flex;
67
+ padding: 4px;
68
+ color: var(--muted);
69
+ background: none;
70
+ border: 0;
71
+ border-radius: 6px;
72
+ cursor: pointer;
73
+ }
74
+
75
+ .node-panel-icon-btn:hover {
76
+ color: var(--text);
77
+ background: var(--surface-2);
78
+ }
79
+
80
+ .node-panel-plain {
81
+ margin: 0;
82
+ white-space: pre-wrap;
83
+ }
84
+
85
+ .node-panel-dialog {
86
+ position: absolute;
87
+ inset: 0;
88
+ z-index: 5;
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: center;
92
+ background: rgb(0 0 0 / 0.35);
93
+ }
94
+
95
+ .node-panel-dialog-box {
96
+ max-width: 360px;
97
+ padding: 16px;
98
+ color: var(--text);
99
+ background: var(--surface);
100
+ border: 1px solid var(--border);
101
+ border-radius: 8px;
102
+ }
103
+
104
+ .node-panel-dialog-box h3 { margin: 0 0 8px; font-size: 14px; }
105
+ .node-panel-dialog-box p { margin: 0 0 12px; font-size: 13px; }
106
+ .node-panel-dialog-box div { display: flex; justify-content: flex-end; gap: 8px; }
107
+
108
+ .node-content-view {
109
+ min-width: 0;
110
+ min-height: 0;
111
+ overflow: auto;
112
+ padding: 16px;
113
+ overflow-wrap: anywhere;
114
+ }
115
+
116
+ .node-content-view {
117
+ flex: 1;
118
+ }
119
+
120
+ .node-content-edit {
121
+ display: flex;
122
+ flex: 1;
123
+ min-height: 0;
124
+ }
125
+
126
+ .node-content-fields {
127
+ display: flex;
128
+ flex: 1;
129
+ flex-direction: column;
130
+ gap: 8px;
131
+ min-width: 0;
132
+ min-height: 0;
133
+ padding: 12px;
134
+ }
135
+
136
+ .node-content-fields label {
137
+ display: flex;
138
+ flex-direction: column;
139
+ gap: 4px;
140
+ min-height: 0;
141
+ font-size: 12px;
142
+ }
143
+
144
+ .node-content-source {
145
+ flex: 1;
146
+ }
147
+
148
+ .node-content-fields input,
149
+ .node-content-fields textarea {
150
+ min-width: 0;
151
+ padding: 8px;
152
+ color: var(--text);
153
+ background: var(--bg);
154
+ border: 1px solid var(--border);
155
+ border-radius: 4px;
156
+ font: inherit;
157
+ }
158
+
159
+ .node-content-fields textarea {
160
+ flex: 1;
161
+ min-height: 40px;
162
+ resize: none;
163
+ font-family: var(--mono, monospace);
164
+ }
165
+
166
+ .node-content-error {
167
+ flex: none;
168
+ margin: 0;
169
+ padding: 8px 12px;
170
+ color: var(--text);
171
+ background: var(--surface-2);
172
+ }
173
+
174
+ @media (max-width: 760px) {
175
+ .node-content-head > span {
176
+ display: none;
177
+ }
178
+ }
179
+
180
+ .node-content-main {
181
+ display: flex;
182
+ flex: 1;
183
+ min-height: 0;
184
+ }
185
+
186
+ .node-content-pane {
187
+ display: flex;
188
+ flex: 1;
189
+ min-width: 0;
190
+ min-height: 0;
191
+ }
192
+
193
+ /* Its own tab: it takes the whole panel. */
194
+ .node-checklist {
195
+ flex: 1;
196
+ min-width: 0;
197
+ overflow: auto;
198
+ padding: 12px 16px;
199
+ font-size: 12px;
200
+ }
201
+
202
+ .node-checklist h3 {
203
+ display: flex;
204
+ justify-content: space-between;
205
+ margin: 0 0 8px;
206
+ font-size: 13px;
207
+ }
208
+
209
+ .node-checklist h3 span {
210
+ color: var(--muted);
211
+ font-weight: normal;
212
+ }
213
+
214
+ .node-checklist fieldset {
215
+ margin: 0 0 10px;
216
+ padding: 0;
217
+ border: 0;
218
+ }
219
+
220
+ .node-checklist legend {
221
+ padding: 0;
222
+ margin-bottom: 4px;
223
+ color: var(--muted);
224
+ text-transform: uppercase;
225
+ letter-spacing: 0.04em;
226
+ font-size: 11px;
227
+ }
228
+
229
+ .node-checklist label {
230
+ display: flex;
231
+ flex-direction: column;
232
+ gap: 2px;
233
+ padding: 4px 0;
234
+ }
235
+
236
+ .node-checklist label input {
237
+ margin-top: 2px;
238
+ padding: 5px 6px;
239
+ color: var(--text);
240
+ background: var(--bg);
241
+ border: 1px solid var(--border);
242
+ border-radius: 4px;
243
+ font: inherit;
244
+ }
245
+
246
+ .node-checklist small {
247
+ color: var(--muted);
248
+ }
249
+
250
+ @media (max-width: 760px) {
251
+ .node-checklist {
252
+ display: none;
253
+ }
254
+ }
255
+
256
+ .node-content-tabs {
257
+ display: flex;
258
+ flex: none;
259
+ gap: 2px;
260
+ padding: 0 12px;
261
+ border-bottom: 1px solid var(--border);
262
+ }
263
+
264
+ .node-content-tabs button {
265
+ display: inline-flex;
266
+ align-items: center;
267
+ gap: 6px;
268
+ padding: 6px 10px;
269
+ border: 0;
270
+ border-bottom: 2px solid transparent;
271
+ background: none;
272
+ color: var(--muted);
273
+ font: inherit;
274
+ font-size: 12.5px;
275
+ cursor: pointer;
276
+ }
277
+
278
+ .node-content-tabs button[aria-selected='true'] {
279
+ border-bottom-color: var(--accent);
280
+ color: var(--text);
281
+ }
282
+
283
+ .node-tab-dot {
284
+ width: 6px;
285
+ height: 6px;
286
+ border-radius: 50%;
287
+ background: var(--accent);
288
+ }
289
+
290
+ /* One column when the panel is too narrow for two. */
291
+ @media (max-width: 720px) {
292
+ .node-checklist-columns { grid-template-columns: minmax(0, 1fr); }
293
+ }
294
+
295
+ .node-checklist-columns {
296
+ display: grid;
297
+ grid-template-columns: repeat(2, minmax(0, 1fr));
298
+ align-items: start;
299
+ gap: 0 32px;
300
+ }
301
+
302
+ .node-code-links { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
303
+ .node-code-links li { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
304
+ .node-code-links small { opacity: 0.7; }
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@glyphlab/node-panel-react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The node panel for a @glyphlab/diagram-core controller, in React: a node's Markdown content, decision log, code links and Systems design answers, with editing, autosave and conflict handling.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./index.js",
8
+ "types": "./index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "import": "./index.js"
13
+ },
14
+ "./node-panel.css": "./node-panel.css"
15
+ },
16
+ "files": [
17
+ "*.js",
18
+ "*.js.map",
19
+ "*.d.ts",
20
+ "*.css",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "sideEffects": [
25
+ "./node-panel.css"
26
+ ],
27
+ "peerDependencies": {
28
+ "@glyphlab/diagram-core": "^0.1.0",
29
+ "react": "^19",
30
+ "zustand": "^5"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/subtubes-io/subtubes-editor.git",
35
+ "directory": "packages/node-panel-react"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "keywords": [
41
+ "diagram",
42
+ "react",
43
+ "system-design",
44
+ "glyph"
45
+ ]
46
+ }