Generated banner for Build Threejs Scroll Worlds
Generated logo for Build Threejs Scroll Worlds

Build Threejs Scroll Worlds

Meng ToCommunityCreated by Meng To

  • Skill
  • Workflow
  • Software development
  • MIT
  • Medium risk
  • Has scripts

From the author

Build rich, scroll-controlled real-time Three.js experiences as one persistent 3D world whose camera, lighting, atmosphere, materials, objects, DOM story, and interactions evolve across authored chapters. Use for 3D scrollytelling, scroll-driven WebGL worlds, camera journeys, interactive portfolios, product stories, exhibitions, explainers, game or film microsites, spatial narratives, and multi-scene websites where native scroll should travel through one continuous place. Not limited to landing pages.
— the description by Meng To

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a structured approach to creating real-time 3D experiences where native document scroll acts as the deterministic conductor for a persistent Three.js world. It emphasizes maintaining spatial continuity while evolving camera composition, lighting, atmosphere, and interactions across defined story beats. The workflow focuses on data-driven scene management, using a central ledger to coordinate state changes rather than scattering logic throughout the render loop.

What it does

  • Maps native scroll position to a fractional chapter value for deterministic state control.
  • Defines a persistent WebGL scene architecture with separate layers for environment, architecture, and interactives.
  • Provides a ledger-based system to manage camera, lighting, and material states per chapter.
  • Establishes a workflow for progressive asset loading and performance budgeting.
  • Integrates semantic DOM content with 3D spatial narratives.
  • Includes a QA framework for verifying transitions, responsive composition, and interaction stability.

When to use it

  • When building 3D scrollytelling or scroll-driven WebGL worlds.
  • When creating interactive portfolios or product stories that require a continuous 3D space.
  • When objects must remain interactive and spatially consistent while the camera moves through multiple compositions.
  • When a project requires a deterministic, reversible scroll-controlled journey.

Advantages

  • Ensures spatial continuity by keeping the renderer and scene graph alive.
  • Decouples story state from scroll events using a centralized conductor.
  • Provides a clear structure for managing complex 3D scenes through data ledgers.
  • Supports responsive design by allowing chapter-specific camera and layout overrides.
  • Facilitates consistent performance through defined asset and draw call budgets.

Usage examples

“I need to build a 3D product showcase where the user scrolls through different parts of a machine. How do I keep the camera movement smooth?”
The agent will guide you to define a chapter ledger with camera endpoints and use a scroll-conductor to interpolate between them, ensuring smooth, deterministic movement.
“How should I structure my Three.js scene for a multi-chapter scrollytelling site?”
The agent will provide a scene architecture template, including a worldRoot for persistent objects and chapterSets for localized props, ensuring efficient culling and loading.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/mengto/build-threejs-scroll-worlds/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip build-threejs-scroll-worlds.zip -d .agents/skills

Documentation by Meng To

Build Three.js Scroll Worlds

Build one detailed real-time world and use native document scroll as its deterministic conductor. Keep the renderer, scene graph, and spatial continuity alive while camera composition, light, fog, animation, copy, and interaction focus move through authored chapters.

The mechanism is one persistent Three.js world + one normalized reversible scroll state. If removing either makes the experience collapse into stacked sections, this skill applies.

The exact Kage demo proves the quality bar; it is staging, not a mandatory subject or layout. Use its detailed anatomy only when the requested direction benefits from it: references/kage-anatomy.md.

Route the request correctly

  • Use threejs for a single interactive scene with no scroll-authored journey.

  • Use scroll-world-storytelling when deciding between real-time 3D, pre-rendered video, and DOM-first storytelling.

  • Use scroll-scrubbed-visual-sequence for a video or image sequence whose time is scrubbed by scroll.

  • Use cinematic-scroll-storytelling for DOM-first GSAP/Lenis choreography.

  • Use this skill when objects must remain truly spatial and interactive while scroll moves through several compositions in one live WebGL world.

Do not disguise a video as Three.js. The public oso95/scroll-world project, reviewed at commit 71cc36d, is a strong reference for intake, scene ledgers, budget gates, mobile-specific composition, config-driven playback, and seam QA, but its renderer is a pre-generated video chain. This skill adopts those structural strengths while retaining real geometry, materials, lighting, raycasting, and camera control.

Read the build references

Before implementing a new world, read:

Copy references/scroll-conductor.js when a project needs a portable native-scroll conductor rather than a framework-specific implementation.

Start with the output, not the template

Determine what the world is for: a landing page, portfolio, museum chapter, product explanation, game teaser, editorial essay, data story, or another spatial experience. Do not force every request into a hero-plus-CTA layout.

Discover only what is unknown:

  • the subject, audience, and ordered story beats;

  • the art direction and emotional arc;

  • whether the world is continuous, portal-connected, or composed as adjacent sets;

  • the target devices and minimum acceptable hardware;

  • how interactive the world should be beyond scrolling;

  • available models, textures, brand assets, audio, and copy;

  • the initial-load and total-download budget.

If the user supplied an approved reference or working scene, inspect it first and carry its real geometry, materials, textures, light ratios, camera values, and motion constants into the ledger. Do not replace measured details with adjectives.

Write the world bible before code

Define one reusable visual grammar:

  • scale system and unit convention;

  • silhouette language and level of stylization;

  • palette and per-chapter accent progression;

  • material families and wear logic;

  • key, fill, rim, practical, and emissive light roles;

  • fog, haze, particles, weather, and post-processing;

  • typography, DOM composition, and near-plane foreground rules;

  • motion grammar for camera, ambient objects, and interactions.

Every visible detail must support the same world. Random noise, unrelated texture packs, arbitrary particle effects, and one-off materials create complexity without cohesion.

Author a scene ledger

Use 4–8 chapters for most experiences. Store the full contract as data rather than scattering thresholds through CSS and the render loop:

const chapters = [
  {
    id: "threshold",
    scrollWeight: 1.25,
    copy: {
      eyebrow: "Chapter 01",
      title: "Enter the archive",
      body: "A spatial collection revealed through motion."
    },
    camera: {
      position: [0, 3.8, 13.5],
      target: [0, 2.4, -8],
      fov: 38,
      mobile: { position: [0, 4.8, 18], fov: 46 }
    },
    world: {
      key: 1.0,
      practicals: 0.35,
      fog: 0.018,
      particles: 0.25,
      grade: "cold"
    },
    focus: ["gate", "lantern-left"],
    interactions: ["inspect-gate"],
    assets: ["gate-shell", "stone-1k", "mist-atlas"]
  }
];

For each chapter record the story beat, landmark, camera endpoint, occluders, light/fog state, active animation clips, interaction targets, DOM beat, foreground treatment, asset dependencies, and responsive override. Reject a chapter that differs only by copy.

Build one persistent spatial architecture

Create these systems once:

WebGL canvas
  worldRoot
    environment       sky, terrain, distant silhouettes
    architecture      persistent landmarks and paths
    chapterSets       local props grouped for culling and loading
    interactives      raycast targets and animation state
    atmosphere        fog volumes, particles, weather
    nearPlane3D       optional camera-relative depth accents

DOM above canvas
  semantic chapters  headings, copy, links, media, fallback order
  fixed interface    progress, chapter navigation, controls
  fixed cut-outs      optional alpha foregrounds at viewport edges

Use one renderer and normally one scene. Use layers or render passes when transparency, post-processing, or interaction isolation requires them. Do not instantiate one renderer per chapter or rebuild the world at seams.

Choose a topology deliberately:

  • Continuous geography: landmarks share one navigable space; strongest presence, highest world-building cost.

  • Connected sets: rooms or islands are joined by tunnels, gates, fog banks, terrain folds, or darkness; easiest to art-direct and stream.

  • Layered reveal: the same place changes through time, scale, or state; animate materials, visibility, and light instead of moving to unrelated coordinates.

Hide unavoidable discontinuities behind occlusion, darkness, dense atmosphere, an interior threshold, or a close foreground pass. Never let an object visibly teleport in open space.

Give the world real detail

Geometry
  • Establish the large silhouette first, then medium forms, then small accents. A dense scatter of tiny props cannot rescue a weak silhouette.

  • Give architectural edges believable thickness, bevels, joints, caps, reveals, and contact points. Paper-thin roofs and floating props expose the fake immediately.

  • Use modular kits, instancing, merged static meshes, and LODs instead of duplicating unique geometry.

  • Break repetition with authored clusters, scale/rotation ranges, decals, and material variation—not unbounded randomness.

  • Keep collision and raycast proxies simpler than render meshes.

Materials and textures

Use a coherent PBR surface stack where it improves the image:

maprolecommon failure
base colormaterial identity and broad variationbaked highlights fight live lighting
normalsmall directional reliefstrength too high makes rubber or foil
roughnesscontrols highlight breakup and ageflat values make every object plastic
AOcontact and crevice groundingmultiplied too heavily makes dirty seams
metalnessseparates conductors from dielectricsgray values everywhere create implausible mud
emissivepractical lights, screens, runesreplaces light but does not illuminate nearby forms
alpha/transmissionfoliage, cloth, glass, mistsorting, overdraw, and depth artifacts
  • Set color textures to sRGB and keep normal, roughness, AO, metalness, and data textures linear.

  • Keep texel density consistent within a camera range. Reserve 2K–4K maps for surfaces that genuinely fill the frame; use 512–1K atlases for most props.

  • Prefer KTX2/Basis compression, Meshopt/Draco geometry compression, atlases, trim sheets, decals, and baked AO/light where appropriate.

  • Add anisotropy only to shallow-angle surfaces that need it. Setting every texture to the maximum wastes bandwidth.

  • Make wear causal: exposed edges polish, recesses collect grime, water leaves vertical or pooling traces, and paths compress vegetation. Uniform grunge reads as a filter.

  • For foliage, use alpha-tested cards or instanced low-poly clusters before transparent blended planes. Transparency overdraw is often more expensive than geometry.

Lighting and atmosphere
  • Start with one authored key direction, a restrained environment/fill, and practical emissive sources. Add lights only when they create a visible relationship.

  • Bake static bounce and contact where possible. Keep shadow-casting dynamic lights to roughly 1–2 on mobile and 2–4 on desktop unless profiling proves more.

  • Use fog and haze to separate depth planes, not merely darken the scene. Keep the focal landmark inside the contrast window.

  • Match emissive geometry, glow sprites, actual light intensity, and bloom threshold so lamps feel luminous without bleaching the frame.

  • Use post-processing after the unprocessed frame is composed. Bloom, vignette, grain, chromatic separation, and depth of field must not hide weak materials or framing.

Record the detailed surface and light plan in the ledgers from references/world-bible.md.

Map native scroll to deterministic state

Measure stable section anchors only after fonts and critical media settle. Convert scrollY into a fractional chapter value such as 2.35.

Keep separate values:

rig.target = progressFromScroll(scrollY);       // exact reproducible story state
rig.smooth = reduceMotion
  ? rig.target
  : damp(rig.smooth, rig.target, 5.2, dt);      // cinematic render state

Use exact progress for navigation, URLs, accessibility, foreground ownership, and interaction gating. Use smoothed progress for camera and visual interpolation only. The same scroll position must recreate the same state forward, backward, after a fast jump, and after reload.

Use references/scroll-conductor.js as the baseline implementation. Keep native scroll as the source of truth; never integrate wheel delta into story position.

Author the camera as cinematography

Compose every chapter endpoint at desktop and mobile before interpolating.

  • Store position, target, FOV, optional roll, lens shift, and responsive overrides.

  • Use Catmull–Rom curves for broad continuous travel and segment interpolation for deliberate turns.

  • Inspect the curve for wall penetration, ground clipping, target flips, speed spikes, and accidental close passes.

  • Parameterize by chapter progress, not raw curve arc length, so story pacing remains intentional.

  • Use scrollWeight to give important views more dwell; do not distort the camera path just to slow a section.

  • Add restrained pointer parallax only after the base composition works. Clamp it and blend it out near precise transitions.

  • On tall viewports, author a pullback/FOV override instead of accepting an arbitrary center crop.

Camera motion must expose new spatial relationships: approach, reveal, passage, scale change, inspection, horizon, departure. Six dolly-ins aimed at the same center are not six scenes.

Interpolate world state from the same conductor

Resolve the adjacent chapters once per frame and interpolate their declared values:

const { a, b, t } = segmentState(rig.smooth);
moon.scale.setScalar(lerp(a.world.moon, b.world.moon, t));
key.intensity = lerp(a.world.key, b.world.key, t);
scene.fog.density = lerp(a.world.fog, b.world.fog, t);

Prefer continuous physical change: occlusion, material blend, animation mixer weight, light, fog, scale, and transform. When swapping assets, crossfade only within an occluded or atmospherically dense interval and keep both states loaded until the transition completes.

Add interactions without fighting scroll

Scroll owns macro movement. Pointer, touch, and keyboard interactions own local response.

  • Use raycasting against named proxy meshes, not every decorative triangle.

  • Define idle, hover, focused, active, and unavailable states for each interactive object.

  • Let hover warm light, shift material response, reveal a label, or trigger a short animation; do not move the scroll camera away from its path.

  • Pause or soften pointer parallax while the user drags a control or activates a hotspot.

  • Gate interactions by exact chapter visibility and depth; hidden objects must not capture events.

  • Mirror every essential hotspot with a DOM button or link in document order. Give it a visible focus state and synchronize its state with the 3D object.

  • On touch, use tap-to-focus then tap-to-activate when accidental activation would be costly. Keep hit targets at least 44 CSS px in the DOM proxy.

  • Make decorative particle trails and cursor effects optional, camera-relative where appropriate, and disabled for coarse pointers or reduced motion.

Write the interaction matrix before implementation; use the pattern in references/world-bible.md.

Keep the DOM story semantic

Keep headings, body copy, links, controls, and the footer in real HTML above or beside the canvas. The 3D world creates place; the DOM carries meaning and fallback order.

  • Reveal display headings by word only when it supports the rhythm; preserve the complete accessible label.

  • Reveal eyebrow, heading, body, media, proof, and CTA as independent beats.

  • Keep the reading block stable while the camera makes its largest move.

  • Use optional fixed alpha cut-outs as near-plane scenery, not as a requirement of every design. When used, keep them fully opaque while active and fade/blur them out after ownership changes.

Load progressively

Make the first authored frame complete before loading the entire journey.

  1. Inline or preload the critical shell, camera, first-scene geometry, smallest environment, and fallback poster.

  2. Load the next chapter before it can enter; prefetch one or two chapters ahead.

  3. Decode textures off the main interaction path and reveal a chapter only when its required assets are ready.

  4. Keep a designed poster and semantic DOM visible if WebGL, a model, or a texture fails.

  5. Report meaningful progress by required asset weight or completed groups, not an arbitrary timer.

Do not hide a 40 MB world behind a decorative percentage. Record the load plan in the asset ledger.

Hold a measurable performance budget

Start with these budgets, then profile on the actual target device:

budgetmobile targetdesktop target
DPR cap1.25–1.51.5–2
visible triangles150k–300k500k–1.2m
draw calls50–9090–160
shadowed lights1–22–4
simultaneously blended full-screen layers23
critical initial transfer3–6 MB5–10 MB
steady frame time≤16.7 ms ideal, ≤25 ms fallback≤16.7 ms

These are starting envelopes, not success claims. Measure CPU, GPU, texture memory, shader compilation, long tasks, and first-interactive frame.

  • Cap dt near 1/30 second after stalls.

  • Pause on document.hidden and when the experience is not visible.

  • Use a quality governor that lowers DPR and optional effects before deleting authored landmarks.

  • Render secondary cameras, reflections, cloth simulations, and expensive particles only while relevant.

  • Dispose geometries, materials, textures, render targets, observers, events, animation mixers, and RAF during teardown.

Respect motion, access, and failure

  • Preserve native reversible scroll; do not trap the wheel or force a custom scrollbar.

  • For reduced motion, snap camera/state to the nearest composed chapter, stop ambient loops and stagger/blur, and retain the complete ordered DOM story.

  • Keep a real heading hierarchy, landmarks, links, form controls, visible focus, and reachable footer.

  • Provide a composed poster or chapter stills when WebGL is unavailable.

  • Maintain contrast when the world changes behind copy; use authored scrims or local contrast management, not an opaque blanket over the whole scene.

Verify the whole journey

Follow references/quality-and-qa.md. At minimum verify:

  • every camera endpoint at 1440×900, 768×1024, and 390×844;

  • slow, fast, reverse, scrollbar-drag, anchor navigation, reload-at-depth, and resize between chapters;

  • material scale, UVs, texture color space, roughness response, contact grounding, shadow stability, fog depth, and post-processing;

  • hover, focus, tap, activation, chapter gating, and interaction recovery after scrolling away and back;

  • first-load poster, progressive chapter loading, failed asset, context loss, hidden-tab resume, and teardown;

  • reduced motion, WebGL fallback, keyboard order, readable DOM, reachable footer, and a clean console;

  • target-device frame time, draw calls, triangles, texture memory, transfer size, and shader compilation.

Use the Codex browser for visual and interaction verification. Build/DOM checks are not visual proof.

Deliver

Return:

  • the world bible and art-direction constants;

  • chapter, camera, material/texture, interaction, asset, and performance ledgers;

  • a layer and scene-graph map;

  • the working experience and local runtime assets;

  • desktop, tablet, and mobile evidence from representative chapters;

  • measured performance and loading results;

  • fallback/reduced-motion evidence;

  • remaining visual, interaction, or device gaps stated plainly.

Keep the bundled Kage demo unchanged unless the user explicitly asks to revise the reference itself.

Files

demo/index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Kage — Where stillness reveals the unseen</title>
<meta name="description" content="A five-chapter night walk through a Kyoto mountain temple. Charred cypress, lantern light and a vermilion moon, rendered live in WebGL.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%2305070a'/%3E%3Ccircle cx='16' cy='17' r='8' fill='%23e0231c'/%3E%3Crect x='4' y='8' width='24' height='2.6' fill='%23dfe7e0'/%3E%3Crect x='7' y='13' width='18' height='2' fill='%23dfe7e0'/%3E%3C/svg%3E">
<link rel="stylesheet" href="secret-pathways-assets/fonts.css">
<style>
/* ============================================================= tokens */
:root{
  --ink:#05070a;          /* page black */
  --ink-2:#0a0e12;        /* raised black */
  --bone:#dfe7e0;         /* the pale sage white of the wordmark */
  --bone-dim:#aab4ad;
  --muted:#78837c;
  --line:rgba(223,231,224,.13);
  --line-soft:rgba(223,231,224,.07);
  --vermilion:#e0231c;
  --ember:#ff5a3c;
  --gold:#c9a24a;
  --pad:clamp(20px, 3.4vw, 56px);
  --nav-h:84px;
  --ease:cubic-bezier(.22,.61,.36,1);
  --ease-out:cubic-bezier(.16,1,.3,1);
  --ease-io:cubic-bezier(.65,0,.35,1);
}
*,*::before,*::after{box-sizing:border-box}
html{ -webkit-text-size-adjust:100%; scroll-behavior:auto; }
body{
  margin:0; background:var(--ink); color:var(--bone);
  font-family:'Onest',system-ui,-apple-system,'Helvetica Neue',sans-serif;
  font-weight:300; font-size:16px; line-height:1.6;
  -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale;
  overflow-x:hidden;
}
body.is-locked{ overflow:hidden; height:100vh; }
::selection{ background:var(--vermilion); color:#fff; }
img{max-width:100%;display:block}
a{color:inherit;text-decoration:none}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}

/* ============================================================= layers */
#gl{
  position:fixed; inset:0; width:100%; height:100%;
  display:block; z-index:0; background:#05070a;
}
#grain{
  position:fixed; inset:-1px; z-index:60; pointer-events:none;
  opacity:.055; mix-blend-mode:overlay;
  background-repeat:repeat; background-size:180px 180px;
}
#vignette{
  position:fixed; inset:0; z-index:55; pointer-events:none;
  background:radial-gradient(125% 95% at 50% 42%, transparent 40%, rgba(2,4,6,.55) 100%);
}
.page{ position:relative; z-index:10; }

