# Signature Effects — recipe contract (BK-357)

Read this only when a manifest screen carries a `signature` block (see SKILL.md → "Signature moments"). It turns a flat screen into one that stops the scroll — without breaking self-containment, performance, or accessibility.

The governing lesson from the dogfood that produced this library: **a restrained-but-tasteful screen loses; one art-directed focal spectacle wins.** Commit to ONE moment per screen and make it loud. Everything else on the screen stays calm.

## The one rule

**One signature per screen.** The `signature.type` names it. Everything else uses only entrance reveals or stays still. A page with two spectacles has none. Utility screens (dashboards, settings, data tables) get `signature.type: "none"` on purpose — in a work surface, ambient motion is a defect, not delight.

## The tiers (pick the smallest that delivers the moment)

| Tier | Tech | Budget | Use when |
|---|---|---|---|
| **T0** | CSS only (+ tiny inline JS) | ~0 runtime cost, ≤150 KB total | Scroll choreography, kinetic type, gradient mesh, magnetic CTA. Reach for T0 first. |
| **T1** | one vanilla-JS `<canvas>`, < 5 KB | pauses off-viewport + tab-hidden | Particle ambience (embers, dust) whose colours read from tokens. |
| **T2** | raw WebGL fragment shader (no libraries) | lazy after LCP, DPR ≤ 1.5 | Full-viewport atmospheric fields: aurora, liquid metal, gradient flow. |
| **T3** | three.js (or similar), **vendored-by-bundling** | ≤ 600 KB total, lazy + poster-first | Genuine 3D: floating objects, particle systems that assemble. Only when 2D truly can't carry it. |

## Non-negotiable contract (every tier, every recipe)

1. **Poster-first.** The first paint is an inline CSS/SVG poster that approximates the effect. The effect fades in *after* it initialises. The poster is also the permanent fallback.
2. **Reduced-motion.** Under `@media (prefers-reduced-motion: reduce)` the effect never starts (WebGL/canvas/3D) or collapses to its final static state (CSS). The screen is complete on the poster alone.
3. **JS-off.** With scripts disabled the poster carries the screen. No broken hero, ever.
4. **Token-driven.** Colours come from CSS custom properties (`--accent`, `--glow`, surface tokens). Shaders and 3D materials re-read them on `data-theme` change (use a `MutationObserver`). Changing one token must recolour the effect — that is the buyer/customiser promise.
5. **Lazy + polite.** Init after `requestIdleCallback` and only when the canvas is in view (`IntersectionObserver`). Pause when off-screen and when `document.hidden`.
6. **Honest performance.** T0/T1/T2 keep Lighthouse ≥ 90. T3 targets LCP < 2.5 s desktop and its real Lighthouse score is measured and stated, floor 75. Never claim a number you did not measure.

## The T3 rule (hard, non-obvious — do not skip)

Modern three.js ships **ES-module builds only**, and browsers **block ES modules loaded from `file://`**. A pack that a buyer unzips and double-clicks would break. Therefore T3 effects must be **vendored-by-bundling**:

- Author the scene as a module, then compile it to ONE classic-script IIFE with a bundler (esbuild: `--bundle --minify --format=iife`).
- Load the bundle with a plain `<script src="assets/js/<name>.bundle.js">` that sits **beside the hero** (never a CDN).
- The scene exposes `window.<Name>.init(canvas, config)` and returns `{ start, stop }`; the HTML lazy-loads the bundle poster-first.

This is the only way three.js works from `file://`. It was verified end-to-end in the dogfood.

## QA rules (each earned from a real bug — check every one before you ship a signature)

1. **`--on-accent` is a required token, and check fill-behind-text contrast.** A bright accent that reads fine as text on the surface can still fail AA as a *fill behind text* (e.g. white on a light-orange button = 2.86:1). Give primary CTAs `color: var(--on-accent)` and verify ≥ 4.5:1 (or ≥ 3:1 large) in BOTH themes.
2. **Never combine `filter` with `background-clip: text`.** The filter paints the element box and defeats the clip, dropping an opaque rectangle behind gradient text. Use `text-shadow`/`drop-shadow` on a *separate* element, or skip the filter.
3. **No timer-raced style mutations.** Rotating/animated text must not be driven by overlapping `setInterval`+`setTimeout` mutating inline `opacity`/`transform` — it can strand mid-transition. Use a CSS class + forced reflow (`el.classList.remove(x); void el.offsetWidth; el.classList.add(x);`).
4. **Lint inline GLSL/JS strings.** A shader written as a joined JS string array is a footgun — one missing quote throws at runtime and only a browser probe catches it. Compile-check the effect in a headless browser before shipping.
5. **A parent `max-width` clamps its children.** Don't let a headline container's width starve the sub-paragraph beneath it. Size the container for the widest child.

