Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ad758c5
feat(glyphcss): interactive/CodePen export mounts stock effect layers…
apresmoi Jul 18, 2026
4cdf740
feat(website): export active effect to CodePen from gallery + /synth
apresmoi Jul 18, 2026
9d21749
feat(website): synth plane fills the viewport and locks the camera
apresmoi Jul 18, 2026
0623de1
feat(effects): zero-lib minimal-JS static export for field-synth + /s…
apresmoi Jul 18, 2026
657c998
feat(website): match synth voice sliders to Dock; mobile drawer layou…
apresmoi Jul 18, 2026
e0d253b
chore(bench): static effect export comparison (prebaked frames vs min…
apresmoi Jul 18, 2026
ff3ce7c
merge: interactive CodePen effect export (gallery)
apresmoi Jul 18, 2026
e091a0e
feat(website): waveform trendlines per voice + combined interference …
apresmoi Jul 18, 2026
9536f7e
feat(website): voice wave above square glyph; combined scope as viewp…
apresmoi Jul 18, 2026
df239d7
fix(website): grow voice trend to fill left column, remove empty row …
apresmoi Jul 18, 2026
776cf2a
feat(website): put wave-shape toggles above field toggles in voice card
apresmoi Jul 18, 2026
40d7621
feat(website): SVG toggle icons + clean active-ring outline; fix spir…
apresmoi Jul 18, 2026
5b00c68
feat(website): move combined scope into the Dock MIX folder above Com…
apresmoi Jul 18, 2026
dba1d14
feat(website): synth export as floating collapsible panel (out of the…
apresmoi Jul 18, 2026
ae3b655
feat(website): mobile export tab, per-voice-colored previews, disable…
apresmoi Jul 18, 2026
598d225
perf(effects): inline affine coords + skip base grid for flat/plane s…
apresmoi Jul 18, 2026
267edde
feat(website): gallery-style /synth export — bottom-left CodePen + fr…
apresmoi Jul 18, 2026
89e29ba
perf(effects): drop redundant per-cell DATA for fully-covered exports…
apresmoi Jul 18, 2026
3265a88
feat(website): wordart composer UI — composition rail, effects folder…
apresmoi Jul 18, 2026
1b87a9a
fix(website): render legible 'a' in wordart preset tiles (white-space…
apresmoi Jul 18, 2026
a446f06
feat(website): wordart density control, synth-style export buttons, n…
apresmoi Jul 18, 2026
17a2d13
fix(website): match wordart left rail width to synth (340px)
apresmoi Jul 18, 2026
39d224e
feat(website): wordart export regenerates mesh via @glyphcss/fonts co…
apresmoi Jul 18, 2026
524a5e4
fix(website): wordart default font Roboto (Google), export rotation m…
apresmoi Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ website/public/skill.md

# Local planning doc — kept out of git per project decision
ANIMATIONS.md

# Generated static-effect-export bench artifacts
bench/static-effect-export/out/
bench/static-effect-export/shots/
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pre>` 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 `<pre>`), a **CLI** (`glyphcss-compile`), and `compileInteractive`.
- **`GlyphSceneStatic`** (React + Vue) — SSR/SSG component that renders the compiled `<pre>` 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.
- **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 `<pre>`, 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@<ver>?deps=glyphcss@<ver>"` (same `<ver>` 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. 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 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` 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.

## No per-frame DOM mutation

Expand All @@ -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 `<pre>`, 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 → `<pre>` 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 → `<pre>` 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: `<glyph-scene>`, `<glyph-mesh>`, `<glyph-effect-layer>`, `<glyph-hotspot>`, `<glyph-perspective-camera>`, `<glyph-orthographic-camera>`, `<glyph-camera>` (ortho alias), `<glyph-orbit-controls>`, `<glyph-map-controls>`, `<glyph-first-person-controls>`. 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.
Expand Down
130 changes: 130 additions & 0 deletions bench/static-effect-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Static effect-export benchmark — prebaked frames (A) vs inlined vanilla-JS (B)

Deciding how to export an **effect-only, static-camera** glyphcss animation as a
self-contained pen (one HTML + CSS + JS, no build step for the consumer).

**Scenario.** One mesh (icosphere, `subdivisions:3`), a **fixed** orthographic
camera (no rotation), and the stock **field-synth** effect animating its texture
over `time` (surface-mapped moiré, `space:"surface"`, `blend:"replace"`). Grid
**100×40** cells (≈2453 covered by the silhouette), **4 s** loop, 12 px cell.

Two strategies:

- **A — prebaked frames (zero JS).** Bake N grids at `time = i/N · loop`, stack
them into one `<pre>`, cycle with a pure-CSS `steps(N)` animation. Same idea as
the existing `buildGlyphFramesExport`, but varying the effect `time` instead of
`rotY` (camera fixed). Rendered here by the **real, pure** glyphcss rasterizer +
effect compositor run in Node — byte-faithful to the runtime render.
- **B — minimal inlined vanilla JS (no libraries).** Bake, once, the per-cell
resolved effect-domain coordinate `(x, y, cx, cy)` + Lambert `shade` + cell
position, then ship a tiny hand-written field-synth evaluator that recomputes
every covered cell per `requestAnimationFrame` and rewrites the `<pre>`.
**Zero external dependencies** (no `import`, no `http(s)://`, no CDN, no
glyphcss/@glyphcss/effects — verified by grep and by an offline browser run).

## Payload (gzip is the honest metric — CodePen/HTTP serve gzipped)

| Strategy | Raw | **Gzip** | Gzip vs B |
|-----------------|---------|------------|-----------|
| A — N = 12 | 464 KB | **51.0 KB**| 2.7× |
| A — N = 24 | 918 KB | **97.4 KB**| 5.1× |
| A — N = 36 | 1365 KB | **141.7 KB**| 7.2× |
| **B — inlined JS** | 86 KB | **19.2 KB** | 1.0× |

**Crossover: A's payload exceeds B at N ≈ 3.6 frames.** A grows linearly
(≈ 5.8 KB + 3.9 KB·N gzipped — one frame's dithered colored ASCII costs ~3.9 KB
gz even after cross-frame color-class dedupe). B is **fixed** at 19.2 KB gz
regardless of loop length or smoothness. So for anything past ~4 discrete frames,
B is smaller — and the gap widens fast (7× at a still-choppy 36 frames).

### Where B's bytes go (it scales with the grid, not with N)

| Part | Raw | Gzip |
|------------------------------|--------|----------|
| Baked per-cell `(x,y,cx,cy,shade,col,row)` (2453 cells) | 84.1 KB | **16.9 KB** |
| Runtime evaluator + markup | 4.3 KB | ~1.5 KB |
| Baked params (`CFG`) | 291 B | — |

The field-synth evaluator itself is **~4 KB raw** (~1.5 KB gz); **86 %** of B is
the baked per-cell coordinate table. B therefore scales with **covered cell
count** (grid resolution × silhouette), not with frame count or loop duration.

## Quality

- **A:** discrete — temporal smoothness ∝ N; at N=12 the loop visibly steps.
Instant paint, **zero JS/CPU** after load. Fixed resolution. A non-periodic
effect (e.g. a `noise` voice, whose time axis isn't periodic) shows a
**loop seam** at the wrap; here we chose periodic voices (radial sins, integer
cycles over 4 s) so A loops cleanly.
- **B:** **continuous** — perfectly smooth, any loop length, no seam constraint
(it just evaluates `time = now/1000 % loop`; a truly aperiodic effect can even
be shown non-looping). Instant first paint of the baked static grid, then the
JS "hydrates" and animates. Tiny JS.

**Equivalence.** A and B render the **same** effect: cell-for-cell the two agree
**95–97 %** across sampled times, with `visible` counts matching within <2 %
(the small residual is 3-decimal coordinate quantization flipping cells that sit
right on a ramp-index or dither-threshold boundary — visually indistinguishable).
Screenshots confirm identical pattern, ramp, and blue→pink gradient.

## CPU note for B (rough)

Per frame B recomputes every covered cell: 2 active oscillators (here) → a couple
of `sin`/`hypot` + a combine + ramp/color/lit + Bayer dither, then builds the
colored `<pre>` innerHTML from color runs. For 2453 cells that is a few hundred
µs of math plus the innerHTML parse/paint of the `<pre>` — comfortably 60 fps on
a 100×40 grid. Cost is **O(covered cells) per frame** and independent of voice
count beyond the active ones; it grows with grid resolution (a 200×80 grid is 4×
the cells) and with `useColors` (colored spans cost ParseHTML/Style/Paint on top
of the recompute). This is the same per-frame budget the live glyphcss runtime
pays; B just inlines the field-synth slice of it with no library around it.

## Recommendation

**Ship Strategy B (inlined vanilla-JS) as the default for effect-only /
static-camera exports.** For any loop worth shipping (≳4 frames) B is smaller
gzipped, and it is *continuously* smooth with no frame-count/loop-length/seam
trade-off — a 4 s loop and a 40 s loop are the same 19 KB. A only wins in the
degenerate low-N corner (≤3 frames) or when a **zero-JS** artifact is a hard
requirement (CSP forbids inline script, or a truly no-runtime SSG fragment). Keep
A as a secondary "zero-JS, few-frames" option, but B is the right primary.

Caveats to carry into a productionized B:
- Payload is dominated by the baked per-cell coordinate table (scales with grid
size, not N). `cx,cy` are per-surface-group constants baked per-cell today —
de-duplicating them per group, and packing `x,y` as quantized deltas or a typed
binary blob, would shrink B further.
- This slice bakes **one** static camera + **one** mesh + a **fixed param set**
(only `time` animates). Animating other params, moving the camera, or per-mesh
detail layers are out of scope — those defeat "bake the resolved coordinate".
- Matches the runtime only for the layer's **actual** blend. Note field-synth's
`defaultBlend` is UI metadata and is **not** auto-applied to a layer — a layer
with no explicit `blend` uses `over` (coverage saturates to 1, no dither),
which looks denser than the `replace` texture used here. An exporter must read
the layer's real blend/opacity/order, not the definition default.

## Reproduce

```
node bench/static-effect-export/build.mjs # bakes both pens → out/, prints the size table
node bench/static-effect-export/verify.mjs # Playwright render+animate+offline check → shots/
```

- Prototype export code: `bench/static-effect-export/harness.ts`
(`buildStrategyA`, `buildStrategyB`, the Node bake path, and the build-time
surface-basis copy used only to resolve B's per-cell coordinates).
- Generated pens + `sizes.json`: `bench/static-effect-export/out/`.

### How B's inlined JS was generated

**Hand-written** faithful vanilla-JS port of the field-synth per-cell math
(`synthWave`/`synthNoise3`/`synthOsc`/`combineSynth`/`lerpPacked`/`scalePackedColor`
+ the compositor's Bayer coverage dither), chosen over an esbuild bundle because
the per-cell slice is small and hand-writing gives the most honest, minimal size
with no bundler runtime/wrapper noise. Its output is validated against the real
compositor in Node (95–97 % cell match) and visually via screenshots.

**Self-containment proof:** `grep -iE 'import |https?://|require\(|@glyphcss'
strategyB.html` → 0 hits; and it renders + animates in headless Chromium with
`context.setOffline(true)` + all non-`file://` requests aborted (0 external
requests observed).
24 changes: 24 additions & 0 deletions bench/static-effect-export/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Bundle generate.ts (which imports library SOURCE via aliases) into a Node ESM
// bundle, then run it to emit both export strategies + size table.
import { build } from "esbuild";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";

const here = dirname(fileURLToPath(import.meta.url));

await build({
entryPoints: [resolve(here, "generate.ts")],
outfile: resolve(here, "generate.bundle.mjs"),
bundle: true,
platform: "node",
format: "esm",
target: "node18",
logLevel: "info",
alias: {
"glyphcss": resolve(here, "../../packages/glyphcss/src/index.ts"),
"@glyphcss/core": resolve(here, "../../packages/core/src/index.ts"),
"@glyphcss/effects": resolve(here, "../../packages/effects/src/index.ts"),
},
});

await import(resolve(here, "generate.bundle.mjs"));
51 changes: 51 additions & 0 deletions bench/static-effect-export/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Generate both export strategies for the representative case, write the pens to
* disk, and print raw + gzipped payload sizes for the results table.
*/
import { gzipSync } from "node:zlib";
import { writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { makeBaked, buildStrategyA, buildStrategyB, COLS, ROWS, LOOP_SECONDS } from "./harness";

const here = dirname(fileURLToPath(import.meta.url));
const outDir = resolve(here, "out");
mkdirSync(outDir, { recursive: true });

const gz = (s: string) => gzipSync(Buffer.from(s, "utf8"), { level: 9 }).length;
const kb = (n: number) => (n / 1024).toFixed(1);

const baked = makeBaked();

const A_FRAMES = [12, 24, 36];
const results: { name: string; raw: number; gz: number }[] = [];

for (const n of A_FRAMES) {
const html = buildStrategyA(baked, n);
writeFileSync(resolve(outDir, `strategyA-${n}.html`), html);
results.push({ name: `A (N=${n})`, raw: html.length, gz: gz(html) });
}

const b = buildStrategyB(baked);
writeFileSync(resolve(outDir, "strategyB.html"), b);
results.push({ name: "B (inlined JS)", raw: b.length, gz: gz(b) });

console.log(`\nRepresentative case: sphere, ${COLS}x${ROWS} grid, ${LOOP_SECONDS}s loop, field-synth (space:"surface")\n`);
console.log("Strategy | Raw (KB) | Gzip (KB)");
console.log("--------------------|----------|----------");
for (const r of results) {
console.log(`${r.name.padEnd(19)} | ${kb(r.raw).padStart(8)} | ${kb(r.gz).padStart(8)}`);
}

const bGz = results.find((r) => r.name.startsWith("B"))!.gz;
console.log(`\nStrategy B gzip = ${bGz} bytes (${kb(bGz)} KB).`);
// crossover: per-frame gzip cost of A
const a12 = results.find((r) => r.name.includes("12"))!.gz;
const a36 = results.find((r) => r.name.includes("36"))!.gz;
const perFrame = (a36 - a12) / (36 - 12);
const base = a12 - perFrame * 12;
const crossover = (bGz - base) / perFrame;
console.log(`A gzip ≈ ${base.toFixed(0)} + ${perFrame.toFixed(0)}*N bytes → A exceeds B at N ≈ ${crossover.toFixed(1)} frames.`);

writeFileSync(resolve(outDir, "sizes.json"), JSON.stringify({ results, perFrameGz: perFrame, baseGz: base, crossover }, null, 2));
console.log(`\nWrote pens + sizes.json to ${outDir}`);
Loading
Loading