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.
- package/README.md +104 -0
- package/package.json +82 -0
- package/scripts/emit-screen.mjs +192 -0
- package/src/core/json-canvas.ts +301 -0
- package/src/core/layer-roles.ts +77 -0
- package/src/core/motion.ts +390 -0
- package/src/core/mount.ts +130 -0
- package/src/index.ts +55 -0
- package/src/layers/01-generate/archify-ir.ts +234 -0
- package/src/layers/02-render/Canvas.tsx +211 -0
- package/src/layers/02-render/ComponentNode.tsx +103 -0
- package/src/layers/02-render/IsometricView.tsx +288 -0
- package/src/layers/02-render/icons.ts +199 -0
- package/src/layers/02-render/isometric.ts +149 -0
- package/src/layers/02-render/route.ts +297 -0
- package/src/layers/03-substrate/harvest-adapter.ts +161 -0
- package/src/layers/04-shell/Gallery.tsx +246 -0
- package/src/layers/04-shell/Inspector.tsx +153 -0
- package/src/layers/04-shell/Palette.tsx +149 -0
- package/src/layers/04-shell/Shell.tsx +306 -0
- package/src/layers/04-shell/mount-react.tsx +42 -0
- package/src/main.tsx +108 -0
- package/src/styles.css +410 -0
|
@@ -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
|
+
}
|