prism-viz-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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"