diff --git a/optional-skills/creative/auteur/LICENSE b/optional-skills/creative/auteur/LICENSE new file mode 100644 index 0000000000..34d81ae5d3 --- /dev/null +++ b/optional-skills/creative/auteur/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 agiwhitelist + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/optional-skills/creative/auteur/SKILL.md b/optional-skills/creative/auteur/SKILL.md new file mode 100644 index 0000000000..fc4d0bc17f --- /dev/null +++ b/optional-skills/creative/auteur/SKILL.md @@ -0,0 +1,168 @@ +--- +name: auteur +description: Design and build cinematic, award-level web pages. +version: 1.3.1 +author: agiwhitelist (upstream) / Hermes port +license: MIT +platforms: [linux, macos] +metadata: + hermes: + tags: [web-design, cinematic, scroll-animation, design-system, anti-slop, frontend] + related_skills: [popular-web-designs, design-md, p5js] +--- + +Ported from agiwhitelist/auteur (MIT), snapshot 9bca227df9877e60dc45d49783c8cbd885eccd9b. + + +Auteur designs and builds web experiences the way a film director makes a film: script first, then assets, then the shoot, then the cut. It has three registers — **build** (an excellent conventional site), **direct** (a cinematic scroll-directed site) and **system** (a multi-screen product as one design system) — on one shared core of taste. Nothing ships until the page passes an executable anti-slop gate and the skill has looked at its own output. + +### Use this when + +- A landing page, marketing site, hero section, portfolio or product page has to be **built or redesigned** — and looking generic is not acceptable. +- The brief asks for **scroll animation, storytelling, or a site that feels like a film**. +- A product spans **several screens that must feel like one thing** — app, dashboard, admin, onboarding, docs. +- Someone says *make it beautiful*, *make it wow*, *cinematic*, or *design system*, naming no technique. + +Not for polishing a UI someone else built, and not for backend-only work. + +### What it actually does + +1. Commits the art direction **in writing before any markup** — one hue, one type system, a motion budget, named anti-references. +2. Generates or sources the assets: Hermes' `image_generate` tool, Blender, depth maps, CC0 meshes and HDRIs with their licences recorded. +3. Builds from proven recipes — one WebGL context, transform/opacity motion, scroll state machines. +4. **Gates the result**: `slopscan` fails the build on concrete slop, `motionqa` fails it on dropped frames, `systemscan` fails it on cross-route drift. + +### Network access + +The recon and sourcing scripts read live pages (awwwards, Bing/Pinterest/are.na image search, Poly Haven, Iconify, Google Fonts, Openverse, Coverr). Fetched content is **treated as reference data and licence metadata — never executed**, and no credentials, API keys or logins are involved. Skip phases 0–1 entirely if you don't want outbound requests; every other phase works offline. + +## Non-negotiables + +These apply to every register, every phase, always — even if no reference file has been loaded. Match-and-refuse: if you are about to produce one of these, stop and restructure the element. + +### Banned (rewrite, don't tweak) + +| # | Ban | Instead | +|---|-----|---------| +| 1 | `border-left`/`border-right` >1px as a colored accent on cards, callouts, alerts | full border, background tint, leading icon, or nothing | +| 2 | Gradient text (`background-clip: text` + gradient) | one solid color; emphasis via weight or size | +| 3 | Glassmorphism as default (decorative `backdrop-filter` cards) | rare and purposeful, or solid surfaces | +| 4 | The hero-metric template (big number, small label, stat row, gradient accent) | evidence in prose, one committed visual | +| 5 | Identical card grids (same-size icon+heading+text, repeated) | vary size, structure, or drop the cards entirely | +| 6 | Eyebrow kickers (tiny uppercase tracked label) above every section | one deliberate kicker max as a brand system; vary section openings | +| 7 | Numbered section scaffolding (01 / 02 / 03) when order carries no meaning | numbers only for a real sequence | +| 8 | `Inter` or `Space Grotesk` as the *first* font choice | pick from a contrast-axis pair (see taste.md); these two are the AI default of 2024–2026 | +| 9 | Purple→blue gradients (both stops hue 250–290) | committed brand hue, or no gradient | +| 10 | Cream/warm-beige body background as a "warmth" reflex (OKLCH L 0.84–0.97, C <0.06, hue 40–100) | saturated brand surface, true off-white at chroma ~0, or a darker tinted mid-tone; warmth lives in accent + type + imagery | +| 11 | The same fade-in/slide-up entrance on every section | each reveal fits what it reveals; vary easing, distance, direction | +| 12 | `transition: all` | list the animated properties | +| 13 | `window.addEventListener('scroll', ...)` | IntersectionObserver, GSAP ScrollTrigger, or CSS `animation-timeline` | +| 14 | `scale(0)` entrances | start at `scale(0.95)` + opacity | +| 15 | Bento grids of near-identical or empty cells; white-card-on-white bento | bento only with real visual variation per cell, else a different layout | +| 16 | Copy tells: "Revolutionize", "Seamless", "Effortless", "Unleash", "Elevate", em-dash–heavy sentences, decoration strips like "BRAND. MOTION. SPATIAL." | concrete claims in plain words | +| 17 | More than one marquee per page | one, or none | +| 18 | Instrument Serif / Playfair Display as the reflex "elegant serif" | serifs chosen for the brand, not from the AI shortlist | + +A ban may be overridden only through a written `auteur-allow` (see Verification) with a real reason — a deliberate, argued choice is voice; a default is slop. + +### Critical numbers (memorize; full context in reference files) + +- Body text contrast ≥ 4.5:1 (large text ≥ 3:1). Placeholders too. Muted-gray-on-tinted-white is the #1 AI readability failure. +- Body line length 65–75ch. Display heading ceiling: clamp max ≤ 6rem *for headings in prose flow* — a wordmark or a deliberately type-led hero is exempt and the commit-sheet must say so. Display letter-spacing ≥ −0.04em. +- Durations: button 100–160ms · tooltip 125–200ms · dropdown 150–250ms · modal/drawer 200–500ms · any UI >300ms needs a written reason. +- Enter/exit easing = ease-out. `ease-in` is banned on UI. +- Animate only `transform` and `opacity`. Stagger 30–80ms. +- Motion budget: ≤ 3 scroll-triggered pattern families per page; **one** primary wow peak, supporting scenes at lower intensity. +- Scrub smoothing 0.3–0.8. Hero video ≤ 2MB. LCP < 2.5s. CLS < 0.1. +- Fullscreen passes (bloom, grain, DoF, any full-frame shader) are priced **per pixel, not per object** — they, not geometry, are what blows the frame budget. A perf number counts only when measured at **DPR 2 on a production build**: DPR 1 quarters the cost of every such pass, and a dev server roughly doubles the frame. +- `prefers-reduced-motion` = an alternative art direction (gentler, not zero), never an afterthought. +- Content must be readable with JS disabled: reveals enhance an already-visible default, never gate visibility. + +## Routing + +Read the argument / brief and route: + +1. **`direct`** or the brief smells cinematic — "wow", "cinematic", "immersive", "storytelling", "launch page", "premium brand", "make people stop scrolling" → load `references/direct.md` and follow its phases. This is the flagship register. +2. **`build`** or the brief is ONE conventional surface — a marketing page, a landing, a single product page → load `references/build.md`. +3. **`system`** or the brief has **more than one screen that must feel like one product** — app, dashboard, admin, settings, onboarding, a docs or content site with real navigation → load `references/system.md`. The unit of design becomes the component × state, the failure mode becomes drift rather than boredom, and there is deliberately **no peak**. If you are already in `build` and a second screen appears, stop and switch: half a system is worse than either. +4. **`edit`** or the request modifies a page this skill built (the project contains `design/DESIGN.md`) — "add a section", "change the pricing", "swap the hero copy" → read `design/DESIGN.md` FIRST and follow its Editing protocol: reuse its tokens, section-opening patterns, and motion families; after the change run slopscan and re-shoot the affected viewports. An edit that ignores DESIGN.md is a regression even if it looks good in isolation. +5. **`recon `** or the ask is only for reference material — "найди референсы", "собери мудборд", "what's the state of the art for X sites" → load `references/recon.md` and run just that phase: scout live sites, build the moodboard, hand back `design/refs/REFERENCES.md` (with the `steal:` lines filled) and `design/moodboard/contact-sheet.png` (with the read filled). No commit-sheet, no build. +6. **`audit `** → load `references/verify.md` and run the verification pipeline on an auteur-built page. If the target is an existing UI auteur didn't build and the user wants it *polished* rather than *rebuilt*, say that a dedicated UI-polish/critique pass (upstream paired auteur with a separate 'impeccable' skill, not vendored here) is the right tool and offer to continue only if they want a rebuild. +7. **Ambiguous** (e.g. plain "сделай лендинг") → ask exactly one question: "Обычный отличный лендинг или кино-режим со scroll-режиссурой и генерацией ассетов?" Then route. (Multi-screen briefs are not ambiguous — they are `system`.) Don't ask anything else yet — each register runs its own intake. + +All three registers share phase zero, and its centre of gravity is the commit-sheet. Order differs: **build** runs recon → commit-sheet → mockup; **direct** runs recon → storyboard → commit-sheet → mockup, because the film's scenes are what the six decisions get made *about*; **system** runs recon → system-sheet (route map + component inventory) → commit-sheet → mockup, because the six decisions get made about a product, not a page. Either way nothing is coded before the sheet is full. + +## The commit-sheet (before any code, both registers) + +Slop is what happens when defaults make the decisions. The commit-sheet forces seven real decisions onto paper before the first line of code. Copy `templates/COMMIT-SHEET.md` into the project (e.g. `design/COMMIT-SHEET.md`) and fill all seven fields with non-defaults: + +1. **Peak** — the ONE primary wow moment (direct) or signature element (build). One sentence. If you can't name it, you're not ready to build. +2. **Color** — primary as OKLCH + commitment tier (restrained / committed / full-palette / drenched) + one line: *why this is not lavender, not cream, and not the category reflex* + **the background lightness as a number** (target mean L), because "dark feels premium" is where this skill drifts, and a number can be checked afterwards where a mood cannot. +3. **Type** — display + text pairing on a contrast axis (serif+sans, geometric+humanist, mono+serif...) + one line: *why not Inter*. +4. **Grid break** — the one concrete thing that breaks the symmetric-grid default: an overlap, an asymmetric split, a diagonal flow, a full-bleed interruption. Name it specifically. +5. **Motion budget** — how many scroll-pattern families (≤3) and what they are. +6. **Reflex check** — write down: (a) what a generic AI would do for this category (first-order reflex), (b) what a generic AI avoiding (a) would do (second-order reflex — e.g. fintech → "terminal dark mode" is *also* saturated now), (c) your chosen deviation from both. If recon ran, (a) is not a guess: whatever `design/refs/REFERENCES.md` showed five times *is* the reflex, dated and with receipts. +7. **House tells broken** — name the **two (minimum)** items from `taste.md` §2.5 you are deliberately not doing this time, and what replaces each. Fields 6a/6b are the reflexes of the *category*; these are the reflexes of *this skill*, which recur across unrelated projects and are invisible from inside any one of them: near-black backgrounds, mono service labels, the logo/status/action header, the scroll-instruction footer, amber-or-acid accents, the wordmark-as-hero, glow standing in for lighting. Measured across nine showcase builds, eight were dark and three landed within 0.002 of the same lightness. A tell that genuinely belongs here can stay — say why, as with an `auteur-allow`. + +Gate: every field filled with a specific, non-default answer. An empty or generic field ("modern, clean look") means stop and decide. This artifact is checked again at verification. + +## Phases at a glance + +| Phase | build register | direct register | system register | Reference to load | +|---|---|---|---|---| +| 0 | recon → commit-sheet → hero mockup gate | recon → screenplay (STORYBOARD.md) → commit-sheet → hero mockup gate | recon → SYSTEM-SHEET.md (routes + component inventory + states) → commit-sheet → mockup gate | `recon.md`, then `build.md` / `direct.md` / `system.md` | +| 1 | — | asset production (generate → edit → optimize) | — (source icons/fonts via `source.mjs`) | `assets.md` | +| 2 | build the page | assemble the film (smooth scroll first, hero, scenes top-down) | tokens → the shell → screens in traffic order → every state | `build.md` / `scroll-cinema.md` / `system.md` + `taste.md` + `motion.md` | +| 3 | verify | verify + CINEMA-QA.md | verify + **systemscan across every route** | `verify.md` | +| 4 | lock the style: fill `design/DESIGN.md` | same | same, but DESIGN.md is the **component contract** | `templates/DESIGN.md` | + +The hero mockup gate (one static throwaway screen, screenshotted and approved before anything else is built) is the cheapest moment to change art direction — details in each register's reference. `design/DESIGN.md` is the style contract that makes every later edit stay in style (the `edit` route reads it first). + +Never skip a gate because the intermediate result "looks done". The gates exist because a page that merely looks done is exactly what every other AI ships. + +## Reference files + +- `references/recon.md` — **phase 0 scouting**, two executable legs: `scripts/refscout.mjs` profiles live award-level sites (real stack, pinned scenes, scroll budget, fonts, painted palette, screenshots — mechanics, not skins) and `scripts/moodboard.mjs` builds a numbered contact sheet from Bing / Pinterest / are.na so the art direction is decided from live material instead of memory. Also: query craft, the steal rule, how recon feeds the commit-sheet, and the "reference images are not assets" line. Load at the top of phase 0. +- `references/taste.md` — the full anti-slop system: extended bans with replacements, second-order category reflex table, color strategy tiers, typography pairing, copy rules. Load for any visual decision-making. +- `references/motion.md` — the motion school: when to animate, easing/duration/spring numbers, performance rules, motion budget, sound policy. Load before writing any animation. +- `references/build.md` — the standard register process. Load when routed to build. +- `references/system.md` — the **multi-screen register**: route map, the component inventory as a gate, the state matrix (empty/loading/error are not edge cases), density rules, the no-peak rule, and `scripts/systemscan.mjs` — which crawls every route, reads what the browser actually painted, fails a control type over its declared variant budget — counting *states* (disabled, current, inside a `data-state` row) separately, so implementing the state matrix never reads as drift — presses Tab to catch controls with no visible focus state, and renders one tile per rendered variant so drift is visible as well as counted. Load when routed to system. +- `references/direct.md` — the cinematic register: screenplay contract, scene-sheets, dramaturgy, assembly order. Load when routed to direct. +- `references/assets.md` — the media crew and routing (in Hermes: `image_generate` for all image generation and edits, `terminal` for ffmpeg/node; upstream's video/score CLI routing kept as reference), **§0.5 source-vs-generate** (`scripts/source.mjs`: CC0 glTF meshes, HDRIs and PBR materials from Poly Haven, icons, fonts, CC images, stock video — with a licence ledger, because generation cannot make geometry or an IBL and stock video must never be the peak), the consistency trick (edit frame A into frame B), local video via the first→last-frame chain, generated elements/mockups, the ambient score, the degradation ladder, and asset caching. Load during direct phase 1. +- `references/scroll-cinema.md` — working code recipes: scroll-scrubbed video, canvas sequences, GSAP+Lenis foundation, CSS scroll-driven animations, text reveals, the two-keyframe WebGL displacement transition, view transitions, ambient audio, and the cinematic transition library (wipe, curtain, letterbox, shutter, depth parallax). Load during assembly. +- `references/scroll-flight.md` — the **video-scrub tier**: a photoreal "fly through the world" hero driven by scroll, using the drop-in `templates/scroll-flight-engine.js`. The canonical recipe for scroll-scrubbed *video* (encode-for-scrubbing `-g 8`, encoded-frame posters, SSIM seam gate, chain architecture A/B, iOS/mobile decode hardening, crossfade-vs-seamless seams). Load when the hero should be photoreal footage/AI-video rather than real-time WebGL. +- `references/ambient-backgrounds.md` — **quiet** texture for secondary sections and simpler builds (not a hero): a curated 6 editorial/analog effects (paper grain, ledger/blueprint rules, topographic contour, ink tide, sparse dust, one heat-haze shader) + a zero-motion static-mesh default. The governing rule (weaker than the quietest foreground element; one ambient per page), the CSS/SVG-first stack, and the `feTurbulence`-static perf rule. Load when a section needs to not be flat but must NOT compete with copy. +- `references/verify.md` — the acceptance pipeline: slopscan → screenshot journey → motion/perf/audio QA (FPS at DPR 2 on a production build, long-tasks, audio-gate, reduced-motion, for Tier-1 scenes) → numeric rubric → **reference diff** (your frame beside the reference that set the direction, with `scripts/chromadiff.mjs` measuring the colour drift a model never sees in itself) → QA sign-off. Load at phase 3. + +## Verification is part of the build + +The page is not done when the code compiles. It is done when: + +1. `node scripts/slopscan.mjs ` exits 0 (fails are fixed, not suppressed — `/* auteur-allow: RULE_ID -- reason */` exists for deliberate choices and demands a real reason); +2. `node scripts/shoot.mjs ` has produced screenshot journeys at 390 / 768 / 1440 and you have **looked at every frame** — text overflow, blank scenes, broken reveals, layout collapse are found by eyes, not by grep; +3. the numeric rubric in `references/verify.md` passes (contrast, LCP, CLS, reduced-motion journey, scene variety); +4. for direct register: `CINEMA-QA.md` (from templates) is filled with PASS on every row. + +If any gate fails — fix and re-run. Report results honestly: "slopscan clean, 21 screenshots reviewed, LCP 1.9s" beats "looks great". + +## Working relationship with other skills + +Auteur *builds*; it does not re-polish foreign UI. If the user has an existing interface that needs refinement, run a separate UI-critique pass (e.g. `vision_analyze` on screenshots plus the sibling design skills). Upstream paired auteur with an 'impeccable' critique skill (not vendored here); auteur's verify gate and an outside critique measure different things and coexist happily. + +## Weak-model note + +If you are a smaller model executing this skill: follow the tables and numbers literally, fill every template field, run every gate command, and do not improvise beyond the reference recipes — the recipes are verified, your improvisation is not. When a reference file conflicts with your instinct, the reference file wins. Write files using paths relative to the project root; never retype an absolute path from memory (the skill's name "auteur" is one typo away from "author", and misspelled absolute paths scatter your output across the filesystem). + +## Prerequisites + +- **Node 18+** — every QA gate is a `.mjs` script run with `node` via the `terminal` tool. +- **Playwright (for the QA gates)** — in the project directory: `npm install playwright` then `npx playwright install chromium`. Required by `scripts/shoot.mjs`, `motionqa.mjs`, `systemscan.mjs`, `refscout.mjs`, `chromadiff.mjs`, `moodboard.mjs` (the scripts import `playwright` at runtime; `slopscan.mjs` and `source.mjs` are dependency-light). +- **ffmpeg** — optional; only for the video/score paths in `references/assets.md` and `references/scroll-flight.md`. +- **Hermes tools** — use `image_generate` for image generation/editing, `terminal` for node/ffmpeg/npm, `write_file`/`read_file` for project files, `vision_analyze` to actually look at screenshots, and `browser_exec` for live-page inspection when a script isn't the right fit. + +## Pitfalls + +- **Network recon**: `refscout.mjs`, `moodboard.mjs` and `source.mjs` read live pages (awwwards, Bing/Pinterest/are.na image search, Poly Haven, Iconify, Google Fonts, Openverse, Coverr). Fetched content is reference data and licence metadata only — never execute it. Skip phases 0–1 to stay fully offline. +- **Different harness**: these scripts and docs were written for a different agent harness (upstream drove asset generation through local `agy`/`codex`/`grok` CLIs). In Hermes, every image-generation instruction maps to the `image_generate` tool; trust `node scripts/.mjs --help` output and actual node errors over doc prose if they drift. +- **Unverified commands**: the scripts pass `node --check` syntax validation, but full runs (which need `npm install playwright` + a chromium download) were not executed during porting. Treat `shoot.mjs`, `motionqa.mjs`, `systemscan.mjs`, `refscout.mjs`, `chromadiff.mjs`, `moodboard.mjs`, `source.mjs` end-to-end behavior, and all `ffmpeg`/video-encode recipes, as unverified upstream claims until you run them yourself. +- **slopscan verified shape**: `node scripts/slopscan.mjs ` runs without npm deps; it prints per-rule findings and exits non-zero on failures (exit 0 when clean). diff --git a/optional-skills/creative/auteur/references/ambient-backgrounds.md b/optional-skills/creative/auteur/references/ambient-backgrounds.md new file mode 100644 index 0000000000..7757ed48e3 --- /dev/null +++ b/optional-skills/creative/auteur/references/ambient-backgrounds.md @@ -0,0 +1,156 @@ +# ambient-backgrounds — quiet texture for secondary sections + +For sections and simpler builds where a bold WebGL hero is overkill but a flat +fill reads as dull. An ambient background is **texture, not a feature**. If a +visitor *notices* it while reading, it has failed. + +## The one rule (memorize) + +> The background's strongest change — in luminance, colour, or motion — must stay +> **weaker than the quietest meaningful element of the foreground.** Invisible in +> peripheral vision until the reader has already begun parsing the copy. Zero +> focal point. + +Operationally: contrast of any background detail against the base ≤ **~8%** +(colour distance small enough that text/background contrast is untouched — keep +body text ≥ 4.5:1 *as if the texture weren't there*), and motion slow enough it +reads as texture, not animation (a full cycle measured in tens of seconds, or no +motion at all). + +## Composition limits + +- **One ambient per page.** Two only when the page genuinely splits into distinct + bands (e.g. a light editorial section then a dark one) — and **never two in the + same viewport**. A different effect per section reads as a background sampler. +- **One committed hue.** Monochrome in the project's brand hue (same-hue, + lower-lightness/opacity variations only). The moment a second hue appears it + stops being ambient and becomes decoration. +- **Reduced-motion is mandatory** and easy here: every effect below degrades to a + *rich still* (its last/seeded frame), never a blank fill. + +## Banned (slopscan-adjacent) + +The 250–290° purple→blue gradient · neon pulse / "cosmic ripple" (the template- +generator defaults) · glassmorphism-by-default · big blurred glowing orbs · +animated `feTurbulence` re-rastered on scroll (see perf note) · rainbow/2-hue +anything · a texture legible enough to compete with 16–18px body copy. + +## Performance facts (verified, load-bearing) + +- **CSS and SVG (static) cost ~nothing** — composited once, no per-frame JS. + Prefer them. `canvas2d` with < ~100 primitives on a throttled rAF is cheap. + A **single** small WebGL fragment shader is fine; multi-pass WebGL is not + "ambient" — it belongs to the hero tier. +- **SVG `feTurbulence` is CPU-rasterised in Chromium and expensive to re-raster.** + Use it ONLY as a **static bake** (render once into a tiled data-URI). Never + animate `baseFrequency`, and never let it re-rasterise on a scroll transform — + that alone can drop a page below 60fps on a weak iGPU. + +--- + +## The set (6 + a zero-motion default) + +Each: technique · how it works · **NOT** (the taste trap). All assume a +`prefers-reduced-motion: reduce` branch that freezes to the still. + +### 0. Static mesh (the always-safe default — zero motion, zero JS) +`pure CSS`. Two or three large, soft, same-hue radial/linear gradients at 150% +size, hand-placed. It's just a considered, non-flat ground. +```css +.amb-mesh{position:fixed;inset:0;z-index:0;pointer-events:none; + background: + radial-gradient(60% 50% at 18% 12%, color-mix(in srgb,var(--accent) 7%,transparent), transparent 70%), + radial-gradient(50% 60% at 88% 90%, color-mix(in srgb,var(--accent) 5%,transparent), transparent 70%), + var(--bg);} +``` +**NOT:** the cool-blue/lavender default mesh, or SaaS-cream — commit to the brand hue. + +### 1. Paper grain +`SVG, static bake`. A `feTurbulence` tile baked once into a data-URI, tiled and +held at 3–6% opacity — analog tooth that kills the flat-digital deadness. +```css +.amb-grain{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.05; + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E"); + background-size:160px 160px;} +``` +**NOT:** animate it, or push opacity past ~0.08 — grain is felt, not seen. + +### 2. Ledger / blueprint rules +`pure CSS`. Same-hue hairlines (a baseline rhythm, with a bolder every-Nth), +static, ≤6% opacity — an editorial/instrument scaffold. +```css +.amb-ledger{position:fixed;inset:0;z-index:0;pointer-events:none; + --l:color-mix(in srgb,var(--ink) 6%,transparent); + --lb:color-mix(in srgb,var(--ink) 10%,transparent); + background: + repeating-linear-gradient(0deg,transparent 0 27px,var(--l) 27px 28px), + repeating-linear-gradient(0deg,transparent 0 139px,var(--lb) 139px 140px);} +``` +**NOT:** colour the lines, add verticals, or tighten spacing until it reads as a grid/table. + +### 3. Topographic contour drift +`canvas2d`. A handful of low-frequency flow-lines from a layered-sine field, +drifting imperceptibly; one context, tiny primitive count. +```js +const cv=document.getElementById('amb'),g=cv.getContext('2d'); +const RM=matchMedia('(prefers-reduced-motion: reduce)').matches; +function fit(){cv.width=innerWidth;cv.height=innerHeight;} +function frame(t){fit();g.clearRect(0,0,cv.width,cv.height); + g.strokeStyle=getComputedStyle(cv).getPropertyValue('--line')||'rgba(40,36,32,.05)';g.lineWidth=1; + const T=RM?0:t*0.00004; + for(let i=0;i<9;i++){g.beginPath(); + for(let x=0;x<=cv.width;x+=14){ + const y=cv.height*(i+1)/10 + Math.sin(x*0.006+T+i)*12 + Math.sin(x*0.013-T*1.7)*7; + x?g.lineTo(x,y):g.moveTo(x,y);} + g.globalAlpha=.5;g.stroke();} + if(!RM)requestAnimationFrame(frame);} +requestAnimationFrame(frame); // RM draws one still frame and stops +``` +**NOT:** high curvature/density that forms a recognisable landscape silhouette, or bright/coloured strokes. + +### 4. Sumi / ink tide +`canvas2d`. Two–three desaturated low-frequency bands advected slowly on a +low-res buffer (upscaled) — a breathing wash, monochrome. +Sketch: draw 2–3 soft horizontal `createLinearGradient` bands into a small +offscreen (e.g. 64px tall), offset each by `sin(t)` at different phases, then +`drawImage` upscaled with `imageSmoothing` on. Alpha ≤ 0.06. +**NOT:** fluid-sim turbulence, saturated colour, or crushed blacks — it's a whisper, not a lava lamp. + +### 5. Sparse dust +`canvas2d`. Fewer than ~40 specks, drifting < 0.04px/frame on a throttled rAF; +static seeded frame for reduced-motion. +```js +const pts=Array.from({length:34},(_,i)=>({x:Math.abs(Math.sin(i*99.7))%1,y:Math.abs(Math.cos(i*57.3))%1,r:.5+(i%3)*.4})); +// per frame: y -= 0.00006 (wrap), draw each at alpha .07 in the brand hue; RM → draw once. +``` +**NOT:** a dense twinkling starfield, trails, or high speed — that's a screensaver, not ambient. + +### 6. Heat-haze / paper-ripple +`single WebGL fragment shader` (the one shader you're allowed). A ≤1px domain-warp +of a monochrome field — the surface subtly "breathes". Use the standard fullscreen- +quad harness (see `scroll-cinema.md`); fragment core: +```glsl +// uv in [0,1]; uTime slow; brand hue in uBase; result stays near-monochrome +float n = sin(uv.x*8.+uTime*.15)*.5 + sin(uv.y*11.-uTime*.11)*.5; +vec2 warp = vec2(n)*0.004; // <= a few px at 1080p +float g = texture(uTex, uv+warp).r; // or a baked gradient +outColor = vec4(mix(uBase, uBase*1.03, g), 1.); +``` +Fallback: a pre-rendered static frame (or effect #0). **NOT:** chromatic aberration, +liquid blobs, or any warp large enough to visibly bend text edges. + +--- + +## Wiring notes + +- The ambient layer is `position:fixed; inset:0; z-index:0; pointer-events:none`; + content sits above on its own stacking context. Keep a solid `--bg` under it so + text contrast is guaranteed by the base, not the texture. +- Trigger any scroll-linked drift with `IntersectionObserver` (pause the rAF when + the section is off-screen), never `addEventListener('scroll')`. +- Reduced-motion: one `matchMedia('(prefers-reduced-motion: reduce)')` check that + draws a single frame and returns — every effect above already shows this shape. +- Distinctiveness is the point: reach for #2/#3/#6 and the sumi/letterpress + register before plain grain — "grain + grid" alone is now a SaaS default. The + authored textures (ledger, contour, ink, heat-haze) are what separate auteur + from a prompt generator. diff --git a/optional-skills/creative/auteur/references/assets.md b/optional-skills/creative/auteur/references/assets.md new file mode 100644 index 0000000000..336badfb48 --- /dev/null +++ b/optional-skills/creative/auteur/references/assets.md @@ -0,0 +1,411 @@ +> **Hermes adaptation note:** upstream auteur generated assets through local agent CLIs (`agy`, `codex`, `grok`). In Hermes, read every such invocation as a call to the built-in `image_generate` tool with the same prompt (then move the returned file into the project's `assets/gen/` path), use the `terminal` tool for `ffmpeg`/`node`/`npx`, and `browser_exec` or Playwright-via-terminal for screenshot loops. The per-CLI routing/strength tables below are upstream reference material — the taste guidance transfers, the CLI names do not. + +# assets.md — producing visual assets + +The storyboard's `asset:` lines are a shot list. This file turns them into files on disk: generated keyframes, consistent A→B pairs, optimized video/sequences. Two disciplines rule everything: **one frame first** (approve art direction at the cost of one image, then batch) and **cache everything** (generation costs money and minutes; never regenerate what exists). + +## 0. The crew — probe once, route by strength + +**Probe with a real round trip, never with `--version`.** These are subscription CLIs and the failure +that actually happens is expired auth, not a missing binary — measured: all three passed `--version` +and all three were dead. Worse, they fail dishonestly: **`grok` exits 0 while printing "Not signed +in"** and **`agy` exits 2 while printing nothing at all**. Read the OUTPUT, not the exit code. + +```bash +agy -p "reply with the single word: ok" # Gemini: fast image gen + edit, writes straight to a path +codex exec --skip-git-repo-check "reply with: ok" # gpt-image: highest fidelity +grok -p "reply with the single word: ok" # grok-4.5: gen + edit + real video, one consistent engine +ffmpeg -version # not a subscription; --version is fine here +``` + +A tool that does not answer `ok` is unavailable, whatever its version says. Note it as unavailable in +the asset plan and route around it — §0.5 and §4 — rather than discovering it mid-shoot. + +Grok reaches **grok-4.5** only through the non-EU proxy (`ALL_PROXY="$GROK_PROXY" HTTPS_PROXY="$GROK_PROXY"`); without it grok still gens/edits/videos on grok-build. MiniMax music (ambient score) needs `MINIMAX_API_KEY` — skip the audio leg if unset. + +**Route each asset to its strength** (locked by a shootout, 2026-07): + +| Asset | Tool | Why | +|---|---|---| +| Hero / brand-critical stills — peak scene, abstract hero background (needs clean negative space for text), product mockup / UI screen, premium transparent element or icon | **codex** | quality king across every type tested: cleanest UI render, most negative space, best material realism. Weaknesses: palette drifts warm (weak on teal-shadow / cool briefs), and it cannot make video. It *can* edit an existing frame — via stdin only, see §2 | +| Any scene that becomes VIDEO or needs a consistent A→B edit pair; exact brand-COLOR adherence | **grok-4.5** | one engine does gen + edit + video → zero scene drift across the A→B→clip pipeline; best palette adherence when codex drifts warm | +| Volume & CONTEXT — lifestyle/environmental shots (room, hands, props, in-situ), bulk backgrounds, fast iteration | **agy** | fast, natural environmental context, writes direct to file | +| Real video | **grok** `image_to_video` (6 or 10s) | animate an approved keyframe | +| Ambient score | **MiniMax** music | one loopable bed matched to the commit-sheet mood | + +All three do transparent PNG (alpha): codex crispest, grok close, agy usable-but-softer. **Match the asset's background to the page** — generate the subject on the SAME ground the page uses (white-on-white, or true alpha) so it melts into the layout with no visible frame; a photographic rectangle floating on a flat page is an instant slop tell. + +Missing a tool → don't fake it: descend the ladder (§4), ask the user for assets, or pivot to type-led/CSS scenes (a great film can be shot entirely in typography). Note what's available in the asset plan. + +## 0.5 Source before you generate — the routing decision + +Generation is not the only tool and for a whole class of assets it is the wrong one. You cannot +generate a glTF mesh, a 16-bit HDRI that actually lights a WebGL scene, or a seamlessly tiling PBR +material with matching normal/rough/AO maps — and CC0 versions of all three exist at production +quality. `scripts/source.mjs` fetches them and writes a licence ledger for every file. + +Search first with `--list` (prints a shortlist to stdout, downloads nothing, writes no ledger), then +fetch. **Always pass `--out`** — the default is `assets/sourced` relative to the current directory, +which drops a ledger and a 2.6MB font-metadata cache wherever you happened to be standing. + +```bash +O=assets/sourced +node scripts/source.mjs model "microscope" --list # read the shortlist, then commit to one +node scripts/source.mjs hdri "coastal dusk 03" --res 1k --out $O # numeric suffixes work +node scripts/source.mjs model "vintage microscope" --res 1k --out $O +node scripts/source.mjs texture "concrete rough" --res 1k --out $O +node scripts/source.mjs icon "bottle" --out $O # single noun — the index is one keyword +node scripts/source.mjs font "serif variable" --out $O # downloads the woff2 and prints the @font-face +node scripts/source.mjs image "whisky barrel" --out $O # CC — attribution REQUIRED +node scripts/source.mjs video "snow forest" --out $O # stock — ambient only, never the peak +``` + +**Inspect a mesh before you write the scene it appears in.** `node -e "console.log(JSON.parse(require('fs').readFileSync('x.gltf')).nodes.map(n=>n.name))"` costs nothing and changes films: a mesh whose parts are *named* can come apart, label itself, and be re-assembled on scroll, which is a scene no image model can express at any budget. A mesh that is one welded blob can only spin. Poly Haven's listing also carries `condition` (clean / worn / weathered / rusted) and `material` — a `worn` asset reads as an antique, not as a product someone can buy this week. + +**Sourced masters are heavy — budget for the conversion, not the download.** A 1k mesh + HDRI + PBR set is ~7MB of masters, which is most of a page budget. Three moves take that to well under 1MB: + +```bash +# HDRI → 512×256 is indistinguishable once PMREM blurs it by roughness anyway +ffmpeg -i env_1k.hdr -vf scale=512:256 -c:v hdr -update 1 -frames:v 1 env_512.hdr +# mesh textures: 1024 for albedo/ARM, 512 for normals, q4 +ffmpeg -i tex_diff_1k.jpg -vf scale=1024:1024 -q:v 4 tex_diff.jpg +# drop maps you do not sample: `arm` already carries AO+roughness+metal, so `rough` and `disp` are dead weight +``` + +**Getting three.js into a no-build page.** Sourcing a mesh means you now need a renderer, and there are three tempting wrong answers: ES modules with an import map (CORS-blocked over `file://`), a CDN (a third-party origin most briefs forbid), and the old UMD build (wrong colour management). Bundle once, commit the output: + +```bash +npm i three@latest && npx esbuild entry.js --bundle --format=iife --global-name=THREEX --minify > assets/vendor/three-bundle.js +``` +Name the ~20 symbols you actually use in `entry.js` rather than `export * from 'three'` — that alone was 731KB → 560KB. Note `RGBELoader` is a deprecation shim in recent releases; the class is `HDRLoader`. + +| The asset is | Route | Why | +|---|---|---| +| a 3D mesh (glTF/GLB) | **source** — Poly Haven, CC0 | no image model produces geometry | +| an HDRI to light a WebGL scene | **source** — Poly Haven, CC0 | a generated "sky picture" is not an IBL; the lighting will look wrong and you won't know why | +| a tiling PBR material (diff/nor/rough/arm) | **source** — Poly Haven, CC0 | seamlessness and matched map sets are not generation outputs | +| an icon set | **source** — Iconify | generated icons drift in weight and stroke across a set (§7) | +| a typeface | **source** — Google Fonts | and check it against ban #8/#18 before falling in love | +| **the peak scene keyframe** | **generate** | it has to be this brand's world and nobody else's — this is the whole point | +| the hero video | **generate** (§3) | the wow moment cannot be a clip three thousand pages already use | +| an environmental / lifestyle still | **generate** (agy) | unless the brief needs a *real, identifiable* place | +| a documentary photo of a real thing or place | **source** — Openverse | generation invents; if it must be true, it must be photographed | +| an ambient background loop or video texture | **source ok** — Coverr | supporting layer only | + +**The stock-video rule is not optional.** Stock footage is generic by construction. Auteur exists to +ship committed, specific assets, so sourced video is an ambient loop, a texture, or a +reduced-motion fallback — never the peak. If your wow moment is stock, you do not have a wow moment. + +**The ledger ships with the site.** `assets/sourced/ASSETS-SOURCED.md` records the licence of every +downloaded file. CC0 (Poly Haven) and OFL (Google Fonts) need nothing. **Openverse images are +CC-BY / CC-BY-SA: the credit line in the ledger must appear on the page** — a footer credits block is +fine, no credit is a licence violation. Coverr and Mixkit permit use but prohibit redistribution, +which means the clip goes in your page, not in your public asset repo or template. Before shipping, +read the ledger and clear every "attribution required" line. + +Do not confuse sourced assets with the moodboard. `design/moodboard/` is other people's work, used +only to decide direction and then thrown away (`recon.md`). `assets/sourced/` is licensed material +that genuinely ships. + +**Sourced assets are gitignored by default, which is exactly how the scene 404s in production.** The +fetch script writes into an ignored directory; the deploy builds from the repository; the page arrives +on the host without its textures, HDRI or meshes — and a missing HDRI does not degrade gracefully, it +throws. Decide it once, in writing, before the first deploy: either commit the optimized assets (after +§5 they are small enough to) or run the fetch as a build step. "It works locally" is this bug's +signature, and it always surfaces in front of the client. + +## 1. Generating keyframes + +Build the prompt FROM the scene-sheet — `subject` + `camera` + `lighting` are literal prompt parameters, plus palette anchors from the commit-sheet: + +> "⟨subject⟩, ⟨camera: low-angle close shot / orbital view / macro detail⟩, ⟨lighting: hard rim light at dusk / soft studio / neon-soaked⟩, color palette anchored on ⟨primary OKLCH → describe as human color⟩, photographic, no text, no watermark, 16:9" + +**agy (fast, direct to file):** +```bash +agy -p "Generate an image: . Save to /assets/gen/s3-peak-a.png" +``` +Always give an absolute path; verify the file actually landed on disk (agy occasionally reports success without writing — re-run once if missing). + +**codex (higher quality, for the peak scene / brand-critical frames):** +```bash +codex exec --skip-git-repo-check "Generate an image: " +``` +codex cannot write into your project (read-only sandbox). Pick up the newest PNG from its output store and copy it yourself — PowerShell: +```powershell +Get-ChildItem "$env:USERPROFILE\.codex\generated_images" -Recurse -Filter *.png | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 | + Copy-Item -Destination "assets/gen/s3-peak-a.png" +``` + +Default split: agy for volume and iteration speed; codex for the peak scene and anything the viewer will stare at. + +## 2. The consistency trick: frame B is an EDIT of frame A, never a second generation + +Two independent generations of "the same scene" are never the same scene — lighting, geometry and lens drift. Editing frame A into frame B keeps the world intact and is what makes the two-keyframe cinema moves (displacement morph, before/after scrub) look like camera work instead of a jump cut. + +**agy edit — word the change HARSHLY.** agy ignores soft phrasing ("replace X with Y" often returns the original). Use the REQUIRED CHANGE pattern: +```bash +agy -p "Load the image /assets/gen/s3-peak-a.png and edit it. REQUIRED CHANGE: the laptop is now open, screen glowing, and the room lights have dimmed. KEEP IDENTICAL: camera angle, framing, composition, every other object, lighting direction, color grade. Save to /assets/gen/s3-peak-b.png" +``` + +**codex edit — prompt via stdin only** (a positional prompt together with `-i` fails with "No prompt provided"): +```bash +printf '%s' "REQUIRED CHANGE: ... KEEP IDENTICAL: camera, composition, lighting." | codex exec --skip-git-repo-check -i assets/gen/s3-peak-a.png - +``` +…then pick up from `generated_images` as above. + +**Verify the pair eyes-on before building on it:** open A and B side by side. Same camera? Same composition? Only the intended state changed? Small texture drift is fine — the displacement transition tolerates it (it *hides* mid-morph mush). A camera/framing shift is a FAIL: re-edit with harder KEEP IDENTICAL wording, then try codex, then descend the ladder. + +**Retry policy:** any generation/edit gets ONE sharpened retry on the same tool, then ONE attempt on the other tool, then descend the ladder. Do not burn ten generations chasing a frame — reshape the scene instead. + +### N-frame chains (for the scroll-cinema state-machine engine) + +Extend the A→B pair to a chain: **A→B→C→D…, each an EDIT of the previous frame** (never a fresh gen), so +the whole world stays photographically consistent while it ages / opens / transforms / gets crowded. 4–6 +frames covers most stories. Verify each link same-camera before editing the next; keep the chain in scroll +order (`s1-a … s1-d`). This chain IS the input to scroll-cinema's Tier-1 scrubber — do the whole chain in +ONE grok session so the image model never drifts. + +## 2.5 Depth maps (for the 2.5D composite / rack-focus) + +A hero still becomes dimensional with a grayscale depth map (0 = far … 1 = near). Generate it locally — +on this machine (no discrete GPU) **Depth-Anything V2 Small runs on CPU** in seconds per hero image: + +```bash +py -3.13 -m pip install -q transformers torch pillow # one-time (~torch is heavy but CPU-only is fine) +py -3.13 - <<'PY' +from transformers import pipeline; from PIL import Image +dep = pipeline('depth-estimation', model='depth-anything/Depth-Anything-V2-Small-hf') +dep(Image.open('assets/gen/s1-hero.png'))['depth'].save('assets/gen/s1-hero-depth.png') +PY +``` + +Alternatives: a **Blender Z-pass** when the scene is a 3D render (Blender CLI; §9); or ask the generator for a +grayscale "depth-style" version (fast, imperfect — ok for subtle pointer-parallax, NOT for rack-focus). +Depth is a cached master like any still. Feed color + depth to scroll-cinema §3 (2.5D composite). + +## 3. Video — now local via grok + +Grok animates an approved keyframe: `grok image_to_video` (6 or 10 seconds). Run it through the proxy for grok-4.5, save into `assets/gen/`, then optimize (§5). **Spend video like the motion budget spends attention** — one hero clip + at most a couple supporting; a video that isn't the wow peak is usually a still that should have stayed a still. + +```bash +ALL_PROXY="$GROK_PROXY" HTTPS_PROXY="$GROK_PROXY" grok -m grok-4.5 --yolo -p \ + "image_to_video on /assets/gen/s1-hero-a.png: slow push-in, rising steam, 6s. Save the mp4 to /assets/gen/s1-hero.mp4" +``` + +**Directed A→B state change (before/after, "first+last frame").** ⚠️ grok has NO true first+last-frame interpolator (checked 2026-07): `image_to_video` animates ONE source frame with no end frame; `reference_to_video` takes 2–7 images but treats them as style/content *references*, not strict start/end keyframes — an A+B reference clip is organic drift, not a controlled morph. Routes, best first: +- **Controlled, on the web (preferred):** the WebGL displacement morph between frame A and frame B (scroll-cinema.md) — exact, scroll-scrubbable, no video model, and it's the skill's signature move anyway. This is the real answer to "we have two frames and want the transition". +- **Organic video:** `reference_to_video` with A+B as references for a loose transition, or `image_to_video` on A for pure motion (push-in, steam, drift) — endpoints not guaranteed. +- **A TRUE controlled first→last VIDEO** (hard requirement) still means browser Kling (first+last mode) / Runway / Veo: package frame A + frame B + the motion prompt for the user, continue other scenes, drop the clip in when it arrives. + +**Going past 10s — chain segments.** Clips cap at 6–10s: `image_to_video` frame A, generate/edit the next state, animate that, `ffmpeg` concat. Each segment starts on the previous last frame so the seams hide. + +**Other honest sources:** user-provided footage (ask at intake — real footage still beats gen for truly photographic hero shots) and **Remotion** (local render) for graphic/typographic motion (kinetic type, animated diagrams, UI mockup motion) — it's code: consistent, revisable, free. + +If video still isn't right — the ladder (§4) covers you; scroll-scrubbed *sequences* read as "video" anyway. + +## 4. The degradation ladder (per scene, stop at the first rung you can execute) + +| Rung | What | Needs | Feels like | +|---|---|---|---| +| 1 | Scroll-scrubbed video | a real clip (§3) | full cinema | +| 2 | Canvas image sequence | a clip to explode into frames, or 6–12 generated in-between edits | Apple-grade product cinema | +| 3 | WebGL displacement morph A→B | just TWO keyframes (§2) | a living transition; the skill's signature move | +| 4 | Layered depth parallax | one keyframe cut into 2–4 layers (subject/bg), or CSS layers | dimensional, quietly premium | +| 5 | Kinetic typography / computed / pure CSS scene | nothing | still cinema, if the type system is strong | + +**When NO generator answers the probe**, rungs 1–4 are all unreachable at once — every one of them +needs at least one generated keyframe. Do not treat that as "descend one rung": go back to §0.5 and +re-read the source-vs-generate table as a *fallback* table rather than a spending decision, then land +on rung 5. And drop the idea that rung 5 is a consolation prize: for a brand whose claim is precision, +a scene *computed from the same data the product is about* is more honest than any photograph, because +nothing in it could have been someone else's object. Measured on a real run — three planned +generations became three computed scenes and the page got better. + +Rung 3 is the default answer to "we generated two images and want the video feel" — recipe (full GLSL) in scroll-cinema.md. + +## 5. Optimization recipes (run for every heavy asset) + +```bash +# Hero video → H.264 baseline (plays everywhere incl. iOS), streaming-ready, target ≤2MB +ffmpeg -i src.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -movflags +faststart -crf 23 -an hero.mp4 +# SCROLL-SCRUBBED video is different — a tiny GOP makes frame-accurate seeking cheap (see scroll-flight.md) +ffmpeg -i src.mp4 -an -vf "unsharp=5:5:0.8:5:5:0.0" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart scrub.mp4 +# WebM alternative for Chromium (smaller at same quality) +ffmpeg -i src.mp4 -c:v libvpx-vp9 -crf 30 -b:v 0 -an hero.webm +# Poster (first frame) for instant paint + reduced-motion fallback +ffmpeg -i hero.mp4 -frames:v 1 poster.png && ffmpeg -i poster.png -quality 82 poster.webp +# Explode a clip into a canvas sequence (target 60–240 frames total; ≤150KB/frame at 1440w) +ffmpeg -i hero.mp4 -vf "fps=30,scale=1440:-1" frames/f_%04d.webp +# Any still → WebP for the page (keep PNG originals in assets/gen as masters) +ffmpeg -i in.png -quality 82 out.webp +``` + +Budgets (verify.md re-checks): hero video ≤2MB · poster ≤300KB · sequence frame ≤150KB @1440w · any static hero image ≤400KB · mobile variants at 720w for every asset >500KB. + +**When a 4K texture still looks soft, resolution is not the problem.** Check two things, in this order. First, anisotropic filtering — off by default in three.js, and without it any surface viewed at a grazing angle (ground under a low camera, a floor receding to the horizon) smears no matter how many pixels the map holds: `tex.anisotropy = renderer.capabilities.getMaxAnisotropy()`. Second, the tiling scale, counted as **metres per repeat rather than repeats per plane** — a 260m ground plane with 28 repeats is a 9-metre tile, and at 9 metres the detail is gone at any texture size; ~3m per repeat is a working default for ground. Both mistakes look identical to "the texture is too low-res", which is why the reflex fix (download the 8K version) makes the page heavier and no sharper. + +## 6. Cache & bookkeeping + +- Names: `assets/gen/s--.png`. Before ANY generation, check the path — exists means reuse (iterating on layout must not re-bill image generation). +- Keep `assets/gen/ASSETS.log.md`: one line per asset — file, tool, full prompt, date. Makes retries reproducible and hands the user the recipe to regenerate at higher quality later. +- Masters stay PNG in `assets/gen/`; the page consumes optimized WebP/AVIF/mp4 from `assets/`. +- Rights note for the user (once, in the log header): generated media follows each generator's terms (Gemini / OpenAI / xAI / MiniMax); fine for product marketing, but flag it if the client needs exclusive IP or has legal review. + +## 7. Generated elements & mockups (not just full scenes) + +The crew also produces the small stuff — but every generated element must survive slopscan; a generated gradient/texture that's just decoration is banned like any other. Spend it, then make it earn its place. + +- **Textures / grain / noise / abstract shapes** → agy (fast, transparent where possible). Use as CSS `background`, `mask-image`, or a low-opacity overlay. Generate once, cache, reuse. +- **UI mockups in a scene** (device frame + screen, product-in-hand) → codex or grok for the still; Remotion when the mockup must move. +- **Hero mockup gate** (phase 0) can now be a *generated* frame, not only a hand-built HTML screen — one throwaway, screenshotted, approved before the real build. +- **Iconography / brand marks** → generate a set, then hand-pick: generated icon sets drift in weight/style, so treat them as sketches to redraw in SVG, not final assets. + +## 8. Ambient score (MiniMax music) + +If `MINIMAX_API_KEY` is set, generate ONE short, loopable ambient bed matched to the commit-sheet mood (tempo, key, tension). Playback recipe lives in scroll-cinema.md; generation rules: + +- Off by default; start on a user gesture — never autoplay with sound. Provide an honest, visible mute/unmute. +- Loop seamlessly: generate a phrase that resolves to its own start, then trim on a zero-crossing with ffmpeg. +- Budget it: ≤ ~1MB, mono is fine for ambience, lazy-load after LCP. +- It's set dressing, not content — the page must be complete and comprehensible with sound off. +- No key / not wanted → skip silently. Sound is the least load-bearing layer; never gate meaning on it. + +## 9. 3D scenes & camera paths (Blender CLI) + +Blender 5.1 is already on `PATH`; `cli-anything-blender` is also available for inspection. Keep the asset build reproducible with one native headless command: + +```powershell +blender --background --python tools\build_dolly.py +``` + +This complete `tools/build_dolly.py` builds a lit scene, moves `PathCamera` along a Bezier curve while tracking an Empty, bakes the camera transform, exports glTF 2.0 with camera + animation, then renders a 16-bit near-white/far-black depth master: + +```python +from pathlib import Path +import math +import bpy + +ROOT = Path(bpy.path.abspath("//")).resolve() +OUT = ROOT / "assets" / "gen" +OUT.mkdir(parents=True, exist_ok=True) + +bpy.ops.object.select_all(action="SELECT") +bpy.ops.object.delete(use_global=False) + +scene = bpy.context.scene +scene.frame_start, scene.frame_end = 1, 180 +scene.render.engine = "BLENDER_EEVEE_NEXT" +scene.render.resolution_x, scene.render.resolution_y = 1920, 1080 +scene.render.resolution_percentage = 100 +scene.render.image_settings.file_format = "PNG" +scene.world.color = (0.008, 0.01, 0.012) + +def material(name, color, metallic=0.0, roughness=0.45): + mat = bpy.data.materials.new(name) + mat.diffuse_color = (*color, 1.0) + mat.metallic, mat.roughness = metallic, roughness + return mat + +bpy.ops.mesh.primitive_plane_add(size=30, location=(0, 0, 0)) +bpy.context.object.data.materials.append(material("Floor", (0.025, 0.03, 0.035), 0.0, 0.28)) + +bronze = material("Bronze", (0.32, 0.12, 0.035), 0.72, 0.2) +for i, xyz in enumerate(((-4, -1, 1), (-2, 2, 1.6), (0, -2, 1.2), (2, 1, 2.1), (4, -1, 1.4))): + bpy.ops.mesh.primitive_cube_add(location=xyz, scale=(0.8, 0.8, xyz[2])) + box = bpy.context.object + box.name = f"Monolith_{i:02d}" + box.data.materials.append(bronze) + +for name, location, energy, size in ( + ("Key", (-4, -3, 8), 1500, 5), + ("Rim", (5, 2, 5), 900, 3), +): + data = bpy.data.lights.new(name, "AREA") + data.energy, data.shape, data.size = energy, "DISK", size + light = bpy.data.objects.new(name, data) + light.location = location + scene.collection.objects.link(light) + +curve_data = bpy.data.curves.new("DollyPath", "CURVE") +curve_data.dimensions, curve_data.resolution_u = "3D", 32 +spline = curve_data.splines.new("BEZIER") +points = ((-7, -7, 2.2), (-4, 2, 3.0), (1, -4, 2.5), (7, 5, 3.8), (2, 8, 4.4)) +spline.bezier_points.add(len(points) - 1) +for point, co in zip(spline.bezier_points, points): + point.co = co + point.handle_left_type = point.handle_right_type = "AUTO" +path = bpy.data.objects.new("DollyPath", curve_data) +scene.collection.objects.link(path) + +target = bpy.data.objects.new("LookTarget", None) +target.empty_display_type = "SPHERE" +target.location = (0, 0, 1.5) +scene.collection.objects.link(target) + +camera_data = bpy.data.cameras.new("PathCamera") +camera_data.lens, camera_data.clip_start, camera_data.clip_end = 42, 0.1, 40 +camera = bpy.data.objects.new("PathCamera", camera_data) +scene.collection.objects.link(camera) +scene.camera = camera + +follow = camera.constraints.new("FOLLOW_PATH") +follow.target, follow.use_fixed_location = path, True +follow.forward_axis, follow.up_axis = "FORWARD_NEGATIVE_Z", "UP_Y" +follow.offset_factor = 0.0 +follow.keyframe_insert("offset_factor", frame=scene.frame_start) +follow.offset_factor = 1.0 +follow.keyframe_insert("offset_factor", frame=scene.frame_end) + +track = camera.constraints.new("TRACK_TO") +track.target, track.track_axis, track.up_axis = target, "TRACK_NEGATIVE_Z", "UP_Y" + +bpy.ops.object.select_all(action="DESELECT") +camera.select_set(True) +bpy.context.view_layer.objects.active = camera +bpy.ops.nla.bake( + frame_start=scene.frame_start, + frame_end=scene.frame_end, + step=1, + only_selected=True, + visual_keying=True, + clear_constraints=True, + bake_types={"OBJECT"}, +) +camera.animation_data.action.name = "CameraPath" + +bpy.ops.export_scene.gltf( + filepath=str(OUT / "dolly.glb"), + export_format="GLB", + export_cameras=True, + export_animations=True, +) + +scene.view_layers[0].use_pass_z = True +scene.use_nodes = True +nodes, links = scene.node_tree.nodes, scene.node_tree.links +nodes.clear() +layers = nodes.new("CompositorNodeRLayers") +depth_range = nodes.new("CompositorNodeMapRange") +depth_range.inputs["From Min"].default_value = camera_data.clip_start +depth_range.inputs["From Max"].default_value = camera_data.clip_end +depth_range.inputs["To Min"].default_value = 1.0 +depth_range.inputs["To Max"].default_value = 0.0 +depth_range.use_clamp = True +depth = nodes.new("CompositorNodeOutputFile") +depth.base_path = str(OUT) +depth.format.file_format, depth.format.color_mode, depth.format.color_depth = "PNG", "BW", "16" +depth.file_slots[0].path = "dolly-depth-" +composite = nodes.new("CompositorNodeComposite") +links.new(layers.outputs["Depth"], depth_range.inputs["Value"]) +links.new(depth_range.outputs["Value"], depth.inputs[0]) +links.new(layers.outputs["Image"], composite.inputs["Image"]) + +scene.frame_set((scene.frame_start + scene.frame_end) // 2) +scene.render.filepath = str(OUT / "dolly-poster.png") +bpy.ops.render.render(write_still=True) +``` + +`dolly.glb` is the cached master; Three's `GLTFLoader` handles Blender→Three axis conversion. Keep the GLB ≤3MB, keep `dolly-poster.png` as the no-WebGL/reduced-motion fallback, and feed `dolly-depth-0090.png` to the 2.5D recipe when the live scene is too expensive. + +**Rules (or it's slop):** bake constraints before export; export one camera action, not per-shot GLBs; verify first/middle/last frames eyes-on; render depth from the same camera and frame; never rebuild a cached master during layout iteration. diff --git a/optional-skills/creative/auteur/references/build.md b/optional-skills/creative/auteur/references/build.md new file mode 100644 index 0000000000..3425b7044f --- /dev/null +++ b/optional-skills/creative/auteur/references/build.md @@ -0,0 +1,39 @@ +# build.md — the standard register + +Not every page is a film, and forcing cinema onto a docs site is its own kind of slop. The build register produces a conventional surface executed at award level: committed color, real typography, one signature, disciplined motion. Same taste core, calmer camera. + +## Process + +1. **Intake (one message):** product · audience · register of the surface (marketing page / product UI / content site) · brand constraints · stack. Autonomous → write assumptions down. +2. **Recon (bounded, ~5 min):** load `references/recon.md`. `node scripts/refscout.mjs --from awwwards --limit 6` for live references (real stack, page shape, fonts, palette, screenshots — note the ONE mechanic taken from each), and `node scripts/moodboard.mjs "" ""` when the art direction is still open. This register usually leans harder on the moodboard than on the mechanics. No playwright / no network → skip; taste.md's reflex table carries you. Never cite references you didn't see, and never quote a fingerprint the tool marked NO CAPTURE. +3. **Commit-sheet** (SKILL.md) — all six fields. In this register "Peak" means the **signature element**: the one thing a visitor would describe to a friend. A signature is load-bearing, not decoration: an interactive hero object, a distinctive navigation behavior, an oversized typographic system, a chart that responds to the reader. Pick one, execute it fully. +4. **Mockup gate:** one static throwaway hero screen (`design/mockup-hero.html`) with real copy, the commit-sheet palette and type — screenshot at 1440/390, run `node scripts/slopscan.mjs design/` on it (free, and this is the cheapest place to catch a banned gradient or a contrast failure), look, get a yes (user) or self-check against the commit-sheet (autonomous). Approved CSS custom properties become the project tokens verbatim; "approved with carried notes" is a legal verdict as long as the notes are written down. Minutes now, or a rebuild later. +5. **Skeleton before skin:** semantic HTML for the whole page first — headings hierarchy, landmarks, real copy (write it; lorem hides layout truth). The page must read as a document with CSS off. +6. **Tokens:** define OKLCH custom properties (bg, surface, ink, muted, accent + the commitment-tier colors), the type scale (clamp()-based), the spacing scale — before any component. Load `taste.md` for color/type decisions if not already loaded. +7. **Build top-down**, mobile-first. Each section: layout → type → color → then motion *last* (load `motion.md` before the first animation; respect the page motion budget from the commit-sheet). +8. **States are the product:** hover (gated `@media (hover:hover)`), focus-visible (always, and it must look designed, not default-blue-unless-brand), active, disabled, loading, empty, error. A beautiful happy path with default focus rings is an unfinished page. +9. **Verify** (verify.md): slopscan → shoot → rubric. Same gates as cinema, minus CINEMA-QA. +10. **Lock the style:** fill `templates/DESIGN.md` → `design/DESIGN.md` from the shipped code, so every later edit (the `edit` route) stays in the system instead of drifting back to the mode. + +## Modern platform defaults (use, don't ask) + +- Container queries for anything that lives in a variable-width slot; viewport queries for page chrome. +- `text-wrap: balance` on headings, `pretty` on prose. `@property` for animatable custom properties (gradient angles, numeric counters). +- Popover API + `` for menus/modals — free top-layer, light-dismiss, focus management; a positioned div in an `overflow:hidden` parent is a clipped dropdown waiting to happen. +- View Transitions (same-doc) for SPA state changes; `linear()` easing for spring feels without JS. +- `scroll-margin-top` on anchor targets under sticky headers. `:focus-visible` over `:focus`. `color-scheme` declared. +- Progressive enhancement is the architecture: CSS does the work until JS demonstrably wins; every JS enhancement wraps in a capability check; the un-enhanced page is complete, not broken. + +## Craft details that separate good from generated + +- Vertical rhythm: section paddings vary with content weight (tight where dense, airy around the signature). No uniform `padding-block: 6rem` down the whole page. +- Max ONE full-width colored band per viewport-height of scroll, or the page becomes a flag. +- Icons: one family, one stroke width, sized to the type scale (1cap or 1.2em), never as filler decoration next to every heading. +- Images get `aspect-ratio` reserved space (CLS), meaningful `alt`, `loading="lazy"` below the fold ONLY (hero is eager + `fetchpriority="high"`). +- Forms: labels always visible (placeholders are not labels), errors inline next to the field with recovery text, submit shows progress state. +- Tables for tabular data — styled, sticky-headed, right-aligned numerals with `font-variant-numeric: tabular-nums` — not card-ified into unscannability. +- Footer is a real place (sitemap, contact, legal), not three centered links. + +## When to escalate to the direct register + +If during build the commit-sheet's signature keeps growing — the client wants "more wow", the hero wants scroll choreography, assets want to be generated — stop patching. Say the surface has outgrown the register, and restart phase 0 in `direct.md` with the storyboard. A half-cinema page (one heavy scroll-jacked hero bolted onto a static page) is worse than either register done purely. diff --git a/optional-skills/creative/auteur/references/direct.md b/optional-skills/creative/auteur/references/direct.md new file mode 100644 index 0000000000..12893093af --- /dev/null +++ b/optional-skills/creative/auteur/references/direct.md @@ -0,0 +1,129 @@ +> **Hermes adaptation note:** upstream auteur generated assets through local agent CLIs (`agy`, `codex`, `grok`). In Hermes, read every such invocation as a call to the built-in `image_generate` tool with the same prompt (then move the returned file into the project's `assets/gen/` path), use the `terminal` tool for `ffmpeg`/`node`/`npx`, and `browser_exec` or Playwright-via-terminal for screenshot loops. The per-CLI routing/strength tables below are upstream reference material — the taste guidance transfers, the CLI names do not. + +# direct.md — the cinematic register + +In this register the page is a film: the viewport is the frame, scroll is the timeline, sections are scenes. You are the director, and directors do not start by shooting — they start with a script. Every phase below ends with a gate; do not cross a gate that fails. + +## Phase 0a — Intake (one message) + +Ask once, compactly: product & what it does · audience · the ONE feeling a visitor should leave with (awe / calm / hunger / trust / momentum...) · brand constraints (colors, fonts, logo — if any) · assets that already exist (photos, video, 3D, none) · where it will be hosted (static vs framework). If working autonomously, derive answers from available materials and write every derived answer into the STORYBOARD header's `Assumptions made` field — not into your own reasoning, where the next session cannot see it. + +## Phase 0a.5 — Recon (steal like a director) + +Before writing the screenplay, spend one short bounded pass gathering live reference — the reflex table in taste.md tells you what to avoid; recon tells you what's currently *alive*. Load `references/recon.md` and run both legs: + +```bash +node scripts/refscout.mjs --from awwwards --limit 8 # → design/refs/REFERENCES.md + shots +node scripts/moodboard.mjs "" "" --limit 24 # → design/moodboard/contact-sheet.png +``` + +- **refscout** profiles live award-level sites: their real stack, pinned scenes, scroll budget, fonts and painted palette, plus screenshots. You are hunting for *mechanics*, not skins. Look at every shot — the numbers describe the machinery, only your eyes judge the film. +- **moodboard** answers the other question: what should this *feel* like. Two or three queries on different axes (subject / treatment / graphic language), then fill the four read-lines in `MOODBOARD.md`; they feed commit-sheet fields 2 and 4 and every scene-sheet's `lighting:`. +- Note in the storyboard header (`References taken`): 2–3 named references and the ONE mechanic taken from each ("madewithgsap.com — section title pinned while cards scroll through it"). Stolen ideas get adapted to this brand, never copied wholesale — a reference is a starting camera position, not a set. +- Sites the tool marks **NO CAPTURE** withheld their CSS/JS from the headless browser; open them yourself or drop them, never quote a fingerprint it refused to give. No playwright / no network → skip recon without guilt; the reflex table + transition library carry you. Never cite a reference you didn't actually see. +- Whatever recon shows five times IS the category's first-order reflex — that finding belongs in commit-sheet field 6a, and your peak has to deviate from it. + +## Phase 0b — Screenplay + +Copy `templates/STORYBOARD.md` into the project (`design/STORYBOARD.md`) and write the film: + +**Structure: 5–7 scenes, classic arc.** + +| Beat | Role | Typical scenes | +|---|---|---| +| Hook | stop the scroll, set the world | 1 (the hero) | +| Rising | develop the promise | 1–2 | +| **Peak** | the ONE wow moment | exactly 1 | +| Proof | make it credible | 1–2 | +| Door | the CTA, land the feeling | 1 | + +**Dramaturgy rules:** +- Score each scene's intensity 1–10. Exactly one scene ≥8 (the peak). The hero hooks at 6–7 — if the hero *is* the peak, the rest of the page must consciously de-escalate (harder to pull off; prefer the peak at 40–70% depth). +- Two adjacent scenes must not share the same layout family or the same motion family. The cut between scenes is part of the film — pick every transition deliberately (library in scroll-cinema.md). +- The feeling from intake is the film's key. Every scene either builds it or contrasts it deliberately; a scene that does neither gets cut. Fewer, better scenes beat more scenes. + +**Scene-sheet — fill every field for every scene:** + +``` +### Scene N — | beat: hook|rising|peak|proof|door | intensity: 1-10 +purpose: what the viewer must FEEL and LEARN here (one line each) +subject: the single visual subject (product | image | typography | data | scene) +layout_family: full-bleed-media | split-asymmetric | centred-type | stacked-cards | + editorial-columns | pinned-canvas | marginal-notes (must differ from both neighbours) +motion_family: scroll-scrub | pinned-stage | entrance-reveal | parallax-depth | kinetic-type | + ambient-loop | none (≤3 distinct families page-wide; must differ from both neighbours) +camera: POV & framing — eye-level / low-angle (heroic) / high-angle (overview) / + macro (detail) / orbital (show all sides) / static +lighting: mood of the frame — hard contrast / golden / dusk / studio / neon / paper-flat +motion: what moves, in one sentence, incl. what drives it (scroll-scrub | entrance | loop | hover) +transition_in / transition_out: from the library (cut / wipe-mask / curtain / letterbox / + shutter / depth-parallax / displacement / view-transition) +scroll_len: how much scroll this scene owns (100vh–400vh; peak usually 300–400vh pinned) +copy: the headline + subline that live in this scene (write the actual words) +media: the director's shot spec for this scene's asset (→ the asset plan; route via assets.md): + · type: still | A→B morph | video | sequence | element/texture | 3D model | HDRI | none (type-led) + · route: SOURCE or GENERATE — assets.md §0.5 decides. Geometry, IBL lighting and tiling + materials are SOURCE (source.mjs, CC0); the peak keyframe and the hero video are + always GENERATE; stock video is never the peak + · tool: codex (peak photoreal) | grok-4.5 (color hero + ANYTHING that becomes video) | agy (volume/elements) + | source.mjs hdri|model|texture|icon|image|video + · frame prompt: the literal keyframe prompt = subject + camera + lighting + palette anchor (write it now) + · motion prompt: (video/morph only) what moves — e.g. "grok image_to_video on frame A: slow push-in + steam, 6s". Controlled A→B state change = WebGL displacement morph (two frames), NOT a video model + · score: (peak/ambient scenes only) mood/tempo for MiniMax music, or "none" +fallback: what this scene is when WebGL/video/motion is unavailable (static frame + one line) +``` + +`camera` and `lighting` matter even for pure-CSS scenes: they discipline composition (low-angle → oversized subject, viewer looks up; macro → crop tighter than comfortable) and they become literal prompt parameters when the asset is AI-generated. + +**GATE 0:** storyboard complete; exactly one peak; adjacent scenes differ in layout & motion family; every scene has a fallback and real copy (not lorem). If the user is present, get the storyboard approved — cheapest possible moment to change the film. Then fill the commit-sheet (SKILL.md) — the storyboard feeds it. + +## Phase 0c — Style gate (mockup before the shoot) + +Changing the art direction after six scenes are built costs a rebuild; changing it on one static screen costs minutes. Before asset production: + +1. Build ONE static hero screen as a throwaway HTML file (`design/mockup-hero.html`): real headline copy, the commit-sheet palette and type, the grid break — **no animations, no assets** (a solid-color placeholder block where the generated keyframe will live). 15–30 minutes of work, not more. +2. Screenshot it at 1440 and 390 (shoot.mjs on the file), look at it, and run the taste.md §8 self-check on the *image*. +3. Run `node scripts/slopscan.mjs design/` on the mockup. It costs nothing and this is the moment to catch a banned gradient or a contrast failure — *before* these tokens become the project tokens. +4. User present → show the screenshots and get a yes/no on the art direction (offer 2 variants only if genuinely torn — a director proposes, not a menu). Autonomous → self-check against the commit-sheet and record the verdict in the storyboard header. +5. Three verdicts, not two: + - **approved** → the mockup's CSS custom properties become the project tokens verbatim. + - **approved with carried notes** → good enough to build on, but with named problems you are knowingly carrying (a rule that will lose against real photography, a provisional typeface). Write them into the storyboard header's `Style gate verdict` field and resolve them by GATE 2. Undeclared carried notes become permanent. + - **rejected** → cheap redo of phase 0c, not of the film. + +## Phase 1 — Asset production + +Load `references/assets.md` and derive the asset plan from the storyboard's `media:` blocks. Split it in two before spending anything: everything routed SOURCE is fetched first (`source.mjs`, minutes and free), because a real CC0 mesh or HDRI often changes what the generated frames around it need to be. + +Order of operations (cost discipline): +1. List every needed asset with its scene, target resolution, and technique (from the selection table in scroll-cinema.md). +2. Generate ONE keyframe first (the peak scene's frame A). Check it against `lighting`/`camera` of the scene-sheet. Only after it's right, produce its frame B via *edit* and the remaining scenes' assets — this catches a wrong art direction at 1 image of cost, not 12. +3. Optimize everything (recipes in assets.md), verify weights against budget (hero ≤2MB video / ≤300KB poster / ≤150KB per sequence frame at 1440w). + +**GATE 1:** every scene's asset exists on disk at final path, weights within budget, frame A/B pairs verified same-scene-same-camera (open both, compare eyes-on), poster/fallback image exists for every heavy asset. + +## Phase 2 — Assembly + +Load `references/scroll-cinema.md`. Build order is not negotiable (it prevents the classic "everything jitters" rebuild): + +1. **Foundation first:** Lenis + GSAP ScrollTrigger integration skeleton (or CSS `animation-timeline` for simple scenes with the `@supports` fallback). No scene work until smooth scroll runs clean. +2. **Hero scene** — sets the technical pattern for everything after it. +3. **Scenes top-to-bottom**, one at a time; `ScrollTrigger.refresh()` after each. Wire each scene's `transition_in/out` from the library as you go — transitions are scene work, not polish. +4. **Text reveals** on headings/paragraphs per motion.md numbers — this stitches the film together. +5. **Reduced-motion cut**: implement the alternative art direction now (static posters, soft opacity rhythm, no pinning, no scrub), not as an afterthought. It's a *cut of the same film*, and it's also your no-JS/weak-device story. +6. **Mobile pass**: pinned scenes shorten or unpin (`scroll_len` × 0.6), assets swap to 720p/cropped variants, hover-driven moments get touch equivalents or graceful absence. + +Performance discipline while assembling: only `transform`/`opacity`; `will-change` only on actively animated layers (and removed after); heavy scenes lazy-init via IntersectionObserver; one `requestAnimationFrame` loop owner (GSAP's ticker) — never parallel rAF loops. + +**GATE 2:** every scene works at 390/768/1440; scrolling up replays cleanly (scrub is bidirectional — test it); no console errors; reduced-motion cut watchable end-to-end. + +## Phase 3 — Verification + +Load `references/verify.md`, run the full pipeline (slopscan → shoot → rubric), and fill `templates/CINEMA-QA.md`. The film ships when QA is all PASS — and after you have *watched your own film*: one uninterrupted slow scroll top to bottom, then one fast. Jank you can feel beats any metric. + +## Phase 4 — Lock the style (DESIGN.md) + +A shipped film gets sequels: "add a testimonials scene", "swap the pricing". Without a locked style contract, every later edit — by you, another model, or another session — drifts back toward the mode. After QA passes, fill `templates/DESIGN.md` into `design/DESIGN.md`: the actual tokens, type system, motion vocabulary, section-opening patterns, and this project's own ban additions. Every future edit starts by reading it (the `edit` route in SKILL.md enforces this). This is what makes the style *survive you*. + +## Sound (optional scene layer) + +Only if the brief asks for atmosphere: one ambient loop, off by default, visible mute/unmute toggle, starts only on user gesture (autoplay policies), volume ≤0.3, `prefers-reduced-motion` implies silent default. Policy details in motion.md §Sound. Sound is seasoning — a silent film that wows silently is complete. diff --git a/optional-skills/creative/auteur/references/motion.md b/optional-skills/creative/auteur/references/motion.md new file mode 100644 index 0000000000..bed1b763cc --- /dev/null +++ b/optional-skills/creative/auteur/references/motion.md @@ -0,0 +1,355 @@ +# Motion Reference — auteur skill + +Numeric, enforceable animation rules distilled from 13 motion sources. Every number is exact. Conflicts are resolved; only the winning rule appears. + +--- + +## When to animate + +Animate only when the motion answers one of these six questions: + +1. **Hierarchy** — does it show what matters most? +2. **Storytelling** — does it narrate a sequence? +3. **Feedback** — does it confirm an action? +4. **State transition** — does it show what changed? +5. **Spatial consistency** — does it orient the user in space? +6. **Preventing jarring change** — does it smooth a discontinuity? + +"Looks cool" is not a reason. If none of the six apply, delete the animation. + +**Frequency decision framework** — stop at the first row that matches: + +| How often the user triggers this | Rule | +|---|---| +| 100+ times/day (keyboard shortcuts, command palette) | Zero animation, ever | +| Tens/day (hover, list navigation) | Drastically reduce — near zero | +| Occasional (modal, drawer, toast) | Standard motion allowed | +| Rare / first-time experience | Can add delight | + +Apply this before writing any transition. A command palette toggle with a 200ms fade is a P1 block. + +--- + +## Easing + +**The resolved policy** (Emil over raphaelsalaja for UI): + +- **Enter → `ease-out`**. Arrives fast, settles gently. Feels faster than `ease-in` at identical duration. +- **Exit → `ease-out`** (same as enter for UI menus, drawers, toasts — this is the system-response model). +- **`ease-in` is banned on all UI motion.** Reserve it exclusively for Web Audio gain envelopes (exponential release before silence). +- **Marquee / progress bars / time representation → `linear`** only. Never use linear for positional motion. +- **On-screen morph (element repositions while visible) → `ease-in-out`.** +- **Hover / color → `ease` (CSS default).** + +Built-in CSS easing curves are too weak. Always use custom curves: + +```css +:root { + --ease-out-quart: cubic-bezier(0.23, 1, 0.32, 1); /* default for enter/exit */ + --ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1); /* on-screen morphs */ + --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* large panel slides */ +} +``` + +For spring-like bounces without a spring library, use `linear()` with sampled keyframes (CSS `linear()` function, widely supported 2024+). + +--- + +## Duration + +Default table — apply literally, justify any deviation in a comment: + +| Element | Duration | +|---|---| +| Button press / tap feedback | 100–160 ms | +| Tooltip appear | 125–200 ms | +| Dropdown / select open | 150–250 ms | +| Modal / drawer enter | 200–500 ms | +| Marketing / explanatory sequences | Longer allowed | + +**Hard rule: any UI transition over 300 ms requires a written justification** (comment in code or design note). No exceptions. If the animation feels slow, shorten the duration first — do not sharpen the curve as the primary fix. + +Similar elements must use identical timing. `button-primary 200ms` vs `button-secondary 150ms` is a fail. + +Modal exit is faster than enter (release snap): enter 200 ms, exit 150 ms. + +--- + +## Spring vs easing + +Decision table — pick one row and commit: + +| Motion type | Best choice | Why | +|---|---|---| +| User-driven (drag, flick, gesture) | Spring | Survives interruption; preserves velocity | +| System-driven (state change, feedback) | Easing | Clear start/end, predictable timing | +| Time representation (progress, loading) | Linear | 1:1 time-to-progress | +| High-frequency (typing, fast toggles) | None | Adds noise, makes UI feel slower | + +**Spring parameters:** +- Gesture / drag: `stiffness: 500, damping: 30` — balanced, no excessive bounce. +- Apple-style (preferred for simplicity): `{ type: "spring", duration: 0.5, bounce: 0.2 }`. +- Bounce > 0.3 only for drag-to-dismiss and explicitly playful contexts. Never in standard UI. +- Preserve velocity on flick: `animate(target, { x: 0 }, { type: "spring", velocity: info.velocity.x })`. + +**Rapidly-triggered elements (toasts, toggles) → CSS `transition`, not `@keyframes`.** Keyframes restart from zero on re-trigger; transitions retarget mid-flight smoothly. + +**Modal system state change → 200 ms `ease-out`, not spring.** Spring on a toast feels restless. + +--- + +## Physicality + +**Never `transform: scale(0)` for entrance.** Nothing in the real world appears from nothing. Start at `scale(0.95)` + `opacity: 0` at minimum; `scale(0.97)` is the safe default for small UI elements. + +**Press / tap squash-stretch:** `scale` range `0.95–1.05`. The standard: + +```css +button:active { + transform: scale(0.97); + transition: transform 160ms var(--ease-out-quart); +} +``` + +`whileTap={{ scale: 0.8 }}` is a P1 fail — too exaggerated. + +**Origin-aware popovers and dropdowns** — the element must scale from its trigger, not from its own center: + +```css +/* When using Radix UI */ +[data-radix-popper-content-wrapper] > * { + transform-origin: var(--radix-popover-content-transform-origin); +} + +/* When using Base UI */ +[data-popup] { + transform-origin: var(--transform-origin); +} +``` + +**Modals are exempt from origin-awareness** — keep `transform-origin: center` on modals. They represent a system interrupt, not a trigger-anchored element. + +Never set `transform-origin: center` on trigger-anchored popovers, tooltips, or dropdowns. + +--- + +## Performance + +**GPU-composited properties only: `transform` and `opacity`.** Animating `width`, `height`, `top`, `left`, `margin`, or `padding` forces layout → paint → composite on every frame. This is unanimously banned across all 13 sources. + +**`window.addEventListener('scroll', …)` is banned** — jank-prone, no batching, blocks main thread. Use instead: + +- Framer Motion: `useScroll()` + `useTransform()` +- GSAP: `ScrollTrigger` +- Vanilla: `IntersectionObserver` +- CSS: `animation-timeline: view()` + +**Framer Motion shorthands (`x`, `y`, `scale` as separate props) are not hardware-accelerated under load** — they run on the main thread via rAF. For pinned sections and scroll-scrubbed animations, use full transform strings or GSAP: + +```tsx +// Weak under scroll load: + + +// Correct for pinned / scroll-driven: + +// or migrate to GSAP for the section +``` + +**Never drive a child's transform via a CSS variable on a parent** — causes style-recalc storm on all children. Set `transform` directly on the target element. + +**Continuous values (mouse position, scroll progress, pointer physics) → `useMotionValue` + `useTransform`, never `useState`.** `useState` triggers a React re-render per scroll tick; `useMotionValue` updates the DOM directly. + +```tsx +// Banned: +const [scrollY, setScrollY] = useState(0); +useEffect(() => { window.addEventListener('scroll', () => setScrollY(window.scrollY)); }, []); + +// Correct: +const { scrollY } = useScroll(); +const opacity = useTransform(scrollY, [0, 300], [1, 0]); +``` + +`useEffect` animations must always include cleanup (`gsap.context()` + `ctx.revert()`, or Motion's unsubscribe). + +`will-change: transform` — use sparingly, only on elements that are actively animating. It promotes to a GPU layer immediately; overuse wastes VRAM. + +Grain / noise filter overlays: only on `position: fixed; inset: 0; pointer-events: none; z-index: 60` pseudo-elements. Never on scrolling containers — continuous GPU repaints destroy mobile FPS. + +### Fullscreen passes are priced per pixel, not per object + +A scene rarely dies of geometry. Hundreds of thousands of triangles, thousands of particles, shadows and volumetric fog all fit inside a 16.7ms frame. What eats the budget is every pass that touches the whole screen, because those cost the same whether the frame contains one sphere or a city. Order of magnitude, measured on a retina laptop (1440×900 @2x = 5.2MP) for one WebGL scene: + +| Pass | ~cost / frame | | +|---|---|---| +| chromatic aberration + grain | 8ms | the "free" cinematic layer is the most expensive thing on the page | +| bloom | 7ms | at half-res; dropping to quarter-res saved 0.7ms — the cost is compositing over the frame, not the blur | +| custom transition shader | 5ms | | +| depth of field | 17ms | over the entire budget alone; it was cut, not optimized | + +Read the **order**, not the absolutes — your GPU differs, and summing these is meaningless because passes overlap. Three rules follow: + +- **Pixel count is the main lever — for pages that have these passes.** A scene carrying DoF + bloom + grain runs 60fps at 2MP and 30fps at 4.5MP. A scene with no fullscreen pass barely notices: measured on three showcase sites at 4× CPU throttle, DPR 1 → 2 moved minFps by 0–1 (53→54, 53→53, 54→54), because there the ceiling is the main thread, not fillrate. Still measure at DPR 2 — the day a bloom lands, the honest number is already the one you have been quoting. Cap `renderer.setPixelRatio(Math.min(devicePixelRatio, 2))`, and when a scene is over budget, cut resolution or a pass before you cut geometry. +- **Measure by ablation** — switch passes off one at a time and re-measure. Intuition is wrong about which one hurts: shadows usually turn out nearly free, and the effect that "barely does anything" is often the 8ms one. +- **Measure the production build.** A dev server costs roughly 2× per frame (HMR client, unminified bundles, no asset pipeline), so its numbers describe a page nobody will load. `motionqa.mjs` flags a detected dev server, but it cannot detect every one of them. + +--- + +## Stagger and orchestration + +- **Stagger delay: 30–80 ms between items.** Upper bound is 50 ms per item for lists — anything longer makes the reveal feel broken. +- Stagger is decorative. **It must never block interaction.** The list is interactive from the moment it renders; the stagger is cosmetic only. +- **Reveal animations must enhance an already-visible default.** Content must be readable with JavaScript disabled, because CSS transitions pause in hidden tabs — a section that starts `opacity: 0` via JS will ship blank in that case. + +```tsx +// Motion RevealStagger skeleton (feature lists, testimonials, logo walls): +initial={{ opacity: 0, y: 24 }} +whileInView={{ opacity: 1, y: 0 }} +viewport={{ once: true, amount: 0.3 }} +transition={{ duration: 0.6, delay: i * 0.06, ease: [0.16, 1, 0.3, 1] }} +``` + +`staggerChildren` in Framer Motion requires parent and child to be in the same Client Component tree. Async data → pass through props into a centralized parent Motion wrapper. + +**Library routing:** +- Framer Motion — UI components, Bento layouts, state-change animations. +- GSAP + ScrollTrigger — full-page scrolltelling, pinned sections, horizontal pans. +- Never mix GSAP/Three.js and Framer Motion in the same component tree. + +--- + +## Motion budget + +Page-level constraints that most motion guidance omits: + +- **Max 3 distinct scroll-triggered animation families per page.** (A "family" = a combination of easing + distance + direction. Three fade-up variants count as one if identical.) +- **Each additional scroll reveal must differ from the previous in at least one dimension** — easing, distance, or direction. Uniform fade-in on every section is a fail. +- **Marquee: max 1 per page.** +- **One primary "wow" peak per page.** Supporting scenes run at lower visual intensity. Two hero-level spectacles compete and cancel each other. +- If a storyboard scene claims intensity >4, the scene must visibly move. If it can't (asset missing, perf budget), downshift the scene's intensity honestly instead of faking it with decoration. + +--- + +## Modals, drawers, toasts + +- **Modals:** `transform-origin: center`. Enter 200 ms `ease-out`; exit 150 ms (faster, release snap). Spring is wrong here — use easing. +- **Drawers / toasts:** CSS `transition`, not `@keyframes` — these are rapidly triggered and must retarget smoothly on re-trigger. `@starting-style { opacity: 0; transform: translateY(100%); }` for CSS-only entry without JS. +- **Tooltips:** suppress delay and animation on subsequent hovers — after the first tooltip, all are instant: + ```css + [data-instant] { transition-duration: 0ms; } + ``` +- **Drag-to-dismiss:** use momentum, not distance threshold. `Math.abs(distance) / elapsedTime > 0.11` → dismiss. A flick is enough. +- Enable pointer capture during drag so motion continues after the cursor leaves the element. +- Multi-touch protection: `if (isDragging) return;` — ignore new touch points after drag begins. + +--- + +## Reduced motion + +`@media (prefers-reduced-motion: reduce)` is **mandatory for any scroll-driven animation, parallax, or large-scale motion.** Not optional. + +**Reduced = gentler, not zero.** Treat it as an alternative art direction: + +| Keep | Drop | +|---|---| +| `opacity` transitions | `transform` movement | +| `color` / `background` transitions | Parallax offsets | +| Subtle scale (≤ 2%) | Scroll-scrubbing | +| State indication | Entrance slide-in | + +```css +@media (prefers-reduced-motion: reduce) { + .animated-section { + /* opacity-only fallback — transforms removed, state still visible */ + transform: none !important; + animation: none !important; + transition: opacity 200ms ease; + } +} +``` + +--- + +## Hover + +Gate all hover effects behind the pointer media query — touch devices fire false hover states on tap: + +```css +@media (hover: hover) and (pointer: fine) { + .card:hover { + transform: translateY(-4px); + transition: transform 200ms var(--ease-out-quart); + } +} +``` + +No hover animation outside this gate. Ever. + +--- + +## Sound + +Sound is a parallel channel to motion — it follows the same budget discipline. + +**Use sound only for:** +- Confirmation (payment completed, file uploaded, form submitted) +- Error state +- Notification / alert + +**Never use sound for:** typing, hover, scroll events, keyboard navigation — keyboard nav with click sounds becomes unbearable immediately. + +**Implementation rules:** + +```ts +// singleton — new AudioContext() per call leaks nodes and hits mobile context limits +let _ctx: AudioContext | null = null; +function getAudioContext(): AudioContext { + if (!_ctx) _ctx = new AudioContext(); + if (_ctx.state === 'suspended') _ctx.resume(); + return _ctx; +} + +function playConfirm() { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; // doubles as reduced-sound + const ctx = getAudioContext(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); gain.connect(ctx.destination); + gain.gain.setValueAtTime(0.3, ctx.currentTime); // default 0.3, never 1.0 + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); // exponential, not linear + osc.start(); osc.stop(ctx.currentTime + 0.4); + osc.onended = () => { osc.disconnect(); gain.disconnect(); }; +} +``` + +- Default volume: **0.3**. Never 1.0. +- Envelope decay: **`exponentialRampToValueAtTime(0.001, t)`**, not `linearRampToValueAtTime(0, t)`. Linear sounds mechanical; exponential matches human perception. Always call `setValueAtTime` before ramping. +- `prefers-reduced-motion` doubles as reduced-sound — if the media query matches, skip playback entirely. +- Provide an explicit sound toggle in settings: ``. +- Sound weight must match action weight: soft click for toggle, success chime for purchase. A loud buzzer for form validation is punishing — never do this. +- Click/tap sounds: 5–15 ms duration, bandpass filter 3 000–6 000 Hz, Q 2–5. +- Rapid re-trigger: `audio.currentTime = 0` before `play()`. + +--- + +## Anti-pattern quick reference + +| Pattern | Why it fails | +|---|---| +| `transition: all` | Animates every property including layout — unbounded | +| `scale(0)` entrance | Nothing appears from nothing; start at 0.95 | +| `ease-in` on UI | Feels slower than ease-out at identical duration | +| Animation on 100+/day actions | Accumulates into constant noise | +| UI duration > 300 ms, no justification | Noticeably slow | +| `transform-origin: center` on trigger-anchored popovers | Scales from wrong origin | +| `@keyframes` on toasts / toggles | Restarts from zero on re-trigger | +| Animating `width/height/margin/top/left` | Forces layout + paint every frame | +| Framer Motion `x/y/scale` under scroll load | Main-thread rAF, not composited | +| CSS variable on parent to drive child transform | Style-recalc storm on all children | +| Missing `prefers-reduced-motion` | Accessibility block | +| `:hover` without `(hover: hover) and (pointer: fine)` | False-fires on touch | +| Uniform fade-in on every scroll section | Violates motion budget | +| > 1 marquee per page | Visual noise | +| `new AudioContext()` per call | Leaks nodes; crashes on mobile | +| `linearRampToValueAtTime(0, t)` for decay | Sounds mechanical | +| Sound on hover / scroll / keyboard nav | Unbearable at speed | +| Default volume 1.0 | Jarring | diff --git a/optional-skills/creative/auteur/references/recon.md b/optional-skills/creative/auteur/references/recon.md new file mode 100644 index 0000000000..3768e5aa87 --- /dev/null +++ b/optional-skills/creative/auteur/references/recon.md @@ -0,0 +1,210 @@ +# recon.md — scouting live references and building a moodboard + +Taste is not invented at the desk. Every director watches films before shooting one, and the +reflex table in `taste.md` only tells you what to *avoid* — recon tells you what is currently +*alive*. Two commands do the gathering; you do the reading. Both write into `design/`, both are +cheap, both are bounded: recon is a short pass at the top of phase 0, not a research project. + +| | command | answers | +|---|---|---| +| **A** | `node scripts/refscout.mjs` | *how do the best sites in this space actually work?* — live sites, their real stack, their scroll mechanics, screenshots | +| **B** | `node scripts/moodboard.mjs` | *what should this feel like?* — palette, light, composition, type energy, texture | + +Run A when the brief has a **mechanic** question (direct register, "wow", scroll choreography). +Run B when the brief has a **look** question (any register with an undecided art direction). +Most briefs want both, in that order, ~5 minutes total. Skip either without guilt when the brief +already fixes that axis (existing brand book → skip B; "boring docs site" → skip A). + +Both need `playwright` (`npm i -D playwright && npx playwright install chromium`). No API keys, +no logins, no paid services. + +--- + +## A. refscout — take the references apart + +```bash +# harvest what is winning right now, profile it +node scripts/refscout.mjs --from awwwards --limit 8 + +# same, filtered by the gallery's own category or a text search +node scripts/refscout.mjs --from awwwards:scrolling --limit 6 +node scripts/refscout.mjs --from awwwards --search "coffee" --limit 5 + +# or profile sites you already know / found with WebSearch +node scripts/refscout.mjs https://a.com https://b.com --shots 4 +``` + +Output lands in `design/refs/`: `REFERENCES.md` (read it), `refs.json`, `shots/*.png`. + +**Finding candidates.** `--from awwwards` is the only harvester wired in, because it is the only +gallery whose listing *and* detail pages render reliably headless and expose the outbound site +URL. For everything else — godly.website, curated.design, minimal.gallery, land-book, siteinspire, +thefwa, lapa.ninja, mobbin — use WebSearch to find the write-ups, then pass the site URLs to +refscout positionally. Searching for the *page about* a site is more reliable than scraping the +gallery that lists it. + +**Naming a technique you can see but can't name.** originkit.dev is a catalogue of ~160 motion +effects, each with a live preview and a name — useful when a reference does something you want to +describe in the commit-sheet and have no word for. Browse it, take the vocabulary, then build the +thing yourself; the components are React/framer-motion behind a signup, and half the catalogue is +the exact drop-in ornament `slopscan` exists to keep out. + +### Reading a fingerprint + +Each entry reports what the page actually loaded and did, not what its marketing says: + +- **stack** — libraries found in the JS the page really fetched. Bundlers hide globals, so this is + matched against bundle text; treat it as strong evidence, not proof. `GSAP + ScrollTrigger + + ScrollSmoother + Lenis` is the house style of the entire awwwards top tier — seeing it for the + fifth time is the useful signal, not a coincidence. +- **mechanics** — the derived read: pinned scenes (counted from `.pin-spacer` elements ScrollTrigger + leaves in the DOM), WebGL driven by scroll, sticky stacks, CSS `animation-timeline`, mix-blend + layers, custom cursor, scrubbed video. This is the column you are actually shopping in. +- **page shape** — `22× viewport tall · 14 sections` tells you the scroll budget the reference spends. + A 22× page with 4 pinned scenes is a different film from a 3× page with one WebGL hero, and the + difference is a decision you have to make too. +- **type / palette** — the fonts and colours as painted, sampled from computed styles. `LayGrotesk + + PPNeueMontrealMono`, `Thunder + PP Fraktion Mono` — this is where you learn that the top tier is not + running Inter, which is exactly ban #8 with receipts. The display face is measured as the largest + *painted* text (`Thunder, sans-serif 118px/800`), never the first `h1`, because a hidden or + fallback-styled heading reports whatever the cascade left there and will cheerfully claim an + awwwards winner ships Inter. The px figure is evidence too: this tier runs display type past 100px. +- **shots** — hero plus scroll stops at 1440. **Look at every hero. Look at the remaining stops only + for the 2–3 sites you actually take a steal from.** At `--limit 8 --shots 3` that is 23 images and + reading all of them is not triage, it is a context bonfire. The numbers tell you the machinery; + your eyes decide whether it is beautiful — but only for the references you are actually using. + +### ⚠ "NO CAPTURE" entries + +Some sites hand a headless browser the server-rendered HTML and then never deliver their CSS or JS +(edge-streamed pages, bot walls). Anything read off such a page — fonts, colours, stack — would be +Chrome's defaults dressed up as findings, so refscout reports **nothing** for them rather than +something false. Roughly 1 in 8 award sites lands here. When it does: open the URL yourself, or +describe it from the gallery write-up, or drop it. Never quote a fingerprint the tool refused to give. + +### The steal rule + +Every entry has a `**steal:**` line and it is not decoration — fill it in, one line, before you +close the file: + +> "madewithgsap.com — the section title stays pinned while its cards scroll *through* it; we do this +> once, on the proof scene, with the product name instead of a title." + +Rules, in order of how often they are broken: + +1. **One idea per reference, named as a mechanic** — "pinned title, content scrolls through", not + "cool scroll effect", and never "the vibe". +2. **Adapted, or it is theft.** A reference is a starting camera position, not a set. Changing the + colours of a copied layout is not adaptation. +3. **Two references maximum feeding one scene.** Three is a collage, and collages read as slop. +4. **Never cite a reference you did not look at.** A fingerprint without eyes on the shots is half a + reference. +5. Named brand assets — logos, custom typefaces, photography, illustration — are theirs. Mechanics + and structure are free; identity is not. + +### Feeding the commit-sheet + +Recon changes what you write in the commit-sheet, especially field 6: + +- The stacks and mechanics you saw five times ARE the first-order reflex (6a) for this category. If + your peak is the fifth pinned-WebGL-hero of the day, you found the mode, not an idea. +- The type and palette columns give you a real, dated map of what the category currently looks like + — much sharper than reasoning about it from memory. +- Any mechanic you take goes into the motion budget (field 5) as one of the ≤3 families, not on top + of it. + +In the direct register, put the 2–3 references and their one-line steals in the STORYBOARD header +(`References taken`) so the decision survives into the build. + +--- + +## B. moodboard — decide what it should feel like + +```bash +node scripts/moodboard.mjs "editorial brutalist layout dark" "high contrast type poster" --limit 24 +node scripts/moodboard.mjs "terracotta ceramic studio light" --source bing,arena --limit 16 +``` + +Output lands in `design/moodboard/`: `contact-sheet*.png` (**look at these**), numbered `img/NN.jpg`, +and `MOODBOARD.md` mapping every tile number to its origin page. Twenty tiles arrive as one image, +so reading a moodboard costs one look, not twenty. + +**Sources** (all no-auth, tried in this order and interleaved so none owns the sheet): + +- `bing` — the workhorse. Bing's image index reaches into Pinterest, Dribbble and Behance CDNs and + serves originals that hotlink fine through a browser context. +- `pinterest` — logged-out search renders a partial grid behind the login wall: sometimes ~15 pins, + sometimes zero, day to day. Genuinely useful when it answers, never load-bearing; when it returns + nothing the script says so and bing has already covered it. +- `arena` — the public are.na search API. Lower volume, highest curation: these are blocks designers + saved for themselves. + +### Query craft + +The quality of a moodboard is decided entirely by the queries. Three rules: + +1. **Two or three queries on different axes, never one.** A single query returns near-duplicates of + one template. Pick axes: *subject/medium* ("ceramic studio photography"), *treatment* ("hard rim + light, deep shadow"), *graphic language* ("swiss grid poster, red accent"). +2. **Search the feeling, not the category.** `"coffee website"` returns other coffee websites — the + category reflex, delivered to your desk. `"steel and steam, industrial macro, warm shadow"` + returns material you can actually direct from. +3. **Check the anchor noun is not half of a fixed compound.** `"polished brass instrument macro"` + returns trumpets and saxophones, because *brass instrument* is one word to a search index. Same + trap: hard surface, light bulb, glass ceiling, sound board, steel drum. The noun is supposed to + ground the query, and an idiom hijacks it instead. +4. **Anchor a treatment query with a concrete noun.** Image search collapses an abstract phrase to + its most commercially indexed substring: `"hard rim light on dark glass, wet stone, near-black + macro"` came back as *hard surface* — hi-vis workers, gravel, granite pavers. `"wet black stone, + single hard light"` does not. When one query of three drifts, that is the mechanism. +5. **Never search a brand you intend to resemble.** That is the shortest path to a page that looks + like a competitor with the logo swapped. + +### Reading the sheet + +Fill the four lines at the bottom of `MOODBOARD.md` — the moodboard exists to produce these, and an +unfilled index means the sheet was decoration: + +- **dominant palette** across the tiles you liked → commit-sheet field 2, converted to OKLCH. Take + the *relationship* (drenched single hue / near-mono with one accent / earthy midtones), not a + literal eyedropper. +- **light and contrast character** → the `lighting:` line of every scene-sheet and the literal + lighting parameter in generated-asset prompts (`assets.md` §1). +- **one composition move worth stealing** → commit-sheet field 4 (grid break). +- **what to explicitly avoid from these** → a real answer, because a sheet always contains the + category reflex too, and naming it is how you stop drifting into it. + +Expect 10–20% junk per sheet. It usually is not spread evenly: one query drifts wholesale while the +others stay clean, so read the junk as a signal about that query, not about the sheet. Ignore the +strays; re-run only if a whole axis came back wrong, and re-run that axis with a concrete noun. + +### Reference images are not assets + +Downloaded images are direction only. They are other people's work, they are not licensed to you, +and none of them ships: + +- never place a downloaded image in the page, not even as a placeholder that "we'll swap later"; +- never reproduce one composition — the moodboard sets palette, light and energy, and the actual + frames get generated per `assets.md`; +- when the art direction is locked, the moodboard's job is done: it informs the generation prompts + and then stays in `design/`. + +`design/moodboard/` and `design/refs/` are working artifacts. Ship them to no one and add them to +`.gitignore` if the repo is public. + +--- + +## When recon fails + +| symptom | what it means | do this | +|---|---|---| +| `awwwards listing returned no cards` | the gallery markup moved | fall back to WebSearch + positional URLs | +| most entries are `NO CAPTURE` | network or a broad bot wall | profile fewer, better-known sites; lean on the shots and your own eyes | +| `pinterest returned 0` | login wall, normal | ignore, bing covered it | +| the sheet is 20 variants of one image | single narrow query | re-run with 2–3 queries on different axes | +| one query's tiles are a different subject entirely | search collapsed an abstract phrase to a common substring | re-run that axis anchored to a concrete noun | +| a script looks hung for minutes | you piped it through `tail`/`head` | both stream progress to stderr; drop the pipe to watch it | +| references all look alike | you found the category mode | that IS the finding — write it into reflex-check 6a and deviate | + +Recon is bounded. If it has not produced a usable read in one pass, stop and decide from +`taste.md` — a director who cannot find a reference still has to shoot the film. diff --git a/optional-skills/creative/auteur/references/scroll-cinema.md b/optional-skills/creative/auteur/references/scroll-cinema.md new file mode 100644 index 0000000000..1460139453 --- /dev/null +++ b/optional-skills/creative/auteur/references/scroll-cinema.md @@ -0,0 +1,1532 @@ +# scroll-cinema — Cinematic Scroll Recipes + +Stack default 2026: **GSAP 3.13 + ScrollTrigger 3.13 (free), Lenis 1.3, Three.js r170+, CSS scroll-driven animations (Chrome 115+ / Safari 26) with `@supports` fallback on GSAP. View Transitions API — 88.64% global support.** + +--- + +## Technique selection + +| Narrative need | Technique | +|---|---| +| Product in motion — user scrubs through a scene | Scroll-scrubbed video | +| Mood/state shift between two AI-generated keyframes | WebGL displacement shader transition | +| Atmosphere with available video asset | Scroll-scrubbed video | +| Text as hero — no assets | Kinetic typography (SplitType stagger) | +| Simple section reveals — minimal JS | CSS scroll-driven animations with GSAP fallback | +| Page-to-page continuity, shared-element morphs | View Transitions API | +| Pixel-accurate product animation, any frame swappable | Canvas image sequence | +| 3D object or abstract CGI scene | Three.js + ScrollTrigger | +| Atmospheric brand presence | Ambient audio + mute toggle | + +--- + +## Scroll-scrubbed video + +Viewer scrolls — a pre-generated video scrubs forward/backward in sync with scroll progress. First frame = one composition, last frame = another (product revealed, camera moved). This is the primary wow technique: igloo.website, Runway, Veo launch pages, Apple AirPods. + +> The GSAP recipe below is the minimal single-clip scrubber. For a **photoreal, multi-scene "fly through the world"** — mobile/iOS-hardened, blob-loaded, seek-coalesced, with crossfade or seamless seams — use the drop-in engine in `templates/scroll-flight-engine.js` and the full recipe in **`references/scroll-flight.md`** (it also carries the critical `-g 8` encode and the SSIM seam gate). Reach for that when the hero is footage/AI-video rather than a single generated clip. + +**Video prep:** +```bash +# H.264 baseline for Safari/old Android, VP9 for Chrome +ffmpeg -i source.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p \ + -movflags +faststart -crf 23 hero.mp4 +ffmpeg -i source.mp4 -c:v libvpx-vp9 -crf 30 -b:v 0 hero.webm + +# Teaser (first 1-2s, < 200KB) — poster effect while main loads +ffmpeg -i source.mp4 -t 2 -c:v libx264 -profile:v baseline -level 3.1 -crf 28 teaser.mp4 +``` + +```html + +``` + +Scrubbing needs a scroll-progress engine. A raw `window.addEventListener('scroll')` loop is banned by this skill's own rules (and slopscan) — ScrollTrigger is the sanctioned driver: + +**GSAP ScrollTrigger + Lenis (idiomatic Awwwards stack):** +```js +import gsap from 'gsap' +import ScrollTrigger from 'gsap/ScrollTrigger' +import Lenis from 'lenis' + +gsap.registerPlugin(ScrollTrigger) + +const lenis = new Lenis() +lenis.on('scroll', ScrollTrigger.update) +gsap.ticker.add((time) => lenis.raf(time * 1000)) +gsap.ticker.lagSmoothing(0) + +const video = document.querySelector('#hero') +video.pause() + +ScrollTrigger.create({ + trigger: video, + start: 'top top', + end: '+=400%', + pin: true, + scrub: 0.5, // 0 = instant, number = smoothing in seconds; use 0.3–0.8 + onUpdate: (self) => { + video.currentTime = self.progress * video.duration + }, +}) +``` + +| Pitfall | Fix | +|---|---| +| Video > 2 MB | Compress to ≤ 2 MB; loop 5–8 s; serve teaser as first paint | +| `currentTime` resets after full load | Wait for `loadedmetadata`, then set; `preload="metadata"` is required | +| Seek jitter on scrub | Move `currentTime` only while scrolling; never touch it on pause | +| iOS Safari autoplay | `muted playsinline` attributes are mandatory — video won't play without them | +| `prefers-reduced-motion` | Show static poster + `autoplay muted loop` video, no scrub | +| Mobile > 1080p lag | Serve `hero-mobile.mp4` at 720p via media query | +| `content-visibility: auto` on parent | Breaks ScrollTrigger — never use on pinned ancestors | + +**Use when:** hero section, 3–6 s sell-the-product moment, user = director concept. +**Avoid when:** long-form content pages, information-first UX, full-page application. + +--- + +## Canvas image sequence + +60–240 pre-rendered frames drawn to `` per scroll progress. Deterministic, no seek jitter, better first-frame LCP, swappable per A/B test. Apple AirPods Pro pattern (2019+). + +**Export frames:** +```bash +# From video/CGI at 30 fps: +ffmpeg -i render.mp4 -vf "fps=30" -q:v 5 frames/frame_%04d.jpg +# WebP (smaller): +ffmpeg -i render.mp4 -vf "fps=30" -lossless 0 -compression_level 4 -q:v 70 frames/frame_%04d.webp +``` +Frame budget: 24 fps × 2–5 s = **48–120 frames** for one camera pass. 60–240 for complex scenes. + +**HTML:** +```html + +
+``` + +**Preload + draw:** +```js +const canvas = document.getElementById('seq') +const ctx = canvas.getContext('2d') +const FRAME_COUNT = 120 +const frames = new Array(FRAME_COUNT) + +function preloadFrame(i) { + return new Promise(resolve => { + const img = new Image() + img.onload = () => { frames[i] = img; resolve() } + img.onerror = resolve // don't crash on a broken frame + img.src = `/frames/frame_${String(i + 1).padStart(4, '0')}.webp` + }) +} + +async function preloadAll() { + // batch 8 at a time — avoids saturating the network + for (let i = 0; i < FRAME_COUNT; i += 8) { + await Promise.all( + Array.from({ length: 8 }, (_, k) => i + k) + .filter(k => k < FRAME_COUNT) + .map(k => preloadFrame(k)) + ) + } +} + +function draw(progress) { + const idx = Math.min(FRAME_COUNT - 1, Math.floor(progress * FRAME_COUNT)) + if (frames[idx]) ctx.drawImage(frames[idx], 0, 0, canvas.width, canvas.height) +} + +// Drive with ScrollTrigger (raw scroll listeners are banned by this skill): +preloadAll().then(() => { + ScrollTrigger.create({ + trigger: '#seq', + start: 'top top', + end: '+=400%', + pin: true, + scrub: 0.3, + onUpdate: (self) => draw(self.progress), + }) +}) +``` + +| Pitfall | Fix | +|---|---| +| 240 frames × 100 KB = 24 MB | WebP lossless 0 q70 → ~30–60 KB/frame → 7–15 MB total; serve smaller set to mobile | +| Blocking preload (5+ s) | Batch 8 simultaneously; `` for first 5–10 frames | +| Retina blur | Multiply canvas size by `devicePixelRatio`; `ctx.scale(dpr, dpr)` | +| Screen reader sees nothing | `role="img"` + `aria-label` on canvas; add `

` description in section | +| PageSpeed "large network payload" | CDN + HTTP/2 multiplexing + Service Worker cache; minimal first frame as LCP | + +**Use when:** pixel-accurate product spin, text-in-frame scenes, A/B-swappable keyframes. +**Avoid when:** AI scene > 10 s (video is cheaper); dynamic/personalized content. + +--- + +## GSAP ScrollTrigger + Lenis foundation + +The load-bearing skeleton. Wire this first — every other scroll effect depends on it being present and correct. + +```bash +npm i gsap lenis +# ScrollTrigger is free since 2024; only SplitText and MorphSVG are Club-only +``` + +**Foundation (copy verbatim, do not reorder):** +```js +import gsap from 'gsap' +import ScrollTrigger from 'gsap/ScrollTrigger' +import Lenis from 'lenis' +import 'lenis/dist/lenis.css' + +gsap.registerPlugin(ScrollTrigger) + +const lenis = new Lenis() +lenis.on('scroll', ScrollTrigger.update) // keep ScrollTrigger in sync with Lenis +gsap.ticker.add((time) => lenis.raf(time * 1000)) +gsap.ticker.lagSmoothing(0) // prevent GSAP catching up after tab blur +``` + +**Common recipes built on the foundation:** +```js +// Fade-up on enter +gsap.from('.card', { + y: 60, opacity: 0, duration: 1, ease: 'power3.out', + scrollTrigger: { trigger: '.card', start: 'top 80%', toggleActions: 'play none none reverse' }, +}) + +// Horizontal scroll panel (pinned, 3 screen-widths of horizontal travel) +gsap.to('.pin-inner', { + x: () => -(document.querySelector('.pin-inner').scrollWidth - window.innerWidth), + ease: 'none', + scrollTrigger: { trigger: '.pin-section', pin: true, scrub: 1, end: '+=300%' }, +}) + +// Parallax background (slower than content) +gsap.to('.bg', { + yPercent: -30, ease: 'none', + scrollTrigger: { trigger: '.section', start: 'top bottom', end: 'bottom top', scrub: true }, +}) + +// Section snapping +ScrollTrigger.create({ + trigger: '.section', start: 'top top', + snap: { snapTo: 1, duration: 0.4 }, +}) +``` + +| Pitfall | Fix | +|---|---| +| Lenis and ScrollTrigger drift apart | Mandatory: `lenis.on('scroll', ScrollTrigger.update)` + `lagSmoothing(0)` | +| ScrollTrigger misses dynamically added DOM | Call `ScrollTrigger.refresh()` or `ScrollTrigger.sort()` after dynamic inserts | +| Firefox anchor jumps fight smooth scroll | `lenis.scrollTo(target, { lock: true, duration: 1.2 })` | +| `content-visibility: auto` on pinned parent | Never use — breaks ScrollTrigger | +| GSAP SplitText is Club (paid) | Use **SplitType** (`npm i split-type`) instead — MIT, same API shape | + +**Use when:** any site with more than one scroll effect, pin/parallax/horizontal scroll needed. +**Avoid when:** pure CSS page with no animations, or JS-budget-critical JAMStack. + +--- + +## CSS scroll-driven animations + +Native `animation-timeline` API — scroll-linked animations with zero JS. Chrome 115+ / Edge 115+ / Safari 26 stable. Firefox 132+ behind flag. ~75–80% global coverage; use `@supports` + dynamic GSAP import as fallback. + +```css +/* Fade-up as element enters viewport */ +.card { + opacity: 0; + transform: translateY(40px); + animation: card-in linear both; + animation-timeline: view(); /* always declare AFTER the animation shorthand */ + animation-range: entry 10% cover 40%; +} +@keyframes card-in { + to { opacity: 1; transform: translateY(0); } +} + +/* Page-wide scroll progress bar */ +#progress { + transform-origin: 0 50%; + transform: scaleX(0); + animation: grow auto linear; + animation-timeline: scroll(); +} +@keyframes grow { to { transform: scaleX(1); } } + +/* Named scroll container (not the whole document) */ +.gallery { + overflow-x: scroll; + scroll-timeline: --gallery inline; +} +.gallery__progress { + animation: grow auto linear; + animation-timeline: --gallery; +} +``` + +**`@supports` + dynamic GSAP fallback:** +```css +@supports not (animation-timeline: scroll()) { + .card { opacity: 1; transform: none; } +} +``` +```js +if (!CSS.supports('animation-timeline', 'scroll()')) { + import('gsap').then(({ default: gsap }) => { + import('gsap/ScrollTrigger').then(({ default: ScrollTrigger }) => { + gsap.registerPlugin(ScrollTrigger) + gsap.from('.card', { y: 40, opacity: 0, scrollTrigger: { trigger: '.card', start: 'top 80%' } }) + }) + }) +} +``` + +| Pitfall | Fix | +|---|---| +| `animation-timeline` in shorthand gets reset | Declare `animation-timeline` separately, always after the `animation` shorthand | +| Firefox off by default | `@supports` + JS fallback; flag: `about:config → layout.css.scroll-driven-animations.enabled` | +| Layout properties (`width`, `top`) are slow | Animate only composited properties: `transform`, `opacity`, `filter` | +| `view()` fails inside a scroll container | Read MDN `view-timeline-scope` | + +**Use when:** simple pages, no GSAP budget, maximum GPU-composited performance. +**Avoid when:** you need pin sections, snapping, complex timelines — that's GSAP territory. + +--- + +## Text reveal / stagger + +Text appears line-by-line, word-by-word, or character-by-character from behind a mask. Controls reading pace and creates cinematic rhythm. + +**Option A — SplitType (free, MIT):** +```js +import SplitType from 'split-type' + +const text = new SplitType('.hero h1', { types: 'lines, words, chars' }) + +gsap.from(text.chars, { + opacity: 0, + y: 20, + stagger: 0.025, + duration: 0.6, + ease: 'power2.out', + scrollTrigger: { trigger: '.hero', start: 'top 70%' }, +}) + +// Recompute on resize (only needed for absolute-position mode) +window.addEventListener('resize', () => text.split()) +``` +Add `font-kerning: none` on the target element — prevents 1–2 px character jumps after split. + +**Option B — GSAP SplitText (Club GSAP, paid):** +```js +import SplitText from 'gsap/SplitText' +gsap.registerPlugin(SplitText) + +const split = SplitText.create('.hero h1', { + type: 'chars, lines', + mask: 'lines', // reveal mask — lines don't reflow out of container + autoSplit: true, // reacts to resize automatically + onSplit(self) { + return gsap.from(self.chars, { yPercent: 110, stagger: 0.04, duration: 1.2, ease: 'expo.out' }) + }, +}) +``` + +**Option C — pure CSS (zero dependencies):** +```css +.reveal-text > span { + display: inline-block; + transform: translateY(100%); + opacity: 0; + transition: transform 1s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.6s; +} +.reveal-text.is-visible > span { transform: translateY(0); opacity: 1; } +.reveal-text > span:nth-child(1) { transition-delay: 0.00s; } +.reveal-text > span:nth-child(2) { transition-delay: 0.04s; } +/* continue for each child */ +``` +```js +const io = new IntersectionObserver( + (entries) => entries.forEach(e => e.isIntersecting && e.target.classList.add('is-visible')), + { threshold: 0.2 } +) +document.querySelectorAll('.reveal-text').forEach(el => io.observe(el)) +``` + +| Pitfall | Fix | +|---|---| +| `` inside split text loses semantics | `aria: 'none'` (SplitText) + hidden duplicate text for screen readers | +| Lines reflow on resize | `autoSplit: true` (SplitText) or `ResizeObserver` → `text.split()` | +| Layout shift after split | Reserve height with `min-height`; or use mask variant so no element leaves flow | +| `prefers-reduced-motion` | Skip transition entirely — show final state immediately | + +**Use when:** hero headings, key subheads, manifesto lines, pull quotes. +**Avoid when:** body-text articles, documentation, every other paragraph — it desensitizes. + +--- + +## Three.js displacement shader transition + +Two AI-generated keyframe images (frame A → frame B, produced per assets.md: B is an *edit* of A) morphed by a displacement map, scrubbed by scroll. The skill's signature move: a living, filmic transition between two stills — no video needed. One full-viewport quad, one ShaderMaterial; simpler and richer-looking than scene compositing. Copy-pasteable — all pieces are here. + +```bash +npm i three +``` + +```js +import * as THREE from 'three' +import gsap from 'gsap' +import ScrollTrigger from 'gsap/ScrollTrigger' + +gsap.registerPlugin(ScrollTrigger) + +const canvas = document.querySelector('#morph') +const renderer = new THREE.WebGLRenderer({ canvas, antialias: false }) +renderer.setPixelRatio(Math.min(2, devicePixelRatio)) // cap DPR — saves FPS on Retina +renderer.outputColorSpace = THREE.SRGBColorSpace + +const scene = new THREE.Scene() +// Orthographic camera + 2×2 plane = a screen-space quad, no perspective math +const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + +const loader = new THREE.TextureLoader() +const [texA, texB, disp] = await Promise.all([ + loader.loadAsync('/assets/s3-peak-a.webp'), + loader.loadAsync('/assets/s3-peak-b.webp'), + // Displacement map: any soft grayscale — clouds/noise, or frame A itself + // blurred to ~20px grayscale (free, and the morph follows the image's own forms) + loader.loadAsync('/assets/s3-disp.webp'), +]) +;[texA, texB].forEach(t => { t.colorSpace = THREE.SRGBColorSpace }) + +const material = new THREE.ShaderMaterial({ + uniforms: { + texA: { value: texA }, + texB: { value: texB }, + disp: { value: disp }, + progress: { value: 0 }, + intensity: { value: 0.3 }, // 0.15 subtle · 0.3 default · 0.6 dramatic + // cover-fit correction: plane aspect vs image aspect + scale: { value: coverScale(texA, canvas) }, + }, + vertexShader: /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */ ` + uniform sampler2D texA, texB, disp; + uniform float progress, intensity; + uniform vec2 scale; + varying vec2 vUv; + + void main() { + vec2 uv = (vUv - 0.5) * scale + 0.5; // object-fit: cover + float d = texture2D(disp, uv).r; + // classic displacement morph: A slides out along the map, B slides in + vec2 uvA = uv + vec2( progress * d * intensity, 0.0); + vec2 uvB = uv - vec2((1.0 - progress) * d * intensity, 0.0); + vec4 a = texture2D(texA, uvA); + vec4 b = texture2D(texB, uvB); + gl_FragColor = mix(a, b, smoothstep(0.15, 0.85, progress)); + } + `, +}) +scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)) + +// cover-fit helper: returns UV scale so the image covers the canvas without stretching +function coverScale(tex, el) { + const ia = tex.image.width / tex.image.height + const ca = el.clientWidth / el.clientHeight + return ca > ia ? new THREE.Vector2(1, ia / ca) : new THREE.Vector2(ca / ia, 1) +} + +// --- Scroll → progress (render only on change; no idle rAF loop) --- +ScrollTrigger.create({ + trigger: '#scene-peak', + start: 'top top', + end: '+=250%', + pin: true, + scrub: 0.5, + onUpdate: (self) => { + material.uniforms.progress.value = self.progress + renderer.render(scene, camera) + }, +}) +addEventListener('resize', () => { + renderer.setSize(canvas.clientWidth, canvas.clientHeight, false) + material.uniforms.scale.value = coverScale(texA, canvas) + renderer.render(scene, camera) +}) +renderer.setSize(canvas.clientWidth, canvas.clientHeight, false) +renderer.render(scene, camera) +``` + +Displacement direction variants: use `vec2(0.0, d * intensity)` for vertical flow; `(uv - 0.5) * d * intensity` for radial bloom; rotate the vector by scene's camera motion for directed morphs. Small A/B drift from the CLI edit hides inside the mid-morph distortion — that's why this rung of the ladder tolerates imperfect keyframe pairs. + +**For full 3D scene→scene transitions** (two live THREE scenes, not stills): render both via `EffectComposer` with two `RenderPass`es and blend in a final `ShaderPass` with the same progress uniform — same scrub wiring, heavier GPU bill; reach for it only when both scenes genuinely need live geometry. + +--- + +## 3D beyond the morph + +Three compact recipes for when a scene needs live 3D. All share the morph section's hygiene: DPR cap at 2, render-on-demand where possible, dispose on teardown, `` fallback for no-WebGL/low-end (`navigator.hardwareConcurrency < 4`), reduced-motion → static render. + +### GLB product model scrubbed by scroll +The product rotates/travels as the user scrolls — the "turn it in your hands" scene. Needs a .glb (from the client, a 3D artist, or an AI mesh generator — check quality eyes-on; auto-generated topology is often mush up close). +```js +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js' +import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js' + +const draco = new DRACOLoader() +draco.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.7/') +const loader = new GLTFLoader().setDRACOLoader(draco) + +const { scene: model } = await loader.loadAsync('/assets/product.glb') +scene.add(model) +// Environment lighting sells realism more than any material tweak: +scene.environment = await new THREE.PMREMGenerator(renderer) + .fromEquirectangular(await new THREE.TextureLoader().loadAsync('/assets/studio.hdr.jpg')).texture + +ScrollTrigger.create({ + trigger: '#scene-product', start: 'top top', end: '+=300%', pin: true, scrub: 0.5, + onUpdate: (self) => { + model.rotation.y = self.progress * Math.PI * 1.5 // 270° over the scene + camera.position.z = 4 - self.progress * 1.2 // slow push-in + renderer.render(scene, camera) // render only on scroll change + }, +}) +``` +| Pitfall | Fix | +|---|---| +| GLB is 20 MB | Draco/meshopt compression (`gltf-transform optimize in.glb out.glb`), target ≤3 MB | +| Model pops in late | preload + show poster until `loadAsync` resolves; reserve canvas size (CLS) | +| Materials look flat | environment map (above) beats adding lights; `ACESFilmicToneMapping` | + +### Particle field (depth without a model) +2–5k points drifting with scroll-linked parallax — atmosphere for a hero when there's no asset at all. +```js +const N = 3000 +const pos = new Float32Array(N * 3) +for (let i = 0; i < N * 3; i++) pos[i] = (Math.random() - 0.5) * 10 +const geo = new THREE.BufferGeometry() +geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)) +const pts = new THREE.Points(geo, new THREE.PointsMaterial({ + size: 0.02, color: 0xd4a24e, transparent: true, opacity: 0.7, depthWrite: false, +})) +scene.add(pts) +ScrollTrigger.create({ + trigger: '#hero', start: 'top top', end: 'bottom top', scrub: 0.5, + onUpdate: (s) => { pts.rotation.y = s.progress * 0.6; pts.position.y = s.progress * -1.5; renderer.render(scene, camera) }, +}) +``` +Cap N by device: `navigator.hardwareConcurrency < 6 ? 1200 : 3000`. Points must never carry meaning — they're weather, not content. + +### Animated shader background (one quad, no geometry) +Same screen-space quad rig as the displacement morph, but the fragment shader generates the visual: flowing noise, grain-drenched gradient in the brand hue, contour lines. Swap the morph's fragmentShader for a noise-driven one and feed `uTime` from GSAP's ticker only while the section is on screen (IntersectionObserver gate). This is the cheapest "expensive-looking" background that isn't a stock video — and it recolors with the token palette for free. Keep chroma/hue inside the commit-sheet palette; a shader background in an off-brand hue is just animated slop. + +**Other shader variants (swap the `fragmentShader` body):** +- Cross-fade: `gl_FragColor = mix(c1, c2, progress);` +- Radial reveal: `float r = length(vUv - 0.5); gl_FragColor = mix(c1, c2, step(r, progress));` +- Pixelation: round `vUv` to a grid scaled by `progress` before sampling +- Glitch: offset UV with noise on `progress > 0.3` + +| Pitfall | Fix | +|---|---| +| Low-end mobile FPS drops | Check `navigator.hardwareConcurrency < 4` or `navigator.deviceMemory < 4` → fall back to `` | +| Backgrounded tab keeps rendering | `cancelAnimationFrame` on `visibilitychange: hidden` | +| Texture color shift | `renderer.outputColorSpace = THREE.SRGBColorSpace` + `texture.colorSpace = THREE.SRGBColorSpace` per texture | +| GPU memory leak on teardown | `scene.traverse(o => { o.geometry?.dispose(); o.material?.dispose() })` | +| Long `if` blocks in shader | Replace all branching with `step()` / `smoothstep()` — branch divergence kills GPU perf | +| iOS Safari WebGL 2 | Available on A12+ (2018+); add `WebGL1Renderer` fallback for older | + +**Use when:** two AI keyframes, brand identity moment, abstract state transition. +**Avoid when:** product catalog, text-first page, information-priority UX. + +--- + +## View Transitions API + +Native animated transitions between pages or DOM states. 88.64% global support. Chrome 111+, Safari 18+, Firefox 144+. No libraries needed. + +**Same-document (SPA) transition:** +```js +async function navigate(newHtml) { + if (!document.startViewTransition) { + document.documentElement.innerHTML = newHtml // silent fallback + return + } + document.startViewTransition(() => { + document.documentElement.innerHTML = newHtml + }) +} + +document.querySelectorAll('a[data-spa]').forEach(a => { + a.addEventListener('click', async e => { + e.preventDefault() + navigate(await (await fetch(a.href)).text()) + }) +}) +``` + +**CSS transition styling:** +```css +/* Default: whole-document crossfade */ +::view-transition-old(root), +::view-transition-new(root) { animation-duration: 0.3s; } + +/* Named element morphs between pages (shared-element transition) */ +.product-card { view-transition-name: product-card-main; } +.product-detail__img { view-transition-name: product-card-main; } + +::view-transition-old(product-card-main), +::view-transition-new(product-card-main) { + animation-duration: 0.5s; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} +``` + +**Cross-document MPA (Chromium-only as of 2026 stable):** +```html + + +``` +```css +/* Slide-up enter/exit */ +::view-transition-old(root) { animation: 0.4s ease-in both slide-up-out; } +::view-transition-new(root) { animation: 0.4s ease-in both slide-up-in; } +@keyframes slide-up-out { to { transform: translateY(-100%); } } +@keyframes slide-up-in { from { transform: translateY(100%); } } +``` + +| Pitfall | Fix | +|---|---| +| Safari < 18, Firefox < 144 | Wrap in `if (document.startViewTransition)` — falls back to instant swap | +| Duplicate `view-transition-name` on same page | Each name must be unique per frame; use data-attribute selectors | +| Glitchy positioned/transformed elements | View Transitions capture layout boxes; rebuild manually for complex absolute layouts | +| `prefers-reduced-motion` | `@media (prefers-reduced-motion: reduce) { ::view-transition-* { animation-duration: 0.01ms !important; } }` | + +**Use when:** e-commerce card → detail, portfolio thumbnail → case study, SPA navigation. +**Avoid when:** you want GLSL noise/glitch — use a persistent WebGL scene instead. + +--- + +## Ambient audio + mute toggle + +Background ambient sound (hum, rain, synth) launched on first user gesture, with a persistent mute toggle. Default off for first-time visitors — 90% of users dislike auto-sound. + +```html + + +``` + +```js +const audio = document.getElementById('ambient') +const btn = document.getElementById('mute') +const onSpan = btn.querySelector('[data-on]') +const offSpan = btn.querySelector('[data-off]') + +const saved = localStorage.getItem('ambient-muted') +let muted = saved === null ? true : saved === 'true' // default OFF on first visit + +const apply = () => { + audio.muted = muted + audio.volume = muted ? 0 : 0.4 + onSpan.hidden = muted + offSpan.hidden = !muted + localStorage.setItem('ambient-muted', String(muted)) +} +apply() + +btn.addEventListener('click', async () => { + muted = !muted + apply() + if (!muted && audio.paused) { + await audio.play().catch(e => console.warn('autoplay blocked', e)) + } +}) + +// Soft-start on first pointer interaction (if not muted by user preference) +const start = async () => { + if (muted) return + try { await audio.play() } catch {} +} +window.addEventListener('pointermove', start, { once: true }) +``` + +**WebAudio for filter/reverb control:** +```js +// Must be created inside a user gesture handler (AudioContext unlock rule) +const ctx = new (window.AudioContext || window.webkitAudioContext)() +const source = ctx.createMediaElementSource(audio) +const gain = ctx.createGain() +gain.gain.value = muted ? 0 : 0.4 +source.connect(gain).connect(ctx.destination) +// chain ctx.createBiquadFilter / ctx.createConvolver for EQ and reverb +``` + +```bash +# Encode ambient loop: 64–96 kbps Opus, 30–60 s loop, < 200 KB +ffmpeg -i source.wav -c:a libopus -b:a 64k ambient.opus +``` + +| Pitfall | Fix | +|---|---| +| Browser blocks autoplay | Only call `audio.play()` inside a click/touch handler | +| Tab hidden — audio continues | `audio.volume = 0` on `visibilitychange: hidden` | +| `prefers-reduced-motion` | Default muted; do not auto-start | +| iOS Safari `

+
+
+
+
+
+``` +```js +document.querySelectorAll('[data-parallax-container]').forEach(container => { + container.querySelectorAll('.layer[data-speed]').forEach(layer => { + const speed = parseFloat(layer.dataset.speed) + gsap.to(layer, { + yPercent: -30 * speed, + ease: 'none', + scrollTrigger: { + trigger: container, + start: 'top bottom', + end: 'bottom top', + scrub: true, + }, + }) + }) +}) +``` + +### Cursor parallax (pointer-driven depth — "flat photo feels 3D under the mouse") + +The Depth parallax above shifts layers by *scroll*; this shifts them by the *cursor*, so a still composite gains dimension as the mouse moves. Perfect for a white-on-white hero where a subject generated on the page's own background (assets.md §0 "match the background") should feel dimensional, not pasted. Reuse the same 2–4 layers (subject cut from background — assets.md ladder rung 4). + +```html +
+ + + +
+``` +```js +document.querySelectorAll('[data-pointer-parallax]').forEach(scene => { + const layers = [...scene.querySelectorAll('.layer[data-depth]')] + let tx = 0, ty = 0, raf = 0 + const apply = () => { + raf = 0 + for (const l of layers) { + const d = parseFloat(l.dataset.depth) * 100 // px of travel at screen edge + l.style.transform = `translate3d(${(-tx*d).toFixed(1)}px, ${(-ty*d).toFixed(1)}px, 0)` + } + } + const onMove = e => { + const r = scene.getBoundingClientRect() + tx = ((e.clientX - r.left) / r.width - 0.5) * 2 // -1..1 from center + ty = ((e.clientY - r.top) / r.height - 0.5) * 2 + if (!raf) raf = requestAnimationFrame(apply) // never write transform per event + } + // desktop pointer only; touch has no hover, and respect reduced-motion + if (matchMedia('(hover: hover) and (pointer: fine)').matches && + !matchMedia('(prefers-reduced-motion: reduce)').matches) { + scene.addEventListener('pointermove', onMove, { passive: true }) + scene.addEventListener('pointerleave', () => { tx = ty = 0; apply() }) + } +}) +``` +```css +.tilt-scene .layer { transition: transform 120ms ease-out; will-change: transform; } +``` + +Rules: rAF-throttle (one transform write per frame, never per event); clamp so the *farthest* layer travels ≤ ~12–16px — big travel reads as a cheap gimmick, subtle depth reads as craft; animate only `transform`; disabled on touch (`hover: none`) and under `prefers-reduced-motion`; the `120ms ease-out` gives a soft settle instead of rubber-banding. **Fancier (peak only):** one image + a generated depth map in a WebGL shader (parallax-occlusion) for a true "3D photo" — reach for it only when this hero is THE wow peak, and generate the depth pass as its own asset. + +--- + +## State-machine cinema — A→B→C morph + audio-reactive (Tier-1 engine) + +The signature 2026 move: ONE world that transforms through a chain of scene-consistent frames +(assets.md §2 — grok edit-chain A→B→C…N), scrubbed by scroll AND driven by an audio track, both feeding +the SAME `uMix`/`uEnergy` uniforms so picture and sound move as one. Extends the two-frame displacement +to N frames. One persistent WebGL context; swap textures, never remount. + +### 1. Frame-chain scrubber (scroll → uProgress → GPU morph) + +Preload N consistent frames. Scroll maps 0→1 across the chain; the shader mixes the two frames bracketing +the current position with a noise-driven wipe, so it reads as a morph, not a crossfade. + +```html +
+ +
+``` +```js +import * as THREE from 'three' +import Lenis from 'lenis' +const FRAMES = ['/gen/s1-a.webp','/gen/s1-b.webp','/gen/s1-c.webp','/gen/s1-d.webp'] // grok edit-chain, in order +const canvas = document.getElementById('c') +const renderer = new THREE.WebGLRenderer({ canvas, antialias:true }) +renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) +const scene = new THREE.Scene(), cam = new THREE.OrthographicCamera(-1,1,1,-1,0,1) +const load = new THREE.TextureLoader() +const tex = FRAMES.map(f => load.load(f)) +const noise = load.load('/gen/noise.webp') // generated grayscale (assets.md §7) +const u = { + uFrom:{value:tex[0]}, uTo:{value:tex[1]}, uMix:{value:0}, // 0..1 between the two bracketing frames + uEnergy:{value:0}, uNoise:{value:noise}, // audio pushes displacement +} +const mat = new THREE.ShaderMaterial({ uniforms:u, + vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position,1.); }`, + fragmentShader:`precision highp float; varying vec2 vUv; + uniform sampler2D uFrom,uTo,uNoise; uniform float uMix,uEnergy; + void main(){ + float n = texture2D(uNoise, vUv).r; + float amt = smoothstep(n-0.15, n+0.15, uMix); // noise wipe between frames + vec2 disp = (uMix*(1.-uMix)+uEnergy) * 0.06 * vec2(n-0.5); // bulge peaks mid-morph + on the beat + vec3 a = texture2D(uFrom, vUv+disp).rgb; + vec3 b = texture2D(uTo, vUv-disp).rgb; + gl_FragColor = vec4(mix(a,b,amt), 1.); + }`}) +scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2,2), mat)) +const resize=()=>renderer.setSize(innerWidth,innerHeight); addEventListener('resize',resize); resize() + +let target=0, cur=0 +const seg = 1/(FRAMES.length-1) // scroll span per frame pair +const stage = document.getElementById('stage') +const setTarget=()=>{ const r=stage.getBoundingClientRect(); + target = Math.min(1, Math.max(0, -r.top/(r.height-innerHeight))) } +const lenis = new Lenis(); lenis.on('scroll', setTarget) +function raf(t){ + lenis.raf(t) + cur += (target-cur)*0.08 // scrub smoothing (0.3-0.8 feel) + const p = cur/seg, i = Math.min(FRAMES.length-2, Math.floor(p)) + u.uFrom.value = tex[i]; u.uTo.value = tex[i+1]; u.uMix.value = p - i + u.uEnergy.value += (audioEnergy()-u.uEnergy.value)*0.2 // smoothed beat (see §2; 0 until audio starts) + renderer.render(scene,cam); requestAnimationFrame(raf) +} +requestAnimationFrame(raf) +``` + +### 2. Audio-reactive — the same uniforms, on the beat + +MiniMax score (assets.md §8) → Web Audio `AnalyserNode` → low-band energy → `uEnergy`, so the morph +pulses with the music. Start on the mute-toggle gesture (see Ambient audio); default OFF, and NEVER gate +the visual on audio — with sound off the scroll scrub is the whole show. + +```js +let analyser, freq +function startAudio(){ + if (analyser) return + const ctx = new AudioContext() + const src = ctx.createMediaElementSource(document.getElementById('ambient')) + analyser = ctx.createAnalyser(); analyser.fftSize = 256 + src.connect(analyser); analyser.connect(ctx.destination) + freq = new Uint8Array(analyser.frequencyBinCount); ctx.resume() +} +document.getElementById('mute').addEventListener('click', startAudio) +function audioEnergy(){ // called from raf() + if (!analyser) return 0 + analyser.getByteFrequencyData(freq) + let bass=0; for (let k=0;k<8;k++) bass+=freq[k] + return bass/8/255 // 0..1 low-band energy +} +``` + +### 3. Depth-map 2.5D composite (flat still → dimensional) + +A hero still + its depth map (assets.md §2.5) → shader offsets UV per-pixel by depth × (pointer + a little +scroll) → parallax-occlusion "3D photo" + optional rack focus. Controlled, cheap, no 3D model. + +```glsl +// uColor (hero), uDepth (grayscale 0 far…1 near), uPointer (vec2 -1..1), uFocus (0..1) +float d = texture2D(uDepth, vUv).r; +vec2 off = uPointer * (d-0.5) * 0.04; // near pixels shift more → parallax-occlusion +vec3 col = texture2D(uColor, vUv + off).rgb; +float coc = abs(d - uFocus); // rack focus: distance from the focal plane +// feed coc to a cheap multi-tap blur (or a separate DOF pass) for depth-of-field on scroll +gl_FragColor = vec4(col, 1.); +``` +Pointer → `uPointer` (rAF-throttled, travel ≤ ~0.04 — Cursor-parallax rules); scroll can drift `uFocus` +for a rack-focus reveal as the section enters. + +### Rules (or it's slop) +- ONE persistent WebGL context; swap textures, never remount per scene (memory climb + stutter = the #1 reported pitfall). +- Write uniforms / `element.style` in rAF ONLY — never React `setState` per scroll/audio frame (that's a 120Hz slideshow). +- Clamp: displacement & parallax travel small; `uEnergy` capped so the beat *breathes* the frame, not seizures it. +- `prefers-reduced-motion` → freeze on the nearest frame + a static end-state, no audio drive; content readable with JS off. +- Audio OFF by default, user-gesture to start; the scroll story must stand alone in silence. +- LOD: fewer / lower-res frames + drop the depth pass under `(max-width:768px)` or low-power — verify.md checks the tiers. +- Preload the frame chain and decode to `ImageBitmap` before first paint — mid-scroll texture decode is visible jank. +- **One function owns each shared quantity, and everything else calls it.** Scroll progress, camera path, the height of a terrain, the position of the peak: the moment two subsystems compute the same value separately they drift, and the drift looks like a rendering bug rather than a duplicated formula. A terrain whose height function lives inside the mesh component gets fog that lies flat and slices through the hills, and a cursor that dents the snow somewhere other than where it points — three readers, one truth, or three different worlds. +- **Every number that shapes the scene lives in one config module, with a comment saying why it is that number and what breaks otherwise.** Constants scattered across components make tuning a search problem, and a bare `0.36` teaches the next session nothing — `bevel: 0.36 // never 0: the highlight needs a chamfer to run along, a sharp edge reads as plastic` survives being "cleaned up". + +--- + +## Tier-2 scene recipes + +### 3D scroll-camera dolly — Blender path → glTF → Three.js + +Author the move once in Blender (assets.md §9), then scrub the exported camera action. `quickTo` retargets six reusable values; no tween is created per frame. + +```html +
+ + + Bronze forms in a dark gallery + + +
+``` + +```css +#dolly-stage { position: relative; display: grid; min-height: 400vh; } +#dolly, #dolly-poster { + grid-area: 1 / 1; + position: sticky; + top: 0; + display: block; + width: 100%; + height: 100vh; +} +#dolly { opacity: 0; } +#dolly-poster { opacity: 1; } +#dolly-poster img { width: 100%; height: 100%; object-fit: cover; } +#dolly, #dolly-poster { transition: opacity 200ms cubic-bezier(0.23, 1, 0.32, 1); } +``` + +```js +import * as THREE from 'three' +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js' +import gsap from 'gsap' +import ScrollTrigger from 'gsap/ScrollTrigger' + +gsap.registerPlugin(ScrollTrigger) + +async function initDolly() { + const canvas = document.getElementById('dolly') + const poster = document.getElementById('dolly-poster') + const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches + + let renderer + try { + renderer = new THREE.WebGLRenderer({ canvas, antialias: true }) + } catch { + return // poster remains the complete no-WebGL scene + } + renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) + renderer.outputColorSpace = THREE.SRGBColorSpace + + const scene = new THREE.Scene() + const gltf = await new GLTFLoader().loadAsync('/assets/gen/dolly.glb') + scene.add(gltf.scene) + + const pathCamera = gltf.cameras.find(c => c.name === 'PathCamera') + const clip = gltf.animations.find(c => + c.name === 'CameraPath' || c.tracks.some(track => track.name.startsWith('PathCamera.')) + ) + if (!pathCamera || !clip) throw new Error('dolly.glb needs PathCamera + CameraPath') + + const mixer = new THREE.AnimationMixer(gltf.scene) + mixer.clipAction(clip).play() + const camera = pathCamera.clone(false) + const sampledPosition = new THREE.Vector3() + const sampledDirection = new THREE.Vector3() + const sampledLook = new THREE.Vector3() + const look = new THREE.Vector3() + + const moveX = gsap.quickTo(camera.position, 'x', { duration: 0.28, ease: 'power3.out' }) + const moveY = gsap.quickTo(camera.position, 'y', { duration: 0.28, ease: 'power3.out' }) + const moveZ = gsap.quickTo(camera.position, 'z', { duration: 0.28, ease: 'power3.out' }) + const lookX = gsap.quickTo(look, 'x', { duration: 0.28, ease: 'power3.out' }) + const lookY = gsap.quickTo(look, 'y', { duration: 0.28, ease: 'power3.out' }) + const lookZ = gsap.quickTo(look, 'z', { duration: 0.28, ease: 'power3.out' }) + + const resize = () => { + const width = canvas.clientWidth + const height = canvas.clientHeight + renderer.setSize(width, height, false) + camera.aspect = width / height + camera.updateProjectionMatrix() + } + addEventListener('resize', () => requestAnimationFrame(resize), { passive: true }) + resize() + + const samplePath = (progress, immediate = false) => { + mixer.setTime(progress * clip.duration) + pathCamera.updateWorldMatrix(true, false) + pathCamera.getWorldPosition(sampledPosition) + pathCamera.getWorldDirection(sampledDirection) + sampledLook.copy(sampledPosition).add(sampledDirection) + if (immediate) { + camera.position.copy(sampledPosition) + look.copy(sampledLook) + return + } + moveX(sampledPosition.x); moveY(sampledPosition.y); moveZ(sampledPosition.z) + lookX(sampledLook.x); lookY(sampledLook.y); lookZ(sampledLook.z) + } + + const revealCanvas = () => { + canvas.style.opacity = '1' + poster.style.opacity = '0' + } + + if (reduceMotion) { + samplePath(0.55, true) // static authored composition, not a blank canvas + camera.lookAt(look) + renderer.render(scene, camera) + requestAnimationFrame(revealCanvas) + return + } + + samplePath(0, true) + camera.lookAt(look) + renderer.render(scene, camera) + let progress = 0 + let active = true + const trigger = ScrollTrigger.create({ + trigger: '#dolly-stage', + start: 'top top', + end: 'bottom bottom', + scrub: 0.5, + onToggle: self => { active = self.isActive }, + onUpdate: self => { progress = self.progress }, // state only; WebGL writes stay in rAF + }) + const tick = () => { + if (!active) return + samplePath(progress) + camera.lookAt(look) + renderer.render(scene, camera) + } + gsap.ticker.add(tick) + tick() + requestAnimationFrame(revealCanvas) + + return () => { + trigger.kill() + gsap.ticker.remove(tick) + mixer.stopAllAction() + gltf.scene.traverse(o => { o.geometry?.dispose(); o.material?.dispose() }) + renderer.dispose() + } +} + +initDolly() +``` + +If the Tier-1 engine already owns `renderer`, pass that renderer/scene into `initDolly`; never construct a second context. glTF carries Blender's coordinate conversion, lens, camera transform, and baked animation. + +**Rules (or it's slop):** one persistent renderer; `ScrollTrigger` owns progress; all pose/render/style writes happen in GSAP's rAF ticker; `quickTo` is created once; reduced motion renders one authored camera frame; keep the poster for no-WebGL and dispose on route teardown. + +### Signature post-FX pass — photochemical misregistration + +Commit to one look: restrained channel misregistration plus 2% moving grain. No bloom stack; highlights and color stay authored in the scene, while scroll and Tier-1 `uEnergy` only make the print breathe. + +```js +import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js' +import { RenderPass } from 'three/addons/postprocessing/RenderPass.js' +import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js' + +const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches +let scrollFxTarget = 0 +let scrollFx = 0 + +ScrollTrigger.create({ + trigger: '#stage', + start: 'top bottom', + end: 'bottom top', + scrub: 0.5, + onUpdate: self => { scrollFxTarget = self.progress }, // state only +}) + +let composer, signature +if (!reduceMotion) { + composer = new EffectComposer(renderer) // same renderer/context as Tier-1 + composer.addPass(new RenderPass(scene, cam)) + signature = new ShaderPass({ + uniforms: { + tDiffuse: { value: null }, + uTime: { value: 0 }, + uScroll: { value: 0 }, + uEnergy: { value: 0 }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } + `, + fragmentShader: ` + precision highp float; + uniform sampler2D tDiffuse; + uniform float uTime, uScroll, uEnergy; + varying vec2 vUv; + + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); + } + void main() { + float edge = 0.00035 + 0.00065 * uScroll + 0.00045 * uEnergy; + vec2 split = vec2(edge, 0.0); + float r = texture2D(tDiffuse, vUv + split).r; + float g = texture2D(tDiffuse, vUv).g; + float b = texture2D(tDiffuse, vUv - split).b; + float grain = (hash(gl_FragCoord.xy + floor(uTime * 24.0)) - 0.5) * 0.02; + gl_FragColor = vec4(vec3(r, g, b) + grain, 1.0); + } + `, + }) + composer.addPass(signature) // the ONE signature pass +} + +// Call from the existing Tier-1 requestAnimationFrame loop in place of renderer.render(). +function renderWithSignature(time) { + scrollFx += (scrollFxTarget - scrollFx) * 0.08 + if (reduceMotion) { + renderer.render(scene, cam) // clean static/gentle art direction + return + } + signature.uniforms.uTime.value = time * 0.001 + signature.uniforms.uScroll.value = scrollFx + signature.uniforms.uEnergy.value = Math.min(0.35, u.uEnergy.value) + composer.render() +} +``` + +Non-WebGL layers get the same imperfect-print idea without another canvas: one SVG displacement filter, drifting slowly on GSAP's rAF ticker. + +```html + +
Product still
+``` + +```css +.paper-fx { filter: url("#paper-warp"); } +@media (prefers-reduced-motion: reduce) { + .paper-fx { filter: none; } +} +``` + +```js +const turbulence = document.getElementById('paper-noise') +if (turbulence && !reduceMotion) { + gsap.ticker.add(time => { + const x = 0.009 + Math.sin(time * 0.35) * 0.0007 + const y = 0.013 + Math.cos(time * 0.27) * 0.0007 + turbulence.setAttribute('baseFrequency', `${x.toFixed(4)} ${y.toFixed(4)}`) + }) +} +``` + +**Rules (or it's slop):** one custom pass, no effect buffet; aberration ≤0.0015 UV and grain ≈2%; update uniforms/SVG attributes in rAF only; reuse Tier-1 `uEnergy`; reduced motion removes both GPU and SVG passes. + +### Physics pointer-trail — alpha cut-outs with gravity + +Transparent PNG cut-outs inherit pointer velocity, then fall through a tiny Verlet step. Pointer events only record state; spawn, physics, draw, fade, and cull happen in rAF. + +```html + +``` + +```css +#pointer-trail { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 60; +} +@media (hover: none), (prefers-reduced-motion: reduce) { + #pointer-trail { display: none; } +} +``` + +```js +const canTrail = + matchMedia('(hover: hover) and (pointer: fine)').matches && + !matchMedia('(prefers-reduced-motion: reduce)').matches + +if (canTrail) { + const canvas = document.getElementById('pointer-trail') + const ctx = canvas.getContext('2d', { alpha: true }) + const paths = ['/assets/cutout-01.png', '/assets/cutout-02.png', '/assets/cutout-03.png'] + const sprites = paths.map(src => { + const image = new Image() + image.decoding = 'async' + image.src = src + return image + }) + Promise.allSettled(sprites.map(image => image.decode())) + + const MAX_SPRITES = 28 + const SPAWN_DISTANCE = 46 + const GRAVITY = 0.28 + const DRAG = 0.985 + const particles = [] + const pointer = { x: 0, y: 0, dx: 0, dy: 0, travel: 0, pending: false, seen: false } + let raf = 0 + let spriteIndex = 0 + + const resize = () => { + const dpr = Math.min(devicePixelRatio, 2) + canvas.width = Math.round(innerWidth * dpr) + canvas.height = Math.round(innerHeight * dpr) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + } + resize() + addEventListener('resize', () => requestAnimationFrame(resize), { passive: true }) + + const spawn = () => { + const image = sprites[spriteIndex++ % sprites.length] + if (!image.complete || !image.naturalWidth || pointer.y > innerHeight - 120) return + if (particles.length === MAX_SPRITES) particles.shift() + const vx = Math.max(-7, Math.min(7, pointer.dx * 0.32)) + const vy = Math.max(-5, Math.min(5, pointer.dy * 0.22)) + const size = 42 + Math.random() * 34 + particles.push({ + image, + x: pointer.x, + y: pointer.y, + px: pointer.x - vx, + py: pointer.y - vy, + size, + age: 0, + life: 72 + Math.random() * 30, + rotation: (Math.random() - 0.5) * 0.25, + spin: (Math.random() - 0.5) * 0.035, + }) + } + + const wake = () => { + if (!raf) raf = requestAnimationFrame(frame) + } + + function frame() { + raf = 0 + if (pointer.pending) { + if (pointer.travel >= SPAWN_DISTANCE) { + pointer.travel %= SPAWN_DISTANCE + spawn() // at most one spawn per animation frame + } + pointer.pending = false + } + + ctx.clearRect(0, 0, innerWidth, innerHeight) + for (let i = particles.length - 1; i >= 0; i--) { + const p = particles[i] + const vx = (p.x - p.px) * DRAG + const vy = (p.y - p.py) * DRAG + p.px = p.x; p.py = p.y + p.x += vx + p.y += vy + GRAVITY + p.rotation += p.spin + p.age++ + + const remaining = 1 - p.age / p.life + if (remaining <= 0 || p.y - p.size > innerHeight || + p.x + p.size < 0 || p.x - p.size > innerWidth) { + particles.splice(i, 1) + continue + } + + ctx.save() + ctx.translate(p.x, p.y) + ctx.rotate(p.rotation) + ctx.globalAlpha = Math.min(1, p.age / 6) * Math.min(1, remaining * 4) + ctx.drawImage(p.image, -p.size / 2, -p.size / 2, p.size, p.size) + ctx.restore() + } + if (particles.length || pointer.pending) wake() + } + + addEventListener('pointermove', event => { + if (!pointer.seen) { + pointer.x = event.clientX + pointer.y = event.clientY + pointer.seen = true + return + } + pointer.dx = event.clientX - pointer.x + pointer.dy = event.clientY - pointer.y + pointer.travel += Math.hypot(pointer.dx, pointer.dy) + pointer.x = event.clientX + pointer.y = event.clientY + pointer.pending = true + wake() // event writes state; rAF owns every visual write + }, { passive: true }) +} +``` + +**Rules (or it's slop):** cap at 28 sprites; gravity ≈0.28px/frame²; one spawn maximum per rAF; cull by lifetime and viewport; no DOM sprite churn; disable on touch and reduced motion. + +### Living type — variable axes + gooey nav + +Flex one display/nav face, not the reading layer. Scroll sets the phrase's posture; Web Audio adds a small pulse through the same rAF-owned CSS variables. + +```html +

Matter changes under pressure.

+

Body copy stays fixed at a tested reading weight.

+ + + +
+ +``` + +```css +@font-face { + font-family: "Brand Variable"; + src: url("/fonts/brand-variable.woff2") format("woff2"); + font-weight: 300 800; + font-stretch: 75% 125%; + font-display: swap; +} +.living-title { + --vf-wght: 570; + --vf-wdth: 96; + font-family: "Brand Variable", serif; + font-variation-settings: "wght" var(--vf-wght), "wdth" var(--vf-wdth); +} +.scene-copy { + max-width: 70ch; + font-family: "Source Sans 3", system-ui, sans-serif; + font-weight: 420; + line-height: 1.5; +} +.goo-nav { + display: flex; + gap: 0.2rem; + align-items: center; + filter: url("#nav-goo"); +} +.goo-nav :is(a, button) { + border: 0; + border-radius: 999px; + padding: 0.65rem 0.9rem; + color: #fff; + background: #b43d18; + font: 600 0.9rem/1 "Brand Variable", serif; + text-decoration: none; + transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1); +} +@media (hover: hover) and (pointer: fine) { + .goo-nav :is(a, button):hover { transform: translateY(-2px); } +} +@media (prefers-reduced-motion: reduce) { + .living-title { font-variation-settings: "wght" 600, "wdth" 98; } + .goo-nav :is(a, button) { transition: none; } +} +``` + +```js +const title = document.querySelector('.living-title') +const audio = document.getElementById('type-score') +const audioButton = document.getElementById('type-audio') +const reduceTypeMotion = matchMedia('(prefers-reduced-motion: reduce)').matches +let scrollTypeTarget = 0 +let scrollType = 0 +let audioContext, analyser, bins + +ScrollTrigger.create({ + trigger: title, + start: 'top bottom', + end: 'bottom top', + scrub: 0.5, + onUpdate: self => { scrollTypeTarget = self.progress }, // state only +}) + +audioButton.addEventListener('click', async () => { + if (!audioContext) { + audioContext = new AudioContext() + const source = audioContext.createMediaElementSource(audio) + analyser = audioContext.createAnalyser() + analyser.fftSize = 256 + bins = new Uint8Array(analyser.frequencyBinCount) + source.connect(analyser).connect(audioContext.destination) + } + await audioContext.resume() + audio.muted = !audio.muted + if (!audio.muted && audio.paused) await audio.play() + audioButton.setAttribute('aria-pressed', String(!audio.muted)) + audioButton.textContent = audio.muted ? 'Sound off' : 'Sound on' +}) + +function typeEnergy() { + if (!analyser || audio.muted) return 0 + analyser.getByteFrequencyData(bins) + let low = 0 + for (let i = 0; i < 8; i++) low += bins[i] + return low / 8 / 255 +} + +function animateType() { + scrollType += (scrollTypeTarget - scrollType) * 0.08 + const energy = typeEnergy() + const weight = 570 + scrollType * 52 + energy * 16 // 570..638 + const width = 96 + scrollType * 4 + energy * 1.5 // 96..101.5 + title.style.setProperty('--vf-wght', weight.toFixed(1)) + title.style.setProperty('--vf-wdth', width.toFixed(1)) + requestAnimationFrame(animateType) +} +if (!reduceTypeMotion) requestAnimationFrame(animateType) // reduced branch stays at CSS 600/98 +``` + +The SVG filter merges adjacent nav pills; `feComposite` restores crisp labels after the alpha threshold. Verify the font actually exposes `wght`/`wdth`; substitute `opsz` only when the file declares it. + +**Rules (or it's slop):** axis travel stays subtle; CSS variables update in rAF only; reduced motion freezes the axes; body copy never flexes; audio is muted by default and starts only on the button gesture; goo belongs to one compact nav, not the page. + +## Assembly order + +Build in this sequence — each step depends on the previous. + +1. **Smooth scroll foundation first.** Wire Lenis + GSAP integration (`lenis.on('scroll', ScrollTrigger.update)` + `lagSmoothing(0)`). Do not skip — all scroll effects jitter without it. +2. **Hero section.** Scrub video or Three.js displacement. This sets the site's tone and reveals performance constraints early. +3. **Sections top-to-bottom.** After each section's ScrollTrigger is initialized, call `ScrollTrigger.refresh()` so positions are recalculated with the full DOM height. +4. **Text reveals last.** SplitType/SplitText on headings ties the sections together and is cheapest to adjust. + +**Every effect ships with its fallback chain:** + +| Effect | Primary | Fallback 1 | Fallback 2 | +|---|---|---|---| +| Three.js / WebGL scene | WebGL | `` static poster | Nothing (CSS background) | +| CSS `animation-timeline` | Native API | Dynamic GSAP import via `@supports` check | Static visible state | +| Scrub video | `