## The recipes

Each maps a `signature.type` to a build. `signature-hero-example.html` in this folder is a complete, self-contained T2 reference — read it once to see the whole contract in one file, then adapt (do not copy verbatim).

| `signature.type` | Tier | The moment |
|---|---|---|
| `shader-aurora` | T2 | Full-viewport ember/aurora curtains (5-octave fbm), lean toward the pointer; monumental type rises through the light. |
| `shader-liquid` | T2 | Molten-metal height field lit by screen-space normals (diffuse + specular + fresnel); cursor ripple rings. |
| `gradient-mesh` | T0 | Animated CSS gradient-mesh + noise panel; pure CSS, collapses to a static gradient under reduced-motion. |
| `scroll-cinematic` | T0 | A pinned sticky frame + a tall scroll track; a rAF scroll driver sets a `--p` (0→1) custom property that scales/hue-shifts the scene and cross-fades acts. JS-off unpins to a stacked page. |
| `kinetic-typography` | T0 | Oversized display type; split each character into spans with a staggered rise; optional rotating word (rule 3) and a magnetic CTA (pointer-fine only). |
| `magnetic-cta` | T0 | A single button that pulls slightly toward the cursor within a radius (CSS transform + a few lines of JS); disabled on touch and under reduced-motion. |
| `particle-ambience` | T1 | A `<canvas>` field of drifting embers whose colours come from `--accent`/`--glow`; < 5 KB, pauses off-view. |
| `three-orbit` | T3 | A machined object floating in procedural studio light, orbiting the pointer; token-bound emissive + rim. Slim lighting (skip PMREM if a plain light rig reads well). |
| `three-particles` | T3 | Thousands of `Points` that sample a canvas-rendered word and assemble into it, scatter from the cursor, and reform. `config.word` is the brand word. |
| `scroll-scrub` | T1 | Scroll drives a pre-rendered clip's `currentTime` — the visitor flies through a scene by scrolling. Complete on posters alone; the clips are an upgrade, never a requirement. Reference: `scroll-scrub-engine.js` + `scroll-scrub-example.html`. |
| `three-flight` | T3 | A live camera flies along a spline through a procedural world, driven by the same scroll progress. Real 3D, regenerable for free, re-themes from tokens at runtime. Reference: `src/three-flight.mjs` + `three-flight-example.html`. |
| `none` | — | No signature. Utility/data screens. Identity carried by tokens + type only. |

## Choosing between `scroll-scrub` and `three-flight`

Both deliver a "fly through the world" landing page from the same scroll driver.
They differ in what renders and, decisively, in what it costs.

| | `scroll-scrub` (T1) | `three-flight` (T3) |
|---|---|---|
| Renders | a pre-rendered clip, scrubbed | a live three.js camera |
| Look | whatever the footage is — photoreal is possible | procedural; stylised, not photoreal |
| Weight | posters + your clips | ~520 KB bundle, lazy |
| Re-theme on a token change | no — the pixels are baked | **yes, instantly** |
| Cost to regenerate | a render bill per page | **zero** |

Default to `three-flight`. Reach for `scroll-scrub` only when the shot genuinely
has to be photoreal footage, and be explicit that the page now has a render cost
attached to it.

### The `file://` rule for scrubbed media (non-obvious, already cost us once)

`fetch()` is blocked from a `file://` page — verified in a headless probe, not
assumed. The upstream engine this technique was adapted from loads every clip as
a Blob via `fetch`, which is correct on a server and **fatal for an SDTK pack**,
because a buyer unzips it and double-clicks. This is the same trap as the T3
ES-module rule above.

So `scroll-scrub-engine.js` branches: Blob on `http(s):` (guaranteed seekable
even without byte-range support), direct `video.src` on `file:` (seekable because
no server is involved), and it falls back to a plain `src` if the Blob path fails
for any other reason. If you write another media-loading effect, do the same.

## Manifest signature block (shape)

```json
"signature": {
  "type": "shader-aurora",
  "tier": "T2",
  "intensity": 1.0,
  "fallbackPoster": "inline",
  "recipe": "aurora-flow",
  "bundle": "assets/js/hero-aurora.bundle.js"
}
```

`bundle` is present only for T3. `intensity` (0–1.5) scales amplitude and should also fall through a `--motion-intensity` token so Preview Studio can tune it. `fallbackPoster: "inline"` means the poster is CSS in the same file.

## Tooling (ship-side, not in the buyer's file)

- Bundle T3 scenes with esbuild IIFE (see the T3 rule).
- Verify every signature screen in a headless browser (both themes): zero console errors, zero external requests, and — for T3 — that it renders from `file://`. That probe is what catches QA rules 1–5.
