diff --git a/AGENTS.md b/AGENTS.md index 5d9629a6..6a9de619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ There are no per-polygon DOM elements. There is no CSS `matrix3d`. The base `` and every detail `
`; changing `layer.params`, opacity, blend, order, or enabled state then recomposes and safely encodes those retained grids without transforming or projecting the mesh again. The handle's flat `params` object has stable identity, validates known string keys, accepts Anime.js/private symbol metadata, and automatically schedules a coalesced effect transaction. Generic effects run before the legacy `transformCells` escape hatch. An effect-only transaction stages every affected string before assigning each `
` at most once. With no mounted effect and no hook, the original direct renderer remains byte-identical and does not allocate retained effect frames.
 
-The current generic compositor supports `over`/`replace`, `"surfaces"`/`"viewport"` targets, canonical base-grid affines across detail densities, and the `baseColor`, `baseShade`, `depth`, `normal`, `worldPosition`, and `uv0` requested inputs. Hard surface requirements are solid-mode-only; `optionalRequirements` retain those inputs in solid mode but allow a program-defined fallback in wireframe/voxel modes. Mesh-handle targets, scene-image sampling, program scratch buffers, surface-key/UV-footprint inputs, and static compile/export evaluation are not implemented yet and reject explicitly. `@glyphcss/effects` supplies Matrix rain, flow text, scan, wipe, scramble, glitch, noise dissolve, ripple, and **field synth** as reusable definitions. `field-synth` is a small composable synth — up to six oscillators (`fieldN` × `waveN`: radial/linearX/linearY/diagonal/angular/spiral/noise × sin/triangle/saw/square, each with `freqN`/`speedN`/`ampN`), combined (`add`/`multiply`/`max`/`min`/`difference`) into one scalar field mapped to a glyph ramp + color, over `space` — for emergent moiré/interference patterns. `ampN` is a MIX WEIGHT (blends the result toward `combine(result, voiceN)` per voice, not a signal gain), so low amp gently mixes instead of `multiply` collapsing to flat; `amp 0` skips the voice. `lit` (0..1) modulates output color by the surface Lambert shade so lighting reads through the texture. `voiceColors` blends each active voice's `colorN` by its contribution (per-voice color composition); off keeps the `color`/`colorB`/`gradient` value-gradient. `@glyphcss/effects` also exports `GlyphRamps` (named character-sets: Fade/Blocks/Shades/Dots/Binary/ASCII/Hatch/Stars/Digital). The **`/synth` website page** is the front-end: dual-sidebar modular synth (add/remove voice cards with live per-voice previews + a color swatch, icon multi-toggles, per-voice mix; Stage/Mix/Output/Lighting Dock; live preset gallery), full patch persisted to the URL. For surfaces it gives each face a local 0..1 UV so patterns map per-face. For Matrix/flow/scan, `space: "auto"` resolves authored UVs first and then generated world-surface coordinates; `space: "surface"` forces the generated orientation-aware mapping, while `space: "scene"` uses projected scene coordinates. On generated surface coordinates, `down` is world `-Z` projected into each geometric face's tangent plane; coplanar polygons agree, differently oriented polygons derive different downhill directions, and a plane perpendicular to that vector gets a deterministic pseudo-random in-plane fallback. Generated Matrix rain fits each quantized coplanar surface basis to projected glyph-cell space, orthogonalizes its visible flow and lane vectors, and evaluates both sparse trail membership and glyph phase in that same face-local field. The word therefore shares the strand's direction and projected-cell velocity on sheared or foreshortened faces, remains ordered when a periodic strand wraps, and does not mutate in place while its mask moves separately. Active rain cells have full logical coverage; sparsity comes from lane selection and trail length, not fractional tail coverage that the compositor would dither into broken strands. Matrix also supports original surface colors or a configurable shaded monochrome color. The package owns no clock. Applications animate explicit parameters such as `time` directly or through Anime.js. React and Vue mirror the scene-root `` wrapper, and custom HTML configures `` through JavaScript properties/`configure()` because executable definitions are values, not JSON attributes. `ANIMATIONS.md` records both this implemented slice and the richer approved graph/sampling architecture that remains.
+The current generic compositor supports `over`/`replace`, `"surfaces"`/`"viewport"` targets, canonical base-grid affines across detail densities, and the `baseColor`, `baseShade`, `depth`, `normal`, `worldPosition`, and `uv0` requested inputs. Hard surface requirements are solid-mode-only; `optionalRequirements` retain those inputs in solid mode but allow a program-defined fallback in wireframe/voxel modes. Mesh-handle targets, scene-image sampling, program scratch buffers, surface-key/UV-footprint inputs, and static compile/export evaluation are not implemented yet and reject explicitly. `@glyphcss/effects` supplies Matrix rain, flow text, scan, wipe, scramble, glitch, noise dissolve, ripple, and **field synth** as reusable definitions. `field-synth` is a small composable synth — up to six oscillators (`fieldN` × `waveN`: radial/linearX/linearY/diagonal/angular/spiral/noise × sin/triangle/saw/square, each with `freqN`/`speedN`/`ampN`), combined (`add`/`multiply`/`max`/`min`/`difference`) into one scalar field mapped to a glyph ramp + color, over `space` — for emergent moiré/interference patterns. `ampN` is a MIX WEIGHT (blends the result toward `combine(result, voiceN)` per voice, not a signal gain), so low amp gently mixes instead of `multiply` collapsing to flat; `amp 0` skips the voice. `lit` (0..1) modulates output color by the surface Lambert shade so lighting reads through the texture. `voiceColors` blends each active voice's `colorN` by its contribution (per-voice color composition); off keeps the `color`/`colorB`/`gradient` value-gradient. `@glyphcss/effects` also exports `GlyphRamps` (named character-sets: Fade/Blocks/Shades/Dots/Binary/ASCII/Hatch/Stars/Digital). The **`/synth` website page** is the front-end: dual-sidebar modular synth (add/remove voice cards with live per-voice previews + a color swatch, icon multi-toggles, per-voice mix; Stage/Mix/Output/Lighting Dock; live preset gallery), full patch persisted to the URL. For surfaces it gives each face a local 0..1 UV so patterns map per-face. For Matrix/flow/scan, `space: "auto"` resolves authored UVs first and then generated world-surface coordinates; `space: "surface"` forces the generated orientation-aware mapping, while `space: "scene"` uses projected scene coordinates. On generated surface coordinates, `down` is world `-Z` projected into each geometric face's tangent plane; coplanar polygons agree, differently oriented polygons derive different downhill directions, and a plane perpendicular to that vector gets a deterministic pseudo-random in-plane fallback. Generated Matrix rain fits each quantized coplanar surface basis to projected glyph-cell space, orthogonalizes its visible flow and lane vectors, and evaluates both sparse trail membership and glyph phase in that same face-local field. The word therefore shares the strand's direction and projected-cell velocity on sheared or foreshortened faces, remains ordered when a periodic strand wraps, and does not mutate in place while its mask moves separately. Active rain cells have full logical coverage; sparsity comes from lane selection and trail length, not fractional tail coverage that the compositor would dither into broken strands. Matrix also supports original surface colors or a configurable monochrome color; in monochrome each strand lights a bright `headColor` head and fades its tail toward black (still modulated by surface shade) for the classic head-to-tail falling-code gradient. The package owns no clock. Applications animate explicit parameters such as `time` directly or through Anime.js. React and Vue mirror the scene-root `` wrapper, and custom HTML configures `` through JavaScript properties/`configure()` because executable definitions are values, not JSON attributes. `ANIMATIONS.md` records both this implemented slice and the richer approved graph/sampling architecture that remains.
 
 ### Per-mesh detail layers
 
diff --git a/packages/effects/src/stock.test.ts b/packages/effects/src/stock.test.ts
index 56ad5e46..469e7634 100644
--- a/packages/effects/src/stock.test.ts
+++ b/packages/effects/src/stock.test.ts
@@ -408,47 +408,90 @@ describe("stock effects", () => {
     expect(activePairs).toBeGreaterThan(0);
   });
 
-  it("uses surface shade for every monochrome rain glyph", () => {
-    const shade = new Float32Array(12 * 6).fill(0.25);
+  it("lights a bright head and fades the tail on every monochrome strand", () => {
+    // Body cells use `color` (green, red channel 0); head cells use `headColor`
+    // (mint, red channel > 0). The red channel therefore separates head from
+    // body, and the green channel measures the head→tail brightness gradient.
     const output = evaluate(matrixRain, {
       colorMode: "monochrome",
-      color: "#804020",
+      color: "#00ff66",
+      headColor: "#e8ffe8",
       space: "scene",
       time: 1.25,
       speedMin: 5,
       speedMax: 5,
       density: 1,
-      trail: 4,
-    }, { shade });
-    const shaded = parseGlyphEffectColor("#201008").packed;
-    const rainColors: number[] = [];
-
+      trail: 8,
+    }, { shade: new Float32Array(12 * 6).fill(1) });
+    const rgb = (packed: number) => ({ r: (packed >>> 16) & 0xff, g: (packed >>> 8) & 0xff, b: packed & 0xff });
+    const active: { r: number; g: number; b: number }[] = [];
     for (let i = 0; i < output.coverage.length; i++) {
       if (output.coverage[i]! <= 0) continue;
       expect(output.channels[i]! & GlyphEffectOutputChannel.Color).toBeTruthy();
-      rainColors.push(output.color[i]!);
+      active.push(rgb(output.color[i]!));
     }
+    const heads = active.filter((c) => c.r > 0);
+    const body = active.filter((c) => c.r === 0);
+
+    expect(heads.length).toBeGreaterThan(0);
+    expect(body.length).toBeGreaterThan(1);
+    // The tail is a gradient, not one flat green.
+    expect(new Set(body.map((c) => c.g)).size).toBeGreaterThan(1);
+    // The bright head outshines the darkest tail cell.
+    expect(Math.max(...heads.map((c) => c.g))).toBeGreaterThan(Math.min(...body.map((c) => c.g)));
+  });
+
+  it("modulates monochrome brightness by surface shade", () => {
+    const params = {
+      colorMode: "monochrome",
+      color: "#00ff66",
+      space: "scene" as const,
+      time: 1.25,
+      speedMin: 5,
+      speedMax: 5,
+      density: 1,
+      trail: 8,
+    };
+    const green = (output: ReturnType) => {
+      let sum = 0;
+      for (let i = 0; i < output.coverage.length; i++) {
+        if (output.coverage[i]! > 0) sum += (output.color[i]! >>> 8) & 0xff;
+      }
+      return sum;
+    };
+    const dim = evaluate(matrixRain, params, { shade: new Float32Array(12 * 6).fill(0.2) });
+    const bright = evaluate(matrixRain, params, { shade: new Float32Array(12 * 6).fill(1) });
 
-    expect(rainColors.length).toBeGreaterThan(0);
-    expect(new Set(rainColors)).toEqual(new Set([shaded]));
+    expect(green(dim)).toBeGreaterThan(0);
+    expect(green(bright)).toBeGreaterThan(green(dim));
   });
 
-  it("falls back to the selected monochrome color when surface shade is unavailable", () => {
+  it("renders monochrome rain from the configured color when surface shade is unavailable", () => {
     const output = evaluate(matrixRain, {
       colorMode: "monochrome",
       color: "#37c96f",
+      headColor: "#d8ffe4",
       space: "scene",
       time: 1.25,
       speedMin: 5,
       speedMax: 5,
       density: 1,
-      trail: 4,
+      trail: 8,
     });
-    const selected = parseGlyphEffectColor("#37c96f").packed;
-    const activeColors = Array.from(output.color).filter((_, index) => output.coverage[index]! > 0);
+    const rgb = (packed: number) => ({ r: (packed >>> 16) & 0xff, g: (packed >>> 8) & 0xff, b: packed & 0xff });
+    const active: { r: number; g: number; b: number }[] = [];
+    for (let i = 0; i < output.coverage.length; i++) {
+      if (output.coverage[i]! > 0) active.push(rgb(output.color[i]!));
+    }
 
-    expect(activeColors.length).toBeGreaterThan(0);
-    expect(new Set(activeColors)).toEqual(new Set([selected]));
+    expect(active.length).toBeGreaterThan(0);
+    // Every cell is a real, green-dominant color (no fall-through to black).
+    for (const c of active) {
+      expect(c.g).toBeGreaterThan(0);
+      expect(c.g).toBeGreaterThanOrEqual(c.r);
+    }
+    // Head + fading tail give more than one brightness level.
+    expect(new Set(active.map((c) => c.g)).size).toBeGreaterThan(1);
   });
 
   it("scales monochrome coverage by the color's alpha channel", () => {
diff --git a/packages/effects/src/stock.ts b/packages/effects/src/stock.ts
index f03bff21..1644364d 100644
--- a/packages/effects/src/stock.ts
+++ b/packages/effects/src/stock.ts
@@ -700,9 +700,22 @@ export const matrixRain: GlyphStockEffectDefinition = {
         );
         setGlyph(context, i, glyphs[glyphIndex]!);
         if (monochrome) {
+          // Matrix signature: a bright leading head that fades to a dark tail.
+          // `behind` is 0 at the head and grows toward the tail — light the
+          // head cell toward `headColor` and fall the body off toward black so
+          // each strand glows at its tip and trails away, instead of every cell
+          // sharing one flat green. Surface shade still modulates so lit faces
+          // read brighter than faces turned away from the light.
           const shade = context.base.shade?.[i];
-          const intensity = shade !== undefined && Number.isFinite(shade) ? shade : 1;
-          setColor(context, i, scalePackedColor(parsedColor.packed, intensity));
+          const surfaceLit = shade !== undefined && Number.isFinite(shade) ? shade : 1;
+          const headSpan = 1 / glyphsPerPatternCell;
+          if (behind < headSpan && parsedHead.opacity > 0) {
+            setColor(context, i, scalePackedColor(parsedHead.packed, 0.6 + 0.4 * surfaceLit));
+          } else {
+            const fade = Math.pow(1 - behind / trail, 1.6);
+            const intensity = (0.12 + 0.88 * fade) * (0.5 + 0.5 * surfaceLit);
+            setColor(context, i, scalePackedColor(parsedColor.packed, intensity));
+          }
           context.output.coverage[i] = parsedColor.opacity;
         } else {
           context.output.coverage[i] = 1;
diff --git a/packages/glyphcss/src/render/rasterize.ts b/packages/glyphcss/src/render/rasterize.ts
index c568e9fb..8b3a9bd8 100644
--- a/packages/glyphcss/src/render/rasterize.ts
+++ b/packages/glyphcss/src/render/rasterize.ts
@@ -1215,7 +1215,7 @@ function scanFillTriangle(
   const ccw = area2 > 0;
   const perspectiveAttributes = aq !== 1 || bq !== 1 || cq !== 1;
   const interpolatePerspective = perspectiveAttributes
-    && (worldPosBuf !== null || surfaceUvBuf !== null || tex !== null);
+    && (worldPosBuf !== null || surfaceUvBuf !== null || tex !== null || sh !== null);
 
   // Bounding box clamped to grid.
   let minX = ax < bx ? ax : bx; if (cx < minX) minX = cx;
@@ -1342,9 +1342,20 @@ function scanFillTriangle(
         // Shadow occlusion: barycentric-interpolate light-space (u,v,depth),
         // sample the shadow map, darken if occluded.
         if (sh !== null) {
-          const lu = (wA * sh.luA + wB * sh.luB + wC * sh.luC) * invArea2;
-          const lv = (wA * sh.lvA + wB * sh.lvB + wC * sh.lvC) * invArea2;
-          const ld = (wA * sh.ldA + wB * sh.ldB + wC * sh.ldC) * invArea2;
+          // Light-space (u,v,depth) are affine in world position, so they need
+          // the SAME perspective-correct interpolation as world position/UV —
+          // interpolating them affinely in screen space warps the shadow under a
+          // perspective camera (and the warp shifts with the camera, worst up
+          // close). Ortho supplies q=1, reducing exactly to affine barycentrics.
+          const lu = perspectiveAttributes
+            ? (wA * aq * sh.luA + wB * bq * sh.luB + wC * cq * sh.luC) * invQ
+            : (wA * sh.luA + wB * sh.luB + wC * sh.luC) * invArea2;
+          const lv = perspectiveAttributes
+            ? (wA * aq * sh.lvA + wB * bq * sh.lvB + wC * cq * sh.lvC) * invQ
+            : (wA * sh.lvA + wB * sh.lvB + wC * sh.lvC) * invArea2;
+          const ld = perspectiveAttributes
+            ? (wA * aq * sh.ldA + wB * bq * sh.ldB + wC * cq * sh.ldC) * invQ
+            : (wA * sh.ldA + wB * sh.ldB + wC * sh.ldC) * invArea2;
           // Nearest-neighbor sample (integer texel coords).
           const tu = lu | 0;
           const tv = lv | 0;
diff --git a/packages/glyphcss/src/render/shadow.test.ts b/packages/glyphcss/src/render/shadow.test.ts
index 54f301f6..e50e3aa2 100644
--- a/packages/glyphcss/src/render/shadow.test.ts
+++ b/packages/glyphcss/src/render/shadow.test.ts
@@ -227,4 +227,53 @@ describe("shadow map", () => {
 
     expect(rasterize(ctxSelf)).not.toBe(rasterize(ctxBaseline));
   });
+
+  it("perspective-correct: a big receiver quad shadows the same cells as a tessellated one", () => {
+    // The light-space (u,v,depth) sampled per cell must be interpolated
+    // perspective-correctly. On a small triangle affine ≈ perspective-correct,
+    // but a big receiver quad under a perspective camera warps the shadow if the
+    // UVs are interpolated affinely (screen-space). A finely tessellated ground
+    // is affine-safe, so it's the ground truth: the big single quad must land
+    // the shadow in the same cells. (Before the fix these diverged badly.)
+    const cube = makeCubePolygons(3); // hovering caster so the shadow falls on open ground
+    const nc = cube.length;
+    const light = { direction: [0.5, -0.3, 0.8] as Vec3, intensity: 1 };
+    const cam = createGlyphPerspectiveCamera({ rotX: 66, rotY: 20, zoom: 90, distance: 14 });
+    const grid = { cols: 90, rows: 46, cellAspect: 2 };
+
+    const bigGround: Polygon[] = [
+      { vertices: [[-24, -24, 0], [24, -24, 0], [24, 24, 0]], color: "#888888" },
+      { vertices: [[-24, -24, 0], [24, 24, 0], [-24, 24, 0]], color: "#888888" },
+    ];
+    const tessGround: Polygon[] = [];
+    for (let x = -24; x < 24; x += 4) for (let y = -24; y < 24; y += 4) {
+      tessGround.push({ vertices: [[x, y, 0], [x + 4, y, 0], [x + 4, y + 4, 0], [x, y + 4, 0]], color: "#888888" });
+    }
+
+    const shadowedCells = (ground: Polygon[]): Set => {
+      const all = [...cube, ...ground];
+      const n = all.length;
+      const cast = new Array(n).fill(false); for (let i = 0; i < nc; i++) cast[i] = true;
+      const receive = new Array(n).fill(false); for (let i = nc; i < n; i++) receive[i] = true;
+      const common = { camera: cam, grid, polygons: all, mode: "solid" as const, directionalLight: light, ambientLight: AMB_LIGHT, useColors: false };
+      const base = rasterize(buildRasterizeContext(common)).split("\n");
+      const shad = rasterize(buildRasterizeContext({ ...common, shadow: { opacity: 0.85, lift: 0.06 }, castShadowFlags: cast, receiveShadowFlags: receive })).split("\n");
+      const set = new Set();
+      for (let r = 0; r < grid.rows; r++) for (let c = 0; c < grid.cols; c++) {
+        if (base[r]?.[c] !== undefined && base[r]![c] !== shad[r]?.[c]) set.add(`${r},${c}`);
+      }
+      return set;
+    };
+
+    const big = shadowedCells(bigGround);
+    const tess = shadowedCells(tessGround);
+
+    expect(tess.size).toBeGreaterThan(5); // there is a real shadow to compare
+    let intersection = 0;
+    for (const key of big) if (tess.has(key)) intersection++;
+    const union = big.size + tess.size - intersection;
+    // Perspective-correct → the big quad lands the shadow where the tessellated
+    // reference does. Affine interpolation would drop this Jaccard far below 0.7.
+    expect(intersection / union).toBeGreaterThan(0.7);
+  });
 });
diff --git a/website/public/og/city-lab.png b/website/public/og/city-lab.png
new file mode 100644
index 00000000..0746497f
Binary files /dev/null and b/website/public/og/city-lab.png differ
diff --git a/website/public/og/flatmap.png b/website/public/og/flatmap.png
new file mode 100644
index 00000000..06875abf
Binary files /dev/null and b/website/public/og/flatmap.png differ
diff --git a/website/public/og/gallery.png b/website/public/og/gallery.png
new file mode 100644
index 00000000..b718506f
Binary files /dev/null and b/website/public/og/gallery.png differ
diff --git a/website/public/og/image.png b/website/public/og/image.png
new file mode 100644
index 00000000..42744062
Binary files /dev/null and b/website/public/og/image.png differ
diff --git a/website/public/og/parthenon.png b/website/public/og/parthenon.png
new file mode 100644
index 00000000..8ea1b9ea
Binary files /dev/null and b/website/public/og/parthenon.png differ
diff --git a/website/public/og/synth.png b/website/public/og/synth.png
new file mode 100644
index 00000000..5d7414f4
Binary files /dev/null and b/website/public/og/synth.png differ
diff --git a/website/public/og/wordart.png b/website/public/og/wordart.png
new file mode 100644
index 00000000..ec5f5bf1
Binary files /dev/null and b/website/public/og/wordart.png differ
diff --git a/website/public/og/world.png b/website/public/og/world.png
new file mode 100644
index 00000000..07454935
Binary files /dev/null and b/website/public/og/world.png differ
diff --git a/website/scripts/generate-og.mjs b/website/scripts/generate-og.mjs
new file mode 100644
index 00000000..065a6ecd
--- /dev/null
+++ b/website/scripts/generate-og.mjs
@@ -0,0 +1,256 @@
+// Generate per-page social-preview (Open Graph) images.
+//
+// Each image reuses the landing `og.png` template — dark canvas, the
+// `[ glyphcss ]` wordmark + a page-specific tagline on the left, and the
+// page's REAL glyphcss ASCII render on the right. We capture the render live
+// (Playwright → the running preview server) so effect layers (field-synth,
+// Matrix rain) bake into the still exactly as a visitor sees them; a static
+// `compileScene` can't evaluate a mounted effect.
+//
+// Prereq: `pnpm --filter @glyphcss/website build && astro preview --port 4323`.
+// Run:    node website/scripts/generate-og.mjs [slug ...]   (default: all)
+//
+// Output: website/public/og/.png  (1200×630)
+
+import { chromium } from "playwright";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const OUT_DIR = path.resolve(__dirname, "../public/og");
+const BASE = process.env.OG_BASE ?? "http://localhost:4323";
+
+const W = 1200;
+const H = 630;
+// Render panel on the right half of the canvas.
+const PANEL = { x: 600, y: 70, w: 548, h: 490 };
+
+const PAGES = [
+  {
+    slug: "synth",
+    url: "/synth",
+    tagline: ["modular ASCII ", "field synth"],
+    sub: "Stack oscillators into moiré interference — a live in-browser synthesizer for glyph fields.",
+    foot: "glyphcss.com/synth",
+    settle: 3800,
+  },
+  {
+    slug: "wordart",
+    // Capture a single frame-filling word in the brand yellow→orange gradient
+    // with auto-spin off (front-facing, reproducible) — the default two-line
+    // bevel-gold "Glyph/CSS" reads muddy at OG scale.
+    url: "/wordart?text=GLYPH&fill=gradient&ga=%23ffd23f&gb=%23ff5e3a&spin=0",
+    tagline: ["3D ASCII text ", "from any font"],
+    sub: "Extrude any Google font into shaded, gradient, block-textured glyphs — one 
.",
+    foot: "glyphcss.com/wordart",
+    settle: 3800,
+  },
+  {
+    slug: "gallery",
+    // ?model= loads the classic glTF Duck.
+    url: "/gallery?model=2604777134",
+    tagline: ["the ASCII ", "render playground"],
+    sub: "Drop in OBJ · glTF · GLB · STL · .vox and tune camera, density, and shading live.",
+    foot: "glyphcss.com/gallery",
+    settle: 5500,
+  },
+  {
+    slug: "parthenon",
+    url: "/examples/parthenon",
+    tagline: ["the Parthenon, ", "in Matrix rain"],
+    sub: "An octastyle Doric temple built from primitives, green rain streaming down its columns.",
+    foot: "glyphcss.com/examples/parthenon",
+    settle: 4200,
+  },
+  {
+    slug: "image",
+    url: "/examples/image",
+    tagline: ["any image, ", "as glyphs"],
+    sub: "Drop in a photo and watch it rasterise into shaded, colored ASCII in real time.",
+    foot: "glyphcss.com/examples/image",
+    settle: 4200,
+    // The page renders nothing until an image is dropped — feed it a sample
+    // through its hidden file input so the OG shows a real rasterised photo. A
+    // bright, full-frame texture rasterises far better than a screenshot (whose
+    // black margins map to blank glyphs).
+    inputFile: { selector: "#file", path: "public/jslogo.png" },
+  },
+  {
+    slug: "world",
+    url: "/examples/world",
+    tagline: ["ASCII ", "Earth"],
+    sub: "ETOPO1 global topography rasterised into glyphs. Isometric, drag to orbit.",
+    foot: "glyphcss.com/examples/world",
+    settle: 4200,
+  },
+  {
+    slug: "flatmap",
+    url: "/examples/flatmap",
+    tagline: ["ASCII ", "iso world map"],
+    sub: "The whole planet flattened into a tiled isometric glyph map.",
+    foot: "glyphcss.com/examples/flatmap",
+    settle: 4200,
+  },
+  {
+    slug: "city-lab",
+    url: "/examples/city-lab",
+    tagline: ["endless ", "ASCII city"],
+    sub: "A procedural city rendered as colored ASCII on a performance-first render loop.",
+    foot: "glyphcss.com/examples/city-lab",
+    settle: 4200,
+  },
+];
+
+// Fit a monospace grid (cols × rows) into a pixel box. Monospace advance ≈
+// 0.6·fontSize; line box = fontSize (the render pre uses line-height == size).
+function fitFont(cols, rows, box) {
+  const byW = box.w / (cols * 0.6);
+  const byH = box.h / rows;
+  return Math.max(3, Math.min(byW, byH));
+}
+
+const esc = (s) =>
+  s.replace(/&/g, "&").replace(//g, ">");
+
+function templateHtml(page, render) {
+  const font = Math.floor(fitFont(render.cols, render.rows, PANEL) * 100) / 100;
+  const [t0, t1] = page.tagline;
+  const boost = page.boost ? `filter: ${page.boost};` : "";
+  return `
+
+
+
+
+
+  
+
[ glyphcss ]
+
${esc(t0)}${esc(t1)}
+
${esc(page.sub)}
+
${page.foot}
+
+
${render.html}
+`; +} + +async function capture(page, browser) { + const pg = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }); + await pg.goto(BASE + page.url, { waitUntil: "networkidle", timeout: 45000 }); + if (page.inputFile) { + await pg.setInputFiles( + page.inputFile.selector, + path.resolve(__dirname, "..", page.inputFile.path), + ); + } + await pg.waitForTimeout(page.settle); + const render = await pg.evaluate(() => { + const pres = [...document.querySelectorAll("pre")]; + let best = null, bestLen = -1; + for (const p of pres) { + const len = (p.textContent || "").replace(/\s/g, "").length; + if (len > bestLen) { bestLen = len; best = p; } + } + if (!best) return null; + + // Walk the DOM into a grid of { ch, color } cells so we can crop the render + // to its content bounding box — the raw render is padded with blank cells + // (a small mesh in a big viewport), which would otherwise shrink it in the + // OG panel. Cropping makes each card fill its space. + const grid = [[]]; + const walk = (node, color) => { + for (const n of node.childNodes) { + if (n.nodeType === 3) { + for (const ch of n.nodeValue) { + if (ch === "\n") grid.push([]); + else grid[grid.length - 1].push({ ch, color }); + } + } else if (n.nodeType === 1) { + walk(n, n.style && n.style.color ? n.style.color : color); + } + } + }; + walk(best, null); + while (grid.length && grid[grid.length - 1].length === 0) grid.pop(); + + let minC = Infinity, maxC = -1, minR = Infinity, maxR = -1; + grid.forEach((row, r) => row.forEach((cell, c) => { + if (cell.ch !== " ") { + if (c < minC) minC = c; + if (c > maxC) maxC = c; + if (r < minR) minR = r; + if (r > maxR) maxR = r; + } + })); + if (maxC < 0) return null; + const PAD = 1; + minC = Math.max(0, minC - PAD); minR = Math.max(0, minR - PAD); + maxC += PAD; maxR += PAD; + + const esc = (s) => s.replace(/&/g, "&").replace(//g, ">"); + let html = ""; + for (let r = minR; r <= maxR; r++) { + const row = grid[r] || []; + let run = "", runColor; + const flush = () => { + if (!run) return; + html += runColor ? `${esc(run)}` : esc(run); + run = ""; + }; + for (let c = minC; c <= maxC; c++) { + const cell = row[c] || { ch: " ", color: null }; + const col = cell.ch === " " ? null : cell.color; + if (col !== runColor) { flush(); runColor = col; } + run += cell.ch; + } + flush(); + if (r < maxR) html += "\n"; + } + return { html, cols: maxC - minC + 1, rows: maxR - minR + 1 }; + }); + if (!render) throw new Error(`no
 found on ${page.url}`);
+  await pg.setContent(templateHtml(page, render), { waitUntil: "networkidle" });
+  await pg.waitForTimeout(500); // let the webfont settle
+  const el = await pg.$("body");
+  const file = path.join(OUT_DIR, `${page.slug}.png`);
+  await el.screenshot({ path: file, clip: { x: 0, y: 0, width: W, height: H } });
+  await pg.close();
+  console.log(`  ✓ ${page.slug}.png  (grid ${render.cols}×${render.rows})`);
+}
+
+const want = process.argv.slice(2);
+const pages = want.length ? PAGES.filter((p) => want.includes(p.slug)) : PAGES;
+const browser = await chromium.launch();
+console.log(`Generating ${pages.length} OG image(s) → public/og/`);
+for (const p of pages) {
+  try { await capture(p, browser); }
+  catch (e) { console.error(`  ✗ ${p.slug}: ${e.message}`); }
+}
+await browser.close();
diff --git a/website/src/components/BaseHead.astro b/website/src/components/BaseHead.astro
new file mode 100644
index 00000000..9835bd0a
--- /dev/null
+++ b/website/src/components/BaseHead.astro
@@ -0,0 +1,42 @@
+---
+// Shared  for the hand-rolled Astro pages (landing, gallery, synth,
+// wordart, examples). Emits title/description + canonical + Open Graph +
+// Twitter card + Analytics + favicon in one place so the social-preview meta
+// can't drift per page. Starlight docs pages get their own equivalent via the
+// `head` config in astro.config.mjs.
+//
+// `image` is a root-relative path under /og (or /og.png). Each page passes its
+// own 1200×630 ASCII-render card generated by scripts/generate-og.mjs.
+import Analytics from "./Analytics.astro";
+
+interface Props {
+  title: string;
+  description: string;
+  path: string;
+  image?: string;
+}
+
+const { title, description, path, image = "/og.png" } = Astro.props;
+const SITE = "https://glyphcss.com";
+const canonical = `${SITE}${path}`;
+const ogImage = `${SITE}${image}`;
+---
+
+
+
+
+{title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/components/ExamplesSidebar.astro b/website/src/components/ExamplesSidebar.astro
index f41d1ae9..0053cc26 100644
--- a/website/src/components/ExamplesSidebar.astro
+++ b/website/src/components/ExamplesSidebar.astro
@@ -1,6 +1,6 @@
 ---
 interface Props {
-  active: "world" | "flatmap" | "city-lab" | "image";
+  active: "world" | "flatmap" | "city-lab" | "image" | "parthenon";
 }
 const { active } = Astro.props;
 
@@ -9,6 +9,7 @@ const items = [
   { id: "flatmap", label: "Flat Map", href: "/examples/flatmap/", desc: "Iso · Web Mercator" },
   { id: "city-lab", label: "City Lab", href: "/examples/city-lab/", desc: "Procedural · Endless" },
   { id: "image", label: "Image", href: "/examples/image/", desc: "Drop a photo · ASCII" },
+  { id: "parthenon", label: "Parthenon", href: "/examples/parthenon/", desc: "Matrix · Greek temple" },
 ] as const;
 ---
 
diff --git a/website/src/content/docs/guides/effects.mdx b/website/src/content/docs/guides/effects.mdx
index 83ec9a56..5b8a75fa 100644
--- a/website/src/content/docs/guides/effects.mdx
+++ b/website/src/content/docs/guides/effects.mdx
@@ -185,9 +185,11 @@ Every active trail cell emits full coverage; `Streams` and `Trail` create the
 sparse shape without coverage dithering breaking a word into isolated glyphs.
 
 Matrix rain's `Color mode` can preserve the model's original colors or use a
-single `Monochrome color`. Monochrome strands multiply that hue by the retained
-surface shade, so lighting, shadows, texture luminance, and the 3D silhouette
-remain visible.
+single `Monochrome color`. In monochrome, each strand lights a bright head with
+the `Original head color` and fades its tail toward black — the classic
+head-to-tail falling-code gradient — while the retained surface shade still
+modulates brightness, so lighting, shadows, texture luminance, and the 3D
+silhouette remain visible.
 
 ## Field synth
 
diff --git a/website/src/pages/examples/city-lab.astro b/website/src/pages/examples/city-lab.astro
index 143aedbf..24e4ddb6 100644
--- a/website/src/pages/examples/city-lab.astro
+++ b/website/src/pages/examples/city-lab.astro
@@ -1,5 +1,5 @@
 ---
-import Analytics from "../../components/Analytics.astro";
+import BaseHead from "../../components/BaseHead.astro";
 import DocsHeader from "../../components/DocsHeader.astro";
 import ExamplesSidebar from "../../components/ExamplesSidebar.astro";
 import { StatsOverlay } from "../../components/StatsOverlay";
@@ -10,11 +10,7 @@ const description = "A procedural endless city rendered as colored ASCII with a
 
 
 
-  
-  
-  
-  
-  {title}
+  
   
+
+
+  
+  
+ +
+
+
+

[ PARTHENON · MATRIX ]

+

The Parthenon as an octastyle Doric peripteral temple — three crepidoma steps, an 8 × 17 colonnade, entablature, cornice, triangular pediments, and a walkable interior: porch columns, cella doorways, the naos colonnade, and a golden Athena Parthenos — built from glyphcss primitives while green Matrix rain from @glyphcss/effects streams down every surface. Orbit by default — drag to orbit, scroll to zoom. Switch to FPV in the dock to walk in: click the render to capture the mouse, WASD to walk (the steps carry you up onto the stylobate — go through the east door), Space to jump, Esc to release.

+
+
+
+ +
+

[ CONTROLS ]

+
+
style
+
+ + +
+
+
+
camera
+
+ + +
+
+
+
density1.00
+ +
+
+
environment
+
+ + + + +
+
+
+
cloud size1.00
+ +
+
+
cloud cover0.46
+ +
+
+
cloud drift1.00
+ +
+
+
sun azimuth340
+ +
+
+
sun height38
+ +
+
+
matrix scale0.50
+ +
+
+
speed min5
+ +
+
+
speed max13
+ +
+
+
trail13
+ +
+
+
streams0.38
+ +
+
+
color mode
+
+ + +
+
+
+
mono color#00FF66
+ +
+
+
+
+
+
+ + + + diff --git a/website/src/pages/examples/world.astro b/website/src/pages/examples/world.astro index 6c90c2af..03f65551 100644 --- a/website/src/pages/examples/world.astro +++ b/website/src/pages/examples/world.astro @@ -1,18 +1,14 @@ --- import ExamplesSidebar from "../../components/ExamplesSidebar.astro"; import DocsHeader from "../../components/DocsHeader.astro"; -import Analytics from "../../components/Analytics.astro"; +import BaseHead from "../../components/BaseHead.astro"; const title = "glyphcss · ASCII Earth"; const description = "ETOPO1 global topography rasterised into ASCII. Isometric, drag to orbit."; --- - - - - {title} - +