From ad758c535cf040cb576e37e03145fd7f712cdb39 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 06:43:22 +0200 Subject: [PATCH 01/23] feat(glyphcss): interactive/CodePen export mounts stock effect layers by id --- AGENTS.md | 4 +- .../src/api/interactiveExport.test.ts | 108 ++++++++++++++++++ .../glyphcss/src/api/interactiveExport.ts | 75 ++++++++++-- 3 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 packages/glyphcss/src/api/interactiveExport.test.ts diff --git a/AGENTS.md b/AGENTS.md index 2105a6fa..cf779f79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,9 +90,9 @@ Because `rasterize` is pure (geometry + camera → string), a scene can be rende - **`compileScene(opts)`** (in `glyphcss`, pure) — polygons + camera + options → the `
` string. **Byte-identical to the runtime render** for the same inputs; same defaults as `createGlyphScene`. The foundation for every adapter.
 - **`@glyphcss/compile`** — Node adapters around `compileScene`: `loadMeshFromFile`, `compileFile`, a **Vite plugin** (`import x from "./m.glb?glyph&…"` → baked `
`), a **CLI** (`glyphcss-compile`), and `compileInteractive`.
 - **`GlyphSceneStatic`** (React + Vue) — SSR/SSG component that renders the compiled `
` with no client runtime (mirror of each other; static counterpart to `GlyphScene`).
-- **Interactive export** — `buildGlyphInteractiveExport(polygons, { interactions })` (pure, browser-safe, in `glyphcss`): the declared interactions (`orbit`/`zoom`/`pan`/`fpv`) drive **both** the wired control (only that one is imported → the snippet tree-shakes) **and** the decimation budget (`decimatePolygons` — coarser for orbit, finer when `zoom`/`fpv` let the camera approach). Output is a self-contained CDN+inlined-mesh snippet; `glyphCodepenPrefill` turns it into a CodePen POST (the gallery's "CodePen" button). Less declared interactivity = less mesh + less runtime shipped.
+- **Interactive export** — `buildGlyphInteractiveExport(polygons, { interactions })` (pure, browser-safe, in `glyphcss`): the declared interactions (`orbit`/`zoom`/`pan`/`fpv`) drive **both** the wired control (only that one is imported → the snippet tree-shakes) **and** the decimation budget (`decimatePolygons` — coarser for orbit, finer when `zoom`/`fpv` let the camera approach). Output is a self-contained CDN+inlined-mesh snippet; `glyphCodepenPrefill` turns it into a CodePen POST (the gallery's "CodePen" button). Less declared interactivity = less mesh + less runtime shipped. An optional `effect: { id, params, blend?, timeScale? }` mounts a live **stock** `@glyphcss/effects` layer: the snippet adds a second `import { getGlyphEffect } from "https://esm.sh/@glyphcss/effects@?deps=glyphcss@"` (same `` as the glyphcss import; `?deps` dedupes the shared glyphcss module instance), resolves the effect by id at runtime (`glyphcss` itself never imports `@glyphcss/effects` — that dependency only points one way), calls `scene.addEffectLayer(...)` after the scene/controls are wired, and — when `timeScale > 0` — appends a small `requestAnimationFrame` loop driving `params.time`.
 
-Dynamic Glyph Effect layers are runtime-only in the current slice. Static compilation and the interactive/CodePen exporters do not serialize or evaluate them yet; callers must not imply that an exported scene contains a mounted effect.
+Dynamic Glyph Effect layers are otherwise runtime-only. Static compilation still cannot serialize or evaluate them, and the interactive/CodePen exporter only supports **stock** effects by id — a custom `defineGlyphEffect` definition can't be serialized across the CDN boundary and isn't exported either.
 
 ## No per-frame DOM mutation
 
diff --git a/packages/glyphcss/src/api/interactiveExport.test.ts b/packages/glyphcss/src/api/interactiveExport.test.ts
new file mode 100644
index 00000000..4504acaf
--- /dev/null
+++ b/packages/glyphcss/src/api/interactiveExport.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect } from "vitest";
+import { buildGlyphInteractiveExport, glyphCodepenPrefill } from "./interactiveExport";
+import type { Polygon } from "@glyphcss/core";
+
+const TRIANGLE: Polygon[] = [
+  { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], color: "#ff0000" },
+  { vertices: [[0, 0, 1], [1, 0, 1], [0, 1, 1]], color: "#00ff00" },
+];
+
+describe("buildGlyphInteractiveExport — effect option", () => {
+  it("no effect → unchanged output (regression guard)", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, { interactions: ["orbit"] });
+    expect(r.html).not.toContain("@glyphcss/effects");
+    expect(r.html).not.toContain("getGlyphEffect");
+    expect(r.html).not.toContain("addEffectLayer");
+    expect(r.html).not.toContain("requestAnimationFrame");
+  });
+
+  it("animated effect → effects CDN import, getGlyphEffect lookup, addEffectLayer, inlined params, rAF clock", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["orbit"],
+      effect: {
+        id: "field-synth",
+        params: { space: "surface", field1: "radial", amp1: 0.5, time: 999 },
+        blend: "replace",
+        timeScale: 2,
+      },
+    });
+    expect(r.html).toContain('import { getGlyphEffect } from "https://esm.sh/@glyphcss/effects?deps=glyphcss";');
+    expect(r.html).toContain('getGlyphEffect("field-synth")');
+    expect(r.html).toContain("scene.addEffectLayer({ effect: getGlyphEffect(\"field-synth\"), blend: \"replace\", params:");
+    // Params are inlined without the authored `time` key — the clock owns it.
+    const paramsMatch = r.html.match(/params: (\{[^}]*\})/);
+    expect(paramsMatch).not.toBeNull();
+    const params = JSON.parse(paramsMatch![1]);
+    expect(params).toEqual({ space: "surface", field1: "radial", amp1: 0.5 });
+    expect(params.time).toBeUndefined();
+    // rAF clock drives params.time at the declared speed.
+    expect(r.html).toContain("requestAnimationFrame(_tick)");
+    expect(r.html).toContain("glyphEffect.params.time = ((now - _t0) / 1000) * 2;");
+  });
+
+  it("static effect (no timeScale) → mounts the layer but emits no rAF clock", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: [],
+      effect: { id: "matrix-rain", params: { density: 0.5 } },
+    });
+    expect(r.html).toContain('getGlyphEffect("matrix-rain")');
+    expect(r.html).toContain("scene.addEffectLayer(");
+    // Default blend is "over" (glyphcss's own addEffectLayer default) when unset.
+    expect(r.html).toContain('blend: "over"');
+    expect(r.html).not.toContain("requestAnimationFrame");
+    expect(r.html).not.toContain("_tick");
+  });
+
+  it("timeScale: 0 is treated as static — no clock emitted", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["orbit"],
+      effect: { id: "scan", params: {}, timeScale: 0 },
+    });
+    expect(r.html).toContain("scene.addEffectLayer(");
+    expect(r.html).not.toContain("requestAnimationFrame");
+  });
+
+  it("cdnVersion pins both the glyphcss and @glyphcss/effects CDN imports, plus the deps dedupe query", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["orbit"],
+      cdnVersion: "0.1.1",
+      effect: { id: "glitch", params: {}, timeScale: 1 },
+    });
+    expect(r.html).toContain('from "https://esm.sh/glyphcss@0.1.1";');
+    expect(r.html).toContain(
+      'import { getGlyphEffect } from "https://esm.sh/@glyphcss/effects@0.1.1?deps=glyphcss@0.1.1";',
+    );
+  });
+
+  it("mounts the effect after controls in both the normal and FPV branches", () => {
+    const normal = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["orbit"],
+      effect: { id: "wipe", params: {}, timeScale: 1 },
+    });
+    const orbitIdx = normal.html.indexOf("createGlyphOrbitControls(");
+    const effectIdx = normal.html.indexOf("scene.addEffectLayer(");
+    expect(orbitIdx).toBeGreaterThan(-1);
+    expect(effectIdx).toBeGreaterThan(orbitIdx);
+
+    const fpv = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["fpv"],
+      autoCenter: true,
+      effect: { id: "wipe", params: {}, timeScale: 1 },
+    });
+    const fpvSetupIdx = fpv.html.indexOf("fpv.setOrigin(");
+    const fpvEffectIdx = fpv.html.indexOf("scene.addEffectLayer(");
+    expect(fpvSetupIdx).toBeGreaterThan(-1);
+    expect(fpvEffectIdx).toBeGreaterThan(fpvSetupIdx);
+  });
+
+  it("glyphCodepenPrefill carries the effect-mounting script through the pen payload", () => {
+    const r = buildGlyphInteractiveExport(TRIANGLE, {
+      interactions: ["orbit"],
+      effect: { id: "ripple", params: {}, timeScale: 1 },
+    });
+    const pre = glyphCodepenPrefill(r, "effect-demo");
+    const data = JSON.parse(pre.data);
+    expect(data.html).toContain('getGlyphEffect("ripple")');
+    expect(data.html).toContain("requestAnimationFrame(_tick)");
+  });
+});
diff --git a/packages/glyphcss/src/api/interactiveExport.ts b/packages/glyphcss/src/api/interactiveExport.ts
index cde27e97..81f6e40b 100644
--- a/packages/glyphcss/src/api/interactiveExport.ts
+++ b/packages/glyphcss/src/api/interactiveExport.ts
@@ -37,6 +37,22 @@ export interface GlyphInteractiveExportOptions {
   cellAspect?: number;
   mode?: RenderMode;
   useColors?: boolean;
+  /**
+   * Mount a live stock `@glyphcss/effects` layer, resolved by id from the CDN
+   * at runtime. The exporter never imports `@glyphcss/effects` itself (that
+   * package depends on glyphcss, never the reverse) — `id` stays an opaque
+   * string until the snippet runs in the browser.
+   */
+  effect?: {
+    /** Stock effect id, e.g. "field-synth" / "matrix-rain". */
+    id: string;
+    /** Flat ABI params. Any `time` key is stripped — the emitted clock owns it. */
+    params: Record;
+    /** Default: "over" (glyphcss's own `addEffectLayer` default). */
+    blend?: "over" | "replace";
+    /** Clock speed. >0 emits an rAF loop driving `params.time`; 0/undefined = static. */
+    timeScale?: number;
+  };
 }
 
 export interface GlyphInteractiveExportResult {
@@ -74,8 +90,31 @@ const BASE_CSS = `html,body{margin:0;height:100%;background:#0b0d10;color:#e2e8f
 #MOUNT{width:100vw;height:100vh}
 #MOUNT pre.glyph-output{margin:0;font:13px/1 ui-monospace,Menlo,monospace;white-space:pre}`;
 
+// The clock owns `time` — strip any authored value so the rAF loop (or the
+// static single assignment) is the sole writer.
+function serializeEffectParams(params: Record): string {
+  const { time: _time, ...rest } = params;
+  return JSON.stringify(rest);
+}
+
+// Shared by the FPV and normal branches of buildScript so the effect-mount +
+// clock snippet isn't duplicated. `effect` here is the resolved, non-optional
+// export option — callers gate on `opts.effect` before calling this.
+function buildEffectBlock(effect: NonNullable): string {
+  const blend = effect.blend ?? "over";
+  const layerLine = `const glyphEffect = scene.addEffectLayer({ effect: getGlyphEffect(${JSON.stringify(effect.id)}), blend: ${JSON.stringify(blend)}, params: ${serializeEffectParams(effect.params)} });`;
+  if (!effect.timeScale) return layerLine;
+  return `${layerLine}
+const _t0 = performance.now();
+(function _tick(now) {
+  glyphEffect.params.time = ((now - _t0) / 1000) * ${round(effect.timeScale)};
+  requestAnimationFrame(_tick);
+})(performance.now());`;
+}
+
 function buildScript(opts: {
   cdn: string;
+  effectsCdn?: string;
   mountId: string;
   polysJson: string;
   rotX?: number; rotY?: number; zoom?: number;
@@ -83,6 +122,7 @@ function buildScript(opts: {
   projection?: "perspective" | "orthographic"; perspectivePx?: number;
   interactions: GlyphInteraction[];
   scale: number;
+  effect?: GlyphInteractiveExportOptions["effect"];
 }): string {
   const hasFpv = opts.interactions.includes("fpv");
   const hasPan = opts.interactions.includes("pan");
@@ -105,9 +145,12 @@ function buildScript(opts: {
   if (opts.useColors === false) sceneOpts.push("useColors: false");
   if (opts.cellAspect !== undefined) sceneOpts.push(`cellAspect: ${opts.cellAspect}`);
 
-  const head = `import { ${imports.join(", ")} } from "${opts.cdn}";
+  const importLines = [`import { ${imports.join(", ")} } from "${opts.cdn}";`];
+  if (opts.effect) importLines.push(`import { getGlyphEffect } from "${opts.effectsCdn}";`);
+  const head = `${importLines.join("\n")}
 const polygons = ${opts.polysJson}.map((p) => ({ vertices: p[0], color: p[1] }));`;
   const mount = `document.getElementById(${JSON.stringify(opts.mountId)})`;
+  const effectBlock = opts.effect ? buildEffectBlock(opts.effect) : undefined;
 
   // FPV mirrors the gallery: perspective at rotX=90, zoom × model-scale,
   // and move/jump/gravity/eyeHeight scaled by the model so it works at any size.
@@ -123,13 +166,16 @@ const polygons = ${opts.polysJson}.map((p) => ({ vertices: p[0], color: p[1] }))
     const fpvZoom = round((opts.zoom ?? 0.5) * s);
     const fpvPerspective = round(200 * s);
     const fpvOpts = `{ moveSpeed: ${round(1 * s)}, jumpVelocity: ${round(0.7 * s)}, gravity: ${round(1.8 * s)}, eyeHeight: ${eyeZ}, crouchHeight: ${round(0.1 * s)}, groundZ: 0, lookSensitivity: 0.15, minPitch: 5, maxPitch: 175 }`;
-    return `${head}
-const camera = createGlyphPerspectiveCamera({ rotX: 90, rotY: ${round(rotY)}, zoom: ${fpvZoom}, perspective: ${fpvPerspective} });
+    return [
+      head,
+      `const camera = createGlyphPerspectiveCamera({ rotX: 90, rotY: ${round(rotY)}, zoom: ${fpvZoom}, perspective: ${fpvPerspective} });
 const scene = createGlyphScene(${mount}, { camera, ${sceneOpts.join(", ")} });
 scene.add(polygons);
 const fpv = createGlyphFirstPersonControls(scene, ${fpvOpts});
 fpv.setOrigin([${spawnX}, ${spawnY}, ${eyeZ}]);
-// Click the scene to capture the mouse, then WASD to move, mouse to look.`;
+// Click the scene to capture the mouse, then WASD to move, mouse to look.`,
+      effectBlock,
+    ].filter(Boolean).join("\n");
   }
 
   const camParts: string[] = [];
@@ -145,11 +191,14 @@ fpv.setOrigin([${spawnX}, ${spawnY}, ${eyeZ}]);
     controlLine = `createGlyphOrbitControls(scene, { drag: ${hasOrbit ? "true" : "false"}, wheel: ${hasZoom ? "true" : "false"} });`;
   }
 
-  return `${head}
-const camera = ${cameraCtor}({ ${camParts.join(", ")} });
+  return [
+    head,
+    `const camera = ${cameraCtor}({ ${camParts.join(", ")} });
 const scene = createGlyphScene(${mount}, { camera, ${sceneOpts.join(", ")} });
 scene.add(polygons);
-${controlLine}`;
+${controlLine}`,
+    effectBlock,
+  ].filter(Boolean).join("\n");
 }
 
 function modelScale(polygons: Polygon[]): number {
@@ -173,15 +222,23 @@ export function buildGlyphInteractiveExport(
   const decimated = decimatePolygons(centered, { grid });
 
   const mountId = options.mountId ?? "glyph";
-  const cdn = `https://esm.sh/glyphcss${options.cdnVersion ? `@${options.cdnVersion}` : ""}`;
+  const versionSuffix = options.cdnVersion ? `@${options.cdnVersion}` : "";
+  const cdn = `https://esm.sh/glyphcss${versionSuffix}`;
+  // `?deps=glyphcss` pins esm.sh to the same glyphcss instance the main
+  // import resolves, so the effect layer's runtime type checks (which compare
+  // against glyphcss's exported classes/symbols) see one module graph.
+  const effectsCdn = options.effect
+    ? `https://esm.sh/@glyphcss/effects${versionSuffix}?deps=glyphcss${versionSuffix}`
+    : undefined;
   const script = buildScript({
-    cdn, mountId,
+    cdn, effectsCdn, mountId,
     polysJson: serializePolygons(decimated),
     rotX: options.rotX, rotY: options.rotY, zoom: options.zoom,
     projection: options.projection, perspectivePx: options.perspectivePx,
     cellAspect: options.cellAspect, mode: options.mode, useColors: options.useColors,
     interactions,
     scale: modelScale(decimated),
+    effect: options.effect,
   });
 
   const css = BASE_CSS.replace(/MOUNT/g, mountId);

From 4cdf740da620d5e49fca02d070192425f66c347a Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 18 Jul 2026 07:03:11 +0200
Subject: [PATCH 02/23] feat(website): export active effect to CodePen from
 gallery + /synth

---
 .../components/GalleryWorkbench/CodePanel.tsx | 10 +++-
 .../SynthWorkbench/SynthWorkbench.tsx         | 58 +++++++++++++++++++
 .../SynthWorkbench/synth-workbench.css        | 17 ++++++
 3 files changed, 84 insertions(+), 1 deletion(-)

diff --git a/website/src/components/GalleryWorkbench/CodePanel.tsx b/website/src/components/GalleryWorkbench/CodePanel.tsx
index c951d48b..f2db639b 100644
--- a/website/src/components/GalleryWorkbench/CodePanel.tsx
+++ b/website/src/components/GalleryWorkbench/CodePanel.tsx
@@ -589,6 +589,14 @@ export function CodePanel({ meshUrl, options, selectedPreset, effectState, effec
         mode: options.renderMode === "wireframe" ? "wireframe" : "solid",
         useColors: options.useColors,
         decimateGrid,
+        effect: effectState.effectId
+          ? {
+              id: effectState.effectId,
+              params: effectState.params,
+              blend: effectState.blend,
+              timeScale: effectState.paused ? 0 : effectState.timeScale,
+            }
+          : undefined,
       });
       postToCodepen(glyphCodepenPrefill(result, title));
     } catch (err) {
@@ -596,7 +604,7 @@ export function CodePanel({ meshUrl, options, selectedPreset, effectState, effec
     } finally {
       setExporting(false);
     }
-  }, [meshUrl, selectedPreset, options, interactions, staticMode, staticEncoding, rotate]);
+  }, [meshUrl, selectedPreset, options, interactions, staticMode, staticEncoding, rotate, effectState]);
 
   return (
     
         
+
setLighting((l) => ({ ...l, ...partial }))} params={params} onParam={onParam} /> diff --git a/website/src/components/SynthWorkbench/synth-workbench.css b/website/src/components/SynthWorkbench/synth-workbench.css index 088752fa..bcee7cd7 100644 --- a/website/src/components/SynthWorkbench/synth-workbench.css +++ b/website/src/components/SynthWorkbench/synth-workbench.css @@ -147,6 +147,23 @@ /* ── Center: viewport ──────────────────────────────────────────────────── */ .synth-main { flex: 1; position: relative; min-width: 0; } .synth-viewport { position: absolute; inset: 0; overflow: hidden; } +.synth-codepen-btn { + /* Bottom-left, not top-right — the floating Dock GUI anchors top-right of + `.synth-body` (the nearest `position: relative` ancestor) and would + otherwise sit on top of this button and eat its clicks. */ + position: absolute; + bottom: 12px; + left: 12px; + z-index: 5; + font: inherit; + font-size: 11px; + letter-spacing: 0.04em; + padding: 5px 12px; + border-radius: 0; + cursor: pointer; + background: rgba(7, 9, 13, 0.7); + backdrop-filter: blur(2px); +} .synth-viewport .glyph-output { color: rgba(255, 232, 184, 0.92); } /* ── Footer: full-width preset gallery ─────────────────────────────────── */ From 9d217497865631709a0ebe3a044de03184370c07 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 16:45:31 +0200 Subject: [PATCH 03/23] feat(website): synth plane fills the viewport and locks the camera --- .../SynthWorkbench/SynthWorkbench.tsx | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/website/src/components/SynthWorkbench/SynthWorkbench.tsx b/website/src/components/SynthWorkbench/SynthWorkbench.tsx index 9757e7bc..62c97ca1 100644 --- a/website/src/components/SynthWorkbench/SynthWorkbench.tsx +++ b/website/src/components/SynthWorkbench/SynthWorkbench.tsx @@ -106,7 +106,12 @@ const isFlat = (name: string) => name === "plane"; // of the grid. MUST project with the same MEASURED cell metrics the renderer uses // (`metrics`), else the default cell (BASE_TILE/cellAspect) is ~4× off and the zoom // massively overshoots. Call after a render so the
 reflects the real cell.
-function frameObject(scene: GlyphSceneHandle, camera: { zoom: number; project: (v: [number, number, number], c: number, r: number, a: number, m?: unknown) => number[] }, polys: Polys, fill = 0.72): void {
+// `cover`: fit the SMALLER axis exactly at `fill` and overscan the larger one
+// (like CSS `background-size: cover`) instead of the default `contain` behaviour
+// (fit the LARGER axis, margin on the smaller one). Used for the fullscreen plane
+// so its texture reaches every edge of a non-square viewport instead of framing
+// with letterbox bars.
+function frameObject(scene: GlyphSceneHandle, camera: { zoom: number; project: (v: [number, number, number], c: number, r: number, a: number, m?: unknown) => number[] }, polys: Polys, fill = 0.72, cover = false): void {
   const o = scene.getOptions();
   const pre = scene.host.querySelector("pre.glyph-output") as HTMLElement | null;
   let metrics: { cellWidth: number; cellHeight: number } | undefined;
@@ -123,7 +128,10 @@ function frameObject(scene: GlyphSceneHandle, camera: { zoom: number; project: (
     if (pr[1]! < minr) minr = pr[1]!; if (pr[1]! > maxr) maxr = pr[1]!;
   }
   const w = maxc - minc, h = maxr - minr;
-  if (w > 0 && h > 0) camera.zoom = Math.min((fill * o.cols) / w, (fill * o.rows) / h);
+  if (w > 0 && h > 0) {
+    const zc = (fill * o.cols) / w, zr = (fill * o.rows) / h;
+    camera.zoom = cover ? Math.max(zc, zr) : Math.min(zc, zr);
+  }
 }
 
 // Isolate one voice into osc-1 (amp 1) so a card can preview its solo contribution.
@@ -300,12 +308,16 @@ export default function SynthWorkbench() {
     const camera = createGlyphOrthographicCamera({ rotX: flat ? 0 : 58, rotY: flat ? 0 : 32, zoom: 46 });
     const scene = createGlyphScene(host, { camera, autoSize: true, mode: "solid", useColors: true, glyphPalette: "default", doubleSided: flat, interactiveDownscale: 1, ...buildLighting(lightingRef.current) });
     host.style.fontSize = `${13 / densityRef.current}px`;
-    createGlyphOrbitControls(scene, { drag: true, wheel: true });
+    // The plane is a fullscreen-shader-style backdrop: camera stays locked head-on,
+    // so no orbit controls for it. Every other shape keeps orbit exactly as before.
+    if (!flat) createGlyphOrbitControls(scene, { drag: true, wheel: true });
     const polys = shapePolys(shape);
     meshRef.current = scene.add(polys) as { dispose: () => void };
     scene.fit();
     scene.rerender(); // render once so the 
 reflects the real cell size
-    frameObject(scene, camera, polys, flat ? 0.98 : 0.72);
+    // `cover` + slight overscan (fill > 1) so the plane reaches every edge of a
+    // non-square viewport instead of "contain"-fitting with letterbox margins.
+    frameObject(scene, camera, polys, flat ? 1.02 : 0.72, flat);
     scene.rerender();
     const layer = scene.addEffectLayer({ effect: fieldSynth, params: paramsRef.current, blend: "replace", target: "surfaces" });
     layerRef.current = layer as unknown as { setParams: (p: Params) => void; dispose: () => void };
@@ -319,7 +331,21 @@ export default function SynthWorkbench() {
       layerRef.current?.setParams({ time: t });
     };
     raf = requestAnimationFrame(tick);
-    return () => { cancelAnimationFrame(raf); layerRef.current?.dispose(); scene.destroy(); sceneRef.current = null; layerRef.current = null; };
+    // Camera.zoom is CSS px per world unit, independent of host size — resizing
+    // the viewport doesn't grow the plane's on-screen footprint on its own. Only
+    // the fullscreen plane needs to re-cover on resize; framed 3D shapes keep
+    // their fixed on-screen size exactly as before (untouched by this observer).
+    let resizeObserver: ResizeObserver | null = null;
+    if (flat && typeof ResizeObserver !== "undefined") {
+      resizeObserver = new ResizeObserver(() => {
+        scene.fit();
+        scene.rerender();
+        frameObject(scene, camera, polys, 1.02, true);
+        scene.rerender();
+      });
+      resizeObserver.observe(host);
+    }
+    return () => { cancelAnimationFrame(raf); resizeObserver?.disconnect(); layerRef.current?.dispose(); scene.destroy(); sceneRef.current = null; layerRef.current = null; };
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [shape]);
 

From 0623de11d1ab23865a2af79b4c010fdc15505e9f Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 18 Jul 2026 17:23:25 +0200
Subject: [PATCH 04/23] feat(effects): zero-lib minimal-JS static export for
 field-synth + /synth export button

---
 AGENTS.md                                     |   6 +-
 packages/effects/src/index.ts                 |  10 +
 packages/effects/src/staticExport.test.ts     | 115 ++++
 packages/effects/src/staticExport.ts          | 499 ++++++++++++++++++
 packages/effects/src/stock.ts                 |  33 +-
 packages/glyphcss/src/index.ts                |  12 +
 .../glyphcss/src/render/effectCompositor.ts   |  10 +-
 .../SynthWorkbench/SynthWorkbench.tsx         | 117 +++-
 8 files changed, 780 insertions(+), 22 deletions(-)
 create mode 100644 packages/effects/src/staticExport.test.ts
 create mode 100644 packages/effects/src/staticExport.ts

diff --git a/AGENTS.md b/AGENTS.md
index 2105a6fa..5ee92893 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -90,9 +90,11 @@ Because `rasterize` is pure (geometry + camera → string), a scene can be rende
 - **`compileScene(opts)`** (in `glyphcss`, pure) — polygons + camera + options → the `
` string. **Byte-identical to the runtime render** for the same inputs; same defaults as `createGlyphScene`. The foundation for every adapter.
 - **`@glyphcss/compile`** — Node adapters around `compileScene`: `loadMeshFromFile`, `compileFile`, a **Vite plugin** (`import x from "./m.glb?glyph&…"` → baked `
`), a **CLI** (`glyphcss-compile`), and `compileInteractive`.
 - **`GlyphSceneStatic`** (React + Vue) — SSR/SSG component that renders the compiled `
` with no client runtime (mirror of each other; static counterpart to `GlyphScene`).
+- **Frame-roll export** — `buildGlyphFramesExport(polygons, { frameCount, durationSec, rotX, rotY, zoom, … })` (pure, browser-safe, in `glyphcss`): bakes a turntable of `frameCount` full `compileScene` renders, stacks them in one `
`, and cycles them with a pure-CSS `steps()` animation — **zero runtime JS**, faithful per-face color, instant paint. Trade-offs: discrete angles (smoothness ∝ `frameCount`), fixed resolution, and payload grows **linearly** with `frameCount` (a colored frame costs several KB gzipped even after cross-frame color-class dedupe) — good for a handful of frames, not for a long or continuous loop.
 - **Interactive export** — `buildGlyphInteractiveExport(polygons, { interactions })` (pure, browser-safe, in `glyphcss`): the declared interactions (`orbit`/`zoom`/`pan`/`fpv`) drive **both** the wired control (only that one is imported → the snippet tree-shakes) **and** the decimation budget (`decimatePolygons` — coarser for orbit, finer when `zoom`/`fpv` let the camera approach). Output is a self-contained CDN+inlined-mesh snippet; `glyphCodepenPrefill` turns it into a CodePen POST (the gallery's "CodePen" button). Less declared interactivity = less mesh + less runtime shipped.
+- **Static effect export (field-synth)** — `buildGlyphFieldSynthStaticExport(polygons, { params, blend, loopSeconds, cols, rows, … })` (pure, browser-safe, in `@glyphcss/effects` — not `glyphcss`, since it needs a stock effect's own math): for an **effect-only, static-camera** scene (fixed mesh + camera, only the field-synth texture animating over `time`), bakes the static base grid plus each covered cell's resolved field-synth domain coordinate **once**, reusing glyphcss's real rasterizer + effect-input machinery, then ships a tiny hand-written vanilla-JS field-synth evaluator that recomputes the pattern every `requestAnimationFrame` — **zero imports, zero CDN, zero `glyphcss`/`@glyphcss/*` at runtime**. Fixed payload regardless of loop length and continuously smooth, unlike a frame-roll export whose payload grows with frame count; see `bench/static-effect-export.md` for the size/quality trade-off that motivated shipping this over more prebaked frames. Always reads the layer's **real** `blend` (`over` vs `replace`), never the effect definition's `defaultBlend` UI metadata. Field-synth only today — the `/synth` page's export button is the reference caller; a future effect id needs its own exported coordinate resolver (mirroring `fieldSynthCoordinate`) plus a hand-written inlined-JS port of its per-cell math, since there's no way to ship an arbitrary `GlyphEffectProgram.evaluate()` without shipping glyphcss's effect runtime alongside it.
 
-Dynamic Glyph Effect layers are runtime-only in the current slice. Static compilation and the interactive/CodePen exporters do not serialize or evaluate them yet; callers must not imply that an exported scene contains a mounted effect.
+Dynamic Glyph Effect layers are otherwise runtime-only: `compileScene`/`GlyphSceneStatic`, the frame-roll export, and the interactive/CodePen exporter do not serialize or evaluate a mounted effect, and callers must not imply that an export built from any of those carries one. `buildGlyphFieldSynthStaticExport` is the one exception, and only for its own effect-only, static-camera scenario — it does not generalize to a moving camera, multiple effects, or geometry animation.
 
 ## No per-frame DOM mutation
 
@@ -111,7 +113,7 @@ Every public export gets a `Glyph` prefix. Exceptions are generic math/geometry
 - **Hooks/composables:** `useGlyphCamera`, `useGlyphMesh`, `useGlyphSceneContext`, `useGlyphAnimation`.
 - **Components:** `GlyphScene`, `GlyphSceneStatic` (SSR/build-time `
`, no runtime), `GlyphEffectLayer`, `GlyphPerspectiveCamera`, `GlyphOrthographicCamera`, `GlyphOrbitControls`, `GlyphMapControls`, `GlyphFirstPersonControls`, `GlyphAxesHelper`, `GlyphDirectionalLightHelper`, `GlyphThreePerspectiveCamera`, `GlyphThreeOrthographicCamera`, `GlyphThreeMesh`.
 - **Types:** `GlyphDirectionalLight`, `GlyphAmbientLight`, `GlyphEffectDefinition`, `GlyphEffectProgram`, `GlyphEffectLayerHandle`, `GlyphAnimationMixer`, `GlyphAnimationAction`, `GlyphAnimationClip`, `GlyphAnimationTarget`.
-- **Functions:** `defineGlyphEffect`, `parseGlyphEffectColor`, `createGlyphAnimationMixer`, `injectGlyphBaseStyles`, `compileScene` (pure, DOM-less render → `
` string), `buildGlyphInteractiveExport` / `glyphCodepenPrefill` (polygons + declared interactions → portable self-contained snippet), `decimatePolygons` (core — resolution-target mesh simplification).
+- **Functions:** `defineGlyphEffect`, `parseGlyphEffectColor`, `createGlyphAnimationMixer`, `injectGlyphBaseStyles`, `compileScene` (pure, DOM-less render → `
` string), `buildGlyphFramesExport` (turntable → prebaked-frame `steps()` export), `buildGlyphInteractiveExport` / `glyphCodepenPrefill` (polygons + declared interactions → portable self-contained snippet), `buildGlyphFieldSynthStaticExport` (`@glyphcss/effects` — effect-only static-camera scene → self-contained inlined-JS pen), `decimatePolygons` (core — resolution-target mesh simplification).
 - **Vanilla factories:** `createGlyphScene`, `createGlyphCamera` (ortho alias), `createGlyphPerspectiveCamera`, `createGlyphOrthographicCamera`, `createGlyphOrbitControls`, `createGlyphMapControls`, `createGlyphFirstPersonControls`.
 - **HTML custom elements:** `glyph-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, ``, `` (ortho alias), ``, ``, ``. Any new element follows the same shape.
 - `GlyphCamera` is the ergonomic default alias — it resolves to `GlyphOrthographicCamera`. The voxel render mode and iso/diagrammatic scenes are glyphcss's differentiator; ortho is the more representative default.
diff --git a/packages/effects/src/index.ts b/packages/effects/src/index.ts
index b625faa7..5eacd7e4 100644
--- a/packages/effects/src/index.ts
+++ b/packages/effects/src/index.ts
@@ -26,3 +26,13 @@ export type {
   GlyphStockEffect,
   GlyphStockEffectDefinition,
 } from "./stock";
+
+// Static effect export — bake an effect-only, static-camera scene into a
+// self-contained pen (inlined vanilla-JS evaluator, zero glyphcss at runtime).
+// Field-synth only for now; see the module doc for what generalizing needs.
+export { buildGlyphFieldSynthStaticExport } from "./staticExport";
+export type {
+  GlyphFieldSynthStaticExportEffect,
+  GlyphFieldSynthStaticExportOptions,
+  GlyphFieldSynthStaticExportResult,
+} from "./staticExport";
diff --git a/packages/effects/src/staticExport.test.ts b/packages/effects/src/staticExport.test.ts
new file mode 100644
index 00000000..a7ea80f8
--- /dev/null
+++ b/packages/effects/src/staticExport.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it } from "vitest";
+import { spherePolygons, type Polygon } from "glyphcss";
+import { buildGlyphFieldSynthStaticExport, type GlyphFieldSynthStaticExportOptions } from "./staticExport";
+
+function mesh(): Polygon[] {
+  return spherePolygons({ center: [0, 0, 0], size: 4, subdivisions: 1, color: "#8fb3d9" });
+}
+
+function baseOptions(overrides: Partial = {}): GlyphFieldSynthStaticExportOptions {
+  return {
+    params: {
+      space: "surface",
+      scale: 2.5,
+      field1: "radial", wave1: "sin", freq1: 6, speed1: 0.5, amp1: 1,
+      field2: "angular", wave2: "saw", freq2: 4, speed2: 0.3, amp2: 0.6,
+      combine: "multiply",
+      glyphs: " .:-=+*#%@",
+      color: "#7df9ff",
+      colorB: "#ff4fa3",
+      gradient: 0.5,
+    },
+    blend: "replace",
+    loopSeconds: 4,
+    cols: 24,
+    rows: 12,
+    rotX: 62,
+    rotY: 38,
+    zoom: 3,
+    ...overrides,
+  };
+}
+
+describe("buildGlyphFieldSynthStaticExport", () => {
+  it("rejects an unsupported effect id", () => {
+    expect(() => buildGlyphFieldSynthStaticExport(mesh(), { ...baseOptions(), effect: "matrix-rain" as never }))
+      .toThrow(/field-synth/);
+  });
+
+  it("rejects a non-positive grid or loop", () => {
+    expect(() => buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ cols: 0 }))).toThrow();
+    expect(() => buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ rows: 0 }))).toThrow();
+    expect(() => buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ loopSeconds: 0 }))).toThrow();
+  });
+
+  it("produces a non-empty base frame", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions());
+    const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/);
+    expect(dataMatch).not.toBeNull();
+    const data = JSON.parse(dataMatch![1]!) as { c: number[]; r: number[]; x: number[]; bg: string[] };
+    expect(data.c.length).toBeGreaterThan(0);
+    expect(data.c.length).toBe(data.r.length);
+    expect(data.c.length).toBe(data.x.length);
+    expect(data.bg.some((g) => g !== " ")).toBe(true);
+  });
+
+  it("is fully self-contained: no imports, no network URLs, no @glyphcss package reference", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions());
+    for (const source of [result.html, result.css, result.js, result.pen.html, result.pen.css, result.pen.js]) {
+      expect(source).not.toMatch(/\bimport\s/);
+      expect(source).not.toMatch(/\brequire\(/);
+      expect(source).not.toMatch(/https?:\/\//);
+      expect(source).not.toMatch(/@glyphcss/);
+    }
+    // The JS payload specifically must never mention the `glyphcss` package
+    // itself (a title like "glyphcss field synth" in the HTML doc is fine —
+    // it's copy, not a dependency).
+    expect(result.js).not.toMatch(/\bglyphcss\b/);
+  });
+
+  it("inlines the field-synth math and an animation loop, with no glyphcss import", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions());
+    expect(result.js).toContain("requestAnimationFrame");
+    // Oscillator / combine / dither primitives — the hand-written vanilla-JS port.
+    expect(result.js).toContain("function osc(");
+    expect(result.js).toContain("function combine(");
+    expect(result.js).toContain("function noise3(");
+    expect(result.js).toContain("function thr(");
+    expect(result.html).toContain("
");
+  });
+
+  it("bakes the supplied params (loop seconds, ramp, combine mode) into CFG", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ loopSeconds: 7.5 }));
+    const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s);
+    expect(cfgMatch).not.toBeNull();
+    const cfg = JSON.parse(cfgMatch![1]!) as {
+      loop: number;
+      combine: string;
+      ramp: string[];
+      blend: string;
+      voices: { amp: number }[];
+    };
+    expect(cfg.loop).toBe(7.5);
+    expect(cfg.combine).toBe("multiply");
+    expect(cfg.ramp.join("")).toBe(" .:-=+*#%@");
+    // Only voices with amp > 0 are shipped (field1 + field2 above; the other four defaulted to amp 0).
+    expect(cfg.voices.length).toBe(2);
+  });
+
+  it("reads the REAL mounted blend verbatim instead of the effect definition's own defaultBlend", () => {
+    const replaceResult = buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ blend: "replace" }));
+    const overResult = buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ blend: "over" }));
+    expect(replaceResult.js).toMatch(/"blend":"replace"/);
+    expect(overResult.js).toMatch(/"blend":"over"/);
+  });
+
+  it("respects useColors: false by emitting plain-text output with no color spans", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ useColors: false }));
+    expect(result.js).toMatch(/"useColors":false/);
+    expect(result.js).toContain("pre.textContent=text");
+  });
+
+  it("throws when field-synth's own param validation rejects the patch", () => {
+    expect(() => buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ params: { glyphs: "" } }))).toThrow();
+  });
+});
diff --git a/packages/effects/src/staticExport.ts b/packages/effects/src/staticExport.ts
new file mode 100644
index 00000000..5d36b8b0
--- /dev/null
+++ b/packages/effects/src/staticExport.ts
@@ -0,0 +1,499 @@
+/**
+ * Static effect export — bakes an "effect only, static camera" scene into a
+ * self-contained pen (one HTML document, no imports, no CDN, no
+ * `glyphcss`/`@glyphcss/*` at runtime). Productionizes the strategy proven in
+ * `bench/static-effect-export.md` (Strategy B: minimal inlined vanilla-JS):
+ * bake the static base grid + each covered cell's resolved effect-domain
+ * coordinate ONCE, then ship a tiny hand-written evaluator that recomputes the
+ * pattern every `requestAnimationFrame` — smaller and smoother than a
+ * prebaked-frame `steps()` export for anything past a handful of frames, and
+ * unlike the live runtime, ships zero library code.
+ *
+ * The bake step reuses glyphcss's real, pure render + effect-input machinery
+ * (`buildRasterizeContext`, `rasterize`, `retainGlyphEffectOutput`) and this
+ * package's own field-synth coordinate resolution (`fieldSynthCoordinate` +
+ * friends, exported from `./stock` for exactly this reuse) — so the baked
+ * per-cell coordinates are byte-identical to what a mounted `` would read, not a hand-copied
+ * approximation. Only the PER-FRAME oscillator math is duplicated as plain JS
+ * text, because that is the entire point of the deliverable: a standalone
+ * evaluator with no glyphcss import.
+ *
+ * Scope: field-synth only. Generalizing to another stock effect needs two more
+ * things per effect id: (1) that effect's own coordinate resolver exported the
+ * same way fieldSynthCoordinate is, and (2) a hand-written inlined JS port of
+ * its per-cell math (there is no way to ship an arbitrary GlyphEffectProgram's
+ * `evaluate()` without shipping a JS engine's worth of glyphcss around it).
+ */
+import {
+  buildRasterizeContext,
+  rasterize,
+  retainGlyphEffectOutput,
+  parseGlyphEffectColor,
+  type CellGrid,
+  type GlyphAmbientLight,
+  type GlyphCamera,
+  type GlyphDirectionalLight,
+  type GlyphEffectBlend,
+  type GlyphEffectCoordinates,
+  type GlyphEffectOutputMetadata,
+  type GlyphEffectParamsOf,
+  type GlyphEffectScratchView,
+  type Polygon,
+  type RenderMode,
+  type RetainedGlyphEffectOutput,
+  createGlyphOrthographicCamera,
+  createGlyphPerspectiveCamera,
+  DEFAULT_PERSPECTIVE,
+} from "glyphcss";
+import {
+  fieldSynth,
+  findUvBounds,
+  generatedSurfaceField,
+  fieldSynthCoordinate,
+  defaultGlyphEffectParams,
+  SYNTH_VOICES,
+  type AnyContext,
+  type AnyParams,
+  type EffectSpace,
+} from "./stock";
+
+export type GlyphFieldSynthStaticExportEffect = "field-synth";
+
+export interface GlyphFieldSynthStaticExportOptions {
+  /** Which stock effect to export. Only `"field-synth"` is supported today (see module doc). */
+  effect?: GlyphFieldSynthStaticExportEffect;
+  /** field-synth params (a partial patch over its own defaults — same shape the live layer takes). `time` is ignored (the export always starts its own clock at 0). */
+  params: Partial>;
+  /** The blend the layer is ACTUALLY mounted with. Read verbatim — never defaulted from the effect definition's `defaultBlend` metadata, since `over` vs `replace` changes the composited look. */
+  blend: GlyphEffectBlend;
+  /** Effect layer opacity, matching `GlyphEffectLayerCommonOptions.opacity`. Default 1. */
+  opacity?: number;
+  /** Seconds before the client-side clock wraps (`time = (now/1000) % loopSeconds`). Longer = smoother-feeling for a slow patch, no payload cost either way (the baked table is time-independent). */
+  loopSeconds: number;
+  cols: number;
+  rows: number;
+  mode?: RenderMode;
+  cellAspect?: number;
+  useColors?: boolean;
+  rotX?: number;
+  rotY?: number;
+  zoom?: number;
+  projection?: "orthographic" | "perspective";
+  perspectivePx?: number;
+  fontSizePx?: number;
+  lineHeightPx?: number;
+  directionalLight?: GlyphDirectionalLight;
+  ambientLight?: GlyphAmbientLight;
+  title?: string;
+}
+
+export interface GlyphFieldSynthStaticExportResult {
+  /** Self-contained `…` document. */
+  html: string;
+  css: string;
+  js: string;
+  /** Split for CodePen prefill (mirrors `GlyphInteractiveExportResult.pen`). */
+  pen: { html: string; css: string; js: string };
+}
+
+const EMPTY_SCRATCH: GlyphEffectScratchView = {
+  images: [],
+  floatFields: [],
+  uintFields: [],
+  glyphFields: [],
+  samples: [],
+};
+
+function clamp01(value: number): number {
+  return value < 0 ? 0 : value > 1 ? 1 : value;
+}
+
+// Mirrors createGlyphScene's identical bbox→worldToSceneScale computation
+// (packages/glyphcss/src/api/createGlyphScene.ts) — plain bounding-box
+// arithmetic, not effect math, so a local copy carries none of the
+// silent-divergence risk the surface-basis functions would.
+function computeWorldToSceneScale(polygons: readonly Polygon[], cols: number, rows: number): number {
+  let minX = Infinity, minY = Infinity, minZ = Infinity;
+  let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
+  for (const polygon of polygons) {
+    for (const vertex of polygon.vertices) {
+      if (vertex[0] < minX) minX = vertex[0];
+      if (vertex[1] < minY) minY = vertex[1];
+      if (vertex[2] < minZ) minZ = vertex[2];
+      if (vertex[0] > maxX) maxX = vertex[0];
+      if (vertex[1] > maxY) maxY = vertex[1];
+      if (vertex[2] > maxZ) maxZ = vertex[2];
+    }
+  }
+  const span = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
+  return Number.isFinite(span) && span > 1e-9 ? Math.min(cols, rows) / span : 1;
+}
+
+function buildCamera(options: GlyphFieldSynthStaticExportOptions): GlyphCamera {
+  if (options.projection === "perspective") {
+    return createGlyphPerspectiveCamera({
+      rotX: options.rotX,
+      rotY: options.rotY,
+      zoom: options.zoom,
+      perspective: options.perspectivePx ?? DEFAULT_PERSPECTIVE,
+    });
+  }
+  return createGlyphOrthographicCamera({ rotX: options.rotX, rotY: options.rotY, zoom: options.zoom });
+}
+
+interface BakedCell {
+  col: number;
+  row: number;
+  x: number;
+  y: number;
+  cx: number;
+  cy: number;
+  shade: number;
+  glyph: string;
+  /** Packed 24-bit RGB, or `-1` for "no color" (matches the runtime's sentinel). */  color: number;
+}
+
+interface Baked {
+  cells: BakedCell[];
+  cols: number;
+  rows: number;
+}
+
+// Runs the REAL rasterizer + retains the base frame in the exact shape a
+// mounted effect layer's evaluate() reads (GlyphEffectFrameView + coverage),
+// then resolves each covered cell's field-synth domain coordinate with the
+// package's own (real, tested) fieldSynthCoordinate — not a re-derivation.
+function bake(
+  polygons: Polygon[],
+  options: GlyphFieldSynthStaticExportOptions,
+  params: GlyphEffectParamsOf,
+): Baked {
+  const mode: RenderMode = options.mode ?? "solid";
+  // AGENTS.md: worldPosition/normal/shade retention is solid-mode-only.
+  const retainOptional = mode === "solid";
+  const camera = buildCamera(options);
+  const worldToSceneScale = retainOptional
+    ? computeWorldToSceneScale(polygons, options.cols, options.rows)
+    : undefined;
+
+  const metadata: GlyphEffectOutputMetadata = {
+    id: "static-export",
+    // Never dereferenced: retainGlyphEffectOutput / fieldSynthCoordinate only
+    // read the geometry fields below; `.pre` only matters to a real DOM write,
+    // which this pure builder never performs.
+    pre: null as unknown as HTMLPreElement,
+    isBase: true,
+    cellToSceneGrid: [1, 0, 0, 1, 0, 0],
+    sceneGridSize: [options.cols, options.rows],
+    localCellFootprint: [1, 1],
+    ...(worldToSceneScale !== undefined ? { worldToSceneScale } : {}),
+  };
+
+  let retained: RetainedGlyphEffectOutput | null = null;
+  const ctx = buildRasterizeContext({
+    camera,
+    grid: { cols: options.cols, rows: options.rows, cellAspect: options.cellAspect ?? 2 },
+    polygons,
+    mode,
+    directionalLight: options.directionalLight ?? { direction: [0.5, 0.7, 0.5], intensity: 1 },
+    ambientLight: options.ambientLight ?? { intensity: 0.4 },
+    useColors: options.useColors ?? true,
+    retainShade: retainOptional,
+    retainWorldPosition: retainOptional,
+    retainNormal: retainOptional,
+  });
+  ctx.transformCells = (grid: CellGrid) => {
+    retained = retainGlyphEffectOutput(grid, metadata);
+    return grid;
+  };
+  rasterize(ctx);
+  if (retained === null) throw new Error("glyphcss: static field-synth export — rasterize did not produce a base frame.");
+  const baked: RetainedGlyphEffectOutput = retained;
+
+  const coordinates: GlyphEffectCoordinates = {
+    cellToSceneGrid: metadata.cellToSceneGrid,
+    sceneGridSize: metadata.sceneGridSize,
+    localCellFootprint: metadata.localCellFootprint,
+    ...(metadata.worldToSceneScale !== undefined ? { worldToSceneScale: metadata.worldToSceneScale } : {}),
+  };
+  const context: AnyContext = {
+    params: params as unknown as AnyParams,
+    state: undefined,
+    base: baked.base,
+    input: baked.base,
+    target: { coverage: baked.baseCoverage },
+    coordinates,
+    scratch: EMPTY_SCRATCH,
+    output: baked.emission,
+  };
+
+  const uvBounds = findUvBounds(context);
+  const [sceneCols, sceneRows] = coordinates.sceneGridSize;
+  const generatedSurface = params.space !== "scene" && !(params.space === "auto" && uvBounds)
+    ? generatedSurfaceField(context)
+    : undefined;
+
+  const cells: BakedCell[] = [];
+  const n = baked.base.length;
+  for (let i = 0; i < n; i++) {
+    if (baked.baseCoverage[i]! <= 0) continue;
+    const coord = fieldSynthCoordinate(
+      context,
+      i,
+      params.space as EffectSpace,
+      uvBounds,
+      params.scale,
+      params.originU,
+      params.originV,
+      sceneCols,
+      sceneRows,
+      generatedSurface,
+    );
+    if (!coord) continue;
+    const [x, y, cx, cy] = coord;
+    const shadeArr = baked.base.shade;
+    const sh = shadeArr ? shadeArr[i]! : Number.NaN;
+    const glyph = baked.baseGrid.char[i] ?? " ";
+    const colorHex = baked.baseGrid.color[i] ?? null;
+    cells.push({
+      col: i % sceneCols,
+      row: (i / sceneCols) | 0,
+      x,
+      y,
+      cx,
+      cy,
+      shade: Number.isFinite(sh) ? sh : 1,
+      glyph,
+      color: colorHex ? parseGlyphEffectColor(colorHex).packed : -1,
+    });
+  }
+  return { cells, cols: options.cols, rows: options.rows };
+}
+
+function jsonForScript(value: unknown): string {
+  return JSON.stringify(value)
+    .replace(//g, "\\u003e")
+    .replace(/&/g, "\\u0026")
+    .replace(/\u2028/g, "\\u2028")
+    .replace(/\u2029/g, "\\u2029");
+}
+
+// Shared vanilla-JS runtime, generalized from the bench's validated
+// Strategy B evaluator: N active oscillators (not a fixed set), the general
+// blend formula (`over` OR `replace`, with opacity) instead of a
+// replace-only shortcut, and optional per-voice color mixing. Reproduces
+// `combineSynth`/`synthOsc`/`synthWave`/`synthNoise3`/`lerpPacked`/
+// `scalePackedColor` (packages/effects/src/stock.ts) and the compositor's
+// blend + Bayer coverage dither (packages/glyphcss/src/render/
+// effectCompositor.ts `blendPackedColor`/`coverageThreshold`) as plain JS —
+// this text is the one piece that can't be "reused" instead of hand-written,
+// since it has to run with zero glyphcss/effects code alongside it.
+const RUNTIME_JS = `
+"use strict";
+var D=DATA,C=CFG,N=D.c.length,ramp=C.ramp,rmax=ramp.length-1,V=C.voices;
+var BAYER=[0,8,2,10,12,4,14,6,3,11,1,9,15,7,13,5];
+function pmod(a,m){return((a%m)+m)%m}
+function clamp01(v){return v<0?0:v>1?1:v}
+function wave(k,t){var p=t-Math.floor(t);if(k==="triangle")return 4*Math.abs(p-0.5)-1;if(k==="saw")return 2*p-1;if(k==="square")return p<0.5?1:-1;return Math.sin(t*Math.PI*2)}
+function h3(x,y,z){var h=Math.sin(x*127.1+y*311.7+z*74.7)*43758.5453;return h-Math.floor(h)}
+function noise3(x,y,z){var xi=Math.floor(x),yi=Math.floor(y),zi=Math.floor(z),xf=x-xi,yf=y-yi,zf=z-zi;
+var u=xf*xf*(3-2*xf),v=yf*yf*(3-2*yf),w=zf*zf*(3-2*zf);
+var a000=h3(xi,yi,zi),a100=h3(xi+1,yi,zi),a010=h3(xi,yi+1,zi),a110=h3(xi+1,yi+1,zi),a001=h3(xi,yi,zi+1),a101=h3(xi+1,yi,zi+1),a011=h3(xi,yi+1,zi+1),a111=h3(xi+1,yi+1,zi+1);
+var f0=(a000*(1-u)+a100*u)*(1-v)+(a010*(1-u)+a110*u)*v,f1=(a001*(1-u)+a101*u)*(1-v)+(a011*(1-u)+a111*u)*v;return f0*(1-w)+f1*w}
+function osc(o,x,y,cx,cy,t){if(o.field==="noise")return 2*noise3(x*o.freq,y*o.freq,t*o.speed)-1;var raw;
+switch(o.field){case"linearX":raw=x;break;case"linearY":raw=y;break;case"diagonal":raw=(x+y)*0.70710678;break;
+case"angular":raw=Math.atan2(y-cy,x-cx)/(Math.PI*2);break;case"spiral":raw=Math.hypot(x-cx,y-cy)+Math.atan2(y-cy,x-cx)/(Math.PI*2);break;default:raw=Math.hypot(x-cx,y-cy)}
+return wave(o.wave,raw*o.freq-t*o.speed)}
+function combine(a,b){switch(C.combine){case"add":return a+b;case"max":return Math.max(a,b);case"min":return Math.min(a,b);case"difference":return Math.abs(a-b);default:return a*b}}
+function lerp(a,b,t){var ar=(a>>16)&255,ag=(a>>8)&255,ab=a&255,br=(b>>16)&255,bg=(b>>8)&255,bb=b&255;
+return(Math.round(ar+(br-ar)*t)<<16)|(Math.round(ag+(bg-ag)*t)<<8)|Math.round(ab+(bb-ab)*t)}
+function shadeColor(p,s){var r=Math.round(((p>>16)&255)*s),g=Math.round(((p>>8)&255)*s),b=Math.round((p&255)*s);return(r<<16)|(g<<8)|b}
+function blendColor(input,emitted,iw,ew){var ip=input!==-1,ep=emitted!==-1;
+if(ip&&ep){var t=iw+ew;if(t<=0)return emitted;
+var r=Math.floor((((input>>16)&255)*iw+((emitted>>16)&255)*ew)/t+0.5),g=Math.floor((((input>>8)&255)*iw+((emitted>>8)&255)*ew)/t+0.5),b=Math.floor(((input&255)*iw+(emitted&255)*ew)/t+0.5);
+return(r<<16)|(g<<8)|b}
+if(ip!==ep)return ew>=iw?emitted:input;return-1}
+function thr(col,rw){var x=col+0.5,y=rw+0.5,mx=Math.floor(4*x),my=Math.floor(4*y);
+var coarse=BAYER[pmod(Math.floor(my/4),4)*4+pmod(Math.floor(mx/4),4)],fine=BAYER[pmod(my,4)*4+pmod(mx,4)];return(16*coarse+fine+0.5)/256}
+var pre=document.getElementById("g");
+var rowBuf=new Array(C.rows);
+function frame(now){var t=(now/1000)%C.loop;
+for(var i=0;i>16)&255,g=(c.p>>8)&255,b=c.p&255;
+cr+=r*w;cg+=g*w;cbv+=b*w;co+=c.o*w;cw+=w;car+=r*V[j].amp;cag+=g*V[j].amp;cabv+=b*V[j].amp;cao+=c.o*V[j].amp;caw+=V[j].amp}}
+var value=clamp01(C.bias+C.gain*combined*0.5);
+var packed=-1,resolvedOpacity=0;
+if(value>0){
+if(C.voiceColors&&cw>0){packed=(Math.round(cr/cw)<<16)|(Math.round(cg/cw)<<8)|Math.round(cbv/cw);resolvedOpacity=co/cw}
+else if(C.voiceColors&&caw>0){packed=(Math.round(car/caw)<<16)|(Math.round(cag/caw)<<8)|Math.round(cabv/caw);resolvedOpacity=cao/caw}
+else{packed=C.gradient>0?lerp(C.cA.p,C.cB.p,clamp01(value*C.gradient)):C.cA.p;resolvedOpacity=C.cA.o}
+if(C.lit>0)packed=shadeColor(packed,1-C.lit*(1-clamp01(D.sh[k])))}
+var emittedCoverage=value>0?clamp01(value*resolvedOpacity):0;
+var inputCoverage=D.bg[k]!==" "?1:0;
+var emittedWeight=emittedCoverage*C.opacity;
+var inputWeight=C.blend==="over"?inputCoverage*(1-emittedWeight):inputCoverage*(1-C.opacity);
+var nextCoverage=clamp01(emittedWeight+inputWeight);
+var col=D.c[k],rw=D.r[k];
+var visible=nextCoverage>=1||(nextCoverage>0&&nextCoverage>thr(col,rw));
+if(!visible)continue;
+var chooseEmitted=emittedWeight>=inputWeight;
+var g=chooseEmitted?ramp[Math.min(rmax,Math.max(0,Math.round(value*rmax)))]:D.bg[k];
+var pk=blendColor(D.bc[k],packed,inputWeight,emittedWeight);
+rowBuf[rw].push([col,g,pk])}
+if(C.useColors){var html="";
+for(var r2=0;r2";line+=pk2===-1?"":"";open=true;prevColor=pk2}
+line+=gg;cur=cc}
+if(open)line+="";html+=line+"\\n"}
+pre.innerHTML=html}else{var text="";
+for(var r3=0;r3, options: GlyphFieldSynthStaticExportOptions): { js: string; gridCols: number; gridRows: number } {
+  let minCol = Infinity, maxCol = -Infinity, minRow = Infinity, maxRow = -Infinity;
+  for (const c of baked.cells) {
+    if (c.col < minCol) minCol = c.col;
+    if (c.col > maxCol) maxCol = c.col;
+    if (c.row < minRow) minRow = c.row;
+    if (c.row > maxRow) maxRow = c.row;
+  }
+  if (!Number.isFinite(minCol)) { minCol = 0; maxCol = -1; minRow = 0; maxRow = -1; }
+  const gridCols = Math.max(0, maxCol - minCol + 1);
+  const gridRows = Math.max(0, maxRow - minRow + 1);
+
+  const q = (n: number) => Math.round(n * 1000) / 1000;
+  const col: number[] = [], row: number[] = [], X: number[] = [], Y: number[] = [];
+  const CXa: number[] = [], CYa: number[] = [], SH: number[] = [], BG: string[] = [], BC: number[] = [];
+  // `cx,cy` are per-coplanar-group constants; in the common single-facet /
+  // uv-space / scene-space cases they're a SINGLE constant across the whole
+  // bake — detected and hoisted to a scalar so the (usually large) per-cell
+  // arrays don't repeat it. Multi-facet meshes (several differently-oriented
+  // coplanar groups) keep the per-cell arrays; deduping those too by group id
+  // is a further, not-yet-implemented win (see module doc / AGENTS.md).
+  let cxFixed = true, cyFixed = true;
+  const cx0 = baked.cells[0]?.cx ?? 0, cy0 = baked.cells[0]?.cy ?? 0;
+  for (const c of baked.cells) {
+    if (Math.abs(c.cx - cx0) > 1e-6) cxFixed = false;
+    if (Math.abs(c.cy - cy0) > 1e-6) cyFixed = false;
+  }
+  for (const c of baked.cells) {
+    col.push(c.col - minCol);
+    row.push(c.row - minRow);
+    X.push(q(c.x));
+    Y.push(q(c.y));
+    if (!cxFixed) CXa.push(q(c.cx));
+    if (!cyFixed) CYa.push(q(c.cy));
+    SH.push(Math.round(c.shade * 100) / 100);
+    BG.push(c.glyph);
+    BC.push(c.color);
+  }
+  const data: Record = { c: col, r: row, x: X, y: Y, sh: SH, bg: BG, bc: BC };
+  if (!cxFixed) data.cx = CXa;
+  if (!cyFixed) data.cy = CYa;
+
+  const voices: { field: string; wave: string; freq: number; speed: number; amp: number; color: { p: number; o: number } }[] = [];
+  for (let k = 1; k <= SYNTH_VOICES; k++) {
+    const amp = (params as unknown as AnyParams)[`amp${k}`] as number;
+    if (!(amp > 0)) continue;
+    const parsedColor = parseGlyphEffectColor((params as unknown as AnyParams)[`color${k}`] as string);
+    voices.push({
+      field: (params as unknown as AnyParams)[`field${k}`] as string,
+      wave: (params as unknown as AnyParams)[`wave${k}`] as string,
+      freq: (params as unknown as AnyParams)[`freq${k}`] as number,
+      speed: (params as unknown as AnyParams)[`speed${k}`] as number,
+      amp,
+      color: { p: parsedColor.packed, o: parsedColor.opacity },
+    });
+  }
+  const cA = parseGlyphEffectColor(params.color);
+  const cB = parseGlyphEffectColor(params.colorB);
+
+  const cfg = {
+    rows: gridRows,
+    loop: options.loopSeconds,
+    combine: params.combine,
+    gradient: params.gradient,
+    lit: params.lit,
+    gain: params.gain,
+    bias: params.bias,
+    ramp: Array.from(params.glyphs),
+    cA: { p: cA.packed, o: cA.opacity },
+    cB: { p: cB.packed, o: cB.opacity },
+    voiceColors: params.voiceColors,
+    voices,
+    blend: options.blend,
+    opacity: clamp01(options.opacity ?? 1),
+    useColors: options.useColors ?? true,
+    cxFixed,
+    cyFixed,
+    cx: cxFixed ? q(cx0) : 0,
+    cy: cyFixed ? q(cy0) : 0,
+  };
+
+  const js = `var DATA=${jsonForScript(data)};var CFG=${jsonForScript(cfg)};${RUNTIME_JS}`;
+  return { js, gridCols, gridRows };
+}
+
+/**
+ * Bake the current field-synth patch over a static-camera mesh into a
+ * self-contained pen: inlined per-cell coordinates + a tiny hand-written
+ * vanilla-JS evaluator, animated with `requestAnimationFrame`. Zero imports,
+ * zero CDN, zero `glyphcss`/`@glyphcss/*` at runtime.
+ */
+export function buildGlyphFieldSynthStaticExport(
+  polygons: Polygon[],
+  options: GlyphFieldSynthStaticExportOptions,
+): GlyphFieldSynthStaticExportResult {
+  if (options.effect !== undefined && options.effect !== "field-synth") {
+    throw new Error(
+      `glyphcss: buildGlyphFieldSynthStaticExport only supports the "field-synth" effect (got "${options.effect}"). `
+      + "Generalizing to another stock effect needs that effect's own coordinate resolver exported from " + "@glyphcss/effects and a hand-written inlined JS port of its per-cell math — see the module doc.",
+    );
+  }
+  if (!(options.cols > 0) || !(options.rows > 0)) {
+    throw new RangeError("glyphcss: buildGlyphFieldSynthStaticExport requires cols/rows > 0.");
+  }
+  if (!(options.loopSeconds > 0)) {
+    throw new RangeError("glyphcss: buildGlyphFieldSynthStaticExport requires loopSeconds > 0.");
+  }
+
+  const params = { ...defaultGlyphEffectParams(fieldSynth), ...options.params, time: 0 } as GlyphEffectParamsOf;
+  fieldSynth.program.validateParams?.(params);
+
+  const baked = bake(polygons, options, params);
+  const { js, gridCols, gridRows } = buildRuntime(baked, params, options);
+
+  const fontSizePx = options.fontSizePx ?? 12;
+  const lineHeightPx = options.lineHeightPx ?? fontSizePx;
+  const css = `html,body{margin:0;height:100%;background:#0b0d10;display:grid;place-items:center}
+#g{margin:0;white-space:pre;font-family:ui-monospace,Menlo,monospace;font-size:${fontSizePx}px;line-height:${lineHeightPx}px;color:#ccc;width:${gridCols}ch;height:${gridRows * lineHeightPx}px}`;
+  const title = options.title ?? "glyphcss field synth";
+  const bodyHtml = `
`;
+  const html = `${title}${bodyHtml}`;
+
+  return {
+    html,
+    css,
+    js,
+    pen: { html: bodyHtml, css, js },
+  };
+}
diff --git a/packages/effects/src/stock.ts b/packages/effects/src/stock.ts
index f9acfbe7..481c3c5b 100644
--- a/packages/effects/src/stock.ts
+++ b/packages/effects/src/stock.ts
@@ -24,13 +24,16 @@ export interface GlyphStockEffectDefinition<
   readonly presets?: readonly GlyphEffectPreset[];
 }
 
-type AnyParams = Record;
-type AnyContext

= GlyphEffectEvaluateContext; +// Exported (alongside a handful of coordinate-resolution internals below) so +// `staticExport.ts`'s build-time baker can construct the same evaluate() +// context shape and reuse the real surface-basis math instead of copying it. +export type AnyParams = Record; +export type AnyContext

= GlyphEffectEvaluateContext; const GLYPH = GlyphEffectOutputChannel.Glyph; const COLOR = GlyphEffectOutputChannel.Color; -interface SurfaceMetricAccumulator { +export interface SurfaceMetricAccumulator { count: number; sumX: number; sumY: number; @@ -58,7 +61,7 @@ interface SurfaceMetricAccumulator { maxV: number; } -interface GeneratedSurfaceField { +export interface GeneratedSurfaceField { readonly normal: object; readonly cols: number; readonly rows: number; @@ -68,7 +71,10 @@ interface GeneratedSurfaceField { readonly groups: readonly SurfaceMetricAccumulator[]; } -type EffectSpace = "auto" | "surface" | "scene"; +// Exported so a build-time exporter (see `staticExport.ts`) can resolve the +// SAME domain coordinate a mounted effect layer's `evaluate()` reads, instead +// of re-deriving the surface-basis / coplanar-group math by hand. +export type EffectSpace = "auto" | "surface" | "scene"; type EffectDirection = "down" | "up" | "right" | "left"; const GENERATED_SURFACE_PITCH = 4; const generatedSurfaceFieldCache = new WeakMap(); @@ -155,7 +161,7 @@ function glyphRamp(value: string): string[] { // Callers only truth-test the result as an "authored UVs are usable" gate — // no caller reads bound values, so this returns the gate directly instead of // a bounds struct. -function findUvBounds

(context: AnyContext

): boolean { +export function findUvBounds

(context: AnyContext

): boolean { const uv = context.base.uv0; if (!uv) return false; let minU = Infinity; @@ -385,7 +391,7 @@ function solveSurfaceMetric(group: SurfaceMetricAccumulator): void { group.dyDv = dyDv * vScale; } -function generatedSurfaceField

(context: AnyContext

): GeneratedSurfaceField | null { +export function generatedSurfaceField

(context: AnyContext

): GeneratedSurfaceField | null { const position = context.base.worldPosition; const normal = context.base.normal; if (!position || !normal || typeof position !== "object" || typeof normal !== "object") return null; @@ -981,10 +987,13 @@ export const ripple: GlyphStockEffectDefinition = { // patterns (moiré, plaid, sonar, lattices) come from. Runs over surfaces via // `space`. -const SYNTH_VOICES = 6; -const SYNTH_FIELDS = ["radial", "linearX", "linearY", "diagonal", "angular", "spiral", "noise"] as const; -const SYNTH_WAVES = ["sin", "triangle", "saw", "square"] as const; -const SYNTH_COMBINES = ["add", "multiply", "max", "min", "difference"] as const; +// Exported so `staticExport.ts` can enumerate voices / validate field-wave- +// combine names against the same lists the schema uses, instead of a second +// hardcoded copy that could drift. +export const SYNTH_VOICES = 6; +export const SYNTH_FIELDS = ["radial", "linearX", "linearY", "diagonal", "angular", "spiral", "noise"] as const; +export const SYNTH_WAVES = ["sin", "triangle", "saw", "square"] as const; +export const SYNTH_COMBINES = ["add", "multiply", "max", "min", "difference"] as const; function synthWave(kind: string, t: number): number { const p = t - Math.floor(t); // 0..1 @@ -1138,7 +1147,7 @@ interface SynthVoice { // matrixRain reads, just with min/max also tracked), returning a per-cell // (cx, cy) alongside (x, y). UV and scene branches keep the original // origin*scale center untouched. -function fieldSynthCoordinate

( +export function fieldSynthCoordinate

( context: AnyContext

, index: number, space: EffectSpace, diff --git a/packages/glyphcss/src/index.ts b/packages/glyphcss/src/index.ts index fe08307e..2fdc5f64 100644 --- a/packages/glyphcss/src/index.ts +++ b/packages/glyphcss/src/index.ts @@ -28,6 +28,18 @@ export type { // Effect-program protocol + scene-root compositor layers. export * from "./api/effects"; +// Retained-effect base frame — turns a rasterized `CellGrid` (with optional +// shade/worldPosition/normal buffers) into the `GlyphEffectFrameView` + +// coverage shape an effect program's `evaluate()` reads. Exposed so a +// build-time exporter (e.g. `@glyphcss/effects`'s static field-synth export) +// can bake the SAME per-cell inputs a mounted effect layer sees, instead of +// re-deriving coverage/color-packing from a `CellGrid` by hand. +export { retainGlyphEffectOutput } from "./render/effectCompositor"; +export type { + GlyphEffectOutputMetadata, + RetainedGlyphEffectOutput, +} from "./render/effectCompositor"; + // Static compile — render a scene to its `

` without a DOM (build-time / SSR).
 export { compileScene } from "./api/compileScene";
 export type { CompileSceneOptions, CompileSceneResult } from "./api/compileScene";
diff --git a/packages/glyphcss/src/render/effectCompositor.ts b/packages/glyphcss/src/render/effectCompositor.ts
index a18eac36..03e36bb9 100644
--- a/packages/glyphcss/src/render/effectCompositor.ts
+++ b/packages/glyphcss/src/render/effectCompositor.ts
@@ -25,7 +25,15 @@ import {
 } from "../api/effects";
 
 type AnyParams = Record;
-type AnyProgram = GlyphEffectProgram;
+// `unknown` (not `any`) for the state param: `GlyphEffectProgram`'s
+// `createState` field type is a conditional on `[S] extends [undefined]`, and
+// a bare `any` there triggers TS's "any collapses a conditional type to the
+// union of both branches" rule, which made `program.createState` resolve to
+// an uncallable `never` at the one call site below. `unknown` picks the
+// `{ createState(): unknown }` branch cleanly — correct anyway, since a
+// runtime-dispatched program's state is always handled opaquely (cast at the
+// point of use), never actually relied on to BE `any`.
+type AnyProgram = GlyphEffectProgram;
 
 const SUPPORTED_REQUIREMENTS = new Set([
   "baseColor",
diff --git a/website/src/components/SynthWorkbench/SynthWorkbench.tsx b/website/src/components/SynthWorkbench/SynthWorkbench.tsx
index 62c97ca1..fea42f3d 100644
--- a/website/src/components/SynthWorkbench/SynthWorkbench.tsx
+++ b/website/src/components/SynthWorkbench/SynthWorkbench.tsx
@@ -5,17 +5,31 @@ import {
   createGlyphOrbitControls,
   injectGlyphBaseStyles,
   resolveGeometry,
+  type GlyphEffectBlend,
   type GlyphGeometryName,
   type GlyphSceneHandle,
 } from "glyphcss";
-import { GlyphFieldSynthEffect as fieldSynth, defaultGlyphEffectParams, GlyphRamps } from "@glyphcss/effects";
-import type { GlyphEffectPreset } from "@glyphcss/effects";
+import {
+  GlyphFieldSynthEffect as fieldSynth,
+  buildGlyphFieldSynthStaticExport,
+  defaultGlyphEffectParams,
+  GlyphRamps,
+} from "@glyphcss/effects";
+import type { GlyphEffectPreset, GlyphFieldSynthStaticExportResult } from "@glyphcss/effects";
 import { Dock } from "../Dock";
 import { useDockGui } from "../Dock/slots";
-import { useColor, useFolder, useOption, useSlider, useText, useToggle } from "../Dock/primitives";
+import { useButton, useColor, useFolder, useOption, useReadonlyText, useSlider, useText, useToggle } from "../Dock/primitives";
 import "../GalleryWorkbench/gallery-workbench.css";
 import "./synth-workbench.css";
 
+// The ONE blend both `scene.addEffectLayer()` calls below mount the layer
+// with. The static export must read the layer's REAL blend rather than the
+// effect definition's own `defaultBlend` metadata (see
+// `GlyphFieldSynthStaticExportOptions.blend` doc) — sharing this constant
+// keeps the exported pen from silently drifting off whatever the live scene
+// actually renders with.
+const SYNTH_EFFECT_BLEND: GlyphEffectBlend = "replace";
+
 type ParamValue = number | string | boolean;
 type Params = Record;
 type Polys = ReturnType;
@@ -158,7 +172,7 @@ function useSynthPreview(host: HTMLElement | null, getParams: () => Params, deps
     const polys = flatQuad(3);
     scene.add(polys); scene.fit(); scene.rerender();
     frameObject(scene, camera, polys, 0.98);
-    const layer = scene.addEffectLayer({ effect: fieldSynth, params: getParams(), blend: "replace", target: "surfaces" });
+    const layer = scene.addEffectLayer({ effect: fieldSynth, params: getParams(), blend: SYNTH_EFFECT_BLEND, target: "surfaces" });
     layerRef.current = layer as unknown as { setParams: (p: Params) => void; dispose: () => void };
     scene.rerender();
     let last = performance.now(), t = 0, raf = 0;
@@ -215,12 +229,13 @@ function PresetTile({ preset, onApply }: { preset: GlyphEffectPreset; onA
 }
 
 // ── Right dock controls (stage / mix / output) ────────────────────────────────
-function SynthDock({ shape, onShape, timeScale, onTimeScale, paused, onPaused, density, onDensity, lighting, onLight, params, onParam }: {
+function SynthDock({ shape, onShape, timeScale, onTimeScale, paused, onPaused, density, onDensity, lighting, onLight, params, onParam, onExportCodepen, onExportCopyHtml, exportStatus }: {
   shape: string; onShape: (s: string) => void;
   timeScale: number; onTimeScale: (n: number) => void; paused: boolean; onPaused: (b: boolean) => void;
   density: number; onDensity: (n: number) => void;
   lighting: Lighting; onLight: (partial: Partial) => void;
   params: Params; onParam: (key: string, value: ParamValue) => void;
+  onExportCodepen: () => void; onExportCopyHtml: () => void; exportStatus: string;
 }): null {
   const gui = useDockGui();
   const s = (k: string) => String(params[k] ?? "");
@@ -256,6 +271,16 @@ function SynthDock({ shape, onShape, timeScale, onTimeScale, paused, onPaused, d
   useSlider(light, "Key", { min: 0, max: 2, step: 0.05 }, lighting.keyIntensity, (v) => onLight({ keyIntensity: v }));
   useColor(light, "Key color", lighting.keyColor, (v) => onLight({ keyColor: v }));
   useSlider(light, "Ambient", { min: 0, max: 1, step: 0.05 }, lighting.ambient, (v) => onLight({ ambient: v }));
+
+  // Static export — bakes the current patch into a self-contained pen (inlined
+  // vanilla-JS field-synth evaluator, zero glyphcss at runtime). Both actions
+  // build the SAME export; one opens it in CodePen, the other copies the full
+  // standalone HTML document so it can be tested locally (drop into a file and
+  // open it — no server, no build step).
+  const exportFolder = useFolder(gui, "Export", { open: false });
+  useButton(exportFolder, "Open in CodePen", onExportCodepen);
+  useButton(exportFolder, "Copy standalone HTML", onExportCopyHtml);
+  useReadonlyText(exportFolder, "Status", exportStatus);
   return null;
 }
 
@@ -319,7 +344,7 @@ export default function SynthWorkbench() {
     // non-square viewport instead of "contain"-fitting with letterbox margins.
     frameObject(scene, camera, polys, flat ? 1.02 : 0.72, flat);
     scene.rerender();
-    const layer = scene.addEffectLayer({ effect: fieldSynth, params: paramsRef.current, blend: "replace", target: "surfaces" });
+    const layer = scene.addEffectLayer({ effect: fieldSynth, params: paramsRef.current, blend: SYNTH_EFFECT_BLEND, target: "surfaces" });
     layerRef.current = layer as unknown as { setParams: (p: Params) => void; dispose: () => void };
     sceneRef.current = scene; cameraRef.current = camera;
     let last = performance.now(), t = 0, raf = 0;
@@ -405,6 +430,84 @@ export default function SynthWorkbench() {
 
   const presets = useMemo(() => (fieldSynth.presets ?? []) as readonly GlyphEffectPreset[], []);
 
+  // ── Static export (effect-only, static-camera self-contained pen) ──────────
+  const [exportStatus, setExportStatus] = useState("");
+
+  // Builds the SAME export `buildGlyphFieldSynthStaticExport` bakes for both
+  // "open in CodePen" and "copy standalone HTML" — reads the mesh, the current
+  // patch, the camera, the density-driven grid, and the blend the layer is
+  // ACTUALLY mounted with (SYNTH_EFFECT_BLEND), so the pen matches what's on
+  // screen. `loopSeconds` scales inversely with the live time-scale so a
+  // faster-animating patch doesn't wrap after an unreasonably long wall-clock
+  // wait, and vice-versa; the exported clock is otherwise independent — it
+  // starts fresh from `time=0` on load, not from wherever the live preview is.
+  const buildSynthExport = useCallback((): GlyphFieldSynthStaticExportResult | null => {
+    const scene = sceneRef.current, camera = cameraRef.current, host = hostRef.current;
+    if (!scene || !camera || !host) return null;
+    const pre = host.querySelector("pre.glyph-output") as HTMLElement | null;
+    if (!pre) return null;
+    const rect = pre.getBoundingClientRect();
+    const lines = (pre.textContent ?? "").replace(/\s+$/, "").split("\n");
+    const rows = Math.max(1, lines.length);
+    const cols = Math.max(1, lines.reduce((m, l) => Math.max(m, l.length), 1));
+    const cs = getComputedStyle(pre);
+    const fontSizePx = parseFloat(cs.fontSize) || 13;
+    const lineHeightPx = cs.lineHeight === "normal" ? fontSizePx * 1.2 : (parseFloat(cs.lineHeight) || fontSizePx);
+    const { cellAspect } = scene.getOptions();
+    const loopSeconds = Math.max(4, 40 / Math.max(0.05, timeScale));
+    return buildGlyphFieldSynthStaticExport(shapePolys(shape), {
+      params,
+      blend: SYNTH_EFFECT_BLEND,
+      loopSeconds,
+      cols,
+      rows,
+      cellAspect,
+      mode: "solid",
+      useColors: true,
+      rotX: camera.rotX,
+      rotY: camera.rotY,
+      zoom: camera.zoom,
+      projection: "orthographic",
+      fontSizePx,
+      lineHeightPx: rect.height > 0 && rows > 0 ? rect.height / rows : lineHeightPx,
+      directionalLight: buildLighting(lighting).directionalLight,
+      ambientLight: buildLighting(lighting).ambientLight,
+      title: `glyphcss field synth — ${shape}`,
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [shape, params, lighting, timeScale]);
+
+  /** POST a CodePen prefill payload (opens a new pen in a new tab) — mirrors the gallery's CodePanel. */
+  function postToCodepen(title: string, pen: { html: string; css: string; js: string }): void {
+    const form = document.createElement("form");
+    form.method = "POST";
+    form.action = "https://codepen.io/pen/define";
+    form.target = "_blank";
+    const input = document.createElement("input");
+    input.type = "hidden";
+    input.name = "data";
+    input.value = JSON.stringify({ title, ...pen, editors: "110" });
+    form.appendChild(input);
+    document.body.appendChild(form);
+    form.submit();
+    form.remove();
+  }
+
+  const handleExportCodepen = useCallback(() => {
+    const result = buildSynthExport();
+    if (!result) { setExportStatus("Nothing rendered yet"); return; }
+    postToCodepen(`glyphcss field synth — ${shape}`, result.pen);
+    setExportStatus("Opened in CodePen");
+  }, [buildSynthExport, shape]);
+
+  const handleExportCopyHtml = useCallback(() => {
+    const result = buildSynthExport();
+    if (!result) { setExportStatus("Nothing rendered yet"); return; }
+    navigator.clipboard.writeText(result.html)
+      .then(() => setExportStatus("Copied standalone HTML"))
+      .catch(() => setExportStatus("Copy failed — clipboard permission?"));
+  }, [buildSynthExport]);
+
   return (
     
@@ -424,7 +527,7 @@ export default function SynthWorkbench() {
- setLighting((l) => ({ ...l, ...partial }))} params={params} onParam={onParam} /> + setLighting((l) => ({ ...l, ...partial }))} params={params} onParam={onParam} onExportCodepen={handleExportCodepen} onExportCopyHtml={handleExportCopyHtml} exportStatus={exportStatus} />
From 657c998f38b770fd2ca837add5ccd66bd72c31d8 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 17:43:42 +0200 Subject: [PATCH 05/23] feat(website): match synth voice sliders to Dock; mobile drawer layout mirroring gallery --- .../SynthWorkbench/SynthWorkbench.tsx | 54 +++++- .../SynthWorkbench/synth-workbench.css | 167 ++++++++++++++++-- 2 files changed, 204 insertions(+), 17 deletions(-) diff --git a/website/src/components/SynthWorkbench/SynthWorkbench.tsx b/website/src/components/SynthWorkbench/SynthWorkbench.tsx index fea42f3d..82bceca7 100644 --- a/website/src/components/SynthWorkbench/SynthWorkbench.tsx +++ b/website/src/components/SynthWorkbench/SynthWorkbench.tsx @@ -208,9 +208,9 @@ function VoiceCard({ slot, index, params, onParam, onRemove }: {
onParam(`field${slot}`, v)} /> onParam(`wave${slot}`, v)} /> - - - + + +
); @@ -317,6 +317,17 @@ export default function SynthWorkbench() { const [lighting, setLighting] = useState(() => ({ ...DEFAULT_LIGHTING, ...((initial?.l as Partial) ?? {}) })); const lightingRef = useRef(lighting); lightingRef.current = lighting; + // Mobile-only: which panel is open as a bottom drawer (null = viewport only). + // Mirrors the gallery's `mobilePanel` pattern (same tab-bar/drawer mechanism, + // same 760px breakpoint) so the two pages feel consistent on small screens. + const [mobilePanel, setMobilePanel] = useState<"voices" | "controls" | "presets" | null>(null); + useEffect(() => { + if (!mobilePanel) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMobilePanel(null); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [mobilePanel]); + const paramsRef = useRef(params); paramsRef.current = params; const tsRef = useRef(timeScale); tsRef.current = timeScale; const pausedRef = useRef(paused); pausedRef.current = paused; @@ -509,9 +520,9 @@ export default function SynthWorkbench() { }, [buildSynthExport]); return ( -
+
-
- onParam(`field${slot}`, v)} /> onParam(`wave${slot}`, v)} /> + onParam(`field${slot}`, v)} /> From 40d7621109ef2d0b854dc00602fd34bfe534f140 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 20:24:36 +0200 Subject: [PATCH 11/23] feat(website): SVG toggle icons + clean active-ring outline; fix spiral icon --- .../SynthWorkbench/SynthWorkbench.tsx | 70 +++++++++++++++++-- .../SynthWorkbench/synth-workbench.css | 13 +++- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/website/src/components/SynthWorkbench/SynthWorkbench.tsx b/website/src/components/SynthWorkbench/SynthWorkbench.tsx index 77f97bac..234226da 100644 --- a/website/src/components/SynthWorkbench/SynthWorkbench.tsx +++ b/website/src/components/SynthWorkbench/SynthWorkbench.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type SVGProps } from "react"; import { createGlyphScene, createGlyphOrthographicCamera, @@ -48,17 +48,75 @@ const SHAPE_OPTS = opts(SHAPES), COMBINE_OPTS = opts(COMBINES), SPACE_OPTS = opt const RAMP_OPTS: Record = { ...Object.fromEntries(Object.keys(GlyphRamps).map((k) => [k, k])), Custom: "Custom" }; const matchRamp = (glyphs: string): string => Object.entries(GlyphRamps).find(([, v]) => v === glyphs)?.[0] ?? "Custom"; -// ASCII icons for the field/wave multi-toggles (segmented control, like text-align). -const FIELD_ICONS: Record = { radial: "◎", linearX: "→", linearY: "↓", diagonal: "↘", angular: "↻", spiral: "@", noise: "▚" }; -const WAVE_ICONS: Record = { sin: "∿", triangle: "∧", saw: "╱", square: "⊓" }; +// Inline SVG icons for the field/wave multi-toggles (segmented control, like +// text-align). `stroke`/`fill: currentColor` so each icon inherits the button's +// text color for free — dim when inactive, cyan when `.is-active` (see +// `.gx-toggle-btn` / `.gx-toggle-btn.is-active` in synth-workbench.css). +function ToggleIcon({ children, ...rest }: SVGProps) { + return ( + + ); +} + +// Each shape is tuned at actual button size (~15px), not just eyeballed bigger and +// shrunk: round joins blur short segments into blobs at this size, so `saw` and +// `square` use square caps/miter joins to keep their corners crisp, and `square`'s +// plateaus are widened relative to its drop so they don't get swallowed by the cap. +const WAVE_ICONS: Record = { + sin: , + triangle: , + saw: , + square: , +}; + +const FIELD_ICONS: Record = { + radial: ( + + + + + + ), + linearX: , + linearY: , + diagonal: , + angular: ( + + + + + ), + // Archimedean spiral (2.2 turns), sampled to a fixed polyline — a hand-drawn + // nested-arc "snail shell" path read as a crown/W at icon size, this reads + // unambiguously as a spiral. + spiral: ( + + + + ), + noise: ( + + + + + + + + + + + ), +}; const FIELD_TOGGLE = FIELDS.map((v) => ({ value: v as string, icon: FIELD_ICONS[v], title: v })); const WAVE_TOGGLE = WAVES.map((v) => ({ value: v as string, icon: WAVE_ICONS[v], title: v })); -function IconToggle({ options, value, onChange }: { options: { value: string; icon: string; title: string }[]; value: string; onChange: (v: string) => void }) { +function IconToggle({ options, value, onChange }: { options: { value: string; icon: ReactNode; title: string }[]; value: string; onChange: (v: string) => void }) { return (
{options.map((o) => ( - + ))}
); diff --git a/website/src/components/SynthWorkbench/synth-workbench.css b/website/src/components/SynthWorkbench/synth-workbench.css index 0db02e57..667c00e6 100644 --- a/website/src/components/SynthWorkbench/synth-workbench.css +++ b/website/src/components/SynthWorkbench/synth-workbench.css @@ -123,12 +123,23 @@ .gx-toggle { display: flex; } .gx-toggle-btn { flex: 1; min-width: 0; font: inherit; font-size: 13px; line-height: 1; padding: 4px 0; + display: flex; align-items: center; justify-content: center; background: #0d1118; color: rgba(255, 232, 184, 0.55); border: 1px solid rgba(255, 232, 184, 0.18); border-left-width: 0; cursor: pointer; } .gx-toggle-btn:first-child { border-left-width: 1px; } .gx-toggle-btn:hover:not(.is-active) { background: #131c28; color: rgba(255, 232, 184, 0.85); } -.gx-toggle-btn.is-active { background: rgba(56, 189, 248, 0.2); color: rgba(56, 189, 248, 1); border-color: rgba(56, 189, 248, 0.55); } +/* box-shadow (not border-color) draws the active ring: border-left-width is 0 on + every button but the first (adjacent buttons share an edge), so a border-color + change alone paints cyan on 3 sides and leaves the neighbour's default-colored + border showing through on the left. An inset ring is independent of border-width + and border-color: transparent hides the shared border underneath, so the ring + reads as a clean, even 1px outline on all four sides with no layout shift. */ +.gx-toggle-btn.is-active { + background: rgba(56, 189, 248, 0.2); color: rgba(56, 189, 248, 1); + border-color: transparent; box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.85); + position: relative; z-index: 1; +} /* glyphcss select — square, dark, custom caret (like the Dock) */ .gx-select { position: relative; flex: 1; min-width: 0; } From 5b00c68aa34cb9c89efdcadf0d16414fce47e684 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 20:53:20 +0200 Subject: [PATCH 12/23] feat(website): move combined scope into the Dock MIX folder above Combine --- website/src/components/Dock/primitives.tsx | 38 ++++++++++++++ .../SynthWorkbench/SynthWorkbench.tsx | 45 ++++++++-------- .../SynthWorkbench/synth-workbench.css | 52 +++++++------------ 3 files changed, 81 insertions(+), 54 deletions(-) diff --git a/website/src/components/Dock/primitives.tsx b/website/src/components/Dock/primitives.tsx index 4b9e829e..90646a72 100644 --- a/website/src/components/Dock/primitives.tsx +++ b/website/src/components/Dock/primitives.tsx @@ -300,6 +300,44 @@ export function useReadonlyNumber( return ctrl; } +/** + * Insert a plain DOM node into a folder's children container for mounting + * arbitrary content (e.g. a React portal) alongside its lil-gui controllers. + * `parent.$children` is the documented DOM element lil-gui appends controller + * rows into (see the `lil-gui` type declarations), so this stays inside the + * public contract rather than reaching into private internals. + * + * Controllers only ever `appendChild` themselves, so a `"top"` slot must be + * requested before any sibling `use*` calls in the same folder to land above + * them; a `"bottom"` slot can be requested at any point. + */ +export function useDockSlot( + parent: GUI | null, + options?: { position?: "top" | "bottom"; className?: string }, +): HTMLDivElement | null { + const [host, setHost] = useState(null); + const optsRef = useRef(options); + optsRef.current = options; + + useEffect(() => { + if (!parent) return; + const el = document.createElement("div"); + el.className = "dock-slot"; + if (optsRef.current?.className) el.classList.add(optsRef.current.className); + const container = parent.$children; + if (optsRef.current?.position === "bottom") container.appendChild(el); + else container.insertBefore(el, container.firstChild); + setHost(el); + return () => { + container.removeChild(el); + setHost(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parent]); + + return host; +} + /** Always-disabled text display with the same visual treatment as readonly numbers. */ export function useReadonlyText(parent: GUI | null, label: string, value: string): DockController | null { const [ctrl, setCtrl] = useState | null>(null); diff --git a/website/src/components/SynthWorkbench/SynthWorkbench.tsx b/website/src/components/SynthWorkbench/SynthWorkbench.tsx index 234226da..a81765a4 100644 --- a/website/src/components/SynthWorkbench/SynthWorkbench.tsx +++ b/website/src/components/SynthWorkbench/SynthWorkbench.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type SVGProps } from "react"; +import { createPortal } from "react-dom"; import { createGlyphScene, createGlyphOrthographicCamera, @@ -20,7 +21,7 @@ import { import type { GlyphEffectPreset, GlyphFieldSynthStaticExportResult } from "@glyphcss/effects"; import { Dock } from "../Dock"; import { useDockGui } from "../Dock/slots"; -import { useButton, useColor, useFolder, useOption, useReadonlyText, useSlider, useText, useToggle } from "../Dock/primitives"; +import { useButton, useColor, useDockSlot, useFolder, useOption, useReadonlyText, useSlider, useText, useToggle } from "../Dock/primitives"; import "../GalleryWorkbench/gallery-workbench.css"; import "./synth-workbench.css"; @@ -301,15 +302,14 @@ function buildCombinedPathD(voices: readonly CombinedVoice[], combineMode: strin return d; } -// Floating oscilloscope overlay docked in a corner of the render viewport (NOT -// the voices rail — see `.synth-scope` in the CSS): each active voice's raw wave -// faint in its own color, the real mixed result bold on top — the fastest way to -// SEE interference (two close frequencies drifting in and out of phase = a visible -// beating envelope). One shared rAF loop for the whole strip (not one per voice), -// driven by the SAME paused/time-scale refs that drive the actual mounted scene, -// so it tracks what's on screen rather than free-running on its own clock. Purely -// a readout over the mesh, so it must never intercept pointer events meant for -// orbit/drag on the viewport underneath (`pointer-events: none`, see CSS). +// Combined-waveform oscilloscope, portaled into the right Dock's MIX folder +// (above Combine — see `useDockSlot(mix, { position: "top" })` in `SynthDock`): +// each active voice's raw wave faint in its own color, the real mixed result +// bold on top — the fastest way to SEE interference (two close frequencies +// drifting in and out of phase = a visible beating envelope). One shared rAF +// loop for the whole strip (not one per voice), driven by the SAME +// paused/time-scale refs that drive the actual mounted scene, so it tracks +// what's on screen rather than free-running on its own clock. function SynthScope({ paramsRef, tsRef, pausedRef }: { paramsRef: { current: Params }; tsRef: { current: number }; pausedRef: { current: boolean }; }) { @@ -343,14 +343,14 @@ function SynthScope({ paramsRef, tsRef, pausedRef }: { return () => cancelAnimationFrame(raf); }, [paramsRef, tsRef, pausedRef]); return ( -
diff --git a/website/src/components/SynthWorkbench/synth-workbench.css b/website/src/components/SynthWorkbench/synth-workbench.css index 667c00e6..b53a09da 100644 --- a/website/src/components/SynthWorkbench/synth-workbench.css +++ b/website/src/components/SynthWorkbench/synth-workbench.css @@ -235,37 +235,32 @@ } .synth-viewport .glyph-output { color: rgba(255, 232, 184, 0.92); } -/* Floating scope: a small oscilloscope HUD docked in the viewport's bottom-left - corner (moved off the voices rail so it doesn't cost sidebar height) — every - active voice's raw wave faint in its own color, the real mixed result +/* Combined-waveform scope, portaled into the right Dock's MIX folder above + Combine (`useDockSlot(mix, { position: "top" })` in SynthWorkbench.tsx) — + every active voice's raw wave faint in its own color, the real mixed result (combineSynth, mix-weighted) bold on top, so close frequencies visibly beat - instead of just reading as two numbers. Read-only overlay: `pointer-events: - none` so it never steals drag/orbit input from the render underneath, and it - stays small + semi-transparent so it doesn't obscure the pattern. */ -.synth-scope { - position: absolute; - left: 12px; - bottom: 12px; - z-index: 4; - width: 176px; - padding: 7px 8px 8px; - background: rgba(4, 6, 11, 0.72); - border: 1px solid rgba(255, 232, 184, 0.16); - backdrop-filter: blur(3px); - -webkit-backdrop-filter: blur(3px); - pointer-events: none; + instead of just reading as two numbers. `.dock-scope-slot` is the plain DOM + node `useDockSlot` inserts as a folder row; it isn't a `.controller`, so it + gets its own padding here matching lil-gui's `--padding`/`--spacing` rather + than inheriting the controller layout. The plot is fully responsive (no + fixed width) so it fills whatever width the Dock panel happens to have. */ +.dn-floating-controls .lil-gui .dock-scope-slot { + padding: var(--spacing, 5px) var(--padding, 6px) 0; } -.synth-scope-label { +.dock-scope { + width: 100%; +} +.dock-scope-label { display: block; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: rgba(255, 232, 184, 0.5); margin-bottom: 4px; } -.synth-scope-plot { - display: block; width: 100%; height: 44px; +.dock-scope-plot { + display: block; width: 100%; height: 52px; background: rgba(2, 4, 10, 0.85); border: 1px solid rgba(255, 232, 184, 0.16); } -.synth-scope-mid { stroke: rgba(255, 232, 184, 0.16); stroke-width: 1; } -.synth-scope-voice { stroke-width: 1; opacity: 0.4; } -.synth-scope-mix { stroke: #f4f0e6; stroke-width: 1.6; opacity: 0.95; } +.dock-scope-mid { stroke: rgba(255, 232, 184, 0.16); stroke-width: 1; } +.dock-scope-voice { stroke-width: 1; opacity: 0.4; } +.dock-scope-mix { stroke: #f4f0e6; stroke-width: 1.6; opacity: 0.95; } /* ── Footer: full-width preset gallery ─────────────────────────────────── */ .synth-presets { @@ -307,15 +302,6 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } - /* The scope floats inside `.synth-main`, which spans the full viewport height - on mobile (the voices rail is out of flow), so its default bottom-left - corner would sit directly under the always-visible tab bar. Reuse the same - `--mobile-panel-bottom` offset the drawers use to clear it. */ - .dn-root--synth .synth-scope { - bottom: var(--mobile-panel-bottom); - width: 148px; - } - /* Voices rail: out of the flex flow so `.synth-main` fills the full width, shown as a bottom drawer like the gallery's `.models-sidebar`. */ .dn-root--synth .synth-voices { From dba1d14ba286528bfb1fb96322537f638dd81d60 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 20:59:57 +0200 Subject: [PATCH 13/23] feat(website): synth export as floating collapsible panel (out of the Dock) --- .../SynthWorkbench/SynthWorkbench.tsx | 70 +++++++++++++++---- .../SynthWorkbench/synth-workbench.css | 63 ++++++++++++----- 2 files changed, 104 insertions(+), 29 deletions(-) diff --git a/website/src/components/SynthWorkbench/SynthWorkbench.tsx b/website/src/components/SynthWorkbench/SynthWorkbench.tsx index a81765a4..94303fcb 100644 --- a/website/src/components/SynthWorkbench/SynthWorkbench.tsx +++ b/website/src/components/SynthWorkbench/SynthWorkbench.tsx @@ -21,7 +21,7 @@ import { import type { GlyphEffectPreset, GlyphFieldSynthStaticExportResult } from "@glyphcss/effects"; import { Dock } from "../Dock"; import { useDockGui } from "../Dock/slots"; -import { useButton, useColor, useDockSlot, useFolder, useOption, useReadonlyText, useSlider, useText, useToggle } from "../Dock/primitives"; +import { useColor, useDockSlot, useFolder, useOption, useSlider, useText, useToggle } from "../Dock/primitives"; import "../GalleryWorkbench/gallery-workbench.css"; import "./synth-workbench.css"; @@ -417,13 +417,12 @@ function PresetTile({ preset, onApply }: { preset: GlyphEffectPreset; onA } // ── Right dock controls (stage / mix / output) ──────────────────────────────── -function SynthDock({ shape, onShape, timeScale, onTimeScale, paused, onPaused, density, onDensity, lighting, onLight, params, onParam, onExportCodepen, onExportCopyHtml, exportStatus, paramsRef, tsRef, pausedRef }: { +function SynthDock({ shape, onShape, timeScale, onTimeScale, paused, onPaused, density, onDensity, lighting, onLight, params, onParam, paramsRef, tsRef, pausedRef }: { shape: string; onShape: (s: string) => void; timeScale: number; onTimeScale: (n: number) => void; paused: boolean; onPaused: (b: boolean) => void; density: number; onDensity: (n: number) => void; lighting: Lighting; onLight: (partial: Partial) => void; params: Params; onParam: (key: string, value: ParamValue) => void; - onExportCodepen: () => void; onExportCopyHtml: () => void; exportStatus: string; paramsRef: { current: Params }; tsRef: { current: number }; pausedRef: { current: boolean }; }): ReactNode { const gui = useDockGui(); @@ -464,18 +463,62 @@ function SynthDock({ shape, onShape, timeScale, onTimeScale, paused, onPaused, d useColor(light, "Key color", lighting.keyColor, (v) => onLight({ keyColor: v })); useSlider(light, "Ambient", { min: 0, max: 1, step: 0.05 }, lighting.ambient, (v) => onLight({ ambient: v })); - // Static export — bakes the current patch into a self-contained pen (inlined - // vanilla-JS field-synth evaluator, zero glyphcss at runtime). Both actions - // build the SAME export; one opens it in CodePen, the other copies the full - // standalone HTML document so it can be tested locally (drop into a file and - // open it — no server, no build step). - const exportFolder = useFolder(gui, "Export", { open: false }); - useButton(exportFolder, "Open in CodePen", onExportCodepen); - useButton(exportFolder, "Copy standalone HTML", onExportCopyHtml); - useReadonlyText(exportFolder, "Status", exportStatus); return scopeHost ? createPortal(, scopeHost) : null; } +// ── Floating export control (bottom-left of the viewport) ───────────────────── +// Mirrors the gallery's `.gw-code-panel` collapse pattern (CodePanel.tsx + +// gallery-workbench.css): a small header trigger, collapsed by default, that +// expands to reveal actions. Reuses the shared `.gw-code-panel__*` header/ +// action classes for visual consistency; the export logic itself lives in +// `SynthWorkbench` (`handleExportCodepen` / `handleExportCopyHtml`) — this +// component only renders the trigger + the two buttons + status readout. +function SynthExportPanel({ onExportCodepen, onExportCopyHtml, exportStatus }: { + onExportCodepen: () => void; onExportCopyHtml: () => void; exportStatus: string; +}): ReactNode { + const [collapsed, setCollapsed] = useState(true); + return ( +
+
+ [ EXPORT ] +
+ +
+
+ {!collapsed && ( +
+ + + {exportStatus && {exportStatus}} +
+ )} +
+ ); +} + // ── URL persistence (everything the synth is configured to, in ?s=) ─────────── function encodeSynthState(state: unknown): string { try { return btoa(unescape(encodeURIComponent(JSON.stringify(state)))).replace(/=+$/, ""); } catch { return ""; } @@ -728,9 +771,10 @@ export default function SynthWorkbench() {
+
- setLighting((l) => ({ ...l, ...partial }))} params={params} onParam={onParam} onExportCodepen={handleExportCodepen} onExportCopyHtml={handleExportCopyHtml} exportStatus={exportStatus} paramsRef={paramsRef} tsRef={tsRef} pausedRef={pausedRef} /> + setLighting((l) => ({ ...l, ...partial }))} params={params} onParam={onParam} paramsRef={paramsRef} tsRef={tsRef} pausedRef={pausedRef} />
diff --git a/website/src/components/SynthWorkbench/synth-workbench.css b/website/src/components/SynthWorkbench/synth-workbench.css index b53a09da..35584d66 100644 --- a/website/src/components/SynthWorkbench/synth-workbench.css +++ b/website/src/components/SynthWorkbench/synth-workbench.css @@ -216,24 +216,55 @@ /* ── Center: viewport ──────────────────────────────────────────────────── */ .synth-main { flex: 1; position: relative; min-width: 0; } .synth-viewport { position: absolute; inset: 0; overflow: hidden; } -.synth-codepen-btn { - /* Bottom-left, not top-right — the floating Dock GUI anchors top-right of - `.synth-body` (the nearest `position: relative` ancestor) and would - otherwise sit on top of this button and eat its clicks. */ +.synth-viewport .glyph-output { color: rgba(255, 232, 184, 0.92); } + +/* ── Floating export panel (bottom-left of the viewport) ───────────────────── + Mirrors the gallery's `.gw-code-panel` collapse pattern: a small header + trigger, collapsed by default (no code/tabs here — just two export + actions), that expands downward-anchored-upward (position: absolute + + `bottom` fixes the bottom edge, so growth pushes the top edge up instead + of overflowing off-screen). Reuses `.gw-code-panel__head/__legend/ + __actions/__action(--codepen)` from gallery-workbench.css (imported by + this page) for the header/button look; positioning + collapse sizing are + synth-specific since this panel has no tabs/code body to size around. */ +.synth-export { position: absolute; - bottom: 12px; - left: 12px; - z-index: 5; - font: inherit; - font-size: 11px; - letter-spacing: 0.04em; - padding: 5px 12px; - border-radius: 0; - cursor: pointer; - background: rgba(7, 9, 13, 0.7); - backdrop-filter: blur(2px); + bottom: var(--overlay-bottom, 12px); + left: var(--overlay-left, 12px); + z-index: 14; + display: flex; + flex-direction: column; + width: max-content; + max-width: min(280px, calc(100% - 24px)); + background: #0d1018; + border: 1px solid rgba(255, 232, 184, 0.18); + font-family: ui-monospace, "JetBrains Mono", "SF Mono", "Menlo", monospace; +} +.synth-export__body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; +} +.synth-export__body .gw-code-panel__action { + width: 100%; + text-align: center; +} +.synth-export__status { + font-size: 10px; + letter-spacing: 0.02em; + color: rgba(255, 232, 184, 0.55); +} + +@media (max-width: 760px) { + /* Sits just above the mobile tab bar (same `--mobile-panel-bottom` offset + the drawers use) instead of the desktop `--overlay-bottom`, so it never + sits underneath/behind the fixed tab bar. */ + .dn-root--synth .synth-export { + bottom: var(--mobile-panel-bottom); + max-width: calc(100% - var(--overlay-left) - var(--overlay-right)); + } } -.synth-viewport .glyph-output { color: rgba(255, 232, 184, 0.92); } /* Combined-waveform scope, portaled into the right Dock's MIX folder above Combine (`useDockSlot(mix, { position: "top" })` in SynthWorkbench.tsx) — From ae3b6558da2530f337645a198f838ebc4630d04e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 21:12:13 +0200 Subject: [PATCH 14/23] feat(website): mobile export tab, per-voice-colored previews, disable global color controls when per-voice on --- .../SynthWorkbench/SynthWorkbench.tsx | 55 +++++++++++++++---- .../SynthWorkbench/synth-workbench.css | 25 ++++++--- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/website/src/components/SynthWorkbench/SynthWorkbench.tsx b/website/src/components/SynthWorkbench/SynthWorkbench.tsx index 94303fcb..21c8c94d 100644 --- a/website/src/components/SynthWorkbench/SynthWorkbench.tsx +++ b/website/src/components/SynthWorkbench/SynthWorkbench.tsx @@ -210,12 +210,20 @@ function frameObject(scene: GlyphSceneHandle, camera: { zoom: number; project: ( } // Isolate one voice into osc-1 (amp 1) so a card can preview its solo contribution. +// Colored through the SAME path the real render uses: when per-voice colors is +// ON, osc-1 carries the voice's own `color{slot}` and `voiceColors: true`, so +// `fieldSynth`'s evaluate() resolves the preview's color from that single active +// voice (matching the trendline, which already reads `color{slot}`) — no CSS +// override needed. When OFF, it falls back to the main `color`/`colorB`/`gradient`, +// same as the rest of the scene. function soloParams(params: Params, slot: number): Params { const base = synthDefaults(); for (let k = 1; k <= MAX_VOICES; k++) base[`amp${k}`] = 0; base.field1 = params[`field${slot}`]; base.wave1 = params[`wave${slot}`]; base.freq1 = params[`freq${slot}`]; base.speed1 = params[`speed${slot}`]; base.amp1 = 1; base.space = params.space; base.scale = params.scale; base.glyphs = params.glyphs; + base.voiceColors = params.voiceColors === true; + base.color1 = params[`color${slot}`]; base.color = params.color; base.colorB = params.colorB; base.gradient = params.gradient; base.gain = 1; base.bias = 0.5; return base; @@ -375,7 +383,7 @@ function VoiceCard({ slot, index, params, onParam, onRemove }: { const v = trendRef.current; path.setAttribute("d", buildWavePathD(v.wave, v.freq, v.speed, v.amp, t, 100, 30)); }, []); - useSynthPreview(host, () => soloParams(params, slot), [params[`field${slot}`], params[`wave${slot}`], params[`freq${slot}`], params[`speed${slot}`], params.space, params.scale, params.color, params.colorB, params.gradient, params.glyphs, host], onTick); + useSynthPreview(host, () => soloParams(params, slot), [params[`field${slot}`], params[`wave${slot}`], params[`freq${slot}`], params[`speed${slot}`], params[`color${slot}`], params.voiceColors, params.space, params.scale, params.color, params.colorB, params.gradient, params.glyphs, host], onTick); const fill = (v: number, min: number, max: number) => ({ ["--fill" as string]: `${((v - min) / (max - min)) * 100}%` } as CSSProperties); return (
@@ -450,10 +458,20 @@ function SynthDock({ shape, onShape, timeScale, onTimeScale, paused, onPaused, d const out = useFolder(gui, "Output", { open: true }); useOption(out, "Ramp", RAMP_OPTS, matchRamp(s("glyphs")), (name) => { if (name !== "Custom" && GlyphRamps[name]) onParam("glyphs", GlyphRamps[name]); }); useText(out, "Chars", s("glyphs"), (v) => onParam("glyphs", v)); - useToggle(out, "Per-voice colors", params.voiceColors === true, (v) => onParam("voiceColors", v)); - useColor(out, "Color", s("color"), (v) => onParam("color", v)); - useColor(out, "Color B", s("colorB"), (v) => onParam("colorB", v)); - useSlider(out, "Gradient", { min: 0, max: 1, step: 0.05 }, n("gradient"), (v) => onParam("gradient", v)); + const voiceColorsOn = params.voiceColors === true; + useToggle(out, "Per-voice colors", voiceColorsOn, (v) => onParam("voiceColors", v)); + const colorCtrl = useColor(out, "Color", s("color"), (v) => onParam("color", v)); + const colorBCtrl = useColor(out, "Color B", s("colorB"), (v) => onParam("colorB", v)); + const gradientCtrl = useSlider(out, "Gradient", { min: 0, max: 1, step: 0.05 }, n("gradient"), (v) => onParam("gradient", v)); + // Color/Color B/Gradient only drive output when per-voice colors is OFF — each + // voice's own color wins over them once it's on (see `fieldSynth`'s evaluate()). + // Grey them out via the same `DockController.setEnabled` every Dock primitive + // already exposes, rather than adding a bespoke disabled prop. + useEffect(() => { + colorCtrl?.setEnabled(!voiceColorsOn); + colorBCtrl?.setEnabled(!voiceColorsOn); + gradientCtrl?.setEnabled(!voiceColorsOn); + }, [colorCtrl, colorBCtrl, gradientCtrl, voiceColorsOn]); const light = useFolder(gui, "Lighting", { open: false }); useSlider(light, "Amount", { min: 0, max: 1, step: 0.05 }, n("lit"), (v) => onParam("lit", v)); @@ -473,12 +491,20 @@ function SynthDock({ shape, onShape, timeScale, onTimeScale, paused, onPaused, d // action classes for visual consistency; the export logic itself lives in // `SynthWorkbench` (`handleExportCodepen` / `handleExportCopyHtml`) — this // component only renders the trigger + the two buttons + status readout. -function SynthExportPanel({ onExportCodepen, onExportCopyHtml, exportStatus }: { - onExportCodepen: () => void; onExportCopyHtml: () => void; exportStatus: string; +// On mobile this same panel doubles as the drawer opened by the `.dn-mobile-tabs` +// Export tab (`open` → `is-mobile-open`, same mechanism as the gallery's Code +// tab / `.gw-code-panel`) — desktop behavior (floating, collapsed by default, +// toggled by its own header) is untouched. +function SynthExportPanel({ id, open, onExportCodepen, onExportCopyHtml, exportStatus }: { + id?: string; open?: boolean; onExportCodepen: () => void; onExportCopyHtml: () => void; exportStatus: string; }): ReactNode { const [collapsed, setCollapsed] = useState(true); + // Opening via the mobile Export tab reveals the actions in one tap — like the + // gallery's Code tab — instead of requiring a second tap on the header once + // the drawer is already open. + useEffect(() => { if (open) setCollapsed(false); }, [open]); return ( -
+
[ EXPORT ]
@@ -555,7 +581,7 @@ export default function SynthWorkbench() { // Mobile-only: which panel is open as a bottom drawer (null = viewport only). // Mirrors the gallery's `mobilePanel` pattern (same tab-bar/drawer mechanism, // same 760px breakpoint) so the two pages feel consistent on small screens. - const [mobilePanel, setMobilePanel] = useState<"voices" | "controls" | "presets" | null>(null); + const [mobilePanel, setMobilePanel] = useState<"voices" | "controls" | "presets" | "export" | null>(null); useEffect(() => { if (!mobilePanel) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMobilePanel(null); }; @@ -771,7 +797,7 @@ export default function SynthWorkbench() {
- +
setLighting((l) => ({ ...l, ...partial }))} params={params} onParam={onParam} paramsRef={paramsRef} tsRef={tsRef} pausedRef={pausedRef} /> @@ -808,6 +834,15 @@ export default function SynthWorkbench() { > Presets +
); diff --git a/website/src/components/SynthWorkbench/synth-workbench.css b/website/src/components/SynthWorkbench/synth-workbench.css index 35584d66..200cb03c 100644 --- a/website/src/components/SynthWorkbench/synth-workbench.css +++ b/website/src/components/SynthWorkbench/synth-workbench.css @@ -257,13 +257,21 @@ } @media (max-width: 760px) { - /* Sits just above the mobile tab bar (same `--mobile-panel-bottom` offset - the drawers use) instead of the desktop `--overlay-bottom`, so it never - sits underneath/behind the fixed tab bar. */ + /* Export becomes a bottom-drawer reachable only via the Export tab, mirroring + the gallery's `.gw-code-panel` mobile pattern (hidden until `.is-mobile-open`, + same `--mobile-panel-bottom` offset, same drawer z-index) instead of staying + always-visible and merely repositioning. Desktop's floating panel is untouched. */ .dn-root--synth .synth-export { + display: none; + top: auto; + left: var(--overlay-left); + right: var(--overlay-right); bottom: var(--mobile-panel-bottom); - max-width: calc(100% - var(--overlay-left) - var(--overlay-right)); + width: auto; + max-width: none; + z-index: 25; } + .dn-root--synth .synth-export.is-mobile-open { display: flex; } } /* Combined-waveform scope, portaled into the right Dock's MIX folder above @@ -320,9 +328,10 @@ page): same 760px breakpoint, same `is-mobile-open` toggle driven by a bottom tab bar, viewport stays full-bleed and primary. The Voices rail, the Dock, and the preset footer all collapse into drawers instead of - fighting the render for space. `.dn-mobile-tabs`'s own chrome (buttons, - position) is already styled generically there; only the 3-tab grid and - the drawer positioning/toggle are synth-specific here. */ + fighting the render for space. Export (the floating panel below) joins the + same tab bar as a 4th tab. `.dn-mobile-tabs`'s own chrome (buttons, position) + is already styled generically there; only the 4-tab grid and the drawer + positioning/toggle are synth-specific here. */ @media (max-width: 760px) { .dn-root--synth { --mobile-tabs-height: 52px; @@ -330,7 +339,7 @@ } .dn-root--synth .dn-mobile-tabs { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } /* Voices rail: out of the flex flow so `.synth-main` fills the full width, From 598d2259c3e62e269f635e53b05318df9a26cab0 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 21:31:33 +0200 Subject: [PATCH 15/23] perf(effects): inline affine coords + skip base grid for flat/plane static export --- AGENTS.md | 2 +- packages/effects/src/staticExport.test.ts | 108 ++++++++++++++++++ packages/effects/src/staticExport.ts | 128 ++++++++++++++++++++-- 3 files changed, 228 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81f50a78..5d9629a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Because `rasterize` is pure (geometry + camera → string), a scene can be rende - **`GlyphSceneStatic`** (React + Vue) — SSR/SSG component that renders the compiled `
` with no client runtime (mirror of each other; static counterpart to `GlyphScene`).
 - **Frame-roll export** — `buildGlyphFramesExport(polygons, { frameCount, durationSec, rotX, rotY, zoom, … })` (pure, browser-safe, in `glyphcss`): bakes a turntable of `frameCount` full `compileScene` renders, stacks them in one `
`, and cycles them with a pure-CSS `steps()` animation — **zero runtime JS**, faithful per-face color, instant paint. Trade-offs: discrete angles (smoothness ∝ `frameCount`), fixed resolution, and payload grows **linearly** with `frameCount` (a colored frame costs several KB gzipped even after cross-frame color-class dedupe) — good for a handful of frames, not for a long or continuous loop.
 - **Interactive export** — `buildGlyphInteractiveExport(polygons, { interactions })` (pure, browser-safe, in `glyphcss`): the declared interactions (`orbit`/`zoom`/`pan`/`fpv`) drive **both** the wired control (only that one is imported → the snippet tree-shakes) **and** the decimation budget (`decimatePolygons` — coarser for orbit, finer when `zoom`/`fpv` let the camera approach). Output is a self-contained CDN+inlined-mesh snippet; `glyphCodepenPrefill` turns it into a CodePen POST (the gallery's "CodePen" button). Less declared interactivity = less mesh + less runtime shipped. An optional `effect: { id, params, blend?, timeScale? }` mounts a live **stock** `@glyphcss/effects` layer: the snippet adds a second `import { getGlyphEffect } from "https://esm.sh/@glyphcss/effects@?deps=glyphcss@"` (same `` as the glyphcss import; `?deps` dedupes the shared glyphcss module instance), resolves the effect by id at runtime (`glyphcss` itself never imports `@glyphcss/effects` — that dependency only points one way), calls `scene.addEffectLayer(...)` after the scene/controls are wired, and — when `timeScale > 0` — appends a small `requestAnimationFrame` loop driving `params.time`. This is the export for a **moving-camera or multi-effect** scene (the gallery's "CodePen" button); it ships the glyphcss + effects runtime from the CDN.
-- **Static effect export (field-synth)** — `buildGlyphFieldSynthStaticExport(polygons, { params, blend, loopSeconds, cols, rows, … })` (pure, browser-safe, in `@glyphcss/effects` — not `glyphcss`, since it needs a stock effect's own math): for an **effect-only, static-camera** scene (fixed mesh + camera, only the field-synth texture animating over `time`), bakes the static base grid plus each covered cell's resolved field-synth domain coordinate **once**, reusing glyphcss's real rasterizer + effect-input machinery, then ships a tiny hand-written vanilla-JS field-synth evaluator that recomputes the pattern every `requestAnimationFrame` — **zero imports, zero CDN, zero `glyphcss`/`@glyphcss/*` at runtime**. Fixed payload regardless of loop length and continuously smooth, unlike a frame-roll export whose payload grows with frame count; see `bench/static-effect-export.md` for the size/quality trade-off that motivated shipping this over more prebaked frames. Always reads the layer's **real** `blend` (`over` vs `replace`), never the effect definition's `defaultBlend` UI metadata. Field-synth only today — the `/synth` page's export button is the reference caller; a future effect id needs its own exported coordinate resolver (mirroring `fieldSynthCoordinate`) plus a hand-written inlined-JS port of its per-cell math, since there's no way to ship an arbitrary `GlyphEffectProgram.evaluate()` without shipping glyphcss's effect runtime alongside it.
+- **Static effect export (field-synth)** — `buildGlyphFieldSynthStaticExport(polygons, { params, blend, loopSeconds, cols, rows, … })` (pure, browser-safe, in `@glyphcss/effects` — not `glyphcss`, since it needs a stock effect's own math): for an **effect-only, static-camera** scene (fixed mesh + camera, only the field-synth texture animating over `time`), bakes the static base grid plus each covered cell's resolved field-synth domain coordinate **once**, reusing glyphcss's real rasterizer + effect-input machinery, then ships a tiny hand-written vanilla-JS field-synth evaluator that recomputes the pattern every `requestAnimationFrame` — **zero imports, zero CDN, zero `glyphcss`/`@glyphcss/*` at runtime**. Fixed payload regardless of loop length and continuously smooth, unlike a frame-roll export whose payload grows with frame count; see `bench/static-effect-export.md` for the size/quality trade-off that motivated shipping this over more prebaked frames. Always reads the layer's **real** `blend` (`over` vs `replace`), never the effect definition's `defaultBlend` UI metadata. Field-synth only today — the `/synth` page's export button is the reference caller; a future effect id needs its own exported coordinate resolver (mirroring `fieldSynthCoordinate`) plus a hand-written inlined-JS port of its per-cell math, since there's no way to ship an arbitrary `GlyphEffectProgram.evaluate()` without shipping glyphcss's effect runtime alongside it. Two further bakes shrink this for the common flat-surface case (the `/synth` page's default fullscreen plane): when every covered cell's per-cell domain coordinate fits an AFFINE function of grid `(col,row)` within a tight residual (a least-squares fit checked against every cell, not just on average, so a genuinely curved surface can't be mis-fit), the per-cell coordinate table — normally ~86% of the payload — is replaced with 6 fitted scalars and a one-line runtime formula; curved/projected surfaces (cube, sphere, …) fail that check and keep the table, unchanged. Separately, when the effect covers every grid cell with `blend:"replace"` at opacity 1 (the base contributes provably zero weight to every composited cell), the baked base glyph/color grid is skipped too. Both are pure size optimizations of the same bake — output is unchanged for curved/partial/`over`/opacity<1 cases, and matches the un-optimized table path within color-channel rounding for the affine case.
 
 Dynamic Glyph Effect layers are otherwise runtime-only: `compileScene`/`GlyphSceneStatic` and the frame-roll export do not serialize or evaluate a mounted effect. Two paths do carry a live effect: the **interactive/CodePen exporter** mounts a **stock** effect by id from the `@glyphcss/effects` CDN (a custom `defineGlyphEffect` can't cross the CDN boundary), and **`buildGlyphFieldSynthStaticExport`** bakes an effect-only, static-camera field-synth scene into a self-contained inlined-JS pen. Neither generalizes to a moving camera plus effect, arbitrary effects in a static bake, or geometry animation.
 
diff --git a/packages/effects/src/staticExport.test.ts b/packages/effects/src/staticExport.test.ts
index a7ea80f8..ac18cdd5 100644
--- a/packages/effects/src/staticExport.test.ts
+++ b/packages/effects/src/staticExport.test.ts
@@ -6,6 +6,19 @@ function mesh(): Polygon[] {
   return spherePolygons({ center: [0, 0, 0], size: 4, subdivisions: 1, color: "#8fb3d9" });
 }
 
+// Mirrors website/src/components/SynthWorkbench/SynthWorkbench.tsx `flatQuad`
+// (the /synth page's default "plane" shape): a flat square in the world XY
+// plane, z=0, linear 0..1 UVs across the whole quad — the case whose
+// per-cell field-synth domain coordinate is an exact affine function of
+// (col,row).
+function planeMesh(size = 3): Polygon[] {
+  return [{
+    vertices: [[-size, -size, 0], [size, -size, 0], [size, size, 0], [-size, size, 0]],
+    uvs: [[0, 0], [1, 0], [1, 1], [0, 1]],
+    color: "#8fb3d9",
+  }];
+}
+
 function baseOptions(overrides: Partial = {}): GlyphFieldSynthStaticExportOptions {
   return {
     params: {
@@ -112,4 +125,99 @@ describe("buildGlyphFieldSynthStaticExport", () => {
   it("throws when field-synth's own param validation rejects the patch", () => {
     expect(() => buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ params: { glyphs: "" } }))).toThrow();
   });
+
+  it("a flat, head-on, fully-covered plane bakes an inline affine coordinate function instead of a per-cell table, and skips the base grid", () => {
+    const result = buildGlyphFieldSynthStaticExport(planeMesh(), baseOptions({
+      rotX: 0,
+      rotY: 0,
+      zoom: 150,
+      cols: 24,
+      rows: 12,
+    }));
+    const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/);
+    expect(dataMatch).not.toBeNull();
+    const data = JSON.parse(dataMatch![1]!) as Record;
+    const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s);
+    expect(cfgMatch).not.toBeNull();
+    const cfg = JSON.parse(cfgMatch![1]!) as { aff: number[] | null; skipBase: boolean };
+
+    // No per-cell coordinate table (the ~86%-of-payload cost the affine path
+    // is meant to eliminate) and no baked base glyph/color grid (`replace`
+    // blend at opacity 1 fully covering the grid makes it unread).
+    expect(data.x).toBeUndefined();
+    expect(data.y).toBeUndefined();
+    expect(data.bg).toBeUndefined();
+    expect(data.bc).toBeUndefined();
+    // The per-cell col/row index and shade arrays are still baked (cheap,
+    // and needed regardless of the coordinate strategy).
+    expect(Array.isArray(data.c)).toBe(true);
+    expect((data.c as unknown[]).length).toBeGreaterThan(0);
+
+    // 6 fitted scalars driving `x = aff[0]*col + aff[1]*row + aff[2]`
+    // (and the `y` analogue) at runtime instead.
+    expect(cfg.aff).not.toBeNull();
+    expect(cfg.aff).toHaveLength(6);
+    expect(cfg.aff!.every((n) => Number.isFinite(n))).toBe(true);
+    expect(cfg.skipBase).toBe(true);
+    expect(result.js).toContain("C.aff?C.aff[0]*D.c[k]+C.aff[1]*D.r[k]+C.aff[2]:D.x[k]");
+  });
+
+  it("a curved surface (sphere) keeps the baked per-cell coordinate table and base grid — never mis-detected as affine", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions());
+    const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/);
+    const data = JSON.parse(dataMatch![1]!) as { x: number[]; y: number[]; bg: string[]; bc: number[] };
+    const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s);
+    const cfg = JSON.parse(cfgMatch![1]!) as { aff: number[] | null; skipBase: boolean };
+
+    expect(cfg.aff).toBeNull();
+    expect(cfg.skipBase).toBe(false);
+    expect(Array.isArray(data.x)).toBe(true);
+    expect(Array.isArray(data.y)).toBe(true);
+    expect(data.x.length).toBeGreaterThan(0);
+    expect(Array.isArray(data.bg)).toBe(true);
+    expect(Array.isArray(data.bc)).toBe(true);
+  });
+
+  it("a partially-covered plane (not filling the grid) keeps the base grid even though coordinates are still affine", () => {
+    const result = buildGlyphFieldSynthStaticExport(planeMesh(), baseOptions({
+      rotX: 0,
+      rotY: 0,
+      zoom: 40, // small enough that the quad doesn't fill the 24x12 grid
+      cols: 24,
+      rows: 12,
+    }));
+    const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/);
+    const data = JSON.parse(dataMatch![1]!) as Record;
+    const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s);
+    const cfg = JSON.parse(cfgMatch![1]!) as { aff: number[] | null; skipBase: boolean };
+
+    // Coordinates are still an exact affine function of (col,row) on a flat
+    // plane regardless of how much of the grid it covers.
+    expect(cfg.aff).not.toBeNull();
+    expect(data.x).toBeUndefined();
+    // But the base is genuinely needed here (uncovered cells fall back to
+    // it), so it must NOT be skipped.
+    expect(cfg.skipBase).toBe(false);
+    expect(data.bg).toBeDefined();
+    expect(data.bc).toBeDefined();
+  });
+
+  it("opacity < 1 with a fully-covered `replace` plane keeps the base grid (input genuinely still shows through)", () => {
+    const result = buildGlyphFieldSynthStaticExport(planeMesh(), baseOptions({
+      rotX: 0,
+      rotY: 0,
+      zoom: 150,
+      cols: 24,
+      rows: 12,
+      opacity: 0.5,
+    }));
+    const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/);
+    const data = JSON.parse(dataMatch![1]!) as Record;
+    const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s);
+    const cfg = JSON.parse(cfgMatch![1]!) as { skipBase: boolean };
+
+    expect(cfg.skipBase).toBe(false);
+    expect(data.bg).toBeDefined();
+    expect(data.bc).toBeDefined();
+  });
 });
diff --git a/packages/effects/src/staticExport.ts b/packages/effects/src/staticExport.ts
index 5d36b8b0..cedfd5ad 100644
--- a/packages/effects/src/staticExport.ts
+++ b/packages/effects/src/staticExport.ts
@@ -24,6 +24,17 @@
  * same way fieldSynthCoordinate is, and (2) a hand-written inlined JS port of
  * its per-cell math (there is no way to ship an arbitrary GlyphEffectProgram's
  * `evaluate()` without shipping a JS engine's worth of glyphcss around it).
+ *
+ * Two payload cuts beyond the bench's baseline (see `detectAffineCoords` /
+ * `skipBase` in `buildRuntime`): a flat, head-on surface with a linear UV map
+ * (e.g. the `/synth` page's default fullscreen plane) makes every cell's
+ * domain coordinate an exact affine function of (col,row), so the per-cell
+ * coordinate table — the bulk of the payload — is replaced with 6 fitted
+ * scalars and a one-line formula; and when the effect covers every cell of
+ * the grid with `blend:"replace"` at full opacity, the baked base glyph/color
+ * grid is provably never read by the compositor and is skipped too. Curved or
+ * projected surfaces (cube, sphere, a tilted plane, …) fail the affine
+ * residual check and keep the table, unchanged from before.
  */
 import {
   buildRasterizeContext,
@@ -271,6 +282,78 @@ function bake(
   return { cells, cols: options.cols, rows: options.rows };
 }
 
+interface AffineCoordFit {
+  ax: number; bx: number; ecx: number;
+  ay: number; by: number; ecy: number;
+}
+
+// A flat, head-on surface with a linear UV map (the /synth page's default
+// fullscreen plane, and any other surface whose per-cell domain coordinate
+// happens to come out affine) needs no per-cell coordinate table at all: the
+// per-cell (x,y) is an exact affine function of grid (col,row), so the
+// runtime can recompute it from 6 fitted scalars instead of shipping one
+// float pair per cell — the ~86% of the payload the bench in
+// bench/static-effect-export.md attributes to that table. Curved or
+// perspective-projected surfaces (cube, sphere, a tilted/foreshortened
+// plane, …) are NOT globally affine in cell space and must keep the baked
+// table; the fit below detects that mechanically rather than assuming it
+// from the shape name, so any surface that happens to be affine benefits and
+// nothing else is misdetected.
+//
+// Least-squares fit of (col,row) → x and (col,row) → y shares one 3×3
+// normal-equations solve (both regressions have the same design matrix).
+// "Affine within epsilon" requires the max PER-CELL residual — not an
+// average — to be under AFFINE_EPSILON: an average-error check would accept
+// a surface that's affine almost everywhere but curves at the edges, which
+// would silently corrupt exactly those cells once the table is dropped.
+const AFFINE_EPSILON = 1e-3;
+
+function detectAffineCoords(points: { col: number; row: number; x: number; y: number }[]): AffineCoordFit | null {
+  const n = points.length;
+  if (n < 3) return null;
+  let Scc = 0, Scr = 0, Sc = 0, Srr = 0, Sr = 0;
+  let Sxc = 0, Sxr = 0, Sx = 0, Syc = 0, Syr = 0, Sy = 0;
+  for (const p of points) {
+    Scc += p.col * p.col; Scr += p.col * p.row; Sc += p.col;
+    Srr += p.row * p.row; Sr += p.row;
+    Sxc += p.col * p.x; Sxr += p.row * p.x; Sx += p.x;
+    Syc += p.col * p.y; Syr += p.row * p.y; Sy += p.y;
+  }
+  const m00 = Scc, m01 = Scr, m02 = Sc;
+  const m10 = Scr, m11 = Srr, m12 = Sr;
+  const m20 = Sc, m21 = Sr, m22 = n;
+  const det =
+    m00 * (m11 * m22 - m12 * m21)
+    - m01 * (m10 * m22 - m12 * m20)
+    + m02 * (m10 * m21 - m11 * m20);
+  // Singular normal matrix — e.g. a 1-row/1-col grid, or too few points to
+  // pin down 3 unknowns per axis. Keep the table; there's nothing to fit.
+  if (Math.abs(det) < 1e-9) return null;
+  const solve = (r0: number, r1: number, r2: number): [number, number, number] => {
+    const dA =
+      r0 * (m11 * m22 - m12 * m21)
+      - m01 * (r1 * m22 - m12 * r2)
+      + m02 * (r1 * m21 - m11 * r2);
+    const dB =
+      m00 * (r1 * m22 - m12 * r2)
+      - r0 * (m10 * m22 - m12 * m20)
+      + m02 * (m10 * r2 - r1 * m20);
+    const dC =
+      m00 * (m11 * r2 - r1 * m21)
+      - m01 * (m10 * r2 - r1 * m20)
+      + r0 * (m10 * m21 - m11 * m20);
+    return [dA / det, dB / det, dC / det];
+  };
+  const [ax, bx, ecx] = solve(Sxc, Sxr, Sx);
+  const [ay, by, ecy] = solve(Syc, Syr, Sy);
+  for (const p of points) {
+    const px = ax * p.col + bx * p.row + ecx;
+    const py = ay * p.col + by * p.row + ecy;
+    if (Math.abs(px - p.x) >= AFFINE_EPSILON || Math.abs(py - p.y) >= AFFINE_EPSILON) return null;
+  }
+  return { ax, bx, ecx, ay, by, ecy };
+}
+
 function jsonForScript(value: unknown): string {
   return JSON.stringify(value)
     .replace(/0){packed=(Math.round(car/caw)<<16)|(Math.round(cag/c
 else{packed=C.gradient>0?lerp(C.cA.p,C.cB.p,clamp01(value*C.gradient)):C.cA.p;resolvedOpacity=C.cA.o}
 if(C.lit>0)packed=shadeColor(packed,1-C.lit*(1-clamp01(D.sh[k])))}
 var emittedCoverage=value>0?clamp01(value*resolvedOpacity):0;
-var inputCoverage=D.bg[k]!==" "?1:0;
+var inputCoverage=C.skipBase?1:D.bg[k]!==" "?1:0;
 var emittedWeight=emittedCoverage*C.opacity;
 var inputWeight=C.blend==="over"?inputCoverage*(1-emittedWeight):inputCoverage*(1-C.opacity);
 var nextCoverage=clamp01(emittedWeight+inputWeight);
@@ -345,8 +430,8 @@ var col=D.c[k],rw=D.r[k];
 var visible=nextCoverage>=1||(nextCoverage>0&&nextCoverage>thr(col,rw));
 if(!visible)continue;
 var chooseEmitted=emittedWeight>=inputWeight;
-var g=chooseEmitted?ramp[Math.min(rmax,Math.max(0,Math.round(value*rmax)))]:D.bg[k];
-var pk=blendColor(D.bc[k],packed,inputWeight,emittedWeight);
+var g=chooseEmitted?ramp[Math.min(rmax,Math.max(0,Math.round(value*rmax)))]:(C.skipBase?" ":D.bg[k]);
+var pk=blendColor(C.skipBase?-1:D.bc[k],packed,inputWeight,emittedWeight);
 rowBuf[rw].push([col,g,pk])}
 if(C.useColors){var html="";
 for(var r2=0;r2 Math.round(n * 1000) / 1000;
+  const affine = detectAffineCoords(
+    baked.cells.map((c) => ({ col: c.col - minCol, row: c.row - minRow, x: c.x, y: c.y })),
+  );
+  // Plane-fill case: every raster cell in the grid got baked (no silhouette
+  // gaps — the surface fills the whole viewport) AND the effect fully
+  // overwrites the pixel (`replace` at opacity 1 means the base contributes
+  // EXACTLY zero weight to every composited cell: see `inputWeight` in
+  // RUNTIME_JS's `frame()` — `inputCoverage*(1-C.opacity)` is 0 whenever
+  // opacity is 1). The baked base glyph/color grid is then provably never
+  // read, so it's dropped entirely and the runtime hardcodes "no base"
+  // instead. Partial coverage, `over`, or opacity < 1 all mean the base
+  // genuinely still shows through — keep baking it.
+  const skipBase =
+    baked.cells.length === options.cols * options.rows
+    && options.blend === "replace"
+    && clamp01(options.opacity ?? 1) === 1;
+
   const col: number[] = [], row: number[] = [], X: number[] = [], Y: number[] = [];
   const CXa: number[] = [], CYa: number[] = [], SH: number[] = [], BG: string[] = [], BC: number[] = [];
   // `cx,cy` are per-coplanar-group constants; in the common single-facet /
@@ -398,15 +500,15 @@ function buildRuntime(baked: Baked, params: GlyphEffectParamsOf = { c: col, r: row, x: X, y: Y, sh: SH, bg: BG, bc: BC };
+  const data: Record = { c: col, r: row, sh: SH };
+  if (!affine) { data.x = X; data.y = Y; }
+  if (!skipBase) { data.bg = BG; data.bc = BC; }
   if (!cxFixed) data.cx = CXa;
   if (!cyFixed) data.cy = CYa;
 
@@ -447,6 +549,14 @@ function buildRuntime(baked: Baked, params: GlyphEffectParamsOf
Date: Sat, 18 Jul 2026 22:31:43 +0200
Subject: [PATCH 16/23] =?UTF-8?q?feat(website):=20gallery-style=20/synth?=
 =?UTF-8?q?=20export=20=E2=80=94=20bottom-left=20CodePen=20+=20framework?=
 =?UTF-8?q?=20code=20window?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../SynthWorkbench/SynthCodePanel.tsx         |  86 ++++
 .../SynthWorkbench/SynthWorkbench.tsx         | 246 +++++++-----
 .../SynthWorkbench/synth-workbench.css        |  86 ++--
 .../SynthWorkbench/synthSnippets.ts           | 380 ++++++++++++++++++
 4 files changed, 660 insertions(+), 138 deletions(-)
 create mode 100644 website/src/components/SynthWorkbench/SynthCodePanel.tsx
 create mode 100644 website/src/components/SynthWorkbench/synthSnippets.ts

diff --git a/website/src/components/SynthWorkbench/SynthCodePanel.tsx b/website/src/components/SynthWorkbench/SynthCodePanel.tsx
new file mode 100644
index 00000000..0d828be3
--- /dev/null
+++ b/website/src/components/SynthWorkbench/SynthCodePanel.tsx
@@ -0,0 +1,86 @@
+import { useCallback, useMemo, useState } from "react";
+import { generateSynthSnippets, type SynthSnippetInput, type SynthTab } from "./synthSnippets";
+
+const TAB_LABEL: Record = { html: "HTML", vanilla: "JS", react: "React", vue: "Vue" };
+const TAB_ORDER: SynthTab[] = ["html", "vanilla", "react", "vue"];
+
+interface SynthCodePanelProps {
+  id?: string;
+  className?: string;
+  input: SynthSnippetInput;
+  onCodepen: () => void;
+  exporting: boolean;
+  onClose: () => void;
+}
+
+/**
+ * Gallery-style export code window for /synth — same look (`.gw-code-panel`
+ * header/tabs/actions/code classes from `gallery-workbench.css`, imported by
+ * this page) as `GalleryWorkbench/CodePanel.tsx`, generating framework
+ * snippets tailored to the synth's shape + camera + live field-synth patch
+ * instead of the gallery's model/preset/interaction state. Visibility is
+ * owned by the parent (`SynthWorkbench`'s `codeOpen` / mobile "Export" tab) —
+ * this component only mounts while shown, unlike the gallery's panel which
+ * stays mounted and merely collapses its body.
+ */
+export function SynthCodePanel({ id, className, input, onCodepen, exporting, onClose }: SynthCodePanelProps) {
+  const [tab, setTab] = useState("react");
+  const [copied, setCopied] = useState(false);
+  const snippets = useMemo(() => generateSynthSnippets(input), [input]);
+
+  const handleCopy = useCallback(async () => {
+    try {
+      await navigator.clipboard.writeText(snippets[tab]);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 1200);
+    } catch {
+      /* no-op */
+    }
+  }, [snippets, tab]);
+
+  return (
+    
+  );
+}
diff --git a/website/src/components/SynthWorkbench/SynthWorkbench.tsx b/website/src/components/SynthWorkbench/SynthWorkbench.tsx
index 21c8c94d..e606826a 100644
--- a/website/src/components/SynthWorkbench/SynthWorkbench.tsx
+++ b/website/src/components/SynthWorkbench/SynthWorkbench.tsx
@@ -6,6 +6,8 @@ import {
   createGlyphOrbitControls,
   injectGlyphBaseStyles,
   resolveGeometry,
+  buildGlyphInteractiveExport,
+  glyphCodepenPrefill,
   type GlyphEffectBlend,
   type GlyphGeometryName,
   type GlyphSceneHandle,
@@ -22,6 +24,8 @@ import type { GlyphEffectPreset, GlyphFieldSynthStaticExportResult } from "@glyp
 import { Dock } from "../Dock";
 import { useDockGui } from "../Dock/slots";
 import { useColor, useDockSlot, useFolder, useOption, useSlider, useText, useToggle } from "../Dock/primitives";
+import { SynthCodePanel } from "./SynthCodePanel";
+import type { SynthSnippetInput } from "./synthSnippets";
 import "../GalleryWorkbench/gallery-workbench.css";
 import "./synth-workbench.css";
 
@@ -484,67 +488,6 @@ function SynthDock({ shape, onShape, timeScale, onTimeScale, paused, onPaused, d
   return scopeHost ? createPortal(, scopeHost) : null;
 }
 
-// ── Floating export control (bottom-left of the viewport) ─────────────────────
-// Mirrors the gallery's `.gw-code-panel` collapse pattern (CodePanel.tsx +
-// gallery-workbench.css): a small header trigger, collapsed by default, that
-// expands to reveal actions. Reuses the shared `.gw-code-panel__*` header/
-// action classes for visual consistency; the export logic itself lives in
-// `SynthWorkbench` (`handleExportCodepen` / `handleExportCopyHtml`) — this
-// component only renders the trigger + the two buttons + status readout.
-// On mobile this same panel doubles as the drawer opened by the `.dn-mobile-tabs`
-// Export tab (`open` → `is-mobile-open`, same mechanism as the gallery's Code
-// tab / `.gw-code-panel`) — desktop behavior (floating, collapsed by default,
-// toggled by its own header) is untouched.
-function SynthExportPanel({ id, open, onExportCodepen, onExportCopyHtml, exportStatus }: {
-  id?: string; open?: boolean; onExportCodepen: () => void; onExportCopyHtml: () => void; exportStatus: string;
-}): ReactNode {
-  const [collapsed, setCollapsed] = useState(true);
-  // Opening via the mobile Export tab reveals the actions in one tap — like the
-  // gallery's Code tab — instead of requiring a second tap on the header once
-  // the drawer is already open.
-  useEffect(() => { if (open) setCollapsed(false); }, [open]);
-  return (
-    
-
- [ EXPORT ] -
- -
-
- {!collapsed && ( -
- - - {exportStatus && {exportStatus}} -
- )} -
- ); -} - // ── URL persistence (everything the synth is configured to, in ?s=) ─────────── function encodeSynthState(state: unknown): string { try { return btoa(unescape(encodeURIComponent(JSON.stringify(state)))).replace(/=+$/, ""); } catch { return ""; } @@ -702,17 +645,75 @@ export default function SynthWorkbench() { const presets = useMemo(() => (fieldSynth.presets ?? []) as readonly GlyphEffectPreset[], []); - // ── Static export (effect-only, static-camera self-contained pen) ────────── - const [exportStatus, setExportStatus] = useState(""); - - // Builds the SAME export `buildGlyphFieldSynthStaticExport` bakes for both - // "open in CodePen" and "copy standalone HTML" — reads the mesh, the current - // patch, the camera, the density-driven grid, and the blend the layer is - // ACTUALLY mounted with (SYNTH_EFFECT_BLEND), so the pen matches what's on - // screen. `loopSeconds` scales inversely with the live time-scale so a - // faster-animating patch doesn't wrap after an unreasonably long wall-clock - // wait, and vice-versa; the exported clock is otherwise independent — it - // starts fresh from `time=0` on load, not from wherever the live preview is. + // ── Export ─────────────────────────────────────────────────────────────── + // A standard, always-visible "Open in CodePen" button (static, zero-lib — + // ships the currently-rendered ASCII as-is, no glyphcss/effects runtime) + // plus an "Export" toggle that mounts a gallery-look code window + // (`SynthCodePanel`, mirroring `GalleryWorkbench`'s `CodePanel`): framework + // tabs of lib-based code (imports glyphcss + @glyphcss/effects from a CDN, + // mounts the field-synth layer + a time clock) with its OWN CodePen action + // that ships that dynamic version. The `codeOpen` desktop toggle and the + // mobile `mobilePanel === "export"` tab share one code window; + // `cameraSnapshot` captures the live (imperative, non-React-state) camera + // orientation at the moment the window opens, since orbiting doesn't trigger + // a re-render otherwise. + const [codeOpen, setCodeOpen] = useState(false); + const [exporting, setExporting] = useState(false); + const [cameraSnapshot, setCameraSnapshot] = useState({ rotX: 0, rotY: 0, zoom: 46 }); + + const snapshotCamera = useCallback(() => { + const camera = cameraRef.current; + if (camera) setCameraSnapshot({ rotX: camera.rotX, rotY: camera.rotY, zoom: camera.zoom }); + }, []); + const toggleCodeOpen = useCallback(() => { + setCodeOpen((open) => { if (!open) snapshotCamera(); return !open; }); + }, [snapshotCamera]); + const handleMobileExportTab = useCallback(() => { + setMobilePanel((current) => { + if (current === "export") return null; + snapshotCamera(); + return "export"; + }); + }, [snapshotCamera]); + const closeCodePanel = useCallback(() => { + setCodeOpen(false); + setMobilePanel((m) => (m === "export" ? null : m)); + }, []); + + const codeInput = useMemo(() => ({ + shape, + params: params as Record, + timeScale, + paused, + density, + lighting, + camera: cameraSnapshot, + }), [shape, params, timeScale, paused, density, lighting, cameraSnapshot]); + + /** POST a raw CodePen prefill `data` JSON payload (opens a new pen in a new tab). */ + function postCodepenForm(action: string, data: string): void { + const form = document.createElement("form"); + form.method = "POST"; + form.action = action; + form.target = "_blank"; + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "data"; + input.value = data; + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + form.remove(); + } + + // Builds the SAME static (zero-lib) export `buildGlyphFieldSynthStaticExport` + // bakes for the standalone "Open in CodePen" button — reads the mesh, the + // current patch, the camera, the density-driven grid, and the blend the + // layer is ACTUALLY mounted with (SYNTH_EFFECT_BLEND), so the pen matches + // what's on screen. `loopSeconds` scales inversely with the live time-scale + // so a faster-animating patch doesn't wrap after an unreasonably long + // wall-clock wait, and vice-versa; the exported clock is otherwise + // independent — it starts fresh from `time=0` on load. const buildSynthExport = useCallback((): GlyphFieldSynthStaticExportResult | null => { const scene = sceneRef.current, camera = cameraRef.current, host = hostRef.current; if (!scene || !camera || !host) return null; @@ -749,36 +750,51 @@ export default function SynthWorkbench() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [shape, params, lighting, timeScale]); - /** POST a CodePen prefill payload (opens a new pen in a new tab) — mirrors the gallery's CodePanel. */ - function postToCodepen(title: string, pen: { html: string; css: string; js: string }): void { - const form = document.createElement("form"); - form.method = "POST"; - form.action = "https://codepen.io/pen/define"; - form.target = "_blank"; - const input = document.createElement("input"); - input.type = "hidden"; - input.name = "data"; - input.value = JSON.stringify({ title, ...pen, editors: "110" }); - form.appendChild(input); - document.body.appendChild(form); - form.submit(); - form.remove(); - } - - const handleExportCodepen = useCallback(() => { + // Standalone, always-visible "Open in CodePen" button (bottom-left): ships + // the static, zero-lib baked pen (the ASCII the page currently renders + + // a pure-CSS loop) — no glyphcss/effects runtime. + const handleExportCodepenStatic = useCallback(() => { const result = buildSynthExport(); - if (!result) { setExportStatus("Nothing rendered yet"); return; } - postToCodepen(`glyphcss field synth — ${shape}`, result.pen); - setExportStatus("Opened in CodePen"); + if (!result) return; + setExporting(true); + try { + postCodepenForm("https://codepen.io/pen/define", JSON.stringify({ title: `glyphcss field synth — ${shape}`, ...result.pen, editors: "110" })); + } finally { + setExporting(false); + } }, [buildSynthExport, shape]); - const handleExportCopyHtml = useCallback(() => { - const result = buildSynthExport(); - if (!result) { setExportStatus("Nothing rendered yet"); return; } - navigator.clipboard.writeText(result.html) - .then(() => setExportStatus("Copied standalone HTML")) - .catch(() => setExportStatus("Copy failed — clipboard permission?")); - }, [buildSynthExport]); + // "Export" code window's own CodePen action (and each framework tab): + // compiles the current shape + camera + field-synth patch into a + // self-contained, lib-based (glyphcss + @glyphcss/effects from the CDN) + // CodePen — mirrors the gallery's `handleCodepen`. + const handleExportCodepenDynamic = useCallback(() => { + const camera = cameraRef.current; + if (!camera) return; + setExporting(true); + try { + const flat = isFlat(shape); + const result = buildGlyphInteractiveExport(shapePolys(shape), { + interactions: flat ? [] : ["orbit"], + rotX: camera.rotX, + rotY: camera.rotY, + zoom: camera.zoom, + projection: "orthographic", + mode: "solid", + useColors: true, + effect: { + id: fieldSynth.id, + params: params as Record, + blend: SYNTH_EFFECT_BLEND, + timeScale: paused ? 0 : timeScale, + }, + }); + const prefill = glyphCodepenPrefill(result, `glyphcss field synth — ${shape}`); + postCodepenForm(prefill.action, prefill.data); + } finally { + setExporting(false); + } + }, [shape, params, paused, timeScale]); return (
@@ -797,7 +813,35 @@ export default function SynthWorkbench() {
- +
+ + +
+ {(codeOpen || mobilePanel === "export") && ( + + )}
setLighting((l) => ({ ...l, ...partial }))} params={params} onParam={onParam} paramsRef={paramsRef} tsRef={tsRef} pausedRef={pausedRef} /> @@ -839,7 +883,7 @@ export default function SynthWorkbench() { className={`dn-mobile-tabs__button${mobilePanel === "export" ? " is-active" : ""}`} aria-controls="synth-export-panel" aria-expanded={mobilePanel === "export"} - onClick={() => setMobilePanel((current) => current === "export" ? null : "export")} + onClick={handleMobileExportTab} > Export diff --git a/website/src/components/SynthWorkbench/synth-workbench.css b/website/src/components/SynthWorkbench/synth-workbench.css index 200cb03c..d28860d0 100644 --- a/website/src/components/SynthWorkbench/synth-workbench.css +++ b/website/src/components/SynthWorkbench/synth-workbench.css @@ -218,60 +218,72 @@ .synth-viewport { position: absolute; inset: 0; overflow: hidden; } .synth-viewport .glyph-output { color: rgba(255, 232, 184, 0.92); } -/* ── Floating export panel (bottom-left of the viewport) ───────────────────── - Mirrors the gallery's `.gw-code-panel` collapse pattern: a small header - trigger, collapsed by default (no code/tabs here — just two export - actions), that expands downward-anchored-upward (position: absolute + - `bottom` fixes the bottom edge, so growth pushes the top edge up instead - of overflowing off-screen). Reuses `.gw-code-panel__head/__legend/ - __actions/__action(--codepen)` from gallery-workbench.css (imported by - this page) for the header/button look; positioning + collapse sizing are - synth-specific since this panel has no tabs/code body to size around. */ -.synth-export { +/* ── Export (bottom-left of the viewport) ──────────────────────────────────── + Gallery-style export: a standard, always-visible "Open in CodePen" button + plus an "Export" toggle, both bottom-left; "Export" mounts a gallery-look + code window (`SynthCodePanel` — reuses `.gw-code-panel__head/__legend/ + __tabs/__tab/__actions/__action(--codepen)/__body/__code` from + gallery-workbench.css, imported by this page) above the bar. Unlike the + gallery's `CodePanel` (always mounted, internal collapse), the code window + here is only mounted by `SynthWorkbench` while open — `.synth-code-panel` + only needs to override the base `.gw-code-panel` rule's position so it + sits above the bar instead of on top of it, and to redo the mobile + drawer sizing scoped to `.dn-root--synth` (the gallery's own mobile + override only matches `.dn-root--gallery`, so it never applies here). */ +.synth-export-bar { position: absolute; - bottom: var(--overlay-bottom, 12px); left: var(--overlay-left, 12px); - z-index: 14; - display: flex; - flex-direction: column; - width: max-content; - max-width: min(280px, calc(100% - 24px)); - background: #0d1018; - border: 1px solid rgba(255, 232, 184, 0.18); - font-family: ui-monospace, "JetBrains Mono", "SF Mono", "Menlo", monospace; -} -.synth-export__body { + bottom: var(--overlay-bottom, 12px); + z-index: 15; display: flex; - flex-direction: column; gap: 6px; - padding: 8px 10px; } -.synth-export__body .gw-code-panel__action { - width: 100%; - text-align: center; +.synth-export-bar .gw-code-panel__action { + background: #0d1018; +} +.synth-export-bar .gw-code-panel__action.is-active { + color: #03050a; + background: rgba(56, 189, 248, 0.85); + border-color: rgba(56, 189, 248, 0.85); } -.synth-export__status { - font-size: 10px; - letter-spacing: 0.02em; - color: rgba(255, 232, 184, 0.55); + +/* Compound with `.gw-code-panel` (0,2,0) rather than `.synth-code-panel` + alone (0,1,0): Astro/Vite emit `gallery-workbench.css` and + `synth-workbench.css` as separate chunks, and the built page has been + observed loading the gallery chunk's AFTER this page's own chunk + (reversed from the source `import` order) — a same-specificity override + would then lose the cascade to `.gw-code-panel`'s base rule. The extra + specificity makes this override win regardless of chunk/link order. */ +.synth-code-panel.gw-code-panel { + bottom: calc(var(--overlay-bottom, 12px) + 34px); + width: clamp(360px, 42vw, 640px); + /* `.gw-code-panel`'s own max-height formula assumes the gallery's taller + chrome above the panel (`- 60vh`) and starves this shorter `.synth-main` + (viewport + a slim bottom bar, no side inspector) down to a sliver — + size against this container's own height instead, generously (like the + gallery's panel reads on a typical viewport). */ + max-height: min(64vh, calc(100% - var(--overlay-top, 12px) - 34px - 12px)); } @media (max-width: 760px) { - /* Export becomes a bottom-drawer reachable only via the Export tab, mirroring - the gallery's `.gw-code-panel` mobile pattern (hidden until `.is-mobile-open`, - same `--mobile-panel-bottom` offset, same drawer z-index) instead of staying - always-visible and merely repositioning. Desktop's floating panel is untouched. */ - .dn-root--synth .synth-export { - display: none; + /* The bar duplicates the mobile tab bar's "Export" tab — hide it and rely + on that tab (same drawer, opened via `mobilePanel === "export"`). */ + .dn-root--synth .synth-export-bar { display: none; } + + /* Export becomes a full bottom-drawer reachable via the Export tab — + mirrors the gallery's `.dn-root--gallery .gw-code-panel.is-mobile-open` + mobile sizing, scoped to this page's root class instead. */ + .dn-root--synth .synth-code-panel.gw-code-panel { top: auto; left: var(--overlay-left); right: var(--overlay-right); bottom: var(--mobile-panel-bottom); width: auto; max-width: none; + max-height: none; + height: min(66vh, calc(100% - var(--overlay-top) - var(--mobile-panel-bottom) - 8px)); z-index: 25; } - .dn-root--synth .synth-export.is-mobile-open { display: flex; } } /* Combined-waveform scope, portaled into the right Dock's MIX folder above diff --git a/website/src/components/SynthWorkbench/synthSnippets.ts b/website/src/components/SynthWorkbench/synthSnippets.ts new file mode 100644 index 00000000..70397674 --- /dev/null +++ b/website/src/components/SynthWorkbench/synthSnippets.ts @@ -0,0 +1,380 @@ +/** + * synthSnippets — generate the /synth page's "use the libs" export code + * (HTML custom elements / vanilla JS / React / Vue), each importing glyphcss + * + `@glyphcss/effects` from a CDN and mounting the live field-synth patch + * (current params + a rAF time clock) on the current shape + camera. + * + * Sibling to the gallery's `CodePanel.tsx` `generateSnippets` — same shape + * (one function per framework, rendered by the same-look `SynthCodePanel`) + * — kept separate rather than reused directly because the synth scene has + * no primitive/src mesh, no perspective camera, and needs its own + * per-face-UV shape builder (`shapePolys`/`withFaceUvs`, mirroring + * `SynthWorkbench.tsx`) inlined into every snippet: `@glyphcss/core` has no + * `"plane"` geometry and `resolveGeometry` never generates UVs, so a + * faithful export has to ship that helper rather than call a lib function. + */ + +export type SynthTab = "html" | "vanilla" | "react" | "vue"; + +export interface SynthLighting { + azimuth: number; + elevation: number; + keyIntensity: number; + keyColor: string; + ambient: number; +} + +export interface SynthSnippetInput { + shape: string; + /** Live field-synth params, minus `time` (the emitted clock owns it). */ + params: Record; + timeScale: number; + paused: boolean; + density: number; + lighting: SynthLighting; + camera: { rotX: number; rotY: number; zoom: number }; +} + +/** The ONE blend both scene.addEffectLayer() calls in SynthWorkbench mount the + * layer with — kept in sync with `SYNTH_EFFECT_BLEND` there. */ +export const SYNTH_EFFECT_BLEND = "replace"; + +export const isFlatShape = (shape: string): boolean => shape === "plane"; + +function fmt(n: number): string { + if (!Number.isFinite(n)) return "0"; + return String(Number(n.toFixed(2))); +} + +function vec3(v: [number, number, number]): string { + return `[${fmt(v[0])}, ${fmt(v[1])}, ${fmt(v[2])}]`; +} + +function dirFromSpherical(azimuthDeg: number, elevationDeg: number): [number, number, number] { + const az = (azimuthDeg * Math.PI) / 180; + const el = (elevationDeg * Math.PI) / 180; + return [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)]; +} + +function jsonForScript(value: unknown): string { + const json = JSON.stringify(value); + if (json === undefined) throw new TypeError("Synth snippet values must be JSON-serializable."); + return json + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +/** + * Inlined verbatim (functionally) from `SynthWorkbench.tsx`'s `flatQuad` / + * `withFaceUvs` / `shapePolys` so the exported snippet reproduces the exact + * per-face UV mapping the live page renders with. + */ +function shapePolysHelperSrc(shape: string): string { + if (isFlatShape(shape)) { + return `function shapePolys() { + const size = 3; + return [{ + vertices: [[-size, -size, 0], [size, -size, 0], [size, size, 0], [-size, size, 0]], + uvs: [[0, 0], [1, 0], [1, 1], [0, 1]], + }]; +}`; + } + return `function withFaceUvs(polys) { + return polys.map((p) => { + const vs = p.vertices; + const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; + const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }; + const n = norm(cross(sub(vs[1], vs[0]), sub(vs[2], vs[0]))); + const u = norm(sub(vs[1], vs[0])); + const v = cross(n, u); + const proj = vs.map((w) => { const d = sub(w, vs[0]); return [dot(d, u), dot(d, v)]; }); + let mnu = Infinity, mxu = -Infinity, mnv = Infinity, mxv = -Infinity; + for (const [pu, pv] of proj) { if (pu < mnu) mnu = pu; if (pu > mxu) mxu = pu; if (pv < mnv) mnv = pv; if (pv > mxv) mxv = pv; } + const su = (mxu - mnu) || 1, sv = (mxv - mnv) || 1; + return { ...p, uvs: proj.map(([pu, pv]) => [(pu - mnu) / su, (pv - mnv) / sv]) }; + }); +} +function shapePolys() { + return withFaceUvs(resolveGeometry(${JSON.stringify(shape)}, { size: 3 })); +}`; +} + +interface Prepared { + flat: boolean; + fontSizePx: number; + lightDir: [number, number, number]; + effectParams: string; + effectTimeScale: string; + helperSrc: string; + rotX: string; + rotY: string; + zoom: string; + lighting: SynthLighting; +} + +function prepare(input: SynthSnippetInput): Prepared { + const { shape, params, timeScale, paused, density, lighting, camera } = input; + return { + flat: isFlatShape(shape), + fontSizePx: Math.round((13 / density) * 100) / 100, + lightDir: dirFromSpherical(lighting.azimuth, lighting.elevation), + effectParams: jsonForScript(params), + effectTimeScale: fmt(paused ? 0 : timeScale), + helperSrc: shapePolysHelperSrc(shape), + rotX: fmt(camera.rotX), + rotY: fmt(camera.rotY), + zoom: fmt(camera.zoom), + lighting, + }; +} + +function buildVanilla(p: Prepared): string { + const imports = [ + `import {\n createGlyphOrthographicCamera,\n createGlyphScene,${p.flat ? "" : "\n createGlyphOrbitControls,"}\n} from "glyphcss";`, + ]; + if (!p.flat) imports.push(`import { resolveGeometry } from "@glyphcss/core";`); + imports.push(`import { GlyphEffects } from "@glyphcss/effects";`); + + return `${imports.join("\n")} + +${p.helperSrc} + +const host = document.querySelector("#scene")!; +// Cell font-size sets the ASCII resolution; autoSize fills the host's box. +host.style.fontSize = "${p.fontSizePx}px"; + +const camera = createGlyphOrthographicCamera({ rotX: ${p.rotX}, rotY: ${p.rotY}, zoom: ${p.zoom} }); + +const scene = createGlyphScene(host, { + camera, + mode: "solid", + autoSize: true, + useColors: true, + glyphPalette: "default", + doubleSided: ${p.flat}, + directionalLight: { + direction: ${vec3(p.lightDir)}, + intensity: ${fmt(p.lighting.keyIntensity)}, + color: "${p.lighting.keyColor}", + }, + ambientLight: { intensity: ${fmt(p.lighting.ambient)} }, +}); + +scene.add(shapePolys()); +scene.fit(); +${p.flat ? "" : "createGlyphOrbitControls(scene, { drag: true, wheel: true });\n"} +const effectLayer = scene.addEffectLayer({ + effect: GlyphEffects.fieldSynth, + params: ${p.effectParams}, + target: "surfaces", + blend: "${SYNTH_EFFECT_BLEND}", +}); + +let effectTime = 0; +let effectPrevious = performance.now(); +function animateEffect(now: number) { + effectTime += Math.min((now - effectPrevious) / 1000, 0.1) * ${p.effectTimeScale}; + effectPrevious = now; + effectLayer.params.time = effectTime; + requestAnimationFrame(animateEffect); +} +requestAnimationFrame(animateEffect);`; +} + +function buildReact(p: Prepared): string { + const coreSpecifiers = p.flat ? "type Polygon" : "resolveGeometry, type Polygon"; + return `import { useEffect, useMemo, useRef } from "react"; +import { + GlyphOrthographicCamera, + GlyphScene, + GlyphMesh,${p.flat ? "" : "\n GlyphOrbitControls,"} + GlyphEffectLayer, + type GlyphEffectLayerHandle, +} from "@glyphcss/react"; +import { ${coreSpecifiers} } from "@glyphcss/core"; +import { GlyphEffects } from "@glyphcss/effects"; + +${p.helperSrc} + +const directionalLight = { + direction: ${vec3(p.lightDir)}, + intensity: ${fmt(p.lighting.keyIntensity)}, + color: "${p.lighting.keyColor}", +}; +const ambientLight = { intensity: ${fmt(p.lighting.ambient)} }; + +export function App() { + const polygons = useMemo(() => shapePolys(), []); + const effectLayer = useRef>(null); + + useEffect(() => { + let raf = 0; + let time = 0; + let previous = performance.now(); + const frame = (now: number) => { + time += Math.min((now - previous) / 1000, 0.1) * ${p.effectTimeScale}; + previous = now; + if (effectLayer.current) effectLayer.current.params.time = time; + raf = requestAnimationFrame(frame); + }; + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, []); + + return ( + + + ${p.flat ? "" : "\n "} + + + + ); +}`; +} + +function buildVue(p: Prepared): string { + const coreSpecifiers = p.flat ? "type Polygon" : "resolveGeometry, type Polygon"; + return ` + +`; +} + +function buildHtml(p: Prepared): string { + const imports = [`import { GlyphEffects } from "https://esm.sh/@glyphcss/effects";`]; + if (!p.flat) imports.unshift(`import { resolveGeometry } from "https://esm.sh/@glyphcss/core";`); + + return ` + + + + + + + + + ${p.flat ? "" : "\n "} + + + + + +`; +} + +export function generateSynthSnippets(input: SynthSnippetInput): Record { + const p = prepare(input); + return { + html: buildHtml(p), + vanilla: buildVanilla(p), + react: buildReact(p), + vue: buildVue(p), + }; +} From 89e29ba12f4cb96fb21d28ff619d9f498432cba7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 22:51:40 +0200 Subject: [PATCH 17/23] perf(effects): drop redundant per-cell DATA for fully-covered exports (plane pen ships CFG only) --- packages/effects/src/staticExport.test.ts | 77 +++++++++++++-------- packages/effects/src/staticExport.ts | 81 ++++++++++++++++------- 2 files changed, 107 insertions(+), 51 deletions(-) diff --git a/packages/effects/src/staticExport.test.ts b/packages/effects/src/staticExport.test.ts index ac18cdd5..865d2cc0 100644 --- a/packages/effects/src/staticExport.test.ts +++ b/packages/effects/src/staticExport.test.ts @@ -126,7 +126,7 @@ describe("buildGlyphFieldSynthStaticExport", () => { expect(() => buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ params: { glyphs: "" } }))).toThrow(); }); - it("a flat, head-on, fully-covered plane bakes an inline affine coordinate function instead of a per-cell table, and skips the base grid", () => { + it("a flat, head-on, fully-covered plane drops the per-cell table entirely: no DATA at all", () => { const result = buildGlyphFieldSynthStaticExport(planeMesh(), baseOptions({ rotX: 0, rotY: 0, @@ -134,43 +134,54 @@ describe("buildGlyphFieldSynthStaticExport", () => { cols: 24, rows: 12, })); - const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/); - expect(dataMatch).not.toBeNull(); - const data = JSON.parse(dataMatch![1]!) as Record; + // A flat, evenly-lit, fully-covered, head-on plane hoists every per-cell + // field it bakes (coords → affine scalars, shade → a constant, cx/cy → + // constants, col/row → derivable from the loop index, base → unread) — + // nothing is left for `DATA` to carry, so `var DATA=` is omitted + // entirely rather than emitted as `{}`. + expect(result.js).not.toMatch(/var DATA=/); + expect(result.js).toMatch(/^var CFG=/); + const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s); expect(cfgMatch).not.toBeNull(); - const cfg = JSON.parse(cfgMatch![1]!) as { aff: number[] | null; skipBase: boolean }; - - // No per-cell coordinate table (the ~86%-of-payload cost the affine path - // is meant to eliminate) and no baked base glyph/color grid (`replace` - // blend at opacity 1 fully covering the grid makes it unread). - expect(data.x).toBeUndefined(); - expect(data.y).toBeUndefined(); - expect(data.bg).toBeUndefined(); - expect(data.bc).toBeUndefined(); - // The per-cell col/row index and shade arrays are still baked (cheap, - // and needed regardless of the coordinate strategy). - expect(Array.isArray(data.c)).toBe(true); - expect((data.c as unknown[]).length).toBeGreaterThan(0); + const cfg = JSON.parse(cfgMatch![1]!) as { + aff: number[] | null; skipBase: boolean; full: boolean; cols: number; + shFixed: boolean; sh: number; cxFixed: boolean; cyFixed: boolean; + }; // 6 fitted scalars driving `x = aff[0]*col + aff[1]*row + aff[2]` - // (and the `y` analogue) at runtime instead. + // (and the `y` analogue) at runtime instead of a per-cell table. expect(cfg.aff).not.toBeNull(); expect(cfg.aff).toHaveLength(6); expect(cfg.aff!.every((n) => Number.isFinite(n))).toBe(true); expect(cfg.skipBase).toBe(true); - expect(result.js).toContain("C.aff?C.aff[0]*D.c[k]+C.aff[1]*D.r[k]+C.aff[2]:D.x[k]"); + expect(cfg.full).toBe(true); + expect(cfg.cols).toBe(24); + expect(cfg.shFixed).toBe(true); + expect(cfg.cxFixed).toBe(true); + expect(cfg.cyFixed).toBe(true); + expect(result.js).toContain("var col0=C.full?k%C.cols:D.c[k],row0=C.full?(k/C.cols)|0:D.r[k];"); + expect(result.js).toContain("C.aff?C.aff[0]*col0+C.aff[1]*row0+C.aff[2]:D.x[k]"); + expect(result.js).toContain("typeof DATA<\"u\"?DATA:0"); }); - it("a curved surface (sphere) keeps the baked per-cell coordinate table and base grid — never mis-detected as affine", () => { + it("a curved surface (sphere) keeps the baked per-cell coordinate AND index table — never mis-detected as affine or full", () => { const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions()); const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/); - const data = JSON.parse(dataMatch![1]!) as { x: number[]; y: number[]; bg: string[]; bc: number[] }; + expect(dataMatch).not.toBeNull(); + const data = JSON.parse(dataMatch![1]!) as { c: number[]; r: number[]; x: number[]; y: number[]; bg: string[]; bc: number[] }; const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s); - const cfg = JSON.parse(cfgMatch![1]!) as { aff: number[] | null; skipBase: boolean }; + const cfg = JSON.parse(cfgMatch![1]!) as { aff: number[] | null; skipBase: boolean; full: boolean }; expect(cfg.aff).toBeNull(); expect(cfg.skipBase).toBe(false); + expect(cfg.full).toBe(false); + // Sparse (silhouette-gapped) bakes genuinely need the per-cell index + // table — there's no formula for "which cells are covered". + expect(Array.isArray(data.c)).toBe(true); + expect(Array.isArray(data.r)).toBe(true); + expect(data.c.length).toBeGreaterThan(0); + expect(data.c.length).toBe(data.r.length); expect(Array.isArray(data.x)).toBe(true); expect(Array.isArray(data.y)).toBe(true); expect(data.x.length).toBeGreaterThan(0); @@ -178,7 +189,7 @@ describe("buildGlyphFieldSynthStaticExport", () => { expect(Array.isArray(data.bc)).toBe(true); }); - it("a partially-covered plane (not filling the grid) keeps the base grid even though coordinates are still affine", () => { + it("a partially-covered plane (not filling the grid) keeps the index table and base grid even though coordinates are still affine", () => { const result = buildGlyphFieldSynthStaticExport(planeMesh(), baseOptions({ rotX: 0, rotY: 0, @@ -187,22 +198,28 @@ describe("buildGlyphFieldSynthStaticExport", () => { rows: 12, })); const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/); + expect(dataMatch).not.toBeNull(); const data = JSON.parse(dataMatch![1]!) as Record; const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s); - const cfg = JSON.parse(cfgMatch![1]!) as { aff: number[] | null; skipBase: boolean }; + const cfg = JSON.parse(cfgMatch![1]!) as { aff: number[] | null; skipBase: boolean; full: boolean }; // Coordinates are still an exact affine function of (col,row) on a flat // plane regardless of how much of the grid it covers. expect(cfg.aff).not.toBeNull(); expect(data.x).toBeUndefined(); - // But the base is genuinely needed here (uncovered cells fall back to + // But it's not fully covered, so the index table stays (col/row can't be + // derived from the loop index without a formula for which cells exist). + expect(cfg.full).toBe(false); + expect(Array.isArray(data.c)).toBe(true); + expect((data.c as unknown[]).length).toBeGreaterThan(0); + // The base is genuinely needed here too (uncovered cells fall back to // it), so it must NOT be skipped. expect(cfg.skipBase).toBe(false); expect(data.bg).toBeDefined(); expect(data.bc).toBeDefined(); }); - it("opacity < 1 with a fully-covered `replace` plane keeps the base grid (input genuinely still shows through)", () => { + it("opacity < 1 with a fully-covered `replace` plane drops the index table (still fully covered) but keeps the base grid (input genuinely still shows through)", () => { const result = buildGlyphFieldSynthStaticExport(planeMesh(), baseOptions({ rotX: 0, rotY: 0, @@ -212,11 +229,17 @@ describe("buildGlyphFieldSynthStaticExport", () => { opacity: 0.5, })); const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/); + expect(dataMatch).not.toBeNull(); const data = JSON.parse(dataMatch![1]!) as Record; const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s); - const cfg = JSON.parse(cfgMatch![1]!) as { skipBase: boolean }; + const cfg = JSON.parse(cfgMatch![1]!) as { skipBase: boolean; full: boolean }; + expect(cfg.full).toBe(true); expect(cfg.skipBase).toBe(false); + // Fully covered, so the index table is still droppable independent of + // whether the base is skipped. + expect(data.c).toBeUndefined(); + expect(data.r).toBeUndefined(); expect(data.bg).toBeDefined(); expect(data.bc).toBeDefined(); }); diff --git a/packages/effects/src/staticExport.ts b/packages/effects/src/staticExport.ts index cedfd5ad..644aa6a4 100644 --- a/packages/effects/src/staticExport.ts +++ b/packages/effects/src/staticExport.ts @@ -375,7 +375,7 @@ function jsonForScript(value: unknown): string { // since it has to run with zero glyphcss/effects code alongside it. const RUNTIME_JS = ` "use strict"; -var D=DATA,C=CFG,N=D.c.length,ramp=C.ramp,rmax=ramp.length-1,V=C.voices; +var D=typeof DATA<"u"?DATA:0,C=CFG,N=C.full?C.cols*C.rows:D.c.length,ramp=C.ramp,rmax=ramp.length-1,V=C.voices; var BAYER=[0,8,2,10,12,4,14,6,3,11,1,9,15,7,13,5]; function pmod(a,m){return((a%m)+m)%m} function clamp01(v){return v<0?0:v>1?1:v} @@ -405,9 +405,11 @@ var rowBuf=new Array(C.rows); function frame(now){var t=(now/1000)%C.loop; for(var i=0;i0){ if(C.voiceColors&&cw>0){packed=(Math.round(cr/cw)<<16)|(Math.round(cg/cw)<<8)|Math.round(cbv/cw);resolvedOpacity=co/cw} else if(C.voiceColors&&caw>0){packed=(Math.round(car/caw)<<16)|(Math.round(cag/caw)<<8)|Math.round(cabv/caw);resolvedOpacity=cao/caw} else{packed=C.gradient>0?lerp(C.cA.p,C.cB.p,clamp01(value*C.gradient)):C.cA.p;resolvedOpacity=C.cA.o} -if(C.lit>0)packed=shadeColor(packed,1-C.lit*(1-clamp01(D.sh[k])))} +if(C.lit>0)packed=shadeColor(packed,1-C.lit*(1-clamp01(sh)))} var emittedCoverage=value>0?clamp01(value*resolvedOpacity):0; var inputCoverage=C.skipBase?1:D.bg[k]!==" "?1:0; var emittedWeight=emittedCoverage*C.opacity; var inputWeight=C.blend==="over"?inputCoverage*(1-emittedWeight):inputCoverage*(1-C.opacity); var nextCoverage=clamp01(emittedWeight+inputWeight); -var col=D.c[k],rw=D.r[k]; +var col=col0,rw=row0; var visible=nextCoverage>=1||(nextCoverage>0&&nextCoverage>thr(col,rw)); if(!visible)continue; var chooseEmitted=emittedWeight>=inputWeight; @@ -469,19 +471,26 @@ function buildRuntime(baked: Baked, params: GlyphEffectParamsOf ({ col: c.col - minCol, row: c.row - minRow, x: c.x, y: c.y })), ); - // Plane-fill case: every raster cell in the grid got baked (no silhouette - // gaps — the surface fills the whole viewport) AND the effect fully - // overwrites the pixel (`replace` at opacity 1 means the base contributes - // EXACTLY zero weight to every composited cell: see `inputWeight` in - // RUNTIME_JS's `frame()` — `inputCoverage*(1-C.opacity)` is 0 whenever - // opacity is 1). The baked base glyph/color grid is then provably never - // read, so it's dropped entirely and the runtime hardcodes "no base" - // instead. Partial coverage, `over`, or opacity < 1 all mean the base - // genuinely still shows through — keep baking it. - const skipBase = - baked.cells.length === options.cols * options.rows - && options.blend === "replace" - && clamp01(options.opacity ?? 1) === 1; + // Plane-fill: every raster cell in `cols×rows` got baked — no silhouette + // gaps, the surface fills the whole viewport. `bake()` walks the grid in + // increasing row-major index order (`i = 0..cols*rows-1`, `col=i%cols, + // row=(i/cols)|0`) and only skips uncovered cells, so a full bake is + // guaranteed to still be in that exact row-major order — every (col,row) + // pair appears exactly once, in scan order. That means the per-cell `c`/`r` + // index arrays carry zero information beyond the loop counter: the runtime + // can derive them from `k` and `CFG.cols` instead of reading a table. + // Sparse bakes (silhouette gaps — e.g. a cube or sphere) keep the table; + // there's no formula for "which cells are covered". + const full = baked.cells.length === options.cols * options.rows; + // Plane-fill case, further restricted: the effect also fully overwrites + // the pixel (`replace` at opacity 1 means the base contributes EXACTLY + // zero weight to every composited cell: see `inputWeight` in RUNTIME_JS's + // `frame()` — `inputCoverage*(1-C.opacity)` is 0 whenever opacity is 1). + // The baked base glyph/color grid is then provably never read, so it's + // dropped entirely and the runtime hardcodes "no base" instead. Partial + // coverage, `over`, or opacity < 1 all mean the base genuinely still shows + // through — keep baking it. + const skipBase = full && options.blend === "replace" && clamp01(options.opacity ?? 1) === 1; const col: number[] = [], row: number[] = [], X: number[] = [], Y: number[] = []; const CXa: number[] = [], CYa: number[] = [], SH: number[] = [], BG: string[] = [], BC: number[] = []; @@ -491,26 +500,35 @@ function buildRuntime(baked: Baked, params: GlyphEffectParamsOf 1e-6) cxFixed = false; if (Math.abs(c.cy - cy0) > 1e-6) cyFixed = false; + if (Math.round(c.shade * 100) / 100 !== sh0) shFixed = false; } for (const c of baked.cells) { - col.push(c.col - minCol); - row.push(c.row - minRow); + if (!full) { col.push(c.col - minCol); row.push(c.row - minRow); } if (!affine) { X.push(q(c.x)); Y.push(q(c.y)); } if (!cxFixed) CXa.push(q(c.cx)); if (!cyFixed) CYa.push(q(c.cy)); - SH.push(Math.round(c.shade * 100) / 100); + if (!shFixed) SH.push(Math.round(c.shade * 100) / 100); if (!skipBase) { BG.push(c.glyph); BC.push(c.color); } } - const data: Record = { c: col, r: row, sh: SH }; + const data: Record = {}; + if (!full) { data.c = col; data.r = row; } if (!affine) { data.x = X; data.y = Y; } if (!skipBase) { data.bg = BG; data.bc = BC; } if (!cxFixed) data.cx = CXa; if (!cyFixed) data.cy = CYa; + if (!shFixed) data.sh = SH; + const hasData = Object.keys(data).length > 0; const voices: { field: string; wave: string; freq: number; speed: number; amp: number; color: { p: number; o: number } }[] = []; for (let k = 1; k <= SYNTH_VOICES; k++) { @@ -549,6 +567,8 @@ function buildRuntime(baked: Baked, params: GlyphEffectParamsOf Date: Sat, 18 Jul 2026 23:52:05 +0200 Subject: [PATCH 18/23] =?UTF-8?q?feat(website):=20wordart=20composer=20UI?= =?UTF-8?q?=20=E2=80=94=20composition=20rail,=20effects=20folder,=20static?= =?UTF-8?q?=20glyph=20presets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WordArtWorkbench/WordArtWorkbench.tsx | 1218 +++++++++++------ .../components/WordArtWorkbench/wordart.css | 662 +++++---- website/src/pages/wordart.astro | 11 +- 3 files changed, 1209 insertions(+), 682 deletions(-) diff --git a/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx b/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx index 02746f4b..710c1f2e 100644 --- a/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx +++ b/website/src/components/WordArtWorkbench/WordArtWorkbench.tsx @@ -1,5 +1,7 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { + GlyphEffectLayer, GlyphMesh, GlyphOrthographicCamera, GlyphPerspectiveCamera, @@ -7,7 +9,11 @@ import { } from "@glyphcss/react"; import type { Vec3 } from "@glyphcss/react"; import type { Polygon } from "@glyphcss/react"; -import GUI from "lil-gui"; +import type { GlyphEffectLayerHandle } from "@glyphcss/react"; +import { compileScene, createGlyphOrthographicCamera, injectGlyphBaseStyles } from "glyphcss"; +import type { CompileSceneResult, GlyphEffectDefinition, GlyphEffectParamSchema } from "glyphcss"; +import type { GlyphEffectId } from "@glyphcss/effects"; +import type { GUI } from "lil-gui"; import { StatsOverlay } from "../StatsOverlay"; import { composeText, @@ -25,6 +31,21 @@ import { type Profile, type WarpShape, } from "@glyphcss/fonts"; +import { Dock } from "../Dock"; +import { useDockGui } from "../Dock/slots"; +import { useColor, useDockSlot, useFolder, useOption, useSlider, useText, useToggle } from "../Dock/primitives"; +import { EffectParameterControls, useEffectsFolder } from "../Dock/folders/useEffectsFolder"; +import { + DEFAULT_GALLERY_EFFECT_STATE, + GALLERY_EFFECT_OPTIONS, + createGalleryEffectState, + galleryEffectDefaultParams, + galleryEffectDefinition, + sanitizeGalleryEffectParams, + type GalleryEffectDefinition, +} from "../GalleryWorkbench/effects"; +import type { GalleryEffectBlend, GalleryEffectParamValue, GalleryEffectState } from "../GalleryWorkbench/types"; +import "../GalleryWorkbench/gallery-workbench.css"; import "./wordart.css"; type Align = "left" | "center" | "right"; @@ -86,36 +107,46 @@ interface Preset { outline?: { color: string; width: number }; /** Flat two-layer drop shadow (no extrusion walls). */ layered?: boolean; - /** CSS background for the preset tile thumbnail (defaults to `color`). */ - thumb?: string; } -// Left-rail style presets — each is a full "look": extrusion, layered front/back, +// Bottom preset row — each is a full "look": extrusion, layered front/back, // and/or a baked-in WordArt warp (like the builder's shape tiles). const PRESETS: Preset[] = [ { label: "Gold Gradient", profile: "bevel", depth: 26, color: "#ffd23f", sideColor: "#7c4a12", - fill: "gradient", gradA: "#ffe14d", gradB: "#ff7a1a", gradAngle: 270, thumb: "linear-gradient(#ffe14d,#ff7a1a)" }, + fill: "gradient", gradA: "#ffe14d", gradB: "#ff7a1a", gradAngle: 270 }, { label: "Grape Pop", profile: "flat", depth: 5, color: "#b14be0", sideColor: "#7a8cff", backColor: "#8aa0ff", offset: 14, layered: true, - fill: "gradient", gradA: "#c45cf0", gradB: "#7a1fb8", gradAngle: 270, thumb: "linear-gradient(#c45cf0,#7a1fb8)" }, + fill: "gradient", gradA: "#c45cf0", gradB: "#7a1fb8", gradAngle: 270 }, { label: "Chrome", profile: "bevel", depth: 22, color: "#d7dde4", sideColor: "#3a2222", - fill: "gradient", gradA: "#f4f8ff", gradB: "#9a4b4b", gradAngle: 270, thumb: "linear-gradient(#f4f8ff 45%,#9a4b4b)" }, + fill: "gradient", gradA: "#f4f8ff", gradB: "#9a4b4b", gradAngle: 270 }, { label: "Rainbow", profile: "flat", depth: 10, color: "#ff5e3a", sideColor: "#7a2a55", - fill: "rainbow", gradAngle: 0, thumb: "linear-gradient(90deg,#ff3b30,#ffcc00,#34c759,#007aff,#af52de)" }, + fill: "rainbow", gradAngle: 0 }, { label: "Sky Outline", profile: "flat", depth: 8, color: "#7ec8ff", sideColor: "#2b50b0", - outline: { color: "#1838b8", width: 3 }, thumb: "#7ec8ff" }, + outline: { color: "#1838b8", width: 3 } }, { label: "Grass Block", profile: "flat", depth: 18, color: "#6ab04c", sideColor: "#6b4a2b", - fill: "texture", faceTex: "grass3", sideTex: "dirt", thumb: "url(/textures/wordart/grass3.svg) center/cover" }, + fill: "texture", faceTex: "grass3", sideTex: "dirt" }, { label: "Brick Wall", profile: "bevel", depth: 22, color: "#a8432a", sideColor: "#7a2f1d", - fill: "texture", faceTex: "brick", sideTex: "brick2", thumb: "url(/textures/wordart/brick.svg) center/cover" }, + fill: "texture", faceTex: "brick", sideTex: "brick2" }, { label: "Stone", profile: "flat", depth: 20, color: "#8d8d8d", sideColor: "#5a5a5a", - fill: "texture", faceTex: "rock", sideTex: "rock3", thumb: "url(/textures/wordart/rock.svg) center/cover" }, + fill: "texture", faceTex: "rock", sideTex: "rock3" }, { label: "Ice", profile: "bevel", depth: 18, color: "#b9e6ff", sideColor: "#6aa9cc", - fill: "texture", faceTex: "ice", sideTex: "ice3", thumb: "url(/textures/wordart/ice.svg) center/cover" }, + fill: "texture", faceTex: "ice", sideTex: "ice3" }, { label: "Gold Bevel", profile: "bevel", depth: 26, color: "#d4a82a", sideColor: "#7c5e16" }, - { label: "Retro Block", profile: "flat", depth: 6, color: "#ff4d6d", sideColor: "#3a0ca3", backColor: "#3a0ca3", offset: 16, layered: true, thumb: "#ff4d6d" }, + { label: "Retro Block", profile: "flat", depth: 6, color: "#ff4d6d", sideColor: "#3a0ca3", backColor: "#3a0ca3", offset: 16, layered: true }, { label: "Arch Gold", profile: "bevel", depth: 22, color: "#e9b949", sideColor: "#8a5a12", warp: { shape: "arch", amount: 0.6 } }, { label: "Wave Mint", profile: "round", depth: 24, color: "#7cffb2", sideColor: "#2f8f5e", warp: { shape: "wave", amount: 0.55 } }, - { label: "Ink Shadow", profile: "flat", depth: 4, color: "#e8edf2", sideColor: "#2b313b", backColor: "#2b313b", offset: 12, layered: true, thumb: "#e8edf2" }, + { label: "Ink Shadow", profile: "flat", depth: 4, color: "#e8edf2", sideColor: "#2b313b", backColor: "#2b313b", offset: 12, layered: true }, + { label: "Sand Dune", profile: "round", depth: 16, color: "#e3c17a", sideColor: "#b8935a", + fill: "texture", faceTex: "sand", sideTex: "dirt2", warp: { shape: "wave", amount: 0.4 } }, + { label: "Timber", profile: "bevel", depth: 20, color: "#a9713f", sideColor: "#5c3a1e", + fill: "texture", faceTex: "wood", sideTex: "wood3" }, + { label: "Ore Vein", profile: "flat", depth: 18, color: "#c9a227", sideColor: "#3a3a3a", + fill: "texture", faceTex: "mine", sideTex: "rock3" }, + { label: "Glass Frost", profile: "bevel", depth: 16, color: "#dff3ff", sideColor: "#7fb8d9", + fill: "texture", faceTex: "glass", sideTex: "ice3" }, + { label: "Neon Outline", profile: "flat", depth: 6, color: "#0b0f1a", sideColor: "#0b0f1a", + outline: { color: "#ff2fd0", width: 5 } }, + { label: "Copper Shine", profile: "bevel", depth: 24, color: "#e0813a", sideColor: "#6b2f12", + fill: "gradient", gradA: "#ffcf8a", gradB: "#a34a12", gradAngle: 200 }, ]; function applyCase(text: string, mode: "as-typed" | "upper" | "lower" | "title"): string { @@ -125,14 +156,6 @@ function applyCase(text: string, mode: "as-typed" | "upper" | "lower" | "title") return text; } -function readable(hex: string): string { - const m = /^#?([0-9a-f]{6})$/i.exec(hex); - if (!m) return "#000"; - const n = parseInt(m[1], 16); - const lum = 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255); - return lum > 150 ? "#0b0f18" : "#ffffff"; -} - function fitWordArtZoom(polygons: Polygon[], stageW: number, stageH: number, scaleX = 1, scaleY = 1): number { if (!polygons.length) return 3; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity; @@ -150,22 +173,44 @@ function fitWordArtZoom(polygons: Polygon[], stageW: number, stageH: number, sca return Math.max(0.5, Math.min(10, Math.min(fitW, fitH))); } -function codePenPayload(snapshotHtml: string, title: string): string { - const parsed = new DOMParser().parseFromString(snapshotHtml, "text/html"); - const css = Array.from(parsed.querySelectorAll("style")).map((s) => s.textContent ?? "").filter(Boolean).join("\n\n"); - const html = parsed.body.innerHTML.trim() || snapshotHtml; - return JSON.stringify({ title, html, css, editors: "100", layout: "left" }); -} - - // All controls persist to the URL query string so any look is a shareable link. const URL_SEARCH = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams(); const qs = (k: string, d: string) => URL_SEARCH.get(k) ?? d; const qn = (k: string, d: number) => (URL_SEARCH.has(k) ? Number(URL_SEARCH.get(k)) : d); const qb = (k: string, d: boolean) => (URL_SEARCH.has(k) ? URL_SEARCH.get(k) === "1" : d); +/** Restore the Effects folder's selection from the URL (mirrors the gallery's + * `fx`/`fxb`/`fxp`/`fxs`/`fxx` shape, but folded into this page's single + * flat-URLSearchParams persistence pass instead of a second read-modify-write + * — see the big `useEffect` below, which is the only writer). */ +function initialEffectState(): GalleryEffectState { + const id = qs("fx", ""); + if (!id) return DEFAULT_GALLERY_EFFECT_STATE; + const definition = galleryEffectDefinition(id as GlyphEffectId); + if (!definition) return DEFAULT_GALLERY_EFFECT_STATE; + const state = createGalleryEffectState(definition.id, { + blend: qs("fxb", definition.defaultBlend) as GalleryEffectBlend, + paused: qb("fxp", false), + timeScale: qn("fxs", 1), + }); + if (!state) return DEFAULT_GALLERY_EFFECT_STATE; + const rawParams = qs("fxx", ""); + if (rawParams) { + try { + state.params = sanitizeGalleryEffectParams(definition, JSON.parse(rawParams)); + } catch { + // Malformed/legacy `fxx` payload — keep the definition's defaults. + } + } + return state; +} + export function WordArtWorkbench() { const [font, setFont] = useState(null); + // Pinned to the bundled default font (never swapped to a picked Google font) + // so the preset tiles' single-letter static renders stay stable — they only + // need to change look, not typeface, when a preset changes colors/profile. + const [previewFont, setPreviewFont] = useState(null); const [catalog, setCatalog] = useState([]); const [entry, setEntry] = useState(null); const [familyInput, setFamilyInput] = useState(() => qs("font", "")); @@ -222,10 +267,14 @@ export function WordArtWorkbench() { const [lightColor, setLightColor] = useState(() => qs("lc", "#ffffff")); const [lightAz, setLightAz] = useState(() => qn("laz", -25)); const [lightEl, setLightEl] = useState(() => qn("lel", 45)); + // Glyph Effects layer (gallery-style): same state shape, same + // `scene.addEffectLayer`-backed `` wiring, just applied to + // the word-art mesh instead of a dropped model. + const [effectState, setEffectState] = useState(initialEffectState); const [activePreset, setActivePreset] = useState(null); - // Mobile: only one floating panel is open at a time, toggled by the bottom tabs. - const [mobilePanel, setMobilePanel] = useState<"style" | "controls" | null>(null); - + // Mobile: only one floating panel is open at a time, toggled by the bottom tabs + // (mirrors /synth's voices/controls/presets drawer pattern). + const [mobilePanel, setMobilePanel] = useState<"compose" | "controls" | "presets" | null>(null); useEffect(() => { if (!mobilePanel) return; @@ -234,10 +283,16 @@ export function WordArtWorkbench() { return () => window.removeEventListener("keydown", onKey); }, [mobilePanel]); + // `.glyph-output` (the compiled preset tiles below use it directly, with no + // `` ancestor) needs the base stylesheet present — the live + // Stage's GlyphScene injects it too, but do it here explicitly so the tiles + // don't depend on mount order. + useEffect(() => { injectGlyphBaseStyles(); }, []); + // Default bundled font + Google catalog. If the URL named a font, select it // once the catalog is in. useEffect(() => { - loadFont("/fonts/default.ttf").then(setFont).catch((e) => setStatus(String(e))); + loadFont("/fonts/default.ttf").then((f) => { setFont(f); setPreviewFont(f); }).catch((e) => setStatus(String(e))); listGoogleFonts() .then((c) => { setCatalog(c); @@ -301,9 +356,28 @@ export function WordArtWorkbench() { ss("olc", outlineColor, "#1a1a2e"); sn("olw", outlineWidth, 3); if (layered) p.set("layer", "1"); + // Effects folder — folded into this same flat-URLSearchParams pass (rather + // than a second read-modify-write like the gallery's `useEffectRouteSync`) + // so it can't race the rest of this page's controls for the last write. + if (effectState.effectId) { + p.set("fx", effectState.effectId); + const definition = galleryEffectDefinition(effectState.effectId); + if (definition) { + ss("fxb", effectState.blend, definition.defaultBlend); + if (effectState.paused) p.set("fxp", "1"); + sn("fxs", effectState.timeScale, 1); + const defaults = galleryEffectDefaultParams(definition); + const overrides: Record = {}; + for (const [name, value] of Object.entries(effectState.params)) { + if (name === "time" || value === defaults[name]) continue; + overrides[name] = value; + } + if (Object.keys(overrides).length > 0) p.set("fxx", JSON.stringify(overrides)); + } + } const search = p.toString(); window.history.replaceState(null, "", `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`); - }, [text, entry, weight, italic, textCase, scaleX, scaleY, profile, depth, letterSpacing, lineHeight, align, underline, strike, color, sideColor, backColor, offset, curveSegments, simplify, profileSegments, warpShape, warpAmount, spin, perspective, zoomScale, lightIntensity, ambient, lightColor, lightAz, lightEl, roundConvex, bezier, fillType, gradA, gradB, gradAngle, faceTex, sideFill, sideTex, backFill, backTex, outlineOn, outlineColor, outlineWidth, layered]); + }, [text, entry, weight, italic, textCase, scaleX, scaleY, profile, depth, letterSpacing, lineHeight, align, underline, strike, color, sideColor, backColor, offset, curveSegments, simplify, profileSegments, warpShape, warpAmount, spin, perspective, zoomScale, lightIntensity, ambient, lightColor, lightAz, lightEl, roundConvex, bezier, fillType, gradA, gradB, gradAngle, faceTex, sideFill, sideTex, backFill, backTex, outlineOn, outlineColor, outlineWidth, layered, effectState]); // Load the picked Google font whenever family / weight / style changes. useEffect(() => { @@ -384,6 +458,36 @@ export function WordArtWorkbench() { return [-Math.sin(e), -Math.sin(a) * Math.cos(e), Math.max(0.25, Math.cos(e))]; }, [lightAz, lightEl]); + const effectDefinition = useMemo( + () => galleryEffectDefinition(effectState.effectId), + [effectState.effectId], + ); + + const handleEffectChange = useCallback((effectId: GlyphEffectId | null) => { + setEffectState((current) => { + if (!effectId) return DEFAULT_GALLERY_EFFECT_STATE; + return createGalleryEffectState(effectId, { + paused: current.paused, + timeScale: current.timeScale, + }) ?? DEFAULT_GALLERY_EFFECT_STATE; + }); + }, []); + + const updateEffectSettings = useCallback( + (partial: Partial>) => { + setEffectState((current) => ({ ...current, ...partial })); + }, + [], + ); + + const updateEffectParams = useCallback((partial: Record) => { + setEffectState((current) => { + const params = { ...current.params, ...partial }; + const definition = galleryEffectDefinition(current.effectId); + return { ...current, params: definition ? sanitizeGalleryEffectParams(definition, params) : params }; + }); + }, []); + function pickFamily(value: string) { setFamilyInput(value); const f = catalog.find((e) => e.family.toLowerCase() === value.trim().toLowerCase()); @@ -417,39 +521,6 @@ export function WordArtWorkbench() { setActivePreset(p.label); } - const leftValues: LeftValues = { - weight, italic, underline, strike, textCase, align, color, sideColor, backColor, - fillType, gradA, gradB, gradAngle, image: fillImage, faceTex, - sideFill, sideTex, backFill, backTex, - outlineOn, outlineColor, outlineWidth, - }; - const leftSet = (k: keyof LeftValues, v: number | string | boolean) => { - switch (k) { - case "weight": setWeight(v as number); break; - case "italic": setItalic(v as boolean); break; - case "underline": setUnderline(v as boolean); break; - case "strike": setStrike(v as boolean); break; - case "textCase": setTextCase(v as "as-typed" | "upper" | "lower" | "title"); break; - case "align": setAlign(v as Align); break; - case "color": setColor(v as string); break; - case "sideColor": setSideColor(v as string); break; - case "backColor": setBackColor(v as string); break; - case "fillType": setFillType(v as FillType); break; - case "gradA": setGradA(v as string); break; - case "gradB": setGradB(v as string); break; - case "gradAngle": setGradAngle(v as number); break; - case "image": setFillImage(v as string); break; - case "faceTex": setFaceTex(v as string); break; - case "sideFill": setSideFill(v as FaceFill); break; - case "sideTex": setSideTex(v as string); break; - case "backFill": setBackFill(v as FaceFill); break; - case "backTex": setBackTex(v as string); break; - case "outlineOn": setOutlineOn(v as boolean); break; - case "outlineColor": setOutlineColor(v as string); break; - case "outlineWidth": setOutlineWidth(v as number); break; - } - }; - // The Profile dropdown encodes edge shape only — colors now come from the // axial face stops, so there's no coverage to bundle in. const profileMode = profile === "flat" ? "flat" @@ -459,7 +530,7 @@ export function WordArtWorkbench() { const guiValues: GuiValues = { layered, profileMode, warp: warpShape, bend: warpAmount, - depth, letterSpacing, lineHeight, scaleX, scaleY, + depth, scaleX, scaleY, curveSegments, simplify, profileSegments, offset, perspective, zoom: zoomScale, spin, light: lightIntensity, ambient, az: lightAz, el: lightEl, lightColor, @@ -476,8 +547,6 @@ export function WordArtWorkbench() { case "warp": setWarpShape(v as WarpShape); break; case "bend": setWarpAmount(v as number); break; case "depth": setDepth(v as number); break; - case "letterSpacing": setLetterSpacing(v as number); break; - case "lineHeight": setLineHeight(v as number); break; case "scaleX": setScaleX(v as number); break; case "scaleY": setScaleY(v as number); break; case "curveSegments": setCurveSegments(v as number); break; @@ -495,88 +564,165 @@ export function WordArtWorkbench() { } }; + const leftValues: LeftValues = { + weight, italic, underline, strike, textCase, align, letterSpacing, lineHeight, + color, sideColor, backColor, + fillType, gradA, gradB, gradAngle, image: fillImage, faceTex, + sideFill, sideTex, backFill, backTex, + outlineOn, outlineColor, outlineWidth, + }; + const leftSet = (k: keyof LeftValues, v: number | string | boolean) => { + switch (k) { + case "weight": setWeight(v as number); break; + case "italic": setItalic(v as boolean); break; + case "underline": setUnderline(v as boolean); break; + case "strike": setStrike(v as boolean); break; + case "textCase": setTextCase(v as "as-typed" | "upper" | "lower" | "title"); break; + case "align": setAlign(v as Align); break; + case "letterSpacing": setLetterSpacing(v as number); break; + case "lineHeight": setLineHeight(v as number); break; + case "color": setColor(v as string); break; + case "sideColor": setSideColor(v as string); break; + case "backColor": setBackColor(v as string); break; + case "fillType": setFillType(v as FillType); break; + case "gradA": setGradA(v as string); break; + case "gradB": setGradB(v as string); break; + case "gradAngle": setGradAngle(v as number); break; + case "image": setFillImage(v as string); break; + case "faceTex": setFaceTex(v as string); break; + case "sideFill": setSideFill(v as FaceFill); break; + case "sideTex": setSideTex(v as string); break; + case "backFill": setBackFill(v as FaceFill); break; + case "backTex": setBackTex(v as string); break; + case "outlineOn": setOutlineOn(v as boolean); break; + case "outlineColor": setOutlineColor(v as string); break; + case "outlineWidth": setOutlineWidth(v as number); break; + } + }; + + // Bottom preset row — one static single-letter glyphcss render per preset, + // computed once (memoized on the pinned preview font) with NO live scene / + // rAF: `compileScene` is pure (geometry + camera → string), so each tile is + // a plain `
` string baked at mount and re-baked only if the bundled
+  // font itself reloads.
+  const presetTiles = useMemo(() => {
+    if (!previewFont) return null;
+    const map = new Map();
+    for (const p of PRESETS) map.set(p.label, renderPresetTile(previewFont, p));
+    return map;
+  }, [previewFont]);
+
   return (
-    
+
- - -