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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layer roles — the output taxonomy for everything this engine places.
|
|
3
|
+
*
|
|
4
|
+
* SOURCE OF TRUTH: the `LNAME` array in griot-ontology-codex.html (The Griot Stack,
|
|
5
|
+
* artifact c389ca6c). Copied byte-verbatim, middle dots and all. Do not retype these
|
|
6
|
+
* by hand and do not "tidy" the punctuation — the emitter, the canvas, and the plan
|
|
7
|
+
* all key on exact string equality.
|
|
8
|
+
*
|
|
9
|
+
* ELEVEN, NOT NINE. The djeli-uxui-harvest stage contract locked nine and
|
|
10
|
+
* `emit-canvas-nodes.mjs` gated on nine, which meant `Suite meta` (Griot Ontology,
|
|
11
|
+
* Client work) and `Cross-cutting rails` (Meridian, Griotwave, Prism) were rejected by
|
|
12
|
+
* our own validator — exactly the tooling that has to sit on this canvas. Corrected
|
|
13
|
+
* against LNAME on Gavin's instruction, 2026-09-11.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const LAYER_ROLES = [
|
|
17
|
+
"Djeli · container",
|
|
18
|
+
"Collaboration · GenTeam",
|
|
19
|
+
"Creation · build/content/3D",
|
|
20
|
+
"Capture",
|
|
21
|
+
"Intelligence · Super Agent",
|
|
22
|
+
"Governance · Governor",
|
|
23
|
+
"Model-making / data science",
|
|
24
|
+
"Memory · foundation",
|
|
25
|
+
"Deployment",
|
|
26
|
+
"Suite meta",
|
|
27
|
+
"Cross-cutting rails",
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
export type LayerRole = (typeof LAYER_ROLES)[number]
|
|
31
|
+
|
|
32
|
+
/** A finding that fits no role is flagged, never force-fit into a tenth. */
|
|
33
|
+
export const UNPLACEABLE = "unplaceable" as const
|
|
34
|
+
export type LayerSlot = LayerRole | typeof UNPLACEABLE
|
|
35
|
+
|
|
36
|
+
export const ALL_SLOTS: LayerSlot[] = [...LAYER_ROLES, UNPLACEABLE]
|
|
37
|
+
|
|
38
|
+
export function isLayerRole(v: unknown): v is LayerRole {
|
|
39
|
+
return typeof v === "string" && (LAYER_ROLES as readonly string[]).includes(v)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isLayerSlot(v: unknown): v is LayerSlot {
|
|
43
|
+
return isLayerRole(v) || v === UNPLACEABLE
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Per-role ember. Sourced from the ontology codex node colours (the `A` map's `c`
|
|
48
|
+
* field) rather than invented: each role takes the ember of the app that heads it.
|
|
49
|
+
* `Suite meta` and `Cross-cutting rails` take the Griot Ontology / Griotwave embers.
|
|
50
|
+
*/
|
|
51
|
+
export const ROLE_EMBER: Record<LayerSlot, string> = {
|
|
52
|
+
"Djeli · container": "#e0a458",
|
|
53
|
+
"Collaboration · GenTeam": "#9b8cf0",
|
|
54
|
+
"Creation · build/content/3D": "#f2915f",
|
|
55
|
+
Capture: "#4fd0e0",
|
|
56
|
+
"Intelligence · Super Agent": "#e85d3a",
|
|
57
|
+
"Governance · Governor": "#d4af37",
|
|
58
|
+
"Model-making / data science": "#e0a458",
|
|
59
|
+
"Memory · foundation": "#7c7cf0",
|
|
60
|
+
Deployment: "#9a8c98",
|
|
61
|
+
"Suite meta": "#e0a458",
|
|
62
|
+
"Cross-cutting rails": "#d4af37",
|
|
63
|
+
unplaceable: "#6b7385",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The Genspark equivalence, from the ontology codex's stack section. Kept because it
|
|
68
|
+
* is how Gavin reads the layering ("Collaboration = GenTeam, Intelligence = Super
|
|
69
|
+
* Agent, Memory = SecondBrain; Governance is the layer that is new").
|
|
70
|
+
*/
|
|
71
|
+
export const ROLE_EQUIV: Partial<Record<LayerSlot, string>> = {
|
|
72
|
+
"Djeli · container": "the frame",
|
|
73
|
+
"Collaboration · GenTeam": "= GenTeam",
|
|
74
|
+
"Intelligence · Super Agent": "= Super Agent",
|
|
75
|
+
"Memory · foundation": "= SecondBrain",
|
|
76
|
+
"Governance · Governor": "new",
|
|
77
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The motion layer — one clock for the whole engine.
|
|
3
|
+
*
|
|
4
|
+
* The point of prism-viz-engine is not four tools side by side; it is their motion
|
|
5
|
+
* design and methodologies fused into one coherent thing. Each of the three sources
|
|
6
|
+
* owns a different layer, which is why they compose instead of collide:
|
|
7
|
+
*
|
|
8
|
+
* diagram-design THE LAW references/animation.md — four modes, a token clock,
|
|
9
|
+
* eight semantic primitives, a static-first contract,
|
|
10
|
+
* reduced-motion and print discipline, anti-patterns.
|
|
11
|
+
* The most rigorous motion spec in the cluster by far.
|
|
12
|
+
* FossFLOW THE CAMERA GSAP 3.11.4. SceneLayer.tsx:32-37 tweens translate +
|
|
13
|
+
* scale at 0.25s; Grid.tsx:32-36 tweens the tile
|
|
14
|
+
* background in step so the floor moves with the camera.
|
|
15
|
+
* Lanshu THE TOKENS render_animated_diagram.py:599-647 — glow dots,
|
|
16
|
+
* pulse_rect phase maths, sequential module activation.
|
|
17
|
+
* archify THE RUNTIME template.html:4130-4181 Reading Depth; :4294-4655 the
|
|
18
|
+
* interaction inventory (focus, lens, reach, route probe,
|
|
19
|
+
* relationship pin); :938-1000 the Motion Governor.
|
|
20
|
+
* Added as the fourth seat 2026-09-11 on Gavin's read.
|
|
21
|
+
*
|
|
22
|
+
* ── THE DELIVERY RULING, 2026-09-11 ────────────────────────────────────────────
|
|
23
|
+
* Lanshu ships its motion as a 41-frame, 6-8MB GIF (`optimize=False`, :662). That is a
|
|
24
|
+
* DELIVERY LIMITATION, not a design decision — the renderer is Python/PIL with no
|
|
25
|
+
* runtime, so the only way to show travelling light was to bake it.
|
|
26
|
+
*
|
|
27
|
+
* The vocabulary is the asset; the baking is the loss. A GIF cannot be focused, cannot
|
|
28
|
+
* be probed, cannot honour `prefers-reduced-motion`, and cannot let a reader click into
|
|
29
|
+
* a region to ask what it is. archify already solved exactly that — and independently
|
|
30
|
+
* arrived at Lanshu's own thesis: Lanshu's 300ms pulse cursor walking Input → Scan →
|
|
31
|
+
* Import → Index → Decision → Archive → Pack IS archify's guided-view chapter rail
|
|
32
|
+
* (:5092-5129). Same idea, one baked and one live.
|
|
33
|
+
*
|
|
34
|
+
* RULED: Lanshu's motion vocabulary runs REALTIME under archify's Motion Governor, never
|
|
35
|
+
* pre-rendered. The artefact export survives as an OUTPUT (archify's WebM path, capped
|
|
36
|
+
* at 1280 and never upscaled) rather than as the medium. What the reader gets is the
|
|
37
|
+
* instrument; what they can hand someone else is the recording.
|
|
38
|
+
*
|
|
39
|
+
* This also settles harvest item 10 — "one engine needs an explicit rule for when a
|
|
40
|
+
* plate is ANIMATED versus STATIC." It is not a property of the engine, it is a property
|
|
41
|
+
* of the MODE: `loop` is an explainer artefact and may breathe; `none`/`reveal`/`step`
|
|
42
|
+
* are documents and may not. The Governor is the mechanism that enforces it, and print,
|
|
43
|
+
* embed and reduced-motion all collapse to the document case automatically.
|
|
44
|
+
*
|
|
45
|
+
* ── THE CONFLICT, RULED 2-1 ─────────────────────────────────────────────────────
|
|
46
|
+
* Merging these silently would be dishonest. TWO independent sources forbid the thing
|
|
47
|
+
* the third is built on:
|
|
48
|
+
*
|
|
49
|
+
* diagram-design animation.md:46 "Avoid zoom, parallax, bounce, shake, GLOW,
|
|
50
|
+
* particles, and indefinite blinking."
|
|
51
|
+
* visual-explainer SKILL.md:109 bans continuous glow/pulse/breathing on static
|
|
52
|
+
* content. Reached independently — the two repos
|
|
53
|
+
* share no lineage.
|
|
54
|
+
* Lanshu :557-596 bloom on six container edges (alpha 70, radius 18,
|
|
55
|
+
* GaussianBlur 4); :599 draw_glow_dot;
|
|
56
|
+
* :606 pulse_rect. This IS its entire value.
|
|
57
|
+
*
|
|
58
|
+
* RULING (2-1, and recorded as 2-1 rather than laundered into consensus): the law wins
|
|
59
|
+
* on SEMANTICS, Lanshu's primitives survive as DECORATION under it. A glow or pulse may
|
|
60
|
+
* never encode meaning, is always `aria-hidden`, only runs in `loop` mode at a >=3s
|
|
61
|
+
* cycle, is the first thing dropped under `prefers-reduced-motion`, and never appears in
|
|
62
|
+
* an export.
|
|
63
|
+
*
|
|
64
|
+
* What makes that defensible rather than a fudge is Lanshu's own thesis, which the
|
|
65
|
+
* design harvest surfaced: "the pulse order teaches the reading order while the plate
|
|
66
|
+
* itself never changes — only light is added." One region lit at a time, advancing every
|
|
67
|
+
* 6 frames (300ms) over a 41-frame / 20fps / 2.05s loop. That is motion explaining a
|
|
68
|
+
* complete static figure — which is diagram-design's own first principle, arriving from
|
|
69
|
+
* the opposite direction. The two sides are closer than the prohibition suggests; what
|
|
70
|
+
* is actually banned is glow that CARRIES meaning, and Lanshu's never does.
|
|
71
|
+
*
|
|
72
|
+
* The camera is exempt from that argument entirely: pan/zoom is chrome, not figure, so
|
|
73
|
+
* FossFLOW's 0.25s tween sits outside the semantic budget.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
// ── the clock — diagram-design animation.md:49-55, adopted verbatim ─────────────
|
|
77
|
+
export const MOTION = {
|
|
78
|
+
/** micro-feedback: hover, focus, selection */
|
|
79
|
+
fast: 160,
|
|
80
|
+
/** one semantic step entering */
|
|
81
|
+
step: 480,
|
|
82
|
+
/** how long a completed step is held before the next */
|
|
83
|
+
hold: 720,
|
|
84
|
+
/** hard ceiling on a whole autoplay run — animation.md:105 */
|
|
85
|
+
maxTotal: 8000,
|
|
86
|
+
/** animation.md:54 */
|
|
87
|
+
ease: "cubic-bezier(.2,.8,.2,1)",
|
|
88
|
+
/**
|
|
89
|
+
* FossFLOW's camera tween — SceneLayer.tsx:33. Seconds there, ms here.
|
|
90
|
+
* Deliberately NOT one of the semantic tokens above: the camera is chrome.
|
|
91
|
+
*/
|
|
92
|
+
camera: 250,
|
|
93
|
+
} as const
|
|
94
|
+
|
|
95
|
+
/** animation.md:7 — exactly one mode per figure. */
|
|
96
|
+
export type MotionMode = "none" | "reveal" | "step" | "loop"
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* animation.md:105 — motion does not raise the static budget. Enforced, not documented:
|
|
100
|
+
* a generator that exceeds these gets rejected the same way bad routing does.
|
|
101
|
+
*/
|
|
102
|
+
export const MOTION_BUDGET = {
|
|
103
|
+
maxSteps: 8,
|
|
104
|
+
targetSteps: [3, 6] as const,
|
|
105
|
+
maxItems: 12,
|
|
106
|
+
maxSimultaneous: 2,
|
|
107
|
+
maxDrawnPaths: 2,
|
|
108
|
+
maxFlowTokens: 1,
|
|
109
|
+
minLoopCycle: 3000,
|
|
110
|
+
translateMax: 24,
|
|
111
|
+
} as const
|
|
112
|
+
|
|
113
|
+
export interface MotionPlan {
|
|
114
|
+
mode: MotionMode
|
|
115
|
+
/** integer steps 1..8, DOM order follows narrative order — animation.md:26 */
|
|
116
|
+
stepCount: number
|
|
117
|
+
/** total autoplay duration; derived, never guessed — animation.md:107 */
|
|
118
|
+
totalMs: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* animation.md:107 — "Set `--motion-total` to step count x `--motion-hold` and keep it
|
|
123
|
+
* within the 8-second budget." Derived here so no caller invents a duration.
|
|
124
|
+
*/
|
|
125
|
+
export function planMotion(mode: MotionMode, stepCount: number): MotionPlan {
|
|
126
|
+
const steps = Math.max(0, Math.min(MOTION_BUDGET.maxSteps, Math.floor(stepCount)))
|
|
127
|
+
const total = mode === "none" ? 0 : Math.min(steps * MOTION.hold, MOTION.maxTotal)
|
|
128
|
+
return { mode, stepCount: steps, totalMs: total }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface MotionViolation {
|
|
132
|
+
rule: string
|
|
133
|
+
problem: string
|
|
134
|
+
fixes: string[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Same posture as the canvas gate: collect everything, coerce nothing. */
|
|
138
|
+
export function validateMotion(plan: MotionPlan, itemCount: number): MotionViolation[] {
|
|
139
|
+
const v: MotionViolation[] = []
|
|
140
|
+
if (plan.stepCount > MOTION_BUDGET.maxSteps)
|
|
141
|
+
v.push({
|
|
142
|
+
rule: "animation.md:105",
|
|
143
|
+
problem: `${plan.stepCount} steps exceeds the ${MOTION_BUDGET.maxSteps}-step budget`,
|
|
144
|
+
fixes: [`reduce to at most ${MOTION_BUDGET.maxSteps} steps (target 3-6)`],
|
|
145
|
+
})
|
|
146
|
+
if (itemCount > MOTION_BUDGET.maxItems)
|
|
147
|
+
v.push({
|
|
148
|
+
rule: "animation.md:105",
|
|
149
|
+
problem: `${itemCount} marked items exceeds the ${MOTION_BUDGET.maxItems}-item budget`,
|
|
150
|
+
fixes: [`mark at most ${MOTION_BUDGET.maxItems} items with data-motion-item`],
|
|
151
|
+
})
|
|
152
|
+
if (plan.totalMs > MOTION.maxTotal)
|
|
153
|
+
v.push({
|
|
154
|
+
rule: "animation.md:107",
|
|
155
|
+
problem: `${plan.totalMs}ms autoplay exceeds the ${MOTION.maxTotal}ms ceiling`,
|
|
156
|
+
fixes: [`lower the step count, or shorten --motion-hold below ${MOTION.hold}ms`],
|
|
157
|
+
})
|
|
158
|
+
if (plan.mode === "loop" && plan.totalMs && plan.totalMs < MOTION_BUDGET.minLoopCycle)
|
|
159
|
+
v.push({
|
|
160
|
+
rule: "animation.md:14",
|
|
161
|
+
problem: `loop cycle ${plan.totalMs}ms is under the ${MOTION_BUDGET.minLoopCycle}ms minimum`,
|
|
162
|
+
fixes: [`slow the loop to at least ${MOTION_BUDGET.minLoopCycle}ms — a quiet hint, not a blink`],
|
|
163
|
+
})
|
|
164
|
+
return v
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── the camera — FossFLOW, reimplemented without GSAP ──────────────────────────
|
|
168
|
+
/**
|
|
169
|
+
* SceneLayer.tsx:32-37 tweens `translateX/translateY/scale` over 0.25s, with
|
|
170
|
+
* `duration: disableAnimation || isFirstRender ? 0 : 0.25`. That first-render guard is
|
|
171
|
+
* the actual design decision worth taking: **the scene must not animate into existence,
|
|
172
|
+
* but every subsequent change eases.** An engine that fades in on load feels like a
|
|
173
|
+
* slideshow; one that snaps on load and glides thereafter feels like an instrument.
|
|
174
|
+
*
|
|
175
|
+
* Reimplemented on rAF rather than pulling GSAP in, for one reason only: GSAP arrives
|
|
176
|
+
* with FossFLOW's whole MUI/Emotion/Paper/Quill stack, and this is the one function of
|
|
177
|
+
* it we need. Same curve, same duration, no tree.
|
|
178
|
+
*/
|
|
179
|
+
export interface CameraState {
|
|
180
|
+
x: number
|
|
181
|
+
y: number
|
|
182
|
+
zoom: number
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `power1.out` — gsap's DEFAULT, which is the curve FossFLOW actually uses, because
|
|
187
|
+
* neither of its two `gsap.to()` calls specifies an ease (SceneLayer.tsx:32-37,
|
|
188
|
+
* Grid.tsx:32-36). An earlier revision of this file used easeOutQuint, which was a guess
|
|
189
|
+
* at the feel rather than the measured curve; the design harvest settled it.
|
|
190
|
+
*
|
|
191
|
+
* power1 is quadratic, so `out` is 1-(1-t)^2. Gentler than quintic — it decelerates
|
|
192
|
+
* sooner and settles longer, and the harvest names exactly why that matters:
|
|
193
|
+
* "a fast drag produces continuous smoothed motion that lags the cursor slightly and
|
|
194
|
+
* catches up on release. That lag IS the instrument feel."
|
|
195
|
+
*/
|
|
196
|
+
const power1Out = (t: number) => 1 - (1 - t) * (1 - t)
|
|
197
|
+
|
|
198
|
+
export function tweenCamera(
|
|
199
|
+
from: CameraState,
|
|
200
|
+
to: CameraState,
|
|
201
|
+
apply: (s: CameraState) => void,
|
|
202
|
+
opts: { durationMs?: number; firstRender?: boolean; reducedMotion?: boolean } = {}
|
|
203
|
+
): () => void {
|
|
204
|
+
const instant =
|
|
205
|
+
opts.firstRender ||
|
|
206
|
+
opts.reducedMotion ||
|
|
207
|
+
(typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches)
|
|
208
|
+
|
|
209
|
+
if (instant) {
|
|
210
|
+
apply(to)
|
|
211
|
+
return () => {}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const dur = opts.durationMs ?? MOTION.camera
|
|
215
|
+
const t0 = performance.now()
|
|
216
|
+
let raf = 0
|
|
217
|
+
const frame = (now: number) => {
|
|
218
|
+
const p = Math.min(1, (now - t0) / dur)
|
|
219
|
+
const e = power1Out(p)
|
|
220
|
+
apply({
|
|
221
|
+
x: from.x + (to.x - from.x) * e,
|
|
222
|
+
y: from.y + (to.y - from.y) * e,
|
|
223
|
+
zoom: from.zoom + (to.zoom - from.zoom) * e,
|
|
224
|
+
})
|
|
225
|
+
if (p < 1) raf = requestAnimationFrame(frame)
|
|
226
|
+
}
|
|
227
|
+
raf = requestAnimationFrame(frame)
|
|
228
|
+
return () => cancelAnimationFrame(raf)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── the decorative primitives — Lanshu, under diagram-design's law ─────────────
|
|
232
|
+
/**
|
|
233
|
+
* `pulse_rect(draw, rect, color, phase, radius)` at render_animated_diagram.py:606,
|
|
234
|
+
* driven by `progress * math.tau * 2` at :647 — two full sine cycles per activation.
|
|
235
|
+
* Returned as a 0..1 intensity so the caller decides what it drives (opacity, stroke
|
|
236
|
+
* width, filter). It must never drive anything semantic.
|
|
237
|
+
*/
|
|
238
|
+
export function pulseIntensity(elapsedMs: number, cycleMs = MOTION_BUDGET.minLoopCycle): number {
|
|
239
|
+
const progress = (elapsedMs % cycleMs) / cycleMs
|
|
240
|
+
return (Math.sin(progress * Math.PI * 2 * 2) + 1) / 2
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Lanshu's sequential module activation — `active = (idx // 6) % len(pulse_targets)` at
|
|
245
|
+
* :644: one module at a time, six frames each, wrapping. The reason it reads well is
|
|
246
|
+
* that it is *one* thing lit at once, which is also diagram-design's `maxSimultaneous`
|
|
247
|
+
* rule arriving from the other direction. The two sources agree here.
|
|
248
|
+
*/
|
|
249
|
+
export function activeIndex(elapsedMs: number, count: number, dwellMs = 600): number {
|
|
250
|
+
if (count <= 0) return -1
|
|
251
|
+
return Math.floor(elapsedMs / dwellMs) % count
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The gate on every decorative primitive above. animation.md:42 — a flow token is
|
|
256
|
+
* `aria-hidden`, on a fixed path, one at a time, loop >= 3s; :46 forbids glow from
|
|
257
|
+
* carrying meaning; :76 drops `[data-motion-decorative]` entirely under reduced motion.
|
|
258
|
+
*/
|
|
259
|
+
export function decorativeAttrs(): Record<string, string> {
|
|
260
|
+
return { "aria-hidden": "true", focusable: "false", "data-motion-decorative": "" }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The CSS custom properties, so the stylesheet and the TS never drift apart. */
|
|
264
|
+
export function motionCssVars(): Record<string, string> {
|
|
265
|
+
return {
|
|
266
|
+
"--motion-fast": `${MOTION.fast}ms`,
|
|
267
|
+
"--motion-step": `${MOTION.step}ms`,
|
|
268
|
+
"--motion-hold": `${MOTION.hold}ms`,
|
|
269
|
+
"--motion-camera": `${MOTION.camera}ms`,
|
|
270
|
+
"--motion-ease": MOTION.ease,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Lanshu's vocabulary, as parameters rather than pixels ──────────────────────
|
|
275
|
+
/**
|
|
276
|
+
* render_animated_diagram.py:599-649. The whole animation system is ~35 lines, and the
|
|
277
|
+
* design harvest's judgement stands: "the motion vocabulary is the crown jewel."
|
|
278
|
+
*
|
|
279
|
+
* Ported as PARAMETERS so the paths come from the canvas edge list instead of the 11
|
|
280
|
+
* hardcoded literals at :618-630 — which is the harvest's stated defect 3, hardcoded
|
|
281
|
+
* coordinates as the layout model. Same numbers, driven by real data.
|
|
282
|
+
*
|
|
283
|
+
* Everything here is DECORATION under the 2-1 ruling above: aria-hidden, `loop` mode
|
|
284
|
+
* only, first thing dropped under reduced-motion, never in an export, never carrying
|
|
285
|
+
* meaning. The reading-order cursor is the one that earns its place — it teaches
|
|
286
|
+
* sequence while the plate itself never changes.
|
|
287
|
+
*/
|
|
288
|
+
export const LANSHU = {
|
|
289
|
+
/** :599-603 — 3-stop falloff plus a white core. This is what makes a dot read as a
|
|
290
|
+
* light source rather than a disc. Alphas are 0-255 in the source; kept verbatim. */
|
|
291
|
+
glowDot: {
|
|
292
|
+
stops: [
|
|
293
|
+
{ radius: 15, alpha: 42 },
|
|
294
|
+
{ radius: 10, alpha: 70 },
|
|
295
|
+
{ radius: 5, alpha: 210 },
|
|
296
|
+
],
|
|
297
|
+
core: { radius: 4, alpha: 245 },
|
|
298
|
+
},
|
|
299
|
+
/** :632-634 — the comet. Three draws at t, t-0.035, t-0.07 with falling strength.
|
|
300
|
+
* ~145ms of travel at 20fps: a tail, not a dotted line. */
|
|
301
|
+
trail: [
|
|
302
|
+
{ offset: 0, strength: 1 },
|
|
303
|
+
{ offset: -0.035, strength: 0.72 },
|
|
304
|
+
{ offset: -0.07, strength: 0.44 },
|
|
305
|
+
],
|
|
306
|
+
/** :606-610 — the ring is a shockwave, not an outline: brightest and tightest at the
|
|
307
|
+
* card edge, dissolving outward. alpha = 70 + 70*sin(phase), floored at 25. */
|
|
308
|
+
pulseRing: {
|
|
309
|
+
grows: [0, 4, 8],
|
|
310
|
+
widths: [2, 2, 1],
|
|
311
|
+
baseRadius: 12,
|
|
312
|
+
alphaBase: 70,
|
|
313
|
+
alphaSwing: 70,
|
|
314
|
+
alphaFloor: 25,
|
|
315
|
+
alphaFalloffPerGrow: 8,
|
|
316
|
+
},
|
|
317
|
+
/** :635-647 — ONE region lit at a time, advancing every 6 frames. At 20fps that is
|
|
318
|
+
* 300ms, and the walk is a guided tour of the figure. This is the reading-order
|
|
319
|
+
* teacher, and archify's chapter rail is the same instrument. */
|
|
320
|
+
readingCursor: { stepMs: 300, oneAtATime: true },
|
|
321
|
+
/** :14-15, :661 — 41 frames @ 20fps = 2.05s, and `phase = progress * tau * 2` gives
|
|
322
|
+
* two breaths per loop, ~1Hz. Note 2050ms is UNDER MOTION_BUDGET.minLoopCycle
|
|
323
|
+
* (3000ms), so a Lanshu-faithful loop must be slowed to clear the law's own floor —
|
|
324
|
+
* recorded rather than silently retimed. */
|
|
325
|
+
loop: { frames: 41, fps: 20, cycleMs: 2050, breathsPerCycle: 2 },
|
|
326
|
+
} as const
|
|
327
|
+
|
|
328
|
+
/** The law's floor wins over Lanshu's native cadence. Stated, not hidden. */
|
|
329
|
+
export const LANSHU_LOOP_MS = Math.max(LANSHU.loop.cycleMs, MOTION_BUDGET.minLoopCycle)
|
|
330
|
+
|
|
331
|
+
// ── archify's Reading Depth — the fourth seat's contribution ───────────────────
|
|
332
|
+
/**
|
|
333
|
+
* template.html:4130-4181. Three levels tied to zoom, carried as `data-detail-level`.
|
|
334
|
+
* Elements tag themselves `data-detail="context"` (sublabels, edge labels) or
|
|
335
|
+
* `"fine"` (tags, ordinals). Crucially NOTHING MOVES — the only transform is an 8px
|
|
336
|
+
* nudge on `[data-detail-anchor]` so a primary label re-centres when its sublabel goes.
|
|
337
|
+
*
|
|
338
|
+
* The override is the part worth having: focus, hover, lens, route and reach all reveal
|
|
339
|
+
* their exact matches at ANY scale. archify's own phrasing, and it is the whole
|
|
340
|
+
* philosophy in six words — "reader intent outranks the global zoom level."
|
|
341
|
+
*/
|
|
342
|
+
export const READING_DEPTH = {
|
|
343
|
+
levels: ["map", "read", "full"] as const,
|
|
344
|
+
/** below 100% → map · 100% → read · 175% → full (viewer-runtime.md:8) */
|
|
345
|
+
thresholds: { map: 1, full: 1.75 },
|
|
346
|
+
hides: { map: ["context", "fine"], read: ["fine"], full: [] as string[] },
|
|
347
|
+
anchorNudgePx: 8,
|
|
348
|
+
transitionMs: 160,
|
|
349
|
+
intentOverridesDepth: true,
|
|
350
|
+
} as const
|
|
351
|
+
|
|
352
|
+
export type ReadingDepth = (typeof READING_DEPTH.levels)[number]
|
|
353
|
+
|
|
354
|
+
export function depthForZoom(zoom: number): ReadingDepth {
|
|
355
|
+
if (zoom >= READING_DEPTH.thresholds.full) return "full"
|
|
356
|
+
if (zoom >= READING_DEPTH.thresholds.map) return "read"
|
|
357
|
+
return "map"
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The Motion Governor (archify :938-1000). Static is the DEFAULT; six conditions kill
|
|
362
|
+
* motion outright. Returned as a reason rather than a boolean so a surface can say WHY
|
|
363
|
+
* it is still — the harvest's point that reduced-motion is a designed state, not a
|
|
364
|
+
* blunt `animation: none`.
|
|
365
|
+
*/
|
|
366
|
+
export interface GovernorInput {
|
|
367
|
+
mode: MotionMode
|
|
368
|
+
still?: boolean
|
|
369
|
+
embedded?: boolean
|
|
370
|
+
printing?: boolean
|
|
371
|
+
documentHidden?: boolean
|
|
372
|
+
sharePlayback?: boolean
|
|
373
|
+
reducedMotion?: boolean
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function motionCapable(i: GovernorInput): { capable: boolean; reason?: string } {
|
|
377
|
+
if (i.mode === "none") return { capable: false, reason: "mode=none" }
|
|
378
|
+
if (i.still) return { capable: false, reason: "Live/Still toggle is Still" }
|
|
379
|
+
if (i.embedded) return { capable: false, reason: "embed mode" }
|
|
380
|
+
if (i.printing) return { capable: false, reason: "print" }
|
|
381
|
+
if (i.documentHidden) return { capable: false, reason: "document hidden" }
|
|
382
|
+
if (i.sharePlayback) return { capable: false, reason: "share playback owns the budget" }
|
|
383
|
+
if (i.reducedMotion) return { capable: false, reason: "prefers-reduced-motion" }
|
|
384
|
+
return { capable: true }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Lanshu's breathing is licensed ONLY in loop — the animated-vs-static ruling, computed. */
|
|
388
|
+
export function breathingAllowed(i: GovernorInput): boolean {
|
|
389
|
+
return i.mode === "loop" && motionCapable(i).capable
|
|
390
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mount seam — how this engine runs in three places without three codebases.
|
|
3
|
+
*
|
|
4
|
+
* The requirement is Gavin's, stated plainly: the tool has to run BY ITSELF, run
|
|
5
|
+
* COMBINED with others (Synaptiq, Audion, …), and run INSIDE Djeli. The ontology says
|
|
6
|
+
* the same thing from the container side — Djeli's node reads "THE CONTAINER — every
|
|
7
|
+
* app runs standalone or mounted inside Djeli."
|
|
8
|
+
*
|
|
9
|
+
* So the engine exports one function and owns no window, no router, no global state.
|
|
10
|
+
* The host supplies the element; the engine fills it. That is the entire contract.
|
|
11
|
+
*
|
|
12
|
+
* ── an honest note on the Djeli target ──────────────────────────────────────────
|
|
13
|
+
* The Djeli codex records an OBSERVED gap, not an inference: the shell's tab registry
|
|
14
|
+
* is CLOSED — a `TabKind` union, hand-written openers, a hardcoded `routeDocumentPath`,
|
|
15
|
+
* no IPC channel that takes a module id, and a default-deny navigation guard. So today
|
|
16
|
+
* mounting a Griot panel in Djeli is "a compile-time fork edit at ~6 sites, not a
|
|
17
|
+
* plugin install." This file does not pretend otherwise. `host: "djeli"` is wired and
|
|
18
|
+
* correct on our side; the six-site fork edit is Djeli's work, tracked there, and the
|
|
19
|
+
* genuinely open seams it names are AgentSkill/composeSkills and the provider table.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { type JSONCanvas } from "./json-canvas"
|
|
23
|
+
|
|
24
|
+
export type VizHost = "standalone" | "composed" | "djeli" | "vscode" | "cowork"
|
|
25
|
+
|
|
26
|
+
export interface MountOptions {
|
|
27
|
+
/** Where the engine renders. The host owns the element's size and placement. */
|
|
28
|
+
element: HTMLElement
|
|
29
|
+
/** Which surface is hosting. Detected when omitted. */
|
|
30
|
+
host?: VizHost
|
|
31
|
+
/** The canvas to open with — also the palette library for composable sources. */
|
|
32
|
+
canvas?: JSONCanvas
|
|
33
|
+
/** Named canvases the engine can switch between. */
|
|
34
|
+
sources?: unknown[]
|
|
35
|
+
/** Called on every mutation so the host owns persistence — the engine never writes. */
|
|
36
|
+
onChange?: (canvas: JSONCanvas) => void
|
|
37
|
+
/**
|
|
38
|
+
* Waku Wiring B: open a node's real source file. A box that cannot reveal its source
|
|
39
|
+
* is a picture of a module, not the module. Hosts that can reveal (Djeli, VS Code,
|
|
40
|
+
* the dev server) supply this; hosts that cannot leave it undefined and the engine
|
|
41
|
+
* hides the control rather than offering a dead button.
|
|
42
|
+
*/
|
|
43
|
+
reveal?: (origin: { repo: string; file: string; line: number }) => void | Promise<void>
|
|
44
|
+
/** Waku Wiring C: subscribe to live trace events so a box glows when its code runs. */
|
|
45
|
+
subscribeTrace?: (cb: (nodeId: string) => void) => () => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface VizEngineHandle {
|
|
49
|
+
/** Replace the open canvas. */
|
|
50
|
+
load(canvas: JSONCanvas): void
|
|
51
|
+
/** Current state — the host asks, the engine answers; no shared mutable object. */
|
|
52
|
+
snapshot(): JSONCanvas
|
|
53
|
+
/** Tear down cleanly. A composed host will call this on panel close. */
|
|
54
|
+
destroy(): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Detect the host from hard signals rather than a guess — the same discipline as the
|
|
59
|
+
* env beacon. First match wins; `standalone` is the honest default.
|
|
60
|
+
*/
|
|
61
|
+
export function detectHost(): VizHost {
|
|
62
|
+
const w = globalThis as any
|
|
63
|
+
if (typeof window === "undefined") return "standalone"
|
|
64
|
+
if (w.acquireVsCodeApi) return "vscode"
|
|
65
|
+
if (w.djeli?.tabs || w.aiOffice) return "djeli"
|
|
66
|
+
if (w.claude?.sendPrompt || w.sendPrompt) return "cowork"
|
|
67
|
+
if (window.parent !== window) return "composed"
|
|
68
|
+
return "standalone"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* drive() — the 4-rung graceful-fallback ladder, verbatim from the Prism Gavel codex:
|
|
73
|
+
* mcp-app postMessage -> Cowork sendPrompt -> :52342 POST -> clipboard.
|
|
74
|
+
*
|
|
75
|
+
* This is what makes a box a control instead of a label. A card that can only be read
|
|
76
|
+
* is a static SVG with extra steps; a card whose button wakes the agent is the
|
|
77
|
+
* instrument. The rung that is live depends on the host, and the ladder degrades
|
|
78
|
+
* without ever presenting a dead button.
|
|
79
|
+
*/
|
|
80
|
+
export const WAKE_CHANNEL = "http://127.0.0.1:52342"
|
|
81
|
+
|
|
82
|
+
export async function drive(verb: string, payload: Record<string, unknown> = {}): Promise<{ rung: string; ok: boolean }> {
|
|
83
|
+
const msg = { skill: "prism-viz-engine", verb, ...payload }
|
|
84
|
+
const w = globalThis as any
|
|
85
|
+
|
|
86
|
+
// rung 0 — MCP App widget: JSON-RPC over postMessage to the host
|
|
87
|
+
try {
|
|
88
|
+
if (w.parent && w.parent !== w && w.__mcpApp) {
|
|
89
|
+
w.parent.postMessage({ jsonrpc: "2.0", method: "tools/call", params: msg }, "*")
|
|
90
|
+
return { rung: "mcp-app", ok: true }
|
|
91
|
+
}
|
|
92
|
+
} catch {}
|
|
93
|
+
|
|
94
|
+
// rung 1 — Cowork / brainstorm companion: sendPrompt drives the agent
|
|
95
|
+
try {
|
|
96
|
+
const send = w.claude?.sendPrompt ?? w.sendPrompt
|
|
97
|
+
if (typeof send === "function") {
|
|
98
|
+
send(`${verb} ${JSON.stringify(payload)}`)
|
|
99
|
+
return { rung: "sendPrompt", ok: true }
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
|
|
103
|
+
// rung 2 — the shared digital-griot-mcp wake channel
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch(`${WAKE_CHANNEL}/wake`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "content-type": "application/json" },
|
|
108
|
+
body: JSON.stringify(msg),
|
|
109
|
+
})
|
|
110
|
+
if (res.ok) return { rung: "channel:52342", ok: true }
|
|
111
|
+
} catch {}
|
|
112
|
+
|
|
113
|
+
// rung 3 — clipboard: the agent is woken by a human paste. Still a path, not a dead end.
|
|
114
|
+
try {
|
|
115
|
+
await navigator.clipboard.writeText(JSON.stringify(msg, null, 2))
|
|
116
|
+
return { rung: "clipboard", ok: true }
|
|
117
|
+
} catch {}
|
|
118
|
+
|
|
119
|
+
return { rung: "none", ok: false }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The single entry point. Kept async so the React layer is a dynamic import and a host
|
|
124
|
+
* that only wants the format helpers (`json-canvas`, `layer-roles`) never pays for the
|
|
125
|
+
* renderer.
|
|
126
|
+
*/
|
|
127
|
+
export async function mountVizEngine(opts: MountOptions): Promise<VizEngineHandle> {
|
|
128
|
+
const { mountReact } = await import("../layers/04-shell/mount-react")
|
|
129
|
+
return mountReact({ ...opts, host: opts.host ?? detectHost() })
|
|
130
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prism-viz-engine — public surface.
|
|
3
|
+
*
|
|
4
|
+
* The three mount targets Gavin named all import from here:
|
|
5
|
+
*
|
|
6
|
+
* standalone `npm run dev` in this folder -> src/main.tsx
|
|
7
|
+
* composed Synaptiq / Audion / Griot Hub call mountVizEngine({element, ...})
|
|
8
|
+
* Djeli the container mounts it as a panel (see mount.ts on the closed tab
|
|
9
|
+
* registry — that side is a ~6-site fork edit today, tracked in Djeli)
|
|
10
|
+
*
|
|
11
|
+
* A host that only wants the data format imports `json-canvas` / `layer-roles` and
|
|
12
|
+
* never pulls React or xyflow into its bundle.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
mountVizEngine,
|
|
17
|
+
detectHost,
|
|
18
|
+
drive,
|
|
19
|
+
WAKE_CHANNEL,
|
|
20
|
+
type MountOptions,
|
|
21
|
+
type VizEngineHandle,
|
|
22
|
+
type VizHost,
|
|
23
|
+
} from "./core/mount"
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
LAYER_ROLES,
|
|
27
|
+
ALL_SLOTS,
|
|
28
|
+
UNPLACEABLE,
|
|
29
|
+
ROLE_EMBER,
|
|
30
|
+
ROLE_EQUIV,
|
|
31
|
+
isLayerRole,
|
|
32
|
+
isLayerSlot,
|
|
33
|
+
type LayerRole,
|
|
34
|
+
type LayerSlot,
|
|
35
|
+
} from "./core/layer-roles"
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
emptyCanvas,
|
|
39
|
+
validate,
|
|
40
|
+
assertValid,
|
|
41
|
+
merge,
|
|
42
|
+
serialize,
|
|
43
|
+
type JSONCanvas,
|
|
44
|
+
type CanvasNode,
|
|
45
|
+
type CanvasEdge,
|
|
46
|
+
type GriotNodeMeta,
|
|
47
|
+
type Violation,
|
|
48
|
+
} from "./core/json-canvas"
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
adaptHarvest,
|
|
52
|
+
loadFromKuzu,
|
|
53
|
+
type HarvestedUxNode,
|
|
54
|
+
type HarvestedCodeRow,
|
|
55
|
+
} from "./layers/03-substrate/harvest-adapter"
|