prism-viz-engine 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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The catalogue — a systems-design diagram database, from BOTH systems, grouped by shape.
3
+ *
4
+ * Gavin's framing, and it is the right one: these are not a mood board. 62 diagram-design
5
+ * layouts are 62 SOLVED LAYOUT PROBLEMS — sankey, wardley, fishbone, swimlane, treemap,
6
+ * medallion, ridgeline, story-map, ER, gantt. And archify renders its own five from IR.
7
+ * The two belong in ONE display grouped by SHAPE, because the question being answered is
8
+ * "how does each system solve this, and which part am I integrating" — and you cannot see
9
+ * that if they sit in separate galleries.
10
+ *
11
+ * So SHARED shapes come first, side by side, same width, same row. Where both systems
12
+ * answer the same question, you see both answers together. Everything else follows as the
13
+ * catalogue of layouts only one of them has.
14
+ *
15
+ * These are rendered HTML, not IR — nothing here becomes a canvas node, which is why the
16
+ * catalogue is a mode rather than a canvas source.
17
+ *
18
+ * Caveat carried from the harvest and shown in the UI, not buried: style-guide.md:32 says
19
+ * diagram-design's assets "were built under an earlier skin." A reference, not the spec.
20
+ */
21
+
22
+ import { useEffect, useMemo, useState } from "react"
23
+
24
+ export type CatalogueSource = "diagram-design" | "archify" | "lanshu" | "visual-explainer" | "fossflow"
25
+
26
+ export interface CatalogueItem {
27
+ source: CatalogueSource
28
+ dir: string
29
+ file: string
30
+ type: string
31
+ shape: string
32
+ variant: "light" | "dark" | "full"
33
+ /** example | template | index | icons | preview */
34
+ kind: string
35
+ animated?: boolean
36
+ imported?: boolean
37
+ url: string
38
+ }
39
+
40
+ export interface GalleryProps {
41
+ sidecar: string
42
+ }
43
+
44
+ const SOURCE_LABEL: Record<string, string> = {
45
+ "diagram-design": "diagram-design",
46
+ archify: "archify",
47
+ lanshu: "lanshu",
48
+ "visual-explainer": "visual-explainer",
49
+ fossflow: "fossflow",
50
+ }
51
+
52
+ export function Gallery({ sidecar }: GalleryProps) {
53
+ const [items, setItems] = useState<CatalogueItem[]>([])
54
+ const [shared, setShared] = useState<string[]>([])
55
+ const [notes, setNotes] = useState<string[]>([])
56
+ const [variant, setVariant] = useState<"dark" | "light" | "full">("dark")
57
+ const [q, setQ] = useState("")
58
+ const [open, setOpen] = useState<CatalogueItem | null>(null)
59
+ /**
60
+ * Motion ON by default here, deliberately inverting animation.md's publishing default.
61
+ * A catalogue of still frames cannot answer "how do these move" — and for Lanshu the
62
+ * movement is the whole argument. Each document runs its own motion; `?motion=static`
63
+ * is what would freeze them, so it is simply not appended.
64
+ */
65
+ const [motion, setMotion] = useState(true)
66
+ const [compare, setCompare] = useState<CatalogueItem[] | null>(null)
67
+
68
+ useEffect(() => {
69
+ fetch(`${sidecar}/api/gallery`)
70
+ .then((r) => r.json())
71
+ .then((b) => {
72
+ setItems(b.items ?? [])
73
+ setShared(b.shared ?? [])
74
+ setNotes(b.notes ?? [])
75
+ })
76
+ .catch((e) => setNotes([`catalogue unavailable: ${e.message}`]))
77
+ }, [sidecar])
78
+
79
+ /**
80
+ * archify renders one variant only, so filtering the whole catalogue by variant would
81
+ * silently drop it from every shared row — and the comparison is the point. Variant
82
+ * filters diagram-design; archify always shows what it has.
83
+ */
84
+ const visible = useMemo(() => {
85
+ const needle = q.trim().toLowerCase()
86
+ return items.filter((i) => {
87
+ // Only diagram-design ships variants; filtering everything by variant would drop
88
+ // every other source from every shared row, and the comparison is the point.
89
+ if (i.source === "diagram-design" && i.variant !== variant) return false
90
+ if (needle && !(i.shape.includes(needle) || i.type.includes(needle))) return false
91
+ return true
92
+ })
93
+ }, [items, variant, q])
94
+
95
+ const byShape = useMemo(() => {
96
+ const m = new Map<string, CatalogueItem[]>()
97
+ for (const i of visible) {
98
+ if (!m.has(i.shape)) m.set(i.shape, [])
99
+ m.get(i.shape)!.push(i)
100
+ }
101
+ const sharedFirst = [...m.entries()].sort(([a], [b]) => {
102
+ const as = shared.includes(a) ? 0 : 1
103
+ const bs = shared.includes(b) ? 0 : 1
104
+ return as - bs || a.localeCompare(b)
105
+ })
106
+ return sharedFirst
107
+ }, [visible, shared])
108
+
109
+ const counts = useMemo(
110
+ () => ({
111
+ shapes: new Set(items.map((i) => i.shape)).size,
112
+ bySource: items.reduce<Record<string, number>>((a, i) => ((a[i.source] = (a[i.source] ?? 0) + 1), a), {}),
113
+ animated: items.filter((i) => i.animated).length,
114
+ }),
115
+ [items]
116
+ )
117
+
118
+ const Tile = ({ i, wide }: { i: CatalogueItem; wide?: boolean }) => (
119
+ <figure className={`vz-gal${wide ? " wide" : ""}`} onClick={() => setOpen(i)}>
120
+ <div className="vz-gal-frame">
121
+ {/* Thumbnails freeze: 62 documents animating at once is a fan, not a catalogue.
122
+ Opening one runs it. */}
123
+ {i.file.endsWith(".html") ? (
124
+ <iframe src={`${sidecar}${i.url}?motion=static`} title={i.file} loading="lazy" scrolling="no" />
125
+ ) : (
126
+ /* A GIF carries its own motion and has no static query — Lanshu's previews are
127
+ the clearest case of a thing whose value is the movement. */
128
+ <img className="vz-gal-img" src={`${sidecar}${i.url}`} alt={i.file} loading="lazy" />
129
+ )}
130
+ </div>
131
+ <figcaption>
132
+ <span className={`vz-gal-src vz-src-${i.source}`}>{SOURCE_LABEL[i.source]}</span>
133
+ <b>{i.type}</b>
134
+ <span className="vz-gal-var">{i.variant}</span>
135
+ </figcaption>
136
+ </figure>
137
+ )
138
+
139
+ return (
140
+ <div className="vz-gallery">
141
+ <div className="vz-gallery-bar">
142
+ <span className="vz-gallery-title">
143
+ Catalogue
144
+ <span className="vz-count">{counts.shapes} shapes</span>
145
+ {Object.entries(counts.bySource).map(([src, n]) => (
146
+ <span key={src} className={`vz-count vz-src-${src}`}>{n} {src}</span>
147
+ ))}
148
+ {!!counts.animated && <span className="vz-count vz-anim">{counts.animated} animated</span>}
149
+ </span>
150
+ <input
151
+ className="vz-search vz-gallery-search"
152
+ placeholder="filter by shape…"
153
+ value={q}
154
+ onChange={(e) => setQ(e.target.value)}
155
+ />
156
+ {(["dark", "light", "full"] as const).map((v) => (
157
+ <button key={v} className={`vz-chip${variant === v ? " on" : ""}`} onClick={() => setVariant(v)}>
158
+ {v}
159
+ </button>
160
+ ))}
161
+ <button className={`vz-chip${motion ? " on" : ""}`} onClick={() => setMotion((m) => !m)} title="a catalogue of still frames cannot compare motion">
162
+ motion {motion ? "on" : "static"}
163
+ </button>
164
+ <span className="vz-spacer" />
165
+ <span className="vz-gallery-note">
166
+ diagram-design's assets were built under an earlier skin — reference, not spec
167
+ </span>
168
+ </div>
169
+
170
+ {!!notes.length && <p className="vz-empty">{notes.join(" · ")}</p>}
171
+
172
+ <div className="vz-cat">
173
+ {byShape.map(([shape, group]) => {
174
+ const isShared = shared.includes(shape)
175
+ const sources = [...new Set(group.map((g) => g.source))]
176
+ return (
177
+ <section key={shape} className={`vz-cat-shape${isShared ? " shared" : ""}`}>
178
+ <header className="vz-cat-hd">
179
+ <h3>{shape}</h3>
180
+ {isShared && (
181
+ <button
182
+ className="vz-cat-compare"
183
+ onClick={() => setCompare(sources.map((s) => group.find((g) => g.source === s)!).filter(Boolean))}
184
+ >
185
+ compare {sources.length} ↔
186
+ </button>
187
+ )}
188
+ <span className="vz-cat-count">
189
+ {group.length} · {sources.join(" + ")}
190
+ </span>
191
+ </header>
192
+ <div className={`vz-cat-row${isShared ? " vz-cat-pair" : ""}`}>
193
+ {group.map((i) => (
194
+ <Tile key={i.source + i.file} i={i} wide={isShared} />
195
+ ))}
196
+ </div>
197
+ </section>
198
+ )
199
+ })}
200
+ </div>
201
+
202
+ {/* side-by-side — the comparison the catalogue exists for */}
203
+ {compare && (
204
+ <div className="vz-gal-open" onClick={() => setCompare(null)}>
205
+ <div className="vz-gal-open-bar">
206
+ <b>{compare[0]?.shape}</b>
207
+ <span>the same shape, answered by each system</span>
208
+ <span className="vz-spacer" />
209
+ <button onClick={() => setCompare(null)}>close</button>
210
+ </div>
211
+ <div className="vz-compare" onClick={(e) => e.stopPropagation()}>
212
+ {compare.map((c) => (
213
+ <div key={c.source} className="vz-compare-pane">
214
+ <div className={`vz-gal-src vz-src-${c.source}`}>
215
+ {SOURCE_LABEL[c.source]} · {c.file}
216
+ </div>
217
+ <iframe src={`${sidecar}${c.url}${motion ? "" : "?motion=static"}`} title={c.file} />
218
+ </div>
219
+ ))}
220
+ </div>
221
+ </div>
222
+ )}
223
+
224
+ {open && !compare && (
225
+ <div className="vz-gal-open" onClick={() => setOpen(null)}>
226
+ <div className="vz-gal-open-bar">
227
+ <span className={`vz-gal-src vz-src-${open.source}`}>{SOURCE_LABEL[open.source]}</span>
228
+ <b>{open.type}</b>
229
+ <span>{open.file}</span>
230
+ <span className="vz-spacer" />
231
+ <button onClick={() => setOpen(null)}>close</button>
232
+ </div>
233
+ {open.file.endsWith(".html") ? (
234
+ <iframe
235
+ src={`${sidecar}${open.url}${motion ? "" : "?motion=static"}`}
236
+ title={open.file}
237
+ onClick={(e) => e.stopPropagation()}
238
+ />
239
+ ) : (
240
+ <img className="vz-gal-open-img" src={`${sidecar}${open.url}`} alt={open.file} onClick={(e) => e.stopPropagation()} />
241
+ )}
242
+ </div>
243
+ )}
244
+ </div>
245
+ )
246
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Layer 04 — the inspector. The box's real detail, both harvest halves.
3
+ *
4
+ * Waku's tabbed loop/memory/ops views, in our terms: when you click a box you get what
5
+ * it IS (the UI half — file:line, mount point, what not to copy) and what it DOES (the
6
+ * code half — capability, fit verdict, licence, the standing ruling). Fused, because
7
+ * that is the pair a tooling decision actually needs.
8
+ *
9
+ * The layer select here does the same job as dragging across a lane; both write the
10
+ * same field. Two routes to one act, never two sources of truth.
11
+ */
12
+
13
+ import type { Node } from "@xyflow/react"
14
+ import type { ComponentNodeData } from "../02-render/ComponentNode"
15
+ import { ALL_SLOTS, ROLE_EMBER, ROLE_EQUIV, type LayerSlot } from "../../core/layer-roles"
16
+
17
+ export interface InspectorProps {
18
+ node: Node<ComponentNodeData> | null
19
+ reveal?: (origin: { repo: string; file: string; line: number }) => void | Promise<void>
20
+ onRelayer: (nodeId: string, layer: LayerSlot) => void
21
+ }
22
+
23
+ const Field = ({ k, children, mono }: { k: string; children: React.ReactNode; mono?: boolean }) => (
24
+ <div className="vz-field">
25
+ <span className="vz-field-k">{k}</span>
26
+ <span className={`vz-field-v${mono ? " mono" : ""}`}>{children}</span>
27
+ </div>
28
+ )
29
+
30
+ export function Inspector({ node, reveal, onRelayer }: InspectorProps) {
31
+ if (!node) {
32
+ return (
33
+ <aside className="vz-inspector">
34
+ <h2>The canvas is the routing tool</h2>
35
+ <p className="vz-hint">
36
+ Drag a component out of the palette onto a lane — the lane it lands in <b>is</b> its layer
37
+ role. Drag it to another lane and the role is reassigned.
38
+ </p>
39
+ <p className="vz-hint">
40
+ Handle to handle writes an edge: that is how a component becomes part of a flow, and a
41
+ flow part of a workflow.
42
+ </p>
43
+ <p className="vz-hint">
44
+ <b>Export</b> writes JSON Canvas — the neutral wire between the engine's generators and
45
+ its canvases. The same file opens in Obsidian and feeds{" "}
46
+ <code>emit-canvas-nodes.mjs</code>, so bad routing is rejected rather than saved.
47
+ </p>
48
+ <div className="vz-legend">
49
+ {ALL_SLOTS.map((r) => (
50
+ <span key={r} className="vz-legend-row">
51
+ <i style={{ background: ROLE_EMBER[r] }} />
52
+ {r}
53
+ {ROLE_EQUIV[r] && <em>{ROLE_EQUIV[r]}</em>}
54
+ </span>
55
+ ))}
56
+ </div>
57
+ </aside>
58
+ )
59
+ }
60
+
61
+ const g = node.data.griot
62
+ const ui = g.ui
63
+ const code = g.code
64
+
65
+ return (
66
+ <aside className="vz-inspector">
67
+ <h2 style={{ borderColor: ROLE_EMBER[g.layer as LayerSlot] }}>{node.data.label}</h2>
68
+
69
+ <div className="vz-field">
70
+ <span className="vz-field-k">layer role</span>
71
+ <select
72
+ className="vz-select"
73
+ value={g.layer}
74
+ onChange={(e) => onRelayer(node.id, e.target.value as LayerSlot)}
75
+ >
76
+ {ALL_SLOTS.map((r) => (
77
+ <option key={r} value={r}>
78
+ {r}
79
+ </option>
80
+ ))}
81
+ </select>
82
+ </div>
83
+
84
+ {g.walkLevel && <Field k="walk level">{g.walkLevel}</Field>}
85
+
86
+ {ui && (
87
+ <>
88
+ <div className="vz-field">
89
+ <span className="vz-field-k">source · the evidence</span>
90
+ <button className="vz-reveal" disabled={!reveal} onClick={() => reveal?.(ui.origin)}>
91
+ {ui.origin.file}:{ui.origin.line}
92
+ <span className="vz-reveal-cta">{reveal ? "open ↗" : "no reveal here"}</span>
93
+ </button>
94
+ </div>
95
+ <Field k="mount point" mono>
96
+ {ui.mountPoint}
97
+ </Field>
98
+ <Field k="repo" mono>
99
+ {ui.origin.repo}
100
+ </Field>
101
+ </>
102
+ )}
103
+
104
+ {code && (
105
+ <>
106
+ {code.capability && <Field k="what it does">{code.capability}</Field>}
107
+ {!!code.features?.length && (
108
+ <Field k="features">
109
+ <ul className="vz-list">
110
+ {code.features.map((f) => (
111
+ <li key={f}>{f}</li>
112
+ ))}
113
+ </ul>
114
+ </Field>
115
+ )}
116
+ {code.fit && (
117
+ <Field k="fit verdict">
118
+ {code.fit.app} <b>{"·".repeat(code.fit.strength)}</b> — {code.fit.why}
119
+ </Field>
120
+ )}
121
+ <Field k="licence — a fact, never a verdict" mono>
122
+ {code.licence}
123
+ </Field>
124
+ {(code.decision || code.role || code.stage) && (
125
+ <Field k="ruling" mono>
126
+ {[code.decision, code.role, code.stage].filter(Boolean).join(" · ")}
127
+ </Field>
128
+ )}
129
+ </>
130
+ )}
131
+
132
+ <Field k="what NOT to copy">
133
+ {ui?.notCopy?.length ? (
134
+ <ul className="vz-list vz-notcopy">
135
+ {ui.notCopy.map((x) => (
136
+ <li key={x}>{x}</li>
137
+ ))}
138
+ </ul>
139
+ ) : (
140
+ <span className="vz-dim">— none recorded —</span>
141
+ )}
142
+ </Field>
143
+
144
+ <Field k="provenance" mono>
145
+ {g.provenance.harvestedBy} · {g.provenance.harvestedAt}
146
+ {g.provenance.sourceCommit ? ` · ${g.provenance.sourceCommit}` : ""}
147
+ </Field>
148
+ <Field k="id" mono>
149
+ {node.id}
150
+ </Field>
151
+ </aside>
152
+ )
153
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Layer 04 — the palette. The harvested components, as things you pick up.
3
+ *
4
+ * This is the half that was missing every previous attempt. A lane diagram shows where
5
+ * a component was ROUTED; it does not let you hold one. The palette is the shelf of
6
+ * real, harvested components — each one grounded in file:line, carrying its licence and
7
+ * its fit — that you drag onto the canvas to compose with.
8
+ *
9
+ * Grouped by repo, filterable, searchable. Each card is a drag source; the canvas is
10
+ * the drop target. What crosses between them is the node payload itself, so the thing
11
+ * you dropped is the thing that was harvested — never a copy that can drift.
12
+ */
13
+
14
+ import { useMemo, useState } from "react"
15
+ import type { CanvasNode, GriotNodeMeta } from "../../core/json-canvas"
16
+ import { ROLE_EMBER, type LayerSlot } from "../../core/layer-roles"
17
+
18
+ export interface PaletteProps {
19
+ /** Everything harvested — the shelf, independent of what is placed. */
20
+ library: CanvasNode[]
21
+ /** Ids already on the canvas, so the palette can mark them. */
22
+ placed: Set<string>
23
+ onReveal?: (origin: { repo: string; file: string; line: number }) => void | Promise<void>
24
+ }
25
+
26
+ export function Palette({ library, placed, onReveal }: PaletteProps) {
27
+ const [q, setQ] = useState("")
28
+ const [repo, setRepo] = useState<string>("all")
29
+
30
+ const repos = useMemo(
31
+ () => ["all", ...new Set(library.map((n) => n.griot?.ui?.origin.repo ?? "?").sort())],
32
+ [library]
33
+ )
34
+
35
+ const shown = useMemo(() => {
36
+ const needle = q.trim().toLowerCase()
37
+ return library.filter((n) => {
38
+ const g = n.griot as GriotNodeMeta | undefined
39
+ if (!g) return false
40
+ if (repo !== "all" && g.ui?.origin.repo !== repo) return false
41
+ if (!needle) return true
42
+ const hay = [
43
+ (n as any).label,
44
+ g.ui?.origin.file,
45
+ g.ui?.mountPoint,
46
+ g.layer,
47
+ g.code?.capability,
48
+ g.code?.licence,
49
+ ]
50
+ .join(" ")
51
+ .toLowerCase()
52
+ return hay.includes(needle)
53
+ })
54
+ }, [library, q, repo])
55
+
56
+ const grouped = useMemo(() => {
57
+ const m = new Map<string, CanvasNode[]>()
58
+ for (const n of shown) {
59
+ const r = n.griot?.ui?.origin.repo ?? "?"
60
+ if (!m.has(r)) m.set(r, [])
61
+ m.get(r)!.push(n)
62
+ }
63
+ return [...m.entries()].sort(([a], [b]) => a.localeCompare(b))
64
+ }, [shown])
65
+
66
+ return (
67
+ <aside className="vz-palette">
68
+ <div className="vz-palette-hd">
69
+ <div className="vz-palette-title">
70
+ Components <span className="vz-count">{shown.length}</span>
71
+ </div>
72
+ <input
73
+ className="vz-search"
74
+ placeholder="search components, files, licences…"
75
+ value={q}
76
+ onChange={(e) => setQ(e.target.value)}
77
+ />
78
+ <div className="vz-repos">
79
+ {repos.map((r) => (
80
+ <button key={r} className={`vz-chip${repo === r ? " on" : ""}`} onClick={() => setRepo(r)}>
81
+ {r}
82
+ </button>
83
+ ))}
84
+ </div>
85
+ </div>
86
+
87
+ <div className="vz-palette-body">
88
+ {grouped.map(([r, items]) => (
89
+ <section key={r} className="vz-group">
90
+ <h3 className="vz-group-hd">
91
+ {r} <span className="vz-count">{items.length}</span>
92
+ </h3>
93
+ {items.map((n) => {
94
+ const g = n.griot as GriotNodeMeta
95
+ const ember = ROLE_EMBER[g.layer as LayerSlot] ?? "#6b7385"
96
+ const isPlaced = placed.has(n.id)
97
+ return (
98
+ <div
99
+ key={n.id}
100
+ className={`vz-card${isPlaced ? " placed" : ""}`}
101
+ style={{ ["--ember" as string]: ember }}
102
+ draggable
103
+ onDragStart={(e) => {
104
+ e.dataTransfer.setData("application/griot-node", JSON.stringify(n))
105
+ e.dataTransfer.effectAllowed = "copy"
106
+ }}
107
+ title={g.ui?.mountPoint}
108
+ >
109
+ <div className="vz-card-top">
110
+ <span className="vz-card-kind">{g.walkLevel ?? n.type}</span>
111
+ <span className="vz-card-layer">{g.layer}</span>
112
+ {isPlaced && <span className="vz-placed">on canvas</span>}
113
+ </div>
114
+ <div className="vz-card-label">{(n as any).label}</div>
115
+ {g.ui && (
116
+ <button
117
+ className="vz-card-src"
118
+ disabled={!onReveal}
119
+ onClick={(e) => {
120
+ e.stopPropagation()
121
+ if (onReveal) void onReveal(g.ui!.origin)
122
+ }}
123
+ >
124
+ {g.ui.origin.file}:{g.ui.origin.line}
125
+ </button>
126
+ )}
127
+ <div className="vz-card-foot">
128
+ <span className="vz-lic">{g.code?.licence ?? "—"}</span>
129
+ {!!g.ui?.notCopy?.length && (
130
+ <span className="vz-warn" title={g.ui.notCopy.join("\n")}>
131
+ ⚠{g.ui.notCopy.length}
132
+ </span>
133
+ )}
134
+ </div>
135
+ </div>
136
+ )
137
+ })}
138
+ </section>
139
+ ))}
140
+ {!shown.length && (
141
+ <p className="vz-empty">
142
+ Nothing harvested matches. The palette only ever shows real walked components — it will
143
+ not fill itself with placeholders.
144
+ </p>
145
+ )}
146
+ </div>
147
+ </aside>
148
+ )
149
+ }