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
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# prism-viz-engine
|
|
2
|
+
|
|
3
|
+
**The canvas is the instrument.** A diagramming engine where every box on screen is a real
|
|
4
|
+
module file that opens — not a picture of a system, the system drawing itself.
|
|
5
|
+
|
|
6
|
+
Built for the [Griot Creative Suite](https://github.com/TheDigitalGriot). One engine, mounted
|
|
7
|
+
by many surfaces, so Kweli, Djeli, Prism and Synaptiq share a renderer instead of drifting
|
|
8
|
+
three copies of one.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install prism-viz-engine
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
React 18 is a **peer dependency** — the host supplies it, the engine never bundles a second copy.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## The four layers
|
|
19
|
+
|
|
20
|
+
| layer | what it does | entry |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| **01 · generate** | authors archify-shaped IR, gated by archify's own validator plus a grounding rule | `src/layers/01-generate/archify-ir.ts` |
|
|
23
|
+
| **wire** | **JSON Canvas** — the neutral format between generators and canvases | `src/core/json-canvas.ts` |
|
|
24
|
+
| **02 · render** | two renderers chosen by *what is being drawn*, never by a toggle | `src/layers/02-render/route.ts` |
|
|
25
|
+
| **03 · substrate** | harvest adapter over the live code graph | `src/layers/03-substrate/harvest-adapter.ts` |
|
|
26
|
+
| **04 · shell** | the Waku shell where every box opens its source | `src/layers/04-shell/Shell.tsx` |
|
|
27
|
+
|
|
28
|
+
The wire format is real [JSON Canvas](https://jsoncanvas.org) — the same file opens in Obsidian.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Mount it
|
|
33
|
+
|
|
34
|
+
**Standalone**
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm run dev # vite + the reveal sidecar
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Composed into a host app**
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { mountVizEngine } from "prism-viz-engine"
|
|
44
|
+
|
|
45
|
+
const handle = mountVizEngine({
|
|
46
|
+
element: document.getElementById("canvas")!,
|
|
47
|
+
canvas: myJsonCanvas,
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Data only — no React, no xyflow in your bundle**
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { validate, emptyCanvas } from "prism-viz-engine/json-canvas"
|
|
55
|
+
import { LAYER_ROLES, isLayerRole } from "prism-viz-engine/layer-roles"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## The eleven layer roles
|
|
61
|
+
|
|
62
|
+
The output taxonomy every placed node is routed to. Copied byte-verbatim from the Griot Stack
|
|
63
|
+
`LNAME` array — middle dots and all, because the emitter, the canvas and the plan all key on
|
|
64
|
+
exact string equality.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
Djeli · container Collaboration · GenTeam Creation · build/content/3D
|
|
68
|
+
Capture Intelligence · Super Agent Governance · Governor
|
|
69
|
+
Model-making / data science Memory · foundation Deployment
|
|
70
|
+
Suite meta Cross-cutting rails
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A finding that fits no role is flagged `unplaceable` — **never force-fit into a twelfth**.
|
|
74
|
+
An unfilled layer is reported as unfilled; no source is ever invented to fill it.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Emit a static screen
|
|
79
|
+
|
|
80
|
+
Writes a self-contained HTML canvas — no vite, no sidecar, no external references — that any
|
|
81
|
+
watcher-based panel can serve.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
node scripts/emit-screen.mjs --in <canvas.json> --title "..."
|
|
85
|
+
node scripts/emit-screen.mjs --self
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The trade is stated rather than hidden: a static screen has **no reveal-to-source**, because
|
|
89
|
+
nothing is listening. Layout, lanes, layer colour and the `file:line` on every card all travel.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Honest seams
|
|
94
|
+
|
|
95
|
+
Declared, not disguised:
|
|
96
|
+
|
|
97
|
+
- `prism-graph` and Excalidraw renderers are **named in the router but unbuilt** — the router
|
|
98
|
+
reports what it fell back to rather than pretending.
|
|
99
|
+
- `loadFromKuzu` **throws** rather than returning invented rows.
|
|
100
|
+
- **Reading Depth** (zoom-linked level of detail) is fully specified and not yet applied.
|
|
101
|
+
|
|
102
|
+
## Licence
|
|
103
|
+
|
|
104
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prism-viz-engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "prism-viz-engine — the assembled cluster, built. Layer 01 generate -> JSON Canvas wire -> layer 02 render (xyflow) -> layer 03 substrate (Kuzu) -> layer 04 interactive shell (Waku click-any-box). Mounts standalone, composed into another app, or inside the Djeli container.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "digitalgriot",
|
|
7
|
+
"homepage": "https://github.com/TheDigitalGriot/prism/tree/main/apps/prism-viz-engine",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/TheDigitalGriot/prism.git",
|
|
11
|
+
"directory": "apps/prism-viz-engine"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"diagram",
|
|
15
|
+
"visualization",
|
|
16
|
+
"json-canvas",
|
|
17
|
+
"isometric",
|
|
18
|
+
"xyflow",
|
|
19
|
+
"architecture-diagram",
|
|
20
|
+
"griot"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./src/index.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.ts",
|
|
26
|
+
"./mount": "./src/core/mount.ts",
|
|
27
|
+
"./json-canvas": "./src/core/json-canvas.ts",
|
|
28
|
+
"./layer-roles": "./src/core/layer-roles.ts",
|
|
29
|
+
"./motion": "./src/core/motion.ts"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"scripts",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"dev": "concurrently -k -n reveal,vite -c magenta,cyan \"node server/reveal-server.mjs\" \"vite\"",
|
|
38
|
+
"dev:web": "vite",
|
|
39
|
+
"dev:reveal": "node server/reveal-server.mjs",
|
|
40
|
+
"build": "vite build",
|
|
41
|
+
"preview": "vite preview",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"emit-screen": "node scripts/emit-screen.mjs"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"react": "^18.3.1",
|
|
47
|
+
"react-dom": "^18.3.1"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@emotion/react": "^11.14.0",
|
|
51
|
+
"@emotion/styled": "^11.14.1",
|
|
52
|
+
"@isoflow/isopacks": "^0.0.10",
|
|
53
|
+
"@mui/icons-material": "^5.18.0",
|
|
54
|
+
"@mui/material": "^5.18.0",
|
|
55
|
+
"@xyflow/react": "^12.3.5",
|
|
56
|
+
"auto-bind": "^5.0.1",
|
|
57
|
+
"chroma-js": "^2.6.0",
|
|
58
|
+
"dom-to-image": "^2.6.0",
|
|
59
|
+
"file-saver": "^2.0.5",
|
|
60
|
+
"gsap": "^3.15.0",
|
|
61
|
+
"immer": "^10.2.0",
|
|
62
|
+
"mui-color-input": "^2.0.3",
|
|
63
|
+
"paper": "^0.12.18",
|
|
64
|
+
"pathfinding": "^0.4.18",
|
|
65
|
+
"react-hook-form": "^7.87.0",
|
|
66
|
+
"react-quill": "^2.0.0",
|
|
67
|
+
"react-router-dom": "^6.30.6",
|
|
68
|
+
"uuid": "^9.0.1",
|
|
69
|
+
"zod": "^3.25.76",
|
|
70
|
+
"zustand": "^4.3.3"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@types/react": "^18.3.12",
|
|
74
|
+
"@types/react-dom": "^18.3.1",
|
|
75
|
+
"@vitejs/plugin-react": "^4.3.3",
|
|
76
|
+
"concurrently": "^9.1.0",
|
|
77
|
+
"react": "^18.3.1",
|
|
78
|
+
"react-dom": "^18.3.1",
|
|
79
|
+
"typescript": "^5.6.3",
|
|
80
|
+
"vite": "^5.4.11"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* emit-screen.mjs — the engine as a brainstorm panel.
|
|
4
|
+
*
|
|
5
|
+
* Gavin's read, and it closes the loop: prism-viz-engine should be dogfooded by the
|
|
6
|
+
* brainstorm companion exactly the way Gavel and the workgraph panels are. The mechanism
|
|
7
|
+
* already exists and needed nothing new —
|
|
8
|
+
*
|
|
9
|
+
* server.cjs:76 CONTENT_DIR = <BRAINSTORM_DIR>/content (watched; a new file is a
|
|
10
|
+
* new screen)
|
|
11
|
+
* server.cjs:82 CHANNEL_PORT = 52342 (the same channel drive()
|
|
12
|
+
* already targets)
|
|
13
|
+
* isFullDocument() — a fragment gets wrapped in frame-template.html; a full document is
|
|
14
|
+
* served as-is. Both get helper.js and the channel meta injected.
|
|
15
|
+
*
|
|
16
|
+
* So a screen is a file. This writes one: a canvas, self-contained, that the companion
|
|
17
|
+
* serves and whose nodes can wake the agent through the channel it is already wired to.
|
|
18
|
+
*
|
|
19
|
+
* WHY A SCREEN AND NOT AN IFRAME OF THE APP. A brainstorm screen has to survive on its
|
|
20
|
+
* own — no vite, no sidecar, no dev server. So this emits a STATIC render of the canvas
|
|
21
|
+
* document rather than a pointer at a running one. The trade is real and stated: no
|
|
22
|
+
* reveal-to-source, because nothing is listening. Everything else — layout, lanes, layer
|
|
23
|
+
* colour, file:line on every card — travels.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* node emit-screen.mjs --in <canvas.json> [--title "..."] [--out <dir>]
|
|
27
|
+
* node emit-screen.mjs --self # the engine's own document
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"
|
|
31
|
+
import { join, resolve, basename } from "node:path"
|
|
32
|
+
|
|
33
|
+
const argv = process.argv.slice(2)
|
|
34
|
+
const flag = (n) => argv.includes(n)
|
|
35
|
+
const opt = (n) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null }
|
|
36
|
+
|
|
37
|
+
const PRISM_ROOT = resolve(process.env.PRISM_ROOT ?? join(import.meta.dirname, "..", "..", ".."))
|
|
38
|
+
/** server.cjs:76 — the companion watches <BRAINSTORM_DIR>/content. */
|
|
39
|
+
const BRAINSTORM_DIR = process.env.BRAINSTORM_DIR ?? "/tmp/prism-brainstorm"
|
|
40
|
+
const CONTENT_DIR = opt("--out") ?? join(BRAINSTORM_DIR, "content")
|
|
41
|
+
|
|
42
|
+
const LAYER_ROLES = [
|
|
43
|
+
"Djeli · container", "Collaboration · GenTeam", "Creation · build/content/3D",
|
|
44
|
+
"Capture", "Intelligence · Super Agent", "Governance · Governor",
|
|
45
|
+
"Model-making / data science", "Memory · foundation", "Deployment",
|
|
46
|
+
"Suite meta", "Cross-cutting rails",
|
|
47
|
+
]
|
|
48
|
+
const EMBER = {
|
|
49
|
+
"Djeli · container": "#e0a458", "Collaboration · GenTeam": "#9b8cf0",
|
|
50
|
+
"Creation · build/content/3D": "#f2915f", Capture: "#4fd0e0",
|
|
51
|
+
"Intelligence · Super Agent": "#e85d3a", "Governance · Governor": "#d4af37",
|
|
52
|
+
"Model-making / data science": "#e0a458", "Memory · foundation": "#7c7cf0",
|
|
53
|
+
Deployment: "#9a8c98", "Suite meta": "#e0a458", "Cross-cutting rails": "#d4af37",
|
|
54
|
+
unplaceable: "#6b7385",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const inPath = opt("--in")
|
|
58
|
+
let canvas
|
|
59
|
+
let title = opt("--title")
|
|
60
|
+
|
|
61
|
+
if (flag("--self")) {
|
|
62
|
+
const p = join(PRISM_ROOT, ".prism", "shared", "workgraph", "uxui-canvas-nodes.json")
|
|
63
|
+
if (!existsSync(p)) {
|
|
64
|
+
console.error(`emit-screen: --self needs ${p}. Run a harvest first; nothing is invented here.`)
|
|
65
|
+
process.exit(1)
|
|
66
|
+
}
|
|
67
|
+
const nodes = JSON.parse(readFileSync(p, "utf-8"))
|
|
68
|
+
canvas = {
|
|
69
|
+
// The emitter's own shape carries `layer` at top level and the rest under `data`;
|
|
70
|
+
// lifting it is what the lane layout keys on. Missing it silently collapsed every
|
|
71
|
+
// node into `unplaceable` and reported 1/11 layers — a wrong picture that still
|
|
72
|
+
// rendered, which is the failure mode this engine exists to catch.
|
|
73
|
+
nodes: nodes.map((n) => ({
|
|
74
|
+
...n,
|
|
75
|
+
x: n.position?.x ?? 0,
|
|
76
|
+
y: n.position?.y ?? 0,
|
|
77
|
+
width: 232,
|
|
78
|
+
height: 96,
|
|
79
|
+
griot: { ...n.data, layer: n.layer, ui: { origin: { ...n.data.origin, repo: n.data.provenance?.repo } } },
|
|
80
|
+
})),
|
|
81
|
+
edges: nodes.filter((n) => n.data?.parentId).map((n) => ({ id: `${n.data.parentId}->${n.id}`, fromNode: n.data.parentId, toNode: n.id })),
|
|
82
|
+
}
|
|
83
|
+
title ??= "Harvested components"
|
|
84
|
+
} else if (inPath) {
|
|
85
|
+
if (!existsSync(inPath)) { console.error(`emit-screen: --in not found: ${inPath}`); process.exit(1) }
|
|
86
|
+
canvas = JSON.parse(readFileSync(inPath, "utf-8"))
|
|
87
|
+
title ??= basename(inPath).replace(/\.(canvas\.)?json$/i, "")
|
|
88
|
+
} else {
|
|
89
|
+
console.error("Usage: node emit-screen.mjs --in <canvas.json> [--title ...] | --self")
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const nodes = (canvas.nodes ?? []).filter((n) => n.griot)
|
|
94
|
+
if (!nodes.length) { console.error("emit-screen: no Griot nodes to draw — refusing to emit an empty screen."); process.exit(1) }
|
|
95
|
+
|
|
96
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]))
|
|
97
|
+
const LANE_H = 168, RAIL = 210, CARD_W = 232, CARD_H = 96
|
|
98
|
+
|
|
99
|
+
// Lay out by lane, same rule as the canvas: the band a node sits in IS its layer role.
|
|
100
|
+
const laneIdx = (l) => Math.max(0, LAYER_ROLES.indexOf(l))
|
|
101
|
+
const packed = new Map()
|
|
102
|
+
const placed = nodes.map((n) => {
|
|
103
|
+
const layer = LAYER_ROLES.includes(n.griot.layer) ? n.griot.layer : "unplaceable"
|
|
104
|
+
const key = layer
|
|
105
|
+
const i = packed.get(key) ?? 0
|
|
106
|
+
packed.set(key, i + 1)
|
|
107
|
+
return { n, layer, x: RAIL + 24 + i * (CARD_W + 18), y: laneIdx(layer) * LANE_H + 26 }
|
|
108
|
+
})
|
|
109
|
+
const W = RAIL + 24 + Math.max(1, ...[...packed.values()]) * (CARD_W + 18) + 24
|
|
110
|
+
const H = LAYER_ROLES.length * LANE_H
|
|
111
|
+
|
|
112
|
+
const byId = new Map(placed.map((p) => [p.n.id, p]))
|
|
113
|
+
const edges = (canvas.edges ?? []).filter((e) => byId.has(e.fromNode) && byId.has(e.toNode))
|
|
114
|
+
|
|
115
|
+
const html = `<!doctype html>
|
|
116
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
117
|
+
<title>${esc(title)} · prism-viz-engine</title>
|
|
118
|
+
<style>
|
|
119
|
+
:root{--void:#0a0a0c;--panel:#101418;--panel2:#0d1116;--line:rgba(255,255,255,.08);
|
|
120
|
+
--line2:rgba(255,255,255,.14);--ink:#e8ecf2;--mute:#9aa3b2;--dim:#6b7385;--mint:#10ffba;
|
|
121
|
+
--mono:"JetBrains Mono","SF Mono",ui-monospace,Consolas,monospace;
|
|
122
|
+
--sans:Inter,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
123
|
+
*{box-sizing:border-box}body{margin:0;background:var(--void);color:var(--ink);font-family:var(--sans)}
|
|
124
|
+
.hd{padding:14px 18px;border-bottom:1px solid var(--line);background:var(--panel);
|
|
125
|
+
display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}
|
|
126
|
+
.hd h1{font-size:15px;margin:0;font-weight:650}
|
|
127
|
+
.hd .m{font-family:var(--mono);font-size:11px;color:var(--mute)}
|
|
128
|
+
.wrap{overflow:auto}
|
|
129
|
+
.stage{position:relative;width:${W}px;height:${H}px;
|
|
130
|
+
background-image:radial-gradient(#1a1f26 1px,transparent 1px);background-size:22px 22px}
|
|
131
|
+
.lane{position:absolute;left:0;width:${W}px;border-bottom:1px dashed var(--line)}
|
|
132
|
+
.lane-hd{position:absolute;left:0;top:0;bottom:0;width:${RAIL}px;padding:10px 12px;
|
|
133
|
+
display:flex;flex-direction:column;justify-content:center;gap:3px;
|
|
134
|
+
border-right:1px solid var(--line);background:var(--panel2)}
|
|
135
|
+
.lane-nm{font-size:11.5px;font-weight:600;line-height:1.25}
|
|
136
|
+
.lane-nm::before{content:"";display:inline-block;width:3px;height:11px;border-radius:2px;
|
|
137
|
+
background:var(--e);margin-right:7px;vertical-align:-1px}
|
|
138
|
+
.nd{position:absolute;width:${CARD_W}px;border:1px solid var(--line2);border-left:3px solid var(--e);
|
|
139
|
+
background:var(--panel);border-radius:9px;padding:8px 10px;display:flex;flex-direction:column;gap:3px;
|
|
140
|
+
box-shadow:0 2px 6px rgba(0,0,0,.35)}
|
|
141
|
+
.rp{font-family:var(--mono);font-size:8.5px;text-transform:uppercase;letter-spacing:.06em;
|
|
142
|
+
color:var(--mute);font-weight:700}
|
|
143
|
+
.ty{font-family:var(--mono);font-size:8px;text-transform:uppercase;color:var(--dim);
|
|
144
|
+
border:1px solid var(--line);border-radius:3px;padding:0 4px}
|
|
145
|
+
.lb{font-size:12px;font-weight:560;line-height:1.3}
|
|
146
|
+
.sr{font-family:var(--mono);font-size:9px;color:var(--dim);overflow:hidden;
|
|
147
|
+
text-overflow:ellipsis;white-space:nowrap}
|
|
148
|
+
.lic{font-family:var(--mono);font-size:9px;color:#f59e0b}
|
|
149
|
+
svg.ed{position:absolute;inset:0;width:${W}px;height:${H}px;pointer-events:none}
|
|
150
|
+
svg.ed path{fill:none;stroke:var(--dim);stroke-width:1.5;opacity:.55}
|
|
151
|
+
.ft{padding:10px 18px;border-top:1px solid var(--line);font-family:var(--mono);
|
|
152
|
+
font-size:10.5px;color:var(--dim);display:flex;gap:20px;flex-wrap:wrap}
|
|
153
|
+
</style></head><body>
|
|
154
|
+
<div class="hd"><h1>${esc(title)}</h1>
|
|
155
|
+
<span class="m">${placed.length} nodes · ${edges.length} edges · ${packed.size}/11 layers</span>
|
|
156
|
+
<span class="m" style="color:var(--mint)">prism-viz-engine · static screen</span></div>
|
|
157
|
+
<div class="wrap"><div class="stage">
|
|
158
|
+
${LAYER_ROLES.map((r, i) => `<div class="lane" style="top:${i * LANE_H}px;height:${LANE_H}px;--e:${EMBER[r]}">
|
|
159
|
+
<div class="lane-hd"><span class="lane-nm">${esc(r)}</span></div></div>`).join("")}
|
|
160
|
+
<svg class="ed">${edges.map((e) => {
|
|
161
|
+
const a = byId.get(e.fromNode), b = byId.get(e.toNode)
|
|
162
|
+
const x1 = a.x + CARD_W / 2, y1 = a.y + CARD_H, x2 = b.x + CARD_W / 2, y2 = b.y, m = (y1 + y2) / 2
|
|
163
|
+
return `<path d="M${x1},${y1} C${x1},${m} ${x2},${m} ${x2},${y2}"/>`
|
|
164
|
+
}).join("")}</svg>
|
|
165
|
+
${placed.map((p) => {
|
|
166
|
+
const g = p.n.griot, o = g.ui?.origin ?? g.origin ?? {}
|
|
167
|
+
const repo = o.repo ?? g.provenance?.repo ?? ""
|
|
168
|
+
return `<div class="nd" style="left:${p.x}px;top:${p.y}px;--e:${EMBER[p.layer]}">
|
|
169
|
+
<div style="display:flex;align-items:center;gap:6px">
|
|
170
|
+
${repo ? `<span class="rp">${esc(repo)}</span>` : ""}
|
|
171
|
+
<span class="ty">${esc(p.n.type ?? g.walkLevel ?? "node")}</span></div>
|
|
172
|
+
<div class="lb">${esc(p.n.label ?? p.n.id)}</div>
|
|
173
|
+
${o.file ? `<div class="sr">${esc(o.file)}:${esc(o.line ?? "")}</div>` : ""}
|
|
174
|
+
${g.code?.licence || g.licence ? `<span class="lic">${esc(g.code?.licence ?? g.licence)}</span>` : ""}
|
|
175
|
+
</div>`
|
|
176
|
+
}).join("")}
|
|
177
|
+
</div></div>
|
|
178
|
+
<div class="ft">
|
|
179
|
+
<span>static screen — reveal-to-source needs the sidecar, which is not listening here</span>
|
|
180
|
+
<span>every card carries the file:line it was read from</span>
|
|
181
|
+
</div>
|
|
182
|
+
</body></html>`
|
|
183
|
+
|
|
184
|
+
mkdirSync(CONTENT_DIR, { recursive: true })
|
|
185
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
|
186
|
+
const out = join(CONTENT_DIR, `viz-${slug}.html`)
|
|
187
|
+
writeFileSync(out, html, "utf-8")
|
|
188
|
+
|
|
189
|
+
console.log(`emit-screen: ${placed.length} nodes · ${edges.length} edges · ${packed.size}/11 layers`)
|
|
190
|
+
console.log(` wrote ${out}`)
|
|
191
|
+
console.log(` The brainstorm companion watches this directory — it will appear as a screen.`)
|
|
192
|
+
console.log(` Channel :52342 is the same one drive() targets, so panel and engine share it.`)
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Canvas — the neutral wire between layer 01 (generate) and layer 02 (canvas).
|
|
3
|
+
*
|
|
4
|
+
* The shelf card for `obsidianmd/jsoncanvas` states its own job and this file just
|
|
5
|
+
* honours it: "An interchange FORMAT, not a renderer: the neutral wire between the viz
|
|
6
|
+
* engine's generators (layer 01) and its canvases (layer 02)." Spec v1.0 — nodes
|
|
7
|
+
* (text/file/link/group) + edges, z-order by array position.
|
|
8
|
+
*
|
|
9
|
+
* WHY A WIRE AND NOT A DIRECT COUPLING. Layer 01 has four generators (archify, Diagram
|
|
10
|
+
* Design, Lanshu, visual-explainer) and layer 02 has three canvases (xyflow,
|
|
11
|
+
* react-force-graph, Excalidraw). Wiring 4x3 directly is twelve adapters that rot.
|
|
12
|
+
* Wiring both sides to one format is seven.
|
|
13
|
+
*
|
|
14
|
+
* CORRECTION (2026-09-11, harvest of cclank/lanshu-animated-architecture-diagram).
|
|
15
|
+
* An earlier revision of this comment claimed Lanshu "already emits .excalidraw, which
|
|
16
|
+
* is the same move one step down the stack." That was repeating a shelf blurb, and the
|
|
17
|
+
* harvest killed it: Lanshu has NO node/edge graph. Its spec is a content-slot fill-in
|
|
18
|
+
* form for one hardcoded picture -- every coordinate is a source literal in
|
|
19
|
+
* render_static() (scripts/render_animated_diagram.py:456-554), there is no id/x/y/w/h,
|
|
20
|
+
* and arrows carry startBinding/endBinding = None (:263-264), so 0 of 14 arrows are
|
|
21
|
+
* bound to anything. It serialises ink, not a model, and therefore does not demonstrate
|
|
22
|
+
* this seam at all.
|
|
23
|
+
*
|
|
24
|
+
* What IS liftable from it is narrower and real: the `Excal` writer class
|
|
25
|
+
* (:176-280, 105 lines) -- the element-envelope + id/seed/version discipline an
|
|
26
|
+
* .excalidraw consumer requires. That belongs in a layer-02 Excalidraw exporter, not
|
|
27
|
+
* here. Full grounding: .prism/shared/research/2026-09-11-lanshu.md
|
|
28
|
+
*
|
|
29
|
+
* The Griot additions live under `griot` on each node, never in the spec fields, so a
|
|
30
|
+
* plain JSON Canvas reader (Obsidian, any spec-compliant tool) still opens our files.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { type LayerSlot, isLayerSlot, UNPLACEABLE, ALL_SLOTS } from "./layer-roles"
|
|
34
|
+
|
|
35
|
+
// ── the spec (v1.0) ────────────────────────────────────────────────────────────
|
|
36
|
+
export type CanvasNodeType = "text" | "file" | "link" | "group"
|
|
37
|
+
|
|
38
|
+
export interface CanvasNodeBase {
|
|
39
|
+
id: string
|
|
40
|
+
type: CanvasNodeType
|
|
41
|
+
x: number
|
|
42
|
+
y: number
|
|
43
|
+
width: number
|
|
44
|
+
height: number
|
|
45
|
+
color?: string
|
|
46
|
+
/** Griot extension — ignored by spec-compliant readers, load-bearing for us. */
|
|
47
|
+
griot?: GriotNodeMeta
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CanvasTextNode extends CanvasNodeBase {
|
|
51
|
+
type: "text"
|
|
52
|
+
text: string
|
|
53
|
+
}
|
|
54
|
+
export interface CanvasFileNode extends CanvasNodeBase {
|
|
55
|
+
type: "file"
|
|
56
|
+
file: string
|
|
57
|
+
subpath?: string
|
|
58
|
+
}
|
|
59
|
+
export interface CanvasLinkNode extends CanvasNodeBase {
|
|
60
|
+
type: "link"
|
|
61
|
+
url: string
|
|
62
|
+
}
|
|
63
|
+
export interface CanvasGroupNode extends CanvasNodeBase {
|
|
64
|
+
type: "group"
|
|
65
|
+
label?: string
|
|
66
|
+
background?: string
|
|
67
|
+
backgroundStyle?: "cover" | "ratio" | "repeat"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type CanvasNode = CanvasTextNode | CanvasFileNode | CanvasLinkNode | CanvasGroupNode
|
|
71
|
+
|
|
72
|
+
export type CanvasSide = "top" | "right" | "bottom" | "left"
|
|
73
|
+
export interface CanvasEdge {
|
|
74
|
+
id: string
|
|
75
|
+
fromNode: string
|
|
76
|
+
fromSide?: CanvasSide
|
|
77
|
+
toNode: string
|
|
78
|
+
toSide?: CanvasSide
|
|
79
|
+
color?: string
|
|
80
|
+
label?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface JSONCanvas {
|
|
84
|
+
nodes: CanvasNode[]
|
|
85
|
+
edges: CanvasEdge[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── the Griot extension ────────────────────────────────────────────────────────
|
|
89
|
+
/**
|
|
90
|
+
* Both harvest modes land here, on ONE node. That is the whole point of the two-mode
|
|
91
|
+
* split: `griot-harvest` supplies the code half (what it does, features, fit verdict,
|
|
92
|
+
* licence) and `griot-harvest-ux-ui` supplies the UI half (screen, flow, mount point).
|
|
93
|
+
* UX is functionality — a decision about the future of the tooling needs both, fused,
|
|
94
|
+
* on the same object.
|
|
95
|
+
*/
|
|
96
|
+
export interface GriotNodeMeta {
|
|
97
|
+
/** One of the eleven verbatim roles, or `unplaceable`. Never a twelfth. */
|
|
98
|
+
layer: LayerSlot
|
|
99
|
+
/** component | screen | flow | workflow — the UI-walk rung this finding reached. */
|
|
100
|
+
walkLevel?: "component" | "screen" | "flow" | "workflow"
|
|
101
|
+
|
|
102
|
+
/** ── the UI half (griot-harvest-ux-ui) ───────────────────────────────── */
|
|
103
|
+
ui?: {
|
|
104
|
+
/** The evidence. A node without file:line is rejected, never defaulted. */
|
|
105
|
+
origin: { repo: string; file: string; line: number }
|
|
106
|
+
/** Render path from app root to this node. */
|
|
107
|
+
mountPoint: string
|
|
108
|
+
/** UX antipatterns — what NOT to copy, first-class output. */
|
|
109
|
+
notCopy?: string[]
|
|
110
|
+
/** Captured render of the real component, if one exists. Never a mock. */
|
|
111
|
+
preview?: { kind: "screenshot" | "iframe" | "none"; src?: string }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** ── the code half (griot-harvest) ───────────────────────────────────── */
|
|
115
|
+
code?: {
|
|
116
|
+
/** What it does, in the tool's own terms. */
|
|
117
|
+
capability?: string
|
|
118
|
+
features?: string[]
|
|
119
|
+
/** griot-harvest's fit verdict against a Griot app. */
|
|
120
|
+
fit?: { app: string; strength: 1 | 2 | 3; why: string }
|
|
121
|
+
/** A FACT for a field, never a verdict. `spdx:<id>` | "none declared". */
|
|
122
|
+
licence: string
|
|
123
|
+
/** adopt | trial | defer | pass — mirrors the DGS decision store. */
|
|
124
|
+
decision?: "adopt" | "trial" | "defer" | "pass" | "undecided"
|
|
125
|
+
/** scaffold | component | pattern. */
|
|
126
|
+
role?: "scaffold" | "component" | "pattern"
|
|
127
|
+
stage?: "now" | "next" | "later"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
provenance: {
|
|
131
|
+
harvestedBy: string
|
|
132
|
+
harvestedAt: string
|
|
133
|
+
sourceCommit?: string | null
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── construction + validation ──────────────────────────────────────────────────
|
|
138
|
+
export const emptyCanvas = (): JSONCanvas => ({ nodes: [], edges: [] })
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* GRAFTED from archify's validator (`renderers/shared/validator.mjs`, spdx:MIT), harvested
|
|
142
|
+
* 2026-09-11. Its own comment names the reason this shape exists:
|
|
143
|
+
*
|
|
144
|
+
* validator.mjs:4-5 — "'/nodes/3/label' reads much better as
|
|
145
|
+
* '/nodes/3 (id: \"router\") /label' FOR THE LLM FIXING THE JSON; resolve the nearest
|
|
146
|
+
* enclosing element's id or label."
|
|
147
|
+
*
|
|
148
|
+
* That is the insight worth taking. A gate whose output an agent can act on directly
|
|
149
|
+
* closes the loop: generator emits -> gate rejects -> the rejection IS the repair
|
|
150
|
+
* instruction -> generator re-emits. A bare "invalid at nodes[3]" makes the agent guess.
|
|
151
|
+
*
|
|
152
|
+
* Two pieces lifted, both reimplemented against OUR schema rather than copied:
|
|
153
|
+
* - `identity` — the nearest enclosing id/label, so a path names a thing not an index
|
|
154
|
+
* (validator.mjs:6-19, `annotatedPath`)
|
|
155
|
+
* - `fixes` — imperative repair strings, one per failure kind
|
|
156
|
+
* (validator.mjs:56-68, `supportedFixes`)
|
|
157
|
+
*
|
|
158
|
+
* What we did NOT take: archify drives ajv with five JSON Schema documents and a 421 KB
|
|
159
|
+
* standalone-compiled `generated-validators.mjs`. Our node shape is small and fixed, so
|
|
160
|
+
* a schema compiler would be weight without benefit. The diagnostic ergonomics are the
|
|
161
|
+
* pattern; the validation engine is not.
|
|
162
|
+
*/
|
|
163
|
+
export interface Violation {
|
|
164
|
+
/** Where it failed — a JSON-pointer-ish path into the canvas. */
|
|
165
|
+
where: string
|
|
166
|
+
/** What is wrong. */
|
|
167
|
+
problem: string
|
|
168
|
+
/** The nearest enclosing `id` or `label`, so the path names a thing, not an index. */
|
|
169
|
+
identity?: string | null
|
|
170
|
+
/** Imperative repair instructions an agent can act on without guessing. */
|
|
171
|
+
fixes?: string[]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Render violations as the repair brief an agent should be handed. */
|
|
175
|
+
export function explainViolations(violations: Violation[]): string {
|
|
176
|
+
if (!violations.length) return "valid"
|
|
177
|
+
return violations
|
|
178
|
+
.map((v) => {
|
|
179
|
+
const who = v.identity != null ? ` (id/label: ${JSON.stringify(v.identity)})` : ""
|
|
180
|
+
const how = v.fixes?.length ? `\n fix: ${v.fixes.join(" | ")}` : ""
|
|
181
|
+
return ` - ${v.where}${who}: ${v.problem}${how}`
|
|
182
|
+
})
|
|
183
|
+
.join("\n")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Gate, don't coerce. Same posture as `emit-canvas-nodes.mjs`: collect every violation,
|
|
188
|
+
* write nothing on failure. A silently-defaulted `line: 0` is worse than a rejection —
|
|
189
|
+
* it looks like evidence and isn't.
|
|
190
|
+
*/
|
|
191
|
+
export function validate(canvas: JSONCanvas): Violation[] {
|
|
192
|
+
const v: Violation[] = []
|
|
193
|
+
const seen = new Set<string>()
|
|
194
|
+
/** The archify move: name the thing, not the index. */
|
|
195
|
+
const idOf = (o: any): string | null => o?.id ?? o?.label ?? null
|
|
196
|
+
|
|
197
|
+
for (const [i, n] of canvas.nodes.entries()) {
|
|
198
|
+
const where = `/nodes/${i}`
|
|
199
|
+
const identity = idOf(n)
|
|
200
|
+
const bad = (problem: string, fixes: string[] = []) => v.push({ where, identity, problem, fixes })
|
|
201
|
+
|
|
202
|
+
if (!n || typeof n !== "object") {
|
|
203
|
+
bad("not an object", ["replace with a node object per canvas-node-schema.md"])
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (!n.id) bad('missing "id"', ['add a stable kebab-case "id" unique within this canvas'])
|
|
207
|
+
else if (seen.has(n.id))
|
|
208
|
+
bad(`duplicate id ${JSON.stringify(n.id)}`, [
|
|
209
|
+
"give one of the two a distinct id — merge is keyed on id, so a duplicate silently overwrites",
|
|
210
|
+
])
|
|
211
|
+
else seen.add(n.id)
|
|
212
|
+
|
|
213
|
+
for (const k of ["x", "y", "width", "height"] as const) {
|
|
214
|
+
if (typeof n[k] !== "number")
|
|
215
|
+
bad(`"${k}" must be a number, got ${JSON.stringify((n as any)[k])}`, [
|
|
216
|
+
`set "${k}" to a number`,
|
|
217
|
+
])
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const g = n.griot
|
|
221
|
+
if (!g) continue // a plain spec node is legal; only Griot nodes carry our gates
|
|
222
|
+
|
|
223
|
+
if (!isLayerSlot(g.layer))
|
|
224
|
+
bad(
|
|
225
|
+
`"griot.layer" must be one of the eleven verbatim roles or "${UNPLACEABLE}", got ${JSON.stringify(g.layer)}`,
|
|
226
|
+
[`choose one of ${JSON.stringify([...ALL_SLOTS])}`, "copy the string byte-verbatim, middle dots included"]
|
|
227
|
+
)
|
|
228
|
+
if (!g.provenance?.harvestedBy)
|
|
229
|
+
bad('missing "griot.provenance.harvestedBy"', [
|
|
230
|
+
'set "griot.provenance.harvestedBy" to the skill or agent that produced this node',
|
|
231
|
+
])
|
|
232
|
+
|
|
233
|
+
if (g.ui) {
|
|
234
|
+
if (!g.ui.origin?.file)
|
|
235
|
+
bad('missing "griot.ui.origin.file"', ['set "griot.ui.origin.file" to a repo-relative path'])
|
|
236
|
+
if (typeof g.ui.origin?.line !== "number")
|
|
237
|
+
bad('missing "griot.ui.origin.line" — every finding needs file:line', [
|
|
238
|
+
'set "griot.ui.origin.line" to the line the claim was read from — do not default it to 0',
|
|
239
|
+
])
|
|
240
|
+
if (!g.ui.mountPoint)
|
|
241
|
+
bad('missing "griot.ui.mountPoint"', [
|
|
242
|
+
'set "griot.ui.mountPoint" to the render path from app root to this node',
|
|
243
|
+
])
|
|
244
|
+
}
|
|
245
|
+
if (g.code && typeof g.code.licence !== "string")
|
|
246
|
+
bad('missing "griot.code.licence"', [
|
|
247
|
+
'set "griot.code.licence" to "spdx:<id>" or "none declared" — a fact for a field, never omitted, never a verdict',
|
|
248
|
+
])
|
|
249
|
+
if (!g.ui && !g.code)
|
|
250
|
+
bad("a Griot node carries neither harvest half — nothing to place", [
|
|
251
|
+
'add "griot.ui" (the UX/UI walk) or "griot.code" (the harvest) — a node needs at least one',
|
|
252
|
+
])
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ids = new Set(canvas.nodes.map((n) => n.id))
|
|
256
|
+
for (const [i, e] of canvas.edges.entries()) {
|
|
257
|
+
const where = `/edges/${i}`
|
|
258
|
+
const identity = idOf(e)
|
|
259
|
+
const bad = (problem: string, fixes: string[] = []) => v.push({ where, identity, problem, fixes })
|
|
260
|
+
|
|
261
|
+
if (!e.id) bad('missing "id"', ['add an "id" — merge is keyed on it'])
|
|
262
|
+
if (!ids.has(e.fromNode))
|
|
263
|
+
bad(`fromNode ${JSON.stringify(e.fromNode)} is not a node in this canvas`, [
|
|
264
|
+
"point fromNode at an existing node id, or add the missing node",
|
|
265
|
+
])
|
|
266
|
+
if (!ids.has(e.toNode))
|
|
267
|
+
bad(`toNode ${JSON.stringify(e.toNode)} is not a node in this canvas`, [
|
|
268
|
+
"point toNode at an existing node id, or add the missing node",
|
|
269
|
+
])
|
|
270
|
+
for (const k of ["fromSide", "toSide"] as const) {
|
|
271
|
+
const s = e[k]
|
|
272
|
+
if (s !== undefined && !["top", "right", "bottom", "left"].includes(s))
|
|
273
|
+
bad(`"${k}" must be a side, got ${JSON.stringify(s)}`, [
|
|
274
|
+
'choose one of ["top","right","bottom","left"]',
|
|
275
|
+
])
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return v
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function assertValid(canvas: JSONCanvas): JSONCanvas {
|
|
282
|
+
const v = validate(canvas)
|
|
283
|
+
if (v.length) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`json-canvas: ${v.length} violation(s), nothing written.\n` +
|
|
286
|
+
v.map((x) => ` - ${x.where}: ${x.problem}`).join("\n")
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
return canvas
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Merge by id — re-running a walk updates in place, never duplicates. */
|
|
293
|
+
export function merge(base: JSONCanvas, incoming: JSONCanvas): JSONCanvas {
|
|
294
|
+
const nodes = new Map(base.nodes.map((n) => [n.id, n]))
|
|
295
|
+
for (const n of incoming.nodes) nodes.set(n.id, n)
|
|
296
|
+
const edges = new Map(base.edges.map((e) => [e.id, e]))
|
|
297
|
+
for (const e of incoming.edges) edges.set(e.id, e)
|
|
298
|
+
return { nodes: [...nodes.values()], edges: [...edges.values()] }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const serialize = (c: JSONCanvas) => JSON.stringify(c, null, 2) + "\n"
|