/* ============================================================= type */
.eyebrow{
  font-size:10px; font-weight:500; letter-spacing:.22em; text-transform:uppercase;
  color:var(--bone-dim); text-shadow:0 1px 16px rgba(3,6,8,.9);
}
.jp{ font-family:'NotoJP','Onest',sans-serif; font-weight:400; }
h1,h2,h3{ font-weight:400; margin:0; letter-spacing:-.005em; }
.display{
  text-transform:uppercase; line-height:1.055; letter-spacing:-.012em;
  font-weight:400; text-shadow:0 2px 34px rgba(3,6,8,.72);
}
.h-hero{ font-size:clamp(26px,3.05vw,46px); }
.h-sec{ font-size:clamp(30px,4.0vw,60px); }
.body-lg{ font-size:clamp(14px,1.02vw,17px); line-height:1.72; color:#b4bfb7; font-weight:300; text-shadow:0 1px 20px rgba(3,6,8,.88); }
.body{ font-size:14px; line-height:1.68; color:#9aa5a0; font-weight:300; text-shadow:0 1px 18px rgba(3,6,8,.85); }
.num{ font-size:clamp(26px,2.5vw,36px); font-weight:300; letter-spacing:-.02em; color:var(--bone); font-variant-numeric:tabular-nums; }

/* ============================================================= reveal */
[data-rv]{ opacity:0; }
[data-rv="up"]{ transform:translate3d(0,26px,0); }
[data-rv="fade"]{ transform:none; }
[data-rv].rv-in{ opacity:1; transform:none;
  transition:opacity .9s var(--ease-out), transform 1.05s var(--ease-out); }
.mask-line{ display:block; overflow:hidden; }
.mask-line:not(.word-reveal) > span{ display:block; transform:translate3d(0,110%,0); }
.rv-in .mask-line:not(.word-reveal) > span, .mask-line:not(.word-reveal).rv-in > span{ transform:none; }
.mask-line:not(.word-reveal) > span{ transition:transform 1.05s var(--ease-out); }
.word-mask{ display:inline-block; overflow:hidden; vertical-align:top; }
.word{
  display:inline-block; opacity:0; transform:translate3d(0,112%,0); will-change:transform,opacity;
  transition:transform .82s var(--ease-out), opacity .5s var(--ease-out);
  transition-delay:var(--word-delay,0ms);
}
.word-reveal.rv-in .word{ opacity:1; transform:none; }

/* ============================================================= preloader */
#pre{
  position:fixed; inset:0; z-index:100; background:#05070a;
  display:flex; align-items:center; justify-content:center;
  transition:opacity .8s var(--ease), visibility .8s;
}
#pre.done{ opacity:0; visibility:hidden; pointer-events:none; }
.pre-in{ width:min(420px,74vw); text-align:center; }
.pre-mark{ margin:0 auto 26px; width:44px; height:44px; opacity:.9; }
.pre-mark svg{ width:100%; height:100%; display:block; }
.pre-jp{ font-size:12px; letter-spacing:.5em; color:var(--bone-dim); margin-bottom:20px; }
.pre-bar{ position:relative; height:1px; background:rgba(223,231,224,.14); overflow:hidden; }
.pre-bar i{ position:absolute; inset:0 100% 0 0; background:var(--bone); transition:right .35s linear; }
.pre-meta{ display:flex; justify-content:space-between; margin-top:12px;
  font-size:10px; letter-spacing:.2em; text-transform:uppercase; color:var(--muted); }
.pre-meta b{ font-weight:500; color:var(--bone-dim); font-variant-numeric:tabular-nums; }

/* ============================================================= nav */
.nav{
  position:fixed; top:0; left:0; right:0; height:var(--nav-h); z-index:50;
  display:flex; align-items:center; gap:24px; padding:0 var(--pad);
  transition:transform .55s var(--ease), background .5s linear, backdrop-filter .5s linear;
}
.nav::after{
  content:''; position:absolute; left:var(--pad); right:var(--pad); bottom:0; height:1px;
  background:var(--line-soft); opacity:0; transition:opacity .5s;
}
.nav.stuck{ background:rgba(5,7,10,.62); backdrop-filter:blur(14px) saturate(1.1); -webkit-backdrop-filter:blur(14px) saturate(1.1); }
.nav.stuck::after{ opacity:1; }
.nav.hide{ transform:translate3d(0,-101%,0); }
.brand{ display:flex; align-items:center; gap:11px; flex:0 0 auto; }
.brand svg{ width:34px; height:34px; display:block; }
.brand-tx{ display:flex; flex-direction:column; line-height:1; gap:3px; }
.brand-tx b{ font-size:12px; font-weight:500; letter-spacing:.26em; }
.brand-tx i{ font-style:normal; font-size:8px; letter-spacing:.34em; color:var(--muted); }
.nav-links{ display:flex; gap:clamp(18px,2.6vw,46px); margin-left:auto; }
.nav-link{
  position:relative; font-size:11px; font-weight:500; letter-spacing:.2em; text-transform:uppercase;
  color:var(--bone-dim); display:block; height:15px; line-height:15px; overflow:hidden;
}
.nav-link span{ display:block; height:15px; line-height:15px; transition:transform .55s var(--ease-out), color .3s; }
.nav-link .alt{ position:absolute; inset:0; transform:translate3d(0,100%,0); color:var(--bone); letter-spacing:.32em; }
.nav-link:hover span{ transform:translate3d(0,-100%,0); }
.nav-link:hover .alt{ transform:none; }
.nav-link.on{ color:var(--bone); }
.nav-link.on::after{ content:''; position:absolute; left:0; bottom:0; width:100%; height:1px; background:var(--vermilion); }
.nav-burger{ margin-left:clamp(18px,2.6vw,42px); width:26px; height:16px; position:relative; flex:0 0 auto; }
.nav-burger i{ position:absolute; right:0; height:1.5px; background:var(--bone); transition:width .45s var(--ease-out), transform .45s var(--ease-out); }
.nav-burger i:nth-child(1){ top:4px; width:26px; }
.nav-burger i:nth-child(2){ top:11px; width:17px; }
.nav-burger:hover i:nth-child(2){ width:26px; }
.nav-burger:hover i:nth-child(1){ width:17px; }

/* ============================================================= hero */
.hero{ position:relative; min-height:100svh; padding:0 var(--pad); display:flex; flex-direction:column; }
.hero::before{ content:''; position:absolute; left:0; right:0; top:0; height:46%; pointer-events:none;
  background:linear-gradient(rgba(3,6,9,.72), rgba(3,6,9,.34) 46%, transparent); }
.hero-top{ padding-top:calc(var(--nav-h) + clamp(12px,2.6vh,34px)); max-width:min(560px,46vw); position:relative; z-index:2; }
.hero-top .eyebrow{ display:flex; align-items:center; gap:10px; margin-bottom:20px; }
.hero-top .eyebrow .dot{ width:5px; height:5px; border-radius:50%; background:var(--vermilion); box-shadow:0 0 10px var(--vermilion); }
.hero h1{ margin-bottom:18px; }
.hero-sub{ max-width:322px; text-shadow:0 1px 26px rgba(3,6,8,.95); }
.hero-spacer{ flex:1 1 auto; min-height:clamp(140px,26vh,300px); }
.hero-foot{ padding-bottom:clamp(22px,4.2vh,42px); position:relative; z-index:2; }
.hero-cue{ display:flex; align-items:center; justify-content:flex-end; gap:12px; margin-bottom:14px;
  font-size:9px; letter-spacing:.3em; text-transform:uppercase; color:var(--muted); }
.hero-cue .track{ width:54px; height:1px; background:var(--line); position:relative; overflow:hidden; }
.hero-cue .track i{ position:absolute; inset:0; background:var(--bone); transform-origin:left;
  animation:cue 2.8s var(--ease-io) infinite; }
.chapters{
  display:grid; grid-template-columns:repeat(4,1fr); gap:clamp(14px,2.4vw,40px);
  border-top:1px solid var(--line-soft); padding-top:18px;
}
.chip{ display:flex; gap:14px; align-items:flex-start; cursor:pointer; }
.chip .num{ line-height:1; flex:0 0 auto; transition:color .4s, transform .5s var(--ease-out); }
.chip .tx{ min-width:0; padding-top:3px; }
.chip b{ display:block; font-size:10px; font-weight:500; letter-spacing:.2em; text-transform:uppercase;
  color:var(--bone-dim); margin-bottom:6px; transition:color .4s;
  text-shadow:0 1px 16px rgba(3,6,8,.9); }
.chip p{ margin:0; font-size:11px; line-height:1.5; color:#7d8781; max-width:22ch;
  transition:color .4s; }
.chip:hover .num{ color:var(--ember); transform:translate3d(0,-2px,0); }
.chip:hover b{ color:var(--bone); }
.chip:hover p{ color:var(--bone-dim); }
.chip.on .num{ color:var(--ember); }
.chip.on b{ color:var(--bone); }
/* the floating window: a generated Sanmon still carried over the hero */
.peek{
  position:absolute; z-index:2; right:clamp(78px,11vw,190px); top:clamp(132px,25vh,238px);
  width:clamp(150px,16vw,262px); display:block;
  transition:transform .8s var(--ease-out);
}
.peek-fr{ display:block; aspect-ratio:16/10;
  background:linear-gradient(180deg, rgba(3,6,9,.04) 24%, rgba(3,6,9,.48) 100%),
    url('secret-pathways-assets/generated/kage-sanmon-preview.webp') center / cover no-repeat;
  outline:1px solid rgba(223,231,224,.16); outline-offset:-1px;
  box-shadow:0 34px 70px -30px rgba(0,0,0,.9); transition:outline-color .5s; }
.peek:hover{ transform:translate3d(0,-5px,0); }
.peek:hover .peek-fr{ outline-color:rgba(223,231,224,.42); }
.peek-play{ position:absolute; top:0; right:0; left:0; bottom:auto; aspect-ratio:16/10;
  display:grid; place-items:center; pointer-events:none; }
.peek-play svg{ width:clamp(30px,3.2vw,46px); height:auto; filter:drop-shadow(0 2px 12px rgba(0,0,0,.7));
  transition:transform .55s var(--ease-out); }
.peek:hover .peek-play svg{ transform:scale(1.14); }
.peek-cap{ display:flex; align-items:baseline; gap:9px; margin-top:9px; }
.peek-cap b{ font-size:11px; font-weight:400; letter-spacing:.24em; color:var(--bone-dim); }
.peek-cap i{ font-style:normal; font-size:9px; letter-spacing:.2em; text-transform:uppercase; color:var(--muted); }
@media (max-width:820px){ .peek{ display:none; } }

.hero-side{
  position:absolute; z-index:2; right:var(--pad); top:50%; transform:translateY(-50%);
  display:flex; flex-direction:column; align-items:center; gap:14px; pointer-events:none;
}
.hero-side .v{ writing-mode:vertical-rl; font-size:13px; letter-spacing:.62em; color:rgba(223,231,224,.5); }
@keyframes cue{ 0%{transform:scaleX(0)} 42%{transform:scaleX(1)} 100%{transform:scaleX(1) translateX(100%)} }

/* ============================================================= sections */
.sec{ position:relative; padding:clamp(88px,15vh,190px) var(--pad); }
.sec::before{
  content:''; position:absolute; inset:-22% -4%; z-index:-1; pointer-events:none;
  background:radial-gradient(110% 62% at 30% 50%, rgba(4,7,10,.88), rgba(4,7,10,.62) 42%, rgba(4,7,10,.18) 74%, rgba(4,7,10,0));
  /* A chapter's scrim is there to hold its own copy off the sanctuary, and it
     is inset -22% so its edges never land as a seam. That overshoot, plus the
     section's top padding, means it was washing most of a screen of scene
     above the chapter — the frame stayed masked long after the hero had let
     go of it. The mask takes the top of it back off: nothing at the very top,
     full strength by the time the chapter's own first line arrives. */
  -webkit-mask-image:linear-gradient(transparent, #000 44%);
          mask-image:linear-gradient(transparent, #000 44%);
}
#pathways::before{ background:radial-gradient(108% 64% at 50% 50%, rgba(4,7,10,.70), rgba(4,7,10,.44) 50%, rgba(4,7,10,0)); }
#eternity::before{ background:radial-gradient(82% 58% at 50% 46%, rgba(4,7,10,.26), rgba(4,7,10,.66) 56%, rgba(4,7,10,.90) 82%, rgba(4,7,10,0)); }
.foot::before{
  content:''; position:absolute; inset:0; z-index:-1; pointer-events:none;
  background:linear-gradient(rgba(4,7,10,.55), rgba(4,7,10,.94) 40%, rgba(4,7,10,.98));
}
.sec-head{ display:flex; align-items:baseline; gap:16px; margin-bottom:clamp(30px,5vh,66px); }
.sec-head .k{ font-size:10px; font-weight:500; letter-spacing:.24em; text-transform:uppercase; color:var(--muted); }
.sec-head .k b{ color:var(--vermilion); font-weight:500; }
.rule{ flex:1 1 auto; height:1px; background:var(--line-soft); }

/* — chapter I: the gate */
.gate-grid{ display:grid; grid-template-columns:minmax(0,1.02fr) minmax(0,1fr); gap:clamp(28px,5vw,90px); align-items:start; }
.gate-grid h2{ max-width:11ch; }
.gate-copy{ padding-top:6px; }
.gate-copy .lead{ font-size:clamp(15px,1.16vw,19px); line-height:1.66; color:#c2cdc5; text-shadow:0 1px 20px rgba(3,6,8,.9); }
.gate-copy .lead + p{ margin-top:20px; }
.arrowlink{ display:inline-flex; align-items:center; gap:12px; margin-top:34px;
  font-size:11px; font-weight:500; letter-spacing:.2em; text-transform:uppercase; color:var(--bone); }
.arrowlink .ar{ width:34px; height:34px; border:1px solid var(--line); border-radius:50%;
  display:grid; place-items:center; transition:background .45s var(--ease), border-color .45s, transform .5s var(--ease-out); }
.arrowlink .ar svg{ width:13px; height:13px; transition:transform .5s var(--ease-out); }
.arrowlink:hover .ar{ background:var(--bone); border-color:var(--bone); }
.arrowlink:hover .ar svg{ transform:translate3d(2px,-2px,0); }
.arrowlink:hover .ar svg path{ stroke:#05070a; }
.gate-stats{ display:flex; gap:clamp(24px,4vw,62px); margin-top:clamp(46px,8vh,96px); border-top:1px solid var(--line-soft); padding-top:24px; }
.gate-stats div b{ display:block; font-size:clamp(22px,2.1vw,32px); font-weight:300; letter-spacing:-.02em; }
.gate-stats div span{ font-size:10px; letter-spacing:.2em; text-transform:uppercase; color:var(--muted); }

/* — chapter II: pathways cards */
.cards{ display:grid; grid-template-columns:repeat(3,1fr); gap:clamp(10px,1.4vw,22px); align-items:start; }
.card{ position:relative; cursor:pointer; }
.card:nth-child(1){ transform:translateY(0); }
.card:nth-child(2){ transform:translateY(clamp(26px,5vw,74px)); }
.card:nth-child(3){ transform:translateY(clamp(52px,10vw,148px)); }
/* the frame is a hole punched in the page — the scene is rendered straight
   into this rectangle with a scissored viewport, so each card is a live
   window onto a different corner of the sanctuary */
.card-fr{
  position:relative; aspect-ratio:4/5; background:transparent;
  outline:1px solid var(--line-soft); outline-offset:-1px;
  transition:outline-color .5s var(--ease);
}
.card:hover .card-fr{ outline-color:rgba(223,231,224,.30); }
.card:nth-child(1) .card-fr{
  background:
    linear-gradient(180deg,rgba(3,6,9,.04) 36%,rgba(3,6,9,.72) 100%),
    url('secret-pathways-assets/generated/kage-approach.webp') center / cover no-repeat;
}
.card:nth-child(2) .card-fr{
  background:
    linear-gradient(180deg,rgba(3,6,9,.06) 36%,rgba(3,6,9,.74) 100%),
    url('secret-pathways-assets/generated/kage-lantern-court.webp') center / cover no-repeat;
}
.card:nth-child(3) .card-fr{
  background:
    linear-gradient(180deg,rgba(3,6,9,.05) 36%,rgba(3,6,9,.74) 100%),
    url('secret-pathways-assets/generated/kage-moonwater.webp') center / cover no-repeat;
}
.card-fr::after{ content:''; position:absolute; inset:0;
  background:linear-gradient(transparent 46%, rgba(4,6,9,.80)); pointer-events:none; }
/* The fabric overdraws its card by CL_BLEED on every side so the billow and
   the contact shadow have somewhere to go, so the frame cannot clip it. The
   card's outline is not here at all: it is drawn in the cloth shader, where it
   can follow the folds instead of tracing the flat box behind them. */
.cloth-out{ position:absolute; pointer-events:none; z-index:1; }
.on-cloth{ background:none !important; outline:0 !important; overflow:visible; }
.on-cloth::after{ display:none; }

/* ------------------------------------------------- the lights in the stills
   The three plates each have one source in them — two blood moons and a lit
   stone lantern — and they are the only warm thing in an otherwise cold set of
   images. A glow laid over each one, screened so it adds light rather than
   painting over it, is enough to make a flat still look like it is burning.

   Two tracks per light, at durations that do not divide into each other: the
   wrapper swells, the gradient inside it pulses. One track on its own reads as
   a loop within a couple of seconds; two beating against each other do not
   come back around inside the time anyone looks at a card.

   The moon and the lantern are not the same event. A flame gutters — short,
   irregular, deep — so the lantern's track is written unevenly by hand. A moon
   only breathes through moving air, so the moons get a slow, shallow swell and
   nothing more. Giving them both a flicker is what makes this kind of thing
   look like a novelty; the difference is the whole effect. */
.card-fr{ isolation:isolate; }        /* keep the screen blend inside the card */
.glow{
  position:absolute; z-index:1; pointer-events:none;
  left:var(--gx); top:var(--gy); width:calc(var(--gr) * 2); aspect-ratio:1;
  translate:-50% -50%;                /* leaves transform free for the swell */
  mix-blend-mode:screen;
  animation:glow-swell var(--gt2) var(--ease-io) infinite alternate;
}
.glow::before{
  content:''; position:absolute; inset:0; border-radius:50%;
  background:radial-gradient(closest-side, var(--gc1), var(--gc2) 40%, transparent 72%);
  animation:glow-pulse var(--gt) var(--ease-io) infinite;
}
@keyframes glow-swell{ from{ transform:scale(.93); } to{ transform:scale(1.07); } }
@keyframes glow-pulse{ from{ opacity:.78; } 50%{ opacity:1; } to{ opacity:.78; } }
/* a flame does not breathe, it gutters */
@keyframes glow-flame{
  0%{ opacity:.74 }   6%{ opacity:.97 }  12%{ opacity:.63 }  19%{ opacity:.90 }
  27%{ opacity:.55 }  34%{ opacity:.94 } 42%{ opacity:.71 }  51%{ opacity:1 }
  58%{ opacity:.60 }  66%{ opacity:.88 } 74%{ opacity:.67 }  83%{ opacity:.96 }
  91%{ opacity:.72 } 100%{ opacity:.74 }
}
.glow--flame::before{ animation-name:glow-flame; }
.glow--flame{ animation-duration:var(--gt2); }
@media (prefers-reduced-motion:reduce){
  .glow, .glow::before{ animation:none; }
}
.no-webgl .card-fr{ background:linear-gradient(150deg,#101a1d,#0a0d10 62%,#1a0c0b); }
/* If WebGL will not start, the page stays a page: the scene simply becomes a
   still, and the wordmark moves from the 3-D layer into the document. */
.word-fb{ display:none; }
.no-webgl #gl, .no-webgl .peek-play{ display:none; }
/* fixed, or the gradient is measured against a 6000-pixel body box and the
   moon ends up somewhere around the curriculum table */
.no-webgl body{ background:
  radial-gradient(11vw 11vw at 73% 17%, rgba(232,52,28,.95), rgba(158,20,16,.62) 44%, rgba(96,12,12,.20) 64%, transparent 72%),
  radial-gradient(38vw 32vw at 50% 46%, rgba(228,104,24,.40), rgba(122,42,10,.18) 54%, transparent 74%),
  radial-gradient(120% 80% at 50% 0%, rgba(120,150,158,.16), transparent 60%), #05070a;
  background-attachment:fixed; }
.no-webgl .peek-fr{ background:linear-gradient(160deg,#162326,#0b1013 55%,#241010); }
.no-webgl .word-fb{
  display:block; position:absolute; left:0; right:0; bottom:16%; z-index:1;
  font-family:'Wordmark','Onest',sans-serif; font-weight:600; font-size:21.4vw; line-height:.78;
  letter-spacing:.052em; text-align:center; pointer-events:none;
  background:linear-gradient(#e2ece5, #97a89c); -webkit-background-clip:text; background-clip:text;
  color:transparent;
}
/* The live hero has no foot scrim, but the fallback still needs one: there it
   stands on a bright CSS sky rather than a night scene, and the chips and the
   wordmark have nothing else holding them off it. So this carries the whole
   declaration now rather than re-colouring one it used to inherit. */
.no-webgl .hero::after{ content:''; position:absolute; left:0; right:0; bottom:0; height:34%;
  pointer-events:none; background:linear-gradient(transparent, rgba(3,5,8,.72) 52%, rgba(3,5,8,.96)); }
.no-webgl .sec::before, .no-webgl #pathways::before, .no-webgl #eternity::before{ background:none; }
.card-lab{ position:absolute; left:16px; right:16px; bottom:14px; z-index:2;
  display:flex; align-items:flex-end; justify-content:space-between; gap:10px; }
.card-lab b{ font-size:clamp(13px,1.15vw,17px); font-weight:400; letter-spacing:.02em; text-transform:uppercase; }
.card-lab .jp{ font-size:11px; letter-spacing:.3em; color:rgba(223,231,224,.62); }
.card-ar{ position:absolute; top:14px; right:14px; z-index:2; width:26px; height:26px;
  opacity:0; transform:translate3d(-4px,4px,0); transition:.5s var(--ease-out); }
.card:hover .card-ar{ opacity:1; transform:none; }
.card-meta{ display:flex; justify-content:space-between; margin-top:12px;
  font-size:10px; letter-spacing:.16em; text-transform:uppercase; color:var(--muted); }

/* — chapter III: curriculum */
.cur-head{ display:grid; grid-template-columns:minmax(0,1fr) minmax(0,.72fr); gap:clamp(28px,5vw,90px); align-items:end; margin-bottom:clamp(34px,6vh,72px); }
.cur-head h2{ max-width:13ch; }
.cur{ border-top:1px solid var(--line-soft); }
.les{
  position:relative; display:grid; align-items:center;
  grid-template-columns:64px minmax(0,1.05fr) minmax(0,1.25fr) 86px;
  gap:clamp(14px,2.4vw,40px);
  padding:clamp(20px,2.4vw,30px) 0; border-bottom:1px solid var(--line-soft);
  cursor:pointer; transition:padding .5s var(--ease-out);
}
.les::before{ content:''; position:absolute; left:calc(var(--pad) * -1); right:0; top:0; bottom:0;
  background:linear-gradient(90deg, rgba(224,35,28,.09), transparent 46%);
  opacity:0; transition:opacity .55s var(--ease); pointer-events:none; }
.les:hover::before{ opacity:1; }
.les:hover{ padding-left:clamp(8px,1.2vw,18px); }
.les .k{ font-size:11px; letter-spacing:.16em; color:var(--muted); font-variant-numeric:tabular-nums; transition:color .4s; }
.les:hover .k{ color:var(--vermilion); }
.les h3{ font-size:clamp(16px,1.5vw,23px); font-weight:400; letter-spacing:-.005em; }
.les h3 em{ font-style:normal; font-family:'NotoJP',sans-serif; font-size:.72em; color:var(--muted); margin-left:12px; letter-spacing:.24em; }
.les p{ margin:0; font-size:13px; line-height:1.6; color:#909b95; text-shadow:0 1px 16px rgba(3,6,8,.92); }
.les .t{ text-align:right; font-size:11px; letter-spacing:.14em; color:#8b958f; font-variant-numeric:tabular-nums; text-shadow:0 1px 16px rgba(3,6,8,.92); }
.les .bar{ position:absolute; left:0; bottom:-1px; height:1px; width:100%; background:var(--vermilion);
  transform:scaleX(0); transform-origin:left; transition:transform .7s var(--ease-out); }
.les:hover .bar{ transform:scaleX(1); }

/* — chapter IV: eternity */
.fin{ min-height:100svh; display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; }
.fin .eyebrow{ margin-bottom:26px; }
.fin h2{ font-size:clamp(38px,7.4vw,124px); line-height:.94; letter-spacing:-.03em; text-transform:uppercase; }
.fin p{ max-width:44ch; margin:26px auto 0; }
.cta{
  position:relative; display:inline-flex; align-items:center; gap:14px; margin-top:44px;
  padding:17px 30px; border:1px solid var(--line); border-radius:100px; overflow:hidden;
  font-size:11px; font-weight:500; letter-spacing:.22em; text-transform:uppercase;
  transition:color .45s var(--ease), border-color .45s;
}
.cta i{ position:absolute; inset:0; background:var(--bone); transform:translate3d(0,101%,0);
  transition:transform .62s var(--ease-out); z-index:-1; }
.cta span,.cta svg{ position:relative; z-index:1; }
.cta:hover{ color:#05070a; border-color:var(--bone); }
.cta:hover i{ transform:none; }
.cta:hover svg path{ stroke:#05070a; }

/* — footer */
.foot{ position:relative; padding:clamp(50px,8vh,96px) var(--pad) clamp(26px,4vh,40px);
  border-top:1px solid var(--line-soft); }
.foot-grid,.foot-base{ position:relative; }
.foot-grid{ display:grid; grid-template-columns:minmax(0,1.4fr) repeat(3,minmax(0,.6fr)); gap:clamp(22px,4vw,60px); }
.foot h4{ margin:0 0 16px; font-size:10px; font-weight:500; letter-spacing:.22em; text-transform:uppercase; color:var(--muted); }
.foot ul{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:9px; }
.foot li a{ font-size:13px; color:#8f9a93; transition:color .35s; }
.foot li a:hover{ color:var(--bone); }
.foot-brand p{ margin:16px 0 0; max-width:34ch; font-size:13px; color:#79847e; }
.foot-base{ display:flex; justify-content:space-between; align-items:center; gap:20px; flex-wrap:wrap;
  margin-top:clamp(38px,6vh,74px); padding-top:20px; border-top:1px solid var(--line-soft);
  font-size:10px; letter-spacing:.16em; text-transform:uppercase; color:var(--muted); }

/* ============================================================= progress rail */
.rail{ position:fixed; right:calc(var(--pad) - 4px); top:50%; transform:translateY(-50%); z-index:45;
  display:flex; flex-direction:column; gap:12px; align-items:center; }
.rail button{ width:22px; height:10px; display:grid; place-items:center; }
.rail i{ display:block; width:14px; height:1px; background:rgba(223,231,224,.26); transition:.5s var(--ease-out); }
.rail button.on i{ width:22px; background:var(--bone); }
.rail button:hover i{ width:22px; background:var(--bone-dim); }

/* ============================================================= cursor */
.cur-dot{ position:fixed; z-index:80; top:0; left:0; width:26px; height:26px; margin:-13px 0 0 -13px;
  border:1px solid rgba(223,231,224,.42); border-radius:50%; pointer-events:none;
  transition:width .35s var(--ease-out), height .35s var(--ease-out), margin .35s var(--ease-out),
             background .35s, border-color .35s, opacity .3s; opacity:0; }
.cur-dot.act{ width:52px; height:52px; margin:-26px 0 0 -26px; background:rgba(223,231,224,.07); border-color:rgba(223,231,224,.6); }
@media (hover:hover) and (pointer:fine){ .cur-dot{ opacity:1 } }

/* ============================================================= responsive */
@media (max-width:1080px){
  .hero-top{ max-width:none; }
  .gate-grid{ grid-template-columns:1fr; gap:26px; }
  .cur-head{ grid-template-columns:1fr; gap:18px; }
  .les{ grid-template-columns:44px minmax(0,1fr) 74px; }
  .les p{ display:none; }
  .foot-grid{ grid-template-columns:1fr 1fr; }
}
@media (max-width:820px){
  .nav-links{
    display:flex; position:fixed; top:0; right:0; bottom:0; width:min(84vw,360px);
    padding:calc(var(--nav-h) + 28px) var(--pad) 34px; flex-direction:column; align-items:flex-start; gap:0;
    background:rgba(3,6,9,.98); border-left:1px solid var(--line-soft);
    transform:translate3d(100%,0,0); opacity:0; visibility:hidden; pointer-events:none;
    transition:transform .65s var(--ease-out), opacity .4s, visibility 0s linear .65s;
  }
  .nav.menu-open .nav-links{
    transform:none; opacity:1; visibility:visible; pointer-events:auto; transition-delay:0s;
  }
  .nav-links .nav-link{
    width:100%; height:auto; padding:17px 0; overflow:visible; border-bottom:1px solid var(--line-soft);
    font-size:17px; line-height:1.2;
  }
  .nav-links .nav-link span{ height:auto; line-height:1.2; transform:none !important; }
  .nav-links .nav-link .alt{ position:static; margin-top:6px; font-size:10px; color:var(--muted); }
  .nav-burger.active i:nth-child(1){ top:7px; width:26px; transform:rotate(45deg); }
  .nav-burger.active i:nth-child(2){ top:7px; width:26px; transform:rotate(-45deg); }
  html.nav-open,html.nav-open body{ overflow:hidden; }
  .rail{ display:none; }
  .hero-side{ display:none; }
  .chapters{ grid-template-columns:repeat(2,1fr); gap:18px 14px; }
  .chip p{ font-size:10px; }
  .cards{ grid-template-columns:1fr; gap:14px; }
  .card:nth-child(2),.card:nth-child(3){ transform:none; }
  .gate-stats{ flex-wrap:wrap; gap:20px 34px; }
  .foot-grid{ grid-template-columns:1fr; }
  .hero-cue{ display:none; }
}
/* ================================================== section layouts (B set)
   The six sections ship their B compositions — hero split, story spread,
   gardens mosaic, chapter atlas, gallery closing, manifesto footer. They are
   keyed off stable body data attributes so the rules above keep reading as
   the plain grammar of the page and every B override stays in one block.   */

/* — hero B · split: an editorial reading column holds the left, a compact
     numbered index sits under it, and the live scene window becomes a low,
     right-hand counterweight. The scene itself remains untouched.

     The column carried a 90° scrim over the left three-quarters of the frame.
     It has gone: the sanctuary is already near-black behind the reading, and
     measured against it the copy loses almost nothing without it — the
     headline 15.98:1 to 15.47:1, the standfirst 7.94:1 to 7.75:1, the index
     16.11:1 to 16.08:1, all far past what either needs. It was darkening the
     approach, the gate and the lower steps to buy half a point of contrast
     nobody was short of. */
body[data-layout-hero="b"] .hero-top{ margin-left:0; max-width:min(590px,46vw); }
body[data-layout-hero="b"] .hero-foot{ width:min(480px,40vw); margin-left:0; }
body[data-layout-hero="b"] .hero-cue{ justify-content:flex-start; }
body[data-layout-hero="b"] .chapters{ grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
body[data-layout-hero="b"] .chip{ position:relative; flex-direction:column; gap:0; }
body[data-layout-hero="b"] .chip .num{ font-size:clamp(29px,3.1vw,48px); }
body[data-layout-hero="b"] .chip .tx{
  position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0);
  clip-path:inset(50%); white-space:nowrap;
}
/* the live window drops into the open right corner, away from the reading
   column and the oversized 3-D wordmark */
body[data-layout-hero="b"] .peek{
  left:auto; right:var(--pad); top:auto; bottom:clamp(38px,7vh,96px);
}
body[data-layout-hero="b"] .peek-cap b.jp{ font-size:clamp(20px,2.2vw,34px); letter-spacing:.18em; }
/* The Japanese title reads down the right edge, hung under the navigation —
   the one mark on that side of the frame now that the romanization has gone. */
body[data-layout-hero="b"] .hero-side{
  left:auto; right:var(--pad); top:calc(var(--nav-h) + clamp(14px,3vh,42px));
  bottom:auto; transform:none; display:block;
}
body[data-layout-hero="b"] .hero-side .v{
  position:static; transform:none;
  font-size:clamp(30px,3.2vw,54px); letter-spacing:.12em; color:rgba(223,231,224,.62);
}

/* — story B · spread: the title crosses two columns, the reading column is
     held to the right, and the figures become a ruled band. */
body[data-layout-story="b"] .gate-grid{
  grid-template-columns:minmax(120px,.38fr) minmax(0,1.08fr) minmax(280px,.88fr);
  gap:clamp(20px,3vw,54px);
}
body[data-layout-story="b"] .gate-grid h2{ grid-column:1 / 3; max-width:13ch; }
body[data-layout-story="b"] .gate-copy{ grid-column:3; }
body[data-layout-story="b"] .gate-stats{ display:grid; grid-template-columns:repeat(4,1fr); gap:0; }
body[data-layout-story="b"] .gate-stats div{ min-height:108px; padding:18px; border-left:1px solid var(--line-soft); }
body[data-layout-story="b"] .gate-stats div:last-child{ border-right:1px solid var(--line-soft); }

/* — gardens B · mosaic: one immersive live window beside two stacked ones.
     The cards stretch to their rows and the frames flex, so the two small
     viewports meet the tall one exactly instead of drifting apart. */
body[data-layout-gallery="b"] .cards{
  grid-template-columns:minmax(0,1.55fr) minmax(260px,.75fr);
  grid-template-rows:repeat(2,minmax(0,1fr));
  gap:14px; align-items:stretch;
}
body[data-layout-gallery="b"] .card{ transform:none; display:flex; flex-direction:column; min-height:0; }
body[data-layout-gallery="b"] .card-fr{ aspect-ratio:auto; flex:1 1 auto; min-height:0; }
body[data-layout-gallery="b"] .card:first-child{ grid-row:1 / 3; }
body[data-layout-gallery="b"] .card:first-child .card-fr{ flex:0 0 auto; height:min(72vh,760px); }
body[data-layout-gallery="b"] .card:not(:first-child) .card-fr{ min-height:200px; }

/* — chapters B · atlas: a two-then-three plate grid instead of a syllabus.
     Number and title sit at the top of each plate, the running time drops to
     the foot of it. */
body[data-layout-curriculum="b"] .cur-head{ grid-template-columns:minmax(0,.72fr) minmax(0,1.2fr); align-items:start; }
body[data-layout-curriculum="b"] .cur{
  display:grid; grid-template-columns:repeat(6,1fr); gap:1px;
  background:var(--line-soft); border:1px solid var(--line-soft);
}
body[data-layout-curriculum="b"] .les{
  display:flex; flex-direction:column; align-items:flex-start; min-height:250px;
  padding:24px; gap:14px; border:0; background:rgba(5,7,10,.84); overflow:hidden;
}
body[data-layout-curriculum="b"] .les:nth-child(-n+2){ grid-column:span 3; }
body[data-layout-curriculum="b"] .les:nth-child(n+3){ grid-column:span 2; }
body[data-layout-curriculum="b"] .les::before{ left:0; background:linear-gradient(145deg,rgba(224,35,28,.13),transparent 58%); }
body[data-layout-curriculum="b"] .les:hover{ padding-left:32px; }
body[data-layout-curriculum="b"] .les h3{ font-size:clamp(20px,2vw,30px); }
body[data-layout-curriculum="b"] .les p{ display:block; max-width:34ch; }
body[data-layout-curriculum="b"] .les .t{ text-align:left; margin-top:auto; }

/* — closing B · gallery: the title hangs vertically on the right while the
     chapter mark, the last line and the call to action run down the left. */
body[data-layout-closing="b"] .fin{
  display:grid; grid-template-columns:minmax(260px,.7fr) minmax(0,1.3fr); grid-template-rows:auto auto auto;
  align-content:center; align-items:end; text-align:left;
  column-gap:clamp(40px,7vw,120px); row-gap:clamp(16px,2.6vh,30px);
}
body[data-layout-closing="b"] .fin .eyebrow{ grid-column:1; grid-row:1; margin:0; }
body[data-layout-closing="b"] .fin h2{
  grid-column:2; grid-row:1 / 4; align-self:center; justify-self:end;
  font-size:clamp(52px,6vw,82px); writing-mode:vertical-rl;
}
body[data-layout-closing="b"] .fin p{ grid-column:1; grid-row:2; margin:0; }
body[data-layout-closing="b"] .fin .cta{ grid-column:1; grid-row:3; justify-self:start; margin-top:4px; }

/* — footer B · manifesto: the statement leads, the navigation becomes a
     compact ruled index beneath it. */
body[data-layout-footer="b"] .foot-grid{ grid-template-columns:repeat(3,1fr); }
body[data-layout-footer="b"] .foot-brand{
  grid-column:1 / -1; display:grid; grid-template-columns:70px minmax(0,1fr); align-items:center;
  padding-bottom:34px; margin-bottom:8px; border-bottom:1px solid var(--line-soft);
}
body[data-layout-footer="b"] .foot-brand svg{ width:54px; height:54px; }
body[data-layout-footer="b"] .foot-brand p{
  margin:0; max-width:48ch; font-size:clamp(18px,2vw,30px); line-height:1.35; color:var(--bone-dim);
}
body[data-layout-footer="b"] .foot-grid > div:nth-child(n+3){
  padding-left:clamp(18px,2.2vw,34px); border-left:1px solid var(--line-soft);
}
body[data-layout-footer="b"] .foot-base{ display:grid; grid-template-columns:1fr auto 1fr; }
body[data-layout-footer="b"] .foot-base span:last-child{ text-align:right; }

/* ================================================= foreground layers (KAGE)
   The transparent PNG library in secret-pathways-assets/foreground/png is
   introduced as a near plane. Each section carries one .fg box holding a few
   cut-outs that stand between the live sanctuary and the reading: the scene
   itself is untouched, this is only the garden that grows in front of it.

   The cut-outs are the nearest plane on the page, so they sit in front of the
   reading rather than behind it. Inside .page — itself z-index:10, above the
   #gl canvas at 0:
     -1   .sec::before / .foot::before   the section scrims
      2   headings, copy, links, cards, stats, CTA
      3   .fg                            the cut-outs: aria-hidden, inert
   .page caps everything it holds at tier 10, which is under the fixed
   furniture (rail 45, nav 50), so the chapter's live near plane is moved out
   to #fg-sky at 52 — over the layout and the navigation both, and still under
   the passes that treat the frame as one image: vignette 55, grain 60,
   cursor 80.

   A stage is only ever shown while its own chapter holds the viewport, and
   only in the fixed bottom-anchored form. Parked back in its section it is
   inert and invisible: a near plane that scrolls with the page reads as
   wallpaper printed on the document rather than as something standing between
   the camera and the scene, so there is no second, section-owned appearance.
   Every piece is therefore placed from the bottom edge of that fixed plane —
   left/right and bottom, never top.

   The entrance is driven by the chapter, not by the scroll observer: each
   [data-fg-in] piece rises from the edge it is anchored to when its stage goes
   live, one every 90ms in DOM order, and the reduced-motion block at the foot
   of this sheet flattens it. The wrapper owns the entrance and animates
   opacity and transform only; the artwork itself remains at full opacity, with
   the reading protected by the layer order rather than a translucent
   foreground treatment. */
.fg{
  position:absolute; inset:0; z-index:3;
  overflow:visible; pointer-events:none;
}
/* the host for whichever chapter currently owns the near plane — no box of
   its own, since every stage it holds is position:fixed to the viewport */
#fg-sky{
  position:fixed; left:0; top:0; width:0; height:0;
  z-index:52; pointer-events:none;
}
.sec > *:not(.fg), .foot > *:not(.fg){ position:relative; z-index:2; }
.fg-el{ position:absolute; display:block; opacity:0; }
.fg-el > img{
  width:100%; height:auto;   /* the width/height attrs hold the box before the lazy image lands */
  opacity:1;
  filter:saturate(.88) brightness(.86);
}
.fg-el--flip > img{ transform:scaleX(-1); }
/* each piece waits off the edge it is anchored to until its chapter is called */
[data-fg-in="up"]   { transform:translate3d(0,13%,0); }
[data-fg-in="left"] { transform:translate3d(-11%,6%,0); }
[data-fg-in="right"]{ transform:translate3d(11%,6%,0); }
/* one slow breath, on the two lightest layers only */
@keyframes fg-sway{
  from{ transform:rotate(-.45deg) translate3d(0,2px,0); }
  to  { transform:rotate(.45deg)  translate3d(0,-6px,0); }
}
.fg-el--sway > img{ animation:fg-sway 21s var(--ease-io) infinite alternate; transform-origin:0% 45%; }

/* The active chapter owns a bottom-anchored near plane. Its former stage is
   retained only long enough to dissolve into the next chapter's scene. */
.fg.fg-active,.fg.fg-retiring{
  position:fixed; left:0; right:0; top:auto; bottom:0; height:min(56svh,680px);
  z-index:1; overflow:visible; pointer-events:none;
}
.fg.fg-active .fg-el{
  opacity:1; transform:none;
  transition:opacity 1.2s var(--ease-out), transform 1.5s var(--ease-out);
  transition-delay:var(--fg-delay,0ms);
}
.fg .fg-el:nth-child(2){ --fg-delay:90ms; }
.fg .fg-el:nth-child(3){ --fg-delay:180ms; }
.fg .fg-el:nth-child(4){ --fg-delay:270ms; }
.fg.fg-retiring .fg-el{
  opacity:0; transform:translate3d(0,8vh,0) scale(.98); filter:blur(18px);
  transition:opacity .68s var(--ease-out),transform .78s var(--ease-out),filter .78s var(--ease-out);
}

/* Placement is keyed off [data-fg] rather than the section id, because the
   stage of the live chapter is re-parented into #fg-sky and stops being a
   descendant of its own section for as long as it holds the near plane.

   Every piece is anchored from the bottom of that plane, which is the bottom
   of the viewport. The cut-outs in png/ are trimmed flush to their own lowest
   opaque row, so a negative bottom here is a real amount of artwork buried off
   the foot of the frame, not slack in the file — the ground-sitting pieces
   carry enough of it that their base line never lifts off the edge. */

/* — I · the gate: the wall runs along the lower-left frame, a pine holds the
     far edge, and one bank of grass closes the foot of the chapter */
/* Wall against the left edge, pine against the right: the chapter is framed
   by the two of them with the court open between.

   left:0, not a negative offset — the plate is trimmed to its own artwork, so
   pulling it off-frame cuts the gate itself rather than trimming empty margin.
   Its left edge sits on the viewport's. */
[data-fg="gate"] .fg-wall  { left:0; bottom:-30px; width:clamp(430px,47vw,780px); }
[data-fg="gate"] .fg-pine  { right:-4%; bottom:-46px; width:clamp(190px,21vw,370px); }
[data-fg="gate"] .fg-grass { left:3%;   bottom:-150px; width:clamp(420px,60vw,1050px); }

/* — II · still gardens: sakura and maple sweep in from the two lower corners
     and carry their whole length, a lit lantern and a low bush hold the edges */
[data-fg="pathways"] .fg-sakura { left:-6%;  bottom:-86px;  width:clamp(430px,50vw,880px); }
[data-fg="pathways"] .fg-leaves { right:-6%; bottom:-62px;  width:clamp(330px,38vw,670px); }
[data-fg="pathways"] .fg-lantern{ left:-1%;  bottom:-96px;  width:clamp(120px,13vw,215px); }
[data-fg="pathways"] .fg-bush   { right:-4%; bottom:-38px;  width:clamp(250px,30vw,530px); }
/* the lantern is the one lit object in the library — it keeps its ember */
[data-fg="pathways"] .fg-lantern > img{ filter:saturate(.96) brightness(1); }
[data-fg="pathways"] .fg-leaves > img{ transform-origin:100% 40%; }

/* — III · sacred craft: basalt and a low wall fragment bracket the atlas and
     a stand of grass ties them together, all three below the plate grid */
[data-fg="lessons"] .fg-wall   { right:-8%; bottom:-78px; width:clamp(390px,50vw,870px); }
[data-fg="lessons"] .fg-stones { left:-4%;  bottom:-48px; width:clamp(250px,30vw,530px); }
[data-fg="lessons"] .fg-grass  { left:24%;  bottom:-84px; width:clamp(280px,33vw,580px); }

/* — IV · afterlight: a far hill closes the horizon, the shrine ruins stand at
     the left of it, grass banks the right, one branch hangs into the corner */
[data-fg="eternity"] .fg-hill   { left:-8%; width:min(150vh,116%); bottom:calc(-1 * min(20vh,15.5vw)); }
[data-fg="eternity"] .fg-ruins  { left:-5%;  bottom:-32px; width:clamp(240px,28vw,500px); }
[data-fg="eternity"] .fg-grass  { right:-7%; bottom:-60px; width:clamp(300px,37vw,670px); }
[data-fg="eternity"] .fg-sakura { left:-15%; bottom:-70px; width:clamp(280px,33vw,580px); }

/* — colophon: one restrained planting along the lower edge, nothing above it */
[data-fg="foot"] .fg-bush   { left:-6%;  bottom:-110px; width:clamp(230px,28vw,480px); }
[data-fg="foot"] .fg-grass  { right:-5%; bottom:-98px;  width:clamp(255px,30vw,530px); }
[data-fg="foot"] .fg-stones { left:36%;  bottom:-104px; width:clamp(190px,22vw,370px); }

@media (max-width:1080px){
  /* the columns have collapsed and the copy has taken the width back, so the
     framing layers step down rather than crowd it */
  [data-fg="gate"] .fg-wall      { width:clamp(390px,42vw,600px); }
  [data-fg="pathways"] .fg-sakura{ width:clamp(400px,46vw,660px); }
  [data-fg="pathways"] .fg-leaves{ width:clamp(310px,35vw,500px); }
  [data-fg="lessons"] .fg-wall   { width:clamp(360px,45vw,640px); }
  [data-fg="eternity"] .fg-sakura{ width:clamp(260px,30vw,430px); }
}
@media (max-width:820px){
  /* one or two anchors per section: the stacked cards, the atlas plates and
     the call to action own the whole width here, so every duplicated piece
     of ornament goes and what stays is smaller */
  [data-fg="gate"] .fg-pine, [data-fg="pathways"] .fg-sakura,
  [data-fg="pathways"] .fg-leaves, [data-fg="lessons"] .fg-wall,
  [data-fg="eternity"] .fg-sakura, [data-fg="foot"] .fg-stones{ display:none; }
  [data-fg="gate"] .fg-wall     { left:-14%; bottom:-22px; width:min(82vw,440px); }
  [data-fg="gate"] .fg-grass    { left:auto; right:-10%; bottom:-76px; width:min(94vw,530px); }
  [data-fg="pathways"] .fg-lantern{ left:-4%;   bottom:-58px; width:clamp(80px,20vw,128px); }
  [data-fg="pathways"] .fg-bush   { right:-10%; bottom:-38px; width:min(72vw,400px); }
  [data-fg="lessons"] .fg-stones  { left:-8%; bottom:-34px; width:min(66vw,360px); }
  [data-fg="lessons"] .fg-grass   { left:auto; right:-8%; bottom:-44px; width:min(70vw,380px); }
  [data-fg="eternity"] .fg-ruins  { left:-8%;   bottom:-25px; width:min(54vw,300px); }
  [data-fg="eternity"] .fg-grass  { right:-12%; bottom:-34px; width:min(80vw,440px); }
  [data-fg="foot"] .fg-bush  { left:-12%;  bottom:-26px; width:min(68vw,340px); }
  [data-fg="foot"] .fg-grass { right:-12%; bottom:-30px; width:min(74vw,380px); }
}
@media (prefers-reduced-motion:reduce){
  .fg-el--sway > img{ animation:none; }
  .fg.fg-retiring .fg-el{ transition:none !important; filter:none !important; }
}

@media (max-width:1080px){
  body[data-layout-hero="b"] .chapters{ gap:16px 20px; }
  body[data-layout-story="b"] .gate-grid{ grid-template-columns:1fr; }
  body[data-layout-story="b"] .gate-grid h2,
  body[data-layout-story="b"] .gate-copy{ grid-column:auto; }
  body[data-layout-story="b"] .gate-grid h2{ max-width:16ch; }
  body[data-layout-curriculum="b"] .les:nth-child(n){ grid-column:span 3; }
  body[data-layout-curriculum="b"] .les:last-child{ grid-column:span 6; }
}
@media (max-width:820px){
  /* the split collapses and the copy takes the page back */
  body[data-layout-hero="b"] .hero-top{ margin-left:0; max-width:none; }
  body[data-layout-hero="b"] .hero-foot{ width:100%; }
  body[data-layout-hero="b"] .chapters{ gap:18px 14px; }
  body[data-layout-hero="b"] .chip{ flex-direction:row; gap:14px; }
  body[data-layout-hero="b"] .chip .num{ font-size:clamp(26px,8vw,36px); }
  body[data-layout-hero="b"] .chip .tx{
    position:static; width:auto; height:auto; overflow:visible; clip:auto;
    clip-path:none; white-space:normal;
  }

  body[data-layout-story="b"] .gate-stats{ grid-template-columns:repeat(2,1fr); border-right:1px solid var(--line-soft); }
  body[data-layout-story="b"] .gate-stats div{ min-height:86px; padding:14px; }
  body[data-layout-story="b"] .gate-stats div:nth-child(-n+2){ border-bottom:1px solid var(--line-soft); }
  body[data-layout-story="b"] .gate-stats div:last-child{ border-right:0; }

  /* the mosaic unstacks, but the rhythm survives: the lead window stays the
     tall one and the two field notes stay letterbox */
  body[data-layout-gallery="b"] .cards{ grid-template-columns:1fr; grid-template-rows:auto; }
  body[data-layout-gallery="b"] .card:first-child{ grid-row:auto; }
  body[data-layout-gallery="b"] .card-fr{ flex:0 0 auto; height:auto; }
  body[data-layout-gallery="b"] .card:first-child .card-fr{ height:auto; aspect-ratio:4 / 5; }
  body[data-layout-gallery="b"] .card:not(:first-child) .card-fr{ aspect-ratio:16 / 10; min-height:0; }

  body[data-layout-curriculum="b"] .cur{ grid-template-columns:1fr; }
  body[data-layout-curriculum="b"] .les:nth-child(n){ grid-column:auto; min-height:0; padding:20px; }
  body[data-layout-curriculum="b"] .les:hover{ padding-left:20px; }

  body[data-layout-closing="b"] .fin{ display:flex; align-items:center; text-align:center; gap:0; }
  body[data-layout-closing="b"] .fin .eyebrow{ margin:0 0 26px; }
  body[data-layout-closing="b"] .fin h2{ writing-mode:horizontal-tb; font-size:clamp(38px,7.4vw,124px); }
  body[data-layout-closing="b"] .fin p{ margin:26px auto 0; }
  body[data-layout-closing="b"] .fin .cta{ margin-top:40px; }

  body[data-layout-footer="b"] .foot-grid{ grid-template-columns:1fr; }
  body[data-layout-footer="b"] .foot-brand{ grid-column:auto; grid-template-columns:minmax(0,1fr); row-gap:16px; }
  body[data-layout-footer="b"] .foot-grid > div:nth-child(n+3){ padding-left:0; border-left:0; }
  body[data-layout-footer="b"] .foot-base{ display:flex; }
  body[data-layout-footer="b"] .foot-base span:last-child{ text-align:left; }
}

@media (prefers-reduced-motion:reduce){
  *{ animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
  [data-rv]{ opacity:1 !important; transform:none !important; }
  .mask-line > span,.word{ opacity:1 !important; transform:none !important; }
}
</style>
</head>
<body data-layout-hero="b" data-layout-story="b" data-layout-gallery="b"
      data-layout-curriculum="b" data-layout-closing="b" data-layout-footer="b">

<canvas id="gl" aria-hidden="true"></canvas>
<div id="vignette"></div>
<div id="grain"></div>
<div class="cur-dot" id="cursor"></div>

<!-- ============================================================ preloader -->
<div id="pre">
  <div class="pre-in">
    <div class="pre-mark">
      <svg viewBox="0 0 44 44" fill="none" aria-hidden="true">
        <circle cx="22" cy="24" r="9.5" stroke="#e0231c" stroke-width="1.2"/>
        <path d="M6 12h32M9.5 17h25M22 8v28" stroke="#dfe7e0" stroke-width="1.2"/>
      </svg>
    </div>
    <div class="pre-jp jp">影の道</div>
    <div class="pre-bar"><i id="pre-fill"></i></div>
    <div class="pre-meta">
      <span>Raising the mountain temple</span><b><span id="pre-pct">0</span>%</b>
    </div>
  </div>
</div>

<!-- ============================================================ nav -->
<header class="nav" id="nav">
  <a class="brand" href="#top" data-cursor>
    <svg viewBox="0 0 44 44" fill="none" aria-hidden="true">
      <circle cx="22" cy="25" r="8.6" fill="#e0231c" fill-opacity=".9"/>
      <path d="M5 13h34M9 18.4h26M22 8.5v27" stroke="#dfe7e0" stroke-width="1.5"/>
      <path d="M14 35.5h16" stroke="#dfe7e0" stroke-width="1.2" stroke-opacity=".6"/>
    </svg>
    <span class="brand-tx"><b>KAGE</b><i>HIDDEN REALMS OF KYOTO</i></span>
  </a>
  <nav class="nav-links" id="navlinks">
    <a class="nav-link" href="#gate" data-cursor><span>Temples</span><span class="alt">伽藍</span></a>
    <a class="nav-link" href="#pathways" data-cursor><span>Gardens</span><span class="alt">庭園</span></a>
    <a class="nav-link" href="#lessons" data-cursor><span>Rituals</span><span class="alt">神事</span></a>
    <a class="nav-link" href="#eternity" data-cursor><span>Afterlight</span><span class="alt">残光</span></a>
  </nav>
  <button class="nav-burger" aria-label="Menu" data-cursor><i></i><i></i></button>
</header>

<div class="page" id="top">

<!-- ============================================================ hero -->
<section class="hero" id="hero" data-cam="0">
  <div class="hero-top">
    <div class="eyebrow" data-rv="fade"><span class="dot"></span> Chapter 00 — The Hidden Gate</div>
    <h1 class="display h-hero">
      <span class="mask-line"><span>Where stillness</span></span>
      <span class="mask-line"><span>reveals the</span></span>
      <span class="mask-line"><span>unseen.</span></span>
    </h1>
    <p class="hero-sub body" data-rv="up">Enter Kyoto through its quiet thresholds, where ritual,
      craft, and memory shape the path.</p>
  </div>

  <div class="hero-spacer"></div>

  <div class="hero-foot">
    <div class="hero-cue" data-rv="fade"><span>Scroll to enter</span><span class="track"><i></i></span></div>
    <div class="chapters" id="chips">
      <div class="chip" data-chip="0" data-rv="up" data-cursor><span class="num">01</span>
        <span class="tx"><b>Thresholds</b><p>Discover the hidden gates that open on to deeper paths.</p></span></div>
      <div class="chip" data-chip="1" data-rv="up" data-cursor><span class="num">02</span>
        <span class="tx"><b>Still Gardens</b><p>Witness the courts where silence gently unfolds.</p></span></div>
      <div class="chip" data-chip="2" data-rv="up" data-cursor><span class="num">03</span>
        <span class="tx"><b>Sacred Craft</b><p>Embrace the hands and heritage that shape devotion.</p></span></div>
      <div class="chip" data-chip="3" data-rv="up" data-cursor><span class="num">04</span>
        <span class="tx"><b>Night Rituals</b><p>Explore the rites that awaken when the day is done.</p></span></div>
    </div>
  </div>

  <a class="peek" href="#pathways" data-view="3" data-rv="fade" data-cursor aria-label="Preview: Sanmon, before the bell">
    <!-- data-frame, or the live view is blitted over the whole anchor and
         runs on down behind the caption instead of stopping at the frame -->
    <span class="peek-fr" data-frame></span>
    <span class="peek-play"><svg viewBox="0 0 22 22" fill="none"><path d="M8 5.6 16.4 11 8 16.4z" fill="#dfe7e0"/></svg></span>
    <span class="peek-cap"><b class="jp">山門</b><i>Sanmon — before the bell</i></span>
  </a>

  <div class="word-fb" aria-hidden="true">KAGE</div>

  <div class="hero-side" data-rv="up">
    <span class="v jp">影の道</span>
  </div>
</section>

<!-- ============================================================ chapter I -->
<section class="sec" id="gate" data-cam="1">
  <!-- foreground: the wall arrives first, the pine at the edge, the grass last -->
  <div class="fg" data-fg="gate" aria-hidden="true">
    <span class="fg-el fg-wall" data-fg-in="left">
      <img src="secret-pathways-assets/foreground/png/temple-wall.webp" alt="" width="1536" height="884" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-pine" data-fg-in="right">
      <img src="secret-pathways-assets/foreground/png/pine-tree.webp" alt="" width="1024" height="1438" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-grass" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/tall-grass.webp" alt="" width="1717" height="916" loading="lazy" decoding="async">
    </span>
  </div>
  <div class="sec-head" data-rv="fade">
    <span class="k"><b>01</b> — The Sanmon</span><span class="rule"></span><span class="k jp">山門</span>
  </div>
  <div class="gate-grid">
    <h2 class="display h-sec" data-rv="up">Charred cypress, worn stone, one gate left open.</h2>
    <div class="gate-copy">
      <p class="lead" data-rv="up">Kage begins where the city stops: a mountain gate of cedar burned black,
        standing in its own weather. The soot is not decoration. It is how a board is taught to survive a
        hundred rainy seasons, and the first thing this place asks you to understand.</p>
      <p class="body" data-rv="up">Climb the worn steps and the worship hall lifts out of the mist, its paper
        screens lit from inside like a lantern the size of a house. Above the eaves a vermilion moon holds
        its place, patient, half hidden. Nothing here is in a hurry. Neither, for the next ninety minutes,
        are you.</p>
      <a class="arrowlink" href="#pathways" data-rv="fade" data-cursor>
        <span>Cross the threshold</span>
        <span class="ar"><svg viewBox="0 0 14 14" fill="none"><path d="M3 11 11 3M5 3h6v6" stroke="#dfe7e0" stroke-width="1.3"/></svg></span>
      </a>
    </div>
  </div>
  <div class="gate-stats" data-rv="up">
    <div><b>05</b><span>Chapters</span></div>
    <div><b>92</b><span>Minutes</span></div>
    <div><b>1611</b><span>Hall raised</span></div>
    <div><b>∞</b><span>Stillness</span></div>
  </div>
</section>

<!-- ============================================================ chapter II -->
<section class="sec" id="pathways" data-cam="2">
  <!-- foreground: the garden frames the mosaic from the sides and the two lower corners -->
  <div class="fg" data-fg="pathways" aria-hidden="true">
    <span class="fg-el fg-sakura fg-el--sway" data-fg-in="left">
      <img src="secret-pathways-assets/foreground/png/sakura-branch.webp" alt="" width="1536" height="1024" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-leaves fg-el--sway" data-fg-in="right">
      <img src="secret-pathways-assets/foreground/png/maple-leaves.webp" alt="" width="1536" height="1024" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-lantern" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/stone-lantern.webp" alt="" width="1024" height="1499" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-bush" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/garden-bush.webp" alt="" width="1717" height="876" loading="lazy" decoding="async">
    </span>
  </div>
  <div class="sec-head" data-rv="fade">
    <span class="k"><b>02</b> — Still Gardens</span><span class="rule"></span><span class="k jp">庭園</span>
  </div>
  <div class="cards" id="cards">
    <article class="card" data-rv="up" data-view="0" data-cursor>
      <div class="card-fr" data-frame>
                <span class="card-ar"><svg viewBox="0 0 14 14" fill="none"><path d="M3 11 11 3M5 3h6v6" stroke="#dfe7e0" stroke-width="1.3"/></svg></span>
        <i class="glow" style="--gx:80.2%; --gy:23.9%; --gr:22%; --gt:6.1s; --gt2:9.7s; --gc1:rgba(255,142,108,.50); --gc2:rgba(212,56,38,.24)"></i>
        <div class="card-lab"><b>Approach</b><span class="jp">参道</span></div>
      </div>
      <div class="card-meta"><span>The long climb</span><span>01 / 03</span></div>
    </article>
    <article class="card" data-rv="up" data-view="1" data-cursor>
      <div class="card-fr" data-frame>
                <span class="card-ar"><svg viewBox="0 0 14 14" fill="none"><path d="M3 11 11 3M5 3h6v6" stroke="#dfe7e0" stroke-width="1.3"/></svg></span>
        <i class="glow glow--flame" style="--gx:70.5%; --gy:47.2%; --gr:14%; --gt:3.7s; --gt2:5.3s; --gc1:rgba(255,198,124,.62); --gc2:rgba(226,118,40,.30)"></i>
        <div class="card-lab"><b>Lanterns</b><span class="jp">灯籠</span></div>
      </div>
      <div class="card-meta"><span>Lantern court</span><span>02 / 03</span></div>
    </article>
    <article class="card" data-rv="up" data-view="2" data-cursor>
      <div class="card-fr" data-frame>
                <span class="card-ar"><svg viewBox="0 0 14 14" fill="none"><path d="M3 11 11 3M5 3h6v6" stroke="#dfe7e0" stroke-width="1.3"/></svg></span>
        <i class="glow" style="--gx:48.0%; --gy:16.8%; --gr:20%; --gt:7.3s; --gt2:11.2s; --gc1:rgba(255,138,104,.52); --gc2:rgba(208,54,36,.24)"></i>
        <div class="card-lab"><b>Moonwater</b><span class="jp">月影</span></div>
      </div>
      <div class="card-meta"><span>The wet court</span><span>03 / 03</span></div>
    </article>
  </div>
</section>

<!-- ============================================================ chapter III -->
<section class="sec" id="lessons" data-cam="3">
  <!-- foreground: a low periphery for the atlas — wall fragment, basalt, one stand of grass -->
  <div class="fg" data-fg="lessons" aria-hidden="true">
    <span class="fg-el fg-wall fg-el--flip" data-fg-in="right">
      <img src="secret-pathways-assets/foreground/png/temple-wall.webp" alt="" width="1536" height="884" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-stones" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/basalt-stones.webp" alt="" width="1536" height="996" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-grass" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/tall-grass.webp" alt="" width="1717" height="916" loading="lazy" decoding="async">
    </span>
  </div>
  <div class="sec-head" data-rv="fade">
    <span class="k"><b>03</b> — Sacred Craft</span><span class="rule"></span><span class="k jp">手業</span>
  </div>
  <div class="cur-head">
    <h2 class="display h-sec" data-rv="up">Five chapters. Ninety minutes. One quiet mind.</h2>
    <p class="body-lg" data-rv="up">Each chapter is a walk, not a lecture. You arrive at the gate, climb the
      steps, sit with the lantern, and leave with one thing worth keeping.</p>
  </div>
  <div class="cur" id="cur">
    <div class="les" data-les="0" data-cursor>
      <span class="k">01</span>
      <h3>The Hidden Gate<em class="jp">山門</em></h3>
      <p>Why a gate is a sentence, and what you agree to when you walk under one.</p>
      <span class="t">14 min</span><i class="bar"></i>
    </div>
    <div class="les" data-les="1" data-cursor>
      <span class="k">02</span>
      <h3>Borrowed Scenery<em class="jp">借景</em></h3>
      <p>Shakkei: composing with a mountain you will never own.</p>
      <span class="t">18 min</span><i class="bar"></i>
    </div>
    <div class="les" data-les="2" data-cursor>
      <span class="k">03</span>
      <h3>Charred Cypress<em class="jp">焼杉</em></h3>
      <p>Yakisugi: burning a board black so the weather will let it live.</p>
      <span class="t">21 min</span><i class="bar"></i>
    </div>
    <div class="les" data-les="3" data-cursor>
      <span class="k">04</span>
      <h3>Lantern Light<em class="jp">灯籠</em></h3>
      <p>How a single ember decides the scale of everything around it.</p>
      <span class="t">17 min</span><i class="bar"></i>
    </div>
    <div class="les" data-les="4" data-cursor>
      <span class="k">05</span>
      <h3>The Vermilion Moon<em class="jp">朱月</em></h3>
      <p>Why the moon burns red over the valley, and what the garden does with it.</p>
      <span class="t">22 min</span><i class="bar"></i>
    </div>
  </div>
</section>

<!-- ============================================================ chapter IV -->
<section class="sec fin" id="eternity" data-cam="4">
  <!-- foreground: the closing horizon: hill, ruins, grass, and the last branch -->
  <div class="fg" data-fg="eternity" aria-hidden="true">
    <span class="fg-el fg-hill" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/hill.webp" alt="" width="1774" height="887" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-ruins" data-fg-in="left">
      <img src="secret-pathways-assets/foreground/png/shrine-ruins.webp" alt="" width="1536" height="1001" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-grass" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/tall-grass.webp" alt="" width="1717" height="916" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-sakura" data-fg-in="left">
      <img src="secret-pathways-assets/foreground/png/sakura-branch.webp" alt="" width="1536" height="1024" loading="lazy" decoding="async">
    </span>
  </div>
  <div class="eyebrow" data-rv="fade">Chapter 04 — Afterlight</div>
  <h2 class="display" data-rv="up">Afterlight</h2>
  <p class="body-lg" data-rv="up">The gate does not close behind you. Take the walk whenever the noise
    gets loud — it is always the same path, and never the same light.</p>
  <a class="cta" href="#top" data-rv="fade" data-cursor>
    <i></i><span>Begin the walk</span>
    <svg viewBox="0 0 14 14" fill="none" width="13" height="13"><path d="M3 11 11 3M5 3h6v6" stroke="#dfe7e0" stroke-width="1.3"/></svg>
  </a>
</section>

<!-- ============================================================ footer -->
<footer class="foot" data-cam="5">
  <!-- foreground: one planting along the lower edge of the colophon -->
  <div class="fg" data-fg="foot" aria-hidden="true">
    <span class="fg-el fg-bush" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/garden-bush.webp" alt="" width="1717" height="876" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-grass" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/tall-grass.webp" alt="" width="1717" height="916" loading="lazy" decoding="async">
    </span>
    <span class="fg-el fg-stones" data-fg-in="up">
      <img src="secret-pathways-assets/foreground/png/basalt-stones.webp" alt="" width="1536" height="996" loading="lazy" decoding="async">
    </span>
  </div>
  <div class="foot-grid">
    <div class="foot-brand">
      <svg viewBox="0 0 44 44" fill="none" width="34" height="34" aria-hidden="true">
        <circle cx="22" cy="25" r="8.6" fill="#e0231c" fill-opacity=".9"/>
        <path d="M5 13h34M9 18.4h26M22 8.5v27" stroke="#dfe7e0" stroke-width="1.5"/>
      </svg>
      <p>A five-chapter night walk through a Kyoto mountain temple. Three illustrated garden field notes
        sit inside a live Three.js sanctuary.</p>
    </div>
    <div><h4>Chapters</h4><ul>
      <li><a href="#gate" data-cursor>The Sanmon</a></li>
      <li><a href="#pathways" data-cursor>Still Gardens</a></li>
      <li><a href="#lessons" data-cursor>Sacred Craft</a></li>
      <li><a href="#eternity" data-cursor>Afterlight</a></li>
    </ul></div>
    <div><h4>Practice</h4><ul>
      <li><a href="#lessons" data-cursor>Borrowed scenery</a></li>
      <li><a href="#lessons" data-cursor>Lantern light</a></li>
      <li><a href="#lessons" data-cursor>Charred cypress</a></li>
      <li><a href="#lessons" data-cursor>Raked gravel</a></li>
    </ul></div>
    <div><h4>Elsewhere</h4><ul>
      <li><a href="#top" data-cursor>Journal</a></li>
      <li><a href="#top" data-cursor>Field notes</a></li>
      <li><a href="#top" data-cursor>Colophon</a></li>
    </ul></div>
  </div>
  <div class="foot-base">
    <span>© 2026 Kage — Kage no Michi</span>
    <span class="jp">静けさは一つの技である</span>
    <span>WebGL · Onest · Kyoto</span>
  </div>
</footer>

</div><!-- /.page -->

<!-- The near plane of the active chapter is parked here rather than inside its
     section: .page is a stacking context at z-index 10, so nothing living in it
     can rise past the nav. #fg-sky sits above the whole layout instead. -->
<div id="fg-sky" aria-hidden="true"></div>

<div class="rail" id="rail"></div>

<script src="secret-pathways-assets/three.min.js"></script>
<script>
/* =====================================================================
   KAGE — a live Kyoto mountain temple, after dark.
   Everything on this page is generated at runtime: no photographs,
   no video, no external assets beyond three.js and two subset fonts.
   ===================================================================== */
(function () {
'use strict';

/* ------------------------------------------------------------ 0 · basics */
const Q      = new URLSearchParams(location.search);
const qs     = (k, d) => { const v = Q.get(k); return v === null ? d : v; };
const qn     = (k, d) => { const v = Q.get(k); return v === null ? d : parseFloat(v); };
const REDUCE = matchMedia('(prefers-reduced-motion: reduce)').matches;
const COARSE = matchMedia('(hover: none)').matches;

const clamp  = (v, a, b) => v < a ? a : (v > b ? b : v);
const sat    = v => clamp(v, 0, 1);
const lerp   = (a, b, t) => a + (b - a) * t;
const smooth = (e0, e1, x) => { const t = sat((x - e0) / (e1 - e0)); return t * t * (3 - 2 * t); };
const easeOut= t => 1 - Math.pow(1 - t, 3);
const easeIO = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
const TAU    = Math.PI * 2;
/* frame-rate independent damping */
const damp   = (cur, to, rate, dt) => lerp(cur, to, 1 - Math.exp(-rate * dt));

function mulberry32(a) {
  return function () {
    a |= 0; a = a + 0x6D2B79F5 | 0;
    let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}
/* classic 2-D gradient noise */
function noise2D(seed) {
  const rnd = mulberry32(seed), p = new Uint8Array(256), perm = new Uint8Array(512);
  for (let i = 0; i < 256; i++) p[i] = i;
  for (let i = 255; i > 0; i--) { const j = (rnd() * (i + 1)) | 0, t = p[i]; p[i] = p[j]; p[j] = t; }
  for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
  const G = [[1, 1], [-1, 1], [1, -1], [-1, -1], [1, 0], [-1, 0], [0, 1], [0, -1]];
  const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
  return function (x, y) {
    const xi = Math.floor(x), yi = Math.floor(y);
    const X = xi & 255, Y = yi & 255, xf = x - xi, yf = y - yi;
    const u = fade(xf), v = fade(yf);
    const g = (h, dx, dy) => { const q = G[h & 7]; return q[0] * dx + q[1] * dy; };
    const aa = perm[perm[X] + Y], ab = perm[perm[X] + Y + 1];
    const ba = perm[perm[X + 1] + Y], bb = perm[perm[X + 1] + Y + 1];
    return lerp(lerp(g(aa, xf, yf), g(ba, xf - 1, yf), u),
                lerp(g(ab, xf, yf - 1), g(bb, xf - 1, yf - 1), u), v);
  };
}
function fbm(n, x, y, oct, lac, gain) {
  let a = .5, f = 1, s = 0, m = 0;
  for (let i = 0; i < (oct || 4); i++) { s += a * n(x * f, y * f); m += a; a *= (gain || .5); f *= (lac || 2); }
  return s / m;                                    /* −1 … 1 */
}

/* ------------------------------------------------------------ 1 · canvas */
function cvs(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }
const hex = (r, g, b) => 'rgb(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ')';

/* stacked, up-scaled value noise — 30× faster than a per-pixel fbm and
   indistinguishable once it is multiplied under a dark base colour */
function fbmCanvas(W, H, seed, octaves, baseCells, contrast) {
  const out = cvs(W, H), o = out.getContext('2d');
  o.fillStyle = '#808080'; o.fillRect(0, 0, W, H);
  let cells = baseCells || 3, alpha = 1;
  for (let i = 0; i < (octaves || 5); i++) {
    const n = cvs(cells, cells), nx = n.getContext('2d');
    const im = nx.createImageData(cells, cells), d = im.data, r = mulberry32(seed + i * 977);
    for (let k = 0; k < cells * cells; k++) {
      const v = 128 + (r() - .5) * 255 * (contrast || 1);
      d[k * 4] = d[k * 4 + 1] = d[k * 4 + 2] = clamp(v, 0, 255); d[k * 4 + 3] = 255;
    }
    nx.putImageData(im, 0, 0);
    o.globalAlpha = alpha;
    o.globalCompositeOperation = i === 0 ? 'source-over' : 'overlay';
    o.imageSmoothingEnabled = true; o.imageSmoothingQuality = 'high';
    o.drawImage(n, 0, 0, W, H);
    cells *= 2; alpha *= .62;
  }
  o.globalAlpha = 1; o.globalCompositeOperation = 'source-over';
  return out;
}

/* height → tangent-space normal map (blur first, then Sobel) */
function normalFromHeight(hc, strength) {
  const W = hc.width, H = hc.height;
  const b = cvs(W, H), bx = b.getContext('2d');
  bx.filter = 'blur(1.1px)'; bx.drawImage(hc, 0, 0); bx.filter = 'none';
  const src = bx.getImageData(0, 0, W, H).data;
  const out = cvs(W, H), ox = out.getContext('2d');
  const im = ox.createImageData(W, H), d = im.data;
  const at = (x, y) => src[(((y + H) % H) * W + ((x + W) % W)) * 4] / 255;
  const s = strength || 2.4;
  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
    const gx = (at(x + 1, y) - at(x - 1, y)) * s;
    const gy = (at(x, y + 1) - at(x, y - 1)) * s;
    let nx = -gx, ny = gy, nz = 1;
    const il = 1 / Math.hypot(nx, ny, nz);
    const i = (y * W + x) * 4;
    d[i]     = (nx * il * .5 + .5) * 255;
    d[i + 1] = (ny * il * .5 + .5) * 255;
    d[i + 2] = (nz * il * .5 + .5) * 255;
    d[i + 3] = 255;
  }
  ox.putImageData(im, 0, 0);
  return out;
}

/* ------------------------------------------------------- 2 · surfaces */
/* long, wet, board-formed concrete — the sanctuary walls */
function texWall() {
  const W = 1024, H = 1024;
  const c = cvs(W, H), x = c.getContext('2d');
  x.fillStyle = '#10161a'; x.fillRect(0, 0, W, H);

  /* mottling */
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .82;
  x.drawImage(fbmCanvas(W, H, 41, 6, 3, 1), 0, 0);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* board-form seams every sixth */
  const rnd = mulberry32(7);
  for (let i = 1; i < 6; i++) {
    const y = (H / 6) * i;
    x.fillStyle = 'rgba(0,0,0,.45)'; x.fillRect(0, y - 1.5, W, 3);
    x.fillStyle = 'rgba(190,205,205,.05)'; x.fillRect(0, y + 2, W, 2);
  }
  /* form-tie dimples */
  for (let i = 0; i < 6; i++) for (let j = 0; j < 4; j++) {
    const cx2 = (W / 4) * (j + .5) + (rnd() - .5) * 14, cy = (H / 6) * (i + .5);
    const g = x.createRadialGradient(cx2, cy, 1, cx2, cy, 11);
    g.addColorStop(0, 'rgba(0,0,0,.5)'); g.addColorStop(.7, 'rgba(0,0,0,.18)'); g.addColorStop(1, 'rgba(0,0,0,0)');
    x.fillStyle = g; x.beginPath(); x.arc(cx2, cy, 11, 0, TAU); x.fill();
  }
  /* rain streaks running the full height */
  for (let i = 0; i < 190; i++) {
    const sx = rnd() * W, w = .6 + rnd() * 3.4, top = rnd() * H * .5, len = H * (.4 + rnd() * .7);
    const g = x.createLinearGradient(0, top, 0, top + len);
    const dark = rnd() > .45;
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(.25, dark ? 'rgba(0,0,0,.20)' : 'rgba(170,195,200,.045)');
    g.addColorStop(1, 'rgba(0,0,0,0)');
    x.fillStyle = g; x.fillRect(sx, top, w, len);
  }
  /* fine tooth */
  x.globalAlpha = .16; x.globalCompositeOperation = 'overlay';
  x.drawImage(fbmCanvas(512, 512, 91, 3, 128, 1.4), 0, 0, W, H);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* height for the normal map: seams + streaks only */
  const h = cvs(W, H), hx = h.getContext('2d');
  hx.fillStyle = '#808080'; hx.fillRect(0, 0, W, H);
  hx.globalAlpha = .5; hx.drawImage(fbmCanvas(W, H, 41, 5, 6, 1), 0, 0); hx.globalAlpha = 1;
  for (let i = 1; i < 6; i++) { hx.fillStyle = '#2a2a2a'; hx.fillRect(0, (H / 6) * i - 2, W, 4); }
  return { map: c, normal: normalFromHeight(h, 2.0) };
}

/* wet slate paving — big slabs, tight joints, standing water */
function texFloor() {
  const W = 1024, H = 1024;
  const c = cvs(W, H), x = c.getContext('2d');
  const rnd = mulberry32(23);
  x.fillStyle = '#0a0f12'; x.fillRect(0, 0, W, H);
  const N = 4, S = W / N;
  for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
    const t = .82 + rnd() * .36;
    x.fillStyle = hex(12 * t, 17 * t, 20 * t);
    x.fillRect(i * S + 1.5, j * S + 1.5, S - 3, S - 3);
  }
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .55;
  x.drawImage(fbmCanvas(W, H, 63, 6, 4, 1), 0, 0);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';
  /* joints */
  x.strokeStyle = 'rgba(0,0,0,.72)'; x.lineWidth = 3;
  for (let i = 0; i <= N; i++) {
    x.beginPath(); x.moveTo(i * S, 0); x.lineTo(i * S, H); x.stroke();
    x.beginPath(); x.moveTo(0, i * S); x.lineTo(W, i * S); x.stroke();
  }
  /* height map: joints recessed, grain */
  const h = cvs(W, H), hx = h.getContext('2d');
  hx.fillStyle = '#8c8c8c'; hx.fillRect(0, 0, W, H);
  hx.globalAlpha = .35; hx.drawImage(fbmCanvas(W, H, 63, 5, 8, 1), 0, 0); hx.globalAlpha = 1;
  hx.strokeStyle = '#303030'; hx.lineWidth = 5;
  for (let i = 0; i <= N; i++) {
    hx.beginPath(); hx.moveTo(i * S, 0); hx.lineTo(i * S, H); hx.stroke();
    hx.beginPath(); hx.moveTo(0, i * S); hx.lineTo(W, i * S); hx.stroke();
  }
  /* roughness: mostly glass, drier patches where the rain missed */
  const r = cvs(512, 512), rx = r.getContext('2d');
  rx.fillStyle = '#1c1c1c'; rx.fillRect(0, 0, 512, 512);
  rx.globalAlpha = .95; rx.globalCompositeOperation = 'lighten';
  rx.drawImage(fbmCanvas(512, 512, 77, 4, 3, 1.5), 0, 0);
  rx.globalAlpha = 1; rx.globalCompositeOperation = 'source-over';
  return { map: c, normal: normalFromHeight(h, 1.5), rough: r };
}

/* --------------------------------------------------- temple timber · cedar
   Boards, not a noise field. Three things make sawn wood read as wood and
   none of them is fbm:

     1 · ring geometry. A plank is a flat cut through a round log, so the
         rings meet the face as two branches either side of the pith, and
         wherever the cut runs shallow across a ring the branches close into
         the cathedral arch everyone recognises. Solving the circle for it —
         x = pith ± √(r² − d(y)²), with the cut depth d wandering down the
         board — hands over the arches for free. Drawn strokes only ever give
         stripes, which is what the first pass here did, and stripes on a
         column read as brushed metal.
     2 · the boards themselves. Joints, and a different tone on either side of
         one. A wall with no joint in it is a wall of plastic.
     3 · pores, so the face has tooth rather than a painted finish.

   Broad fbm overlays used to do the work here and they are exactly why the
   hall looked like it was standing under a cloud: at this camera distance a
   noise blotch is dirt, and dirt is not grain. Everything below is structure.

   boards:0 gives a single timber for the columns, sills and rails, which are
   each one stick of wood and must not carry a joint. */
function texWood(seed, opt) {
  const o = opt || {}, W = 512, H = 512;
  const c = cvs(W, H), x = c.getContext('2d');
  const h = cvs(W, H), hx = h.getContext('2d');
  const r = cvs(W, H), rx = r.getContext('2d');
  const rnd = mulberry32(seed || 3);
  const base = o.base || [30, 23, 19];
  x.fillStyle = hex(base[0], base[1], base[2]); x.fillRect(0, 0, W, H);
  hx.fillStyle = '#808080'; hx.fillRect(0, 0, W, H);
  rx.fillStyle = o.rough || '#d6d6d6'; rx.fillRect(0, 0, W, H);

  /* board layout — widths jittered, then normalised so the run closes on the
     tile edge and the map still wraps */
  const nb = o.boards === undefined ? 7 : o.boards;
  const cuts = [0];
  if (nb > 0) {
    const ws = []; let sum = 0;
    for (let i = 0; i < nb; i++) { const v = .70 + rnd() * .60; ws.push(v); sum += v; }
    let acc = 0;
    ws.forEach(v => { acc += v / sum * W; cuts.push(acc); });
  } else cuts.push(W);

  const stroke = (pts, ctx, style, w) => {
    if (pts.length < 2) return;
    ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
    for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
    ctx.strokeStyle = style; ctx.lineWidth = w; ctx.lineCap = 'round'; ctx.stroke();
  };

  for (let b = 0; b < cuts.length - 1; b++) {
    const x0 = cuts[b], x1 = cuts[b + 1], bw = x1 - x0;
    /* every board came off a different part of the log */
    const tone = .80 + rnd() * .44;
    const pith = x0 + bw * (rnd() * 2.8 - .9);        /* usually outside it */
    const d0 = bw * (.12 + rnd() * 1.7);              /* how deep the cut ran */
    const amp = bw * (.10 + rnd() * .40);
    const per = 1 + ((rnd() * 2) | 0), ph = rnd() * TAU;
    const dAt = y => d0 + Math.sin(y / H * TAU * per + ph) * amp;
    const gap = 2.4 + rnd() * 5.0;                    /* ring spacing */

    [x, hx, rx].forEach(d => { d.save(); d.beginPath(); d.rect(x0, 0, bw, H); d.clip(); });
    x.fillStyle = hex(base[0] * tone, base[1] * tone, base[2] * tone);
    x.fillRect(x0, 0, bw, H);

    for (let k = 1; k * gap < bw * 3.4 + d0 + amp; k++) {
      const rr = k * gap * (.88 + rnd() * .24);
      const dark = .42 + rnd() * .38, wide = .8 + rnd() * 1.9;
      for (const side of [-1, 1]) {
        let pts = [];
        const flush = () => {
          /* latewood: a narrow dark band, with the pale earlywood just inside */
          stroke(pts, x, 'rgba(0,0,0,' + dark.toFixed(2) + ')', wide);
          stroke(pts, hx, 'rgba(0,0,0,' + (dark * .8).toFixed(2) + ')', wide);
          stroke(pts.map(q => [q[0] + side * (wide + .6), q[1]]), x,
                 'rgba(150,120,96,' + (dark * .30).toFixed(2) + ')', wide * .7);
          pts = [];
        };
        for (let y = -3; y <= H + 3; y += 3) {
          const d = dAt(y), q = rr * rr - d * d;
          if (q <= 0) { flush(); continue; }
          pts.push([pith + side * Math.sqrt(q), y]);
        }
        flush();
      }
    }
    /* pores — the open tooth of cedar, elongated along the grain */
    for (let i = 0; i < 220; i++) {
      const px2 = x0 + rnd() * bw, py = rnd() * H, ln = 1.5 + rnd() * 6;
      x.fillStyle = 'rgba(0,0,0,' + (.10 + rnd() * .22) + ')';
      x.fillRect(px2, py, .7 + rnd() * .7, ln);
      hx.fillStyle = 'rgba(0,0,0,.22)'; hx.fillRect(px2, py, .8, ln);
    }
    /* the odd knot, where a branch left the trunk */
    if (rnd() > .55) {
      const kx = x0 + bw * (.2 + rnd() * .6), ky = rnd() * H, kr = 2.5 + rnd() * 5;
      for (let i = 5; i >= 1; i--) {
        const t = i / 5;
        x.strokeStyle = 'rgba(0,0,0,' + (.5 - t * .25).toFixed(2) + ')'; x.lineWidth = 1.1;
        x.beginPath(); x.ellipse(kx, ky, kr * t * 1.5, kr * t * 2.4, 0, 0, TAU); x.stroke();
        hx.strokeStyle = 'rgba(0,0,0,.30)'; hx.lineWidth = 1.1;
        hx.beginPath(); hx.ellipse(kx, ky, kr * t * 1.5, kr * t * 2.4, 0, 0, TAU); hx.stroke();
      }
    }
    /* checking: the splits a dried board opens, always along the grain */
    for (let i = 0; i < 5; i++) {
      const sx = x0 + rnd() * bw, sy = rnd() * H, ln = 20 + rnd() * 120;
      const pts = [];
      for (let y = sy; y < sy + ln; y += 6) pts.push([sx + Math.sin(y * .07) * 1.4, y]);
      stroke(pts, x, 'rgba(0,0,0,.62)', .7 + rnd() * 1.2);
      stroke(pts, hx, 'rgba(0,0,0,.55)', .7 + rnd() * 1.2);
    }
    [x, hx, rx].forEach(d => d.restore());

    /* the joint, and the board edge that catches light beside it */
    if (nb > 0) {
      x.fillStyle = 'rgba(0,0,0,.80)'; x.fillRect(x1 - 1.1, 0, 2.2, H);
      x.fillStyle = 'rgba(158,130,106,.14)'; x.fillRect(x1 + 1.1, 0, 1.1, H);
      hx.fillStyle = 'rgba(0,0,0,.85)'; hx.fillRect(x1 - 1.3, 0, 2.6, H);
      hx.fillStyle = 'rgba(255,255,255,.35)'; hx.fillRect(x1 + 1.3, 0, 1.6, H);
      rx.fillStyle = 'rgba(255,255,255,.5)'; rx.fillRect(x1 - 1.6, 0, 3.2, H);
    }
  }

  /* soot, running down the boards rather than pooling in clouds — this is the
     one weathering pass, and it follows the grain because water does */
  for (let i = 0; i < 90; i++) {
    const sx = rnd() * W, top = rnd() * H, ln = H * (.15 + rnd() * .5);
    const g = x.createLinearGradient(0, top, 0, top + ln);
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(.3, 'rgba(0,0,0,' + (.10 + rnd() * .16) + ')');
    g.addColorStop(1, 'rgba(0,0,0,0)');
    x.fillStyle = g; x.fillRect(sx, top, .8 + rnd() * 3.4, ln);
  }
  return { map: c, normal: normalFromHeight(h, o.relief || 2.4), rough: r };
}

/* ------------------------------------------------------------ cut granite
   The lanterns are carved out of one block, so this is a monolith face and
   not coursed masonry: no joints, no bond pattern. What sells it instead is
   grain and wear —

     1 · the speckle. Three mineral populations at three tones: pale feldspar,
         mid quartz, black mica. A granite without visible crystal is grey
         plastic, and it is the one thing a colour ramp cannot fake.
     2 · the chisel. Parallel bruising left by a point on a dressed face, each
         mark a dark gouge with a lit shoulder below it.
     3 · pitting and fracture, and moss keyed to *those* — moss grows where
         water sits, which is the low ground, so it is read out of the height
         field rather than dropped on as its own cloud. That cloud is what
         made this look like weather rather than stone. */
function texStone(seed, opt) {
  const o = opt || {}, W = 512, H = 512;
  const c = cvs(W, H), x = c.getContext('2d');
  const h = cvs(W, H), hx = h.getContext('2d');
  const r = cvs(W, H), rx = r.getContext('2d');
  const rnd = mulberry32(seed || 17);
  const base = o.base || [46, 51, 53];
  x.fillStyle = hex(base[0], base[1], base[2]); x.fillRect(0, 0, W, H);
  hx.fillStyle = '#808080'; hx.fillRect(0, 0, W, H);
  rx.fillStyle = '#e8e8e8'; rx.fillRect(0, 0, W, H);

  /* 1 · crystal */
  const speck = (n, fill, smin, smax, hgt) => {
    for (let i = 0; i < n; i++) {
      const s = smin + rnd() * (smax - smin), px = rnd() * W, py = rnd() * H;
      x.fillStyle = fill(rnd);
      x.beginPath(); x.ellipse(px, py, s, s * (.55 + rnd() * .85), rnd() * TAU, 0, TAU); x.fill();
      if (hgt) {
        hx.fillStyle = hgt;
        hx.beginPath(); hx.ellipse(px, py, s, s * .8, 0, 0, TAU); hx.fill();
      }
    }
  };
  speck(3400, q => 'rgba(206,210,204,' + (.06 + q() * .20) + ')', .6, 2.8, 'rgba(255,255,255,.13)');
  speck(2000, q => 'rgba(126,136,134,' + (.07 + q() * .20) + ')', .8, 3.4, null);
  speck(1300, q => 'rgba(8,10,12,'     + (.14 + q() * .38) + ')', .5, 2.4, 'rgba(0,0,0,.16)');

  /* 2 · the chisel */
  for (let i = 0; i < 220; i++) {
    const py = rnd() * H, px = rnd() * W, len = 5 + rnd() * 24, ang = -.26 + rnd() * .52;
    [[x, 'rgba(0,0,0,.22)', 'rgba(210,216,212,.08)'], [hx, 'rgba(0,0,0,.34)', 'rgba(255,255,255,.26)']]
      .forEach(([d, dk, lt]) => {
        d.save(); d.translate(px, py); d.rotate(ang);
        d.fillStyle = dk; d.fillRect(0, 0, len, 1.5);
        d.fillStyle = lt; d.fillRect(0, 1.5, len, 1.0);
        d.restore();
      });
  }
  /* 3 · pitting and fracture */
  for (let i = 0; i < 260; i++) {
    const px = rnd() * W, py = rnd() * H, rr = .8 + rnd() * rnd() * 5;
    const g = x.createRadialGradient(px - rr * .3, py - rr * .3, rr * .1, px, py, rr);
    g.addColorStop(0, 'rgba(0,0,0,.42)'); g.addColorStop(.7, 'rgba(0,0,0,.16)');
    g.addColorStop(1, 'rgba(198,204,198,.10)');
    x.fillStyle = g; x.beginPath(); x.arc(px, py, rr, 0, TAU); x.fill();
    hx.fillStyle = 'rgba(0,0,0,.40)'; hx.beginPath(); hx.arc(px, py, rr * .8, 0, TAU); hx.fill();
  }
  for (let i = 0; i < 16; i++) {
    let px = rnd() * W, py = rnd() * H, a = rnd() * TAU;
    const pts = [[px, py]];
    for (let k = 0; k < 24; k++) {
      a += (rnd() - .5) * .9; px += Math.cos(a) * 9; py += Math.sin(a) * 9;
      pts.push([px, py]);
    }
    [[x, 'rgba(0,0,0,.46)'], [hx, 'rgba(0,0,0,.55)']].forEach(([d, st]) => {
      d.beginPath(); d.moveTo(pts[0][0], pts[0][1]);
      pts.forEach(q => d.lineTo(q[0], q[1]));
      d.strokeStyle = st; d.lineWidth = .7 + rnd() * .9; d.stroke();
    });
  }

  /* moss, read out of the low ground of the height field */
  const hd = hx.getImageData(0, 0, W, H).data;
  const im = x.getImageData(0, 0, W, H), rm = rx.getImageData(0, 0, W, H);
  const amt = o.moss === undefined ? 1 : o.moss;
  for (let i = 0; i < W * H; i++) {
    const t = clamp((.5 - hd[i * 4] / 255) * 4.2, 0, 1) * amt;
    if (t <= 0) continue;
    const j = i * 4;
    im.data[j]     = lerp(im.data[j],     30, t * .75);
    im.data[j + 1] = lerp(im.data[j + 1], 44, t * .95);
    im.data[j + 2] = lerp(im.data[j + 2], 28, t * .85);
    /* moss is matte — through the same buffer, because a fillRect per pixel
       is a quarter of a million canvas calls and costs more than the rest of
       this generator put together */
    rm.data[j] = rm.data[j + 1] = rm.data[j + 2] = lerp(rm.data[j], 255, t * .5);
  }
  x.putImageData(im, 0, 0); rx.putImageData(rm, 0, 0);
  return { map: c, normal: normalFromHeight(h, o.relief || 3.2), rough: r };
}

/* ------------------------------------------- weathered vermilion · the gate
   Not a red cylinder. The gate is a painted timber that has stood out in the
   weather: the lacquer has crazed into a fine net, flaked off the edges back to
   the black wood underneath, and run in streaks where the rain came down it.
   So the wood is drawn first and the paint is laid over it at less than full
   coverage — the grain has to read *through* the red, which is what stops it
   looking like plastic. */
function texLacquer() {
  const W = 512, H = 512;
  const wood = texWood(131, { base: [34, 22, 17], boards: 0 });
  const c = cvs(W, H), x = c.getContext('2d');
  const h = cvs(W, H), hx = h.getContext('2d');
  const r = cvs(W, H), rx = r.getContext('2d');
  const rnd = mulberry32(5);
  x.drawImage(wood.map, 0, 0);
  hx.fillStyle = '#808080'; hx.fillRect(0, 0, W, H);
  rx.fillStyle = '#8c8c8c'; rx.fillRect(0, 0, W, H);   /* lacquer half-holds a sheen */

  /* the coat */
  x.globalAlpha = .80; x.fillStyle = '#7c1610'; x.fillRect(0, 0, W, H);
  x.globalAlpha = 1;
  /* pull the grain back through it */
  x.globalCompositeOperation = 'multiply'; x.globalAlpha = .42;
  x.drawImage(wood.map, 0, 0);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';
  /* uneven pigment: the brush left it thicker in places */
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .5;
  x.drawImage(fbmCanvas(W, H, 313, 5, 3, 1), 0, 0);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* craquelure — a wandering net of hairline cracks in the paint film */
  for (let i = 0; i < 190; i++) {
    let px = rnd() * W, py = rnd() * H, a = rnd() * TAU;
    x.strokeStyle = 'rgba(24,8,6,' + (.28 + rnd() * .4) + ')';
    hx.strokeStyle = 'rgba(0,0,0,.42)';
    x.lineWidth = hx.lineWidth = .5 + rnd() * .7;
    x.beginPath(); hx.beginPath(); x.moveTo(px, py); hx.moveTo(px, py);
    for (let k = 0; k < 5 + rnd() * 9; k++) {
      a += (rnd() - .5) * 1.5; px += Math.cos(a) * (5 + rnd() * 9); py += Math.sin(a) * (5 + rnd() * 9);
      x.lineTo(px, py); hx.lineTo(px, py);
    }
    x.stroke(); hx.stroke();
  }

  /* flaking: islands where the coat has lifted, showing the burnt wood, each
     with the pale shoulder of paint still standing at its edge */
  for (let i = 0; i < 40; i++) {
    const px = rnd() * W, py = rnd() * H, rad = 2 + rnd() * rnd() * 17;
    const pts = [];
    for (let k = 0; k < 9; k++) {
      const a = k / 9 * TAU, rr = rad * (.55 + rnd() * .75);
      pts.push([px + Math.cos(a) * rr, py + Math.sin(a) * rr]);
    }
    const path = d => {
      d.beginPath(); d.moveTo(pts[0][0], pts[0][1]);
      for (let k = 1; k < pts.length; k++) {
        const p = pts[k], q = pts[(k + 1) % pts.length];
        d.quadraticCurveTo(p[0], p[1], (p[0] + q[0]) / 2, (p[1] + q[1]) / 2);
      }
      d.closePath();
    };
    x.save(); path(x); x.clip(); x.drawImage(wood.map, 0, 0);
    x.fillStyle = 'rgba(0,0,0,.22)'; x.fillRect(0, 0, W, H); x.restore();
    path(x); x.strokeStyle = 'rgba(196,110,76,.30)'; x.lineWidth = 1.2; x.stroke();
    path(hx); hx.fillStyle = 'rgba(0,0,0,.34)'; hx.fill();
    hx.strokeStyle = 'rgba(255,255,255,.30)'; hx.lineWidth = 1.6; hx.stroke();
    path(rx); rx.fillStyle = 'rgba(240,240,240,.75)'; rx.fill();   /* bare wood, matte */
  }

  /* rain running down the column */
  for (let i = 0; i < 120; i++) {
    const sx = rnd() * W, w = .6 + rnd() * 2.6, top = rnd() * H, len = H * (.2 + rnd() * .6);
    const g = x.createLinearGradient(0, top, 0, top + len);
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(.3, rnd() > .5 ? 'rgba(12,4,4,.24)' : 'rgba(210,150,120,.05)');
    g.addColorStop(1, 'rgba(0,0,0,0)');
    x.fillStyle = g; x.fillRect(sx, top, w, len);
  }
  rx.globalAlpha = .6; rx.globalCompositeOperation = 'multiply';
  rx.drawImage(fbmCanvas(W, H, 401, 4, 6, 1.2), 0, 0);
  rx.globalAlpha = 1; rx.globalCompositeOperation = 'source-over';
  return { map: c, normal: normalFromHeight(h, 2.2), rough: r };
}

/* the shoji screen the sun burns through */
function texShoji() {
  const W = 1024, H = 768, c = cvs(W, H), x = c.getContext('2d');
  x.clearRect(0, 0, W, H);
  x.fillStyle = 'rgba(228,222,206,.055)'; x.fillRect(0, 0, W, H);
  x.strokeStyle = 'rgba(10,8,7,.88)';
  const cols = 12, rows = 9;
  x.lineWidth = 5;
  for (let i = 1; i < cols; i++) { x.beginPath(); x.moveTo(W / cols * i, 0); x.lineTo(W / cols * i, H); x.stroke(); }
  for (let j = 1; j < rows; j++) { x.beginPath(); x.moveTo(0, H / rows * j); x.lineTo(W, H / rows * j); x.stroke(); }
  x.lineWidth = 13; x.strokeStyle = 'rgba(8,6,5,.95)';
  x.strokeRect(0, 0, W, H);
  x.beginPath(); x.moveTo(W / 2, 0); x.lineTo(W / 2, H); x.stroke();
  return c;
}

/* one maple leaf, white on transparent — tinted per instance */
function texLeaf() {
  const S = 128, c = cvs(S, S), x = c.getContext('2d');
  x.translate(S / 2, S * .92); x.scale(S / 2.2, -S / 2.2);
  x.beginPath();
  const lobes = 5, spread = 1.9;
  for (let i = 0; i < lobes; i++) {
    const a = -spread / 2 + spread * (i / (lobes - 1)) + Math.PI / 2;
    const len = i === 2 ? .96 : (i === 1 || i === 3 ? .82 : .60);
    const wob = .17;
    x.moveTo(0, .02);
    x.lineTo(Math.cos(a - wob) * len * .55, Math.sin(a - wob) * len * .55);
    x.lineTo(Math.cos(a) * len, Math.sin(a) * len);
    x.lineTo(Math.cos(a + wob) * len * .55, Math.sin(a + wob) * len * .55);
    x.closePath();
  }
  x.fillStyle = '#fff'; x.fill();
  x.lineWidth = .05; x.strokeStyle = '#fff'; x.stroke();
  x.setTransform(1, 0, 0, 1, 0, 0);
  x.globalCompositeOperation = 'destination-out';
  const rnd = mulberry32(3);
  for (let i = 0; i < 40; i++) { x.beginPath(); x.arc(rnd() * S, rnd() * S, rnd() * 3, 0, TAU); x.fill(); }
  return c;
}

/* the night sky itself: black at altitude, going to the fog colour at the
   horizon so the backdrop and the depth cue meet without a seam */
function texSky() {
  const W = 512, H = 512, c = cvs(W, H), x = c.getContext('2d');
  const g = x.createLinearGradient(0, 0, 0, H);
  g.addColorStop(0, 'rgb(6,10,15)');    g.addColorStop(.34, 'rgb(13,22,31)');
  g.addColorStop(.66, 'rgb(17,26,34)'); g.addColorStop(.88, 'rgb(24,35,42)');
  g.addColorStop(1, 'rgb(14,22,28)');
  x.fillStyle = g; x.fillRect(0, 0, W, H);
  /* cloud, and a low warm bloom off the valley behind the ridge */
  x.globalAlpha = .34; x.globalCompositeOperation = 'overlay';
  x.drawImage(fbmCanvas(W, H, 313, 5, 3, .9), 0, 0);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';
  const wg = x.createRadialGradient(W * .68, H * .95, 4, W * .68, H * .95, W * .44);
  wg.addColorStop(0, 'rgba(150,66,26,.30)'); wg.addColorStop(.5, 'rgba(96,44,22,.12)');
  wg.addColorStop(1, 'rgba(0,0,0,0)');
  x.fillStyle = wg; x.fillRect(0, 0, W, H);
  /* a scatter of stars, faint enough to survive the bloom */
  const rnd = mulberry32(881);
  for (let i = 0; i < 420; i++) {
    const sx = rnd() * W, sy = rnd() * H * .78, r = .5 + rnd() * rnd() * 1.7;
    x.fillStyle = 'rgba(214,232,240,' + (.12 + rnd() * .42) * (1 - sy / H) + ')';
    x.beginPath(); x.arc(sx, sy, r, 0, TAU); x.fill();
  }
  return c;
}

/* the wooded ridge the valley closes with — black, ragged, mostly fog */
function texRidge() {
  const W = 2048, H = 512, c = cvs(W, H), x = c.getContext('2d');
  const n = noise2D(1207), rnd = mulberry32(1207);
  x.beginPath(); x.moveTo(0, H);
  for (let i = 0; i <= W; i += 4) {
    const t = i / W;
    const ridge = .46 + .30 * (fbm(n, t * 2.4, .5, 4, 2.1, .55) * .5 + .5)
                      + .16 * (fbm(n, t * 7.5, 3.1, 3, 2.2, .5) * .5 + .5);
    x.lineTo(i, H - ridge * H * .84);
  }
  x.lineTo(W, H); x.closePath();
  x.fillStyle = '#050809'; x.fill();
  /* conifer spikes along the crest so the silhouette reads as forest */
  x.fillStyle = '#050809';
  for (let i = 0; i < 460; i++) {
    const px = rnd() * W, t = px / W;
    const ridge = .46 + .30 * (fbm(n, t * 2.4, .5, 4, 2.1, .55) * .5 + .5)
                      + .16 * (fbm(n, t * 7.5, 3.1, 3, 2.2, .5) * .5 + .5);
    const by = H - ridge * H * .84, hh = 8 + rnd() * 30, ww = 3 + rnd() * 6;
    x.beginPath(); x.moveTo(px, by - hh); x.lineTo(px + ww, by + 4); x.lineTo(px - ww, by + 4);
    x.closePath(); x.fill();
  }
  return c;
}

/* glazed roof tile — half-round caps running down the slope, weathered */
function texRoof() {
  const W = 512, H = 512, c = cvs(W, H), x = c.getContext('2d');
  const h = cvs(W, H), hx = h.getContext('2d');
  x.fillStyle = '#151c20'; x.fillRect(0, 0, W, H);
  hx.fillStyle = '#606060'; hx.fillRect(0, 0, W, H);
  const ribs = 14, s = W / ribs;
  for (let i = 0; i < ribs; i++) {
    const g = x.createLinearGradient(i * s, 0, (i + 1) * s, 0);
    g.addColorStop(0, 'rgba(0,0,0,.62)');  g.addColorStop(.30, 'rgba(148,178,192,.13)');
    g.addColorStop(.66, 'rgba(84,110,124,.05)'); g.addColorStop(1, 'rgba(0,0,0,.62)');
    x.fillStyle = g; x.fillRect(i * s, 0, s, H);
    x.fillStyle = 'rgba(0,0,0,.50)'; x.fillRect(i * s - 3, 0, 6, H);
    x.fillStyle = 'rgba(168,196,208,.09)'; x.fillRect(i * s - 1.2, 0, 1.6, H);
    /* the half-round cap itself, as height: the valley between two tiles is
       the only reason a tiled roof reads as tiled at this distance */
    const hg = hx.createLinearGradient(i * s, 0, (i + 1) * s, 0);
    hg.addColorStop(0, '#2c2c2c'); hg.addColorStop(.5, '#eaeaea'); hg.addColorStop(1, '#2c2c2c');
    hx.fillStyle = hg; hx.fillRect(i * s, 0, s, H);
  }
  /* the courses running down the slope — each tile overlaps the one below */
  const rnd = mulberry32(211);
  for (let y = 0; y < H; y += H / 9) {
    x.fillStyle = 'rgba(0,0,0,.34)'; x.fillRect(0, y, W, 3.5);
    hx.fillStyle = 'rgba(0,0,0,.55)'; hx.fillRect(0, y, W, 3.5);
    hx.fillStyle = 'rgba(255,255,255,.28)'; hx.fillRect(0, y + 3.5, W, 2);
    for (let i = 0; i < 26; i++) {                     /* chipped, mossed edges */
      x.fillStyle = rnd() > .5 ? 'rgba(0,0,0,.30)' : 'rgba(96,116,92,.10)';
      x.fillRect(rnd() * W, y + 1, 2 + rnd() * 9, 2 + rnd() * 3);
    }
  }
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .46;
  x.drawImage(fbmCanvas(256, 256, 211, 5, 3, 1), 0, 0, W, H);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';
  hx.globalAlpha = .3; hx.drawImage(fbmCanvas(256, 256, 213, 4, 16, 1.2), 0, 0, W, H);
  hx.globalAlpha = 1;
  return { map: c, normal: normalFromHeight(h, 2.2) };
}

/* -------------------------------------------------------- the blood moon
   A real lunar disc, not a glow sprite and not a noise field. Three things do
   the work, in this order of importance:

     1 · the maria. The dark seas are the Moon's signature and they are not
         fbm — they are a handful of large, lobed, soft-edged basins in a
         layout everyone has known since childhood. Drawn at their real
         near-side positions and blurred, they are what makes the disc read as
         the Moon at a glance instead of as a planet.
     2 · the ray systems. Fine bright streaks thrown out of Tycho and
         Copernicus, straight across the maria. Nothing else on the surface
         looks like this, so nothing else identifies it as fast.
     3 · the craters, thickest in the bright highlands, each with a rim lit
         from the same side as the rest of the frame.

   The tint is measured off the reference plate, not invented: a blood moon
   sits at G/R ≈ B/R ≈ .48 — a rose red with green and blue level. Grading it
   as an orange (blue well under green) is what makes a CG moon read as a
   fireball, and that is what this was doing. The disc is authored here in
   near-neutral albedo and the colour is applied once, on the material. */
function texMoon() {
  const S = 512, c = cvs(S, S), x = c.getContext('2d');
  const R = S / 2 - 1, rnd = mulberry32(91);
  const px = (u, v) => [S / 2 + u * R, S / 2 + v * R];        /* disc coords */

  x.beginPath(); x.arc(S / 2, S / 2, R, 0, TAU); x.closePath();
  x.save(); x.clip();

  /* highland base. The reference disc is very slightly brighter at the limb,
     not darker — during totality the rim keeps its forward scatter — so this
     ramp opens outward instead of falling off. */
  const g = x.createRadialGradient(S * .46, S * .44, S * .05, S / 2, S / 2, R);
  g.addColorStop(0, 'rgb(150,150,150)'); g.addColorStop(.55, 'rgb(158,158,158)');
  g.addColorStop(.86, 'rgb(178,178,178)'); g.addColorStop(1, 'rgb(196,196,196)');
  x.fillStyle = g; x.fillRect(0, 0, S, S);

  /* fine highland mottle */
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .5;
  x.drawImage(fbmCanvas(256, 256, 517, 6, 4, 1.1), 0, 0, S, S);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* --- 1 · the maria, at their near-side places (u right, v down) --------- */
  const seas = [
    [-.52, -.06, .46, .80],   /* Oceanus Procellarum */
    [-.26, -.38, .31, .92],   /* Imbrium             */
    [ .13, -.31, .20, .88],   /* Serenitatis         */
    [ .30, -.08, .23, .84],   /* Tranquillitatis     */
    [ .45,  .12, .15, .78],   /* Fecunditatis        */
    [ .27,  .27, .12, .74],   /* Nectaris            */
    [ .57, -.30, .12, .95],   /* Crisium, the one that stands on its own */
    [-.27,  .30, .19, .70],   /* Nubium              */
    [-.47,  .25, .13, .72],   /* Humorum             */
  ];
  const sea = cvs(S, S), sx = sea.getContext('2d');
  seas.forEach(([u, v, rad, dk]) => {
    /* a basin is a cluster of lobes, never a circle */
    for (let i = 0; i < 22; i++) {
      const a = rnd() * TAU, off = rnd() * rad * .66;
      const [bx, by] = px(u + Math.cos(a) * off, v + Math.sin(a) * off * .8);
      const rr = rad * R * (.30 + rnd() * .46);
      const bg = sx.createRadialGradient(bx, by, rr * .2, bx, by, rr);
      bg.addColorStop(0, 'rgba(0,0,0,' + (dk * .14).toFixed(3) + ')');
      bg.addColorStop(1, 'rgba(0,0,0,0)');
      sx.fillStyle = bg; sx.beginPath(); sx.arc(bx, by, rr, 0, TAU); sx.fill();
    }
  });
  /* a sea is dark, not black: the real mare/highland contrast is about 4:3,
     and anything heavier turns the disc into a skull */
  x.save(); x.filter = 'blur(9px)'; x.globalAlpha = .90;
  x.drawImage(sea, 0, 0); x.restore();
  /* mottle riding on top of the basins so their floors are not flat washes */
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .18;
  x.drawImage(fbmCanvas(256, 256, 811, 4, 11, 1.2), 0, 0, S, S);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* --- 2 · ray systems, thrown clear across the disc ---------------------- */
  const ray = cvs(S, S), rx2 = ray.getContext('2d');
  [[-.10, .54, 150, 1], [-.28, -.07, 80, .6], [-.46, -.04, 60, .45]].forEach(([u, v, n, str]) => {
    const [ox, oy] = px(u, v);
    for (let i = 0; i < n; i++) {
      const a = rnd() * TAU, len = R * (.30 + rnd() * rnd() * 1.3);
      /* start clear of the crater — rays converging on a point read as a
         lens flare, and the real ones begin outside the ejecta blanket */
      const t0 = R * (.08 + rnd() * .06);
      const p0 = [ox + Math.cos(a) * t0, oy + Math.sin(a) * t0];
      const p1 = [ox + Math.cos(a) * len, oy + Math.sin(a) * len];
      const rg = rx2.createLinearGradient(p0[0], p0[1], p1[0], p1[1]);
      rg.addColorStop(0, 'rgba(255,255,255,' + (.085 * str).toFixed(3) + ')');
      rg.addColorStop(.4, 'rgba(255,255,255,' + (.055 * str).toFixed(3) + ')');
      rg.addColorStop(1, 'rgba(255,255,255,0)');
      rx2.strokeStyle = rg; rx2.lineWidth = .8 + rnd() * 2.4; rx2.lineCap = 'round';
      rx2.beginPath(); rx2.moveTo(p0[0], p0[1]);
      rx2.quadraticCurveTo(ox + Math.cos(a + .06) * len * .55, oy + Math.sin(a + .06) * len * .55,
                           p1[0], p1[1]);
      rx2.stroke();
    }
  });
  x.save(); x.filter = 'blur(2.4px)'; x.globalCompositeOperation = 'lighter';
  x.globalAlpha = .62; x.drawImage(ray, 0, 0); x.restore();

  /* --- 3 · the crater field ----------------------------------------------
     A crater is not a ring. Stroked with a gradient running across it, the
     same circle gives a rim lit on the sun side and thrown into shadow on the
     other — which is the read — where a radial ring gives a bubble. */
  const inSea = (u, v) => seas.some(([su, sv, rad]) =>
    Math.hypot(u - su, (v - sv) * 1.15) < rad * .82);
  for (let i = 0; i < 620; i++) {
    const a = rnd() * TAU, rr = Math.sqrt(rnd()) * .97;
    const u = Math.cos(a) * rr, v = Math.sin(a) * rr;
    /* the seas are young and nearly unmarked — that contrast is the point */
    if (inSea(u, v) && rnd() > .12) continue;
    const [cx2, cy] = px(u, v);
    const big = rnd() > .975;
    const r = (1 + rnd() * rnd() * rnd() * (big ? 34 : 11)) * (S / 512);
    const fade = .55 + .45 * Math.sqrt(Math.max(0, 1 - rr * rr));   /* limb falloff */
    /* foreshortened toward the limb, like anything on a sphere */
    const sq = Math.sqrt(Math.max(0, 1 - rr * rr)) * .72 + .28;
    x.save(); x.translate(cx2, cy); x.rotate(Math.atan2(v, u)); x.scale(sq, 1);
    x.rotate(-Math.atan2(v, u));                       /* keep the sun direction */
    const rimW = Math.max(.8, r * .26);
    const lg = x.createLinearGradient(-r, -r, r, r);   /* sun sits up-left */
    lg.addColorStop(0, 'rgba(255,255,255,' + (.34 * fade).toFixed(3) + ')');
    lg.addColorStop(.5, 'rgba(255,255,255,0)');
    lg.addColorStop(1, 'rgba(0,0,0,' + (.38 * fade).toFixed(3) + ')');
    x.strokeStyle = lg; x.lineWidth = rimW;
    x.beginPath(); x.arc(0, 0, Math.max(.6, r - rimW * .5), 0, TAU); x.stroke();
    if (r > 3) {                                        /* the floor it encloses */
      const fg = x.createLinearGradient(-r, -r, r, r);
      fg.addColorStop(0, 'rgba(0,0,0,' + (.21 * fade).toFixed(3) + ')');
      fg.addColorStop(1, 'rgba(255,255,255,' + (.07 * fade).toFixed(3) + ')');
      x.fillStyle = fg; x.beginPath(); x.arc(0, 0, r - rimW, 0, TAU); x.fill();
    }
    if (r > 13) {                                       /* central peak */
      x.fillStyle = 'rgba(255,255,255,' + (.14 * fade).toFixed(3) + ')';
      x.beginPath(); x.arc(-r * .04, -r * .04, r * .11, 0, TAU); x.fill();
    }
    x.restore();
  }

  /* surface tooth: genuinely fine, or it just adds another layer of cloud
     and the basins disappear under it */
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .13;
  x.drawImage(fbmCanvas(S, S, 977, 2, 210, 1.3), 0, 0, S, S);
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* the terminator side: the disc is not lit dead-on */
  const sh = x.createRadialGradient(S * .40, S * .38, S * .18, S * .52, S * .56, S * .72);
  sh.addColorStop(0, 'rgba(0,0,0,0)'); sh.addColorStop(1, 'rgba(0,0,0,.30)');
  x.fillStyle = sh; x.fillRect(0, 0, S, S);
  x.restore();

  /* a one-pixel feather on the limb so it is a moon in air, not a cut disc */
  const fe = x.createRadialGradient(S / 2, S / 2, R - 2.5, S / 2, S / 2, R);
  fe.addColorStop(0, 'rgba(0,0,0,1)'); fe.addColorStop(1, 'rgba(0,0,0,0)');
  x.globalCompositeOperation = 'destination-in';
  x.fillStyle = fe; x.fillRect(0, 0, S, S);
  x.globalCompositeOperation = 'source-over';
  return c;
}

/* soft round falloff — haze, embers, glows */
function texGlow(inner, mid) {
  const S = 256, c = cvs(S, S), x = c.getContext('2d');
  const g = x.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
  g.addColorStop(0, inner || 'rgba(255,255,255,1)');
  g.addColorStop(.28, mid || 'rgba(255,255,255,.36)');
  g.addColorStop(.62, 'rgba(255,255,255,.07)');
  g.addColorStop(1, 'rgba(255,255,255,0)');
  x.fillStyle = g; x.fillRect(0, 0, S, S);
  return c;
}

/* one wisp — a round mote, not a streak. The whole read is a hard bright
   point sitting in a soft halo, so the falloff is deliberately uneven: almost
   all of the light is spent inside the first eighth of the radius and the rest
   is a wide, very faint bloom. A smooth gradient across the full radius gives
   an evenly lit blob with no centre to it. */
function texWisp() {
  const S = 128, c = cvs(S, S), x = c.getContext('2d');
  const g = x.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
  g.addColorStop(0,   'rgba(255,255,255,1)');      /* the point itself */
  g.addColorStop(.07, 'rgba(236,250,250,.92)');
  g.addColorStop(.16, 'rgba(190,230,238,.40)');
  g.addColorStop(.34, 'rgba(132,192,212,.13)');
  g.addColorStop(.62, 'rgba(88,146,172,.035)');
  g.addColorStop(1,   'rgba(70,120,142,0)');
  x.fillStyle = g; x.fillRect(0, 0, S, S);
  return c;
}

/* ---------------------------------------------- 3 · the foreground cut-outs
   These are the "transparent PNG" layers: painted at high resolution with
   a ragged alpha edge, then hung in front of the type as real geometry so
   they parallax and sway.                                                */
function texGrassCutout(seed, opt) {
  opt = opt || {};
  const W = opt.w || 2048, H = opt.h || 1024;
  const c = cvs(W, H), x = c.getContext('2d');
  const rnd = mulberry32(seed), n = noise2D(seed * 13 + 5);

  /* --- silhouette ------------------------------------------------- */
  const crest = opt.crest !== undefined ? opt.crest : .46;
  const peak  = opt.peak  !== undefined ? opt.peak  : .60;
  const wide  = opt.wide  !== undefined ? opt.wide  : .40;
  const prof = new Float32Array(W);
  for (let i = 0; i < W; i++) {
    const t = i / W;
    let m = Math.exp(-Math.pow((t - crest) / wide, 2) * 2.1);
    m += .46 * Math.exp(-Math.pow((t - crest - (opt.crest2 || .40)) / (wide * .62), 2) * 3.1);
    m += .30 * Math.exp(-Math.pow((t - crest + (opt.crest3 || .46)) / (wide * .70), 2) * 3.4);
    const g = fbm(n, t * 4.2, .5, 4, 2.05, .52) * .5 + .5;
    prof[i] = m * (.80 + .38 * g);
  }
  let pk = 0; for (let i = 0; i < W; i++) pk = Math.max(pk, prof[i]);
  for (let i = 0; i < W; i++) prof[i] *= H * peak / pk;
  const surf = i => H - prof[clamp(i | 0, 0, W - 1)];

  /* --- body ------------------------------------------------------- */
  x.beginPath(); x.moveTo(0, H);
  for (let i = 0; i < W; i += 3) x.lineTo(i, surf(i));
  x.lineTo(W, surf(W - 1)); x.lineTo(W, H); x.closePath();
  const bg = x.createLinearGradient(0, H - H * peak, 0, H);
  bg.addColorStop(0, '#1a2416'); bg.addColorStop(.38, '#0e150c'); bg.addColorStop(1, '#040604');
  x.fillStyle = bg; x.fill();
  x.save(); x.clip();
  x.globalCompositeOperation = 'overlay'; x.globalAlpha = .5;
  x.drawImage(fbmCanvas(1024, 512, seed + 3, 5, 3, 1), 0, 0, W, H);
  x.restore();
  x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';

  /* --- blades ----------------------------------------------------- */
  const LIGHT = opt.light || [-.42, -.91];            /* light comes from up-left */
  const N = opt.blades || 15000;
  const blades = [];
  for (let k = 0; k < N; k++) {
    const i = (rnd() * W) | 0;
    const s = surf(i);
    /* bias the population toward the silhouette so the edge stays ragged */
    const depth = Math.pow(rnd(), 2.3);
    const by = s + depth * (H - s) + (rnd() - .5) * 6;
    if (by > H + 20) continue;
    blades.push({ i: i, by: by, depth: depth, r: rnd(), r2: rnd(), r3: rnd() });
  }
  blades.sort((a, b) => a.by - b.by);                 /* far (high) first */

  const LEN = (opt.len || 46) * (W / 2048);
  for (let k = 0; k < blades.length; k++) {
    const b = blades[k];
    const bx = b.i + (b.r - .5) * 5;
    const grow = 1 - .52 * b.depth;
    const len = LEN * (.36 + 1.05 * b.r2 * b.r2) * grow;
    let lean = (b.r3 - .5) * 1.5 + (opt.wind || .16);
    /* blades on the sunward flank lean into frame */
    const tipx = bx + lean * len * .95, tipy = b.by - len;
    const cx2 = bx + lean * len * .30, cy2 = b.by - len * .62;
    const w = (.9 + 1.9 * b.r) * grow * (W / 2048);

    /* shade: facing the key light, plus exposure to the sky at the crest */
    const dx = tipx - bx, dy = tipy - b.by, il = 1 / Math.hypot(dx, dy);
    const ndl = sat((-(dx * il) * LIGHT[0] - (dy * il) * LIGHT[1]) * .5 + .5);
    const open = Math.pow(1 - b.depth, 1.35);
    let l = .10 + .46 * open + .34 * ndl * open;
    const warm = sat(open * 1.25 - .42) * (opt.warm !== undefined ? opt.warm : 1);
    const r = (10 + 78 * l + 44 * warm) * (opt.tintR || 1);
    const g = (16 + 106 * l + 24 * warm) * (opt.tintG || 1);
    const bl = (12 + 80 * l + 16 * warm) * (opt.tintB || 1);
    x.fillStyle = hex(r, g, bl);
    x.beginPath();
    x.moveTo(bx - w, b.by);
    x.quadraticCurveTo(cx2 - w * .35, cy2, tipx, tipy);
    x.quadraticCurveTo(cx2 + w * .35, cy2, bx + w, b.by);
    x.closePath(); x.fill();
  }

  /* --- grade: red bounce from the sun, cold sky on the crest ------- */
  x.globalCompositeOperation = 'source-atop';
  const rb = x.createLinearGradient(opt.bounceFrom || W, 0, opt.bounceTo || 0, 0);
  rb.addColorStop(0, 'rgba(180,40,16,.24)'); rb.addColorStop(.55, 'rgba(140,32,14,.07)'); rb.addColorStop(1, 'rgba(0,0,0,0)');
  x.fillStyle = rb; x.fillRect(0, 0, W, H);
  const sk = x.createLinearGradient(0, H - H * peak * 1.05, 0, H);
  sk.addColorStop(0, 'rgba(146,182,180,.13)'); sk.addColorStop(.5, 'rgba(0,0,0,0)'); sk.addColorStop(1, 'rgba(0,0,0,.70)');
  x.fillStyle = sk; x.fillRect(0, 0, W, H);
  x.globalCompositeOperation = 'source-over';
  return c;
}

/* wet foreground boulders */
function texRockCutout(seed, opt) {
  opt = opt || {};
  const W = opt.w || 1536, H = opt.h || 1024;
  const c = cvs(W, H), x = c.getContext('2d');
  const rnd = mulberry32(seed), n = noise2D(seed * 7 + 11);
  const blobs = opt.blobs || [[.30, .70, .40, .34], [.66, .82, .38, .28], [.46, .96, .52, .34]];

  blobs.forEach((b, bi) => {
    const cx2 = b[0] * W, cy = b[1] * H, rx = b[2] * W * .5, ry = b[3] * H * .8;
    x.beginPath();
    for (let a = 0; a <= 128; a++) {
      const t = a / 128 * TAU;
      const k = 1 + .17 * fbm(n, Math.cos(t) * 1.7 + bi * 9, Math.sin(t) * 1.7, 4, 2.1, .55);
      const px = cx2 + Math.cos(t) * rx * k, py = cy + Math.sin(t) * ry * k;
      a === 0 ? x.moveTo(px, py) : x.lineTo(px, py);
    }
    x.closePath();
    const g = x.createLinearGradient(cx2 - rx, cy - ry, cx2 + rx * .4, cy + ry);
    const s = 1 - bi * .16;
    g.addColorStop(0, hex(30 * s, 39 * s, 40 * s));
    g.addColorStop(.42, hex(14 * s, 19 * s, 20 * s));
    g.addColorStop(1, hex(6, 9, 9));
    x.fillStyle = g; x.fill();
    x.save(); x.clip();
    x.globalCompositeOperation = 'overlay'; x.globalAlpha = .62;
    x.drawImage(fbmCanvas(768, 512, seed + bi * 31, 6, 4, 1.1), 0, 0, W, H);
    x.globalAlpha = 1; x.globalCompositeOperation = 'source-over';
    /* facets */
    for (let f = 0; f < 26; f++) {
      x.beginPath();
      const fx = cx2 + (rnd() - .5) * rx * 2, fy = cy + (rnd() - .5) * ry * 1.6;
      x.moveTo(fx, fy);
      for (let v = 0; v < 3; v++) x.lineTo(fx + (rnd() - .5) * rx * .8, fy + (rnd() - .5) * ry * .6);
      x.closePath();
      x.fillStyle = rnd() > .5 ? 'rgba(255,255,255,.030)' : 'rgba(0,0,0,.14)';
      x.fill();
    }
    /* wet highlight along the upper rim */
    x.lineWidth = 3 + rnd() * 4; x.strokeStyle = 'rgba(178,206,206,.20)';
    x.beginPath();
    for (let a = 0; a <= 60; a++) {
      const t = Math.PI + a / 60 * Math.PI * .78;
      const k = 1 + .17 * fbm(n, Math.cos(t) * 1.7 + bi * 9, Math.sin(t) * 1.7, 4, 2.1, .55);
      const px = cx2 + Math.cos(t) * rx * k * .97, py = cy + Math.sin(t) * ry * k * .97;
      a === 0 ? x.moveTo(px, py) : x.lineTo(px, py);
    }
    x.stroke();
    x.restore();
  });

  /* a little moss where they meet the ground */
  x.globalCompositeOperation = 'source-atop';
  for (let i = 0; i < 2600; i++) {
    const px = rnd() * W, py = H - Math.pow(rnd(), 1.7) * H * .5;
    x.fillStyle = 'rgba(' + (40 + rnd() * 40 | 0) + ',' + (60 + rnd() * 46 | 0) + ',34,' + (.05 + rnd() * .22) + ')';
    x.fillRect(px, py, 1.6 + rnd() * 2.4, 3 + rnd() * 9);
  }
  const rb = x.createLinearGradient(W, 0, 0, 0);
  rb.addColorStop(0, 'rgba(190,48,22,.16)'); rb.addColorStop(.7, 'rgba(0,0,0,0)');
  x.fillStyle = rb; x.fillRect(0, 0, W, H);
  x.globalCompositeOperation = 'source-over';
  return c;
}

/* a maple bough leaning into the top of frame */
function texBranchCutout(seed) {
  const W = 1536, H = 1024;
  const c = cvs(W, H), x = c.getContext('2d');
  const rnd = mulberry32(seed);
  const leaves = [];

  function bough(px, py, ang, len, wid, depth) {
    const steps = 7;
    let cx2 = px, cy = py, a = ang;
    x.beginPath(); x.moveTo(cx2, cy);
    for (let s = 0; s < steps; s++) {
      a += (rnd() - .5) * .30;
      cx2 += Math.cos(a) * len / steps; cy += Math.sin(a) * len / steps;
      x.lineTo(cx2, cy);
    }
    x.lineCap = 'round'; x.lineWidth = wid;
    x.strokeStyle = 'rgba(' + (26 + depth * 5 | 0) + ',' + (22 + depth * 4 | 0) + ',' + (22 + depth * 4 | 0) + ',1)';
    x.stroke();
    if (depth < 4 && len > 34) {
      const k = depth === 0 ? 3 : 2;
      for (let i = 0; i < k; i++) bough(cx2, cy, a + (rnd() - .5) * 1.25, len * (.58 + rnd() * .18), wid * .58, depth + 1);
    } else {
      for (let i = 0; i < 9; i++)
        leaves.push([cx2 + (rnd() - .5) * 78, cy + (rnd() - .5) * 78, rnd() * TAU, 12 + rnd() * 20, rnd()]);
    }
  }
  bough(W * 1.02, H * .06, Math.PI * .78, 420, 26, 0);
  bough(W * .86, -H * .02, Math.PI * .62, 330, 18, 1);

  const leafImg = texLeaf();
  const tint = cvs(128, 128), tx = tint.getContext('2d');
  leaves.forEach(l => {
    tx.clearRect(0, 0, 128, 128);
    tx.drawImage(leafImg, 0, 0);
    tx.globalCompositeOperation = 'source-in';
    const v = l[4];
    tx.fillStyle = hex(96 + v * 96, 14 + v * 22, 16 + v * 18);
    tx.fillRect(0, 0, 128, 128);
    tx.globalCompositeOperation = 'source-over';
    x.save(); x.translate(l[0], l[1]); x.rotate(l[2]);
    x.globalAlpha = .78 + v * .22;
    x.drawImage(tint, -l[3], -l[3], l[3] * 2, l[3] * 2);
    x.restore();
  });
  x.globalAlpha = 1;
  return c;
}
/* ================================================================= 4 · gl */
const canvas = document.getElementById('gl');
let renderer, scene, camera, maxAniso = 1;
const HI = qs('q', COARSE ? 'low' : 'high');
const LOW = HI === 'low';
const WANT_POST    = qs('post', '1') !== '0';
const WANT_SHADOW  = qs('shadow', LOW ? '0' : '1') !== '0';
const DPR_CAP      = qn('dpr', LOW ? 1.4 : 1.8);
/* the renderer trades resolution for frame rate on its own — the scene is
   fill-bound (five big alpha-blended veils plus a bloom chain), so pixels
   are the only knob worth turning on unknown hardware */
const PERF = { scale: 1, acc: 0, n: 0, locked: qs('adapt', '1') === '0' };

function initGL() {
  if (qs('nogl', '0') !== '0' || !window.THREE) throw new Error('webgl disabled');
  renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: !WANT_POST, alpha: false, powerPreference: 'high-performance' });
  renderer.setPixelRatio(Math.min(devicePixelRatio || 1, DPR_CAP));
  renderer.setSize(innerWidth, innerHeight, false);
  renderer.outputEncoding = WANT_POST ? THREE.LinearEncoding : THREE.sRGBEncoding;
  renderer.toneMapping = WANT_POST ? THREE.NoToneMapping : THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.0;
  renderer.setClearColor(0x05070a, 1);
  if (WANT_SHADOW) { renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; }
  maxAniso = renderer.capabilities.getMaxAnisotropy();
  scene = new THREE.Scene();
    /* Density puts the hall back in the air; the colour is what decides how
     dark it is back there. At this depth most of the building's pixel is
     fog rather than its own material — darkening the timber and the tiles
     moved the hall by 0.18 of a luminance unit, the fog colour moves it
     properly. And it reaches by distance, so the foreground does not
     follow it down. */
  scene.fog = new THREE.FogExp2(0x050a0e, 0.0168);
  scene.background = new THREE.Color(0x060a0d);
  camera = new THREE.PerspectiveCamera(36, innerWidth / innerHeight, .35, 220);
  scene.add(camera);
}

function tx(canvasEl, o) {
  o = o || {};
  const t = new THREE.CanvasTexture(canvasEl);
  t.wrapS = t.wrapT = o.wrap || THREE.ClampToEdgeWrapping;
  if (o.repeat) t.repeat.set(o.repeat[0], o.repeat[1]);
  t.anisotropy = Math.min(o.aniso || 8, maxAniso);
  if (o.srgb !== false) t.encoding = THREE.sRGBEncoding;
  t.needsUpdate = true;
  return t;
}
/* HDR-bright emitters: values above 1 survive because the scene is rendered
   into a half-float buffer, and they are what the bloom feeds on */
const hdr = (r, g, b) => new THREE.Color().setRGB(r, g, b);

/* A {map, normal, rough} set hung on a standard material at one tiling. The
   colour is a multiplier on an already-dark albedo, so it is how a shared
   board or block library is re-tinted per use rather than regenerated.
   roughness stays at 1: the map carries the variation, and anything less
   scales the whole surface toward a polish it should not have. */
function surface(t, rep, o) {
  o = o || {};
  const wrap = THREE.RepeatWrapping, aniso = o.aniso || 8;
  const m = new THREE.MeshStandardMaterial({
    map: tx(t.map, { wrap: wrap, repeat: rep, aniso: aniso }),
    normalMap: tx(t.normal, { wrap: wrap, repeat: rep, srgb: false, aniso: aniso }),
    normalScale: new THREE.Vector2(o.normal === undefined ? .8 : o.normal,
                                   o.normal === undefined ? .8 : o.normal),
    color: o.color === undefined ? 0xffffff : o.color,
    roughness: o.roughness === undefined ? 1 : o.roughness,
    metalness: o.metalness === undefined ? .02 : o.metalness
  });
  if (t.rough) m.roughnessMap = tx(t.rough, { wrap: wrap, repeat: rep, srgb: false });
  return m;
}
/* The board and block libraries are generated once and shared. buildLantern
   alone is called six times, and a granite per lantern is six pointless
   canvases and six normal-map passes at load. */
const LIB = {};
const lib = (k, f) => (LIB[k] || (LIB[k] = f()));
/* the walls are boarded; a column, a sill and a rail are each one stick of
   wood and must not carry a joint down the middle of them */
const wallWood = () => lib('wallWood', () => texWood(3, { boards: 7 }));
const postWood = () => lib('postWood', () => texWood(29, { boards: 0 }));

/* sweep a polygonal cross-section along a path — kasagi, shimaki, beams */
function sweepPoly(points, profile) {
  const segs = points.length, np = profile.length;
  const pos = [], nor = [], uv = [], idx = [];
  const T = new THREE.Vector3(), N = new THREE.Vector3(), B = new THREE.Vector3(), up = new THREE.Vector3(0, 1, 0);
  for (let i = 0; i < segs; i++) {
    const p = points[i], a = points[Math.max(0, i - 1)], b = points[Math.min(segs - 1, i + 1)];
    T.subVectors(b, a).normalize();
    B.crossVectors(T, up).normalize();
    N.crossVectors(B, T).normalize();
    for (let j = 0; j < np; j++) {
      const u = profile[j][0], v = profile[j][1], l = Math.hypot(u, v) || 1;
      pos.push(p.x + B.x * u + N.x * v, p.y + B.y * u + N.y * v, p.z + B.z * u + N.z * v);
      nor.push(B.x * u / l + N.x * v / l, B.y * u / l + N.y * v / l, B.z * u / l + N.z * v / l);
      uv.push(j / np, i / (segs - 1));
    }
  }
  for (let i = 0; i < segs - 1; i++) for (let j = 0; j < np; j++) {
    const j2 = (j + 1) % np, a = i * np + j, b = i * np + j2, c = (i + 1) * np + j2, d = (i + 1) * np + j;
    idx.push(a, b, c, a, c, d);
  }
  /* caps */
  [0, segs - 1].forEach((ring, k) => {
    const base = pos.length / 3, p = points[ring];
    pos.push(p.x, p.y, p.z); nor.push(0, 0, k ? 1 : -1); uv.push(.5, .5);
    for (let j = 0; j < np; j++) {
      const a = ring * np + j, b = ring * np + (j + 1) % np;
      k ? idx.push(base, a, b) : idx.push(base, b, a);
    }
  });
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
  g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
  g.setIndex(idx);
  return g;
}
/* A temple roof. The height is a concave function of the Chebyshev distance
   from the ridge out to the eave, and the last fifth of that run flares back
   upward — that lift at the corners is the whole silhouette. Emitted solid:
   a top shell, an underside shell, and a fascia stitching the two rims. */
function roofGeo(A, B, R, Hr, thick, flare) {
  const NX = 52, NZ = 34, FL = flare === undefined ? .30 : flare, e = 1e-3;
  /* The lift has to be weighted toward the corners. Applied evenly along the
     eave it produces a raised rim the whole way round and the roof reads as a
     saucer; concentrated where the two runs meet, it reads as a temple. */
  const hAt = (x, z) => {
    const cx = Math.min(1, Math.abs(x) / A), cz = Math.min(1, Math.abs(z) / B);
    const tx = Math.max(0, (Math.abs(x) - R) / Math.max(A - R, 1e-4));
    const t = Math.min(1, Math.max(tx, cz));
    return Hr * Math.pow(1 - t, 1.45)
         + FL * Hr * smooth(.72, 1, t) * (.52 + .68 * Math.min(cx, cz));
  };
  const pos = [], nor = [], uv = [], idx = [];
  const N = new THREE.Vector3();
  const VPS = (NX + 1) * (NZ + 1);
  for (let k = 0; k < 2; k++) {
    for (let j = 0; j <= NZ; j++) for (let i = 0; i <= NX; i++) {
      const x = -A + 2 * A * i / NX, z = -B + 2 * B * j / NZ;
      N.set(-(hAt(x + e, z) - hAt(x - e, z)) / (2 * e), 1,
            -(hAt(x, z + e) - hAt(x, z - e)) / (2 * e)).normalize();
      if (k) N.negate();
      pos.push(x, hAt(x, z) - (k ? thick : 0), z);
      nor.push(N.x, N.y, N.z);
      /* one tile every seven metres: at half a metre the ribs land inside a
         pixel at this range and the mip chain averages them to flat grey */
      uv.push(x * .14, z * .14);
    }
    for (let j = 0; j < NZ; j++) for (let i = 0; i < NX; i++) {
      const a = k * VPS + j * (NX + 1) + i, b = a + 1, c = a + NX + 2, d = a + NX + 1;
      k ? idx.push(a, c, b, a, d, c) : idx.push(a, b, c, a, c, d);
    }
  }
  /* fascia: its own ring of vertices so the eave edge keeps a sideways normal */
  const per = [];
  for (let i = 0; i <= NX; i++) per.push([i, 0]);
  for (let j = 1; j <= NZ; j++) per.push([NX, j]);
  for (let i = NX - 1; i >= 0; i--) per.push([i, NZ]);
  for (let j = NZ - 1; j >= 1; j--) per.push([0, j]);
  const base = pos.length / 3;
  per.forEach(p => {
    const x = -A + 2 * A * p[0] / NX, z = -B + 2 * B * p[1] / NZ;
    const nx = p[0] === 0 ? -1 : (p[0] === NX ? 1 : 0);
    const nz = p[1] === 0 ? -1 : (p[1] === NZ ? 1 : 0);
    const l = Math.hypot(nx, nz) || 1;
    const y = hAt(x, z);
    pos.push(x, y, z, x, y - thick, z);
    nor.push(nx / l, 0, nz / l, nx / l, 0, nz / l);
    uv.push(0, 0, 0, 1);
  });
  for (let i = 0; i < per.length; i++) {
    const a = base + i * 2, b = a + 1;
    const c = base + ((i + 1) % per.length) * 2, d = c + 1;
    idx.push(a, b, d, a, d, c);
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
  g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
  g.setIndex(idx);
  return g;
}

function mergeGeos(list) {
  let vN = 0, iN = 0;
  list.forEach(g => { vN += g.attributes.position.count; iN += g.index.count; });
  const pos = new Float32Array(vN * 3), nor = new Float32Array(vN * 3), uv = new Float32Array(vN * 2);
  const idx = vN > 65535 ? new Uint32Array(iN) : new Uint16Array(iN);
  let vo = 0, io = 0;
  list.forEach(g => {
    pos.set(g.attributes.position.array, vo * 3);
    nor.set(g.attributes.normal.array, vo * 3);
    uv.set(g.attributes.uv.array, vo * 2);
    const gi = g.index.array;
    for (let i = 0; i < gi.length; i++) idx[io + i] = gi[i] + vo;
    io += gi.length; vo += g.attributes.position.count;
    g.dispose();
  });
  const out = new THREE.BufferGeometry();
  out.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  out.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
  out.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
  out.setIndex(new THREE.BufferAttribute(idx, 1));
  return out;
}

/* ========================================================== 5 · the world */
const WORLD = {};                 /* named handles the page can animate */
const swayers = [];               /* things the wind touches */

/* ---- the site plan, in metres ----------------------------------------
   Everything downstream — camera waypoints, lantern rows, the moon — is
   pinned to these, so the approach can be re-proportioned in one place. */
const PODIUM    = 7.0;            /* how far the hall stands above the court */
const STEPS     = 40;
const STAIR_Z0  = -11.0;          /* the bottom riser                        */
const STAIR_RUN = .55;            /* → the flight tops out at z = −33        */
const STAIR_W   = 8.4;
const TEMPLE_Z  = -44;            /* the hall's centre line                   */

function buildShell() {
  const wallT = texWall();
  const wallMap = tx(wallT.map, { wrap: THREE.RepeatWrapping, repeat: [4, 1.4] });
  const wallNrm = tx(wallT.normal, { wrap: THREE.RepeatWrapping, repeat: [4, 1.4], srgb: false });
  const wallMat = new THREE.MeshStandardMaterial({
    map: wallMap, normalMap: wallNrm, normalScale: new THREE.Vector2(.85, .85),
    roughness: .78, metalness: .05, color: 0x525c60
  });
  /* The court used to be a roofless box. It is now an open mountain approach:
     nothing encloses the frame but the night, the ridge and the fog. */

  /* the sky, hung far enough back that the whole frustum sits inside it */
  const sky = new THREE.Mesh(new THREE.PlaneGeometry(360, 190),
    new THREE.MeshBasicMaterial({ color: hdr(.60, .70, .80), map: tx(texSky()),
      depthWrite: false, fog: false, toneMapped: false }));
  sky.position.set(0, 62, -108); sky.renderOrder = 0; scene.add(sky);
  WORLD.sky = sky;

  /* the wooded ridge that closes the valley — three-quarters eaten by fog,
     which is the only reason the ground plane can just stop out there */
  /* z, centre y, width, height, x — kept low: a crest that climbs past the
     hall's eaves stops reading as distance and starts reading as a cut-out */
  const ridgeMap = tx(texRidge(), { wrap: THREE.RepeatWrapping, repeat: [1.7, 1] });
  [[-90, 13, 300, 26, 0], [-63, 9.5, 210, 19, 16]].forEach((r, i) => {
    const m = new THREE.Mesh(new THREE.PlaneGeometry(r[2], r[3]),
      /* fog off: at a hundred metres it would lift a black silhouette to 80%
         of the fog colour, and the treeline came back as a *pale* band */
      new THREE.MeshBasicMaterial({ map: i ? tx(texRidge()) : ridgeMap, transparent: true,
        color: i ? 0x0a1015 : 0x06090d, depthWrite: false, fog: false }));
    m.position.set(r[4], r[1], r[0]); m.renderOrder = 1; scene.add(m);
  });

  /* floor + podium */
  const fT = texFloor();
  const floorMat = new THREE.MeshStandardMaterial({
    map: tx(fT.map, { wrap: THREE.RepeatWrapping, repeat: [7, 7], aniso: 16 }),
    normalMap: tx(fT.normal, { wrap: THREE.RepeatWrapping, repeat: [7, 7], srgb: false, aniso: 16 }),
    roughnessMap: tx(fT.rough, { wrap: THREE.RepeatWrapping, repeat: [3.4, 3.4], srgb: false }),
    normalScale: new THREE.Vector2(.30, .30),
    /* The court used to carry a planar mirror and this was tuned to feed it —
       near-polished, and metal enough to hold a reflection. With the mirror
       gone those numbers only made the paving read as sheet metal, so it goes
       back to being wet stone: rough, barely metallic, and lit rather than
       reflecting. */
    roughness: .74, metalness: .06, color: 0x69757a
  });
  const floor = new THREE.Mesh(new THREE.PlaneGeometry(150, 150), floorMat);
  floor.rotation.x = -Math.PI / 2; floor.position.set(0, 0, -18); floor.receiveShadow = true;
  scene.add(floor);
  WORLD.floor = floor; WORLD.floorMat = floorMat;

  /* The podium and the flight use their own material: the mirror shader below
     is only ever attached to the water-level floor, and sharing it would make
     the reflection buffer sample itself. It is also deliberately matte — with
     the floor's metalness the forty risers strobed as the camera drifted. */
  const platMat = new THREE.MeshStandardMaterial({
    map: tx(fT.map, { wrap: THREE.RepeatWrapping, repeat: [3, 1.2], aniso: 16 }),
    normalMap: tx(fT.normal, { wrap: THREE.RepeatWrapping, repeat: [3, 1.2], srgb: false, aniso: 16 }),
    normalScale: new THREE.Vector2(.18, .18), roughness: .93, metalness: .02, color: 0x58636a
  });
  /* the podium the hall stands on */
  const plat = new THREE.Mesh(new THREE.BoxGeometry(42, PODIUM, 24), platMat);
  plat.position.set(0, PODIUM / 2, -45); plat.receiveShadow = true; plat.castShadow = true;
  scene.add(plat);
  const cope = new THREE.Mesh(new THREE.BoxGeometry(43, .34, 25), wallMat);
  cope.position.set(0, PODIUM - .17, -45); cope.receiveShadow = true; scene.add(cope);

  /* The flight. Twenty-six risers merged into one buffer — as separate meshes
     it is twenty-six draw calls for a shape nobody ever gets close to. */
  const treads = [], cheeks = [];
  for (let i = 0; i < STEPS; i++) {
    const y = (i + 1) * (PODIUM / STEPS), z = STAIR_Z0 - (i + .5) * STAIR_RUN;
    const w = STAIR_W + (STEPS - i) * .052;               /* splayed at the foot */
    treads.push(new THREE.BoxGeometry(w, PODIUM / STEPS + .04, STAIR_RUN + .04)
      .translate(0, y - (PODIUM / STEPS) / 2, z));
    [-1, 1].forEach(s => cheeks.push(new THREE.BoxGeometry(.9, 1.5, STAIR_RUN + .06)
      .translate(s * (w / 2 + .45), y - .3, z)));
  }
  const stair = new THREE.Mesh(mergeGeos(treads), platMat);
  stair.receiveShadow = true; stair.castShadow = true; scene.add(stair);
  const rail = new THREE.Mesh(mergeGeos(cheeks), wallMat);
  rail.receiveShadow = true; rail.castShadow = true; scene.add(rail);
}

/* ------------------------------------------------------- the worship hall
   A two-storey Sanmon on the podium at the head of the flight: charred
   timber, two flaring tiled roofs, a bracket band under each eave, and a
   ground floor lit from inside so the lattice reads as the only warm thing
   for two hundred metres.                                                 */
function buildTemple() {
  const g = new THREE.Group();
  /* One board library, sampled at two scales: the hall's flanks want the grain
     long and lying down, the columns want it running their own length. */
  const timber = surface(wallWood(), [4, 1.6], { color: 0x565150, normal: 1.5 });
  const post   = surface(postWood(), [1.1, 1.0], { color: 0x8a746d, normal: 1.05, metalness: .03 });
  const gold   = new THREE.MeshStandardMaterial({ color: 0x8f6f2e, roughness: .38, metalness: .78 });
  const rf = lib('roof', () => texRoof());
  /* Nearly a silhouette: the roof carries almost no colour of its own, and
     what reads on it is relief rather than tone — so the normal goes up as
     the albedo comes down. */
  const tileMat = surface(rf, [1, 1], { color: 0x2b343a, roughness: .74, metalness: .10, normal: 1.4 });
  /* the paper the lamps burn through — the only thing in the hall that is
     allowed to be bright, and even then only just above white */
  const paper = new THREE.MeshBasicMaterial({ color: hdr(1.06, .48, .18), fog: true, toneMapped: false });
  const grid  = new THREE.MeshBasicMaterial({ map: tx(texShoji()), transparent: true,
    depthWrite: false, fog: true });
  WORLD.paper = paper;

  /* one lit bay: a glowing sheet with a lattice hung just in front of it */
  function bay(w, h, x, y, z) {
    const p = new THREE.Mesh(new THREE.PlaneGeometry(w, h), paper);
    p.position.set(x, y, z); g.add(p);
    const s = new THREE.Mesh(new THREE.PlaneGeometry(w, h), grid);
    s.position.set(x, y, z + .06); s.renderOrder = 3; g.add(s);
  }
  /* The block-and-arm band that carries every eave. The pieces are merged, so
     they carry absolute coordinates — TEMPLE_Z has to go in here, not on the
     mesh, or the whole band ends up hanging in the air by the camera. */
  function brackets(halfW, halfD, y, step) {
    const parts = [];
    for (let x = -halfW; x <= halfW + 1e-3; x += step) {
      [-halfD, halfD].forEach(dz => {
        parts.push(new THREE.BoxGeometry(.34, .46, .34).translate(x, y, TEMPLE_Z + dz));
        parts.push(new THREE.BoxGeometry(.92, .17, .24).translate(x, y + .30, TEMPLE_Z + dz));
      });
    }
    for (let dz = -halfD + step; dz < halfD - 1e-3; dz += step) {
      [-halfW, halfW].forEach(x => {
        parts.push(new THREE.BoxGeometry(.34, .46, .34).translate(x, y, TEMPLE_Z + dz));
        parts.push(new THREE.BoxGeometry(.24, .17, .92).translate(x, y + .30, TEMPLE_Z + dz));
      });
    }
    const m = new THREE.Mesh(mergeGeos(parts), post);
    m.castShadow = false; g.add(m);
  }

  const F = PODIUM;                                   /* the hall's floor level */

  /* ---- ground storey: six bays of lit lattice between charred columns --- */
  const core = new THREE.Mesh(new THREE.BoxGeometry(13.6, 5.0, 8.2), timber);
  core.position.set(0, F + 2.5, TEMPLE_Z); core.castShadow = true; g.add(core);
  for (let i = 0; i < 5; i++) {
    const x = -5.6 + i * 2.8;
    bay(1.55, 2.5, x, F + 2.6, TEMPLE_Z + 4.16);
  }
  for (let i = 0; i < 6; i++) {
    const c = new THREE.Mesh(new THREE.CylinderGeometry(.30, .34, 5.0, 14), post);
    c.position.set(-7.0 + i * 2.8, F + 2.5, TEMPLE_Z + 4.24); c.castShadow = true; g.add(c);
  }
  const sill = new THREE.Mesh(new THREE.BoxGeometry(14.6, .40, 8.9), post);
  sill.position.set(0, F + .20, TEMPLE_Z); g.add(sill);
  brackets(7.0, 4.4, F + 5.15, 1.40);

  /* ---- the pent roof over the ground storey ---------------------------- */
  const lower = new THREE.Mesh(roofGeo(9.6, 6.4, 3.2, 2.9, .40, .26), tileMat);
  lower.position.set(0, F + 5.6, TEMPLE_Z); lower.castShadow = true; g.add(lower);

  /* ---- upper storey: railed gallery, dark boards, the temple's name ----- */
  const up = new THREE.Mesh(new THREE.BoxGeometry(10.0, 3.4, 6.0), timber);
  up.position.set(0, F + 9.4, TEMPLE_Z); up.castShadow = true; g.add(up);
  const deck = new THREE.Mesh(new THREE.BoxGeometry(12.4, .26, 8.4), post);
  deck.position.set(0, F + 7.72, TEMPLE_Z); g.add(deck);
  [.10, .94].forEach(dy => [4.2, -4.2].forEach(dz => {
    const r = new THREE.Mesh(new THREE.BoxGeometry(12.4, .17, .17), post);
    r.position.set(0, F + 7.85 + dy, TEMPLE_Z + dz); g.add(r);
  }));
  for (let i = 0; i <= 16; i++) {
    const b = new THREE.Mesh(new THREE.BoxGeometry(.10, 1.05, .10), post);
    b.position.set(-6.2 + i * .775, F + 8.42, TEMPLE_Z + 4.2); g.add(b);
  }
  for (let i = 0; i < 4; i++)
    bay(1.0, 1.35, -4.5 + i * 3.0, F + 9.5, TEMPLE_Z + 3.06);
  /* the plaque */
  const plq = new THREE.Mesh(new THREE.BoxGeometry(1.9, 1.30, .16), gold);
  plq.position.set(0, F + 9.7, TEMPLE_Z + 3.12); g.add(plq);
  const plqIn = new THREE.Mesh(new THREE.BoxGeometry(1.52, .98, .18), timber);
  plqIn.position.set(0, F + 9.7, TEMPLE_Z + 3.16); g.add(plqIn);
  brackets(5.2, 3.3, F + 11.2, 1.30);

  /* ---- the main roof: the silhouette the whole page hangs on ----------- */
  const upper = new THREE.Mesh(roofGeo(10.8, 7.2, 3.6, 5.2, .48, .26), tileMat);
  upper.position.set(0, F + 11.7, TEMPLE_Z); upper.castShadow = true; g.add(upper);
  const ridge = new THREE.Mesh(new THREE.BoxGeometry(7.6, .60, 1.05), tileMat);
  ridge.position.set(0, F + 17.10, TEMPLE_Z); g.add(ridge);
  [-1, 1].forEach(s => {
    const oni = new THREE.Mesh(new THREE.ConeGeometry(.46, 1.15, 4), tileMat);
    oni.position.set(s * 3.9, F + 17.6, TEMPLE_Z); oni.rotation.y = Math.PI / 4; g.add(oni);
  });
  WORLD.templeTop = F + 18.2;

  /* ---- the two flanking wings, lit the same way ------------------------ */
  [-1, 1].forEach(s => {
    const w = new THREE.Mesh(new THREE.BoxGeometry(7.6, 3.4, 5.2), timber);
    w.position.set(s * 10.6, F + 1.7, TEMPLE_Z + 1.4); w.castShadow = true; g.add(w);
    for (let i = 0; i < 3; i++)
      bay(1.3, 1.6, s * 10.6 + (i - 1) * 2.4, F + 1.8, TEMPLE_Z + 4.06);
    const r = new THREE.Mesh(roofGeo(5.0, 4.0, 1.4, 1.9, .32, .26), tileMat);
    r.position.set(s * 10.6, F + 3.4, TEMPLE_Z + 1.4); r.castShadow = true; g.add(r);
  });

  scene.add(g); WORLD.temple = g;

  /* the glow the hall pushes out over its own steps */
  const spill = new THREE.Mesh(new THREE.PlaneGeometry(30, 16),
    new THREE.MeshBasicMaterial({ map: tx(texGlow('rgba(255,150,66,.80)', 'rgba(240,96,26,.24)')),
      transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, opacity: .30 }));
  spill.position.set(0, F + 3.0, TEMPLE_Z + 5.6); spill.renderOrder = 2;
  scene.add(spill); WORLD.hallHalo = spill;

  /* the band of mist that separates the hall from the trees behind it — the
     one thing that stops the whole background reading as a single flat cut-out */
  const mist = new THREE.Mesh(new THREE.PlaneGeometry(64, 20),
    new THREE.MeshBasicMaterial({ map: tx(texGlow('rgba(150,178,190,.62)', 'rgba(104,138,154,.20)')),
      transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, opacity: .17 }));
  mist.position.set(0, F - 1.4, TEMPLE_Z + 10); mist.renderOrder = 2; scene.add(mist);
}

/* --------------------------------------------------- the vermilion moon
   Hung well behind the hall so the roof crosses its lower left — near
   enough to the ridge to sit in the mist, far enough that it never moves
   against the buildings when the rig drifts.                             */
const MOON = { x: 17.9, y: 31.9, z: -72, r: 8.6 };
function buildMoon() {
  const disc = new THREE.Mesh(new THREE.PlaneGeometry(MOON.r * 2, MOON.r * 2),
    /* The whole blood-moon grade lives here, on a near-neutral albedo. The
       plate measures G/R ≈ B/R ≈ .48, but that is a *display* ratio: the map
       is decoded out of sRGB before this multiplies it, so the tint has to be
       written in linear, where the same look is ≈ .21. Reading the ratio
       straight off the reference is what left the moon a pale pink. */
    new THREE.MeshBasicMaterial({ map: tx(texMoon()), color: hdr(3.6, .64, .61),
      transparent: true, depthWrite: false, fog: false, toneMapped: false }));
  disc.position.set(MOON.x, MOON.y, MOON.z); disc.renderOrder = 1;
  scene.add(disc); WORLD.moon = disc;

  /* the air around it — a wide, weak corona that the bloom picks up */
  const halo = new THREE.Mesh(new THREE.PlaneGeometry(MOON.r * 6.4, MOON.r * 6.4),
    new THREE.MeshBasicMaterial({ map: tx(texGlow('rgba(255,124,112,.90)', 'rgba(206,52,48,.26)')),
      transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, opacity: .44 }));
  halo.position.set(MOON.x, MOON.y, MOON.z - .3); halo.renderOrder = 0;
  scene.add(halo); WORLD.moonHalo = halo;
}
/* On a tall frame the rig steps back and its horizontal reach narrows, which
   would slide the moon off the right edge — draw it in by the same amount. */
function placeMoon() {
  if (!WORLD.moon) return;
  const x = MOON.x * (1 - .40 * aspectFix());
  WORLD.moon.position.x = x;
  WORLD.moonHalo.position.x = x;
}

function buildTorii() {
  /* the weathering costs the gate a lot of its chroma, so the tint puts it
     back: red lifted well past green and blue rather than a flat brightening,
     or the vermilion comes out of the grade as brick */
  const lac = surface(lib('lacquer', () => texLacquer()), [2, 2],
    { color: hdr(1.72, 1.02, .94), roughness: .92, metalness: .05, normal: .75 });
  const gold = new THREE.MeshStandardMaterial({ color: 0x7a5d2a, roughness: .50, metalness: .60 });
  const g = new THREE.Group();
  const BASE = .78, H = 8.2, SPAN = 3.55;

  [-1, 1].forEach(s => {
    const col = new THREE.Mesh(new THREE.CylinderGeometry(.30, .38, H, 26), lac);
    col.position.set(s * SPAN, BASE + H / 2, 0); col.castShadow = true; g.add(col);
    const foot = new THREE.Mesh(new THREE.CylinderGeometry(.46, .50, .52, 26), gold);
    foot.position.set(s * SPAN, BASE + .26, 0); foot.castShadow = true; g.add(foot);
    const collar = new THREE.Mesh(new THREE.CylinderGeometry(.345, .345, .26, 26), gold);
    collar.position.set(s * SPAN, BASE + H - 1.42, 0); g.add(collar);
    /* the ornate bracket where the beams cross the column */
    const br = new THREE.Mesh(new THREE.BoxGeometry(.86, .30, .86), gold);
    br.position.set(s * SPAN, BASE + H - .72, 0); g.add(br);
    const br2 = new THREE.Mesh(new THREE.BoxGeometry(.60, .52, .60), gold);
    br2.position.set(s * SPAN, BASE + H - .34, 0); g.add(br2);
    [-1, 1].forEach(d => {
      const wing = new THREE.Mesh(new THREE.BoxGeometry(.24, .70, .18), gold);
      wing.position.set(s * SPAN + d * .40, BASE + H - .62, .30); g.add(wing);
      const wing2 = wing.clone(); wing2.position.z = -.30; g.add(wing2);
    });
  });

  /* nuki — the straight beam that pierces the columns */
  const nuki = new THREE.Mesh(new THREE.BoxGeometry(9.4, .52, .46), lac);
  nuki.position.set(0, BASE + H - 2.15, 0); nuki.castShadow = true; g.add(nuki);

  /* gakuzuka — the little strut at the centre */
  const gak = new THREE.Mesh(new THREE.BoxGeometry(.46, 1.22, .40), lac);
  gak.position.set(0, BASE + H + .10, 0); g.add(gak);
  const gakG = new THREE.Mesh(new THREE.BoxGeometry(.60, .18, .50), gold);
  gakG.position.set(0, BASE + H + .68, 0); g.add(gakG);

  /* shimaki + kasagi — the two crowning beams, swept upward at the ends */
  function beamPath(half, rise, power) {
    const p = [];
    for (let i = 0; i <= 26; i++) {
      const u = i / 26 * 2 - 1;
      p.push(new THREE.Vector3(u * half, Math.pow(Math.abs(u), power) * rise, 0));
    }
    return p;
  }
  const shimaki = new THREE.Mesh(sweepPoly(beamPath(5.05, .40, 2.6),
    [[-.30, -.19], [.30, -.19], [.32, .06], [.28, .21], [-.28, .21], [-.32, .06]]), lac);
  shimaki.position.set(0, BASE + H - .52, 0); shimaki.castShadow = true; g.add(shimaki);

  const kasagi = new THREE.Mesh(sweepPoly(beamPath(5.85, .62, 2.4),
    [[-.42, -.24], [.42, -.24], [.46, .04], [.30, .28], [-.30, .28], [-.46, .04]]), lac);
  kasagi.position.set(0, BASE + H + .96, 0); kasagi.castShadow = true; g.add(kasagi);
  /* a black lacquer cap along the top of the kasagi */
  const cap = new THREE.Mesh(sweepPoly(beamPath(5.95, .62, 2.4),
    [[-.48, .22], [.48, .22], [.48, .34], [-.48, .34]]),
    new THREE.MeshStandardMaterial({ color: 0x120c0c, roughness: .42, metalness: .14 }));
  cap.position.set(0, BASE + H + .96, 0); g.add(cap);

  /* the gate stands at the foot of the flight, not over the court — small
     enough that the hall keeps the top of the frame to itself */
  /* The lowest thing on the gate is the foot of each column, and it starts
     at BASE — so anchoring the group at y=0 left the whole torii hanging
     BASE*scale above the court. Drop it by exactly that and the feet land. */
  const GS = .72;
  g.position.set(0, -BASE * GS, -8.6); g.scale.setScalar(GS);
  scene.add(g); WORLD.torii = g;
}

function buildLantern(x, z, s, y) {
  const stone = lib('lanternStone', () => surface(lib('granite', () => texStone(17)), [1.5, 1.5],
    { color: 0x9aa5a5, metalness: 0, normal: 1.45 }));
  const dark  = lib('lanternDark', () => surface(postWood(), [.9, .9],
    { color: 0xd8c2b6, metalness: .1, normal: .7 }));
  const g = new THREE.Group();
  const base = new THREE.Mesh(new THREE.CylinderGeometry(.44, .52, .26, 20), stone);
  base.position.y = .13; base.castShadow = true; g.add(base);
  const post = new THREE.Mesh(new THREE.CylinderGeometry(.15, .18, 1.02, 16), stone);
  post.position.y = .77; post.castShadow = true; g.add(post);
  const shelf = new THREE.Mesh(new THREE.CylinderGeometry(.42, .34, .13, 20), stone);
  shelf.position.y = 1.34; shelf.castShadow = true; g.add(shelf);

  const box = new THREE.Mesh(new THREE.BoxGeometry(.50, .50, .50), dark);
  box.position.y = 1.66; box.castShadow = true; g.add(box);
  const paneMat = new THREE.MeshBasicMaterial({ color: hdr(2.3, .30, .085), fog: false, toneMapped: false });
  [[0, 0, .256, 0], [0, 0, -.256, Math.PI], [.256, 0, 0, Math.PI / 2], [-.256, 0, 0, -Math.PI / 2]].forEach(p => {
    const pane = new THREE.Mesh(new THREE.PlaneGeometry(.34, .34), paneMat);
    pane.position.set(p[0], 1.66, p[2]); pane.rotation.y = p[3]; g.add(pane);
  });
  WORLD.lanternPane = WORLD.lanternPane || paneMat;

  const roof = new THREE.Mesh(new THREE.CylinderGeometry(.10, .62, .34, 4, 1), stone);
  roof.position.y = 2.06; roof.rotation.y = Math.PI / 4; roof.castShadow = true; g.add(roof);
  const brim = new THREE.Mesh(new THREE.CylinderGeometry(.66, .70, .07, 4, 1), stone);
  brim.position.y = 1.93; brim.rotation.y = Math.PI / 4; g.add(brim);
  const knob = new THREE.Mesh(new THREE.SphereGeometry(.09, 12, 10), stone);
  knob.position.y = 2.28; g.add(knob);
  const tip = new THREE.Mesh(new THREE.ConeGeometry(.055, .16, 10), stone);
  tip.position.y = 2.4; g.add(tip);

  const glow = new THREE.Mesh(new THREE.PlaneGeometry(3.4, 3.4),
    new THREE.MeshBasicMaterial({ map: tx(texGlow('rgba(255,120,60,.9)', 'rgba(255,60,24,.28)')),
      transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, opacity: .5 }));
  glow.position.y = 1.66; glow.renderOrder = 2; g.add(glow);
  glow.userData.billboard = true;

  const lt = new THREE.PointLight(0xff5a24, 2.6, 9, 2);
  lt.position.set(0, 1.66, 0); g.add(lt);
  WORLD.lanternLights = WORLD.lanternLights || [];
  WORLD.lanternLights.push(lt); WORLD.lanternGlows = WORLD.lanternGlows || []; WORLD.lanternGlows.push(glow);

  g.position.set(x, y || 0, z); g.scale.setScalar(s || 1);
  scene.add(g);
  return g;
}

function buildMaple(seed, x, z, scale) {
  const rnd = mulberry32(seed);
  const parts = [], tips = [];
  const M = new THREE.Matrix4(), Q = new THREE.Quaternion(), UP = new THREE.Vector3(0, 1, 0);
  const dir = new THREE.Vector3(), pos = new THREE.Vector3();

  function seg(from, to, r0, r1) {
    dir.subVectors(to, from);
    const len = dir.length(); dir.normalize();
    const geo = new THREE.CylinderGeometry(r1, r0, len, 6, 1, true);
    Q.setFromUnitVectors(UP, dir);
    pos.addVectors(from, to).multiplyScalar(.5);
    M.compose(pos, Q, new THREE.Vector3(1, 1, 1));
    geo.applyMatrix4(M);
    parts.push(geo);
  }
  function branch(from, dirV, len, rad, depth) {
    const to = from.clone().addScaledVector(dirV, len);
    to.y += len * .10;
    seg(from, to, rad, rad * .68);
    if (depth >= 4 || len < .34) { tips.push(to.clone()); return; }
    const n = depth < 2 ? 3 : 2;
    for (let i = 0; i < n; i++) {
      const d = dirV.clone();
      d.x += (rnd() - .5) * 1.25; d.z += (rnd() - .5) * 1.25; d.y += .30 + rnd() * .5;
      d.normalize();
      branch(to, d, len * (.62 + rnd() * .16), rad * .66, depth + 1);
    }
  }
  const root = new THREE.Vector3(0, 0, 0);
  seg(root, new THREE.Vector3(0, 1.5, 0), .22, .16);
  for (let i = 0; i < 4; i++) {
    const a = i / 4 * TAU + rnd();
    branch(new THREE.Vector3(0, 1.5, 0), new THREE.Vector3(Math.cos(a) * .8, .8, Math.sin(a) * .8).normalize(), 1.45, .155, 0);
  }
  const trunk = new THREE.Mesh(mergeGeos(parts),
    new THREE.MeshStandardMaterial({ color: 0x171413, roughness: .94, metalness: .0, side: THREE.DoubleSide }));
  trunk.castShadow = true;

  const leafGeo = new THREE.PlaneGeometry(.36, .36);
  const leafMat = new THREE.MeshStandardMaterial({
    map: tx(texLeaf()), color: 0x2b0406, alphaTest: .42, side: THREE.DoubleSide,
    roughness: .86, metalness: 0, emissive: 0x080000, emissiveIntensity: .12
  });
  const per = LOW ? 5 : 9;
  const inst = new THREE.InstancedMesh(leafGeo, leafMat, tips.length * per);
  const m4 = new THREE.Matrix4(), e = new THREE.Euler(), q2 = new THREE.Quaternion(), sc = new THREE.Vector3();
  let k = 0;
  tips.forEach(t => {
    for (let i = 0; i < per; i++) {
      const p = new THREE.Vector3(t.x + (rnd() - .5) * .95, t.y + (rnd() - .5) * .8, t.z + (rnd() - .5) * .95);
      e.set(rnd() * TAU, rnd() * TAU, rnd() * TAU);
      q2.setFromEuler(e);
      const s = .7 + rnd() * .75; sc.set(s, s, s);
      m4.compose(p, q2, sc);
      inst.setMatrixAt(k++, m4);
    }
  });
  inst.instanceMatrix.needsUpdate = true;
  inst.castShadow = false; inst.frustumCulled = false;

  const g = new THREE.Group();
  g.add(trunk); g.add(inst);
  g.position.set(x, 0, z); g.scale.setScalar(scale || 1);
  g.rotation.y = rnd() * TAU;
  scene.add(g);
  /* leaves breathe in the wind */
  leafMat.onBeforeCompile = sh => {
    sh.uniforms.uT = WORLD.uT;
    sh.vertexShader = 'uniform float uT;\n' + sh.vertexShader.replace('#include <begin_vertex>',
      '#include <begin_vertex>\n' +
      'vec3 wp = (instanceMatrix * vec4(0.0,0.0,0.0,1.0)).xyz;\n' +
      'float ph = wp.x*1.7 + wp.z*1.3 + wp.y*.7;\n' +
      'transformed.x += sin(uT*1.35 + ph)*0.055;\n' +
      'transformed.z += cos(uT*1.05 + ph*1.3)*0.045;\n');
  };
  return g;
}

function buildRocks() {
  const mat = new THREE.MeshStandardMaterial({ color: 0x141a1c, roughness: .46, metalness: .10 });
  const rnd = mulberry32(404), n = noise2D(88);
  const spots = [[-6.2, -5.4, .95], [-7.4, -2.1, .7], [6.6, -7.2, .8], [8.0, -4.0, 1.05],
                 [-9.0, -8.4, 1.15], [9.6, -9.2, .9], [3.4, -7.8, .55], [-3.2, -8.6, .6]];
  spots.forEach((s, i) => {
    const geo = new THREE.IcosahedronGeometry(s[2], 2);
    const p = geo.attributes.position;
    for (let v = 0; v < p.count; v++) {
      const vx = p.getX(v), vy = p.getY(v), vz = p.getZ(v);
      const d = 1 + .34 * fbm(n, vx * 1.6 + i * 7, vz * 1.6 + vy, 3, 2.2, .5);
      p.setXYZ(v, vx * d, vy * d * .68, vz * d);
    }
    geo.computeVertexNormals();
    const m = new THREE.Mesh(geo, mat);
    m.position.set(s[0], s[2] * .30, s[1]);
    m.rotation.set(rnd(), rnd() * TAU, rnd() * .4);
    m.castShadow = true; m.receiveShadow = true;
    scene.add(m);
  });
}

/* ------------------------------- the transparent-PNG foreground layers */
function cutoutMaterial(canvasEl, sway) {
  const mat = new THREE.MeshBasicMaterial({
    map: tx(canvasEl, { aniso: 16 }), transparent: true, depthWrite: true,
    alphaTest: .012, side: THREE.DoubleSide, fog: true, color: 0xffffff
  });
  mat.onBeforeCompile = sh => {
    sh.uniforms.uT = WORLD.uT;
    sh.uniforms.uSway = { value: sway || .07 };
    sh.vertexShader = 'uniform float uT;\nuniform float uSway;\n' + sh.vertexShader.replace(
      '#include <begin_vertex>',
      '#include <begin_vertex>\n' +
      'float h = uv.y;\n' +
      'float ph = position.x*0.30 + float(gl_InstanceID)*0.0;\n' +
      'transformed.x += sin(uT*0.85 + ph)*uSway*h*h;\n' +
      'transformed.y += cos(uT*0.62 + ph*1.7)*uSway*0.35*h*h;\n');
    /* These plates are drawn to the edge of their own canvas — the grass runs
       straight off the left and the bottom — so every sheet ends on a hard
       straight line wherever its plane stops. Feathering the alpha at the
       edges lets each one dissolve into the scene instead of being cut out of
       it. Sides get the widest margin because that is where a bank of grass
       is read as ending; the crest needs none, the blades already taper. */
    sh.fragmentShader = sh.fragmentShader.replace('#include <alphatest_fragment>',
      'float feX = smoothstep(0.0, 0.11, vUv.x) * (1.0 - smoothstep(0.89, 1.0, vUv.x));\n' +
      'float feY = smoothstep(0.0, 0.07, vUv.y);\n' +
      'diffuseColor.a *= feX * feY;\n' +
      '#include <alphatest_fragment>');
  };
  return mat;
}

function buildForeground() {
  /* the plane widths are sized against the frustum at their own depth: a
     26-unit sheet two metres from the lens only shows its middle tenth */
  const layers = [
    /* name        canvas                                                                     x     y     z     w     h   sway */
    ['grassFar',  texGrassCutout(101, { crest: .30, peak: .46, wide: .42, blades: 11000, len: 34, warm: .70, crest2: .46, crest3: .40 }),
                  -3.4,  2.85, -0.4, 24, 12, .040],
    ['rockLeft',  texRockCutout(404, { blobs: [[.24, .78, .48, .38], [.58, .94, .46, .30], [.84, 1.06, .42, .28]] }),
                  -7.2,  2.55,  1.8, 17, 11.3, .010],
    ['grassMid',  texGrassCutout(202, { crest: .60, peak: .55, wide: .40, blades: 13000, len: 40, warm: .95, crest2: -.44, crest3: .34 }),
                   3.9,  2.86,  3.6, 15,  7.5, .062],
    ['grassNear', texGrassCutout(303, { crest: .42, peak: .62, wide: .48, blades: 16000, len: 50, warm: .85, crest2: .42, crest3: .44 }),
                  -1.9,  2.92,  5.6, 12,  6, .092],
    ['bough',     texBranchCutout(505), 4.6, 2.95, 6.4, 13, 8.7, .045],
    ['rockNear',  texRockCutout(606, { blobs: [[.18, .86, .54, .44], [.54, 1.02, .50, .36], [.86, .92, .40, .32]] }),
                  -6.6,  1.70,  6.0, 9, 6, .008]
  ];
  WORLD.fg = [];
  layers.forEach(L => {
    const m = new THREE.Mesh(new THREE.PlaneGeometry(L[5], L[6], 12, 12), cutoutMaterial(L[1], L[7]));
    m.position.set(L[2], L[3], L[4]);
    m.renderOrder = 20 + WORLD.fg.length;
    m.name = L[0];
    m.frustumCulled = false;
    scene.add(m);
    WORLD.fg.push(m);
  });
}

/* --------------------------------------------------- the giant wordmark */
const WORD_Z = 3.0;
function buildWordmark() {
  /* The word is set to the frame width, so a short word is a *taller* word.
     Four letters at the old tracking put a 400-pixel cap height across the
     scene; the letters are spaced out instead, which keeps the type edge to
     edge while the caps come back down to the height of the headline block. */
  const SZ = 320, TRACK = .40, PAD = 26;
  const m = cvs(4, 4).getContext('2d');
  m.font = '600 ' + SZ + 'px Wordmark, sans-serif';
  m.textBaseline = 'alphabetic'; m.textAlign = 'left';
  const word = 'KAGE', gl = [];
  let pen = 0, ascMax = 0, descMax = 0, xMin = 1e9, xMax = -1e9;
  for (const ch of word) {
    const t = m.measureText(ch);
    const g = { ch: ch, adv: t.width, asc: t.actualBoundingBoxAscent, desc: t.actualBoundingBoxDescent,
                l: t.actualBoundingBoxLeft, r: t.actualBoundingBoxRight, pen: pen };
    gl.push(g);
    ascMax = Math.max(ascMax, g.asc); descMax = Math.max(descMax, g.desc);
    xMin = Math.min(xMin, pen - g.l); xMax = Math.max(xMax, pen + g.r);
    pen += t.width + TRACK * SZ;
  }
  const group = new THREE.Group();
  WORD.glyphs = [];
  gl.forEach((g, i) => {
    const cw = Math.ceil(g.l + g.r) + PAD * 2, chh = Math.ceil(g.asc + g.desc) + PAD * 2;
    const c = cvs(cw, chh), x = c.getContext('2d');
    x.font = '600 ' + SZ + 'px Wordmark, sans-serif';
    x.textBaseline = 'alphabetic'; x.textAlign = 'left';
    /* the vertical wash is baked per glyph so the whole word shares one ramp */
    const gy0 = PAD + g.asc - ascMax, gy1 = PAD + g.asc + descMax * .4;
    const grad = x.createLinearGradient(0, gy0, 0, gy1);
    grad.addColorStop(0, 'rgb(226,236,229)');
    grad.addColorStop(.52, 'rgb(198,214,203)');
    grad.addColorStop(1, 'rgb(150,170,157)');
    x.fillStyle = grad;
    x.fillText(g.ch, PAD + g.l, PAD + g.asc);

    const mesh = new THREE.Mesh(new THREE.PlaneGeometry(cw, chh),
      new THREE.MeshBasicMaterial({ map: tx(c, { aniso: 16 }), transparent: true,
        depthWrite: false, side: THREE.DoubleSide, fog: true, opacity: 1 }));
    mesh.position.set(g.pen + (g.r - g.l) / 2, (g.asc - g.desc) / 2 + (PAD - PAD) , 0);
    mesh.position.y = (g.asc - g.desc) / 2;
    mesh.renderOrder = 12;
    mesh.frustumCulled = false;
    mesh.userData.baseY = mesh.position.y;
    group.add(mesh);
    WORD.glyphs.push(mesh);
  });
  group.position.z = WORD_Z;
  scene.add(group);
  WORD.group = group;
  WORD.ink = { xMin: xMin, xMax: xMax, cx: (xMin + xMax) / 2, w: xMax - xMin, asc: ascMax };
}
const WORD = { glyphs: [], group: null, ink: null, reveal: 0 };

/* --------------------------------------------------------- atmospherics */
function buildAtmosphere() {
  /* drifting haze slabs */
  const hazeTex = tx(texGlow('rgba(160,205,210,.55)', 'rgba(110,165,175,.18)'));
  WORLD.haze = [];
  const rnd = mulberry32(66);
  for (let i = 0; i < (LOW ? 4 : 6); i++) {
    const s = 12 + rnd() * 15;
    const h = new THREE.Mesh(new THREE.PlaneGeometry(s, s * .55),
      new THREE.MeshBasicMaterial({ map: hazeTex, transparent: true, blending: THREE.AdditiveBlending,
        depthWrite: false, fog: false, opacity: .05 + rnd() * .07 }));
    h.position.set((rnd() - .5) * 44, 1.5 + rnd() * 10, -38 + rnd() * 40);
    h.renderOrder = 4;
    h.userData = { sp: .06 + rnd() * .12, ph: rnd() * TAU, x0: h.position.x };
    scene.add(h); WORLD.haze.push(h);
  }

  /* embers around the lantern and the disc */
  const N = LOW ? 220 : 460;
  const pos = new Float32Array(N * 3), seed = new Float32Array(N);
  for (let i = 0; i < N; i++) {
    pos[i * 3] = (rnd() - .5) * 30; pos[i * 3 + 1] = rnd() * 11; pos[i * 3 + 2] = -26 + rnd() * 36;
    seed[i] = rnd();
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  g.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1));
  const emb = new THREE.Points(g, new THREE.ShaderMaterial({
    uniforms: { uT: WORLD.uT, uTex: { value: tx(texGlow('rgba(255,190,140,1)', 'rgba(255,120,60,.35)')) },
      uSize: { value: innerHeight * .5 } },
    transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
    vertexShader:
      'attribute float aSeed; uniform float uT; uniform float uSize; varying float vA;\n' +
      'void main(){ vec3 p=position;\n' +
      ' p.y = mod(p.y + uT*(0.14+aSeed*0.28), 11.5);\n' +
      ' p.x += sin(uT*0.36 + aSeed*22.0)*0.85;\n' +
      ' p.z += cos(uT*0.29 + aSeed*17.0)*0.7;\n' +
      ' vec4 mv = modelViewMatrix * vec4(p,1.0);\n' +
      ' vA = (0.25+aSeed*0.75) * smoothstep(11.5,7.0,p.y) * smoothstep(0.0,1.4,p.y);\n' +
      ' gl_PointSize = uSize*(0.010+aSeed*0.020)/max(-mv.z,0.6);\n' +
      ' gl_Position = projectionMatrix * mv; }',
    fragmentShader:
      'uniform sampler2D uTex; varying float vA;\n' +
      'void main(){ vec4 t=texture2D(uTex, gl_PointCoord);\n' +
      ' gl_FragColor = vec4(t.rgb*vec3(1.6,0.78,0.42), t.a*vA*0.75); }'
  }));
  emb.frustumCulled = false; emb.renderOrder = 5;
  scene.add(emb); WORLD.embers = emb;

  /* rain in the slot of open sky */
  if (!LOW) {
    const D = 900, rp = new Float32Array(D * 2 * 3), rt = new Float32Array(D * 2), rs = new Float32Array(D * 2), rl = new Float32Array(D * 2);
    for (let i = 0; i < D; i++) {
      const x = (rnd() - .5) * 40, z = -30 + rnd() * 34, y = rnd() * 17, sp = 7 + rnd() * 9, ln = .30 + rnd() * .55;
      for (let k = 0; k < 2; k++) {
        const o = (i * 2 + k) * 3;
        rp[o] = x; rp[o + 1] = y; rp[o + 2] = z;
        rt[i * 2 + k] = k; rs[i * 2 + k] = sp; rl[i * 2 + k] = ln;
      }
    }
    const rg = new THREE.BufferGeometry();
    rg.setAttribute('position', new THREE.BufferAttribute(rp, 3));
    rg.setAttribute('aTop', new THREE.BufferAttribute(rt, 1));
    rg.setAttribute('aSpeed', new THREE.BufferAttribute(rs, 1));
    rg.setAttribute('aLen', new THREE.BufferAttribute(rl, 1));
    const rain = new THREE.LineSegments(rg, new THREE.ShaderMaterial({
      uniforms: { uT: WORLD.uT },
      transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
      vertexShader:
        'attribute float aTop; attribute float aSpeed; attribute float aLen; uniform float uT; varying float vA;\n' +
        'void main(){ vec3 p=position;\n' +
        ' float y = mod(p.y - uT*aSpeed, 17.0);\n' +
        ' p.y = y + aTop*aLen;\n' +
        ' vA = smoothstep(0.0,3.0,y)*smoothstep(17.0,11.0,y);\n' +
        ' gl_Position = projectionMatrix * modelViewMatrix * vec4(p,1.0); }',
      fragmentShader: 'varying float vA;\nvoid main(){ gl_FragColor = vec4(0.55,0.74,0.82, vA*0.024); }'
    }));
    rain.frustumCulled = false; rain.renderOrder = 6;
    scene.add(rain); WORLD.rain = rain;
  }

  /* ripples on the standing water */
  const ringC = cvs(256, 256), rx2 = ringC.getContext('2d');
  const rg2 = rx2.createRadialGradient(128, 128, 84, 128, 128, 128);
  rg2.addColorStop(0, 'rgba(255,255,255,0)'); rg2.addColorStop(.62, 'rgba(255,255,255,.55)');
  rg2.addColorStop(.86, 'rgba(255,255,255,.22)'); rg2.addColorStop(1, 'rgba(255,255,255,0)');
  rx2.fillStyle = rg2; rx2.fillRect(0, 0, 256, 256);
  const ringTex = tx(ringC);
  WORLD.ripples = [];
  for (let i = 0; i < (LOW ? 6 : 13); i++) {
    const r = new THREE.Mesh(new THREE.PlaneGeometry(1, 1),
      new THREE.MeshBasicMaterial({ map: ringTex, transparent: true, blending: THREE.AdditiveBlending,
        depthWrite: false, fog: true, opacity: 0 }));
    r.rotation.x = -Math.PI / 2; r.renderOrder = 7;
    r.userData = { t: rnd() * 4, x: (rnd() - .5) * 22, z: -8 + rnd() * 22, sp: .5 + rnd() * .5 };
    scene.add(r); WORLD.ripples.push(r);
  }
}

/* ====================================================== 6b · the leaf fall
   The maples shed all the way through the chapter. The fall is anchored to
   the rig rather than to the court: every leaf keeps a real world position so
   its drift is honest, but any leaf that ends up further than LEAF_R from the
   camera in x or z is wrapped back across it. That way the count is what one
   screenful needs — a hundred and seventy — instead of what filling the whole
   valley would cost, and the frame is never empty however far the walk goes.

   A falling leaf is read by its tumble, not by its path. Each one turns on
   two axes at its own rate, so it presents its face, thins to an edge and
   comes back — which is the whole reason these are instanced quads and not
   points, since a point sprite cannot turn away from the camera. */
/* LEAF_AHEAD/LEAF_SPREAD is the disc leaves are recycled into, hung down the
   camera's own sight line; LEAF_R is only the far backstop for a rig that has
   walked out from under its weather. */
const LEAF_AHEAD = 11, LEAF_SPREAD = 12, LEAF_R = 30;
function buildLeafFall() {
  const N = LOW ? 110 : 260;
  const mat = new THREE.MeshStandardMaterial({
    map: tx(texLeaf()), alphaTest: .42, side: THREE.DoubleSide,
    color: 0x40080a, roughness: .84, metalness: 0,
    /* green and blue driven to nothing: the composite tone-maps at the end,
       and an emissive red with any green in it comes back out of that pink */
    emissive: 0x780200, emissiveIntensity: .72
  });
  const inst = new THREE.InstancedMesh(new THREE.PlaneGeometry(.40, .40), mat, N);
  inst.frustumCulled = false; inst.renderOrder = 5;
  inst.castShadow = inst.receiveShadow = false;
  const rnd = mulberry32(404), list = [];
  for (let i = 0; i < N; i++) list.push({
    x: (rnd() - .5) * 2 * LEAF_R, z: (rnd() - .5) * 2 * LEAF_R, y: rnd() * 26,
    fall: .5 + rnd() * .9,
    sway: .45 + rnd() * 1.5, swayPh: rnd() * TAU, swayAmp: .30 + rnd() * .95,
    spin: (rnd() - .5) * 2.6, roll: rnd() * TAU, rollSp: .5 + rnd() * 2.0,
    tilt: rnd() * TAU, s: .55 + rnd() * .9
  });
  scene.add(inst);
  WORLD.leaves = { mesh: inst, list: list };
}
const LEAF_M = new THREE.Matrix4(), LEAF_Q = new THREE.Quaternion();
const LEAF_E = new THREE.Euler(), LEAF_P = new THREE.Vector3(), LEAF_S = new THREE.Vector3();
const LEAF_F = new THREE.Vector3();
function updateLeaves(dt) {
  const LV = WORLD.leaves; if (!LV) return;
  const cy = camera.position.y, L = LV.list;
  /* Respawn ahead of the rig, not around it. A band centred on the camera
     spends nearly all of itself behind and beside the frustum — of a couple of
     hundred leaves only a dozen were ever on screen. Recycling them into the
     cone the camera is actually looking down puts the same count in frame
     several times over, and costs nothing. */
  camera.getWorldDirection(LEAF_F);
  LEAF_F.y = 0;
  if (LEAF_F.lengthSq() < 1e-6) LEAF_F.set(0, 0, -1); else LEAF_F.normalize();
  const fx = camera.position.x + LEAF_F.x * LEAF_AHEAD;
  const fz = camera.position.z + LEAF_F.z * LEAF_AHEAD;
  const seed = !LV.seeded; LV.seeded = true;
  for (let i = 0; i < L.length; i++) {
    const l = L[i];
    l.y -= l.fall * dt;
    l.roll += l.rollSp * dt;
    l.tilt += l.spin * dt;
    if (seed || l.y < cy - 10) {                /* back to the top of the band */
      l.y = seed ? cy - 10 + Math.random() * 26 : cy + 16;
      const a = Math.random() * TAU, r = Math.sqrt(Math.random()) * LEAF_SPREAD;
      l.x = fx + Math.cos(a) * r;
      l.z = fz + Math.sin(a) * r;
    }
    /* the far wrap is a backstop for a rig that has walked away from its own
       leaves — it sits well outside the fog, so nothing is seen to jump */
    const cx = camera.position.x, cz = camera.position.z;
    if (l.x - cx > LEAF_R) l.x -= 2 * LEAF_R; else if (l.x - cx < -LEAF_R) l.x += 2 * LEAF_R;
    if (l.z - cz > LEAF_R) l.z -= 2 * LEAF_R; else if (l.z - cz < -LEAF_R) l.z += 2 * LEAF_R;
    /* the side-slip of a leaf shedding air off one edge, then the other */
    const sw = Math.sin(clock * l.sway + l.swayPh);
    LEAF_P.set(l.x + sw * l.swayAmp, l.y,
               l.z + Math.cos(clock * l.sway * .7 + l.swayPh) * l.swayAmp * .6);
    LEAF_E.set(l.roll, l.tilt, sw * .55);
    LEAF_Q.setFromEuler(LEAF_E);
    LEAF_S.setScalar(l.s);
    LEAF_M.compose(LEAF_P, LEAF_Q, LEAF_S);
    LV.mesh.setMatrixAt(i, LEAF_M);
  }
  LV.mesh.instanceMatrix.needsUpdate = true;
}

/* ==================================================== 6c · the cursor wisps
   A drift of cold motes that follows the pointer.

   It hangs as a child of the camera, so the pointer maps straight into camera
   space through the frustum's own half-height. Unprojecting onto a world
   plane each frame would instead pin the trail to the court, and the rig is
   always drifting — the wisps would swim across the screen whenever the
   camera moved rather than staying under the hand.

   Emission is by distance travelled, not elapsed time. A slow hand then lays
   a continuous drift and a fast one throws the motes apart, which is how a
   moving source actually sheds them; emitting on a timer gives an evenly
   spaced string of beads at every speed. */
const WISP_D = 3.4;
const WISP = { list: [], i: 0, acc: 0, ex: 0, ey: 0, lx: 0, ly: 0, idle: 0, seen: false };
function buildWisps() {
  if (COARSE) return;
  /* The motes are round, so nothing here is oriented: no per-particle angle,
     no rotated lookup. They are also small — a few pixels of core inside a
     faint halo — which is what lets the count go up without the additive fill
     that a screenful of big sprites costs. */
  const N = LOW ? 90 : 190;
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
  g.setAttribute('aA', new THREE.BufferAttribute(new Float32Array(N), 1));
  g.setAttribute('aS', new THREE.BufferAttribute(new Float32Array(N), 1));
  const pts = new THREE.Points(g, new THREE.ShaderMaterial({
    uniforms: { uTex: { value: tx(texWisp()) }, uPx: { value: innerHeight } },
    transparent: true, blending: THREE.AdditiveBlending,
    depthWrite: false, depthTest: false, fog: false,
    vertexShader:
      'attribute float aA; attribute float aS;\n' +
      'uniform float uPx; varying float vA;\n' +
      'void main(){ vA = aA;\n' +
      ' vec4 mv = modelViewMatrix * vec4(position,1.0);\n' +
      ' gl_PointSize = uPx * aS / max(-mv.z, 0.4);\n' +
      ' gl_Position = projectionMatrix * mv; }',
    fragmentShader:
      'uniform sampler2D uTex; varying float vA;\n' +
      'void main(){ if (vA <= 0.0) discard;\n' +
      ' vec4 t = texture2D(uTex, gl_PointCoord);\n' +
      ' gl_FragColor = vec4(t.rgb, t.a * vA); }'
  }));
  pts.frustumCulled = false; pts.renderOrder = 9;
  pts.layers.set(1);                    /* near the lens: never in the mirror */
  camera.add(pts);
  WISP.mesh = pts;
  for (let i = 0; i < N; i++) WISP.list.push({ life: 0, max: 1, vx: 0, vy: 0, sz: 0, ph: 0 });
}
/* the pointer, in camera space, on the plane the trail hangs on */
function wispPoint(nx, ny) {
  const hh = Math.tan(camera.fov * Math.PI / 360) * WISP_D;
  return [nx * hh * camera.aspect, ny * hh];
}
function updateWisps(dt) {
  if (!WISP.mesh) return;
  const g = WISP.mesh.geometry;
  const P = g.attributes.position.array, A = g.attributes.aA.array;
  const S = g.attributes.aS.array;
  const L = WISP.list, N = L.length;

  const pt = wispPoint(RIG.tmx, RIG.tmy);
  if (!WISP.seen) { WISP.ex = WISP.lx = pt[0]; WISP.ey = WISP.ly = pt[1]; WISP.seen = true; }
  /* the emitter trails the pointer slightly, which is what gives the drift
     its lag on a fast flick instead of snapping rigidly to the cursor */
  WISP.ex = damp(WISP.ex, pt[0], 16, dt);
  WISP.ey = damp(WISP.ey, pt[1], 16, dt);

  const dx = WISP.ex - WISP.lx, dy = WISP.ey - WISP.ly;
  const moved = Math.hypot(dx, dy);
  const ang = moved > 1e-5 ? Math.atan2(dy, dx) : 0;
  const spawn = (x, y, a, weak) => {
    const i = WISP.i; WISP.i = (i + 1) % N;      /* slot and state must pair:
       advancing first would put the position in the next slot and the life in
       this one, and every mote appears where the one before it started */
    const w = L[i], k = i * 3;
    /* The plane the trail hangs on is only about 2.2 units tall, so the
       scatter has to be read against that: a few hundredths of a unit is a
       thread stitched to the cursor, not a drift of motes. */
    P[k] = x + (Math.random() + Math.random() - 1) * .30;
    P[k + 1] = y + (Math.random() + Math.random() - 1) * .30;
    P[k + 2] = -WISP_D + (Math.random() - .5) * .9;
    w.life = 0; w.max = (weak ? 2.1 : 1.45) + Math.random() * 1.3;
    w.vx = -Math.cos(a) * .09 + (Math.random() - .5) * .38;
    w.vy = -Math.sin(a) * .09 + (Math.random() - .5) * .32 + .02;
    w.sz = (weak ? .018 : .024) + Math.random() * .026;
    w.ph = Math.random() * TAU;
  };

  WISP.acc += moved;
  const STEP = .030;
  let guard = 0;
  while (WISP.acc >= STEP && guard++ < 14) {
    WISP.acc -= STEP;
    /* lay each one at the distance along the segment it is actually owed, so a
       flick draws a spaced-out line instead of a clump at one end of it */
    const t = moved > 1e-6 ? Math.min(1, guard * STEP / moved) : 0;
    spawn(WISP.lx + dx * t, WISP.ly + dy * t, ang, false);
  }
  /* the faintest breath when the hand is still, so the cursor keeps an aura.
     Rarely, and barely buoyant: emit often here and a stationary pointer
     grows a permanent column of smoke up the middle of the frame. */
  WISP.idle += dt;
  if (WISP.idle > .42) { WISP.idle = 0; spawn(WISP.ex, WISP.ey, Math.random() * TAU, true); }
  WISP.lx = WISP.ex; WISP.ly = WISP.ey;

  for (let i = 0; i < N; i++) {
    const w = L[i], k = i * 3;
    if (w.life >= w.max) { A[i] = 0; continue; }
    w.life += dt;
    const u = w.life / w.max;
    /* curl, so the drift frays rather than blowing along one straight line */
    P[k]     += (w.vx + Math.sin(clock * 1.3 + w.ph) * .17) * dt;
    P[k + 1] += (w.vy + Math.cos(clock * 1.1 + w.ph * 1.7) * .14) * dt;
    /* light damping: they have to coast to carry the scatter outward, where
       the old rate stopped every mote within a tenth of a unit of its spawn */
    w.vx *= 1 - .5 * dt; w.vy = w.vy * (1 - .5 * dt) + .022 * dt;    /* it rises */
    A[i] = smooth(0, .12, u) * (1 - smooth(.22, 1, u)) * .9;
    S[i] = w.sz * (1 + u * .55);       /* a mote softens, it does not swell */
  }
  g.attributes.position.needsUpdate = true;
  g.attributes.aA.needsUpdate = true;
  g.attributes.aS.needsUpdate = true;
}

/* ====================================================== cloth · the plates
   Canvas UI's Cloth, ported off React and off the experimental html-in-canvas
   path it ships with. That path exists so the effect can hang arbitrary live
   HTML on the fabric — it captures the subtree with drawElementImage(), which
   is Chrome-behind-a-flag only, and the component falls back to a flat render
   everywhere else.

   Here the fabric only ever carries a still, so the capture is not needed at
   all: the plate is drawn cover-cropped into a 2-D canvas with the card's own
   two scrims baked on, and that canvas is uploaded straight to the texture.
   The simulation, both shader passes and the option set are the component's,
   unchanged. The result is that the effect runs in every WebGL2 browser
   rather than one flag.

   The grid is the component's 96 squared. Three of these on one page is a
   third of a million cell updates a second, so each one hard-stops when its
   card leaves the viewport and again once the wind dies and the fabric has
   settled — both already in the component, and both load-bearing here. */
const CLOTH_VERT = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aGrid;
layout(location = 1) in vec4 aData;
layout(location = 2) in vec2 aOffset;
uniform vec2 uRes; uniform vec2 uOut; uniform float uBleed; uniform float uFocal;
out vec2 vUv; out vec3 vNormal; out float vFold; out vec2 vLocal;
void main () {
  vUv = aGrid;
  float z = aData.x;
  vec2 nxy = aData.yz;
  vNormal = vec3(nxy, sqrt(max(1.0 - dot(nxy, nxy), 0.04)));
  vFold = aData.w;
  vLocal = aGrid * uRes;
  vec2 px = vLocal + aOffset + vec2(uBleed);
  vec2 ndc = (px / uOut) * 2.0 - 1.0;
  ndc.y = -ndc.y;
  float w = (uFocal - z) / uFocal;
  gl_Position = vec4(ndc, -z / uFocal, w);
}`;
const CLOTH_SDF = `
float fabricDist (vec2 p, vec2 size, float radius) {
  vec2 half_ = size * 0.5;
  float r = min(radius, min(half_.x, half_.y));
  vec2 q = abs(p - half_) - (half_ - vec2(r));
  return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r;
}`;
const CLOTH_FRAG = `#version 300 es
precision highp float;
in vec2 vUv; in vec3 vNormal; in float vFold; in vec2 vLocal;
out vec4 outColor;
uniform sampler2D uContent; uniform float uMaxX; uniform float uLight;
uniform float uSheen; uniform vec3 uBacking; uniform vec2 uRes;
uniform float uRadius; uniform float uDark; uniform float uEdge;
${CLOTH_SDF}
void main () {
  vec2 uv = clamp(vUv, vec2(0.001), vec2(uMaxX - 0.001, 0.999));
  vec4 tex = texture(uContent, uv);
  vec3 fabric = mix(uBacking, tex.rgb, tex.a);
  vec3 n = normalize(vNormal);
  vec3 lightDir = normalize(vec3(-0.3, 0.42, 0.86));
  float diffFlat = 0.58 + 0.42 * lightDir.z;
  float diff = 0.58 + 0.42 * dot(n, lightDir);
  float shade = mix(1.0, (diff / diffFlat) * vFold, uLight);
  vec3 lit = fabric * shade;
  vec3 halfway = normalize(lightDir + vec3(0.0, 0.0, 1.0));
  float specFlat = pow(halfway.z, 34.0);
  float spec = max(pow(max(dot(n, halfway), 0.0), 34.0) - specFlat, 0.0) / (1.0 - specFlat);
  lit += uSheen * spec * mix(vec3(1.0), fabric, 0.35);
  float broadFlat = pow(halfway.z, 6.0);
  float broad = max(pow(max(dot(n, halfway), 0.0), 6.0) - broadFlat, 0.0) / (1.0 - broadFlat);
  lit += uDark * uLight * 0.3 * broad * vec3(1.0);
  float d = fabricDist(vLocal, uRes, uRadius);
  float hemT = smoothstep(0.0, 6.0, -d);
  lit *= mix(1.0, mix(0.93, 1.0, hemT), uLight * (1.0 - uDark));
  lit += vec3(uDark * uLight * 0.08 * (1.0 - hemT));
  /* The card's outline, drawn here rather than as a rule on the box: off the
     same distance field that cuts the fabric out, so it rides every fold,
     takes the perspective with it and rounds itself on the corners. A CSS
     outline can only ever trace the flat rectangle the cloth has left. */
  /* A hairline: the band is under a pixel wide and pulled tight against the
     cut, so it reads as an edge on the fabric rather than a drawn stroke. */
  float rim = smoothstep(1.05, 0.2, abs(d + 0.7));
  lit += rim * uEdge * vec3(0.874, 0.906, 0.878);

  float alpha = clamp(0.5 - d, 0.0, 1.0);
  outColor = vec4(clamp(lit, 0.0, 1.0), 1.0) * alpha;
}`;
const CLOTH_SHADOW_VERT = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aGrid;
layout(location = 1) in vec4 aData;
layout(location = 2) in vec2 aOffset;
uniform vec2 uRes; uniform vec2 uOut; uniform float uBleed;
out vec2 vLocal; out float vLift;
void main () {
  float z = aData.x;
  vLift = z;
  vLocal = aGrid * uRes;
  vec2 px = vLocal + aOffset + vec2(uBleed) + vec2(10.0, 14.0) + vec2(0.3, 0.42) * z;
  vec2 ndc = (px / uOut) * 2.0 - 1.0;
  ndc.y = -ndc.y;
  gl_Position = vec4(ndc, 0.0, 1.0);
}`;
const CLOTH_SHADOW_FRAG = `#version 300 es
precision highp float;
in vec2 vLocal; in float vLift;
out vec4 outColor;
uniform float uShadow; uniform vec2 uRes; uniform float uRadius; uniform float uDark;
${CLOTH_SDF}
void main () {
  float d = fabricDist(vLocal, uRes, uRadius);
  float a = uShadow * smoothstep(0.0, 30.0, -d);
  a *= mix(1.0, 0.55, clamp(vLift / 50.0, 0.0, 1.0));
  a *= mix(1.0, 0.55, uDark);
  outColor = vec4(vec3(uDark) * a, a);
}`;

const CL_SEG = 96, CL_NODES = CL_SEG + 1, CL_DT = 1 / 120;
const CL_WAVE = 30, CL_STIFF = 0.55, CL_GAIN = 5.0, CL_BLEED = 48;
const CLOTH_DEFAULTS = {
  pin: 'top', wind: 3, speed: .5, amplitude: 30, drape: 40, brush: 2.05,
  brushSize: 150, damping: 1, light: .5, sheen: .1, shadow: .25,
  cornerRadius: 20, backing: 'auto', perspective: 1200
};

function createCloth(output, plate, options) {
  const config = Object.assign({}, CLOTH_DEFAULTS, options || {});
  const wrapper = output.parentElement || output;
  output.style.top = output.style.left = -CL_BLEED + 'px';
  output.style.width = 'calc(100% + ' + CL_BLEED * 2 + 'px)';
  output.style.height = 'calc(100% + ' + CL_BLEED * 2 + 'px)';

  const gl = output.getContext('webgl2', { alpha: true, depth: false, stencil: false,
    antialias: true, premultipliedAlpha: true });
  if (!gl || gl.isContextLost()) return null;

  const compile = (type, text) => {
    const sh = gl.createShader(type);
    gl.shaderSource(sh, text); gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) console.error('Cloth:', gl.getShaderInfoLog(sh));
    return sh;
  };
  const link = (v, f) => {
    const prog = gl.createProgram();
    const vs = compile(gl.VERTEX_SHADER, v), fs = compile(gl.FRAGMENT_SHADER, f);
    gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog);
    const u = {}, n = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS);
    for (let i = 0; i < n; i++) { const info = gl.getActiveUniform(prog, i);
      u[info.name] = gl.getUniformLocation(prog, info.name); }
    return { program: prog, vert: vs, frag: fs, uniforms: u };
  };
  const cloth = link(CLOTH_VERT, CLOTH_FRAG);
  const shadow = link(CLOTH_SHADOW_VERT, CLOTH_SHADOW_FRAG);

  const gridVerts = new Float32Array(CL_NODES * CL_NODES * 2);
  for (let y = 0; y < CL_NODES; y++) for (let x = 0; x < CL_NODES; x++) {
    const i = (y * CL_NODES + x) * 2;
    gridVerts[i] = x / CL_SEG; gridVerts[i + 1] = y / CL_SEG;
  }
  const idx = new Uint32Array(CL_SEG * CL_SEG * 6);
  let o = 0;
  for (let y = 0; y < CL_SEG; y++) for (let x = 0; x < CL_SEG; x++) {
    const a = y * CL_NODES + x, b = a + 1, c = a + CL_NODES, d = c + 1;
    idx[o++] = a; idx[o++] = c; idx[o++] = b; idx[o++] = b; idx[o++] = c; idx[o++] = d;
  }
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);
  const gridBuf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
  gl.bufferData(gl.ARRAY_BUFFER, gridVerts, gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
  const dataBuf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, dataBuf);
  gl.bufferData(gl.ARRAY_BUFFER, CL_NODES * CL_NODES * 16, gl.DYNAMIC_DRAW);
  gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0);
  const offBuf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, offBuf);
  gl.bufferData(gl.ARRAY_BUFFER, CL_NODES * CL_NODES * 8, gl.DYNAMIC_DRAW);
  gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 0, 0);
  const idxBuf = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
  gl.bindVertexArray(null);

  const tex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
    new Uint8Array([0, 0, 0, 0]));

  /* the one place this differs from the component: the plate goes straight in */
  function upload() {
    const c = plate();
    if (!c || !c.width) return;
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c);
  }

  function syncSize() {
    const dpr = Math.min(devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.round(output.clientWidth * dpr));
    const h = Math.max(1, Math.round(output.clientHeight * dpr));
    if (output.width !== w || output.height !== h) { output.width = w; output.height = h; }
  }

  let hCur = new Float32Array(CL_NODES * CL_NODES);
  let hPrev = new Float32Array(CL_NODES * CL_NODES);
  let hNext = new Float32Array(CL_NODES * CL_NODES);
  const vData = new Float32Array(CL_NODES * CL_NODES * 4);
  const oData = new Float32Array(CL_NODES * CL_NODES * 2);
  const zF = new Float32Array(CL_NODES * CL_NODES);
  const rowF = new Float32Array(CL_NODES), colF = new Float32Array(CL_NODES);
  const hang = new Float32Array(CL_NODES);
  for (let a = 0; a < CL_NODES; a++) hang[a] = Math.pow(a / CL_SEG, 1.3);

  let simTime = Math.random() * 60, gust = .5, energy = 1;
  let edge = .048, edgeTo = .048;   /* barely there at rest, found on hover */
  const ptr = { x: -1e5, y: -1e5, inside: false };
  const touch = { x: -1e5, y: -1e5, vx: 0, vy: 0, s: 0 };
  const axisA = (x, y) => config.pin === 'top' ? y : config.pin === 'bottom' ? CL_SEG - y
    : config.pin === 'left' ? x : CL_SEG - x;
  const axisB = (x, y) => (config.pin === 'top' || config.pin === 'bottom') ? x : y;

  function stepSim(dt) {
    simTime += dt * Math.max(config.speed, 0);
    const t = simTime, windAmp = CL_GAIN * Math.max(config.wind, 0) * gust;
    const kb1 = (Math.PI * 2) / (CL_SEG / 1.5), kb2 = (Math.PI * 2) / (CL_SEG / 3.8);
    const ka = (Math.PI * 2) / (CL_SEG / 2.2);
    const w1 = CL_WAVE * kb1, w2 = CL_WAVE * kb2, drift = 1.8 * Math.sin(.23 * t);
    for (let b = 0; b < CL_NODES; b++)
      rowF[b] = Math.sin(kb1 * b - w1 * t + drift) + .45 * Math.sin(kb2 * b + w2 * t * .8 + 3);
    for (let a = 0; a < CL_NODES; a++)
      colF[a] = (.7 + .3 * Math.sin(ka * a - 1.7 * t)) * hang[a];
    const c2 = CL_WAVE * CL_WAVE, dt2 = dt * dt;
    const decay = Math.exp(-Math.min(Math.max(config.damping, .05), 8) * dt);
    for (let y = 0; y < CL_NODES; y++) {
      const up = Math.max(y - 1, 0) * CL_NODES, down = Math.min(y + 1, CL_SEG) * CL_NODES;
      const row = y * CL_NODES;
      for (let x = 0; x < CL_NODES; x++) {
        const i = row + x;
        const h = hCur[i];
        const lap = hCur[row + Math.max(x - 1, 0)] + hCur[row + Math.min(x + 1, CL_SEG)]
          + hCur[up + x] + hCur[down + x] - 4 * h;
        const force = windAmp * rowF[axisB(x, y)] * colF[axisA(x, y)];
        const next = 2 * h - hPrev[i] + dt2 * (c2 * lap - CL_STIFF * h + force);
        let v = h + (next - h) * decay;
        if (v > 3.5) v = 3.5; else if (v < -3.5) v = -3.5;
        hNext[i] = v;
      }
    }
    for (let b = 0; b < CL_NODES; b++) {
      let x = b, y = 0;
      if (config.pin === 'bottom') y = CL_SEG;
      else if (config.pin === 'left') { x = 0; y = b; }
      else if (config.pin === 'right') { x = CL_SEG; y = b; }
      hNext[y * CL_NODES + x] = 0;
    }
    const spent = hPrev; hPrev = hCur; hCur = hNext; hNext = spent;
  }

  function imprint(delta, width, height) {
    if (config.brush <= 0 || touch.s < .01) return;
    const cw = width / CL_SEG, ch = height / CL_SEG;
    const rx = Math.max(config.brushSize, 12) / cw, ry = Math.max(config.brushSize, 12) / ch;
    const gx = touch.x / cw, gy = touch.y / ch;
    const x0 = Math.max(Math.ceil(gx - 2.5 * rx), 0), x1 = Math.min(Math.floor(gx + 2.5 * rx), CL_SEG);
    const y0 = Math.max(Math.ceil(gy - 2.5 * ry), 0), y1 = Math.min(Math.floor(gy + 2.5 * ry), CL_SEG);
    const lift = 1.1 * Math.min(config.brush, 3) * touch.s, rate = Math.min(delta * 4, 1);
    for (let y = y0; y <= y1; y++) {
      const oy = (y - gy) / ry, row = y * CL_NODES;
      for (let x = x0; x <= x1; x++) {
        const ox = (x - gx) / rx, g = Math.exp(-(ox * ox + oy * oy));
        if (g < .02) continue;
        const i = row + x, pull = rate * g, goal = lift * g;
        hCur[i] += (goal - hCur[i]) * pull;
        hPrev[i] += (goal - hPrev[i]) * pull;
      }
    }
  }

  function foreshorten(stride, lineStride, ds, anchor, comp) {
    const ds2 = ds * ds;
    for (let l = 0; l < CL_NODES; l++) {
      const base = l * lineStride;
      oData[(base + anchor * stride) * 2 + comp] = 0;
      let cum = 0;
      for (let k = anchor + 1; k < CL_NODES; k++) {
        const i = base + k * stride, dz = zF[i] - zF[i - stride];
        cum += ds - Math.sqrt(Math.max(ds2 - dz * dz, 0));
        oData[i * 2 + comp] = -cum;
      }
      cum = 0;
      for (let k = anchor - 1; k >= 0; k--) {
        const i = base + k * stride, dz = zF[i] - zF[i + stride];
        cum += ds - Math.sqrt(Math.max(ds2 - dz * dz, 0));
        oData[i * 2 + comp] = cum;
      }
    }
  }

  function compose(width, height) {
    const amp = Math.max(config.amplitude, 0);
    const drape = config.drape * (.3 + .7 * gust);
    const cw = width / CL_SEG, ch = height / CL_SEG;
    let e = 0;
    for (let y = 0; y < CL_NODES; y++) {
      const row = y * CL_NODES;
      for (let x = 0; x < CL_NODES; x++) {
        const i = row + x, h = hCur[i];
        if (Math.abs(h) > e) e = Math.abs(h);
        zF[i] = amp * Math.tanh(h) + drape * hang[axisA(x, y)];
      }
    }
    energy = e;
    for (let y = 0; y < CL_NODES; y++) {
      const up = Math.max(y - 1, 0) * CL_NODES, down = Math.min(y + 1, CL_SEG) * CL_NODES;
      const row = y * CL_NODES;
      for (let x = 0; x < CL_NODES; x++) {
        const i = row + x;
        const l = row + Math.max(x - 1, 0), r = row + Math.min(x + 1, CL_SEG);
        const dzdx = (zF[r] - zF[l]) / (2 * cw), dzdy = (zF[down + x] - zF[up + x]) / (2 * ch);
        const inv = 1 / Math.hypot(dzdx, dzdy, 1);
        const curve = zF[l] + zF[r] + zF[up + x] + zF[down + x] - 4 * zF[i];
        let fold = 1 - curve * .01;
        if (fold < .86) fold = .86; else if (fold > 1.06) fold = 1.06;
        const q = i * 4;
        vData[q] = zF[i]; vData[q + 1] = -dzdx * inv; vData[q + 2] = -dzdy * inv; vData[q + 3] = fold;
      }
    }
    const mid = CL_SEG >> 1;
    if (config.pin === 'top' || config.pin === 'bottom') {
      foreshorten(CL_NODES, 1, ch, config.pin === 'top' ? 0 : CL_SEG, 1);
      foreshorten(1, CL_NODES, cw, mid, 0);
    } else {
      foreshorten(1, CL_NODES, cw, config.pin === 'left' ? 0 : CL_SEG, 0);
      foreshorten(CL_NODES, 1, ch, mid, 1);
    }
  }

  const backing = config.backing === 'auto' ? [.02, .026, .035] : config.backing;

  function draw() {
    const resW = Math.max(wrapper.clientWidth, 1), resH = Math.max(wrapper.clientHeight, 1);
    const outW = Math.max(output.clientWidth, 1), outH = Math.max(output.clientHeight, 1);
    const light = Math.min(Math.max(config.light, 0), 1);
    const radius = Math.max(config.cornerRadius, 0);
    const lum = .299 * backing[0] + .587 * backing[1] + .114 * backing[2];
    const dark = Math.min(Math.max((.5 - lum) / .35, 0), 1);

    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, output.width, output.height);
    gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

    gl.bindVertexArray(vao);
    gl.bindBuffer(gl.ARRAY_BUFFER, dataBuf); gl.bufferSubData(gl.ARRAY_BUFFER, 0, vData);
    gl.bindBuffer(gl.ARRAY_BUFFER, offBuf); gl.bufferSubData(gl.ARRAY_BUFFER, 0, oData);

    gl.useProgram(shadow.program);
    gl.uniform2f(shadow.uniforms.uRes, resW, resH);
    gl.uniform2f(shadow.uniforms.uOut, outW, outH);
    gl.uniform1f(shadow.uniforms.uBleed, CL_BLEED);
    gl.uniform1f(shadow.uniforms.uShadow, Math.min(Math.max(config.shadow, 0), 1));
    gl.uniform1f(shadow.uniforms.uRadius, radius);
    gl.uniform1f(shadow.uniforms.uDark, dark);
    gl.drawElements(gl.TRIANGLES, idx.length, gl.UNSIGNED_INT, 0);

    gl.useProgram(cloth.program);
    gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.uniform1i(cloth.uniforms.uContent, 0);
    gl.uniform2f(cloth.uniforms.uRes, resW, resH);
    gl.uniform2f(cloth.uniforms.uOut, outW, outH);
    gl.uniform1f(cloth.uniforms.uBleed, CL_BLEED);
    gl.uniform1f(cloth.uniforms.uFocal, Math.max(config.perspective, 200));
    gl.uniform1f(cloth.uniforms.uMaxX, 1);
    gl.uniform1f(cloth.uniforms.uLight, light);
    gl.uniform1f(cloth.uniforms.uSheen, Math.max(config.sheen, 0));
    gl.uniform1f(cloth.uniforms.uRadius, radius);
    gl.uniform1f(cloth.uniforms.uDark, dark);
    gl.uniform1f(cloth.uniforms.uEdge, edge);
    gl.uniform3f(cloth.uniforms.uBacking, backing[0], backing[1], backing[2]);
    gl.drawElements(gl.TRIANGLES, idx.length, gl.UNSIGNED_INT, 0);
    gl.bindVertexArray(null);
  }

  let raf = 0, last = performance.now(), debt = 0, running = false, visible = false, dead = false;
  function frame(now) {
    if (dead) return;
    if (!visible) { running = false; return; }
    const delta = Math.min((now - last) / 1000, 1 / 20);
    last = now;
    const width = Math.max(wrapper.clientWidth, 1), height = Math.max(wrapper.clientHeight, 1);
    if (!REDUCE) {
      const t = simTime;
      const target = Math.max(.55 + .35 * Math.sin(t * .31 + 1.3)
        + .25 * Math.sin(t * .83) * (.5 + .5 * Math.sin(t * .17)), .15);
      gust += (target - gust) * Math.min(delta * 2, 1);
      const sT = ptr.inside && config.brush > 0 ? 1 : 0;
      touch.s += (sT - touch.s) * Math.min(delta * (ptr.inside ? 8 : 2.5), 1);
      const om = 14;
      touch.vx += ((ptr.x - touch.x) * om * om - 2 * om * touch.vx) * delta;
      touch.vy += ((ptr.y - touch.y) * om * om - 2 * om * touch.vy) * delta;
      touch.x += touch.vx * delta; touch.y += touch.vy * delta;
      imprint(delta, width, height);
      edge += (edgeTo - edge) * Math.min(delta * 5, 1);
      debt = Math.min(debt + delta, CL_DT * 5);
      while (debt >= CL_DT) { stepSim(CL_DT); debt -= CL_DT; }
    }
    compose(width, height);
    draw();
    if (REDUCE || (config.wind <= .001 && energy < .004 && touch.s < .01)) { running = false; return; }
    raf = requestAnimationFrame(frame);
  }
  function start() {
    if (dead || running || !visible) return;
    running = true; last = performance.now(); raf = requestAnimationFrame(frame);
  }

  const ro = new ResizeObserver(() => { syncSize(); upload(); start(); });
  ro.observe(output);
  const io = new IntersectionObserver(es => {
    visible = es[es.length - 1] ? es[es.length - 1].isIntersecting : false;
    if (visible) start();
  });
  io.observe(output);
  const onMove = e => {
    const r = wrapper.getBoundingClientRect();
    const x = e.clientX - r.left, y = e.clientY - r.top;
    if (touch.s < .01) { touch.x = x; touch.y = y; touch.vx = touch.vy = 0; }
    ptr.x = x; ptr.y = y; ptr.inside = true; start();
  };
  const onLeave = () => { ptr.inside = false; };
  wrapper.addEventListener('pointermove', onMove, { passive: true });
  wrapper.addEventListener('pointerleave', onLeave, { passive: true });
  const onHidden = () => { if (document.hidden) { running = false; cancelAnimationFrame(raf); } else start(); };
  document.addEventListener('visibilitychange', onHidden);

  syncSize(); upload(); compose(Math.max(wrapper.clientWidth, 1), Math.max(wrapper.clientHeight, 1));
  return { refresh(){ syncSize(); upload(); start(); }, wake: start,
           setEdge(v){ edgeTo = v; start(); } };
}

/* the plate: the card's own still, cover-cropped, carrying the two scrims the
   CSS used to lay over it — baked in so they ripple with the cloth instead of
   sitting flat on top of it */
function clothPlate(img, w, h) {
  const c = cvs(Math.max(1, w | 0), Math.max(1, h | 0)), x = c.getContext('2d');
  const s = Math.max(c.width / img.width, c.height / img.height);
  const dw = img.width * s, dh = img.height * s;
  x.drawImage(img, (c.width - dw) / 2, (c.height - dh) / 2, dw, dh);
  let g = x.createLinearGradient(0, 0, 0, c.height);
  g.addColorStop(.36, 'rgba(3,6,9,.05)'); g.addColorStop(1, 'rgba(3,6,9,.73)');
  x.fillStyle = g; x.fillRect(0, 0, c.width, c.height);
  g = x.createLinearGradient(0, 0, 0, c.height);
  g.addColorStop(.46, 'rgba(4,6,9,0)'); g.addColorStop(1, 'rgba(4,6,9,.80)');
  x.fillStyle = g; x.fillRect(0, 0, c.width, c.height);
  return c;
}

function buildCardCloth() {
  if (COARSE) return;                     /* no pointer to brush it with */
  $$('.cards .card-fr').forEach(fr => {
    const url = (getComputedStyle(fr).backgroundImage.match(/url\(["']?([^"')]+)/) || [])[1];
    if (!url) return;
    const img = new Image();
    img.onload = () => {
      const dpr = Math.min(devicePixelRatio || 1, 2);
      let plate = null, pw = 0, ph = 0;
      const get = () => {
        const w = Math.round(fr.clientWidth * dpr), h = Math.round(fr.clientHeight * dpr);
        if (!plate || w !== pw || h !== ph) { plate = clothPlate(img, w, h); pw = w; ph = h; }
        return plate;
      };
      const out = document.createElement('canvas');
      out.className = 'cloth-out'; out.setAttribute('aria-hidden', 'true');
      fr.appendChild(out);
      const inst = createCloth(out, get, { wind: 3, speed: .5, amplitude: 30, drape: 40,
        brush: 2.05, brushSize: 150, damping: 1, light: .5, sheen: .1, shadow: .25,
        cornerRadius: 20, perspective: 1200, pin: 'top' });
      /* only hide the card's own painting once the fabric is really carrying it */
      if (!inst) { out.remove(); return; }
      fr.classList.add('on-cloth');
      const card = fr.closest('.card') || fr;
      card.addEventListener('pointerenter', () => inst.setEdge(.185), { passive: true });
      card.addEventListener('pointerleave', () => inst.setEdge(.048), { passive: true });
    };
    img.onerror = () => {};
    img.src = url;
  });
}

function buildLights() {
  scene.add(new THREE.HemisphereLight(0x53838f, 0x060a08, .13));

  const key = new THREE.DirectionalLight(0xb6dbe4, 1.22);
  key.position.set(2.6, 21, 2.5);
  key.target.position.set(0, 2.2, -12.5); scene.add(key.target);
  if (WANT_SHADOW) {
    key.castShadow = true;
    const S = LOW ? 1024 : 2048;
    key.shadow.mapSize.set(S, S);
    const c = key.shadow.camera;
    c.left = -26; c.right = 26; c.top = 34; c.bottom = -16; c.near = 3; c.far = 78;
    key.shadow.bias = -0.0012; key.shadow.normalBias = .035; key.shadow.radius = 2.2;
  }
  scene.add(key); WORLD.key = key;

  /* moonlight: no shadow, no fill, purely a rim down the right-hand slope of
     every roof — without it the hall is a flat black cut-out against the sky */
  const moonKey = new THREE.DirectionalLight(0xff6a42, .52);
  moonKey.position.set(26, 30, -60);
  moonKey.target.position.set(0, 8, -40); scene.add(moonKey.target);
  scene.add(moonKey);

  /* The hall's own lamps. They light the podium and the top of the flight and
     nothing else — a range that reaches the roof turns the whole building into
     a paper lantern and the silhouette disappears. */
  const hallL = new THREE.PointLight(0xff8a26, 2.3, 15, 2);
  hallL.position.set(0, PODIUM + 1.2, TEMPLE_Z + 8.6); scene.add(hallL); WORLD.hallLight = hallL;
  [-1, 1].forEach(s => {
    const w = new THREE.PointLight(0xff8420, 2.2, 11, 2);
    w.position.set(s * 11.4, PODIUM + 1.2, TEMPLE_Z + 5.6); scene.add(w);
  });

  /* the moon throws almost nothing, but a trace of red high on the right
     keeps it attached to the scene instead of floating on top of it */
  const moonL = new THREE.PointLight(0xff3a1c, 3.0, 46, 2);
  moonL.position.set(11.0, 17.0, -24.0); scene.add(moonL); WORLD.moonLight = moonL;

  const fill = new THREE.PointLight(0x86c6d2, 0.95, 30, 2);
  fill.position.set(-1, 13.5, -16.0); scene.add(fill);

  /* the flight is the spine of the composition — without a lamp of its own it
     falls into the same black as the ground and the eye has nothing to climb */
  const stairL = new THREE.PointLight(0xffa049, 4.2, 17, 2);
  stairL.position.set(0, 7.6, -26.0); scene.add(stairL);
}
/* ====================================================== 6 · planar mirror */
/* ================================================== 7 · post-processing */
const POST = { levels: [] };
const QUAD_VS = 'varying vec2 vUv;\nvoid main(){ vUv = uv; gl_Position = vec4( position.xy, 0.0, 1.0 ); }';

function initPost() {
  POST.cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  POST.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), null);
  POST.quad.frustumCulled = false;
  POST.qScene = new THREE.Scene(); POST.qScene.add(POST.quad);
  POST.up = new THREE.ShaderMaterial({
    uniforms: { tS: { value: null }, uAmt: { value: 1 } }, vertexShader: QUAD_VS,
    fragmentShader: 'uniform sampler2D tS; uniform float uAmt; varying vec2 vUv;\nvoid main(){ gl_FragColor = vec4(texture2D(tS,vUv).rgb*uAmt, 1.0); }',
    blending: THREE.AdditiveBlending, transparent: true, depthTest: false, depthWrite: false
  });
  if (!WANT_POST) return;
  const w = renderer.domElement.width, h = renderer.domElement.height;
  const O = { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, type: THREE.HalfFloatType, depthBuffer: false, stencilBuffer: false };
  POST.scene = new THREE.WebGLRenderTarget(w, h, Object.assign({}, O, { depthBuffer: true, samples: LOW ? 0 : 2 }));
  let lw = Math.max(2, w >> 1), lh = Math.max(2, h >> 1);
  const N = 4;
  for (let i = 0; i < N; i++) {
    POST.levels.push({ a: new THREE.WebGLRenderTarget(lw, lh, O), b: new THREE.WebGLRenderTarget(lw, lh, O), w: lw, h: lh });
    lw = Math.max(2, lw >> 1); lh = Math.max(2, lh >> 1);
  }
  POST.bright = new THREE.ShaderMaterial({
    uniforms: { tS: { value: null }, uThr: { value: .86 }, uKnee: { value: .50 } },
    vertexShader: QUAD_VS,
    fragmentShader:
      'uniform sampler2D tS; uniform float uThr; uniform float uKnee; varying vec2 vUv;\n' +
      'void main(){ vec3 c = texture2D(tS, vUv).rgb;\n' +
      ' float l = dot(c, vec3(0.2126,0.7152,0.0722));\n' +
      ' float k = smoothstep(uThr, uThr+uKnee, l);\n' +
      ' gl_FragColor = vec4(c*k, 1.0); }'
  });
  POST.blur = new THREE.ShaderMaterial({
    uniforms: { tS: { value: null }, uDir: { value: new THREE.Vector2(1, 0) } },
    vertexShader: QUAD_VS,
    fragmentShader:
      'uniform sampler2D tS; uniform vec2 uDir; varying vec2 vUv;\n' +
      'void main(){ vec3 c = texture2D(tS, vUv).rgb * 0.2270270270;\n' +
      ' c += texture2D(tS, vUv + uDir*1.3846153846).rgb * 0.3162162162;\n' +
      ' c += texture2D(tS, vUv - uDir*1.3846153846).rgb * 0.3162162162;\n' +
      ' c += texture2D(tS, vUv + uDir*3.2307692308).rgb * 0.0702702703;\n' +
      ' c += texture2D(tS, vUv - uDir*3.2307692308).rgb * 0.0702702703;\n' +
      ' gl_FragColor = vec4(c, 1.0); }'
  });
  POST.comp = new THREE.ShaderMaterial({
    uniforms: {
      tS: { value: null }, tB: { value: null }, uRes: { value: new THREE.Vector2(w, h) },
      uT: { value: 0 }, uBloom: { value: .34 }, uCA: { value: 1 }, uGrain: { value: .020 },
      uVig: { value: 1 }, uExp: { value: .62 }, uFade: { value: 1 }, uSat: { value: 1.05 }
    },
    vertexShader: QUAD_VS,
    fragmentShader:
      'uniform sampler2D tS; uniform sampler2D tB; uniform vec2 uRes;\n' +
      'uniform float uT, uBloom, uCA, uGrain, uVig, uExp, uFade, uSat;\n' +
      'varying vec2 vUv;\n' +
      'vec3 aces(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0.0, 1.0); }\n' +
      'void main(){\n' +
      ' vec2 d = vUv - 0.5; float r2 = dot(d,d);\n' +
      ' float ca = uCA * (0.30 + r2*2.6) * 0.0013;\n' +
      ' vec3 c;\n' +
      ' c.r = texture2D(tS, vUv + d*ca).r;\n' +
      ' c.g = texture2D(tS, vUv).g;\n' +
      ' c.b = texture2D(tS, vUv - d*ca).b;\n' +
      ' c += texture2D(tB, vUv).rgb * uBloom;\n' +
      ' c *= uExp;\n' +
      ' c = aces(c);\n' +
      ' float l = dot(c, vec3(0.2126,0.7152,0.0722));\n' +
      ' c = mix(vec3(l), c, uSat);\n' +
      ' c = mix(c, c*vec3(0.74,1.03,1.11), smoothstep(0.55,0.0,l)*0.80);\n' +    /* teal shadows */
      ' c = mix(c, c*vec3(1.035,0.995,0.968), smoothstep(0.50,1.0,l)*0.26);\n' + /* warm highs   */
      ' float v = smoothstep(1.22, 0.26, length(d*vec2(1.0,0.94))*1.42);\n' +
      ' c *= mix(1.0, v, uVig);\n' +
      ' float g = fract(sin(dot(vUv*uRes + uT*137.0, vec2(12.9898,78.233)))*43758.5453);\n' +
      ' c += (g-0.5)*uGrain;\n' +
      ' c *= uFade;\n' +
      ' vec3 e = pow(max(c,0.0), vec3(1.0/2.2));\n' +
      /* A touch of contrast, applied after the encode rather than before it:
         in linear the same curve mostly crushes the shadows, and this frame is
         nearly all shadow. Pivoted low, at 0.30, so it deepens the night
         without pulling the lit paper of the hall down with it. */
      ' e = clamp((e - 0.30) * 1.00 + 0.30, 0.0, 1.0);\n' +
      ' gl_FragColor = vec4( e, 1.0 );\n' +
      '}'
  });
}
function pass(mat, target, additive) {
  POST.quad.material = mat;
  renderer.setRenderTarget(target || null);
  if (!additive) renderer.clear(true, false, false);
  renderer.render(POST.qScene, POST.cam);
}
function renderPost() {
  const L = POST.levels;
  POST.bright.uniforms.tS.value = POST.scene.texture;
  pass(POST.bright, L[0].a);
  for (let i = 0; i < L.length; i++) {
    if (i > 0) { POST.up.blending = THREE.NoBlending; POST.up.uniforms.uAmt.value = 1; POST.up.uniforms.tS.value = L[i - 1].a; pass(POST.up, L[i].a); }
    POST.blur.uniforms.tS.value = L[i].a; POST.blur.uniforms.uDir.value.set(1 / L[i].w, 0); pass(POST.blur, L[i].b);
    POST.blur.uniforms.tS.value = L[i].b; POST.blur.uniforms.uDir.value.set(0, 1 / L[i].h); pass(POST.blur, L[i].a);
  }
  POST.up.blending = THREE.AdditiveBlending; POST.up.uniforms.uAmt.value = .52;
  for (let i = L.length - 1; i > 0; i--) { POST.up.uniforms.tS.value = L[i].a; pass(POST.up, L[i - 1].a, true); }
  POST.comp.uniforms.tS.value = POST.scene.texture;
  POST.comp.uniforms.tB.value = L[0].a;
  pass(POST.comp, null);
}

/* ================================================= 8 · the camera rig */
const CAM = [
  { p: [  0.0, 4.05, 13.6 ], t: [  0.0, 6.60, -18.0 ], fov: 36 },  /* 0 hero        */
  { p: [ -5.6, 2.35, 11.6 ], t: [  1.2, 5.60, -14.0 ], fov: 48 },  /* 1 the sanmon  */
  { p: [  1.2, 3.60,  2.2 ], t: [ -0.6, 7.50, -22.0 ], fov: 40 },  /* 2 gardens     */
  { p: [  5.2, 2.10, -3.4 ], t: [ -2.6, 7.00, -20.0 ], fov: 46 },  /* 3 craft       */
  { p: [  0.0, 7.60, -16.0 ], t: [  0.0, 13.0, -40.0 ], fov: 42 }, /* 4 afterlight  */
  { p: [  0.0, 10.5, -20.0 ], t: [  0.0, 3.00, -34.0 ], fov: 46 }  /* 5 footer      */
];
const RIG = { prog: 0, smooth: 0, mx: 0, my: 0, tmx: 0, tmy: 0, intro: 0, focus: -1, focusAmt: 0 };
let curveP, curveT, tmpCam;

function buildRig() {
  curveP = new THREE.CatmullRomCurve3(CAM.map(c => new THREE.Vector3(c.p[0], c.p[1], c.p[2])), false, 'catmullrom', .42);
  curveT = new THREE.CatmullRomCurve3(CAM.map(c => new THREE.Vector3(c.t[0], c.t[1], c.t[2])), false, 'catmullrom', .42);
  tmpCam = new THREE.PerspectiveCamera(CAM[0].fov, innerWidth / innerHeight, .35, 220);
  camera.layers.enable(1); camera.layers.enable(2);
}
const _p = new THREE.Vector3(), _t = new THREE.Vector3(), _d = new THREE.Vector3();
/* Every waypoint is composed for a wide frame. On a tall one the same
   numbers crop the gate in half, so the rig steps back along its own view
   axis and opens up a little instead of letting the sides fall away. */
function aspectFix() { return clamp((1.62 - innerWidth / innerHeight) / 1.05, 0, 1); }
function fitAspect(p, t, fov) {
  const nf = aspectFix();
  if (nf <= 0) return fov;
  _d.subVectors(p, t).normalize();
  p.addScaledVector(_d, nf * 8.2);
  p.y += nf * 1.1;
  return fov * (1 + nf * .40);
}
function applyCamera() {
  const N = CAM.length - 1;
  const u = clamp(RIG.smooth / N, 0, 1);
  curveP.getPoint(u, _p); curveT.getPoint(u, _t);
  const i = clamp(Math.floor(RIG.smooth), 0, N - 1), f = clamp(RIG.smooth - i, 0, 1);
  let fov = lerp(CAM[i].fov, CAM[i + 1].fov, f);

  fov = fitAspect(_p, _t, fov);

  /* the opening dolly: a long lens easing in from further back */
  const io = 1 - RIG.intro;
  _p.z += io * 5.6; _p.y += io * 0.65; fov += io * 8;

  /* parallax — a hand-held drift, never enough to break the frame */
  const par = 1 - smooth(0, 1.6, RIG.smooth) * .55;
  _p.x += RIG.mx * 0.62 * par; _p.y += RIG.my * 0.34 * par;
  _t.x -= RIG.mx * 0.20 * par; _t.y -= RIG.my * 0.12 * par;

  camera.position.copy(_p);
  camera.lookAt(_t);
  if (Math.abs(camera.fov - fov) > 1e-4) { camera.fov = fov; camera.updateProjectionMatrix(); }
}

/* ============================================== 9 · scroll ↔ chapters */
const SECS = [].slice.call(document.querySelectorAll('[data-cam]'));
let anchors = [], maxScroll = 1, activeSec = 0;
function measure() {
  maxScroll = Math.max(1, document.documentElement.scrollHeight - innerHeight);
  anchors = SECS.map((el, i) => {
    if (i === 0) return 0;
    if (i === SECS.length - 1) return maxScroll;
    return clamp(el.offsetTop + el.offsetHeight * .5 - innerHeight * .5, 0, maxScroll);
  });
  for (let i = 1; i < anchors.length; i++) anchors[i] = Math.max(anchors[i], anchors[i - 1] + 1);
}
function progressFor(y) {
  if (y <= anchors[0]) return 0;
  for (let i = 0; i < anchors.length - 1; i++)
    if (y <= anchors[i + 1]) return i + (y - anchors[i]) / (anchors[i + 1] - anchors[i]);
  return anchors.length - 1;
}

/* ============================================ 10 · the wordmark layout */
function layoutWord() {
  if (!WORD.group) return;
  const c = CAM[0];
  const hp = new THREE.Vector3(c.p[0], c.p[1], c.p[2]);
  const ht = new THREE.Vector3(c.t[0], c.t[1], c.t[2]);
  tmpCam.fov = fitAspect(hp, ht, c.fov);
  tmpCam.aspect = innerWidth / innerHeight;
  tmpCam.position.copy(hp);
  tmpCam.lookAt(ht);
  tmpCam.updateProjectionMatrix(); tmpCam.updateMatrixWorld(true);
  const hit = (nx, ny) => {
    const v = new THREE.Vector3(nx, ny, .5).unproject(tmpCam).sub(tmpCam.position).normalize();
    return tmpCam.position.clone().addScaledVector(v, (WORD_Z - tmpCam.position.z) / v.z);
  };
  const L = hit(-1, 0), R = hit(1, 0);
  const narrow = innerWidth < 760;
  /* four wide-tracked letters reach the frame edge exactly; pushing past it,
     as the six-letter word could afford to, decapitates the K and the E */
  const fill = narrow ? 1.06 : 1.00;
  const s = (R.x - L.x) * fill / WORD.ink.w;
  /* on a tall frame the mounds sit near mid-height, so the baseline has to
     ride above them or the whole word disappears into the grass */
  const base = hit(0, narrow ? -.16 : -.585);
  WORD.group.scale.setScalar(s);
  WORD.group.position.set(-WORD.ink.cx * s, base.y, WORD_Z);
  WORD.rise = WORD.ink.asc * s * 1.25;
}

/* =================================================== 11 · page wiring */
const $  = s => document.querySelector(s);
const $$ = s => [].slice.call(document.querySelectorAll(s));
const nav = $('#nav'), preEl = $('#pre'), preFill = $('#pre-fill'), prePct = $('#pre-pct');

function wireReveals() {
  splitHeadingWords();
  const groups = new Map();
  const items = $$('[data-rv], .mask-line');
  items.forEach(el => {
    const key = el.parentElement;
    const arr = groups.get(key) || []; arr.push(el); groups.set(key, arr);
  });
  groups.forEach(arr => arr.forEach((el, i) => el.dataset.rvd = i * 85));
  const io = new IntersectionObserver(es => {
    es.forEach(e => {
      if (!e.isIntersecting) return;
      io.unobserve(e.target);
      const d = parseFloat(e.target.dataset.rvd || 0);
      setTimeout(() => e.target.classList.add('rv-in'), REDUCE ? 0 : d);
    });
  }, { rootMargin: '0px 0px -10% 0px', threshold: .04 });
  items.forEach(el => { if (!el.closest('#hero')) io.observe(el); });
}

/* Word-level masks let every display heading arrive at a human reading pace.
   The original text remains the accessible label, while the visual words are
   explicitly presentational. The existing section observer still controls
   when each heading starts. */
function splitHeadingWords() {
  if (REDUCE) return;
  $$('h1.display, h2.display').forEach(heading => {
    const lines = heading.querySelectorAll('.mask-line');
    const targets = lines.length ? [].slice.call(lines) : [heading];
    targets.forEach(target => {
      if (target.dataset.wordReady === 'true') return;
      const phrase = target.textContent.replace(/\s+/g, ' ').trim();
      if (!phrase) return;
      target.dataset.wordReady = 'true';
      target.classList.add('word-reveal');
      target.setAttribute('aria-label', phrase);
      target.textContent = '';
      phrase.split(' ').forEach((word, i) => {
        if (i) target.appendChild(document.createTextNode(' '));
        const mask = document.createElement('span');
        const inner = document.createElement('span');
        mask.className = 'word-mask'; mask.setAttribute('aria-hidden', 'true');
        inner.className = 'word'; inner.textContent = word;
        inner.style.setProperty('--word-delay', (i * 72) + 'ms');
        mask.appendChild(inner); target.appendChild(mask);
      });
    });
  });
}

/* Foreground PNGs are section-owned until their chapter is called. At that
   point the active stage becomes a fixed lower viewport plane; its predecessor
   remains there just long enough to blur and fade out before returning home.

   While it holds that plane the stage is re-parented into #fg-sky, which lives
   outside .page and so is not capped by its stacking context — that is the
   only way a cut-out can pass in front of the nav and the chapter rail. The
   move costs nothing in layout: the stage is position:fixed either way, and
   its placement rules key off [data-fg], not off the section it came from. */
function wireForegroundStages() {
  const pairs = $$('.sec .fg, .foot .fg').map(stage => ({
    section:stage.closest('.sec, .foot'), stage
  })).filter(pair => pair.section);
  if (!pairs.length) return;

  const sky = $('#fg-sky');
  const ratios = new Map(pairs.map(pair => [pair.section, 0]));
  const homes = new WeakMap(pairs.map(pair => [pair.stage, pair.section]));
  const timers = new WeakMap();
  let activeStage = null;

  const lift = stage => {
    if (!sky || stage.parentNode === sky) return;
    sky.appendChild(stage);
    /* A re-inserted element has no previous computed style, so the entrance
       would land already finished. Resolve the parked state in its new parent
       first and the pieces have something to rise from. */
    void stage.offsetWidth;
  };
  const park = stage => {
    const home = homes.get(stage);
    if (home && stage.parentNode !== home) home.insertBefore(stage, home.firstChild);
  };

  const retire = stage => {
    if (!stage || stage === activeStage) return;
    const pending = timers.get(stage);
    if (pending) clearTimeout(pending);
    stage.classList.remove('fg-active');
    if (REDUCE) { park(stage); return; }
    stage.classList.add('fg-retiring');
    timers.set(stage, setTimeout(() => {
      stage.classList.remove('fg-retiring');
      timers.delete(stage);
      park(stage);
    }, 820));
  };

  const activate = stage => {
    if (!stage || stage === activeStage) return;
    const pending = timers.get(stage);
    if (pending) clearTimeout(pending);
    stage.classList.remove('fg-retiring');
    lift(stage);
    stage.classList.add('fg-active');
    const prior = activeStage;
    activeStage = stage;
    retire(prior);
  };

  const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => ratios.set(entry.target, entry.isIntersecting ? entry.intersectionRatio : 0));
    const next = pairs.reduce((best, pair) =>
      (ratios.get(pair.section) || 0) > (ratios.get(best.section) || 0) ? pair : best
    );
    const nextRatio = ratios.get(next.section) || 0;
    if (nextRatio > 0) activate(next.stage);
    else if (activeStage) {
      const prior = activeStage;
      activeStage = null;
      retire(prior);
    }
  }, { rootMargin:'-12% 0px -12% 0px', threshold:[0, .12, .32, .55] });

  pairs.forEach(pair => observer.observe(pair.section));
}

/* ------------------------------------------------------ leaving the hero
   Everything along the foot of the hero is spent the moment the walk starts:
   the cue has been read, the four chapters have been offered, the preview has
   been seen, and the scrim under them is only there to hold the type off the
   sanctuary. Letting the block slide away together reads as the page moving;
   giving each piece its own window empties the foot one element at a time
   instead, which reads as the hero standing down.

   The preview window is not staggered with the rest. It is the largest,
   flattest thing in the frame and a slow fade just dims it in place, so it
   goes early, goes quickly, and dissolves rather than fades: the blur is what
   makes the frame look like it is letting go of it rather than turning it
   down.

   Nothing is written until the scroll actually leaves zero, and everything is
   handed back the moment it returns: these elements carry the page's own
   entrance reveal, and an inline opacity set at rest would pre-empt it. */
function wireHeroExit() {
  const hero = $('#hero'); if (!hero) return;
  /* at = where in the exit it starts, span = how long it takes to go */
  const seq = [
    { el: $('.peek'),      at: .00, span: .26, blur: 10 },
    { el: $('.hero-cue'),  at: .10, span: .30, shift: true },
    ...$$('.chip').map((el, i) => ({ el: el, at: .20 + i * .10, span: .30, shift: true })),
    { el: $('.chapters'),  at: .60, span: .30 },  /* shifting it carries the chips twice */
    { el: $('.hero-side'), at: .70, span: .30 }   /* its transform is the centring -50% */
  ].filter(o => o.el);
  let on = false;
  const apply = () => {
    const t = clamp(scrollY / Math.max(1, innerHeight * .58), 0, 1);
    if (t <= 0) {
      if (!on) return;
      seq.forEach(o => {
        o.el.style.opacity = ''; o.el.style.transform = ''; o.el.style.filter = '';
        o.el.style.pointerEvents = ''; o.el.style.transition = '';
      });
      on = false; return;
    }
    on = true;
    seq.forEach(o => {
      /* these carry the reveal's own .9s opacity transition, which would
         animate every value written here — the fade would then trail the
         scroll by most of a second and stop reading as scroll-driven */
      o.el.style.transition = 'none';
      const a = 1 - smooth(o.at, o.at + o.span, t);
      o.el.style.opacity = a.toFixed(3);
      if (o.shift) o.el.style.transform = 'translate3d(0,' + ((1 - a) * 15).toFixed(1) + 'px,0)';
      if (o.blur) o.el.style.filter = a > .999 ? '' : 'blur(' + ((1 - a) * o.blur).toFixed(1) + 'px)';
      o.el.style.pointerEvents = a < .05 ? 'none' : '';
    });
  };
  addEventListener('scroll', apply, { passive: true });
  addEventListener('resize', apply, { passive: true });
  apply();
}

function wireNav() {
  let last = 0;
  const rail = $('#rail');
  const names = ['The Hidden Gate', 'The Sanmon', 'Still Gardens', 'Sacred Craft', 'Afterlight', 'Colophon'];
  SECS.forEach((s, i) => {
    const b = document.createElement('button');
    b.innerHTML = '<i></i>'; b.title = names[i] || '';
    b.setAttribute('aria-label', names[i] || 'section');
    b.addEventListener('click', () => scrollTo({ top: anchors[i], behavior: REDUCE ? 'auto' : 'smooth' }));
    rail.appendChild(b);
  });
  const dots = $$('#rail button');
  const links = $$('.nav-link');
  const burger = $('.nav-burger');
  const closeMenu = () => {
    nav.classList.remove('menu-open');
    burger.classList.remove('active');
    burger.setAttribute('aria-expanded', 'false');
    document.documentElement.classList.remove('nav-open');
  };
  const setMenu = open => {
    nav.classList.toggle('menu-open', open);
    burger.classList.toggle('active', open);
    burger.setAttribute('aria-expanded', String(open));
    document.documentElement.classList.toggle('nav-open', open);
  };
  burger.setAttribute('aria-controls', 'navlinks');
  burger.setAttribute('aria-expanded', 'false');
  burger.addEventListener('click', () => {
    if (innerWidth > 820) return;
    setMenu(!nav.classList.contains('menu-open'));
  });
  links.forEach(link => link.addEventListener('click', closeMenu));
  addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
  addEventListener('resize', () => { if (innerWidth > 820) closeMenu(); }, { passive:true });
  /* Which chapter each link actually points at, read off its href. The old
     rule was positional — link i lit for section i+1 — which silently assumed
     one link per section in matching order. With five links over four chapters
     every entry past the third lit for its neighbour: standing in Afterlight
     highlighted Stories. Reading the destination cannot drift. */
  const linkSec = links.map(l => SECS.indexOf(document.querySelector(l.getAttribute('href'))));
  window.addEventListener('scroll', () => {
    const y = scrollY;
    nav.classList.toggle('stuck', y > 40);
    nav.classList.toggle('hide', y > last + 4 && y > innerHeight * .8);
    last = y;
    const a = Math.round(progressFor(y));
    if (a !== activeSec) {
      activeSec = a;
      dots.forEach((d, i) => d.classList.toggle('on', i === a));
      links.forEach((l, i) => l.classList.toggle('on', linkSec[i] === a));
    }
  }, { passive: true });
  $$('a[href^="#"]').forEach(a => a.addEventListener('click', e => {
    const t = document.querySelector(a.getAttribute('href'));
    if (!t) return;
    e.preventDefault();
    scrollTo({ top: a.getAttribute('href') === '#top' ? 0 : t.offsetTop - 40, behavior: REDUCE ? 'auto' : 'smooth' });
  }));
}

function wireFocus() {
  const set = i => { RIG.focus = i; };
  $$('[data-chip]').forEach(el => {
    el.addEventListener('mouseenter', () => { set(+el.dataset.chip); $$('[data-chip]').forEach(o => o.classList.toggle('on', o === el)); });
    el.addEventListener('mouseleave', () => { set(-1); el.classList.remove('on'); });
  });
  $$('[data-les]').forEach(el => {
    el.addEventListener('mouseenter', () => set(+el.dataset.les % 4));
    el.addEventListener('mouseleave', () => set(-1));
  });
}

function wireCursor() {
  const dot = $('#cursor');
  if (COARSE) { dot.style.display = 'none'; return; }
  let x = innerWidth / 2, y = innerHeight / 2, tx2 = x, ty = y;
  addEventListener('pointermove', e => {
    tx2 = e.clientX; ty = e.clientY;
    RIG.tmx = (e.clientX / innerWidth) * 2 - 1;
    RIG.tmy = -((e.clientY / innerHeight) * 2 - 1);
  }, { passive: true });
  $$('[data-cursor]').forEach(el => {
    el.addEventListener('mouseenter', () => dot.classList.add('act'));
    el.addEventListener('mouseleave', () => dot.classList.remove('act'));
  });
  (function tick() {
    x = lerp(x, tx2, .18); y = lerp(y, ty, .18);
    dot.style.transform = 'translate3d(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px,0)';
    requestAnimationFrame(tick);
  })();
}

function makeGrain() {
  const S = 180, c = cvs(S, S), x = c.getContext('2d');
  const im = x.createImageData(S, S), d = im.data, r = mulberry32(9);
  for (let i = 0; i < S * S; i++) {
    const v = 110 + r() * 90;
    d[i * 4] = d[i * 4 + 1] = d[i * 4 + 2] = v; d[i * 4 + 3] = 255;
  }
  x.putImageData(im, 0, 0);
  $('#grain').style.backgroundImage = 'url(' + c.toDataURL('image/png') + ')';
}

/* ============================================ 12 · the card viewports */
const CARDS = [];
function buildCards() {
  const defs = [
    { p: [-2.0, 1.60, -2.0], t: [0, 10.0, -34.0], fov: 40 },    /* the long climb */
    { p: [4.2, 2.90, -9.5], t: [6.4, 2.90, -14.4], fov: 40 },   /* lantern court  */
    { p: [3.4, 2.40, 2.0], t: [-1.0, -.60, -6.0], fov: 40 },    /* the wet court  */
    { p: [0.6, 3.40, -12.0], t: [0, 12.0, -40.0], fov: 26 }     /* hero window    */
  ];
  $$('[data-view]').forEach(el => {
    const d = defs[+el.dataset.view]; if (!d) return;
    const c = new THREE.PerspectiveCamera(d.fov, 4 / 5, .3, 200);
    c.position.set(d.p[0], d.p[1], d.p[2]);
    c.lookAt(d.t[0], d.t[1], d.t[2]);
    c.layers.set(0);
    c.userData = { home: c.position.clone(), look: new THREE.Vector3(d.t[0], d.t[1], d.t[2]), push: 0, want: 0 };
    CARDS.push({ cam: c, el: el.querySelector('[data-frame]') || el, rect: null });
    el.addEventListener('mouseenter', () => c.userData.want = 1);
    el.addEventListener('mouseleave', () => c.userData.want = 0);
  });
}
/* A render target keeps its own viewport/scissor — renderer.setScissor()
   only ever reaches the default framebuffer, so the region has to be set on
   whichever surface we are actually drawing into, and re-bound to take. */
const _push = new THREE.Vector3();
function setRegion(rt, x, y, w, h) {
  if (rt) {
    const pr = renderer.getPixelRatio();
    rt.viewport.set(x * pr, y * pr, w * pr, h * pr);
    rt.scissor.set(x * pr, y * pr, w * pr, h * pr);
    rt.scissorTest = true;
    renderer.setRenderTarget(rt);
  } else {
    renderer.setViewport(x, y, w, h);
    renderer.setScissor(x, y, w, h);
    renderer.setScissorTest(true);
  }
}
function clearRegion(rt) {
  if (rt) {
    rt.viewport.set(0, 0, rt.width, rt.height);
    rt.scissor.set(0, 0, rt.width, rt.height);
    rt.scissorTest = false;
    renderer.setRenderTarget(rt);
  } else {
    renderer.setScissorTest(false);
    renderer.setViewport(0, 0, innerWidth, innerHeight);
    renderer.setScissor(0, 0, innerWidth, innerHeight);
  }
}
function cardBuffer(C, w, h) {
  const pr = Math.min(renderer.getPixelRatio(), 2);
  const W = Math.max(8, Math.round(w * pr)), H = Math.max(8, Math.round(h * pr));
  if (C.rt && C.rt.width === W && C.rt.height === H) return C.rt;
  if (C.rt) C.rt.dispose();
  C.rt = new THREE.WebGLRenderTarget(W, H, {
    minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter,
    type: THREE.HalfFloatType, depthBuffer: true, stencilBuffer: false
  });
  C.dirty = true;
  return C.rt;
}
function aimCard(C, aspect) {
  const c = C.cam, u = c.userData;
  c.aspect = aspect; c.updateProjectionMatrix();
  _push.subVectors(u.look, u.home).normalize();
  c.position.copy(u.home).addScaledVector(_push, u.push * .55);
  c.lookAt(u.look);
}
/* Each viewport keeps its own buffer and only re-renders when it is moving
   or when its slot in the refresh rota comes round — the views are static,
   so paying for four scene traversals every frame buys nothing. */
/* The blit lands in the composite at the element's screen rect, and the canvas
   knows nothing about the DOM stacked over it — so a card the page has faded
   goes on painting a live view into the frame with no card around it, which is
   what put a second lit hall in the corner once the hero exit started dimming
   the preview. Cull on the element's effective opacity, not just its rect.

   Below full opacity is the right place to stop: the card's own still is
   opaque, so the view behind it only ever shows through by (1 − alpha). At
   alpha just under one that is nothing, and the cut is invisible. */
function cardAlpha(el) {
  let a = 1;
  for (let n = el; n && n !== document.body; n = n.parentElement) {
    const s = getComputedStyle(n);
    if (s.display === 'none' || s.visibility === 'hidden') return 0;
    a *= +s.opacity;
    if (a < .001) return 0;
  }
  return a;
}
function renderCards(rt) {
  if (!CARDS.length) return;
  let drew = false;
  for (let i = 0; i < CARDS.length; i++) {
    const C = CARDS[i], r = C.el.getBoundingClientRect();
    if (r.bottom < -40 || r.top > innerHeight + 40 || r.width < 4) continue;
    if (cardAlpha(C.el) < .995) continue;
    /* a card carrying cloth has no background left of its own, so the blit
       would show through the fabric's rounded corners and past its edges */
    if (C.el.classList.contains('on-cloth')) continue;
    if (!WANT_POST) {                              /* debug path: straight in */
      setRegion(rt, r.left, innerHeight - r.bottom, r.width, r.height);
      renderer.clear(true, true, false);
      aimCard(C, r.width / r.height);
      renderer.render(scene, C.cam);
      drew = true; continue;
    }
    const buf = cardBuffer(C, r.width, r.height);
    const u = C.cam.userData;
    if (C.dirty || Math.abs(u.push - u.want) > .002 || (FRAME + i * 7) % 24 === 0) {
      aimCard(C, r.width / r.height);
      renderer.setRenderTarget(buf);
      renderer.clear(true, true, false);
      renderer.render(scene, C.cam);
      C.dirty = false;
    }
    setRegion(rt, r.left, innerHeight - r.bottom, r.width, r.height);
    POST.up.blending = THREE.NoBlending;
    POST.up.uniforms.uAmt.value = 1;
    POST.up.uniforms.tS.value = buf.texture;
    pass(POST.up, rt);
    drew = true;
  }
  if (drew) clearRegion(rt);
}

/* ==================================================== 13 · the machine */
let running = false, tPrev = 0, clock = 0, fadeIn = 0;
const INTRO = { t0: 0 };

function resize() {
  const w = innerWidth, h = innerHeight;
  renderer.setPixelRatio(Math.min(devicePixelRatio || 1, DPR_CAP) * PERF.scale);
  renderer.setSize(w, h, false);
  camera.aspect = w / h; camera.updateProjectionMatrix();
  const pw = renderer.domElement.width, ph = renderer.domElement.height;
  if (WANT_POST && POST.scene) {
    POST.scene.setSize(pw, ph);
    POST.comp.uniforms.uRes.value.set(pw, ph);
    let lw = Math.max(2, pw >> 1), lh = Math.max(2, ph >> 1);
    POST.levels.forEach(L => { L.a.setSize(lw, lh); L.b.setSize(lw, lh); L.w = lw; L.h = lh; lw = Math.max(2, lw >> 1); lh = Math.max(2, lh >> 1); });
  }
  /* multisampling is the first thing to go when the budget tightens */
  if (POST.scene) {
    const want = (!LOW && PERF.scale > .78) ? 2 : 0;
    if (POST.scene.samples !== want) { POST.scene.samples = want; POST.scene.dispose(); }
  }
  CARDS.forEach(C => { C.dirty = true; });
  if (WANT_SHADOW && WORLD.key) WORLD.key.shadow.needsUpdate = true;
  if (WORLD.embers) WORLD.embers.material.uniforms.uSize.value = h * renderer.getPixelRatio() * .5;
  if (WISP.mesh) WISP.mesh.material.uniforms.uPx.value = h * renderer.getPixelRatio();
  placeMoon();
  layoutWord();
  measure();
}

function updateWorld(dt) {
  WORLD.uT.value = clock;

  /* the focused chapter warms the lanterns and swells the disc */
  RIG.focusAmt = damp(RIG.focusAmt, RIG.focus >= 0 ? 1 : 0, 5, dt);
  const pulse = Math.sin(clock * 1.9) * .5 + .5;
  const f = RIG.focusAmt;
  if (WORLD.hallHalo) WORLD.hallHalo.material.opacity = .30 + f * .14 + Math.sin(clock * .6) * .035;
  if (WORLD.hallLight) WORLD.hallLight.intensity = 3.4 * (1 + f * .30) * (1 + Math.sin(clock * .43) * .045);
  /* the moon only breathes — the haze in front of it is what actually moves */
  if (WORLD.moonHalo) WORLD.moonHalo.material.opacity = .44 + f * .10 + Math.sin(clock * .34) * .05;
  if (WORLD.lanternLights) WORLD.lanternLights.forEach((l, i) => {
    l.intensity = 2.6 * (1 + f * .55) * (.86 + .22 * Math.sin(clock * (2.3 + i * .7) + i * 2.1) + .1 * pulse);
  });
  if (WORLD.lanternGlows) WORLD.lanternGlows.forEach(g => { g.quaternion.copy(camera.quaternion); });

  /* haze slides across the courtyard */
  if (WORLD.haze) WORLD.haze.forEach(h => {
    h.position.x = h.userData.x0 + Math.sin(clock * h.userData.sp + h.userData.ph) * 5.5;
    h.quaternion.copy(camera.quaternion);
  });

  /* ripples on the standing water */
  if (WORLD.ripples) WORLD.ripples.forEach(r => {
    const u = r.userData;
    u.t += dt * u.sp;
    if (u.t > 4) { u.t = 0; u.x = (Math.random() - .5) * 22; u.z = -8 + Math.random() * 22; }
    const k = u.t / 4;
    const s = .3 + k * 4.2;
    r.scale.set(s, s, 1);
    r.position.set(u.x, .02, u.z);
    r.material.opacity = Math.sin(k * Math.PI) * .16;
  });

  /* the type rises from behind the grass, then dissolves as you close on it */
  if (WORD.group) {
    const near = smooth(0.02, 0.92, RIG.smooth);
    WORD.glyphs.forEach((g, i) => {
      const st = clamp((WORD.reveal - i * .075) / .62, 0, 1);
      const e = easeOut(st);
      g.position.y = g.userData.baseY - (1 - e) * (WORD.ink.asc * 1.15);
      g.material.opacity = e * (1 - near * .96);
      g.visible = g.material.opacity > .004;
    });
  }
  /* the cut-out layers dissolve as the camera reaches them, so the path can
     walk straight through the garden instead of steering around the veil */
  if (WORLD.fg) WORLD.fg.forEach(m => {
    const a = smooth(.9, 4.6, camera.position.z - m.position.z);
    m.material.opacity = a;
    m.visible = a > .006;
  });
  updateLeaves(dt);
  updateWisps(dt);
  if (CARDS.length) CARDS.forEach(C => { const u = C.cam.userData; u.push = damp(u.push, u.want, 3.4, dt); });
}

let FRAME = 0;
function render() {
  FRAME++;
  renderer.setRenderTarget(WANT_POST ? POST.scene : null);
  renderer.clear(true, true, false);
  renderer.render(scene, camera);
  renderCards(WANT_POST ? POST.scene : null);
  renderer.setRenderTarget(null);
  if (WANT_POST) {
    POST.comp.uniforms.uT.value = clock;
    POST.comp.uniforms.uFade.value = fadeIn;
    renderPost();
  }
}

function frame(now) {
  if (!running) return;
  const raw = (now - tPrev) / 1000 || 0;
  const dt = Math.min(raw, .05);              /* animation never jumps … */
  tPrev = now; clock += dt;
  fadeIn = INTRO.t0 ? sat((now - INTRO.t0) / 700) : 1;

  if (!PERF.locked && clock > 2.2) {
    PERF.acc += raw; PERF.n++;      /* … but the governor reads the truth */
    if (PERF.n >= 40 || PERF.acc > .9) {
      const avg = PERF.acc / PERF.n; PERF.acc = 0; PERF.n = 0;
      if (avg > .0230 && PERF.scale > .55) { PERF.scale = Math.max(.55, PERF.scale * (avg > .05 ? .64 : .85)); resize(); }
      else if (avg < .0138 && PERF.scale < 1) { PERF.scale = Math.min(1, PERF.scale + .08); resize(); }
    }
  }
  RIG.prog = progressFor(scrollY);
  RIG.smooth = REDUCE ? RIG.prog : damp(RIG.smooth, RIG.prog, 5.2, dt);
  RIG.mx = damp(RIG.mx, RIG.tmx, 2.6, dt);
  RIG.my = damp(RIG.my, RIG.tmy, 2.6, dt);
  if (INTRO.t0) {
    const el = (now - INTRO.t0) / 1000;
    RIG.intro = sat(el / 2.4);
    WORD.reveal = Math.min(1.2, el / 1.5);
  }

  applyCamera();
  updateWorld(dt);
  render();
  queue();
}
const TIMER = qs('driver', 'raf') === 'timer';
function queue() { TIMER ? setTimeout(() => frame(performance.now()), 16) : requestAnimationFrame(frame); }

/* ======================================================== 14 · booting */
const JOBS = [
  ['Reading the type', () => document.fonts && document.fonts.load('600 320px Wordmark')],
  ['Pouring the ground', () => { initGL(); WORLD.uT = { value: 0 }; buildRig(); buildLights(); }],
  ['Cutting the approach', () => buildShell()],
  ['Raising the hall', () => buildTemple()],
  ['Hanging the moon', () => buildMoon()],
  ['Setting the gate', () => buildTorii()],
  ['Placing the stones', () => {
    buildRocks();
    buildLantern(7.4, -7.0, 1.15); buildLantern(-7.6, -5.2, 1.0);
    /* The pairs flanking the flight were given flat heights — 2 and PODIUM —
       and the flight does not reach either at those depths, so both pairs hung
       in the air. Solve the step under each one from the same constants the
       treads and cheeks are built from, and they stand on the rail instead of
       beside it. */
    const stepAt   = z => Math.max(0, (STAIR_Z0 - z) / STAIR_RUN - .5);
    const cheekTop = z => (stepAt(z) + 1) * (PODIUM / STEPS) + .45;
    const cheekX   = z => (STAIR_W + (STEPS - stepAt(z)) * .052) / 2 + .45;
    [-14.4, -23.5].forEach((z, i) => {
      const sc = i ? .95 : .90;
      buildLantern( cheekX(z), z, sc, cheekTop(z));
      buildLantern(-cheekX(z), z, sc, cheekTop(z));
    });
  }],
  ['Growing the maples', () => {
    buildMaple(71, 12.6, -13.0, 1.05); buildMaple(72, -11.8, -9.4, .95);
    buildMaple(73, 9.2, -19.0, .82);   buildMaple(74, -14.5, -17.5, 1.0);
    buildMaple(75, 16.5, -6.0, .88);
  }],
  ['Painting the near grass', () => buildForeground()],
  ['Cutting the word', () => buildWordmark()],
  ['Raising the mist', () => { buildAtmosphere(); buildLeafFall(); buildWisps(); }],
  ['Polishing the water', () => {
    initPost(); buildCards(); buildCardCloth();
    WORLD.fg.forEach(m => m.layers.set(1));
    WORD.glyphs.forEach(m => m.layers.set(2));
    if (WORLD.rain) WORLD.rain.layers.set(1);
    /* the fall is all around the rig, including behind it — mirroring it
       would drop leaves into the water that are nowhere near the water */
    if (WORLD.leaves) WORLD.leaves.mesh.layers.set(1);
    WORLD.ripples.forEach(r => r.layers.set(1));
    layoutWord(); measure();
    /* nothing that casts a shadow ever moves, so bake it once */
    if (WANT_SHADOW && WORLD.key) { WORLD.key.shadow.autoUpdate = false; WORLD.key.shadow.needsUpdate = true; }
  }]
];

function boot() {
  makeGrain();
  wireReveals(); wireForegroundStages(); wireNav(); wireHeroExit(); wireFocus(); wireCursor();
  document.body.classList.add('is-locked');
  let i = 0;
  const step = () => {
    const j = JOBS[i];
    const done = () => {
      i++;
      const p = i / JOBS.length;
      preFill.style.right = ((1 - p) * 100).toFixed(1) + '%';
      prePct.textContent = Math.round(p * 100);
      if (i < JOBS.length) setTimeout(step, 16); else setTimeout(start, 220);
    };
    let r;
    try { r = j[1](); }
    catch (err) {
      console.error('[kage] job "' + j[0] + '" failed', err);
      if (i <= 1) return fallback(err);          /* no renderer, no scene */
    }
    (r && r.then) ? r.then(done, done) : done();
  };
  setTimeout(step, 60);
}

function fallback(err) {
  document.documentElement.classList.add('no-webgl');
  document.body.classList.add('no-webgl');
  document.body.classList.remove('is-locked');
  preEl.classList.add('done');
  $$('[data-rv], .mask-line').forEach(e => e.classList.add('rv-in'));
  window.__kage = window.__secret = { fallback: true, error: String(err && err.message || err) };
}

function start() {
  addEventListener('resize', () => { resize(); }, { passive: true });
  addEventListener('orientationchange', () => setTimeout(resize, 250));
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) { running = false; }
    else if (!running) { running = true; tPrev = performance.now(); queue(); }
  });
  resize();

  const shot = Q.get('shot');
  if (shot !== null) {                       /* deterministic states for review */
    RIG.intro = 1; WORD.reveal = 1.2; fadeIn = 1; INTRO.t0 = 0;
    const n = clamp(parseInt(shot, 10) || 0, 0, SECS.length - 1);
    scrollTo(0, anchors[n]);
    RIG.smooth = RIG.prog = progressFor(anchors[n]);
    $$('[data-rv], .mask-line').forEach(e => e.classList.add('rv-in'));
    document.body.classList.remove('is-locked');
    preEl.classList.add('done');
  } else {
    preEl.classList.add('done');
    setTimeout(() => {
      document.body.classList.remove('is-locked');
      $('#hero').querySelectorAll('[data-rv], .mask-line').forEach((e, i) =>
        setTimeout(() => e.classList.add('rv-in'), REDUCE ? 0 : 120 + i * 95));
    }, REDUCE ? 0 : 340);
  }
  running = true; tPrev = performance.now();
  INTRO.t0 = shot !== null ? 0 : (REDUCE ? performance.now() - 4000 : performance.now());
  queue();
  window.__kage = window.__secret = { RIG: RIG, WORLD: WORLD, WORD: WORD, CAM: CAM, POST: POST, renderer: renderer, scene: scene, camera: camera, anchors: () => anchors };
}

if (document.readyState === 'complete' || document.readyState === 'interactive') setTimeout(boot, 0);
else addEventListener('DOMContentLoaded', boot);
})();
</script>
</body>
</html>

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Node.js

Security

What our scanner noticed· Medium risk

  • Large base64-encoded blobs
  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Meng To
Publisher
Meng To (Community)
Source
MengTo/Skills · agent-skills/web-design/build-threejs-scroll-worlds · commit 5ea1661
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Meng To or Meng To.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download build-threejs-scroll-worlds.zip