feat(skills): add auteur optional skill — cinematic web design with executable anti-slop gates
Port of agiwhitelist/auteur (MIT, ~1k stars), snapshot 9bca227d. Three registers (build / direct / system) on one taste core: commit-sheet-first art direction, asset generation via image_generate + local CLIs, and node-based quality gates (slopscan anti-slop linter, motionqa frame-drop check, systemscan cross-route drift) run through playwright. - optional-skills/creative/auteur: SKILL.md (de-Clauded, Hermes tool framing), LICENSE (upstream MIT), 11 references, 8 verbatim upstream .mjs scripts (all pass node --check; slopscan smoke-run verified), 6 templates. README gallery assets not vendored (size cap). - tests/skills/test_auteur_skill.py: frontmatter, path-annotation invariant, de-Claude residue, related_skills resolution. - Docs: catalog row, sidebar entry, generated skill page (scoped regen).
This commit is contained in:
21
optional-skills/creative/auteur/LICENSE
Normal file
21
optional-skills/creative/auteur/LICENSE
Normal file
@@ -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.
|
||||
168
optional-skills/creative/auteur/SKILL.md
Normal file
168
optional-skills/creative/auteur/SKILL.md
Normal file
@@ -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 <brief>`** 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 <path-or-url>`** → 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 <src-dir>` 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 <url>` 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/<x>.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 <dir>` runs without npm deps; it prints per-rule findings and exits non-zero on failures (exit 0 when clean).
|
||||
@@ -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.
|
||||
411
optional-skills/creative/auteur/references/assets.md
Normal file
411
optional-skills/creative/auteur/references/assets.md
Normal file
@@ -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: <prompt>. Save to <ABSOLUTE-PATH>/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: <prompt>"
|
||||
```
|
||||
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 <ABS>/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 <ABS>/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 <ABS>/assets/gen/s1-hero-a.png: slow push-in, rising steam, 6s. Save the mp4 to <ABS>/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<scene>-<slug>-<a|b>.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.
|
||||
39
optional-skills/creative/auteur/references/build.md
Normal file
39
optional-skills/creative/auteur/references/build.md
Normal file
@@ -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 "<feeling>" "<treatment>"` 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 + `<dialog>` 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.
|
||||
129
optional-skills/creative/auteur/references/direct.md
Normal file
129
optional-skills/creative/auteur/references/direct.md
Normal file
@@ -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 "<feeling>" "<treatment>" --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 — <name> | 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.
|
||||
355
optional-skills/creative/auteur/references/motion.md
Normal file
355
optional-skills/creative/auteur/references/motion.md
Normal file
@@ -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:
|
||||
<motion.div animate={{ x: 100, scale: 1.2 }} />
|
||||
|
||||
// Correct for pinned / scroll-driven:
|
||||
<motion.div animate={{ transform: "translateX(100px) scale(1.2)" }} />
|
||||
// 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: `<SoundProvider enabled={soundEnabled} />`.
|
||||
- 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 |
|
||||
210
optional-skills/creative/auteur/references/recon.md
Normal file
210
optional-skills/creative/auteur/references/recon.md
Normal file
@@ -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.
|
||||
1532
optional-skills/creative/auteur/references/scroll-cinema.md
Normal file
1532
optional-skills/creative/auteur/references/scroll-cinema.md
Normal file
File diff suppressed because it is too large
Load Diff
192
optional-skills/creative/auteur/references/scroll-flight.md
Normal file
192
optional-skills/creative/auteur/references/scroll-flight.md
Normal file
@@ -0,0 +1,192 @@
|
||||
> **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.
|
||||
|
||||
# scroll-flight — photoreal scroll-scrubbed video ("fly through the world")
|
||||
|
||||
auteur's **video-scrub tier**. The hero is a pre-rendered camera flight whose
|
||||
`currentTime` is driven by scroll — the viewer *pilots* a photoreal world.
|
||||
Complementary to the real-time WebGL recipes in `scroll-cinema.md`, not a
|
||||
replacement.
|
||||
|
||||
Engine: `templates/scroll-flight-engine.js` — a zero-dependency, framework-
|
||||
agnostic, drop-in scrubber. Vendored from **scroll-world**
|
||||
(github.com/cth9191/scroll-world, MIT © cyw); it solves ~18 shipped-in-anger
|
||||
edge cases you do NOT want to re-derive. Read its header for the full config API.
|
||||
|
||||
## When to reach for this (vs WebGL scroll-cinema)
|
||||
|
||||
| Use scroll-flight (video) when… | Use WebGL scroll-cinema when… |
|
||||
|---|---|
|
||||
| the world must look **photoreal** — a real place, product, interior, landscape | the look is generative/abstract — fluid, particles, shaders, type |
|
||||
| the motion is a **camera flight** through a fixed scene | the motion is procedural and reacts to cursor/audio/data live |
|
||||
| you can generate/shoot **video clips** of it | you can express it as math in one WebGL context |
|
||||
|
||||
They compose: a WebGL hero can hand off into a scrubbed-video mid-section.
|
||||
|
||||
## ⚠️ AI clips barely move the camera — the scroll-dolly does the travelling
|
||||
|
||||
The single most important thing to know here. **`grok image_to_video` (and
|
||||
most image→video models) animate a still *ambiently* — light shimmers, water
|
||||
drifts, particles float — but they do NOT fly the camera through the scene.**
|
||||
Verified on disk: a clip's first and last frame are near-identical. So a scene
|
||||
built only from a raw AI clip reads as *a slightly-moving photo*, not a journey.
|
||||
This is the #1 reason a scroll-flight looks like a slideshow.
|
||||
|
||||
The fix lives in the engine, not the prompt: `scroll-flight-engine.js` applies a
|
||||
**scroll-driven camera dolly** — as you scroll through a scene, it pushes the
|
||||
whole scene IN (scale) and drifts it down, inside the scale overscan so edges
|
||||
never reveal. *That* is what manufactures forward/descent travel; the clip's
|
||||
`currentTime` scrub only adds the ambient life on top. Consequences:
|
||||
|
||||
- **Don't over-invest in per-clip motion.** A near-static clip + the dolly looks
|
||||
the same as an aggressively-prompted clip + the dolly. Prompt for *mood and
|
||||
ambient life* (drifting particles, light, creatures), not "fast camera dash"
|
||||
you won't get.
|
||||
- **Stills and clips are interchangeable.** A scene with only a `still` gets the
|
||||
same dolly, so it reads identically to a video scene. Mix freely — ship the
|
||||
clips you reliably get, fill the rest with stills, the dolly unifies them.
|
||||
(ABYSS showcase: 2 video scenes + 3 stills, indistinguishable.)
|
||||
- **Pick worlds whose medium drifts** — underwater, clouds, space, dust, smoke.
|
||||
Ambient drift hides the lack of camera-baked motion; a dry static landscape
|
||||
exposes it.
|
||||
- Real camera travel baked into the footage needs a true flythrough model
|
||||
(Higgsfield/Runway/Veo) or the 2.5D depth-parallax route (still → depth map →
|
||||
WebGL camera through the layers). The dolly is the pragmatic default that
|
||||
needs neither.
|
||||
|
||||
## The pipeline (auteur's toolchain)
|
||||
|
||||
1. **Scene stills** — one anchor still first, get art-direction approval, then
|
||||
batch the rest **style-locked to the anchor** (pass the approved still as the
|
||||
style reference). A style miss caught on the anchor costs 1 gen, not N.
|
||||
Sources: `codex`/`agy` (Gemini)/`grok` image gen — see `assets.md`.
|
||||
2. **Dive clips** — animate each still into a short camera push-in (grok
|
||||
`image_to_video`, or any image→video model). One clip per scene.
|
||||
3. **Seams** — two ways, pick by what your video model can do:
|
||||
- **Crossfade seams (default, grok/start-image-only models).** Leave
|
||||
`connectors` empty/`null`; the engine crossfades directly between adjacent
|
||||
dives. Ship-safe, always works, reads clean. This is auteur's baseline.
|
||||
- **Seamless flight (only with an end-image model — Higgsfield seedance /
|
||||
kling).** Generate connector clips whose **start = prev dive's last frame,
|
||||
end = next dive's first frame** (extract the actual rendered frames, never
|
||||
the stills). Verify every seam with the SSIM gate below before eyeballing.
|
||||
4. **Encode for scrubbing** (§ below) — the single most important step.
|
||||
5. **Posters** — extract each encoded clip's first frame (§ below).
|
||||
6. **Wire** the engine config; run the motion + slopscan gates.
|
||||
|
||||
> auteur has no Higgsfield account by default; grok `image_to_video` is the
|
||||
> baseline. So the honest default is **crossfade-seam** photoreal scrub — still
|
||||
> Apple-tier. Fully-seamless chaining is an upgrade you unlock only with an
|
||||
> end-image-capable video model.
|
||||
|
||||
## Encode for scrubbing — the `-g 8` recipe (critical)
|
||||
|
||||
Scrubbing sets `currentTime` every frame; a decoder's **seek cost scales with how
|
||||
many frames it must decode from the nearest keyframe**. A tiny GOP (keyframe
|
||||
every 8 frames) is what makes frame-accurate seeking cheap. Native res, crf 20,
|
||||
no audio, faststart, light sharpen:
|
||||
|
||||
```bash
|
||||
enc() { ffmpeg -v error -y -i "$1" -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 "$2"; }
|
||||
```
|
||||
|
||||
- **Never upscale** — encode what `ffprobe` reports (some models return 720p).
|
||||
- **Mobile sibling** (`-m.mp4`): 720p, `-g 4` (twice the keyframes = ~half the
|
||||
seek-decode work), crf 23. Wire as `clipMobile`/`connectorsMobile`. Still
|
||||
choppy on a low-end phone → `-g 2`, or `-g 1` (all-intra = instant seeks,
|
||||
bigger files).
|
||||
- The engine loads each clip as a **Blob** (always seekable) and scrubs — it does
|
||||
NOT rely on HTTP byte-range. Do not "optimize" that away, or you get frozen-at-
|
||||
frame-0 on hosts that don't serve ranges.
|
||||
|
||||
## Posters — from the ENCODED clip's first frame
|
||||
|
||||
The still is 3:2, the clip is a 16:9 re-render — if the still is the loading
|
||||
poster, the video paints with a visible crop/render pop on the first scene a
|
||||
visitor sees. Hand off the actual frame:
|
||||
|
||||
```bash
|
||||
ffmpeg -v error -y -ss 0 -i "$ASSETS/vid/$n.mp4" -frames:v 1 -q:v 2 poster.png
|
||||
cwebp -quiet -q 84 poster.png -o "$ASSETS/$n-poster.webp" # → sections[k].poster
|
||||
```
|
||||
|
||||
Keep the source still too: it's the reduced-motion artwork and the no-clip
|
||||
fallback.
|
||||
|
||||
## Seam QA — SSIM gate (before any eyeballing)
|
||||
|
||||
Seamlessness is the product; don't ship it on a squint. A true actual-frame
|
||||
handoff scores SSIM ≥0.95 even after encoding.
|
||||
|
||||
```bash
|
||||
seam_ssim() { # clipA clipB — last frame of A vs first of B
|
||||
ffmpeg -v error -y -sseof -0.05 -i "$1" -frames:v 1 _a.png
|
||||
ffmpeg -v error -y -ss 0 -i "$2" -frames:v 1 _b.png
|
||||
ffmpeg -v info -i _a.png -i _b.png -lavfi ssim -f null - 2>&1 | grep -o 'All:[0-9.]*' | cut -d: -f2
|
||||
}
|
||||
# ≥0.90 pass · 0.75–0.90 warn (crossfade usually hides it) · <0.75 FAIL:
|
||||
# an endpoint was a still, not the neighbour's frame — regenerate, don't rationalize.
|
||||
```
|
||||
|
||||
Re-run after every re-roll: replacing one clip can silently break BOTH its seams.
|
||||
|
||||
## Chain architecture — A vs B
|
||||
|
||||
- **A — one continuous forward take.** Legs chained from actual last frames, no
|
||||
pull-back, no end-image. Use for any grounded walkthrough. No rewind risk.
|
||||
- **B — dive + connector interleave.** More cinematic, but if a connector's
|
||||
camera **velocity reverses** (dive pushes in, connector pulls back out) it
|
||||
reads as a rewind even with a perfect frame-match seam. Inherent to B — keep
|
||||
connector motion continuing forward, or use A.
|
||||
|
||||
## Mobile & iOS — the hard gotchas (the engine handles these; don't undo them)
|
||||
|
||||
- **Frozen / stuck at frame 0** → host isn't serving byte ranges → blob URLs (engine does).
|
||||
- **Blank/black scene on iOS** → a muted video never played won't paint a seeked
|
||||
frame. Engine keeps the poster up until a real frame paints and **primes** each
|
||||
clip (muted play→pause) on first touch. Don't hide the poster on
|
||||
`loadedmetadata`; don't strip `playsinline`/`muted`.
|
||||
- **Frozen on iOS Low Power Mode** → LPM rejects even muted `play()` and
|
||||
`currentTime` scrubbing dies — no video technique survives it. Engine detects
|
||||
the rejected prime and flips the whole page to **stills-with-crossfades**. Keep
|
||||
that `.catch()` fallback if you adapt it.
|
||||
- **Phone stutters on a fast flick** → seeks pile up. Engine **coalesces seeks**
|
||||
(never issues a new `currentTime` while `seeking`); ship the `-m.mp4` tier as
|
||||
the other half.
|
||||
- **iPad gets blurry 720p** → tier by **screen short side** (≤600 CSS px = phone),
|
||||
never by pointer type or UA (iPadOS lies on both).
|
||||
- **Page jumps while scrolling** → the mobile URL bar fires `resize`; the engine
|
||||
ignores height-only resizes (relayout on width change / `orientationchange`).
|
||||
- **Copy behind the notch/URL bar** → engine uses `env(safe-area-inset-bottom)` +
|
||||
`dvh`; keep `<meta viewport … viewport-fit=cover>`.
|
||||
|
||||
## Accessibility / SEO (auteur floor — enforced)
|
||||
|
||||
- `prefers-reduced-motion`, data-saver → **stills mode** (no video load/decode),
|
||||
cross-dissolving as you scroll. Never blank.
|
||||
- The engine builds its DOM in JS, so put a plain-markup copy block marked
|
||||
`data-sw-seo` in the container — crawlers, link previews, and no-JS visitors
|
||||
read it; the engine hides it on mount. **Readable with JS off.**
|
||||
|
||||
## Config pacing knobs
|
||||
|
||||
- `sections[k].scroll` — per-scene scroll distance (more = slower dwell).
|
||||
- `sections[k].linger` (0..1) — remaps scroll→time so the camera **settles
|
||||
mid-scene** where the copy peaks and moves quicker at the seams. Keep ≤0.6.
|
||||
- `diveScroll` / `connScroll`, `crossfade` (seam dissolve width), `scrollMobileFactor`.
|
||||
|
||||
## Upgrade path — canvas frame-sequence (Apple's actual technique)
|
||||
|
||||
For butter on low-end devices or when the blob payload (~8 MB × clips) is too
|
||||
heavy: pre-extract N frames per clip (webp/avif), draw to `<canvas>` (WebCodecs
|
||||
to decode ahead). Frame paint becomes deterministic — no decoder seek latency.
|
||||
The seam doctrine, chain math, and pacing knobs all carry over; only the "paint
|
||||
frame at time t" primitive swaps. More build tooling, more requests — reach for
|
||||
it only when video-scrub genuinely stutters on the target hardware.
|
||||
|
||||
---
|
||||
|
||||
*Technique & engine adapted from **scroll-world** by cyw
|
||||
(github.com/cth9191/scroll-world), MIT. auteur pairs it with grok/Gemini asset
|
||||
generation and its own slopscan / motionqa gates.*
|
||||
165
optional-skills/creative/auteur/references/system.md
Normal file
165
optional-skills/creative/auteur/references/system.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# system.md — the multi-screen register
|
||||
|
||||
`build` makes one excellent surface. `direct` makes a film. Neither survives a product: an app, a
|
||||
dashboard, an admin, a docs site, anything with routes. Not because the taste changes — the taste
|
||||
core is identical — but because **the unit of design changes and so does the failure mode.**
|
||||
|
||||
| | build / direct | system |
|
||||
|---|---|---|
|
||||
| unit of design | the section / the scene | the **component × state** |
|
||||
| failure mode | boredom — it looks like every other AI page | **drift** — screen 7 invents a fourth button |
|
||||
| how you find it | eyes on one page | crawl every route and diff what was painted |
|
||||
| the peak | exactly one wow moment | **none. A settings screen with a wow moment is a bug** |
|
||||
|
||||
The last row is the one people get wrong. In this register there is no peak, and hunting for one
|
||||
produces a dashboard with a hero animation nobody wants to sit through twice. What replaces it is
|
||||
**coherence** — a visitor should be unable to tell which screen the team cared about most — plus at
|
||||
most one *moment of care*: an empty state that is genuinely helpful, a table that does something
|
||||
smart, a keyboard flow that feels designed. A **failure** state is a legitimate answer here and often
|
||||
the best one: a wall display that never blanks when its data goes stale, keeping the numbers and
|
||||
draining the colour out of them and stamping their age, is more care than any animation. Say
|
||||
"care" and people hear "delight", and delight walks you straight back toward a peak. Quiet is the
|
||||
goal; quiet is harder.
|
||||
|
||||
## Routing here
|
||||
|
||||
Take this register when the brief has more than one screen and they must feel like one product:
|
||||
app, dashboard, admin, settings, onboarding, a docs or content site with real navigation, or a
|
||||
marketing site big enough to have sections that outlive the launch. If it is a single page, use
|
||||
`build`. If it is a launch page that has to stop the scroll, use `direct`. If you are already in
|
||||
`build` and the second screen appears, stop and come here — half a system is worse than either.
|
||||
|
||||
## Process
|
||||
|
||||
**1. Intake.** Product · audience · the screens that exist (name them) · who uses it how often (a
|
||||
tool used daily wants less personality than one used monthly) · brand constraints · stack.
|
||||
|
||||
**2. Recon** (`recon.md`, bounded). Product UI has its own reflex table and it is not the landing
|
||||
page one — for this register the moodboard matters less and the *mechanics* matter more. Scout
|
||||
products, not campaigns.
|
||||
|
||||
**3. The system-sheet — this is the gate.** Copy `templates/SYSTEM-SHEET.md` into
|
||||
`design/SYSTEM-SHEET.md` and fill it before any markup. Two halves:
|
||||
|
||||
- **the route map** — every screen, its job in one line, its layout family, and what it shares with
|
||||
the shell. A route nobody can describe in one line is a route nobody designed.
|
||||
- **the component inventory** — every control the product needs, with its variant count declared up
|
||||
front: `button: primary / ghost / danger` and nothing else. This number becomes the budget
|
||||
`systemscan` enforces later. Declaring four button variants and shipping nine is the whole disease.
|
||||
|
||||
**GATE:** every route has a one-line job; every component in the inventory has a named, justified
|
||||
variant count; every variant has its full state row filled (below). Building before this is filled
|
||||
is how you get a product that needs a redesign at screen five.
|
||||
|
||||
**4. Commit-sheet** (SKILL.md, all six fields). Field 1 "Peak" becomes **the moment of care** — the
|
||||
one place the product is more than correct. Field 5 "Motion budget" shrinks hard: an app earns
|
||||
transitions, not choreography. State the durations and stop.
|
||||
|
||||
**5. Tokens, then the shell, then screens.** Tokens as OKLCH custom properties before any component.
|
||||
Then the persistent shell (nav, sidebar, header, the page frame) — it is on every screen, so its
|
||||
mistakes are on every screen. Then screens in order of *traffic*, not of interest: the boring
|
||||
high-traffic one sets the patterns, and building the exciting one first guarantees the rest look
|
||||
like afterthoughts.
|
||||
|
||||
**6. States are the product.** Every interactive component ships every row that applies:
|
||||
|
||||
| state | the rule that gets broken |
|
||||
|---|---|
|
||||
| default | — |
|
||||
| hover | gate it in `@media (hover:hover)`, or touch devices get sticky hover |
|
||||
| **focus-visible** | must be *designed* and visible on every control. `outline: none` without a replacement is a defect, and `systemscan` fails on it — it presses Tab and looks |
|
||||
| active / pressed | a button with no pressed state feels broken before it feels ugly |
|
||||
| disabled | must be distinguishable without relying on colour alone, and must say *why* if the reason is not obvious |
|
||||
| loading | in place, not a full-page spinner; preserve layout so nothing jumps |
|
||||
| empty | the highest-value screen in most apps and the one most often skipped — say what this is, why it is empty, and the one action that fills it |
|
||||
| error | next to the thing that failed, in words, with the recovery action |
|
||||
| selected / current | navigation without a current state is a map with no "you are here" |
|
||||
|
||||
Empty, loading and error are not edge cases in a product; they are the first thing a new user sees.
|
||||
|
||||
**7. Density is a decision, not an accident.** Tables stay tables — sticky header, `tabular-nums`,
|
||||
right-aligned numerals, a real sort affordance — never card-ified into unscannability. Decide rows
|
||||
per screen deliberately. Charts route to the `dataviz` skill, which owns that craft; do not improvise
|
||||
a palette for series colour here.
|
||||
|
||||
> **The sticky-header trap.** Every dense table also needs `overflow-x: auto` on narrow viewports,
|
||||
> and that container **silently becomes the scrollport, which kills `position: sticky` on the head**.
|
||||
> It still looks perfectly correct in a screenshot, and in a `--full` capture the head even appears
|
||||
> stuck. Verify by measuring `getBoundingClientRect().top` after a real scroll, not by looking. The
|
||||
> head also has to be offset by the height of any sticky page chrome above it; `top: 0` parks it
|
||||
> underneath your own header.
|
||||
|
||||
**8. Verify** (`verify.md` + the gate below).
|
||||
|
||||
**9. Lock the contract.** `design/DESIGN.md` in this register is not a style note, it is the
|
||||
**component contract**: the inventory with its final variant counts, the state recipes with real
|
||||
values, and the rule that a new variant is a design decision that edits this file first. Every later
|
||||
edit (the `edit` route) starts here.
|
||||
|
||||
## The gate: systemscan
|
||||
|
||||
```bash
|
||||
node scripts/systemscan.mjs http://localhost:3000 --routes /,/settings,/billing,/team
|
||||
node scripts/systemscan.mjs <url1> <url2> <url3> --max-variants 4
|
||||
```
|
||||
|
||||
It crawls every route, reads what the browser **actually painted**, and reports the system as built
|
||||
rather than as documented. A token in the stylesheet that never renders is not part of the system; a
|
||||
one-off inline style is.
|
||||
|
||||
**Budgets are per kind:** `--max-variants button=5,select=1,toggle=3,4` — a bare number sets the
|
||||
default for everything unnamed. Pass the numbers from your system-sheet, not one global figure.
|
||||
|
||||
**What counts as a variant** is a *painted signature*: `background | colour | border-colour |
|
||||
border-width | radius | font-size/weight | padding | shadow`. A small button and an icon button are
|
||||
separate variants whether or not you named them — write the budget knowing that. Note also that
|
||||
`<a class="btn…">` is classified as a **button**, not a link.
|
||||
|
||||
**A state is not a variant.** `[disabled]` / `aria-disabled`, `aria-current` / `aria-selected`, and
|
||||
any control sitting inside an element carrying a non-default `data-state` are counted and printed
|
||||
separately, never against the budget. The reason is that the state matrix above is compulsory: a
|
||||
disabled secondary button is *supposed* to paint differently, and a run number is supposed to invert
|
||||
inside a late row. Charging those against the variant budget would mean a product with no disabled
|
||||
state scores better than one that implements it properly — the gate would be pushing against its own
|
||||
doctrine. Give your states real hooks (`data-state`, `aria-*`) and the gate reads them as states;
|
||||
paint a fifth button by hand with no hook and it is drift, correctly.
|
||||
|
||||
It fails on:
|
||||
- **a control type over its variant budget** — the number you declared in the system-sheet;
|
||||
- **a route that returned an error status or rendered nothing measurable** — a mistyped route list
|
||||
used to make the gate quieter instead of louder, which is the wrong direction for a gate;
|
||||
- **an interactive element with no visible focus state** — it presses Tab, reads what changed, and a
|
||||
control that paints identically focused and unfocused is a fail. (It drives real keypresses because
|
||||
`:focus-visible` is a heuristic that programmatic focus does not reliably trigger, and because
|
||||
tabbing skips disabled controls for free.)
|
||||
|
||||
It warns on:
|
||||
- **a variant used exactly once** — either promote it into the system or delete it; a one-off is
|
||||
drift with a nice reason attached;
|
||||
- **a colour, type step or radius that appears on exactly one route** — that is where the system is
|
||||
splitting, and it is always the newest screen;
|
||||
- more than a dozen distinct type steps, or no disabled control anywhere on any route (a disabled
|
||||
state that never renders is usually undesigned rather than unnecessary).
|
||||
|
||||
And it writes `components.png`: one tile per distinct rendered variant, labelled with how many times
|
||||
and on how many routes it appears. **Look at it.** Two tiles that look identical to you but appear
|
||||
separately are drift the numbers already caught; a tile that looks foreign is drift your eyes caught
|
||||
first. This sheet is the register's equivalent of the screenshot journey — the app analogue of
|
||||
watching your own film.
|
||||
|
||||
Run it against every route, not a sample. Drift lives on the screen you did not check.
|
||||
|
||||
## Also run
|
||||
|
||||
- `slopscan` over the whole source tree — the bans do not stop applying because it is an app.
|
||||
- `shoot.mjs` accepts several URLs; shoot every route at 390/768/1440 and look. Screens collapse at
|
||||
breakpoints in ways a component inventory cannot predict.
|
||||
- Keyboard pass, `prefers-reduced-motion`, and the no-JS read — same rubric as `verify.md`.
|
||||
|
||||
## What this register does not do
|
||||
|
||||
It builds the design system and the screens. It does not architect an application: routing, state management,
|
||||
data fetching and component-file architecture belong to whatever stack the brief names, and this
|
||||
register produces the markup, tokens and behaviour that live inside it. When the brief is a React or
|
||||
Vue app, say plainly that the output is the design layer and agree where it plugs in — a beautiful
|
||||
system delivered as one HTML file the team then has to dismantle is not a favour.
|
||||
118
optional-skills/creative/auteur/references/taste.md
Normal file
118
optional-skills/creative/auteur/references/taste.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# taste.md — the anti-slop system
|
||||
|
||||
Slop is not ugliness; it is *predictability*. A model asked for "a modern landing page" reaches for the statistical mode of its training data — and so does every other model, which is why AI pages look alike. Taste, operationally, is refusing the mode: every visible choice (color, type, layout, motion, copy) must be traceable to *this* brand and *this* story, not to the category average. This file turns that refusal into procedure.
|
||||
|
||||
## 1. The bans, with escape routes
|
||||
|
||||
The table in SKILL.md is the law; this section is jurisprudence — what to do instead, so the fix doesn't become a second cliché.
|
||||
|
||||
- **Side-stripe borders** → If the element needs categorical marking, use a full 1px border in a meaningful hue, a background tint at 4–8% alpha of the semantic color, or a leading glyph. If it needs *emphasis*, use scale or position, not decoration.
|
||||
- **Gradient text** → Weight, size, or a single committed color. If the heading feels flat, the problem is the composition around it, not missing rainbow.
|
||||
- **Glassmorphism** → Earn blur: it's justified when real content passes *under* a persistent surface (sticky nav over imagery). A static card with `backdrop-filter` over a flat background is costume jewelry.
|
||||
- **Hero-metric / identical card grids** → Break the loop: one large element + two small; a real screenshot/artifact instead of an icon; a claim proven in a sentence instead of a stat. If three items genuinely deserve equal weight, differentiate their *content* (image vs number vs quote).
|
||||
- **Eyebrows & numbered sections** → Sections can open with: an oversized first word, a hairline rule + heading, a change of background, an inline marginal note, a full-bleed image band. Rotate openings; repetition of any single opening is the tell.
|
||||
- **Uniform reveals** → Bind each reveal to its content: text rises a few px and unblurs; an image scales from 1.04; a chart draws in; a list staggers. Same *system*, different *expression*.
|
||||
- **Copy tells** → Replace abstraction with mechanism: not "Seamless integration" but "Connects to Stripe in one webhook". Never let an em dash chain replace sentence structure. Adjectives must be checkable.
|
||||
|
||||
## 2. Category-reflex test (run at two altitudes)
|
||||
|
||||
Run this during the commit-sheet, before code:
|
||||
|
||||
1. **First order:** Complete the sentence honestly — "An AI told to design a ⟨category⟩ site would produce: ⟨palette, type, layout⟩." If your current plan matches, discard it.
|
||||
2. **Second order:** "An AI told to *avoid* that would produce: ⟨…⟩." If your plan matches *that*, discard it too. The second reflex is saturated for every major category as of 2026.
|
||||
3. Commit to a deviation that is motivated by the brand (not random weirdness — an *argued* left turn).
|
||||
|
||||
Reference table of saturated reflexes (both orders are FORBIDDEN as landing spots; the escapes are directions, not templates — pick one and make it the brand's own):
|
||||
|
||||
| Category | 1st-order reflex (dead) | 2nd-order reflex (also dead) | Live escape directions |
|
||||
|---|---|---|---|
|
||||
| AI / dev tool | dark bg, purple-blue glow, terminal type | editorial serif on off-white "anti-SaaS" | physical-material metaphor (metal, paper, film); single drenched brand hue; diagrammatic/blueprint language with real density |
|
||||
| Fintech | navy + gold, trust badges, glass cards | terminal-native dark mode | ledger/print heritage with modern motion; warm daylight photography; brutalist clarity with oversized numerals that mean something |
|
||||
| SaaS B2B | blue gradient hero, 3-card features, hero-metric | cream editorial with serif headlines | product-as-hero (real UI, annotated); mono-hue drench; dense utilitarian grid done beautifully |
|
||||
| Wellness | sage + beige + airy serif | clinical ultra-white minimal | saturated botanical color; dark, calm, candle-lit mood; documentary photography-led |
|
||||
| E-commerce / product | white bg, symmetric product grid | full-bleed lifestyle blur | product macro-photography as texture; catalogue-as-editorial; color pulled *from the product itself* |
|
||||
| Portfolio / agency | huge display type, dark, marquee | raw-HTML brutalism | one signature interactive object; film-like case-study scenes; typographic system with real editorial rules |
|
||||
| Luxury | black + gold + thin serif | ultra-minimal white void | drenched jewel tone; cinematic photography with letterbox; tactile paper/foil texture |
|
||||
|
||||
If the project's category isn't listed, derive the two reflexes yourself — the procedure matters more than the table.
|
||||
|
||||
## 2.5 House tells — the third-order reflex, and the one you cannot see
|
||||
|
||||
§2 catches what a generic AI does for a *category*. It cannot catch what **this skill** does regardless of category, because that reflex does not feel like a reflex from the inside: each project argues its way to the choice honestly, and every project argues its way to the same one. It shows up only when you line the finished work up side by side.
|
||||
|
||||
Measured across the nine showcase sites, which share no subject, no client and no palette brief: **eight of nine are dark** (three landed within 0.002 of each other at mean L ≈ 0.177, at 97–98% dark pixels); a mono service font recurred to the point where the same face, Martian Mono, was independently chosen twice; amber (hue ≈ 30°) was the accent three times; the "logo left / status centre / action right" header appeared in seven of nine, and a scroll-instruction footer with a 01/05-style counter in six.
|
||||
|
||||
None of those is a mistake. All of them together are a signature — and a signature is exactly what a client did not order.
|
||||
|
||||
| # | The tell | What it looks like | Break it by |
|
||||
|---|---|---|---|
|
||||
| 1 | **Near-black by default** | body L < 0.25, "premium = dark" | committing to a lit page: L 0.5–0.9 with the drama in shadow, material and contrast |
|
||||
| 2 | **Mono service type** | tiny mono labels in the corners, technical-drawing voice | no chrome at all, or service type in the display family at small size |
|
||||
| 3 | **The status bar** | logo left · live-dot / status centre · action right | let the hero own the top edge; put navigation somewhere that costs a decision |
|
||||
| 4 | **Scroll-instruction footer** | "SCROLL TO DIVE" + `01 / 05` counter | trust the page; if the affordance is genuinely needed, make it part of the art direction |
|
||||
| 5 | **Amber or acid as the one accent** | hue ≈30 warm glow, or lime/neon on black | any committed hue whose reason is in the brief rather than in the palette's comfort zone |
|
||||
| 6 | **Wordmark-as-hero** | the brand name set enormous, centred, filling the viewport | an image, an object, a diagram, a sentence — the peak carries meaning, not letterforms |
|
||||
| 7 | **Glow as depth** | emissive bloom doing the work of lighting | real light: direction, falloff, shadow, material response |
|
||||
|
||||
**The rule: break at least two, deliberately, and write which two in the commit-sheet (§7).** Breaking one is coincidence; two is a decision. If a tell genuinely belongs in this project — a broadcast brand really does want the on-air dot — keep it and say why, the same way `auteur-allow` works for a ban. What is not allowed is arriving at all seven again without noticing.
|
||||
|
||||
The test is mechanical: put your hero beside the last thing this skill built. If a stranger could tell they came from the same studio, you have found the signature, not the direction.
|
||||
|
||||
## 3. Color
|
||||
|
||||
**Strategy before swatches.** Choose a commitment tier in the commit-sheet:
|
||||
|
||||
- **Restrained** — tinted neutrals + one accent ≤10% of surface. Default for product UI.
|
||||
- **Committed** — one saturated color carries 30–60% of the surface. Default for identity-driven pages. *The most underused tier and the fastest way to not look AI.*
|
||||
- **Full palette** — 3–4 named roles used deliberately. Campaigns, data-rich brands.
|
||||
- **Drenched** — the surface IS the color. Brand heroes, launch pages, cinema scenes.
|
||||
|
||||
**Rules:**
|
||||
- Work in OKLCH. Tinted neutrals: 0.005–0.015 chroma *toward the brand hue* — never "warm by default".
|
||||
- The warm-cream band (L 0.84–0.97, C <0.06, hue 40–100) is the saturated AI default of 2026. "Warm brand" is expressed via accent, typography, imagery — not via beige body.
|
||||
- Dark vs light is never a default. Write one sentence of physical scene ("who uses this, where, under what light, in what mood") and let the scene force the answer. If it doesn't force it, the sentence isn't concrete enough.
|
||||
- Gray text on colored background looks washed out → use a darker shade of the background's own hue, or text-color at reduced alpha.
|
||||
- Contrast: body ≥4.5:1, large ≥3:1, placeholders ≥4.5:1. When close, darken toward ink. "Light gray for elegance" is the #1 readability failure.
|
||||
- Gradients: only within one hue family or between adjacent hues that both belong to the brand; both-stops-purple-blue (hue 250–290) is banned; grays don't gradient.
|
||||
- **The subject must not dissolve into its own field.** A light hero on a light ground — ice on snow, a white product on a white sweep, pale type over a pale wash — reads as a smudge, and no amount of light fixes it. Put the subject *below* the field in value and let only its edges, seams and highlights carry brightness. On a scene that has this problem, this one correction is worth more than every other fix combined; check it on the greyscaled screenshot, where it is unmissable.
|
||||
- **Cheerful drift is the reflex you will not notice.** Handed a near-monochrome reference, a model still returns a friendlier, bluer, more saturated version of it — saturated sky-blue reads as game graphics, not as a photograph, and the drift survives even with the reference on screen. When the reference is desaturated, record its chroma in the commit-sheet as a number (mean OKLCH C) and check your own screenshot against it, because "looks about right" is exactly the judgement that drifted.
|
||||
|
||||
## 4. Typography
|
||||
|
||||
**Pair on a contrast axis, never on similarity.** Two similar-but-not-identical sans faces read as a mistake. Working axes: serif display + sans text · geometric + humanist · mono + serif · high-contrast display serif + grotesque. One family in 3+ weights is always a safe committed choice.
|
||||
|
||||
**Selection procedure** (don't keep a fixed shortlist — shortlists become the next Inter):
|
||||
1. Name the voice in 2 adjectives from the brief (e.g. "engineered, warm").
|
||||
2. On Google Fonts, filter by classification matching the *display* adjective; pick 3 candidates you can defend; test the actual headline copy at target size.
|
||||
3. Text face: prioritize x-height, open apertures, and a real italic. Verify tabular figures if numbers matter.
|
||||
4. Variable font when you need >2 weights (one file, animatable weight for kinetic type).
|
||||
|
||||
**Numbers:** display clamp max ≤6rem **for headings inside prose flow** — a wordmark, a type-led hero, or a scene where oversized type IS the subject is exempt and routinely runs 100–140px at 1440 (refscout the top tier and you will measure exactly that). The ceiling exists to stop 200px of Inter standing in for an idea, not to stop a typographic hero; if you exceed it, the commit-sheet has to say the type is the signature. letter-spacing ≥−0.04em on display, slightly positive on small caps; body 65–75ch; line-height: display 0.95–1.1, body 1.4–1.6; `text-wrap: balance` on h1–h3, `text-wrap: pretty` on prose. Fluid type via clamp() with a rem base so zoom works.
|
||||
|
||||
**Kinetic typography is architecture, not decoration:** oversized text may BE the hero (cheapest wow that exists — zero asset weight). If type is the hero, assets can wait; see direct.md.
|
||||
|
||||
## 5. Layout
|
||||
|
||||
- Grid is a starting field, not a cage. Commit ONE named grid-break per page minimum (from the commit-sheet): an overlap (image crosses a section boundary), an asymmetric split (5/7, 4/8 — not 6/6), a diagonal flow, a full-bleed interruption between contained sections, an element that escapes its column.
|
||||
- Whitespace is a material: vary section padding meaningfully (a tight dense section makes the following airy one land). Uniform `py-24` everywhere is rhythmless.
|
||||
- Cards are the last resort, not the first. Ask: would a table, a list with strong typography, an annotated image, or plain prose serve better? Nested cards are always wrong.
|
||||
- Flexbox for 1D, Grid for 2D; `repeat(auto-fit, minmax(280px, 1fr))` for breakpointless grids.
|
||||
- Semantic z-index scale (dropdown < sticky < backdrop < modal < toast < tooltip); never 999.
|
||||
- Container queries for components that live in variable-width slots; viewport queries for page chrome.
|
||||
|
||||
## 6. Texture and depth
|
||||
|
||||
Flat-solid-everything and blur-everything are both defaults. The live middle: film grain / noise at 2–4% opacity over large color fields (kills the "vector emptiness" of AI pages); hairline rules (1px, low-contrast) as structure; shadows only when something truly floats (one consistent light direction, larger blur than offset); real photographic/generated texture over CSS-only decoration.
|
||||
|
||||
## 7. Copy
|
||||
|
||||
Headlines state a mechanism or an image, not a superlative. Subheads carry the proof. Microcopy is a butler: quiet, precise, present-tense. Buttons say what happens ("Get the report", not "Learn more"). Error text says what to do next. No "Revolutionize / Seamless / Effortless / Unleash / Elevate", no em-dash chains, no "BRAND. MOTION. SPATIAL." strips. If a sentence survives with an adjective deleted, delete the adjective.
|
||||
|
||||
## 8. Self-check before verify
|
||||
|
||||
Before running the pipeline in verify.md, answer honestly:
|
||||
1. Could a stranger guess the category from palette alone? (If yes — reflex won.)
|
||||
2. Is there ONE element a visitor would describe to a friend? (If no — no signature.)
|
||||
3. Do any two sections open identically? (If yes — vary one.)
|
||||
4. Does every animated thing have a reason from motion.md's list? (If no — cut it.)
|
||||
5. Would deleting the third font/color/pattern hurt? (If no — delete it.)
|
||||
172
optional-skills/creative/auteur/references/verify.md
Normal file
172
optional-skills/creative/auteur/references/verify.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# verify.md — the acceptance pipeline
|
||||
|
||||
A page that "looks done" in the editor is exactly what every AI ships. Auteur's output is done when it survives three independent checks: a deterministic linter (catches slop defaults in code), a screenshot journey (catches what only eyes catch), and a numeric rubric (catches what eyes forgive). Run them in this order — each is cheaper than the next.
|
||||
|
||||
## 1. slopscan (deterministic)
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/slopscan.mjs <src-dir>
|
||||
```
|
||||
|
||||
- Exit 1 → fix every FAIL and re-run. Fix means *redesign the element*, not rename the class.
|
||||
- **Paste the linter's final `Summary:` line verbatim into your report and QA sheet** — after your LAST edit, not from an earlier run. A remembered result is not a result; re-run the command.
|
||||
- A FAIL that is a genuinely deliberate, argued choice → suppress in-file with
|
||||
`/* auteur-allow: RULE_ID -- <real reason, min 10 chars> */` — the reason must reference the commit-sheet ("committed glass nav over video hero, see COMMIT-SHEET §2"). Suppressions without real reasons are themselves reported.
|
||||
- WARNs: read each one; fix or consciously accept. >3 accepted warns on one page usually means the design is drifting toward the mode — reread taste.md §8.
|
||||
|
||||
## 2. Screenshot journey (eyes-on, mandatory)
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/shoot.mjs <url> --stops 7 --breakpoints 390,768,1440 --reduced-motion
|
||||
```
|
||||
|
||||
> **Serve it. Do not verify over `file://`.** `fetch` to a `file:` URL is blocked, so any page that
|
||||
> loads a glTF, an HDRI, a JSON or a sequence manifest will silently fall back to its poster — and
|
||||
> `shoot.mjs` will happily photograph a perfectly attractive page in which the entire peak never
|
||||
> booted. A false PASS is worse than a FAIL, and this one is invisible unless you already know what
|
||||
> the peak looks like. Any static server does; 40 lines of `node:http` is enough.
|
||||
|
||||
Then **open and look at every frame**. You are looking for what linters cannot see:
|
||||
|
||||
- text overflowing/wrapping ugly at any breakpoint (the viewport is part of the design)
|
||||
- blank or half-fired scenes (reveal gated on an animation that never ran)
|
||||
- scenes where the scroll-stop caught the page mid-jank
|
||||
- contrast that "passes" numerically but reads muddy on the actual background
|
||||
- two adjacent frames that look like the same layout family (storyboard said they must differ)
|
||||
- the reduced-motion journey: is it a *watchable cut*, or a broken silent ruin?
|
||||
|
||||
Console errors and "possibly blank" warnings printed by shoot.mjs are FAILs until explained.
|
||||
|
||||
> **`--full` lies about `position: fixed` and stuck `position: sticky`.** A full-page capture
|
||||
> composites them at their viewport position, so a fixed bottom nav appears in the middle of the page
|
||||
> and a sticky table head appears to overlap row 1. Both look exactly like layout bugs and neither is.
|
||||
> Judge fixed and sticky elements from the viewport frames only.
|
||||
|
||||
**Interaction smoke-test** (when the page has interactive elements — nav, forms, tabs, mute toggle): drive the real page with playwright (`playwright-cli` skill, or a short script on the same playwright install shoot.mjs uses): click every nav link (lands on the right anchor, header doesn't cover the target), open/close the mobile menu, submit the form empty (inline error appears, nothing explodes), toggle sound if present, tab through the page once (focus visible and in order, ESC closes overlays). Any interaction that throws or dead-ends is a FAIL. Static pages skip this.
|
||||
|
||||
## 2.5 Motion / perf / audio QA (direct register — screenshots are blind to time)
|
||||
|
||||
The screenshot journey (§2) proves the page looks right FROZEN; it says nothing about jank, dropped frames,
|
||||
audio drift, or a WebGL context leaking on route change. For any Tier-1 scene (scroll state-machine,
|
||||
audio-reactive, 2.5D composite, scrubbed video) run a MOTION pass with playwright and assert real numbers:
|
||||
|
||||
> **A perf number is only as honest as the three settings behind it, and they all default to flattering.**
|
||||
> **Pixels:** `motionqa.mjs` renders 1440×900 **@2x = 5.2MP**, because fullscreen passes cost per pixel
|
||||
> and the reviewer's laptop is retina. At DPR 1 the same scene is 1.3MP, the expensive part of the frame
|
||||
> costs a quarter as much, and the gate certifies 60fps on a page that stutters on a MacBook. Expect the
|
||||
> two numbers to be identical on a scene with **no** fullscreen pass (measured: 53→54, 53→53, 54→54 on
|
||||
> three showcase sites) — that is the honest result, not a broken gate; DPR only bites where post-processing does.
|
||||
> **GPU:** headless chromium has none, and its software raster is not a small error — the same three sites
|
||||
> measured 6 / 20 / 20 fps headless against 53 / 53 / 54 headed. A headless number is a floor, never a verdict.
|
||||
> **Build:** measure the **production build**, never the dev server — HMR clients and unminified bundles
|
||||
> cost roughly 2× per frame. That error runs the other way (false FAILs), and a false FAIL is how a scene
|
||||
> gets amputated for nothing; motionqa flags the dev servers it can recognize, but not all of them.
|
||||
> Quote both in the report: "minFps 57 @4× CPU, 1440×900@2x (5.2MP), production build".
|
||||
|
||||
```js
|
||||
// scripts/motionqa.mjs — record while scrolling the whole page, at a mid-laptop CPU tier
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }) // jank hides at full speed
|
||||
await page.evaluate(() => { window.__lt = 0; new PerformanceObserver(l => {
|
||||
for (const e of l.getEntries()) window.__lt = Math.max(window.__lt, e.duration)
|
||||
}).observe({ type:'longtask', buffered:true }) })
|
||||
const minFps = await page.evaluate(() => new Promise(res => {
|
||||
let last = performance.now(), min = 999, end = last + 4000
|
||||
;(function tick(t){ const d = t - last; last = t; if (d>0) min = Math.min(min, 1000/d)
|
||||
scrollBy(0, innerHeight/60); t < end ? requestAnimationFrame(tick) : res(min) })(performance.now())
|
||||
}))
|
||||
const longTask = await page.evaluate(() => window.__lt)
|
||||
```
|
||||
|
||||
Assert (fail = fix, don't ship):
|
||||
- **minFps ≥ 50** on the scrub at 4× CPU throttle — below that the scroll reads as a slideshow.
|
||||
- **No long task > 50ms** during the scroll (one 200ms task IS the jank users feel).
|
||||
- **WebGL context count flat across route changes** — no `Too many active WebGL contexts` in console (the #1 reported leak); swap textures, never remount.
|
||||
- **prefers-reduced-motion journey renders a valid static alternative** — the §2 `--reduced-motion` frames must still tell the story (no blank canvas, no missing hero), not just "motion off".
|
||||
- **Audio (if reactive):** OFF until a user gesture (`audio.paused` true on load), a visible mute control exists, and the visual is complete muted (the scroll pass above already ran silent).
|
||||
- **Poster / first frame paints within LCP budget** before textures decode — no blank hero.
|
||||
|
||||
Console over the whole run: zero errors, zero `THREE.WebGLRenderer: Context Lost`. Report the numbers:
|
||||
"motionqa: minFps 57 @4× throttle · max long-task 34ms · reduced-motion journey clean · audio gesture-gated".
|
||||
|
||||
## 3. Numeric rubric
|
||||
|
||||
| Check | Threshold | How |
|
||||
|---|---|---|
|
||||
| Body contrast | ≥4.5:1 (large ≥3:1, placeholders ≥4.5:1), **measured in every state, not just the default** — an error or stale view that dims its own text is the usual way this fails, and every linter reads the undimmed colour | devtools / axe on final colors, then again with each degraded state applied |
|
||||
| LCP | <2.5s (throttled Fast 3G / 4× CPU) | Lighthouse |
|
||||
| CLS | <0.1 | Lighthouse |
|
||||
| INP | <200ms | Lighthouse / manual scroll+click |
|
||||
| Hero video | ≤2MB (poster ≤300KB) | file sizes on disk |
|
||||
| Sequence frames | ≤150KB each @1440w | file sizes |
|
||||
| Motion budget | ≤3 scroll-pattern families; uniform-reveal check | count them honestly |
|
||||
| Background lightness | within ±0.12 of the L committed in commit-sheet §2 | `chromadiff.mjs <frame> --target-l <L>` |
|
||||
| House tells | ≥2 broken and named in commit-sheet §7; the built page actually breaks them | taste.md §2.5 list vs the hero frame |
|
||||
| One peak | exactly one scene intensity ≥8 | storyboard vs built page |
|
||||
| Adjacent-scene variety | no two neighbors share layout family | screenshot journey |
|
||||
| Reduced-motion | full journey watchable, nothing blank | shoot.mjs --reduced-motion frames |
|
||||
| Motion (Tier-1 scenes) | minFps ≥50 @4× throttle **at DPR 2, on the production build** · long-task ≤50ms · no WebGL leak · audio gesture-gated | scripts/motionqa.mjs (§2.5) |
|
||||
| Keyboard | tab order sane, focus visible, no traps, ESC closes overlays | manual pass |
|
||||
| No-JS | content readable, page navigable | disable JS, reload |
|
||||
| Fallback payload | every degraded cut still carries the peak's *information*, not just a picture of it | reduced-motion / no-JS / no-WebGL, at 390 too — a callout panel hidden by a mobile breakpoint deletes the payload while the desktop screenshots look fine |
|
||||
|
||||
## 3.5 systemscan (system register — one page cannot show you drift)
|
||||
|
||||
A product fails differently from a page: every screen looks fine alone while the fourth button
|
||||
variant quietly appears on screen seven. Run the cross-route gate over **every** route, not a sample:
|
||||
|
||||
```bash
|
||||
node scripts/systemscan.mjs http://localhost:3000 --routes /,/settings,/billing,/team
|
||||
```
|
||||
|
||||
It reads what the browser actually painted, fails a control type over its declared variant budget,
|
||||
presses Tab to catch any control that paints identically focused and unfocused, and writes
|
||||
`components.png` — one tile per rendered variant. Look at that sheet the way the direct register
|
||||
makes you watch your own film: a tile that looks foreign is drift your eyes caught before the
|
||||
numbers did. Full doctrine in `system.md`.
|
||||
|
||||
## 3.7 Reference diff (the last look, before you decide you're done)
|
||||
|
||||
Recon (`design/refs/`) decided the direction in phase 0 and then went quiet for the whole build. Bring it back for one pass: put your own screenshot beside the reference that set the bar and ask what the reference does better. This catches the class of failure every other gate is blind to — the page is fast, clean, linted, and quietly a friendlier, cheaper version of what you set out to make. Drift toward the pleasant middle is not visible from the inside; it is obvious side by side.
|
||||
|
||||
- Compare the same frame at the same width, and **also greyscaled** — value structure and figure/ground survive the conversion, seductive colour does not.
|
||||
- Ask for the differences ranked **from cheap-and-decisive to expensive-and-marginal**, in exactly those terms. Unranked, the answer is twenty equally-weighted bullet points and you cannot tell which one is the page. Ranked, the top two are usually worth more than the rest combined, and the tail is honestly ignorable.
|
||||
- Fix the top of the list, re-shoot, look again. Two rounds is normally the end of it.
|
||||
- Chroma is the specific thing to check against a desaturated reference (`taste.md` §3): the model drifts toward a bluer, happier, more saturated frame even with the reference in view. That one is measurable, so measure it instead of arguing about it:
|
||||
|
||||
```bash
|
||||
node scripts/chromadiff.mjs shots/bp1440-stop00.png design/refs/<the-reference>.png
|
||||
# chromadiff: yours C 0.042 L 0.873 - reference C 0.004 L 0.874 - delta +0.039 → FAIL
|
||||
```
|
||||
|
||||
It fails only upward: quieter than the reference is a choice, louder than the reference is the reflex. `--max-delta` moves the line when the brief genuinely calls for more colour than its reference — say so in the commit-sheet when you move it.
|
||||
|
||||
Then the same check for lightness, against your own sheet rather than a reference:
|
||||
|
||||
```bash
|
||||
node scripts/shoot.mjs <url> --stops 5 --breakpoints 1440 --full --out shots
|
||||
node scripts/chromadiff.mjs shots/bp1440-full.png --target-l 0.55
|
||||
# chromadiff: L 0.177 (committed 0.55) - C 0.013 - 98% dark pixels → FAIL, and that number is the tell
|
||||
```
|
||||
|
||||
**Measure the full-page capture, not a viewport frame.** The sheet commits to the page's *mean* lightness and the first screen is never the mean — a page whose dark fields live below the fold measured 0.876 at the hero and 0.740 over the whole page, one false FAIL from the crop alone. Same caveat as `--full` in §2: use it for this measurement, judge fixed/sticky elements from the viewport frames.
|
||||
|
||||
**And re-read `taste.md` §2.5 with the hero frame in front of you.** Commit-sheet §7 named two house tells to break; confirm the built page actually broke them, because they creep back in during assembly — the corner labels return as "just a caption", the status bar as "just a nav". A page that quietly rebuilt all seven tells is on style, and being on *this skill's* style is the failure the whole file is about.
|
||||
|
||||
The output of this pass is one line in the report: what the reference did better, what you changed, what you consciously left.
|
||||
|
||||
## 4. Sign-off
|
||||
|
||||
- **direct register:** copy `templates/CINEMA-QA.md` into the project, fill every row with PASS/FAIL + evidence (metric value or screenshot filename). All-PASS ships; any FAIL loops back to its phase.
|
||||
- **build register:** the rubric table above, inline in your final report.
|
||||
- **system register:** the rubric table, plus systemscan's verdict quoted verbatim (variant counts per control, focus failures, one-off variants) and confirmation that you looked at `components.png`.
|
||||
- Report honestly and concretely: "slopscan 0 fails / 2 accepted warns (reasons logged); 21+6 screenshots reviewed — fixed S4 headline overflow at 390; LCP 1.8s; CLS 0.02; reduced-motion cut verified." If something is unverified (e.g. no local server to measure LCP), say so explicitly rather than implying a pass.
|
||||
- Optional second opinion: an outside UI critique (upstream used a separate 'impeccable' skill, not vendored; in Hermes, `vision_analyze` on the shoot.mjs screenshots works) — it measures UX heuristics auteur doesn't; disagreement between the two is signal, not noise.
|
||||
|
||||
## When verification keeps failing
|
||||
|
||||
Two full loops on the same gate → stop patching symptoms. The failure is upstream: a scene too ambitious for its asset (descend the ladder in assets.md), a palette that never worked (redo commit-sheet §2), a motion budget blown by scope creep (cut a pattern family). Fixing upstream is cheaper than a third loop downstream.
|
||||
|
||||
**When you cannot tell what a setting even controls, take it to an absurd value.** Set the blur to 200, the displacement to 10, the duration to 5s — and look. If nothing changes, the knob is not connected to what you think it is, and you have found the real bug instead of tuning a decoy for an hour. This is how a focus that is permanently locked to one object gets discovered: crank the blur and watch the foreground stay soft no matter what the number says.
|
||||
|
||||
**And when a fix keeps not working, consider deleting the element instead.** An idea that needs three rounds of rescue is usually just weak — light rays built from cones, a transition that reads as signal loss. Polishing your own bad idea burns tokens and hours on something that comes out anyway; cutting it is a decision, not a defeat.
|
||||
129
optional-skills/creative/auteur/scripts/chromadiff.mjs
Normal file
129
optional-skills/creative/auteur/scripts/chromadiff.mjs
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* chromadiff.mjs — the cheerful-drift gate.
|
||||
* Given a near-monochrome reference, a model still returns a friendlier, bluer, more saturated
|
||||
* version of it, and it does so with the reference on screen. Eyes forgive that drift because each
|
||||
* frame looks fine alone; side by side it is the difference between a photograph and game graphics.
|
||||
* This measures it: mean OKLCH chroma of your screenshot vs the reference that set the direction.
|
||||
* Second mode, one file + --target-l: check the page against the background lightness the commit-sheet
|
||||
* committed to. This skill drifts to near-black across unrelated projects (taste.md §2.5) and every
|
||||
* such page argues convincingly for itself, so the number goes in the sheet BEFORE the build and gets
|
||||
* checked after, when "it just felt right dark" is no longer admissible evidence.
|
||||
* Feed --target-l a FULL-PAGE capture (shoot.mjs --full). The sheet commits to the page's mean
|
||||
* lightness, and the first viewport is never the mean: measured on one build, the hero frame read
|
||||
* 0.876 against a page that actually sat at 0.740 — a false FAIL produced entirely by the crop.
|
||||
* Usage: node chromadiff.mjs <yours.png> <reference.png|jpg> [--max-delta 0.02] [--json]
|
||||
* node chromadiff.mjs <full-page.png> --target-l 0.55 [--tolerance 0.12] [--json]
|
||||
* ponytail: decodes via the playwright chromium that shoot.mjs already installs — no image deps.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { resolve, extname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const VALUE_FLAGS = new Set(['--max-delta', '--target-l', '--tolerance']);
|
||||
const files = argv.filter((a, i) => !a.startsWith('--') && !VALUE_FLAGS.has(argv[i - 1]));
|
||||
const jsonMode = argv.includes('--json');
|
||||
const num = (flag, def) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? +argv[i + 1] : def; };
|
||||
const MAX_DELTA = num('--max-delta', 0.02); // 0.02 OKLCH C is a visible step
|
||||
const TARGET_L = argv.includes('--target-l') ? num('--target-l', NaN) : null;
|
||||
const TOLERANCE = num('--tolerance', 0.12); // wide on purpose: this catches drift, not art direction
|
||||
|
||||
if (TARGET_L !== null && !(TARGET_L >= 0 && TARGET_L <= 1)) {
|
||||
process.stderr.write('--target-l takes the committed mean background lightness, 0..1\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!(files.length === 2 || (files.length === 1 && TARGET_L !== null))) {
|
||||
process.stderr.write('Usage: node chromadiff.mjs <yours.png> <reference.png> [--max-delta 0.02] [--json]\n' +
|
||||
' node chromadiff.mjs <yours.png> --target-l 0.55 [--tolerance 0.12] [--json]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const [mine, ref] = files.map(f => resolve(f));
|
||||
for (const f of [mine, ref].filter(Boolean)) if (!existsSync(f)) { process.stderr.write(`not found: ${f}\n`); process.exit(2); }
|
||||
|
||||
// A file:// image will not decode inside an about:blank page (opaque origin), so the bytes travel
|
||||
// as a data URL instead — which also keeps this working against any page the browser is showing.
|
||||
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif' };
|
||||
const toUrl = async p => {
|
||||
const mime = MIME[extname(p).toLowerCase()];
|
||||
if (!mime) { process.stderr.write(`unsupported image type: ${p}\n`); process.exit(2); }
|
||||
return `data:${mime};base64,${(await readFile(p)).toString('base64')}`;
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
// One decode pass per image, downsampled to 256px on the long edge: chroma is a distribution
|
||||
// statistic, and averaging 65k pixels answers it as well as averaging 4 million.
|
||||
const measure = url => page.evaluate(async src => {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
await img.decode();
|
||||
const scale = 256 / Math.max(img.width, img.height);
|
||||
const w = Math.max(1, Math.round(img.width * scale)), h = Math.max(1, Math.round(img.height * scale));
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w; cv.height = h;
|
||||
const cx = cv.getContext('2d', { willReadFrequently: true });
|
||||
cx.drawImage(img, 0, 0, w, h);
|
||||
const d = cx.getImageData(0, 0, w, h).data;
|
||||
|
||||
const lin = c => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
let sumC = 0, sumL = 0, saturated = 0, dark = 0, n = 0;
|
||||
for (let p = 0; p < d.length; p += 4) {
|
||||
const r = lin(d[p] / 255), g = lin(d[p + 1] / 255), b = lin(d[p + 2] / 255);
|
||||
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
|
||||
const A = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
|
||||
const B = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
|
||||
const C = Math.hypot(A, B);
|
||||
sumC += C; sumL += L; if (C > 0.1) saturated++; if (L < 0.5) dark++; n++;
|
||||
}
|
||||
return { chroma: sumC / n, lightness: sumL / n, saturatedShare: saturated / n, darkShare: dark / n };
|
||||
}, url);
|
||||
|
||||
const a = await measure(await toUrl(mine));
|
||||
const b = ref ? await measure(await toUrl(ref)) : null;
|
||||
await browser.close();
|
||||
|
||||
const r3 = x => +x.toFixed(3);
|
||||
const fails = [];
|
||||
|
||||
// Lightness mode: one frame against the number the commit-sheet committed to.
|
||||
if (TARGET_L !== null) {
|
||||
const dl = a.lightness - TARGET_L;
|
||||
if (Math.abs(dl) > TOLERANCE)
|
||||
fails.push(`background lightness ${r3(a.lightness)} vs committed ${TARGET_L} (${dl > 0 ? '+' : ''}${r3(dl)}, tolerance ±${TOLERANCE})` +
|
||||
(dl < 0 ? ' — the page went darker than the sheet says; taste.md §2.5 tell #1' : ''));
|
||||
const summaryL = { yours: { chroma: r3(a.chroma), lightness: r3(a.lightness), darkShare: r3(a.darkShare) }, targetL: TARGET_L, tolerance: TOLERANCE, fails };
|
||||
if (jsonMode) process.stdout.write(JSON.stringify(summaryL, null, 2) + '\n');
|
||||
else {
|
||||
process.stdout.write(`chromadiff: L ${r3(a.lightness)} (committed ${TARGET_L}) - C ${r3(a.chroma)} - ${Math.round(a.darkShare * 100)}% dark pixels\n`);
|
||||
for (const f of fails) process.stdout.write(`FAIL ${f}\n`);
|
||||
process.stdout.write(fails.length ? 'FAIL — decide it or change the sheet; do not let it drift\n' : 'PASS\n');
|
||||
}
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
}
|
||||
|
||||
const delta = a.chroma - b.chroma;
|
||||
// Only the upward direction fails. Ending up quieter than the reference is a defensible choice
|
||||
// (and often the right one); ending up more colourful than it is the reflex this gate exists for.
|
||||
if (delta > MAX_DELTA) fails.push(`chroma +${r3(delta)} over reference (${r3(a.chroma)} vs ${r3(b.chroma)}, limit +${MAX_DELTA})`);
|
||||
if (a.saturatedShare - b.saturatedShare > 0.15)
|
||||
fails.push(`${Math.round((a.saturatedShare - b.saturatedShare) * 100)}pp more saturated pixels than the reference (C>0.1)`);
|
||||
|
||||
const summary = {
|
||||
yours: { chroma: r3(a.chroma), lightness: r3(a.lightness), saturatedShare: r3(a.saturatedShare) },
|
||||
reference: { chroma: r3(b.chroma), lightness: r3(b.lightness), saturatedShare: r3(b.saturatedShare) },
|
||||
chromaDelta: r3(delta), maxDelta: MAX_DELTA, fails,
|
||||
};
|
||||
if (jsonMode) process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
||||
else {
|
||||
process.stdout.write(`chromadiff: yours C ${r3(a.chroma)} L ${r3(a.lightness)} - reference C ${r3(b.chroma)} L ${r3(b.lightness)} - delta ${delta >= 0 ? '+' : ''}${r3(delta)}\n`);
|
||||
for (const f of fails) process.stdout.write(`FAIL ${f}\n`);
|
||||
process.stdout.write(fails.length ? 'FAIL — this is the drift, not a lighting difference: pull chroma back to the committed palette\n' : 'PASS\n');
|
||||
}
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
240
optional-skills/creative/auteur/scripts/moodboard.mjs
Normal file
240
optional-skills/creative/auteur/scripts/moodboard.mjs
Normal file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* moodboard.mjs — visual reference search → downloaded images → one contact sheet you can actually look at.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/moodboard.mjs "<query>" ["<query2>" ...] [options]
|
||||
*
|
||||
* Options:
|
||||
* --source bing,pinterest,arena which sources (default all three)
|
||||
* --limit N images kept in total (default 24, cap 60)
|
||||
* --out DIR output dir (default design/moodboard)
|
||||
* --cols N contact-sheet columns (default 5)
|
||||
* --min-kb N skip images smaller than this (default 15)
|
||||
*
|
||||
* Writes DIR/img/NN.<ext>, DIR/contact-sheet*.png (LOOK AT THESE), DIR/MOODBOARD.md.
|
||||
* Exit 0 if any image landed.
|
||||
*
|
||||
* Source notes (probed live, 2026-08):
|
||||
* · bing — most reliable. Indexes pinimg / dribbble / behance CDNs and serves
|
||||
* hotlinkable originals through a browser context. This is the workhorse.
|
||||
* · pinterest — logged-out search renders a partial grid behind a login wall: sometimes
|
||||
* ~15 pins, sometimes zero. Best-effort, never load-bearing. Thumbnails are
|
||||
* rewritten 236x → 736x (originals/ is 403 for hotlinks).
|
||||
* · arena — api.are.na public search, no auth. Curated designer taste, lower volume.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { resolve, join } from 'path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length || args.includes('--help')) {
|
||||
console.log(`moodboard.mjs — image search → contact sheet
|
||||
|
||||
node scripts/moodboard.mjs "<query>" ["<query2>" ...] [--source bing,pinterest,arena]
|
||||
[--limit 24] [--out design/moodboard] [--cols 5] [--min-kb 15]`);
|
||||
process.exit(0);
|
||||
}
|
||||
const get = (f, d) => { const i = args.indexOf(f); return i !== -1 ? args[i + 1] : d; };
|
||||
const flagIdx = args.findIndex(a => a.startsWith('--'));
|
||||
const queries = (flagIdx === -1 ? args : args.slice(0, flagIdx)).filter(Boolean);
|
||||
|
||||
const sources = get('--source', 'bing,pinterest,arena').split(',').map(s => s.trim());
|
||||
const limit = Math.min(parseInt(get('--limit', '24'), 10) || 24, 60);
|
||||
const outDir = resolve(get('--out', 'design/moodboard'));
|
||||
const cols = parseInt(get('--cols', '5'), 10) || 5;
|
||||
const minKb = parseInt(get('--min-kb', '15'), 10) || 15;
|
||||
|
||||
if (!queries.length) { console.error('[moodboard] no query given.'); process.exit(1); }
|
||||
|
||||
let chromium;
|
||||
try { ({ chromium } = await import('playwright')); }
|
||||
catch { console.error('playwright not found. Install: npm i -D playwright && npx playwright install chromium'); process.exit(2); }
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ userAgent: UA, viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
|
||||
|
||||
async function open(page, url, settle = 4000) {
|
||||
await page.goto(url, { waitUntil: 'commit', timeout: 30_000 });
|
||||
const dcl = await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).then(() => true).catch(() => false);
|
||||
await page.waitForTimeout(settle);
|
||||
if (!dcl) await page.evaluate(() => window.stop()).catch(() => {}); // stream that never closes
|
||||
}
|
||||
|
||||
// --- sources ------------------------------------------------------------------
|
||||
async function fromBing(q, want) {
|
||||
const page = await ctx.newPage();
|
||||
const out = [];
|
||||
try {
|
||||
await open(page, 'https://www.bing.com/images/search?q=' + encodeURIComponent(q) + '&form=HDRSC2', 3500);
|
||||
for (let pass = 0; pass < 3 && out.length < want; pass++) {
|
||||
const items = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('a.iusc')].map(a => { try { return JSON.parse(a.getAttribute('m')); } catch { return null; } }).filter(Boolean));
|
||||
for (const it of items) {
|
||||
if (out.length >= want) break;
|
||||
if (it.murl && !out.some(o => o.img === it.murl)) out.push({ img: it.murl, thumb: it.turl, page: it.purl, source: 'bing' });
|
||||
}
|
||||
await page.evaluate(() => window.scrollBy(0, innerHeight * 2));
|
||||
await page.waitForTimeout(1800);
|
||||
}
|
||||
} catch (e) { console.error(`[moodboard] bing "${q}": ${e.message.split('\n')[0]}`); }
|
||||
finally { await page.close(); }
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fromPinterest(q, want) {
|
||||
const page = await ctx.newPage();
|
||||
const out = [];
|
||||
try {
|
||||
await open(page, 'https://www.pinterest.com/search/pins/?q=' + encodeURIComponent(q) + '&rs=typed', 6000);
|
||||
for (let pass = 0; pass < 4 && out.length < want; pass++) {
|
||||
const items = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('img')]
|
||||
.filter(i => /i\.pinimg\.com\/\d+x\//.test(i.src))
|
||||
.map(i => ({ src: i.src, pin: i.closest('a[href^="/pin/"]')?.getAttribute('href') || null })));
|
||||
for (const it of items) {
|
||||
// 736x is the largest size that stays hotlinkable; /originals/ returns 403.
|
||||
const img = it.src.replace(/\/\d+x\//, '/736x/');
|
||||
if (out.length >= want) break;
|
||||
if (!out.some(o => o.img === img)) out.push({ img, thumb: it.src, page: it.pin ? 'https://www.pinterest.com' + it.pin : 'https://www.pinterest.com/search/pins/?q=' + encodeURIComponent(q), source: 'pinterest' });
|
||||
}
|
||||
await page.evaluate(() => window.scrollBy(0, innerHeight * 1.5));
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
if (!out.length) console.error('[moodboard] pinterest returned 0 (login wall) — bing covers it.');
|
||||
} catch (e) { console.error(`[moodboard] pinterest "${q}": ${e.message.split('\n')[0]}`); }
|
||||
finally { await page.close(); }
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fromArena(q, want) {
|
||||
const out = [];
|
||||
try {
|
||||
const res = await ctx.request.get(`https://api.are.na/v2/search?q=${encodeURIComponent(q)}&per=${Math.min(want * 2, 40)}`, { timeout: 20_000 });
|
||||
const json = await res.json();
|
||||
for (const b of json.blocks || []) {
|
||||
const img = b.image?.large?.url || b.image?.display?.url || b.image?.original?.url;
|
||||
if (!img || out.length >= want) continue;
|
||||
out.push({ img, thumb: b.image?.thumb?.url, page: `https://www.are.na/block/${b.id}`, source: 'arena', title: b.title });
|
||||
}
|
||||
} catch (e) { console.error(`[moodboard] are.na "${q}": ${e.message.split('\n')[0]}`); }
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- collect ------------------------------------------------------------------
|
||||
const perSourcePerQuery = Math.ceil(limit / (sources.length * queries.length)) + 4;
|
||||
let candidates = [];
|
||||
for (const q of queries) {
|
||||
for (const s of sources) {
|
||||
const fn = s === 'bing' ? fromBing : s === 'pinterest' ? fromPinterest : s === 'arena' ? fromArena : null;
|
||||
if (!fn) { console.error(`[moodboard] unknown source "${s}"`); continue; }
|
||||
const got = await fn(q, perSourcePerQuery);
|
||||
process.stderr.write(`[moodboard] ${s} · "${q}" → ${got.length}\n`);
|
||||
candidates.push(...got.map(g => ({ ...g, query: q })));
|
||||
}
|
||||
}
|
||||
// dedupe by url, then interleave sources so one source can't own the whole sheet
|
||||
const seen = new Set();
|
||||
candidates = candidates.filter(c => !seen.has(c.img) && seen.add(c.img));
|
||||
const bySource = sources.map(s => candidates.filter(c => c.source === s));
|
||||
const ordered = [];
|
||||
for (let i = 0; ordered.length < candidates.length; i++) {
|
||||
let added = false;
|
||||
for (const bucket of bySource) if (bucket[i]) { ordered.push(bucket[i]); added = true; }
|
||||
if (!added) break;
|
||||
}
|
||||
|
||||
// --- download -----------------------------------------------------------------
|
||||
await mkdir(join(outDir, 'img'), { recursive: true });
|
||||
const kept = [];
|
||||
const sizes = new Set();
|
||||
for (const c of ordered) {
|
||||
if (kept.length >= limit) break;
|
||||
let buf = null, ct = '';
|
||||
for (const url of [c.img, c.thumb].filter(Boolean)) {
|
||||
try {
|
||||
const r = await ctx.request.get(url, { timeout: 20_000, headers: { Referer: c.page || '' } });
|
||||
if (!r.ok()) continue;
|
||||
ct = r.headers()['content-type'] || '';
|
||||
if (!/^image\//.test(ct)) continue;
|
||||
const b = await r.body();
|
||||
if (b.length < minKb * 1024) continue;
|
||||
buf = b; c.used = url; break;
|
||||
} catch {}
|
||||
}
|
||||
if (!buf) continue;
|
||||
if (sizes.has(buf.length)) continue; // cheap byte-identical dedupe across sources
|
||||
sizes.add(buf.length);
|
||||
const ext = /png/.test(ct) ? 'png' : /webp/.test(ct) ? 'webp' : /gif/.test(ct) ? 'gif' : 'jpg';
|
||||
const n = String(kept.length + 1).padStart(2, '0');
|
||||
const file = `img/${n}.${ext}`;
|
||||
await writeFile(join(outDir, file), buf);
|
||||
kept.push({ ...c, n, file, kb: Math.round(buf.length / 1024) });
|
||||
}
|
||||
|
||||
// --- contact sheet ------------------------------------------------------------
|
||||
// Rendering an HTML grid and screenshotting it needs no extra dependency and copes
|
||||
// with mixed aspect ratios — one image the model reads instead of 24 separate ones.
|
||||
const PER_SHEET = 20;
|
||||
// A remainder of one or two tiles becoming its own 95%-empty page costs a second look for nothing,
|
||||
// and the doctrine promises reading a moodboard costs one look. Balance the sheets instead.
|
||||
const sheetCount = Math.max(1, Math.round(kept.length / PER_SHEET));
|
||||
const perSheet = Math.ceil(kept.length / sheetCount);
|
||||
const sheets = [];
|
||||
for (let s = 0; s * perSheet < kept.length; s++) {
|
||||
const slice = kept.slice(s * perSheet, (s + 1) * perSheet);
|
||||
const html = `<!doctype html><meta charset="utf-8"><style>
|
||||
body{margin:0;background:#111;font:13px/1.2 ui-monospace,monospace;color:#eee}
|
||||
.g{display:grid;grid-template-columns:repeat(${cols},1fr);gap:10px;padding:10px}
|
||||
figure{margin:0;background:#1c1c1c;border-radius:4px;overflow:hidden}
|
||||
img{display:block;width:100%;height:210px;object-fit:contain;background:#0a0a0a}
|
||||
figcaption{padding:4px 6px;color:#9a9a9a}
|
||||
b{color:#fff}
|
||||
</style><div class=g>${slice.map(k =>
|
||||
`<figure><img src="img/${k.file.split('/')[1]}"><figcaption><b>${k.n}</b> ${k.source}</figcaption></figure>`).join('')}</div>`;
|
||||
const f = join(outDir, `_sheet${s}.html`);
|
||||
await writeFile(f, html, 'utf8');
|
||||
const page = await ctx.newPage();
|
||||
await page.setViewportSize({ width: cols * 320, height: 1000 });
|
||||
await page.goto('file:///' + f.replace(/\\/g, '/'), { waitUntil: 'load', timeout: 20_000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
const name = sheetCount === 1 ? 'contact-sheet.png' : `contact-sheet-${s + 1}.png`;
|
||||
await page.screenshot({ path: join(outDir, name), fullPage: true });
|
||||
await page.close();
|
||||
sheets.push(name);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// --- index --------------------------------------------------------------------
|
||||
const md = [
|
||||
`# MOODBOARD — ${queries.map(q => `"${q}"`).join(' · ')}`,
|
||||
'',
|
||||
sheets.length
|
||||
? `**Look at ${sheets.map(s => `\`${s}\``).join(', ')} first** — every tile is numbered; refer to tiles by number below.`
|
||||
: '_no contact sheet: nothing downloaded._',
|
||||
'',
|
||||
'Reference images are for *direction* — palette, light, composition, texture, type energy.',
|
||||
'They are not assets: never ship a downloaded image, and never reproduce one composition.',
|
||||
'Name what you take from each tile you use, then throw the rest away.',
|
||||
'',
|
||||
'| # | source | kb | origin page |',
|
||||
'|---|---|---|---|',
|
||||
...kept.map(k => `| ${k.n} | ${k.source} | ${k.kb} | ${k.page ? `[link](${k.page})` : '—'} |`),
|
||||
'',
|
||||
'## Read (fill in)',
|
||||
'- dominant palette across the tiles you like: ',
|
||||
'- light / contrast character: ',
|
||||
'- composition move worth stealing: ',
|
||||
'- what to explicitly avoid from these: ',
|
||||
'',
|
||||
].join('\n');
|
||||
await writeFile(join(outDir, 'MOODBOARD.md'), md, 'utf8');
|
||||
|
||||
console.log(`\n=== moodboard ===`);
|
||||
console.log(`queries : ${queries.join(' | ')}`);
|
||||
console.log(`kept : ${kept.length} images (${sources.map(s => `${s}:${kept.filter(k => k.source === s).length}`).join(' ')})`);
|
||||
console.log(`sheets : ${sheets.map(s => join(outDir, s)).join('\n ') || '—'}`);
|
||||
console.log(`index : ${join(outDir, 'MOODBOARD.md')}`);
|
||||
process.exit(kept.length ? 0 : 1);
|
||||
128
optional-skills/creative/auteur/scripts/motionqa.mjs
Normal file
128
optional-skills/creative/auteur/scripts/motionqa.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* motionqa.mjs — motion/perf/audio gate for Tier-1 scenes
|
||||
* (scroll state-machine, audio-reactive, 2.5D composite, scrubbed video).
|
||||
* Screenshots (shoot.mjs) are blind to time; this scrolls the page under CPU throttle and asserts
|
||||
* FPS, long-tasks, audio gating, and WebGL console health.
|
||||
* Usage: node motionqa.mjs <url> [--headed] [--dpr 2] [--throttle 4] [--min-fps 50] [--max-longtask 50] [--json]
|
||||
* --headed is not optional for a real number on any GPU-dependent page, and a headless run
|
||||
* reporting 0 console errors is not a pass — a headed run has caught a 404 the headless one missed.
|
||||
* Measure a PRODUCTION BUILD: a dev server costs roughly double per frame (HMR client, unminified
|
||||
* bundles, no asset pipeline), so its numbers describe a page nobody will ever load.
|
||||
* ponytail: reuses the playwright install shoot.mjs already needs; no new deps.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const url = argv.find(a => !a.startsWith('-'));
|
||||
const opt = (name, def) => { const i = argv.indexOf('--' + name); return i >= 0 && argv[i + 1] ? +argv[i + 1] : def; };
|
||||
const jsonMode = argv.includes('--json');
|
||||
if (!url) {
|
||||
process.stderr.write('Usage: node motionqa.mjs <url> [--headed] [--throttle 4] [--min-fps 50] [--max-longtask 50] [--json]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const THROTTLE = opt('throttle', 4), MIN_FPS = opt('min-fps', 50), MAX_LT = opt('max-longtask', 50);
|
||||
const DPR = opt('dpr', 2) || 2; // `|| 2` so a typo'd --dpr becomes the honest default, not a NaN viewport
|
||||
const headed = argv.includes('--headed'); // headless chromium has NO GPU (software swiftshader) — WebGL FPS there is a floor, not the real number
|
||||
|
||||
const browser = await chromium.launch({ headless: !headed });
|
||||
// 1440x900 @2x = 5.2 megapixels: a retina laptop, which is what a page like this gets judged on.
|
||||
// The default DPR 1 renders 1.3MP, and fullscreen post-processing (bloom, DoF, grain, any full-frame
|
||||
// shader) costs per pixel — so at DPR 1 the expensive part of the frame is a quarter of its real cost
|
||||
// and the gate says 60fps about a page that stutters on the reviewer's MacBook. Fillrate is the budget,
|
||||
// not geometry. --dpr 1 is for reproducing an old measurement, not for judging a scene.
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: DPR });
|
||||
const page = await context.newPage();
|
||||
const consoleErrors = [];
|
||||
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||||
page.on('pageerror', e => consoleErrors.push(String(e)));
|
||||
|
||||
await page.goto(url, { waitUntil: 'load' });
|
||||
|
||||
// audio/video must be silent on load — sound starts on a user gesture, never before
|
||||
const audioAutoplaying = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('audio,video')].some(a => !a.paused && !a.muted && a.volume > 0));
|
||||
const hasCanvas = await page.evaluate(() => !!document.querySelector('canvas'));
|
||||
|
||||
// A dev server costs roughly 2x per frame vs its own production build (HMR client, unminified
|
||||
// bundles, on-the-fly transforms, no image pipeline). That direction only ever produces FALSE
|
||||
// FAILURES — but a false FAIL sends you optimizing a scene that was already fast, which is how a
|
||||
// motion budget gets cut for nothing. Detect the three common dev clients and say so out loud.
|
||||
const devServer = await page.evaluate(() => !!(
|
||||
document.querySelector('script[src*="/@vite/client"]') ||
|
||||
window.__vite_plugin_react_preamble_installed__ ||
|
||||
window.webpackHotUpdate || window.__webpack_hmr ||
|
||||
window.next?.router?.isDevBuild || window.__NEXT_DATA__?.buildId === 'development'
|
||||
));
|
||||
|
||||
// warm up: let CDN modules load, shaders compile, first textures upload — we measure STEADY STATE,
|
||||
// not the cold-start frame. (Headless chromium renders WebGL via software swiftshader, so for WebGL
|
||||
// scenes also prefer a headed/GPU run or a lower --throttle; software raster is not representative.)
|
||||
await page.evaluate(() => new Promise(r => {
|
||||
scrollTo(0, document.body.scrollHeight);
|
||||
setTimeout(() => { scrollTo(0, 0); setTimeout(r, 500); }, 900);
|
||||
}));
|
||||
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: THROTTLE });
|
||||
|
||||
// `buffered: true` replays every long task since navigation — bundle parse, shader compile,
|
||||
// PMREM prefilter — into a number the rubric defines as "during the scroll". Observe live only,
|
||||
// after warm-up, and report load-time separately so both are visible and neither is a false FAIL.
|
||||
await page.evaluate(() => {
|
||||
window.__lt = 0; window.__ltLoad = 0;
|
||||
try {
|
||||
new PerformanceObserver(l => { for (const e of l.getEntries()) window.__ltLoad = Math.max(window.__ltLoad, e.duration); })
|
||||
.observe({ type: 'longtask', buffered: true });
|
||||
new PerformanceObserver(l => { for (const e of l.getEntries()) window.__lt = Math.max(window.__lt, e.duration); })
|
||||
.observe({ type: 'longtask' });
|
||||
} catch { /* longtask unsupported */ }
|
||||
});
|
||||
|
||||
// slow full-page scroll over ~4s, sampling rAF deltas for the worst (min) FPS
|
||||
const minFps = await page.evaluate(() => new Promise(res => {
|
||||
let last = performance.now(), min = 999, end = last + 4000, seen = 0;
|
||||
(function tick(t) {
|
||||
const d = t - last; last = t;
|
||||
if (d > 0) { min = Math.min(min, 1000 / d); seen++; }
|
||||
scrollBy(0, innerHeight / 60);
|
||||
t < end ? requestAnimationFrame(tick) : res(seen > 10 ? min : 60);
|
||||
})(performance.now());
|
||||
}));
|
||||
const maxLongTask = await page.evaluate(() => window.__lt || 0);
|
||||
const loadLongTask = await page.evaluate(() => window.__ltLoad || 0);
|
||||
await browser.close();
|
||||
|
||||
const fails = [], advisories = [];
|
||||
// headless chromium has no GPU → software swiftshader inflates WebGL FPS + raster long-tasks.
|
||||
// Under headless+canvas those two are ADVISORIES (run --headed / on-device for a real number); the
|
||||
// rest (audio gating, console/WebGL errors) are GPU-independent and stay hard fails.
|
||||
const softWebgl = hasCanvas && !headed;
|
||||
const perf = (cond, msg) => { if (cond) (softWebgl ? advisories : fails).push(msg + (softWebgl ? ` [headless software ${hasCanvas ? 'WebGL' : 'rasterization'} — run --headed for a real number]` : '')); };
|
||||
perf(minFps < MIN_FPS, `minFps ${minFps.toFixed(0)} < ${MIN_FPS} @${THROTTLE}x throttle`);
|
||||
perf(maxLongTask > MAX_LT, `long task ${maxLongTask.toFixed(0)}ms > ${MAX_LT}ms`);
|
||||
if (audioAutoplaying) fails.push('audio/video playing with sound on load (must be gesture-gated)');
|
||||
const ctxErr = consoleErrors.filter(e => /WebGL|Context Lost|Too many active/i.test(e));
|
||||
if (ctxErr.length) fails.push(`WebGL context error in console: ${ctxErr[0].slice(0, 80)}`);
|
||||
|
||||
// Load-time long tasks are real information (bundle parse, shader compile, PMREM prefilter) but
|
||||
// they are not what the rubric row asks about, so they are reported, never failed on.
|
||||
if (loadLongTask > MAX_LT) advisories.push(`load-time long task ${loadLongTask.toFixed(0)}ms (bundle parse / shader compile) — not a scroll fail, but it delays interactivity`);
|
||||
if (devServer) advisories.push('measured against a DEV SERVER — a production build runs roughly 2x faster per frame; re-measure on the built output before you cut anything from the scene');
|
||||
const megapixels = +((1440 * 900 * DPR * DPR) / 1e6).toFixed(1);
|
||||
const summary = {
|
||||
minFps: +minFps.toFixed(0), maxLongTask: +maxLongTask.toFixed(0), loadLongTask: +loadLongTask.toFixed(0),
|
||||
dpr: DPR, megapixels, devServer,
|
||||
audioAutoplaying, consoleErrors: consoleErrors.length, headed, softWebgl, fails, advisories,
|
||||
};
|
||||
if (jsonMode) {
|
||||
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
||||
} else {
|
||||
// DPR belongs in the headline number: "minFps 57" means nothing without the pixel count behind it,
|
||||
// and this line is what gets quoted verbatim into the QA sheet.
|
||||
process.stdout.write(`motionqa: minFps ${summary.minFps} @${THROTTLE}x CPU - 1440x900@${DPR}x (${megapixels}MP) - scroll long-task ${summary.maxLongTask}ms (load ${summary.loadLongTask}ms) - audio ${audioAutoplaying ? 'AUTOPLAYING(!)' : 'gesture-gated'} - console errors ${consoleErrors.length}${softWebgl ? ' [headless/software-WebGL]' : ''}${devServer ? ' [DEV SERVER]' : ''}\n`);
|
||||
for (const a of advisories) process.stdout.write(`ADVISORY ${a}\n`);
|
||||
for (const f of fails) process.stdout.write(`FAIL ${f}\n`);
|
||||
if (!fails.length) process.stdout.write(advisories.length ? 'PASS (perf advisories — verify headed)\n' : 'PASS\n');
|
||||
}
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
402
optional-skills/creative/auteur/scripts/refscout.mjs
Normal file
402
optional-skills/creative/auteur/scripts/refscout.mjs
Normal file
@@ -0,0 +1,402 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* refscout.mjs — reference scouting: find live award-level sites, then take them apart.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/refscout.mjs <url> [<url>...] analyse specific sites
|
||||
* node scripts/refscout.mjs --from awwwards harvest the current gallery
|
||||
* node scripts/refscout.mjs --from awwwards:scrolling --limit 6
|
||||
* node scripts/refscout.mjs --from awwwards --search "coffee brand" --limit 5
|
||||
*
|
||||
* Options:
|
||||
* --limit N how many sites (default 6, hard cap 15)
|
||||
* --out DIR output dir (default design/refs)
|
||||
* --shots N screenshots per site (default 3: hero + 2 scroll stops)
|
||||
* --no-shots fingerprint only, no browser screenshots
|
||||
* --width N viewport width for shots (default 1440)
|
||||
*
|
||||
* Writes DIR/REFERENCES.md (read this), DIR/refs.json, DIR/shots/*.png.
|
||||
* Exits 0 if at least one site was profiled, 1 otherwise.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { resolve, join } from 'path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length || args.includes('--help')) {
|
||||
console.log(`refscout.mjs — scout live website references and fingerprint their mechanics
|
||||
|
||||
node scripts/refscout.mjs <url> [<url>...]
|
||||
node scripts/refscout.mjs --from awwwards[:<tag>] [--search "<text>"] [--limit 6]
|
||||
|
||||
--limit N --out DIR --shots N --no-shots --width N`);
|
||||
process.exit(0);
|
||||
}
|
||||
const get = (f, d) => { const i = args.indexOf(f); return i !== -1 ? args[i + 1] : d; };
|
||||
const has = f => args.includes(f);
|
||||
|
||||
const limit = Math.min(parseInt(get('--limit', '6'), 10) || 6, 15);
|
||||
const outDir = resolve(get('--out', 'design/refs'));
|
||||
const shotsN = has('--no-shots') ? 0 : (parseInt(get('--shots', '3'), 10) || 3);
|
||||
const width = parseInt(get('--width', '1440'), 10) || 1440;
|
||||
const from = get('--from', null);
|
||||
const search = get('--search', null);
|
||||
const urls = args.filter(a => /^https?:\/\//.test(a));
|
||||
|
||||
let chromium;
|
||||
try { ({ chromium } = await import('playwright')); }
|
||||
catch { console.error('playwright not found. Install: npm i -D playwright && npx playwright install chromium'); process.exit(2); }
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
|
||||
/**
|
||||
* Award-level sites are heavy streaming-SSR / WebGL pages: 'load' and 'networkidle'
|
||||
* frequently never fire. Commit + a timed settle is the only navigation that survives them.
|
||||
* Returns false when DOMContentLoaded never fired — those pages need freeze() before capture.
|
||||
*/
|
||||
async function open(page, url, settle = 7000, onStatus) {
|
||||
const resp = await page.goto(url, { waitUntil: 'commit', timeout: 30_000 });
|
||||
onStatus?.(resp?.status?.() ?? 0);
|
||||
const dcl = await page.waitForLoadState('domcontentloaded', { timeout: 12_000 }).then(() => true).catch(() => false);
|
||||
await page.waitForTimeout(dcl ? settle : settle + 5000); // no DCL → give hydration longer
|
||||
return dcl;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page whose HTML stream never closes never yields a compositor frame either, so every
|
||||
* screenshot on it times out (measured on basement.studio and lusion.co). `window.stop()`
|
||||
* ends the stream and capture drops to ~40ms. Call it only AFTER the settle wait — stopping
|
||||
* early aborts the CSS/JS the fingerprint needs and leaves you reading Times-New-Roman
|
||||
* defaults off a half-loaded page.
|
||||
*/
|
||||
async function freeze(page, dcl) {
|
||||
if (!dcl) await page.evaluate(() => window.stop()).catch(() => {});
|
||||
}
|
||||
|
||||
// --- library signatures, matched against the concatenated JS the page actually loaded ---
|
||||
// Bundlers hide globals (window.gsap is empty on virtually every modern site), but
|
||||
// minifiers keep string literals and class names, so the bundle text still tells the truth.
|
||||
const LIBS = [
|
||||
['GSAP', /gsap\.registerPlugin|GreenSock|_gsap|gsap\.timeline|gsap\.to\(/],
|
||||
['ScrollTrigger', /ScrollTrigger/],
|
||||
['ScrollSmoother', /ScrollSmoother/],
|
||||
['SplitText', /SplitText|SplitType/],
|
||||
['Lenis', /\blenis\b|new Lenis|lenis-smooth/i],
|
||||
['Locomotive', /locomotive-scroll|LocomotiveScroll|has-scroll-smooth/],
|
||||
['three.js', /THREE\.WebGLRenderer|WebGLRenderer|three\.module|\bTHREE\b/],
|
||||
['R3F', /react-three|@react-three\/fiber|useFrame/],
|
||||
['OGL', /\bogl\b|new Renderer\(\{.*dpr/],
|
||||
['PixiJS', /PIXI\.|pixi\.js/],
|
||||
['Framer Motion', /framer-motion|motion-dom|framerAppearId/],
|
||||
['Motion One', /@motionone|motion\.dev/],
|
||||
['Barba', /barba\.init|@barba|new Barba/],
|
||||
['Swup', /new Swup|@swup/],
|
||||
['Matter.js', /Matter\.Engine/],
|
||||
['Rive', /@rive-app|rive\.wasm/],
|
||||
['Lottie', /lottie-web|bodymovin/],
|
||||
['Spline', /splinetool|spline-viewer/],
|
||||
['Theatre.js', /@theatre\/core/],
|
||||
['Swiper', /new Swiper|swiper-bundle/],
|
||||
['Splitting', /Splitting\(/],
|
||||
];
|
||||
|
||||
function classifyPeak(f) {
|
||||
const out = [];
|
||||
if (f.videos.some(v => !v.autoplay && v.preload !== 'none') && f.scrollRatio > 4) out.push('likely scroll-scrubbed video');
|
||||
if (f.webgl && f.scrollRatio > 3) out.push('WebGL scene driven by scroll');
|
||||
else if (f.webgl) out.push('WebGL hero');
|
||||
if (f.pinSpacers > 0) out.push(`${f.pinSpacers} pinned scene(s) (GSAP ScrollTrigger)`);
|
||||
if (f.stickies > 2) out.push(`${f.stickies} sticky layers (stack/pin effect)`);
|
||||
if (f.cssTimeline) out.push('CSS scroll-driven animations (animation-timeline)');
|
||||
if (f.libs.includes('Lenis') || f.libs.includes('Locomotive') || f.libs.includes('ScrollSmoother')) out.push('smoothed/virtualised scroll');
|
||||
if (f.mixBlend > 0) out.push(`${f.mixBlend} mix-blend-mode layer(s)`);
|
||||
if (f.cursorNone) out.push('custom cursor');
|
||||
if (f.videos.length && !out.length) out.push('looping video texture');
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fingerprint(ctx, url) {
|
||||
const page = await ctx.newPage();
|
||||
const js = [];
|
||||
let jsBytes = 0;
|
||||
page.on('response', async res => {
|
||||
if (jsBytes > 4_000_000) return;
|
||||
const ct = res.headers()['content-type'] || '';
|
||||
if (!/javascript|ecmascript/.test(ct)) return;
|
||||
try { const t = await res.text(); jsBytes += t.length; js.push(t); } catch {}
|
||||
});
|
||||
|
||||
let httpStatus = 0;
|
||||
try {
|
||||
const dcl = await open(page, url, 8000, s => { httpStatus = s; });
|
||||
// one scroll pass so pinning / lazy scenes actually initialise before we look
|
||||
await page.evaluate(() => window.scrollTo({ top: innerHeight * 1.6, behavior: 'instant' }));
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const dom = await page.evaluate(() => {
|
||||
const cs = getComputedStyle;
|
||||
// A blocked / challenge / download navigation can leave us with no body at all.
|
||||
// Bail with an explicitly empty reading rather than throwing away the whole site.
|
||||
if (!document.body) return { title: (document.title || '').slice(0, 90), sheets: 0, readyState: document.readyState, globals: [], htmlClass: '', pinSpacers: 0, stickies: 0, canvases: 0, webgl: false, videos: [], mixBlend: 0, cursorNone: false, cssTimeline: false, fontFaces: [], display: null, bodyFont: '', bg: '', palette: [], sections: 0, scrollH: 0, vh: window.innerHeight, framework: 'unknown' };
|
||||
const all = [...document.querySelectorAll('*')].slice(0, 4000);
|
||||
const canvases = [...document.querySelectorAll('canvas')];
|
||||
let cssTimeline = false;
|
||||
const faces = new Set();
|
||||
for (const ss of document.styleSheets) {
|
||||
try {
|
||||
for (const r of ss.cssRules) {
|
||||
const t = r.cssText || '';
|
||||
if (t.includes('animation-timeline') || t.includes('view-timeline')) cssTimeline = true;
|
||||
if (r.style && r.constructor.name === 'CSSFontFaceRule') faces.add((r.style.fontFamily || '').replace(/["']/g, ''));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Chromium reports modern colour syntaxes verbatim (`lab(48.5 0 0)`, `oklch(...)`), which is
|
||||
// unreadable as a palette. Round-trip every value through a canvas so the report shows a
|
||||
// colour a human can picture, keeping the original alongside.
|
||||
const cx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
||||
const toHex = v => {
|
||||
try {
|
||||
cx.clearRect(0, 0, 1, 1); cx.fillStyle = '#000'; cx.fillStyle = v;
|
||||
cx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, a] = cx.getImageData(0, 0, 1, 1).data;
|
||||
const hex = '#' + [r, g, b].map(n => n.toString(16).padStart(2, '0')).join('');
|
||||
return a < 250 ? `${hex}@${(a / 255).toFixed(2)}` : hex;
|
||||
} catch { return null; }
|
||||
};
|
||||
// sample the real palette from what is painted, not from the token file
|
||||
const colors = {};
|
||||
for (const el of all) {
|
||||
const s = cs(el);
|
||||
for (const c of [s.backgroundColor, s.color]) {
|
||||
if (!c || c === 'rgba(0, 0, 0, 0)') continue;
|
||||
colors[c] = (colors[c] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const palette = Object.entries(colors).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([c, n]) => {
|
||||
const hex = toHex(c);
|
||||
return hex && !/^(#|rgb)/.test(c) ? `${hex} (${c}) ×${n}` : `${hex || c} ×${n}`;
|
||||
});
|
||||
// The display face is the biggest thing actually painted, not the first h1 — a hidden or
|
||||
// fallback-styled heading reports whatever the CSS cascade left there and will happily
|
||||
// claim an awwwards winner ships Inter. Measure instead: largest visible rendered text.
|
||||
let display = null, bestPx = 0;
|
||||
for (const e of all) {
|
||||
if (e.children.length || (e.textContent || '').trim().length < 2) continue;
|
||||
const s = cs(e);
|
||||
if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity === 0) continue;
|
||||
const r = e.getBoundingClientRect();
|
||||
if (r.width < 4 || r.height < 4) continue;
|
||||
const px = parseFloat(s.fontSize) || 0;
|
||||
if (px > bestPx) { bestPx = px; display = { font: s.fontFamily.slice(0, 60), px: Math.round(px), weight: s.fontWeight, sample: e.textContent.trim().replace(/\s+/g, ' ').slice(0, 28) }; }
|
||||
}
|
||||
return {
|
||||
title: (document.title || '').slice(0, 90),
|
||||
sheets: document.styleSheets.length,
|
||||
readyState: document.readyState,
|
||||
globals: ['gsap', 'ScrollTrigger', 'Lenis', 'THREE', 'locomotiveScroll', 'barba', 'Swup', 'PIXI'].filter(k => k in window),
|
||||
htmlClass: document.documentElement.className.slice(0, 100),
|
||||
pinSpacers: document.querySelectorAll('.pin-spacer').length,
|
||||
stickies: all.filter(e => cs(e).position === 'sticky').length,
|
||||
canvases: canvases.length,
|
||||
webgl: canvases.some(c => { try { return !!(c.getContext('webgl2') || c.getContext('webgl')); } catch { return false; } }),
|
||||
videos: [...document.querySelectorAll('video')].slice(0, 4).map(v => ({
|
||||
autoplay: v.autoplay, loop: v.loop, muted: v.muted, preload: v.preload,
|
||||
src: (v.currentSrc || v.src || '').split('/').pop().slice(0, 40),
|
||||
})),
|
||||
mixBlend: all.filter(e => cs(e).mixBlendMode !== 'normal').length,
|
||||
cursorNone: cs(document.body).cursor === 'none',
|
||||
cssTimeline,
|
||||
fontFaces: [...faces].filter(Boolean).slice(0, 8),
|
||||
display,
|
||||
bodyFont: cs(document.body).fontFamily.slice(0, 70),
|
||||
bg: cs(document.body).backgroundColor,
|
||||
palette,
|
||||
sections: document.querySelectorAll('section, main > div, [class*="section"]').length,
|
||||
scrollH: document.documentElement.scrollHeight,
|
||||
vh: window.innerHeight,
|
||||
framework:
|
||||
document.querySelector('[data-framer-name],[data-framer-root]') ? 'Framer' :
|
||||
document.querySelector('[data-wf-page]') ? 'Webflow' :
|
||||
document.querySelector('#__next,[id^=__next]') ? 'Next.js' :
|
||||
document.querySelector('astro-island,[data-astro-cid]') ? 'Astro' :
|
||||
document.querySelector('[data-sveltekit-preload-data]') ? 'SvelteKit' :
|
||||
document.querySelector('#app,[data-v-app]') ? 'Vue/Nuxt' :
|
||||
document.querySelector('[data-shopify],[id^=shopify]') ? 'Shopify' : 'unknown',
|
||||
};
|
||||
});
|
||||
|
||||
const blob = js.join('\n');
|
||||
const libs = LIBS.filter(([, re]) => re.test(blob)).map(([n]) => n);
|
||||
for (const g of dom.globals) if (!libs.some(l => l.toLowerCase().includes(g.toLowerCase().slice(0, 5)))) libs.push(`${g} (global)`);
|
||||
|
||||
const f = { url, ...dom, libs, jsBytes, scrollRatio: +(dom.scrollH / Math.max(dom.vh, 1)).toFixed(1) };
|
||||
f.mechanics = classifyPeak(f);
|
||||
// Some sites (Vercel-edge streamers, bot-walled studios) hand a headless client the SSR
|
||||
// HTML and then never deliver CSS or JS. Everything we'd read off that page — fonts,
|
||||
// palette, libs — is browser default, i.e. a confident lie. Detect it and say so.
|
||||
// Chrome's unstyled body is Times New Roman with zero web fonts loaded; a real site that
|
||||
// merely leaves body at the default still has @font-face rules from its stylesheet.
|
||||
const unstyled = /^"?Times New Roman/.test(dom.bodyFont) && !dom.fontFaces.length;
|
||||
// An error page has a title, a palette and a page shape, and will happily be written up as a
|
||||
// reference with a blank `steal:` line waiting to be filled. It is not a design.
|
||||
f.httpStatus = httpStatus;
|
||||
f.errorPage = httpStatus >= 400 || /^\s*(4\d\d|5\d\d)|bad gateway|not found|forbidden|service unavailable|internal server error/i.test(dom.title || '');
|
||||
f.thin = dom.sheets === 0 || unstyled || f.errorPage;
|
||||
|
||||
// Screenshots are best-effort on purpose: a capture that fails must never throw away
|
||||
// a fingerprint we already paid for.
|
||||
f.shots = [];
|
||||
// A site that never got its CSS has nothing to photograph: take one frame as evidence the
|
||||
// capture failed, not a full journey of identical unstyled pages.
|
||||
const n = f.thin ? Math.min(1, shotsN) : shotsN;
|
||||
if (n > 0) {
|
||||
await freeze(page, dcl);
|
||||
const slug = url.replace(/^https?:\/\//, '').replace(/[^\w.-]+/g, '_').slice(0, 40);
|
||||
const max = Math.max(0, dom.scrollH - dom.vh);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const y = n === 1 ? 0 : Math.round((i / (n - 1)) * max);
|
||||
const name = `${slug}-${String(i).padStart(2, '0')}.png`;
|
||||
try {
|
||||
await page.evaluate(t => window.scrollTo({ top: t, behavior: 'instant' }), y);
|
||||
await page.waitForTimeout(1600);
|
||||
await page.screenshot({ path: join(outDir, 'shots', name), timeout: 20_000 });
|
||||
f.shots.push(`shots/${name}`);
|
||||
} catch (e) {
|
||||
(f.shotErrors ??= []).push(`stop ${i}: ${e.message.split('\n')[0]}`);
|
||||
}
|
||||
}
|
||||
f.shotsWanted = n;
|
||||
}
|
||||
return f;
|
||||
} catch (e) {
|
||||
return { url, error: e.message.split('\n')[0] };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
// --- gallery harvesting -------------------------------------------------------
|
||||
// awwwards is the one gallery whose listing AND detail pages both render reliably headless
|
||||
// and expose the outbound site URL. Other galleries either need JS we can't wait out or bury
|
||||
// the real URL behind affiliate redirects — for those, find URLs with WebSearch and pass them
|
||||
// positionally.
|
||||
async function harvestAwwwards(ctx, tag, text, n) {
|
||||
const page = await ctx.newPage();
|
||||
const base = 'https://www.awwwards.com/websites/';
|
||||
const url = text ? `${base}?text=${encodeURIComponent(text)}` : tag ? `${base}${tag}/` : base;
|
||||
const found = [];
|
||||
try {
|
||||
await open(page, url, 6000);
|
||||
const slugs = await page.evaluate(() =>
|
||||
[...new Set([...document.querySelectorAll('a[href*="/sites/"]')].map(a => a.getAttribute('href')).filter(h => h && h.startsWith('/sites/')))]);
|
||||
if (!slugs.length) console.error(`[refscout] awwwards listing returned no cards for ${url}`);
|
||||
for (const slug of slugs.slice(0, n)) {
|
||||
try {
|
||||
await open(page, 'https://www.awwwards.com' + slug, 3500);
|
||||
const d = await page.evaluate(() => {
|
||||
const visit = [...document.querySelectorAll('a[href^="http"]')]
|
||||
.find(a => /visit site/i.test(a.innerText) || /button/.test(a.className));
|
||||
const ext = visit?.href || [...document.querySelectorAll('a[href^="http"]')]
|
||||
.map(a => a.href).find(h => !/awwwards|twitter|x\.com|facebook|instagram|linkedin|behance|dribbble/i.test(h));
|
||||
return {
|
||||
site: ext,
|
||||
name: (document.querySelector('h1')?.innerText || document.title).trim().slice(0, 60),
|
||||
};
|
||||
});
|
||||
if (d.site) found.push({ ...d, via: 'awwwards' + slug });
|
||||
} catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[refscout] awwwards harvest failed: ${e.message.split('\n')[0]}`);
|
||||
} finally { await page.close(); }
|
||||
return found;
|
||||
}
|
||||
|
||||
// --- run ----------------------------------------------------------------------
|
||||
await mkdir(join(outDir, 'shots'), { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ userAgent: UA, viewport: { width, height: 900 }, ignoreHTTPSErrors: true });
|
||||
|
||||
let targets = urls.map(u => ({ site: u, name: null, via: 'direct' }));
|
||||
if (from) {
|
||||
const [gallery, tag] = from.split(':');
|
||||
if (gallery === 'awwwards') targets = targets.concat(await harvestAwwwards(ctx, tag, search, limit));
|
||||
else console.error(`[refscout] unknown gallery "${gallery}" — supported: awwwards. Pass URLs directly instead (find them with WebSearch).`);
|
||||
}
|
||||
targets = targets.slice(0, limit);
|
||||
|
||||
if (!targets.length) {
|
||||
console.error('[refscout] no targets. Pass URLs, or --from awwwards.');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const t of targets) {
|
||||
process.stderr.write(`[refscout] profiling ${t.site} …\n`);
|
||||
const f = await fingerprint(ctx, t.site);
|
||||
results.push({ ...t, ...f });
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
// --- report -------------------------------------------------------------------
|
||||
const ok = results.filter(r => !r.error);
|
||||
const lines = [];
|
||||
lines.push('# REFERENCES — scouted ' + new Date().toISOString().slice(0, 10));
|
||||
lines.push('');
|
||||
lines.push('Mechanics, not skins. For each reference name the ONE idea you are taking and how it');
|
||||
lines.push('changes for this brand. Copying a reference wholesale is slop with better taste.');
|
||||
lines.push('');
|
||||
lines.push('| # | site | stack detected | page shape | mechanics |');
|
||||
lines.push('|---|---|---|---|---|');
|
||||
ok.forEach((r, i) => {
|
||||
const cell = r.errorPage ? '_error page — not a design_' : r.thin ? '_no capture — see below_' : null;
|
||||
lines.push(`| ${i + 1} | [${r.name || r.url.replace(/^https?:\/\//, '')}](${r.url}) | ${cell ?? (r.libs.slice(0, 4).join(', ') || '—')} | ${cell ?? `${r.scrollRatio}× vh, ${r.sections} sections`} | ${cell ?? (r.mechanics.slice(0, 3).join('; ') || '—')} |`);
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
ok.forEach((r, i) => {
|
||||
lines.push(`## ${i + 1}. ${r.name || r.title || r.url}`);
|
||||
lines.push(`- url: ${r.url}${r.via && r.via !== 'direct' ? ` · via ${r.via}` : ''}`);
|
||||
if (r.errorPage) {
|
||||
lines.push(`- ⚠ **NOT A DESIGN** — this URL returned an error page${r.httpStatus >= 400 ? ` (HTTP ${r.httpStatus})` : ''}, title "${r.title}". Nothing here is a reference. Drop it, or find the site's real address.`);
|
||||
lines.push('');
|
||||
return;
|
||||
}
|
||||
if (r.thin) {
|
||||
lines.push(`- ⚠ **NO CAPTURE** — this site served the headless browser bare HTML and never delivered its CSS/JS (${r.sheets} stylesheets, readyState \`${r.readyState}\`). Any font, colour or stack reading here would be the browser's defaults, so none is reported. Open the URL yourself, or describe it from the gallery write-up — and check the page title above, a dead link from the gallery lands here too.`);
|
||||
if (r.shots.length) lines.push(`- unstyled shots (evidence that the capture failed, not a look at the design): ${r.shots.map(s => `\`${s}\``).join(' ')}`);
|
||||
lines.push('');
|
||||
return;
|
||||
}
|
||||
lines.push(`- stack: ${r.libs.join(', ') || 'nothing detected'}${r.framework !== 'unknown' ? ` · built with ${r.framework}` : ''}`);
|
||||
lines.push(`- mechanics: ${r.mechanics.join('; ') || '—'}`);
|
||||
lines.push(`- page: ${r.scrollRatio}× viewport tall · ${r.sections} sections · ${r.canvases} canvas${r.webgl ? ' (WebGL live)' : ''} · ${r.videos.length} video`);
|
||||
lines.push(`- type: display \`${r.display ? `${r.display.font} ${r.display.px}px/${r.display.weight}` : '?'}\`${r.display ? ` (largest painted text: "${r.display.sample}")` : ''} · body \`${r.bodyFont}\`${r.fontFaces.length ? ` · @font-face: ${r.fontFaces.join(', ')}` : ''}`);
|
||||
lines.push(`- palette (most painted): ${r.palette.join(' · ')}`);
|
||||
if (r.shots.length) lines.push(`- shots: ${r.shots.map(s => `\`${s}\``).join(' ')} ← look at the hero; look at the rest only if you take a steal from this one`);
|
||||
if (r.shotErrors?.length) lines.push(`- ⚠ ${r.shotErrors.length} of ${r.shotsWanted} shots failed (${r.shotErrors.join('; ')}) — the fingerprint above still holds.`);
|
||||
lines.push('- **steal:** _<one mechanic, one line — fill this in>_');
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
const failed = results.filter(r => r.error);
|
||||
if (failed.length) {
|
||||
lines.push('## Not reached');
|
||||
failed.forEach(r => lines.push(`- ${r.url} — ${r.error}`));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
await writeFile(join(outDir, 'REFERENCES.md'), lines.join('\n'), 'utf8');
|
||||
await writeFile(join(outDir, 'refs.json'), JSON.stringify(results, null, 2), 'utf8');
|
||||
|
||||
console.log(`\n=== refscout ===`);
|
||||
console.log(`profiled : ${ok.filter(r => !r.thin).length}/${results.length} usable`);
|
||||
if (ok.some(r => r.thin)) console.log(`no-css : ${ok.filter(r => r.thin).length} (site withheld CSS/JS from headless — reported as unknown, not guessed)`);
|
||||
if (failed.length) console.log(`failed : ${failed.length} — ${failed.map(r => r.url).join(', ')}`);
|
||||
const lostShots = ok.reduce((n, r) => n + (r.shotErrors?.length || 0), 0);
|
||||
if (lostShots) console.log(`shots : ${lostShots} capture(s) failed — flagged per site in the report`);
|
||||
console.log(`report : ${join(outDir, 'REFERENCES.md')}`);
|
||||
console.log(`shots : ${ok.reduce((n, r) => n + r.shots.length, 0)} in ${join(outDir, 'shots')}`);
|
||||
process.exit(ok.length ? 0 : 1);
|
||||
211
optional-skills/creative/auteur/scripts/shoot.mjs
Normal file
211
optional-skills/creative/auteur/scripts/shoot.mjs
Normal file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* shoot.mjs — screenshot journey for scroll-driven websites
|
||||
* Usage: node shoot.mjs <url> [--stops 7] [--out shots] [--breakpoints 390,768,1440] [--dpr 1] [--reduced-motion] [--full]
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve, join } from 'path';
|
||||
|
||||
// --- CLI parsing ---
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length || args[0] === '--help') {
|
||||
console.log('Usage: node shoot.mjs <url> [<url>...] [--stops 7] [--out shots] [--breakpoints 390,768,1440] [--dpr 1] [--reduced-motion] [--full]');
|
||||
console.log('Writes: <out>/bp<width>-stop<NN>.png (also bp<width>-rm-stop<NN>.png with --reduced-motion, bp<width>-full.png with --full)');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Multi-screen products need every route shot, not a sample — take all leading non-flag args.
|
||||
const rawTargets = [];
|
||||
for (const a of args) { if (a.startsWith('--')) break; rawTargets.push(a); }
|
||||
const get = (flag, def) => {
|
||||
const i = args.indexOf(flag);
|
||||
return i !== -1 ? args[i + 1] : def;
|
||||
};
|
||||
const has = flag => args.includes(flag);
|
||||
|
||||
const stopsCount = parseInt(get('--stops', '7'), 10);
|
||||
const outDir = resolve(get('--out', 'shots'));
|
||||
const bpArg = get('--breakpoints', '390,768,1440');
|
||||
const reducedMotion = has('--reduced-motion');
|
||||
const fullPage = has('--full');
|
||||
// Layout bugs read fine at 1x, so that stays the default (30 frames at 2x cost 4x the bytes for
|
||||
// nothing). Shoot --dpr 2 when the question is rendering rather than layout: hairline borders that
|
||||
// vanish, text that only looks crisp at 1x, moiré in a fine pattern, an image whose real resolution
|
||||
// is half what the layout claims.
|
||||
const dpr = parseFloat(get('--dpr', '1')) || 1;
|
||||
|
||||
const breakpoints = bpArg.split(',').map(Number);
|
||||
const heights = { 390: 844, 768: 1024, 1440: 900 };
|
||||
const getHeight = w => heights[w] ?? 900;
|
||||
|
||||
// Convert local paths → file:// URLs
|
||||
const toUrl = u => (u.startsWith('http://') || u.startsWith('https://') || u.startsWith('file://'))
|
||||
? u
|
||||
: 'file:///' + resolve(u).replace(/\\/g, '/');
|
||||
const targets = rawTargets.map(toUrl);
|
||||
// One route keeps the historical bp<width>-stop<NN>.png names; several get a per-route prefix.
|
||||
// Truncating the tail collides for routes that share a long suffix and silently overwrites frames,
|
||||
// so keep the distinguishing end AND an index that cannot repeat.
|
||||
const labelFor = (u, i) => targets.length === 1 ? '' :
|
||||
`${String(i).padStart(2, '0')}_${(u.replace(/^https?:\/\//, '').replace(/^file:\/\/\//, '')
|
||||
.replace(/[^\w]+/g, '_').replace(/^_+|_+$/g, '').slice(-24)) || 'r'}-`;
|
||||
|
||||
// --- Dynamic import playwright with friendly error ---
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await import('playwright'));
|
||||
} catch {
|
||||
console.error('playwright not found. Install: npm i -D playwright && npx playwright install chromium');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// --- Heuristic: is this screenshot likely blank? ---
|
||||
// Checks byte-entropy of the PNG buffer — a nearly-uniform image compresses to
|
||||
// a much smaller ratio vs a varied one. ponytail: approximate, not pixel-perfect.
|
||||
function likelyBlank(buf) {
|
||||
if (buf.length === 0) return true;
|
||||
// PNG files with almost all identical pixels compress extremely well.
|
||||
// Heuristic: if buffer is <2KB for any resolution it's suspicious.
|
||||
const VERY_SMALL = 2048;
|
||||
if (buf.length < VERY_SMALL) return true;
|
||||
// Sample 512 bytes spread across the buffer, count unique byte values.
|
||||
const samples = new Set();
|
||||
const step = Math.max(1, Math.floor(buf.length / 512));
|
||||
for (let i = 33; i < buf.length; i += step) samples.add(buf[i]); // skip PNG header
|
||||
return samples.size < 8; // fewer than 8 distinct byte values → very uniform
|
||||
}
|
||||
|
||||
// --- Zero-pad helper ---
|
||||
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
||||
|
||||
// --- Main ---
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
let totalWritten = 0;
|
||||
let anySucceeded = false;
|
||||
const summary = [];
|
||||
|
||||
for (const [ti, pageUrl] of targets.entries()) {
|
||||
const prefix = labelFor(pageUrl, ti);
|
||||
for (const width of breakpoints) {
|
||||
const vpHeight = getHeight(width);
|
||||
const bpErrors = [];
|
||||
const bpFiles = [];
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: vpHeight },
|
||||
deviceScaleFactor: dpr,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Collect page console errors
|
||||
page.on('console', msg => { if (msg.type() === 'error') bpErrors.push(msg.text()); });
|
||||
page.on('pageerror', err => bpErrors.push(err.message));
|
||||
|
||||
// Navigate with networkidle, fall back to load on timeout
|
||||
try {
|
||||
await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
} catch {
|
||||
await page.goto(pageUrl, { waitUntil: 'load', timeout: 15_000 });
|
||||
}
|
||||
|
||||
const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||
const maxScroll = Math.max(0, scrollHeight - vpHeight);
|
||||
|
||||
// Compute evenly spaced stop positions
|
||||
const stops = Array.from({ length: stopsCount }, (_, i) =>
|
||||
stopsCount === 1 ? 0 : Math.round((i / (stopsCount - 1)) * maxScroll)
|
||||
);
|
||||
|
||||
// Screenshot each stop (incremental scroll so animations fire)
|
||||
let currentY = 0;
|
||||
for (let si = 0; si < stops.length; si++) {
|
||||
const target = stops[si];
|
||||
// Scroll incrementally toward target in chunks so IntersectionObserver triggers
|
||||
const CHUNK = 200;
|
||||
while (Math.abs(currentY - target) > CHUNK) {
|
||||
const next = currentY < target ? Math.min(currentY + CHUNK, target) : Math.max(currentY - CHUNK, target);
|
||||
await page.evaluate(y => window.scrollTo({ top: y, behavior: 'smooth' }), next);
|
||||
await page.waitForTimeout(120);
|
||||
currentY = next;
|
||||
}
|
||||
await page.evaluate(y => window.scrollTo({ top: y, behavior: 'smooth' }), target);
|
||||
await page.waitForTimeout(700); // let scroll-triggered animations settle
|
||||
|
||||
const fname = `${prefix}bp${width}-stop${pad(si)}.png`;
|
||||
const fpath = join(outDir, fname);
|
||||
const buf = await page.screenshot({ path: fpath, fullPage: false });
|
||||
bpFiles.push(fname);
|
||||
totalWritten++;
|
||||
if (likelyBlank(buf)) console.warn(` [warn] ${fname} possibly blank`);
|
||||
}
|
||||
|
||||
// --reduced-motion: redo first + last stop
|
||||
if (reducedMotion) {
|
||||
const rmContext = await browser.newContext({
|
||||
viewport: { width, height: vpHeight },
|
||||
deviceScaleFactor: dpr,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
const rmPage = await rmContext.newPage();
|
||||
try {
|
||||
await rmPage.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
} catch {
|
||||
await rmPage.goto(pageUrl, { waitUntil: 'load', timeout: 15_000 });
|
||||
}
|
||||
// Every stop, not just first and last: the peak lives at 40-70% depth, so shooting only the
|
||||
// ends captures exactly the frames the reduced-motion cut was never going to break.
|
||||
for (const [idx, stop] of stops.map((s, i) => [i, s])) {
|
||||
await rmPage.evaluate(y => window.scrollTo({ top: y, behavior: 'smooth' }), stop);
|
||||
await rmPage.waitForTimeout(700);
|
||||
const fname = `${prefix}bp${width}-rm-stop${pad(idx)}.png`;
|
||||
const fpath = join(outDir, fname);
|
||||
const buf = await rmPage.screenshot({ path: fpath, fullPage: false });
|
||||
bpFiles.push(fname);
|
||||
totalWritten++;
|
||||
if (likelyBlank(buf)) console.warn(` [warn] ${fname} possibly blank`);
|
||||
}
|
||||
await rmContext.close();
|
||||
}
|
||||
|
||||
// --full: one full-page screenshot (and its reduced-motion twin when both flags are on)
|
||||
if (fullPage) {
|
||||
const fname = `${prefix}bp${width}-full.png`;
|
||||
const fpath = join(outDir, fname);
|
||||
const buf = await page.screenshot({ path: fpath, fullPage: true });
|
||||
bpFiles.push(fname);
|
||||
totalWritten++;
|
||||
if (likelyBlank(buf)) console.warn(` [warn] ${fname} possibly blank`);
|
||||
}
|
||||
|
||||
anySucceeded = true;
|
||||
summary.push({ url: pageUrl, width, scrollHeight, files: bpFiles, errors: bpErrors });
|
||||
} catch (err) {
|
||||
console.error(`[${pageUrl} bp ${width}] failed: ${err.message}`);
|
||||
summary.push({ url: pageUrl, width, failed: true, error: err.message });
|
||||
} finally {
|
||||
await browser?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Print summary ---
|
||||
console.log('\n=== shoot.mjs summary ===');
|
||||
console.log(`Output dir : ${outDir}`);
|
||||
console.log(`Files written: ${totalWritten}`);
|
||||
for (const s of summary) {
|
||||
const where = targets.length > 1 ? `${s.url} ` : '';
|
||||
if (s.failed) {
|
||||
console.log(` ${where}bp${s.width}: FAILED — ${s.error}`);
|
||||
} else {
|
||||
console.log(` ${where}bp${s.width}: scrollHeight=${s.scrollHeight}px files=${s.files.length}`);
|
||||
if (s.errors.length) console.log(` page errors: ${s.errors.join(' | ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(anySucceeded ? 0 : 1);
|
||||
543
optional-skills/creative/auteur/scripts/slopscan.mjs
Normal file
543
optional-skills/creative/auteur/scripts/slopscan.mjs
Normal file
@@ -0,0 +1,543 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* slopscan.mjs — AI-slop design linter, zero dependencies, read-only
|
||||
* ponytail: regex tokenization over CSS blocks, no full parser needed for these rules
|
||||
*/
|
||||
import { readFileSync, statSync, readdirSync } from 'node:fs';
|
||||
import { join, extname, resolve } from 'node:path';
|
||||
|
||||
const SCAN_EXTS = new Set(['.css','.scss','.html','.jsx','.tsx','.vue','.svelte','.astro','.js','.mjs','.ts']);
|
||||
// Pure-JS files get only JS-relevant rules — CSS-block heuristics false-positive on JS object literals
|
||||
const PURE_JS_EXTS = new Set(['.js','.mjs','.ts']);
|
||||
const SKIP_DIRS = new Set(['node_modules','dist','build','.git','.next','out']);
|
||||
|
||||
// ── Color helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h = 0, s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
return { h: h * 360, s, l, _rgb: { r, g, b } };
|
||||
}
|
||||
|
||||
/** Returns {h, s, l, isOklch?, C?} or null */
|
||||
function parseColor(tok) {
|
||||
tok = tok.trim().replace(/,$/, '');
|
||||
// oklch(L C H [/ alpha])
|
||||
let m = tok.match(/^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/i);
|
||||
if (m) {
|
||||
const L = parseFloat(m[1]) / (m[1].endsWith('%') ? 100 : 1);
|
||||
const C = parseFloat(m[2]);
|
||||
const H = parseFloat(m[3]);
|
||||
return { h: H, s: C > 0 ? 1 : 0, l: L, isOklch: true, C };
|
||||
}
|
||||
// hsl/hsla
|
||||
m = tok.match(/^hsla?\(\s*([\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?/i);
|
||||
if (m) return { h: parseFloat(m[1]), s: parseFloat(m[2]) / 100, l: parseFloat(m[3]) / 100 };
|
||||
// rgb/rgba
|
||||
m = tok.match(/^rgba?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)\s*[,\s]\s*([\d.]+)/i);
|
||||
if (m) return rgbToHsl(+m[1], +m[2], +m[3]);
|
||||
// #rrggbbaa / #rrggbb / #rgb / #rgba
|
||||
m = tok.match(/^#([0-9a-f]{3,8})$/i);
|
||||
if (m) {
|
||||
let h = m[1];
|
||||
if (h.length === 3 || h.length === 4) h = h.split('').map(c => c + c).join('').slice(0, 6);
|
||||
if (h.length > 6) h = h.slice(0, 6);
|
||||
if (h.length !== 6) return null;
|
||||
return rgbToHsl(parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isGray(c) {
|
||||
if (!c) return true;
|
||||
// ponytail: oklch chroma < 0.03 = achromatic; HSL saturation < 0.05
|
||||
if (c.isOklch) return (c.C ?? 0) < 0.03;
|
||||
return c.s < 0.05;
|
||||
}
|
||||
|
||||
// Extract all color tokens from a gradient string
|
||||
function extractGradientColors(str) {
|
||||
const re = /(#[0-9a-f]{3,8}|(?:oklch|rgba?|hsla?)\s*\([^)]+\))/gi;
|
||||
const colors = [];
|
||||
let m;
|
||||
while ((m = re.exec(str)) !== null) {
|
||||
const c = parseColor(m[1]);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
// ── Line number helper ────────────────────────────────────────────────────────
|
||||
|
||||
function makeLineAt(text) {
|
||||
// ponytail: binary search over precomputed newline positions
|
||||
const starts = [0];
|
||||
for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);
|
||||
return pos => {
|
||||
let lo = 0, hi = starts.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (starts[mid] <= pos) lo = mid; else hi = mid - 1;
|
||||
}
|
||||
return lo + 1;
|
||||
};
|
||||
}
|
||||
|
||||
// ── CSS block extractor ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extracts leaf CSS rule blocks (those with no nested { }).
|
||||
* Returns [{selector, body, startLine}]
|
||||
*/
|
||||
function extractCSSBlocks(text) {
|
||||
const blocks = [];
|
||||
const lineAt = makeLineAt(text);
|
||||
const stack = []; // {selector, bodyStart}
|
||||
let i = 0, selectorStart = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
// Skip block comments
|
||||
if (text[i] === '/' && text[i+1] === '*') {
|
||||
const end = text.indexOf('*/', i + 2);
|
||||
i = end === -1 ? text.length : end + 2;
|
||||
continue;
|
||||
}
|
||||
// Skip strings
|
||||
if (text[i] === '"' || text[i] === "'") {
|
||||
const q = text[i++];
|
||||
while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++; }
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === '{') {
|
||||
const sel = text.slice(selectorStart, i).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
|
||||
stack.push({ selector: sel, bodyStart: i + 1, startLine: lineAt(i + 1) });
|
||||
selectorStart = i + 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === '}') {
|
||||
const frame = stack.pop();
|
||||
if (frame) {
|
||||
const body = text.slice(frame.bodyStart, i);
|
||||
if (!/{/.test(body)) { // leaf block
|
||||
const fullSel = [...stack.map(f => f.selector), frame.selector].filter(Boolean).join(' ');
|
||||
blocks.push({ selector: fullSel || frame.selector, body, startLine: frame.startLine });
|
||||
}
|
||||
}
|
||||
selectorStart = i + 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ── Suppression parser ────────────────────────────────────────────────────────
|
||||
|
||||
function parseSuppressions(text) {
|
||||
const lineAt = makeLineAt(text);
|
||||
const suppressions = new Map();
|
||||
const patterns = [
|
||||
/\/\*\s*auteur-allow:\s*(\w+)\s*--\s*([\s\S]*?)\*\//g,
|
||||
/\/\/\s*auteur-allow:\s*(\w+)\s*--(.*)/gm,
|
||||
/<!--\s*auteur-allow:\s*(\w+)\s*--\s*(.*?)-->/g,
|
||||
];
|
||||
for (const re of patterns) {
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const ruleId = m[1].trim();
|
||||
const reason = m[2].trim();
|
||||
const reasonOk = reason.replace(/\s+/g, '').length >= 10;
|
||||
const line = lineAt(m.index);
|
||||
if (!suppressions.has(ruleId)) suppressions.set(ruleId, { line, reasonOk, reason });
|
||||
}
|
||||
}
|
||||
return suppressions;
|
||||
}
|
||||
|
||||
// ── FAIL rules ────────────────────────────────────────────────────────────────
|
||||
|
||||
function checkFontDefaultSlop(text, findings) {
|
||||
const lineAt = makeLineAt(text);
|
||||
// CSS font-family declarations
|
||||
const re = /font-family\s*:\s*([^\n;{}]+)/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const first = m[1].split(',')[0].trim().replace(/['"]/g, '').trim();
|
||||
if (/^inter$/i.test(first) || /^space grotesk$/i.test(first)) {
|
||||
findings.push({ rule: 'FONT_DEFAULT_SLOP', severity: 'fail', line: lineAt(m.index),
|
||||
detail: `font-family first family is "${first}"` });
|
||||
}
|
||||
}
|
||||
// Tailwind fontFamily config: fontFamily: { key: ['Inter', ...] }
|
||||
const twRe = /fontFamily\s*:\s*\{[^}]*\}/gs;
|
||||
while ((m = twRe.exec(text)) !== null) {
|
||||
const block = m[0];
|
||||
const entryRe = /:\s*\[['"]([^'"]+)['"]/g;
|
||||
let em;
|
||||
while ((em = entryRe.exec(block)) !== null) {
|
||||
const first = em[1].trim();
|
||||
if (/^inter$/i.test(first) || /^space grotesk$/i.test(first)) {
|
||||
findings.push({ rule: 'FONT_DEFAULT_SLOP', severity: 'fail', line: lineAt(m.index),
|
||||
detail: `Tailwind fontFamily first entry is "${first}"` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkAiGradient(text, findings) {
|
||||
const lineAt = makeLineAt(text);
|
||||
const re = /(?:linear|radial|conic)-gradient\s*\(/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
// Extract balanced parens
|
||||
let depth = 0, j = m.index + m[0].length - 1;
|
||||
const start = j;
|
||||
while (j < text.length) {
|
||||
if (text[j] === '(') depth++;
|
||||
else if (text[j] === ')') { depth--; if (depth === 0) break; }
|
||||
j++;
|
||||
}
|
||||
const gradStr = text.slice(start, j + 1);
|
||||
const colors = extractGradientColors(gradStr);
|
||||
const aiColors = colors.filter(c => !isGray(c) && c.h >= 250 && c.h <= 290);
|
||||
if (aiColors.length >= 2) {
|
||||
findings.push({ rule: 'AI_GRADIENT', severity: 'fail', line: lineAt(m.index),
|
||||
detail: `${aiColors.length} gradient stops in hue 250–290 (purple-blue AI default)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkGradientText(blocks, findings) {
|
||||
const gradRe = /(?:linear|radial|conic)-gradient/i;
|
||||
const clipRe = /-webkit-background-clip\s*:\s*text|background-clip\s*:\s*text|-webkit-text-fill-color\s*:\s*transparent/i;
|
||||
for (const block of blocks) {
|
||||
if (gradRe.test(block.body) && clipRe.test(block.body)) {
|
||||
findings.push({ rule: 'GRADIENT_TEXT', severity: 'fail', line: block.startLine,
|
||||
detail: `gradient + background-clip:text (or -webkit-text-fill-color:transparent) in same block` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkTransitionAll(text, findings) {
|
||||
const lineAt = makeLineAt(text);
|
||||
const re = /transition\s*:\s*all\b/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
findings.push({ rule: 'TRANSITION_ALL', severity: 'fail', line: lineAt(m.index),
|
||||
detail: `transition: all — use specific properties instead` });
|
||||
}
|
||||
}
|
||||
|
||||
function checkRawScrollListener(text, findings) {
|
||||
const lineAt = makeLineAt(text);
|
||||
let m;
|
||||
const re = /addEventListener\s*\(\s*['"]scroll['"]/g;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
findings.push({ rule: 'RAW_SCROLL_LISTENER', severity: 'fail', line: lineAt(m.index),
|
||||
detail: `addEventListener('scroll') — use IntersectionObserver or animation-timeline` });
|
||||
}
|
||||
const re2 = /\bonscroll\s*=/g;
|
||||
while ((m = re2.exec(text)) !== null) {
|
||||
findings.push({ rule: 'RAW_SCROLL_LISTENER', severity: 'fail', line: lineAt(m.index),
|
||||
detail: `onscroll= attribute — use IntersectionObserver or animation-timeline` });
|
||||
}
|
||||
}
|
||||
|
||||
// ── WARN rules ────────────────────────────────────────────────────────────────
|
||||
|
||||
function checkGlassCard(blocks, findings) {
|
||||
for (const block of blocks) {
|
||||
if (!/card|tile|panel/i.test(block.selector)) continue;
|
||||
if (/backdrop-filter\s*:\s*blur\s*\(/i.test(block.body)) {
|
||||
findings.push({ rule: 'GLASS_CARD', severity: 'warn', line: block.startLine,
|
||||
detail: `backdrop-filter:blur() in "${block.selector.slice(0, 60)}"` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkCardCloneGrid(blocks, findings) {
|
||||
const getTriple = body => {
|
||||
const get = prop => { const m = body.match(new RegExp(prop + '\\s*:\\s*([^;\\n]+)')); return m ? m[1].trim() : null; };
|
||||
const br = get('border-radius'), p = get('padding'), bs = get('box-shadow');
|
||||
return br && p && bs ? `${br}|${p}|${bs}` : null;
|
||||
};
|
||||
const seen = new Map();
|
||||
for (const block of blocks) {
|
||||
const key = getTriple(block.body);
|
||||
if (!key) continue;
|
||||
if (!seen.has(key)) seen.set(key, []);
|
||||
seen.get(key).push(block.startLine);
|
||||
}
|
||||
for (const [, lines] of seen) {
|
||||
if (lines.length >= 4) {
|
||||
findings.push({ rule: 'CARD_CLONE_GRID', severity: 'warn', line: lines[0],
|
||||
detail: `${lines.length} blocks share identical border-radius/padding/box-shadow (lines ${lines.join(', ')})` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ban #16 is "em-dash-HEAVY sentences" — a density property, not the presence of the glyph.
|
||||
// Flagging every occurrence trains you to suppress the rule, which is how a linter loses its
|
||||
// authority; and the old remediation ("use —") renders the identical character, so
|
||||
// following it changed nothing the rule claimed to detect.
|
||||
const PROSE_EXT = new Set(['.html', '.htm', '.md', '.jsx', '.tsx', '.vue', '.svelte']);
|
||||
function checkEmDashCopy(text, ext, findings) {
|
||||
if (!PROSE_EXT.has(ext)) return;
|
||||
// Keep only what a visitor actually reads: no script/style, no comments, no <title>/<meta>.
|
||||
const prose = text
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<title[\s\S]*?<\/title>/gi, ' ')
|
||||
.replace(/<meta[^>]*>/gi, ' ')
|
||||
// Headings, terms and captions are LABELS, not sentences. "-200m — The Blue" is a dash doing
|
||||
// exactly the job a dash should do, and counting it made the rule argue against good typography.
|
||||
.replace(/<(h[1-6]|dt|summary|figcaption|legend|caption|th)\b[\s\S]*?<\/\1>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
.replace(/(^|\s)\/\/[^\n]*/g, ' ')
|
||||
.replace(/<[^>]+>/g, ' ');
|
||||
|
||||
// Entity-encoded dashes render the identical glyph. Counting only the literal character meant the
|
||||
// rule was defeated by `—` — the exact dodge the comment above says the old advice suffered
|
||||
// from. Decode first, then count.
|
||||
const decoded = prose
|
||||
.replace(/—|—|&#[xX]2014;/g, '—')
|
||||
.replace(/–|–|&#[xX]2013;/g, '–')
|
||||
.replace(/…|…/g, '…')
|
||||
.replace(/ | /g, ' ');
|
||||
const dashes = (decoded.match(/—/g) || []).length;
|
||||
if (dashes < 3) return;
|
||||
const sentences = (decoded.match(/[.!?]["')\]]?(\s|$)/g) || []).length || 1;
|
||||
const words = (decoded.match(/\S+/g) || []).length || 1;
|
||||
const perSentence = dashes / sentences;
|
||||
const per100w = (dashes / words) * 100;
|
||||
// One criterion, because the ban is about the dash replacing sentence structure: if more than
|
||||
// half your sentences carry an em dash, it is structural. A per-100-words test looked reasonable
|
||||
// and flagged prose with one dash every four sentences, which is just writing.
|
||||
if (perSentence < 0.5) return;
|
||||
|
||||
const lineAt = makeLineAt(text);
|
||||
const first = text.indexOf('—');
|
||||
findings.push({
|
||||
rule: 'EM_DASH_COPY', severity: 'warn', line: lineAt(first < 0 ? 0 : first),
|
||||
detail: `${dashes} em dashes across ${sentences} sentences / ${words} words of visible copy (${perSentence.toFixed(2)}/sentence, ${per100w.toFixed(1)} per 100 words) — the dash is doing the work sentence structure should. Rewrite the densest ones as full sentences; an occasional em dash is fine.`,
|
||||
});
|
||||
}
|
||||
|
||||
function checkEyebrowEverywhere(blocks, findings) {
|
||||
const matched = [];
|
||||
for (const block of blocks) {
|
||||
if (!/text-transform\s*:\s*uppercase/i.test(block.body)) continue;
|
||||
const lsM = block.body.match(/letter-spacing\s*:\s*([\d.]+)(em|rem)/i);
|
||||
if (!lsM || parseFloat(lsM[1]) < 0.05) continue;
|
||||
const fsM = block.body.match(/font-size\s*:\s*([\d.]+)(rem|px|em)/i);
|
||||
if (!fsM) continue;
|
||||
const fs = parseFloat(fsM[1]), unit = fsM[2].toLowerCase();
|
||||
const fsRem = unit === 'px' ? fs / 16 : fs;
|
||||
if (fsRem > 0.875) continue;
|
||||
matched.push(block.startLine);
|
||||
}
|
||||
if (matched.length > 3) {
|
||||
findings.push({ rule: 'EYEBROW_EVERYWHERE', severity: 'warn', line: matched[0],
|
||||
detail: `${matched.length} blocks with text-transform:uppercase + letter-spacing≥0.05em + font-size≤0.875rem` });
|
||||
}
|
||||
}
|
||||
|
||||
function checkCreamDefault(blocks, findings) {
|
||||
for (const block of blocks) {
|
||||
if (!/^(?::root|body|html)\b/i.test(block.selector.trim())) continue;
|
||||
const bgM = block.body.match(/background(?:-color)?\s*:\s*([^\n;]+)/i);
|
||||
if (!bgM) continue;
|
||||
const val = bgM[1].trim().split(/\s+/)[0];
|
||||
const c = parseColor(val);
|
||||
if (!c) continue;
|
||||
|
||||
let inBand = false;
|
||||
if (c.isOklch) {
|
||||
inBand = c.l >= 0.84 && c.l <= 0.97 && (c.C ?? 0) < 0.06 && c.h >= 40 && c.h <= 100;
|
||||
} else {
|
||||
// ponytail: sRGB→OKLCH approximation via HSL; check lightness, small chroma gap, warm hue
|
||||
// max-min distance in [0,1] maps roughly to OKLCH chroma < 0.06 for near-white colors
|
||||
const rgb = c._rgb;
|
||||
if (!rgb) continue;
|
||||
const dRgb = Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b);
|
||||
inBand = c.l >= 0.84 && c.l <= 0.97 && dRgb > 0.002 && dRgb < 0.08 && c.h >= 30 && c.h <= 110;
|
||||
}
|
||||
if (inBand) {
|
||||
findings.push({ rule: 'CREAM_DEFAULT', severity: 'warn', line: block.startLine,
|
||||
detail: `body/:root background "${val}" is in warm-cream default band` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tier-1 motion / WebGL / audio rules ─────────────────────────────────────────
|
||||
|
||||
function checkAutoplaySound(text, findings) {
|
||||
const lineAt = makeLineAt(text);
|
||||
const re = /<(audio|video)\b([^>]*)>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (/\bautoplay\b/i.test(m[2]) && !/\bmuted\b/i.test(m[2])) {
|
||||
findings.push({ rule: 'AUTOPLAY_SOUND', severity: 'fail', line: lineAt(m.index),
|
||||
detail: `<${m[1].toLowerCase()} autoplay> without muted — sound must start on a user gesture` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkVideoNoPoster(text, findings) {
|
||||
const lineAt = makeLineAt(text);
|
||||
const re = /<video\b([^>]*)>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (!/\bposter\s*=/i.test(m[1])) {
|
||||
findings.push({ rule: 'VIDEO_NO_POSTER', severity: 'warn', line: lineAt(m.index),
|
||||
detail: `<video> without poster — blank hero until the clip decodes` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkWebglNoReducedMotion(text, findings) {
|
||||
const lineAt = makeLineAt(text);
|
||||
const ctxRe = /new\s+THREE\.WebGLRenderer|getContext\s*\(\s*['"](?:webgl2?|webgpu)['"]|new\s+GPUDevice|new\s+OGLRenderer/;
|
||||
const cm = ctxRe.exec(text);
|
||||
if (!cm) return;
|
||||
if (!/prefers-reduced-motion/i.test(text)) {
|
||||
findings.push({ rule: 'WEBGL_NO_REDUCED_MOTION', severity: 'warn', line: lineAt(cm.index),
|
||||
detail: `WebGL/WebGPU scene with no prefers-reduced-motion branch — reduced-motion is an alternative art direction, not an afterthought` });
|
||||
}
|
||||
}
|
||||
|
||||
function checkPointerNoRaf(text, findings) {
|
||||
const lineAt = makeLineAt(text);
|
||||
const pm = /addEventListener\s*\(\s*['"](?:pointermove|mousemove)['"]/.exec(text);
|
||||
if (!pm) return;
|
||||
if (!/requestAnimationFrame/.test(text)) {
|
||||
findings.push({ rule: 'POINTER_NO_RAF', severity: 'warn', line: lineAt(pm.index),
|
||||
detail: `pointermove/mousemove handler with no requestAnimationFrame — throttle DOM/uniform writes to rAF, never per-event` });
|
||||
}
|
||||
}
|
||||
|
||||
// ── File scanner ──────────────────────────────────────────────────────────────
|
||||
|
||||
function scanFile(filePath) {
|
||||
const text = readFileSync(filePath, 'utf8');
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
const suppressions = parseSuppressions(text);
|
||||
const findings = [];
|
||||
const blocks = extractCSSBlocks(text);
|
||||
|
||||
if (PURE_JS_EXTS.has(ext)) {
|
||||
checkTransitionAll(text, findings);
|
||||
checkRawScrollListener(text, findings);
|
||||
checkWebglNoReducedMotion(text, findings);
|
||||
checkPointerNoRaf(text, findings);
|
||||
} else {
|
||||
checkFontDefaultSlop(text, findings);
|
||||
checkAiGradient(text, findings);
|
||||
checkGradientText(blocks, findings);
|
||||
checkTransitionAll(text, findings);
|
||||
checkRawScrollListener(text, findings);
|
||||
checkGlassCard(blocks, findings);
|
||||
checkCardCloneGrid(blocks, findings);
|
||||
checkEmDashCopy(text, ext, findings);
|
||||
checkEyebrowEverywhere(blocks, findings);
|
||||
checkCreamDefault(blocks, findings);
|
||||
checkAutoplaySound(text, findings);
|
||||
checkVideoNoPoster(text, findings);
|
||||
checkWebglNoReducedMotion(text, findings);
|
||||
checkPointerNoRaf(text, findings);
|
||||
}
|
||||
|
||||
const result = findings.map(f => {
|
||||
const sup = suppressions.get(f.rule);
|
||||
return { ...f, suppressed: !!(sup && sup.reasonOk) };
|
||||
});
|
||||
|
||||
// Invalid suppressions: present but reason too short
|
||||
for (const [ruleId, sup] of suppressions) {
|
||||
if (!sup.reasonOk) {
|
||||
result.push({ rule: 'SUPPRESSION_NO_REASON', severity: 'warn', line: sup.line,
|
||||
detail: `auteur-allow: ${ruleId} — reason too short (need 10+ non-whitespace chars)`, suppressed: false });
|
||||
}
|
||||
}
|
||||
|
||||
return { path: filePath, findings: result };
|
||||
}
|
||||
|
||||
// ── File walker ───────────────────────────────────────────────────────────────
|
||||
|
||||
function walk(entry) {
|
||||
const files = [];
|
||||
let stat;
|
||||
try { stat = statSync(entry); } catch { return files; }
|
||||
if (stat.isFile()) {
|
||||
if (SCAN_EXTS.has(extname(entry).toLowerCase())) files.push(entry);
|
||||
return files;
|
||||
}
|
||||
for (const name of readdirSync(entry)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const full = join(entry, name);
|
||||
try {
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) files.push(...walk(full));
|
||||
else if (SCAN_EXTS.has(extname(name).toLowerCase())) files.push(full);
|
||||
} catch { /* skip unreadable */ }
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const jsonMode = argv.includes('--json');
|
||||
const target = argv.find(a => !a.startsWith('-'));
|
||||
|
||||
if (!target) {
|
||||
process.stderr.write('Usage: node slopscan.mjs <dir-or-file> [--json]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const rootDir = resolve(target);
|
||||
const files = walk(rootDir);
|
||||
const fileResults = files.map(f => scanFile(f));
|
||||
|
||||
let totalFails = 0, totalWarns = 0, totalSuppressed = 0;
|
||||
for (const fr of fileResults) {
|
||||
for (const f of fr.findings) {
|
||||
if (f.suppressed) totalSuppressed++;
|
||||
else if (f.severity === 'fail') totalFails++;
|
||||
else totalWarns++;
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonMode) {
|
||||
process.stdout.write(JSON.stringify({ files: fileResults, summary: { fails: totalFails, warns: totalWarns, suppressed: totalSuppressed } }, null, 2) + '\n');
|
||||
} else {
|
||||
for (const fr of fileResults) {
|
||||
if (!fr.findings.length) continue;
|
||||
const rel = fr.path.replace(rootDir, '').replace(/^[\\/]/, '');
|
||||
for (const f of fr.findings) {
|
||||
const tag = f.suppressed ? 'SKIP' : f.severity.toUpperCase();
|
||||
process.stdout.write(`${tag} ${f.rule} ${rel}:${f.line} — ${f.detail}\n`);
|
||||
}
|
||||
}
|
||||
process.stdout.write(`\nSummary: ${totalFails} fails, ${totalWarns} warns, ${totalSuppressed} suppressed\n`);
|
||||
}
|
||||
|
||||
process.exit(totalFails > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
387
optional-skills/creative/auteur/scripts/source.mjs
Normal file
387
optional-skills/creative/auteur/scripts/source.mjs
Normal file
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* source.mjs — find and download licence-clean assets you don't have to generate.
|
||||
*
|
||||
* Generation is not always the right tool. You cannot generate a glTF mesh, a 16-bit HDRI
|
||||
* environment map, or a correctly-hinted variable font — and CC0 versions of all three exist at
|
||||
* production quality. This fetches those, records the licence for every file, and refuses to
|
||||
* hand you anything whose terms it can't state.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/source.mjs <kind> "<query>" [options]
|
||||
*
|
||||
* kind = hdri | model | texture | icon | font | image | video
|
||||
*
|
||||
* Options:
|
||||
* --limit N how many assets (default 5, cap 20)
|
||||
* --out DIR output dir (default assets/sourced)
|
||||
* --res 1k|2k|4k|8k resolution for hdri/model/texture (default 2k)
|
||||
* --list print a shortlist to stdout; download nothing and write no ledger
|
||||
*
|
||||
* Writes DIR/ASSETS-SOURCED.md — the licence ledger — on every real fetch. Read it before you ship.
|
||||
*
|
||||
* Sources (all no-key, verified live 2026-08):
|
||||
* hdri/model/texture Poly Haven CC0 no attribution required
|
||||
* icon Iconify per set licence reported per icon (MIT/Apache/CC-BY/OFL)
|
||||
* font Google Fonts OFL/Apache free for commercial use, no attribution in UI
|
||||
* image Openverse CC-BY/BY-SA ATTRIBUTION REQUIRED — ledger carries the string
|
||||
* video Coverr free-use no redistribution; see the taste warning below
|
||||
*
|
||||
* The video caveat is not legal boilerplate. Stock footage is generic by construction — the same
|
||||
* drone shot is on three thousand landing pages. Auteur's entire thesis is committed, specific
|
||||
* assets, so sourced video is allowed as an ambient loop, a texture, or a fallback, and is never
|
||||
* the peak scene. If the wow moment is stock, there is no wow moment.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile, readFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve, join, dirname } from 'path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const KINDS = ['hdri', 'model', 'texture', 'icon', 'font', 'image', 'video'];
|
||||
if (!args.length || args.includes('--help') || !KINDS.includes(args[0])) {
|
||||
console.log(`source.mjs — licence-clean asset sourcing
|
||||
|
||||
node scripts/source.mjs <kind> "<query>" [--limit 5] [--out assets/sourced] [--res 2k] [--list]
|
||||
|
||||
kind: ${KINDS.join(' | ')}
|
||||
|
||||
hdri|model|texture Poly Haven, CC0 — what generation cannot make
|
||||
icon Iconify, licence per set
|
||||
font Google Fonts, OFL/Apache
|
||||
image Openverse, CC — attribution REQUIRED, ledger carries it
|
||||
video Coverr, free-use — ambient/fallback only, never the peak`);
|
||||
process.exit(args.length && !KINDS.includes(args[0]) ? 1 : 0);
|
||||
}
|
||||
|
||||
const kind = args[0];
|
||||
const get = (f, d) => { const i = args.indexOf(f); return i !== -1 ? args[i + 1] : d; };
|
||||
const has = f => args.includes(f);
|
||||
const query = (args[1] && !args[1].startsWith('--') ? args[1] : '').trim();
|
||||
|
||||
const limit = Math.min(parseInt(get('--limit', '5'), 10) || 5, 20);
|
||||
const outDir = resolve(get('--out', 'assets/sourced'));
|
||||
const res = get('--res', '2k');
|
||||
const listOnly = has('--list');
|
||||
|
||||
if (!query) { console.error(`[source] no query. e.g. node scripts/source.mjs ${kind} "coastal dusk"`); process.exit(1); }
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
const H = { 'User-Agent': UA, Accept: '*/*' };
|
||||
|
||||
async function json(url) {
|
||||
const r = await fetch(url, { headers: H, signal: AbortSignal.timeout(45_000) });
|
||||
if (!r.ok) throw new Error(`${r.status} ${url.slice(0, 70)}`);
|
||||
return r.json();
|
||||
}
|
||||
async function text(url) {
|
||||
const r = await fetch(url, { headers: H, signal: AbortSignal.timeout(45_000) });
|
||||
if (!r.ok) throw new Error(`${r.status} ${url.slice(0, 70)}`);
|
||||
return r.text();
|
||||
}
|
||||
// Keep pure-digit tokens: Poly Haven names most of its library thing_01 … thing_09, so dropping
|
||||
// short words made a specific asset unaddressable ("studio small 03" silently returned 09).
|
||||
const words = q => q.toLowerCase().split(/[\s,]+/).filter(w => w.length > 2 || /^\d+$/.test(w));
|
||||
|
||||
/** score a Poly Haven asset against the query across name / tags / categories / attributes */
|
||||
function phScore(slug, a, ws) {
|
||||
const hay = [slug, a.name, ...(a.tags || []), ...(a.categories || []), ...Object.values(a.attributes || {})]
|
||||
.join(' ').toLowerCase();
|
||||
return ws.reduce((n, w) => n + (hay.includes(w) ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Poly Haven (CC0)
|
||||
async function polyhaven(type) {
|
||||
const all = await json(`https://api.polyhaven.com/assets?t=${type}`);
|
||||
const ws = words(query);
|
||||
const ranked = Object.entries(all)
|
||||
.map(([slug, a]) => ({ slug, a, score: phScore(slug, a, ws) }))
|
||||
.filter(x => x.score > 0)
|
||||
.sort((x, y) => y.score - x.score || (y.a.download_count || 0) - (x.a.download_count || 0))
|
||||
.slice(0, limit);
|
||||
|
||||
if (!ranked.length) { console.error(`[source] Poly Haven has no ${type} matching "${query}". Try broader words (its tags are physical: "coast", "dusk", "concrete", "chair").`); return []; }
|
||||
|
||||
const out = [];
|
||||
for (const { slug, a, score } of ranked) {
|
||||
const files = await json(`https://api.polyhaven.com/files/${slug}`).catch(() => null);
|
||||
if (!files) continue;
|
||||
const item = {
|
||||
id: slug,
|
||||
title: a.name,
|
||||
licence: 'CC0',
|
||||
attribution: null, // CC0: none required
|
||||
credit: Object.keys(a.authors || {}).join(', '),
|
||||
landing: `https://polyhaven.com/a/${slug}`,
|
||||
note: [a.categories?.join('/'), Object.entries(a.attributes || {}).map(([k, v]) => `${k}=${v}`).join(' ')].filter(Boolean).join(' · '),
|
||||
score, files: [],
|
||||
};
|
||||
|
||||
if (type === 'hdris') {
|
||||
const pick = files.hdri?.[res] || files.hdri?.['2k'] || files.hdri?.['1k'];
|
||||
const f = pick?.hdr || pick?.exr;
|
||||
if (f) item.files.push({ url: f.url, path: `${slug}_${res}.${f.url.split('.').pop()}`, size: f.size });
|
||||
} else if (type === 'models') {
|
||||
const g = files.gltf?.[res]?.gltf || files.gltf?.['2k']?.gltf || files.gltf?.['1k']?.gltf;
|
||||
if (g) {
|
||||
item.files.push({ url: g.url, path: `${slug}/${g.url.split('/').pop()}`, size: g.size });
|
||||
// a glTF without its .bin and textures is a broken file, not a smaller one
|
||||
for (const [rel, inc] of Object.entries(g.include || {})) item.files.push({ url: inc.url, path: `${slug}/${rel}`, size: inc.size });
|
||||
}
|
||||
} else { // textures
|
||||
const maps = { Diffuse: 'diff', nor_gl: 'nor', Rough: 'rough', arm: 'arm', Displacement: 'disp' };
|
||||
for (const [key, tag] of Object.entries(maps)) {
|
||||
const f = files[key]?.[res]?.jpg || files[key]?.['2k']?.jpg || files[key]?.['1k']?.jpg;
|
||||
if (f) item.files.push({ url: f.url, path: `${slug}/${slug}_${tag}_${res}.jpg`, size: f.size });
|
||||
}
|
||||
}
|
||||
if (item.files.length) out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Iconify
|
||||
async function icons() {
|
||||
const collections = await json('https://api.iconify.design/collections').catch(() => ({}));
|
||||
// multi-word queries return nothing; the index is single-keyword
|
||||
const terms = words(query).length ? words(query) : [query];
|
||||
const seen = new Set(), out = [];
|
||||
for (const term of terms) {
|
||||
if (out.length >= limit) break;
|
||||
const r = await json(`https://api.iconify.design/search?query=${encodeURIComponent(term)}&limit=${limit * 4}`).catch(() => null);
|
||||
for (const id of r?.icons || []) {
|
||||
if (out.length >= limit || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const [set, name] = id.split(':');
|
||||
const c = collections[set] || {};
|
||||
out.push({
|
||||
id, title: `${c.name || set} / ${name}`,
|
||||
licence: c.license?.title || 'unknown',
|
||||
attribution: /CC.?BY/i.test(c.license?.title || '') ? `Icon "${name}" from ${c.name || set} — ${c.license?.title}` : null,
|
||||
credit: c.author?.name || '', landing: c.license?.url || `https://icon-sets.iconify.design/${set}/`,
|
||||
note: `set has ${c.total || '?'} icons`,
|
||||
files: [{ url: `https://api.iconify.design/${set}/${name}.svg?height=64`, path: `${set}-${name}.svg` }],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!out.length) console.error('[source] Iconify found nothing — its index is single-keyword; try one noun ("bottle", not "whisky bottle").');
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Google Fonts
|
||||
const GF_BANNED = ['Inter', 'Space Grotesk', 'Instrument Serif', 'Playfair Display'];
|
||||
async function fonts() {
|
||||
// Not inside --out: that just relocates 1.5MB from your cwd into your shipped assets tree.
|
||||
const cacheFile = join(process.env.TEMP || process.env.TMPDIR || '.', 'auteur-gf-metadata.json');
|
||||
let meta;
|
||||
if (existsSync(cacheFile)) meta = JSON.parse(await readFile(cacheFile, 'utf8'));
|
||||
else {
|
||||
meta = JSON.parse((await text('https://fonts.google.com/metadata/fonts')).replace(/^\)\]\}'\n?/, ''));
|
||||
// --list promises to write nothing; a 2.6MB metadata cache is still something.
|
||||
if (!listOnly) {
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await writeFile(cacheFile, JSON.stringify(meta)); // fetch it once per project
|
||||
}
|
||||
}
|
||||
// Google's own category names overlap ("Sans Serif" contains "serif"), so a plain substring
|
||||
// match on "serif" happily returns Noto Sans Display. Resolve the category first, filter on it,
|
||||
// and only then rank the leftover words.
|
||||
const CAT = { serif: 'Serif', slab: 'Serif', sans: 'Sans Serif', grotesque: 'Sans Serif', grotesk: 'Sans Serif', mono: 'Monospace', monospace: 'Monospace', display: 'Display', handwriting: 'Handwriting', script: 'Handwriting' };
|
||||
const ws = words(query);
|
||||
const wantSans = ws.some(w => w === 'sans' || w.startsWith('grotes'));
|
||||
const wantCat = wantSans ? 'Sans Serif' : (ws.map(w => CAT[w]).find(Boolean) || null);
|
||||
const wantVariable = ws.some(w => w === 'variable' || w === 'vf');
|
||||
const rest = ws.filter(w => !CAT[w] && w !== 'variable' && w !== 'vf');
|
||||
|
||||
// "Display", "Mono" and "Sans" are Google categories AND parts of real family names, so a query
|
||||
// like "Playfair Display" was read as a category and filtered its own family out. If the category
|
||||
// reading finds nothing, the word was part of a name — drop the constraint and search again.
|
||||
const inPool = cat => (meta.familyMetadataList || []).filter(f => (!cat || f.category === cat) && (!wantVariable || (f.axes || []).length));
|
||||
let pool = inPool(wantCat);
|
||||
if (wantCat && !pool.some(f => rest.some(w => f.family.toLowerCase().includes(w)))) {
|
||||
const byName = inPool(null).filter(f => rest.some(w => f.family.toLowerCase().includes(w)));
|
||||
if (byName.length) pool = byName;
|
||||
}
|
||||
const ranked = pool
|
||||
.map(f => {
|
||||
const name = f.family.toLowerCase();
|
||||
// A family must actually match something before it is a candidate. Scoring alone did not
|
||||
// enforce that — the "keep banned families listed but demoted" filter let every family
|
||||
// through, so `font "Bodoni Moda"` downloaded Roboto.
|
||||
const hits = rest.reduce((n, w) => n + (name.includes(w) ? 1 : 0), 0);
|
||||
const matched = hits > 0 || (wantCat && !rest.length);
|
||||
let score = (wantCat ? 1 : 0) + hits * 2;
|
||||
if (name === rest.join(' ')) score += 3; // exact family name wins outright
|
||||
if ((f.axes || []).length) score += 0.5; // variable axes are worth having
|
||||
// Google's popularity ranking is exactly what makes a font the AI default; a banned family
|
||||
// must never be the top suggestion, but it stays listed (flagged) rather than hidden.
|
||||
if (GF_BANNED.includes(f.family)) score -= 100;
|
||||
return { f, score, matched };
|
||||
})
|
||||
.filter(x => x.matched)
|
||||
.sort((a, b) => b.score - a.score || (a.f.popularity || 9999) - (b.f.popularity || 9999))
|
||||
.slice(0, limit);
|
||||
if (!ranked.length) console.error(`[source] no Google font matched. Say the shape: "serif variable", "mono", "display grotesk".`);
|
||||
|
||||
const out = ranked.map(({ f }) => {
|
||||
const axes = (f.axes || []).map(a => `${a.tag} ${a.min}–${a.max}`).join(', ');
|
||||
const banned = GF_BANNED.includes(f.family);
|
||||
const spec = (f.axes || []).find(a => a.tag === 'wght')
|
||||
? `${f.family.replace(/ /g, '+')}:wght@${(f.axes.find(a => a.tag === 'wght')).min}..${(f.axes.find(a => a.tag === 'wght')).max}`
|
||||
: f.family.replace(/ /g, '+');
|
||||
return {
|
||||
id: f.family, title: `${f.family} (${f.category})`,
|
||||
licence: 'OFL / Apache-2.0 (Google Fonts)', attribution: null, credit: '',
|
||||
landing: `https://fonts.google.com/specimen/${f.family.replace(/ /g, '+')}`,
|
||||
note: `${banned ? '⛔ ON THE AUTEUR BAN LIST — pick something else. ' : ''}popularity #${f.popularity ?? '?'} · weights ${Object.keys(f.fonts || {}).length}${axes ? ` · variable: ${axes}` : ' · static only'}`,
|
||||
css: `https://fonts.googleapis.com/css2?family=${spec}&display=swap`,
|
||||
files: [], // filled below with the real woff2
|
||||
family: f.family,
|
||||
};
|
||||
});
|
||||
|
||||
// Most of what this skill builds must have zero third-party origins, so linking
|
||||
// fonts.googleapis.com is not an answer. Resolve the CSS with a browser UA (an old UA gets you
|
||||
// ttf), take the latin block, and fetch the actual woff2 so the page can self-host.
|
||||
for (const it of out) {
|
||||
try {
|
||||
const css = await text(it.css);
|
||||
// Google emits one @font-face per unicode subset, each preceded by a `/* latin */` comment.
|
||||
const latin = (css.match(/\/\*\s*latin\s*\*\/([\s\S]*?)(?=\/\*|$)/) || [, ''])[1] || css;
|
||||
const url = (latin.match(/url\((https:\/\/[^)]+\.woff2)\)/) || [])[1];
|
||||
if (url) {
|
||||
it.files.push({ url, path: `${it.family.replace(/ /g, '')}.woff2` });
|
||||
it.selfHost = `@font-face{font-family:'${it.family}';src:url('fonts/${it.family.replace(/ /g, '')}.woff2') format('woff2');font-display:swap;font-weight:${(latin.match(/font-weight:\s*([^;]+)/) || [, '400'])[1].trim()};font-style:normal}`;
|
||||
}
|
||||
} catch { /* leave it link-only; the ledger will show no file */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Openverse (CC)
|
||||
async function images() {
|
||||
// commercial,modification filter is mandatory: the unfiltered index is full of by-nc-nd
|
||||
const r = await json(`https://api.openverse.org/v1/images/?q=${encodeURIComponent(query)}&page_size=${limit}&license_type=commercial,modification`);
|
||||
return (r.results || []).map(x => ({
|
||||
id: x.id, title: x.title || x.id,
|
||||
licence: `CC ${(x.license || '').toUpperCase()} ${x.license_version || ''}`.trim(),
|
||||
attribution: x.attribution || `"${x.title}" by ${x.creator} is licensed under CC ${(x.license || '').toUpperCase()} ${x.license_version || ''}`,
|
||||
credit: x.creator || '', landing: x.foreign_landing_url, note: `${x.width}×${x.height} ${x.filetype || ''} · ${x.provider || ''}`,
|
||||
files: [{ url: x.url, path: `${(x.title || x.id).replace(/[^\w-]+/g, '_').slice(0, 40)}.${x.filetype || 'jpg'}` }],
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- stock video (Coverr)
|
||||
// Mixkit is deliberately not wired in: its search page lists mp4s, but its CDN throttles a
|
||||
// download to ~15KB in 90s, so every fetch dies on timeout. One working source beats two listed.
|
||||
const RES_RANK = { '2160p': 4, '1080p': 3, '720p': 2, '360p': 1 };
|
||||
async function videos() {
|
||||
let html;
|
||||
try { html = await text(`https://coverr.co/s?q=${encodeURIComponent(query)}`); }
|
||||
catch (e) { console.error(`[source] coverr: ${e.message}`); return []; }
|
||||
|
||||
// Keep only the curated library (slug `coverr-…`): `user-ai-generation-…` are user uploads
|
||||
// with unclear provenance, and cdn-staging is not a URL to build on.
|
||||
const best = new Map();
|
||||
for (const u of new Set(html.match(/https:\/\/cdn\.coverr\.co\/videos\/coverr-[^"' ]+\.mp4/g) || [])) {
|
||||
const [, slug, res] = u.match(/videos\/([^/]+)\/(\d+p)\.mp4$/) || [];
|
||||
if (!slug) continue;
|
||||
if (!best.has(slug) || RES_RANK[res] > RES_RANK[best.get(slug).res]) best.set(slug, { u, res });
|
||||
}
|
||||
const out = [...best.entries()].slice(0, limit).map(([slug, { u, res }]) => ({
|
||||
id: slug,
|
||||
title: slug.replace(/^coverr-/, '').replace(/-\d+$/, '').replace(/-/g, ' '),
|
||||
licence: 'Coverr License — free commercial use, redistribution prohibited',
|
||||
attribution: null, credit: 'Coverr',
|
||||
landing: `https://coverr.co/videos/${slug}`,
|
||||
note: `${res} · ⚠ stock — ambient loop / texture / fallback only, never the peak scene`,
|
||||
files: [{ url: u, path: `${slug}-${res}.mp4` }],
|
||||
}));
|
||||
if (!out.length) console.error('[source] no stock video matched — Coverr indexes simple nouns ("snow", "rain", "city", "smoke").');
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- run
|
||||
const fetchers = { hdri: () => polyhaven('hdris'), model: () => polyhaven('models'), texture: () => polyhaven('textures'), icon: icons, font: fonts, image: images, video: videos };
|
||||
let items = [];
|
||||
try { items = await fetchers[kind](); }
|
||||
catch (e) { console.error(`[source] ${kind} lookup failed: ${e.message}`); process.exit(1); }
|
||||
|
||||
if (!items.length) { console.error('[source] nothing found.'); process.exit(1); }
|
||||
|
||||
// --list is a read loop: print the shortlist and touch nothing. Appending every exploratory
|
||||
// search to the ledger buried three shipped assets under 260 lines of search history.
|
||||
if (listOnly) {
|
||||
console.log(`
|
||||
${items.length} match(es) for "${query}" (${kind}):
|
||||
`);
|
||||
for (const it of items) {
|
||||
console.log(` ${it.id}`);
|
||||
console.log(` ${it.title} · ${it.licence}${it.credit ? ` · ${it.credit}` : ''}`);
|
||||
if (it.note) console.log(` ${it.note}`);
|
||||
if (it.css) console.log(` css: ${it.css}`);
|
||||
console.log(` ${it.landing}`);
|
||||
if (it.files.length) console.log(` ${it.files.length} file(s), ${Math.round(it.files.reduce((n, f) => n + (f.size || 0), 0) / 1024)}KB`);
|
||||
console.log('');
|
||||
}
|
||||
console.log(`Nothing downloaded and no ledger written (--list). Re-run without --list to fetch.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await mkdir(outDir, { recursive: true });
|
||||
let bytes = 0, files = 0;
|
||||
{
|
||||
for (const it of items) {
|
||||
for (const f of it.files) {
|
||||
const dest = join(outDir, kind, f.path);
|
||||
if (existsSync(dest)) { f.skipped = true; continue; } // never re-download a cached master
|
||||
try {
|
||||
const r = await fetch(f.url, { headers: { ...H, Referer: it.landing || '' }, signal: AbortSignal.timeout(120_000) });
|
||||
if (!r.ok) { f.error = `HTTP ${r.status}`; continue; }
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
await writeFile(dest, buf);
|
||||
f.written = buf.length; bytes += buf.length; files++;
|
||||
} catch (e) { f.error = e.message.split('\n')[0]; }
|
||||
}
|
||||
process.stderr.write(`[source] ${it.id} — ${it.files.filter(f => f.written || f.skipped).length}/${it.files.length} files\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ledger
|
||||
const ledgerPath = join(outDir, 'ASSETS-SOURCED.md');
|
||||
const prev = existsSync(ledgerPath) ? await readFile(ledgerPath, 'utf8') : `# ASSETS-SOURCED — licence ledger
|
||||
|
||||
Every file below came from someone else. This file is the record of what you may do with it.
|
||||
**Before shipping:** every entry with an "attribution required" line must have that credit visible
|
||||
on the site (a credits block in the footer is fine). CC0 and OFL entries need nothing.
|
||||
|
||||
Sourced assets are for production use; the moodboard in \`design/\` is not — do not confuse them.
|
||||
`;
|
||||
const block = [
|
||||
'',
|
||||
`## ${kind} — "${query}" · ${new Date().toISOString().slice(0, 10)}${listOnly ? ' (list only, nothing downloaded)' : ''}`,
|
||||
'',
|
||||
...items.flatMap(it => {
|
||||
const l = [`### ${it.title}`];
|
||||
l.push(`- licence: **${it.licence}**${it.credit ? ` · by ${it.credit}` : ''}`);
|
||||
if (it.attribution) l.push(`- ⚠ **attribution required** — put this on the page: \`${it.attribution}\``);
|
||||
l.push(`- source: ${it.landing}`);
|
||||
if (it.note) l.push(`- ${it.note}`);
|
||||
if (it.selfHost) l.push(`- self-host (no third-party origin): \`${it.selfHost}\``);
|
||||
else if (it.css) l.push(`- css: \`<link rel="stylesheet" href="${it.css}">\` (woff2 could not be resolved — this links a third-party origin)`);
|
||||
for (const f of it.files) l.push(`- file: \`${kind}/${f.path}\`${f.written ? ` (${Math.round(f.written / 1024)}KB)` : f.skipped ? ' (cached)' : f.error ? ` — FAILED ${f.error}` : ' (not downloaded)'}`);
|
||||
return [...l, ''];
|
||||
}),
|
||||
].join('\n');
|
||||
await writeFile(ledgerPath, prev + block, 'utf8');
|
||||
|
||||
console.log(`\n=== source (${kind}) ===`);
|
||||
console.log(`found : ${items.length} for "${query}"`);
|
||||
const cached = items.reduce((n, it) => n + it.files.filter(f => f.skipped).length, 0);
|
||||
if (!listOnly) console.log(`written : ${files} new${cached ? ` + ${cached} already on disk` : ''}, ${(bytes / 1048576).toFixed(1)}MB → ${join(outDir, kind)}`);
|
||||
const needsCredit = items.filter(i => i.attribution);
|
||||
if (needsCredit.length) console.log(`⚠ credit: ${needsCredit.length} asset(s) REQUIRE visible attribution — see the ledger`);
|
||||
if (kind === 'video') console.log(`⚠ stock video is ambient/fallback material. If your peak scene is stock, you have no peak scene.`);
|
||||
console.log(`ledger : ${ledgerPath}`);
|
||||
process.exit(0);
|
||||
428
optional-skills/creative/auteur/scripts/systemscan.mjs
Normal file
428
optional-skills/creative/auteur/scripts/systemscan.mjs
Normal file
@@ -0,0 +1,428 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* systemscan.mjs — the multi-screen gate: does this product still have ONE design system?
|
||||
*
|
||||
* slopscan reads source and catches defaults. shoot.mjs photographs one page. Neither can see the
|
||||
* failure mode of a real app: drift. Screen 1 has three button variants, screen 7 invents a fourth,
|
||||
* and nobody notices because every screen looks fine on its own. This crawls every route, reads what
|
||||
* the browser ACTUALLY PAINTED, and reports the system as built rather than as documented.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/systemscan.mjs <url> [<url>...]
|
||||
* node scripts/systemscan.mjs http://localhost:3000 --routes /,/settings,/billing
|
||||
*
|
||||
* Options:
|
||||
* --routes a,b,c paths to append to the first url (instead of listing full urls)
|
||||
* --out DIR output dir (default design/system)
|
||||
* --max-variants B variant budget: a number, or per kind — `button=5,select=1,4` (default 4)
|
||||
* --width N viewport width (default 1440)
|
||||
* --no-shots skip the component contact sheet
|
||||
*
|
||||
* Writes DIR/SYSTEM-REPORT.md (read it), DIR/system.json, DIR/components.png.
|
||||
* Exit 1 when the system is provably broken: a control type over budget, an interactive element with
|
||||
* no visible focus state, or a token used on exactly one route.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { resolve, join } from 'path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length || args.includes('--help')) {
|
||||
console.log(`systemscan.mjs — cross-route design-system drift gate
|
||||
|
||||
node scripts/systemscan.mjs <url> [<url>...]
|
||||
node scripts/systemscan.mjs http://localhost:3000 --routes /,/settings,/billing
|
||||
|
||||
--out DIR --max-variants 4|button=5,select=1 --width 1440 --no-shots`);
|
||||
process.exit(0);
|
||||
}
|
||||
const get = (f, d) => { const i = args.indexOf(f); return i !== -1 ? args[i + 1] : d; };
|
||||
const has = f => args.includes(f);
|
||||
|
||||
const outDir = resolve(get('--out', 'design/system'));
|
||||
// Per-kind budgets: `--max-variants button=5,select=1` (a bare number sets the default for the rest).
|
||||
// One global number meant a sheet declaring button:5 and select:1 had to pass 5, leaving every
|
||||
// smaller kind unpoliced — the sheet's central promise enforced for exactly one control kind.
|
||||
const rawBudget = get('--max-variants', '4');
|
||||
const budget = { default: 4, byKind: {} };
|
||||
for (const part of String(rawBudget).split(',').map(s => s.trim()).filter(Boolean)) {
|
||||
const m = part.match(/^([a-z]+)\s*=\s*(\d+)$/i);
|
||||
if (m) budget.byKind[m[1].toLowerCase()] = +m[2];
|
||||
else if (/^\d+$/.test(part)) budget.default = +part;
|
||||
else console.error(`[systemscan] ignoring unparseable budget "${part}" — use 4 or button=5,select=1`);
|
||||
}
|
||||
const budgetFor = kind => budget.byKind[kind] ?? budget.default;
|
||||
const width = parseInt(get('--width', '1440'), 10) || 1440;
|
||||
const shots = !has('--no-shots');
|
||||
|
||||
let urls = args.filter(a => /^https?:\/\//.test(a) || a.startsWith('file://'));
|
||||
const routes = get('--routes', null);
|
||||
if (routes && urls.length) {
|
||||
const base = urls[0].replace(/\/$/, '');
|
||||
urls = routes.split(',').map(r => base + (r.startsWith('/') ? r : '/' + r));
|
||||
}
|
||||
if (!urls.length) { console.error('[systemscan] no urls. Pass them, or a base url + --routes /a,/b'); process.exit(1); }
|
||||
|
||||
let chromium;
|
||||
try { ({ chromium } = await import('playwright')); }
|
||||
catch { console.error('playwright not found. Install: npm i -D playwright && npx playwright install chromium'); process.exit(2); }
|
||||
|
||||
// Everything below is read off getComputedStyle, i.e. the system as PAINTED. A token that exists in
|
||||
// the stylesheet but is never rendered is not part of the system; a one-off inline style is.
|
||||
const COLLECT = () => {
|
||||
const cs = getComputedStyle;
|
||||
const px = v => Math.round(parseFloat(v) || 0);
|
||||
const vis = el => {
|
||||
const s = cs(el);
|
||||
if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 1 && r.height > 1;
|
||||
};
|
||||
const all = [...document.querySelectorAll('*')].slice(0, 6000).filter(vis);
|
||||
|
||||
const controlKind = el => {
|
||||
const t = el.tagName.toLowerCase();
|
||||
const role = el.getAttribute('role');
|
||||
if (t === 'button' || role === 'button') return 'button';
|
||||
if (t === 'a' && /(^|\s)(btn|button)/i.test(el.className || '')) return 'button';
|
||||
if (t === 'a') return 'link';
|
||||
if (t === 'input') return ['checkbox', 'radio'].includes(el.type) ? 'toggle' : 'input';
|
||||
if (t === 'select') return 'select';
|
||||
if (t === 'textarea') return 'input';
|
||||
if (role === 'tab') return 'tab';
|
||||
return null;
|
||||
};
|
||||
|
||||
// The signature is what a user can SEE. Two buttons with different class names but identical
|
||||
// paint are one variant; two with the same class and different paint are two.
|
||||
const sig = el => {
|
||||
const s = cs(el);
|
||||
return [
|
||||
s.backgroundColor, s.color, s.borderColor,
|
||||
`${px(s.borderTopWidth)}b`, `${px(s.borderTopLeftRadius)}r`,
|
||||
`${px(s.fontSize)}/${s.fontWeight}`,
|
||||
`${px(s.paddingTop)}x${px(s.paddingLeft)}`,
|
||||
s.boxShadow === 'none' ? 'noshadow' : 'shadow',
|
||||
].join(' | ');
|
||||
};
|
||||
|
||||
// A STATE is not a variant. A disabled secondary button paints differently from an enabled one by
|
||||
// design — that is the state matrix system.md demands, and counting it as a fifth button punishes
|
||||
// the team that built it while a system with no disabled state at all sails through. Same for a
|
||||
// control that inverts because the row it sits in is in an alert state: the component did not
|
||||
// multiply, its container changed colour underneath it. Both are counted and reported separately.
|
||||
const stateOf = el => {
|
||||
if (el.disabled || el.getAttribute('aria-disabled') === 'true') return 'disabled';
|
||||
if (el.getAttribute('aria-current') || el.getAttribute('aria-selected') === 'true') return 'current';
|
||||
for (let n = el.parentElement, hops = 0; n && hops < 4; n = n.parentElement, hops++) {
|
||||
const st = n.getAttribute?.('data-state');
|
||||
if (st && st !== 'default') return `in-${st}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const controls = {};
|
||||
const states = {};
|
||||
let shotId = 0;
|
||||
for (const el of all) {
|
||||
const kind = controlKind(el);
|
||||
if (!kind) continue;
|
||||
const state = stateOf(el);
|
||||
if (state) { (states[kind] ??= {})[state] = ((states[kind] ??= {})[state] || 0) + 1; continue; }
|
||||
const k = sig(el);
|
||||
(controls[kind] ??= {});
|
||||
if (!controls[kind][k]) {
|
||||
// Tag the exemplar NOW and re-select it by attribute in the screenshot pass. Handing a
|
||||
// positional index between two independent DOM walks produced tiles that showed a parent or a
|
||||
// sibling rather than the control they were captioned with — which destroys the sheet's whole
|
||||
// purpose, because a tile showing the wrong element looks different for the wrong reason.
|
||||
const tag = `ss${shotId++}`;
|
||||
el.setAttribute('data-ss-shot', tag);
|
||||
controls[kind][k] = { count: 0, sample: (el.innerText || el.value || el.type || '').trim().slice(0, 24), tag };
|
||||
}
|
||||
controls[kind][k].count++;
|
||||
}
|
||||
|
||||
const tally = (map, key) => { map[key] = (map[key] || 0) + 1; };
|
||||
const colors = {}, type = {}, radii = {}, shadows = {}, space = {};
|
||||
for (const el of all) {
|
||||
const s = cs(el);
|
||||
if (s.backgroundColor && s.backgroundColor !== 'rgba(0, 0, 0, 0)') tally(colors, s.backgroundColor);
|
||||
// `color` on a wrapper paints no glyph. Tallying it unfiltered made <html>'s inherited initial
|
||||
// colour the most common "colour in the product" and inflates the headline count for everyone.
|
||||
const leafText = el.children.length === 0 && (el.textContent || '').trim();
|
||||
if (s.color && leafText) tally(colors, s.color);
|
||||
if (leafText) {
|
||||
tally(type, `${px(s.fontSize)}px/${s.fontWeight}/${s.fontFamily.split(',')[0].replace(/["']/g, '')}`);
|
||||
}
|
||||
const r = px(s.borderTopLeftRadius); if (r) tally(radii, `${r}px`);
|
||||
if (s.boxShadow && s.boxShadow !== 'none') tally(shadows, s.boxShadow.slice(0, 60));
|
||||
for (const v of [s.paddingTop, s.paddingLeft, s.marginTop]) { const n = px(v); if (n) tally(space, `${n}px`); }
|
||||
}
|
||||
|
||||
return {
|
||||
controls, states, colors, type, radii, shadows, space,
|
||||
focusable: all.filter(el => el.matches('a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])')).length,
|
||||
hasDisabled: all.some(el => el.matches('[disabled],[aria-disabled="true"]')),
|
||||
title: document.title.slice(0, 60),
|
||||
landmarks: ['header', 'nav', 'main', 'footer', 'aside'].filter(t => document.querySelector(t)).join(','),
|
||||
h1: document.querySelectorAll('h1').length,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus state cannot be read from a stylesheet: a `:focus-visible` rule may exist and be overridden,
|
||||
* and `:focus-visible` itself is a heuristic that programmatic `.focus()` does not reliably trigger.
|
||||
* So drive the real thing — press Tab and look at what the browser actually paints. Tabbing also
|
||||
* skips disabled and unfocusable elements for free, which a `.focus()` loop reports as failures.
|
||||
*/
|
||||
const PROBE_FOCUS = async (page, maxStops = 40) => {
|
||||
const paint = () => {
|
||||
const cs = getComputedStyle;
|
||||
// Only properties that actually PAINT. outline-offset alone draws nothing, and including it
|
||||
// let an element that kills its outline still read as "focus state changed".
|
||||
const sig = el => { const s = cs(el); return `${s.outlineStyle === 'none' ? 'no-outline' : s.outline} ${s.boxShadow} ${s.borderColor} ${s.backgroundColor} ${s.color} ${s.textDecorationLine}`; };
|
||||
const els = [...document.querySelectorAll('a[href],button,input,select,textarea,summary,[tabindex]:not([tabindex="-1"])')];
|
||||
els.forEach((el, i) => el.setAttribute('data-ss-i', String(i)));
|
||||
return els.map(sig);
|
||||
};
|
||||
const unfocused = await page.evaluate(paint);
|
||||
await page.evaluate(() => (document.activeElement || document.body).blur?.());
|
||||
|
||||
const bad = [], seen = new Set();
|
||||
for (let i = 0; i < maxStops; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
const stop = await page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
if (!el || el === document.body || !el.hasAttribute?.('data-ss-i')) return null;
|
||||
const s = getComputedStyle(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
i: +el.getAttribute('data-ss-i'),
|
||||
sig: `${s.outlineStyle === 'none' ? 'no-outline' : s.outline} ${s.boxShadow} ${s.borderColor} ${s.backgroundColor} ${s.color} ${s.textDecorationLine}`,
|
||||
label: el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + ' “' + (el.innerText || el.value || el.getAttribute('aria-label') || '').trim().slice(0, 20) + '”',
|
||||
offscreen: r.width < 1 || r.height < 1,
|
||||
};
|
||||
});
|
||||
if (!stop) continue;
|
||||
if (seen.has(stop.i)) break; // wrapped around the tab ring
|
||||
seen.add(stop.i);
|
||||
if (!stop.offscreen && unfocused[stop.i] === stop.sig) bad.push(stop.label);
|
||||
}
|
||||
return { probed: seen.size, noFocusRing: bad };
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ viewport: { width, height: 900 } });
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const perRoute = [];
|
||||
for (const url of urls) {
|
||||
const page = await ctx.newPage();
|
||||
const errs = [];
|
||||
page.on('pageerror', e => errs.push(e.message));
|
||||
page.on('console', m => m.type() === 'error' && errs.push(m.text()));
|
||||
try {
|
||||
process.stderr.write(`[systemscan] ${url}\n`);
|
||||
const resp = await page.goto(url, { waitUntil: 'commit', timeout: 30_000 });
|
||||
const dcl = await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).then(() => true).catch(() => false);
|
||||
await page.waitForTimeout(dcl ? 2500 : 6000);
|
||||
if (!dcl) await page.evaluate(() => window.stop()).catch(() => {});
|
||||
const data = await page.evaluate(COLLECT);
|
||||
const focus = await PROBE_FOCUS(page);
|
||||
const status = resp?.status?.() ?? 0;
|
||||
// A route that renders nothing contributes nothing to the counts, so a mistyped route list made
|
||||
// the gate QUIETER instead of louder. A gate that goes green on a 404 is worse than no gate.
|
||||
const empty = !data.landmarks && !data.h1 && !data.focusable;
|
||||
perRoute.push({ url, ...data, focus, errors: errs, status, empty });
|
||||
if (status >= 400) console.error(`[systemscan] ${url} → HTTP ${status}`);
|
||||
else if (empty) console.error(`[systemscan] ${url} → rendered nothing measurable (no landmark, no h1, no focusable)`);
|
||||
|
||||
if (shots) {
|
||||
// one exemplar screenshot per distinct control variant, so the report has a picture of the
|
||||
// drift and not only a count of it
|
||||
for (const [kind, variants] of Object.entries(data.controls)) {
|
||||
let i = 0;
|
||||
for (const [, v] of Object.entries(variants)) {
|
||||
if (!v.tag) continue;
|
||||
const name = `${kind}-${perRoute.length}-${i++}.png`;
|
||||
try {
|
||||
const box = await page.$(`[data-ss-shot="${v.tag}"]`); // the element we actually measured
|
||||
if (box) { await box.scrollIntoViewIfNeeded({ timeout: 4000 }); await box.screenshot({ path: join(outDir, 'components', name), timeout: 8000 }); v.shot = `components/${name}`; }
|
||||
} catch { /* an element that will not sit still is not worth failing the run over */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
perRoute.push({ url, error: e.message.split('\n')[0] });
|
||||
} finally { await page.close(); }
|
||||
}
|
||||
|
||||
// ---- aggregate across routes -------------------------------------------------
|
||||
const ok = perRoute.filter(r => !r.error);
|
||||
if (!ok.length) { console.error('[systemscan] no route could be read.'); await browser.close(); process.exit(1); }
|
||||
|
||||
// A document scanned twice (`/settings` and `/settings#state-error` are the same DOM) must not count
|
||||
// twice — summing across URLs meant scanning MORE thoroughly hid genuine one-off variants, which is
|
||||
// the opposite of what `system.md` tells you to do. Count per document, take the max, then sum.
|
||||
const docOf = u => u.split('#')[0];
|
||||
const docs = [...new Set(ok.map(r => docOf(r.url)))];
|
||||
|
||||
const mergeCount = key => {
|
||||
const m = {};
|
||||
for (const r of ok) for (const [k, n] of Object.entries(r[key] || {})) {
|
||||
(m[k] ??= { perDoc: {} });
|
||||
const d = docOf(r.url);
|
||||
m[k].perDoc[d] = Math.max(m[k].perDoc[d] || 0, n);
|
||||
}
|
||||
return Object.entries(m)
|
||||
.map(([k, v]) => ({ k, total: Object.values(v.perDoc).reduce((a, b) => a + b, 0), routes: Object.keys(v.perDoc).length }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
};
|
||||
|
||||
const controlVariants = {};
|
||||
for (const r of ok) for (const [kind, vars] of Object.entries(r.controls || {})) {
|
||||
(controlVariants[kind] ??= {});
|
||||
for (const [sig, v] of Object.entries(vars)) {
|
||||
const slot = (controlVariants[kind][sig] ??= { perDoc: {}, sample: v.sample, shot: v.shot });
|
||||
const d = docOf(r.url);
|
||||
slot.perDoc[d] = Math.max(slot.perDoc[d] || 0, v.count);
|
||||
slot.shot ??= v.shot;
|
||||
}
|
||||
}
|
||||
for (const vars of Object.values(controlVariants)) for (const v of Object.values(vars)) {
|
||||
v.count = Object.values(v.perDoc).reduce((a, b) => a + b, 0);
|
||||
v.routes = new Set(Object.keys(v.perDoc));
|
||||
}
|
||||
|
||||
// States, gathered the same way but never counted against the variant budget.
|
||||
const controlStates = {};
|
||||
for (const r of ok) for (const [kind, st] of Object.entries(r.states || {}))
|
||||
for (const [name, n] of Object.entries(st)) {
|
||||
((controlStates[kind] ??= {})[name] ??= 0);
|
||||
controlStates[kind][name] += n;
|
||||
}
|
||||
|
||||
const fails = [], warns = [];
|
||||
for (const [kind, vars] of Object.entries(controlVariants)) {
|
||||
const n = Object.keys(vars).length, b = budgetFor(kind);
|
||||
if (n > b) fails.push(`${kind}: ${n} distinct rendered variants (budget ${b}). A variant nobody can name is drift.`);
|
||||
for (const [sig, v] of Object.entries(vars)) {
|
||||
if (v.count === 1 && n > 1) warns.push(`${kind} variant used exactly once ("${v.sample}") — either promote it into the system or delete it: ${sig}`);
|
||||
}
|
||||
}
|
||||
const blank = perRoute.filter(r => !r.error && (r.status >= 400 || r.empty));
|
||||
if (blank.length) fails.push(`${blank.length} route(s) rendered nothing measurable or returned an error status — a gate that goes green on a 404 is worse than no gate: ${blank.map(r => `${r.url}${r.status >= 400 ? ` (HTTP ${r.status})` : ''}`).join(', ')}`);
|
||||
const noFocus = ok.flatMap(r => (r.focus?.noFocusRing || []).map(t => `${r.url} → ${t}`));
|
||||
if (noFocus.length) fails.push(`${noFocus.length} interactive element(s) paint identically when focused — keyboard users cannot see where they are`);
|
||||
|
||||
const colors = mergeCount('colors'), type = mergeCount('type'), radii = mergeCount('radii'), shadows = mergeCount('shadows');
|
||||
if (docs.length > 1) {
|
||||
for (const [label, list] of [['colour', colors], ['type step', type], ['radius', radii]]) {
|
||||
const singles = list.filter(x => x.routes === 1 && x.total >= 3);
|
||||
if (singles.length) warns.push(`${singles.length} ${label}(s) appear on exactly one route and nowhere else — that is where the system is splitting: ${singles.slice(0, 4).map(s => s.k).join(' · ')}`);
|
||||
}
|
||||
}
|
||||
if (type.length > 12) warns.push(`${type.length} distinct type steps across the product — a scale nobody can hold in their head is not a scale`);
|
||||
if (!ok.some(r => r.hasDisabled)) warns.push('no disabled control appeared on any route — the disabled state is probably undesigned, not absent');
|
||||
|
||||
// ---- component contact sheet -------------------------------------------------
|
||||
let sheet = null;
|
||||
const tiles = Object.entries(controlVariants).flatMap(([kind, vars]) =>
|
||||
Object.entries(vars).filter(([, v]) => v.shot).map(([, v], i) => ({ kind, i, ...v })));
|
||||
if (shots && tiles.length) {
|
||||
const html = `<!doctype html><meta charset="utf-8"><style>
|
||||
body{margin:0;background:#141414;font:12px/1.3 ui-monospace,monospace;color:#ddd}
|
||||
.g{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:12px}
|
||||
figure{margin:0;background:#1e1e1e;border-radius:4px;overflow:hidden;padding:10px}
|
||||
img{display:block;max-width:100%;margin:0 auto 8px}
|
||||
b{color:#fff}
|
||||
</style><div class=g>${tiles.map(t =>
|
||||
`<figure><img src="${t.shot}"><figcaption><b>${t.kind}</b> ×${t.count} · ${t.routes.size} route(s)</figcaption></figure>`).join('')}</div>`;
|
||||
const f = join(outDir, '_components.html');
|
||||
await writeFile(f, html, 'utf8');
|
||||
const p = await ctx.newPage();
|
||||
await p.setViewportSize({ width: 1200, height: 900 });
|
||||
await p.goto('file:///' + f.replace(/\\/g, '/'), { waitUntil: 'load', timeout: 20_000 }).catch(() => {});
|
||||
await p.waitForTimeout(900);
|
||||
// shoot the grid, not the viewport — a fullPage shot of a two-row sheet is mostly empty canvas
|
||||
const grid = await p.$('.g');
|
||||
await (grid || p).screenshot({ path: join(outDir, 'components.png') });
|
||||
await p.close();
|
||||
sheet = 'components.png';
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
// ---- report ------------------------------------------------------------------
|
||||
const L = [];
|
||||
L.push(`# SYSTEM-REPORT — ${ok.length} route(s), ${new Date().toISOString().slice(0, 10)}`);
|
||||
L.push('');
|
||||
L.push('The system as **painted**, not as documented. A token in the stylesheet that never renders is');
|
||||
L.push('not part of the system; a one-off inline style is. Read this against `design/DESIGN.md` — every');
|
||||
L.push('number below that DESIGN.md does not account for is drift.');
|
||||
L.push('');
|
||||
if (sheet) L.push(`**Look at \`${sheet}\`** — one tile per distinct rendered control variant. Two tiles that look the same to you but appear separately are the drift.\n`);
|
||||
|
||||
L.push('## Controls');
|
||||
L.push('');
|
||||
L.push('| kind | distinct variants | budget | total instances |');
|
||||
L.push('|---|---|---|---|');
|
||||
for (const [kind, vars] of Object.entries(controlVariants)) {
|
||||
const n = Object.keys(vars).length;
|
||||
L.push(`| ${kind} | **${n}** | ${budgetFor(kind)} | ${Object.values(vars).reduce((s, v) => s + v.count, 0)} |`);
|
||||
}
|
||||
L.push('');
|
||||
for (const [kind, vars] of Object.entries(controlVariants)) {
|
||||
L.push(`### ${kind}`);
|
||||
for (const [sig, v] of Object.entries(vars)) L.push(`- ×${v.count} on ${v.routes.size} route(s) — "${v.sample}" — \`${sig}\``);
|
||||
if (controlStates[kind]) {
|
||||
const st = Object.entries(controlStates[kind]).map(([n, c]) => `${n} ×${c}`).join(' · ');
|
||||
L.push(`- *states (not counted as variants): ${st}*`);
|
||||
}
|
||||
L.push('');
|
||||
}
|
||||
if (Object.keys(controlStates).length) {
|
||||
L.push('> States — disabled, current, and controls inside a row carrying a `data-state` — are');
|
||||
L.push('> excluded from the variant budget. A disabled button paints differently on purpose; a');
|
||||
L.push('> product that has no disabled state at all should not score better than one that does.');
|
||||
L.push('');
|
||||
}
|
||||
|
||||
L.push('## Tokens as rendered');
|
||||
L.push('');
|
||||
for (const [label, list] of [['Colour', colors], ['Type step', type], ['Radius', radii], ['Shadow', shadows]]) {
|
||||
L.push(`**${label}** (${list.length} distinct)`);
|
||||
for (const x of list.slice(0, 10)) L.push(`- \`${x.k}\` — ${x.total}× on ${x.routes} route(s)`);
|
||||
if (list.length > 10) L.push(`- …and ${list.length - 10} more`);
|
||||
L.push('');
|
||||
}
|
||||
|
||||
L.push('## Per route');
|
||||
L.push('');
|
||||
L.push('| route | landmarks | h1 | focusable | console errors |');
|
||||
L.push('|---|---|---|---|---|');
|
||||
for (const r of ok) L.push(`| ${r.url} | ${r.landmarks || '—'} | ${r.h1} | ${r.focusable} | ${r.errors.length} |`);
|
||||
for (const r of perRoute.filter(r => r.error)) L.push(`| ${r.url} | **unreachable** — ${r.error} | | | |`);
|
||||
L.push('');
|
||||
|
||||
if (fails.length) { L.push('## FAIL'); fails.forEach(f => L.push(`- ${f}`)); L.push(''); }
|
||||
if (noFocus.length) { L.push('### Elements with no visible focus state'); noFocus.slice(0, 20).forEach(t => L.push(`- ${t}`)); L.push(''); }
|
||||
if (warns.length) { L.push('## WARN'); warns.forEach(w => L.push(`- ${w}`)); L.push(''); }
|
||||
if (!fails.length && !warns.length) L.push('No drift detected. The product renders one system.\n');
|
||||
|
||||
await writeFile(join(outDir, 'SYSTEM-REPORT.md'), L.join('\n'), 'utf8');
|
||||
await writeFile(join(outDir, 'system.json'), JSON.stringify({
|
||||
routes: perRoute.map(r => ({ ...r, controls: undefined })),
|
||||
controls: Object.fromEntries(Object.entries(controlVariants).map(([k, v]) =>
|
||||
[k, Object.entries(v).map(([sig, x]) => ({ sig, count: x.count, routes: [...x.routes], sample: x.sample }))])),
|
||||
colors, type, radii, shadows, fails, warns,
|
||||
}, null, 2), 'utf8');
|
||||
|
||||
console.log(`\n=== systemscan ===`);
|
||||
console.log(`routes : ${ok.length}/${perRoute.length} read (${docs.length} distinct document(s))`);
|
||||
console.log(`controls: ${Object.entries(controlVariants).map(([k, v]) => `${k} ${Object.keys(v).length}`).join(' · ') || '—'}`);
|
||||
console.log(`tokens : ${colors.length} colours · ${type.length} type steps · ${radii.length} radii · ${shadows.length} shadows`);
|
||||
console.log(`report : ${join(outDir, 'SYSTEM-REPORT.md')}${sheet ? `\nsheet : ${join(outDir, sheet)}` : ''}`);
|
||||
for (const f of fails) console.log(`FAIL ${f}`);
|
||||
for (const w of warns.slice(0, 5)) console.log(`WARN ${w}`);
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
33
optional-skills/creative/auteur/templates/CINEMA-QA.md
Normal file
33
optional-skills/creative/auteur/templates/CINEMA-QA.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# CINEMA-QA — <project>
|
||||
|
||||
> Fill every row with PASS/FAIL + evidence (metric value or screenshot filename). Any FAIL loops back to its phase. Ship only on all-PASS.
|
||||
|
||||
| # | Check | PASS/FAIL | Evidence |
|
||||
|---|-------|-----------|----------|
|
||||
| 1 | slopscan exit 0, all suppressions carry real reasons | | paste the `Summary:` line verbatim, from a run AFTER the last edit |
|
||||
| 2 | Screenshot journey reviewed frame-by-frame (390/768/1440) | | shots dir + frames flagged→fixed |
|
||||
| 3 | No text overflow / ugly wraps at any breakpoint | | e.g. "fixed S4 h1 at 390" |
|
||||
| 4 | No blank / half-fired scenes in any frame | | |
|
||||
| 5 | Adjacent scenes differ in layout & motion family | | frame pairs compared |
|
||||
| 6 | Exactly one peak (intensity ≥8) on the built page | | scene # |
|
||||
| 7 | Scrub replays cleanly scrolling UP | | manual pass |
|
||||
| 8 | Reduced-motion cut watchable end-to-end, nothing blank | | rm- frames |
|
||||
| 9 | Body contrast ≥4.5:1 (large ≥3:1, placeholders ≥4.5:1) | | measured pairs |
|
||||
| 10 | LCP <2.5s (throttled) | | value |
|
||||
| 11 | CLS <0.1 | | value |
|
||||
| 12 | INP <200ms | | value |
|
||||
| 13 | Hero video ≤2MB; poster ≤300KB; frames ≤150KB | | sizes |
|
||||
| 14 | Motion budget ≤3 families, matches commit-sheet | | list |
|
||||
| 15 | Keyboard: focus visible & designed, no traps, ESC works | | manual pass |
|
||||
| 16 | No-JS: content readable, nothing hidden behind reveals | | manual pass |
|
||||
| 17 | No console errors during full journey | | shoot.mjs summary |
|
||||
| 18 | Mobile: pinned scenes shortened/unpinned, assets 720p | | |
|
||||
| 19 | Sound (if any): off by default, gesture-gated, toggle visible | | or n/a |
|
||||
| 20 | Watched the film: one slow + one fast full scroll, no felt jank | | |
|
||||
| 21 | Perf measured at DPR 2 on the production build, not a dev server | | motionqa line, verbatim |
|
||||
| 22 | Reference diff done: own frame vs the recon reference, greyscaled too | | what it did better / what changed / what was left |
|
||||
| 23 | chromadiff vs the reference passes (no cheerful drift) | | paste the chromadiff line |
|
||||
| 24 | Background lightness within ±0.12 of commit-sheet §2 | | `--target-l` line |
|
||||
| 25 | The two house tells named in commit-sheet §7 are actually broken | | which two, and what replaced them |
|
||||
|
||||
**Verdict:** SHIP / LOOP BACK TO PHASE …
|
||||
50
optional-skills/creative/auteur/templates/COMMIT-SHEET.md
Normal file
50
optional-skills/creative/auteur/templates/COMMIT-SHEET.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# COMMIT-SHEET — <project>
|
||||
|
||||
> Seven decisions before the first line of code. A generic answer ("modern, clean") means the decision hasn't been made — stop and make it. Filled example answers below each field: ✗ is what slop looks like, ✓ is the bar.
|
||||
|
||||
## 1. Peak / Signature
|
||||
<!-- direct: the ONE wow moment. build: the one element a visitor describes to a friend. -->
|
||||
…
|
||||
> ✗ "beautiful animations throughout"
|
||||
> ✓ "peak = scene 4: the espresso machine disassembles into 9 floating parts as you scroll (canvas sequence, 300vh pinned)"
|
||||
|
||||
## 2. Color
|
||||
<!-- primary as OKLCH + tier (restrained / committed / full-palette / drenched) + why it's not lavender, not cream, not the category reflex
|
||||
+ the BACKGROUND LIGHTNESS as a number: target mean L, and one line on why the page lives at that level.
|
||||
"Dark because it's premium" is not a reason — it is the single most common place this skill drifts. -->
|
||||
…
|
||||
> ✗ "purple gradient, feels techy" · "dark theme, feels premium"
|
||||
> ✓ "oklch(0.58 0.19 35) burnt terracotta, tier: committed (~40% of surface). Not the AI lavender; not wellness-beige — terracotta is pulled from the product's clay housing. Background L ≈ 0.55: the machine is photographed in a daylit workshop, and a black page would make the clay read as ceramic-shop-at-night"
|
||||
|
||||
## 3. Type
|
||||
<!-- display + text pair on a contrast axis + why not Inter -->
|
||||
…
|
||||
> ✗ "Inter for everything, it's readable"
|
||||
> ✓ "display: Fraunces (soft wide serif, brand's warmth) / text: Söhne-class grotesque via 'Archivo'. Axis: high-contrast serif × neutral grotesque. Inter rejected as the 2024–26 default"
|
||||
|
||||
## 4. Grid break
|
||||
<!-- the ONE concrete thing that breaks the symmetric grid -->
|
||||
…
|
||||
> ✗ "asymmetric layout"
|
||||
> ✓ "product photo in S2 crosses into S3 over the section boundary (−120px overlap), text wraps around it via shape-outside"
|
||||
|
||||
## 5. Motion budget
|
||||
<!-- ≤3 scroll-pattern families, named -->
|
||||
…
|
||||
> ✓ "(1) scrub-pinned hero, (2) per-word text reveals on headings, (3) depth parallax in proof section. Nothing else scroll-triggered"
|
||||
|
||||
## 6. Reflex check
|
||||
<!-- (a) what a generic AI does for this category; (b) what a generic AI avoiding (a) does; (c) our argued deviation from both.
|
||||
If recon ran, (a) is evidence, not a guess — cite what design/refs/REFERENCES.md showed repeatedly. -->
|
||||
a) …
|
||||
b) …
|
||||
c) …
|
||||
> ✓ "a) coffee = warm beige + serif + steam photo; b) 'specialty' = black + neon + brutalist menu; c) ours = terracotta drench + technical cutaway drawings of the machine (the brand is engineering-led, not café-cozy)"
|
||||
|
||||
## 7. House tells broken
|
||||
<!-- The two (minimum) items from taste.md §2.5 you are deliberately NOT doing this time, and what replaces each.
|
||||
(a) and (b) above are reflexes of the CATEGORY; these are the reflexes of this SKILL, which recur across
|
||||
projects that have nothing to do with each other. Naming them is the only thing that stops them. -->
|
||||
1. …
|
||||
2. …
|
||||
> ✓ "1. near-black background → daylit L 0.55 paper, the shadows do the drama instead; 2. mono service labels in the corners → no chrome at all, the only type on screen is the headline and one caption"
|
||||
70
optional-skills/creative/auteur/templates/DESIGN.md
Normal file
70
optional-skills/creative/auteur/templates/DESIGN.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# DESIGN — <project>
|
||||
|
||||
> The style contract. Written once after the build passes QA; read at the START of every subsequent edit. New work that contradicts this file is wrong even if it looks good in isolation — consistency IS the design. Update the file deliberately when the system itself evolves; never drift it silently.
|
||||
|
||||
## Identity
|
||||
- **The one feeling:** <!-- from the storyboard -->
|
||||
- **Signature / peak:** <!-- what a visitor describes to a friend; do not dilute it with competing spectacles -->
|
||||
- **Register:** build | direct
|
||||
|
||||
## Tokens (verbatim from the shipped CSS)
|
||||
```css
|
||||
:root {
|
||||
/* colors (OKLCH) — bg, surface, ink, muted, accent, + roles */
|
||||
|
||||
/* type scale (clamp-based) */
|
||||
|
||||
/* spacing scale */
|
||||
|
||||
/* radii, shadows, z-scale */
|
||||
|
||||
/* easing custom properties */
|
||||
}
|
||||
```
|
||||
|
||||
## Typography
|
||||
- **Display:** <family, weights, where used, letter-spacing rules>
|
||||
- **Text:** <family, sizes, line-heights, measure>
|
||||
- **Numerals/mono:** <if any>
|
||||
- Pairing axis and the reason (from COMMIT-SHEET). New text styles must come from this system — no new fonts.
|
||||
|
||||
## Color rules
|
||||
- Commitment tier: <restrained/committed/full/drenched> — accent carries ~N% of surface.
|
||||
- What each role means semantically (e.g. "green = money-positive, never decorative").
|
||||
- Forbidden in this project: <e.g. gradients entirely; any warm neutral; ...>
|
||||
|
||||
## Motion vocabulary
|
||||
- The ≤3 scroll families used, with their exact easing/duration/stagger values.
|
||||
- Hover/press/focus recipes (copy the shipped values).
|
||||
- New sections must reuse an existing family or consciously replace one (budget stays ≤3) — never add a fourth.
|
||||
|
||||
## Layout patterns
|
||||
- Grid system + the named grid-break(s) in play.
|
||||
- Section-opening patterns used (list them; rotate among these, don't invent an eyebrow).
|
||||
- Component patterns that exist (cards? tables? ledger rows?) — reuse before inventing.
|
||||
|
||||
## Copy voice
|
||||
- 2–3 adjectives + one example headline that nails it.
|
||||
- Button/label conventions. Banned words stay banned.
|
||||
|
||||
## Project ban additions
|
||||
- auteur-allow suppressions in force (rule + reason).
|
||||
- Anything this project additionally forbids beyond SKILL.md's list.
|
||||
|
||||
## Rejected — decisions that already have a history
|
||||
|
||||
Everything below was tried and taken out, for a reason the finished page no longer shows. Without this list the next session — or the next model — sees an apparent mistake, confidently "corrects" it to the obvious answer, and reintroduces a problem that was already paid for once. The obvious answer is what got rejected; that is the whole point of the row. Write the row the moment you reject something, not at the end when the reason has evaporated.
|
||||
|
||||
| What was tried | Why it lost | What is there instead |
|
||||
|---|---|---|
|
||||
| <e.g. subject lighter than its field> | <silhouette dissolved; no lighting fixed it> | <subject below the field in value, edges carry the light> |
|
||||
| <e.g. volumetric cones for the light shafts> | <read as cheap geometry; three rescue attempts, all worse> | <cut; the glow comes from the interior source alone> |
|
||||
|
||||
The same discipline inside the code: a bare constant teaches nothing, so every tuned number says why it is that number and what breaks otherwise — `--scrub-smooth: 0.45; /* >0.6 and the scene visibly lags the cursor */`. Numbers that carry their reason survive being cleaned up by someone who does not know the history.
|
||||
|
||||
## Editing protocol
|
||||
1. Read this file fully before touching anything.
|
||||
2. New section → pick an existing section-opening pattern + an existing motion family + existing tokens.
|
||||
3. After any edit: `node <skill>/scripts/slopscan.mjs <src>` and re-shoot the changed viewport(s); compare against neighboring sections for family consistency.
|
||||
4. If the edit genuinely needs a new pattern — update THIS file first (that's a design decision, not a patch).
|
||||
5. An edit that "fixes" something in the Rejected table is a regression, however reasonable it looks. Disagree in the file first — argue the row, change it, then edit the page.
|
||||
57
optional-skills/creative/auteur/templates/STORYBOARD.md
Normal file
57
optional-skills/creative/auteur/templates/STORYBOARD.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# STORYBOARD — <project>
|
||||
|
||||
## Film meta
|
||||
- **Product:**
|
||||
- **Audience:**
|
||||
- **The one feeling:** <!-- awe / calm / hunger / trust / momentum … -->
|
||||
- **Peak scene:** <!-- exactly one, intensity ≥8 -->
|
||||
- **Assets available up front:** <!-- footage / photos / 3D / none -->
|
||||
- **Sourced-asset findings:** <!-- what a sourced asset turned out to make possible that you did not plan for. Inspect before you write: a glTF's node names, an HDRI's actual light direction, a texture's tiling. "the mesh has 8 named nodes" is the kind of fact that changes the film, and it must not live only in one session's reasoning. -->
|
||||
- **Assumptions made:** <!-- if intake was autonomous, every answer you derived instead of asking goes HERE (direct.md §0a) -->
|
||||
- **References taken:** <!-- 2–3 from design/refs/REFERENCES.md: "site — the ONE mechanic — how it changes here". Only sites you looked at. -->
|
||||
- **Moodboard read:** <!-- one line: palette relationship + light character taken from design/moodboard, and the one thing on that sheet to avoid -->
|
||||
- **Style gate verdict:** <!-- approved / approved with carried notes / rejected+redone. Carried notes = known problems you are deliberately building on; list them so they get resolved, not forgotten. -->
|
||||
|
||||
## Arc
|
||||
<!-- hook → rising → PEAK → proof → door. 5–7 scenes. Adjacent scenes must differ in layout family AND motion family — the columns below are what makes Gate 0 checkable instead of a vibe. -->
|
||||
|
||||
| # | Scene | Beat | Intensity (1–10) | layout family | motion family |
|
||||
|---|-------|------|------------------|---------------|---------------|
|
||||
| 1 | | hook | | | |
|
||||
| 2 | | | | | |
|
||||
| … | | | | | |
|
||||
|
||||
<!-- layout family: full-bleed-media / split-asymmetric / centred-type / stacked-cards / editorial-columns / pinned-canvas / marginal-notes …
|
||||
motion family: scroll-scrub / pinned-stage / entrance-reveal / parallax-depth / kinetic-type / ambient-loop / none
|
||||
Motion families used across the whole page must be ≤3 (commit-sheet field 5); the same family may
|
||||
repeat, just never in two adjacent scenes. -->
|
||||
|
||||
---
|
||||
|
||||
### Scene 1 — <name> | beat: hook | intensity: N
|
||||
- **purpose:** feel: … / learn: …
|
||||
- **subject:**
|
||||
- **layout_family / motion_family:** <!-- must match the Arc table -->
|
||||
- **camera:** <!-- eye-level / low-angle / high-angle / macro / orbital / static -->
|
||||
- **lighting:** <!-- hard contrast / golden / dusk / studio / neon / paper-flat -->
|
||||
- **motion:** <!-- what moves, driven by scroll-scrub | entrance | loop | hover -->
|
||||
- **transition_in / out:** <!-- cut / wipe-mask / curtain / letterbox / shutter / depth-parallax / displacement / view-transition -->
|
||||
- **scroll_len:** <!-- 100vh–400vh. Literal for pinned and peak scenes; for content-height scenes write "content" rather than a number you will not honour. -->
|
||||
- **copy:** H: "…" / sub: "…" <!-- real words, not lorem -->
|
||||
- **media:** <!-- the director's shot spec — route via assets.md §0.5 (source) and §0 (generate) -->
|
||||
- type: <!-- still | A→B morph | video | sequence | element/texture | 3D model | HDRI | none (type-led) -->
|
||||
- route: <!-- SOURCE (source.mjs hdri|model|texture|icon|image|video) or GENERATE (codex | grok-4.5 | agy) — assets.md §0.5 decides -->
|
||||
- frame prompt: <!-- the literal keyframe prompt = subject + camera + lighting + palette anchor. Write it NOW, not in phase 1. -->
|
||||
- motion prompt: <!-- video/morph only. A controlled A→B state change is the WebGL displacement morph, NOT a video model. -->
|
||||
- score: <!-- peak/ambient scenes only: mood/tempo for MiniMax music, or "none" -->
|
||||
- **fallback:** <!-- the scene when WebGL/video/motion is unavailable -->
|
||||
|
||||
<!-- repeat per scene -->
|
||||
|
||||
## Gate 0 checklist
|
||||
- [ ] exactly one scene with intensity ≥8
|
||||
- [ ] no two adjacent scenes share layout family or motion family (check the Arc table)
|
||||
- [ ] ≤3 distinct motion families across the whole page
|
||||
- [ ] every scene has real copy and a fallback
|
||||
- [ ] every `media:` block is filled: type, route, and — for a GENERATED scene — a literal frame prompt; for a SOURCED or live-3D scene, the equivalent direction to the renderer (subject + camera + lighting + palette), which is what you tune the light rig against; for a type-led scene, `none`. A one-line "generate something" is not a producible ask
|
||||
- [ ] storyboard approved (user) / self-reviewed against the one feeling (autonomous)
|
||||
109
optional-skills/creative/auteur/templates/SYSTEM-SHEET.md
Normal file
109
optional-skills/creative/auteur/templates/SYSTEM-SHEET.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# SYSTEM-SHEET — <product>
|
||||
|
||||
> Filled BEFORE any markup. The route map says what exists; the inventory says what it is built from
|
||||
> and — critically — **how many variants of each thing are allowed**. That number is the budget
|
||||
> `systemscan` enforces after the build. Declaring four button variants and shipping nine is the
|
||||
> disease this file exists to prevent.
|
||||
|
||||
## Product
|
||||
- **What it does:**
|
||||
- **Who uses it, how often:** <!-- daily tools want less personality than monthly ones -->
|
||||
- **Stack / where the markup lands:** <!-- static, React, Vue, Rails views… and who plugs it in -->
|
||||
- **The moment of care:** <!-- the ONE place this product is more than correct. Not a wow moment — an empty state that genuinely helps, a table that does something smart, a keyboard flow that feels designed. There is no peak in this register. -->
|
||||
|
||||
## Route map
|
||||
|
||||
<!-- Every screen. A route nobody can describe in one line is a route nobody designed. -->
|
||||
|
||||
| route | job (one line) | layout family | in the shell? | traffic |
|
||||
|---|---|---|---|---|
|
||||
| `/` | | | yes | high |
|
||||
| `/…` | | | | |
|
||||
|
||||
**The shell** <!-- what persists across routes: nav, sidebar, header, page frame. Its mistakes are on every screen, so it is built first, right after tokens. -->
|
||||
- structure:
|
||||
- collapses to (mobile):
|
||||
- current-route indicator:
|
||||
|
||||
**Build order** <!-- by traffic, not by interest. The boring high-traffic screen sets the patterns. -->
|
||||
1.
|
||||
2.
|
||||
|
||||
## Component inventory
|
||||
|
||||
<!-- Every control the product needs, with its variant count DECLARED. Add a row only when a screen
|
||||
genuinely needs it — a component nobody has a route for is speculative work. -->
|
||||
|
||||
<!-- `systemscan` counts a variant as a PAINTED SIGNATURE: background | colour | border-colour |
|
||||
border-width | radius | font-size/weight | padding | shadow. A disabled button and a small
|
||||
button are separate variants whether or not you name them. `<a class="btn">` counts as a
|
||||
button, not a link. Pass these numbers per kind:
|
||||
--max-variants button=3,link=4,input=2,select=1 -->
|
||||
|
||||
| component | variants (name them all) | budget | where used |
|
||||
|---|---|---|---|
|
||||
| button | primary / ghost / danger | 3 | everywhere |
|
||||
| input | | | |
|
||||
| select | | | |
|
||||
| table | | | |
|
||||
| … | | | |
|
||||
|
||||
**Non-control components** — pills, badges, banners, skeletons, avatars, tags. `systemscan` does not
|
||||
count these (no focus, no interaction), which makes them exactly where drift breeds unseen. Declare
|
||||
them here anyway and police them by eye against `components.png`:
|
||||
|
||||
| component | variants | where used |
|
||||
|---|---|---|
|
||||
| status pill | | |
|
||||
|
||||
**Rule:** a variant not on this list does not get built. If a screen needs one, that is a design
|
||||
decision — edit this file first, then build it.
|
||||
|
||||
## State matrix
|
||||
|
||||
<!-- One row per component. Mark n/a where the state genuinely cannot occur, and say why.
|
||||
focus-visible is never n/a on anything interactive: systemscan presses Tab and looks. -->
|
||||
|
||||
| component | default | hover | focus-visible | active | disabled | loading | empty | error | selected |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| button | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | n/a | n/a | n/a |
|
||||
| | | | | | | | | | |
|
||||
|
||||
**Contrast survives every state.** A degraded, stale or disabled view usually dims something, and
|
||||
dimming is how a designed state quietly becomes unreadable — text at `opacity: .5` on a coloured
|
||||
surface can fall from 6.9:1 to under 3:1 while every linter stays silent, because they all read the
|
||||
undimmed computed colour. State the consequence for each state that changes opacity or colour:
|
||||
|
||||
| state | what it dims | measured contrast after dimming |
|
||||
|---|---|---|
|
||||
| error / stale | | |
|
||||
| disabled | | |
|
||||
|
||||
**How each state is reachable.** Five beautiful empty states nobody can open are five states nobody
|
||||
reviewed. Name the mechanism — a `:target` fragment, a query param, a fixture flag — preferring one
|
||||
that survives with JS off, and list the URLs:
|
||||
|
||||
- mechanism:
|
||||
- URLs:
|
||||
|
||||
**Empty / loading / error are not edge cases.** They are the first thing a new user sees. For each
|
||||
screen that can be empty, write the actual words:
|
||||
|
||||
| screen | empty state says | the one action that fills it |
|
||||
|---|---|---|
|
||||
| | | |
|
||||
|
||||
## Density
|
||||
|
||||
- **Tables:** rows per screen · sticky header? · sort affordance · `tabular-nums` on numerals
|
||||
- **Charts:** route to the `dataviz` skill — do not improvise a series palette here
|
||||
- **What gets truncated, and how the full value is reachable:**
|
||||
|
||||
## Gate checklist
|
||||
- [ ] every route has a one-line job and a layout family
|
||||
- [ ] the shell is described, including its mobile collapse and its current-route indicator
|
||||
- [ ] every component has a named, justified variant count — not "a few"
|
||||
- [ ] every interactive component's `focus-visible` is designed, not defaulted
|
||||
- [ ] empty / loading / error are written as real copy for every screen that can hit them
|
||||
- [ ] build order is by traffic
|
||||
- [ ] no peak. If a screen has a wow moment, justify it or cut it.
|
||||
@@ -0,0 +1,546 @@
|
||||
/* ============================================================================
|
||||
auteur · scroll-flight-engine.js
|
||||
----------------------------------------------------------------------------
|
||||
Portable scroll-scrubbed camera-flight engine. Photoreal AI-video "fly through
|
||||
the world" — auteur's video-scrub tier, complementary to its real-time WebGL
|
||||
recipes. Asset-source-agnostic: feed it ANY .mp4 clips (grok image_to_video,
|
||||
Gemini, real footage). See references/scroll-flight.md for the full recipe.
|
||||
|
||||
VENDORED from scroll-world (github.com/cth9191/scroll-world), MIT (c) 2026 cyw.
|
||||
Adapted for auteur — three changes from upstream:
|
||||
1. default --sw-accent shifted off the 250-290 deg AI-purple (auteur taste);
|
||||
every real site overrides --sw-accent anyway.
|
||||
2. the auteur-allow line below, so slopscan permits the engine's ONE scroll
|
||||
listener (rAF-gated + passive; smoothed-scroll libs like Lenis fight
|
||||
frame-accurate currentTime scrubbing, so native scroll is correct here).
|
||||
3. a scroll-driven camera dolly (see "auteur scroll-dolly" in read()): the
|
||||
scene pushes IN and drifts down as you scroll through it, manufacturing
|
||||
forward/descent travel on top of AI clips that only animate ambiently and
|
||||
don't move the camera themselves.
|
||||
========================================================================== */
|
||||
/* auteur-allow: RAW_SCROLL_LISTENER -- frame-accurate video scrub needs a native rAF-gated passive scroll listener; smoothed-scroll libraries fight currentTime seeking */
|
||||
/* ============================================================================
|
||||
scroll-world — portable scroll-scrubbed camera-flight engine
|
||||
----------------------------------------------------------------------------
|
||||
Framework-agnostic. Vanilla JS, zero dependencies. It builds its own DOM and
|
||||
injects its own (namespaced) CSS into a container you give it, so it drops into
|
||||
plain HTML, Next.js (call from a ref/useEffect), Vue (onMounted), a server-
|
||||
rendered page, anything.
|
||||
|
||||
USAGE
|
||||
mountScrollWorld(document.getElementById('world'), {
|
||||
brand: { name: 'Pearl & Co.', href: '#top' },
|
||||
diveScroll: 1.3, // viewport-heights of scroll per dive clip
|
||||
connScroll: 0.9, // ...per connector clip
|
||||
hint: 'scroll to fly in',
|
||||
nav: true, // show the top section nav
|
||||
atmosphere: true, // subtle gradient + drifting particles behind the clips
|
||||
scrollMobileFactor: 1.2, // extra scroll distance per segment on mobile (small
|
||||
// viewports read the same flight as faster; industry
|
||||
// pattern is a LONGER mobile scroll run)
|
||||
sections: [
|
||||
{ id, label, still, poster, posterMobile, clip, clipMobile, accent,
|
||||
// `poster` = the EXTRACTED FIRST FRAME of the encoded clip
|
||||
// (pipeline.md §5b). Shown while the clip loads, so the
|
||||
// still→video swap is pixel-identical (no crop/render pop).
|
||||
// `posterMobile` = same, extracted from the mobile/portrait
|
||||
// encode (wire it whenever clipMobile has different framing).
|
||||
// Falls back to `still` when absent; `still` remains the
|
||||
// stills-mode / no-clip artwork.
|
||||
scroll: 1.6, // optional per-section override of diveScroll — more scroll
|
||||
// distance = a slower, longer dwell in this scene
|
||||
linger: 0.5, // optional 0..1 — remaps time so the camera settles mid-scene
|
||||
// (exactly where the copy peaks) and moves quicker at the
|
||||
// edges. 0 = linear (default). Keep ≤ 0.6; 1 = full pause.
|
||||
eyebrow, title, body, tags:[…],
|
||||
cta:{ primary:{label,href}, secondary:{label,href} } }, // last section only
|
||||
…
|
||||
],
|
||||
connectors: [clipUrl, …], // length = sections.length - 1 (nulls allowed)
|
||||
connectorsMobile: [clipUrl, …], // optional lighter connectors for phones (same length)
|
||||
|
||||
MOBILE (the clipMobile/connectorsMobile variants are the opt-in mobile tiers;
|
||||
the rest of the phone handling below is always on)
|
||||
Two independent axes, deliberately separate:
|
||||
- CLIP TIER (which file): decided by device class — screen short side ≤600 CSS px
|
||||
= phone → `clipMobile`/`posterMobile`; tablets (iPad Pro included) and desktops
|
||||
get the full master. NOT decided by pointer type: iPadOS reports a coarse
|
||||
pointer and a Mac UA, but has a desktop-class screen + decoder.
|
||||
- BEHAVIOUR hardening (how it acts): on any coarse-pointer / ≤860px viewport the
|
||||
engine coalesces seeks (never issues a new currentTime while the decoder is
|
||||
still `seeking` — fast flicks can't pile up and freeze), takes a coarser seek
|
||||
step, keeps the poster up until the clip actually paints, primes each video
|
||||
(muted play→pause) on first touch (iOS blank-video fix), lengthens the scroll
|
||||
run (`scrollMobileFactor`), drops the drifting particles, and ignores
|
||||
URL-bar-only resizes (no scroll jump).
|
||||
STILLS MODE (automatic fallback, never configured): the page falls back to the
|
||||
stills cross-dissolving as you scroll — no video load or decode — when the user
|
||||
asked for it (`prefers-reduced-motion`, data-saver) or the OS blocks video at
|
||||
runtime (iOS Low Power Mode rejects even muted play(); detected on first touch).
|
||||
Chromium-only network signals (`navigator.connection.saveData`/`effectiveType`)
|
||||
are used strictly as downgrade signals — saveData → stills mode, 2g/3g → shrink
|
||||
the clip prefetch window. iOS exposes none of these, so the baseline stays
|
||||
conservative (posters first, lazy blob fetch near the viewport) for everyone.
|
||||
Nothing here is required — a config with only `clip`/`connectors` still works on
|
||||
phones; the mobile variants just make it lighter and smoother.
|
||||
|
||||
THEME (CSS custom properties; set on the container or :root to override)
|
||||
--sw-bg page background (match your scene bg for seamless posters)
|
||||
--sw-ink primary text
|
||||
--sw-ink-soft secondary text
|
||||
--sw-accent default accent (each section overrides via its `accent`)
|
||||
--sw-font-display / --sw-font-body
|
||||
|
||||
SEO / STATIC COPY
|
||||
The engine builds its DOM client-side, so on its own the page has no crawlable
|
||||
copy. Put a plain-markup version of the copy (h1 + per-section h2/p, real links)
|
||||
inside the container in a block marked `data-sw-seo` — the engine hides it on
|
||||
mount and it never fights the visual layer, but it exists in the served HTML for
|
||||
crawlers, link previews, and no-JS visitors (see index-template.html).
|
||||
|
||||
REQUIREMENTS ON YOUR ASSETS
|
||||
- clips encoded native-res, crf~20, -g 8, +faststart, no audio (see pipeline.md)
|
||||
- connectors' endpoints are the neighbouring dives' ACTUAL frames (see SKILL Step 5)
|
||||
- posters extracted from the ENCODED clips' first frames (pipeline.md §5b)
|
||||
- (optional) mobile variants at ~720p, -g 4 for smoother phone scrubbing
|
||||
The engine loads each clip as a Blob (always seekable) and scrubs currentTime; it does
|
||||
NOT depend on HTTP byte-range support.
|
||||
========================================================================== */
|
||||
|
||||
function mountScrollWorld(container, config) {
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
// BEHAVIOUR hardening (seek step, priming, particles, resize gating) keys off input
|
||||
// type + viewport: `coarse` is captured once (input type doesn't change mid-session);
|
||||
// the ≤860px query is read live via isMobile() so a desktop resize/DevTools toggle
|
||||
// switches seek behaviour without a reload.
|
||||
const coarse = window.matchMedia('(hover: none) and (pointer: coarse)').matches;
|
||||
const smallMQ = window.matchMedia('(max-width: 860px)');
|
||||
const isMobile = () => coarse || smallMQ.matches;
|
||||
// CLIP TIER keys off device class, NOT input type: an iPad Pro is coarse-pointer but
|
||||
// has a desktop-class screen and decoder — it gets the 1080p master, with the touch
|
||||
// hardening above still on. screen.* is stable across rotation and window resizes;
|
||||
// a phone's short side is ≤ ~500 CSS px, tablets start at 744.
|
||||
const phoneClass = Math.min(screen.width, screen.height) <= 600;
|
||||
// Network signals are Chromium-only (iOS/Safari/Firefox expose nothing) — treat them
|
||||
// strictly as a *downgrade* signal on top of a conservative default, never as a gate
|
||||
// for the good experience.
|
||||
const conn = navigator.connection;
|
||||
const dataSaver = !!(conn && conn.saveData);
|
||||
const slowNet = !!(conn && /^(slow-2g|2g|3g)$/.test(conn.effectiveType || ''));
|
||||
// Stills mode: the page becomes the stills cross-dissolving as you scroll — no video
|
||||
// load, no decode. Entered up-front for prefers-reduced-motion and data-saver, and at
|
||||
// runtime when iOS Low Power Mode blocks video (see enterStillsMode/primeVideo).
|
||||
let stillsOnly = reduce || dataSaver;
|
||||
const SECTIONS = config.sections || [];
|
||||
const CONNECTORS = config.connectors || [];
|
||||
const CONNECTORS_M = config.connectorsMobile || [];
|
||||
const DIVE_W = config.diveScroll || 1.3;
|
||||
const CONN_W = config.connScroll || 0.9;
|
||||
const CROSSFADE = (config.crossfade != null) ? config.crossfade : 0.12; // seam dissolve width (vh)
|
||||
const N = SECTIONS.length;
|
||||
if (!N) return;
|
||||
|
||||
injectCSS();
|
||||
container.classList.add('sw-root');
|
||||
// Server-rendered SEO copy (crawlers/no-JS read it from the HTML); once the
|
||||
// engine mounts, the visual layer takes over and the static block hides.
|
||||
container.querySelectorAll('[data-sw-seo]').forEach(n => { n.hidden = true; });
|
||||
|
||||
// ---- build the interleaved segment chain: dive0, conn0, dive1, … diveN-1 ----
|
||||
const SEGMENTS = [];
|
||||
SECTIONS.forEach((s, i) => {
|
||||
const dive = { kind: 'dive', si: i, clip: s.clip, clipM: s.clipMobile, still: s.still,
|
||||
poster: s.poster, posterM: s.posterMobile,
|
||||
accent: s.accent, w: s.scroll || DIVE_W, linger: s.linger || 0 };
|
||||
SEGMENTS.push(dive);
|
||||
s._seg = dive;
|
||||
// A connector is optional: if connectors[i] is falsy, the two dives simply
|
||||
// crossfade directly (no fly-over). Lets a page complete even when a
|
||||
// connector can't be generated (e.g. a content-filter false-positive).
|
||||
if (i < N - 1 && CONNECTORS[i]) {
|
||||
SEGMENTS.push({ kind: 'conn', si: i, clip: CONNECTORS[i], clipM: CONNECTORS_M[i],
|
||||
still: SECTIONS[i + 1].still, poster: SECTIONS[i + 1].poster,
|
||||
posterM: SECTIONS[i + 1].posterMobile,
|
||||
accent: SECTIONS[i + 1].accent, w: CONN_W });
|
||||
}
|
||||
});
|
||||
const NSEG = SEGMENTS.length;
|
||||
|
||||
// ---- DOM ----
|
||||
const sky = el('div', 'sw-sky');
|
||||
if (config.atmosphere !== false) {
|
||||
sky.appendChild(el('div', 'sw-sky__grad'));
|
||||
sky.appendChild(el('div', 'sw-sky__glow'));
|
||||
}
|
||||
const particles = el('div', 'sw-particles'); sky.appendChild(particles);
|
||||
|
||||
const scrollbar = el('div', 'sw-scrollbar');
|
||||
const scrollbarFill = el('span'); scrollbar.appendChild(scrollbarFill);
|
||||
|
||||
const topbar = el('div', 'sw-topbar');
|
||||
if (config.brand) {
|
||||
const brand = el('a', 'sw-brand'); brand.href = (config.brand.href || '#');
|
||||
brand.appendChild(el('span', 'sw-brand__mark'));
|
||||
const nm = el('span', 'sw-brand__name'); nm.textContent = config.brand.name || ''; brand.appendChild(nm);
|
||||
topbar.appendChild(brand);
|
||||
}
|
||||
const nav = el('nav', 'sw-nav'); if (config.nav !== false) topbar.appendChild(nav);
|
||||
if (config.cta && config.cta.label) {
|
||||
const c = el('a', 'sw-topcta'); c.href = config.cta.href || '#'; c.textContent = config.cta.label;
|
||||
topbar.appendChild(c);
|
||||
}
|
||||
|
||||
const stage = el('div', 'sw-stage');
|
||||
const copylayer = el('div', 'sw-copylayer');
|
||||
const route = el('div', 'sw-route');
|
||||
const hint = el('div', 'sw-hint');
|
||||
const hintText = el('span'); hintText.textContent = config.hint || 'scroll'; hint.appendChild(hintText);
|
||||
hint.appendChild(el('i'));
|
||||
const track = el('div', 'sw-track');
|
||||
|
||||
[sky, scrollbar, topbar, stage, copylayer, route, hint, track].forEach(n => container.appendChild(n));
|
||||
|
||||
// segment scenes
|
||||
SEGMENTS.forEach(s => {
|
||||
const scene = el('div', 'sw-scene'); scene.style.setProperty('--sw-accent', s.accent || '');
|
||||
const img = el('img', 'sw-scene__still'); img.alt = ''; img.decoding = 'async'; img.loading = 'lazy';
|
||||
// Prefer the extracted-frame poster (pixel-identical to the clip's first frame,
|
||||
// so the still→video swap can't pop) — matching the encode the device will get.
|
||||
// In stills mode the clip never loads, so the higher-fidelity source still is the
|
||||
// better permanent image.
|
||||
const pref = phoneClass ? (s.posterM || s.poster) : s.poster;
|
||||
const posterSrc = (!stillsOnly && pref) ? pref : s.still;
|
||||
if (posterSrc) img.src = posterSrc;
|
||||
scene.appendChild(img); stage.appendChild(scene);
|
||||
s.el = scene; s.img = img; s.video = null; s.hasClip = false;
|
||||
s.loading = false; s.ready = false; s.cur = 0; s.target = 0; s.visible = false;
|
||||
});
|
||||
|
||||
// per-section copy / route / nav
|
||||
const copies = [], dots = [];
|
||||
SECTIONS.forEach((s, i) => {
|
||||
const c = el('article', 'sw-copy'); c.style.setProperty('--sw-accent', s.accent || '');
|
||||
c.innerHTML =
|
||||
`<span class="sw-copy__num">${pad(i + 1)} / ${pad(N)}</span>` +
|
||||
(s.eyebrow ? `<span class="sw-copy__eyebrow">${esc(s.eyebrow)}</span>` : '') +
|
||||
(s.title ? `<h2 class="sw-copy__title">${esc(s.title)}</h2>` : '') +
|
||||
(s.body ? `<p class="sw-copy__body">${esc(s.body)}</p>` : '') +
|
||||
(s.tags && s.tags.length ? `<ul class="sw-copy__tags">${s.tags.map(t => `<li>${esc(t)}</li>`).join('')}</ul>` : '') +
|
||||
(s.cta ? `<div class="sw-copy__cta">${ctaBtns(s.cta)}</div>` : '');
|
||||
copylayer.appendChild(c); copies.push(c);
|
||||
|
||||
const dot = el('button', 'sw-route__dot'); dot.style.setProperty('--sw-accent', s.accent || '');
|
||||
dot.innerHTML = `<span class="sw-route__label">${esc(s.label || '')}</span><i></i>`;
|
||||
dot.addEventListener('click', () => jumpTo(i)); route.appendChild(dot); dots.push(dot);
|
||||
|
||||
if (config.nav !== false) {
|
||||
const b = el('button', 'sw-nav__item'); b.textContent = s.label || '';
|
||||
b.addEventListener('click', () => jumpTo(i)); nav.appendChild(b);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- math ----
|
||||
const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
|
||||
const smooth = x => { x = clamp(x); return x * x * (3 - 2 * x); };
|
||||
// Per-section dwell: monotone remap of scroll→time so the camera settles mid-scene
|
||||
// (where the copy peaks) and moves quicker near the seams. L=0 linear, L=1 full
|
||||
// mid-scene pause. f(0)=0, f(1)=1 always, so seam frames are untouched.
|
||||
const lingerEase = (x, L) => { L = clamp(L); const c = x - 0.5; return (1 - L) * x + L * (4 * c * c * c + 0.5); };
|
||||
let vh = window.innerHeight, stageX = 0, totalW = 0, activeIndex = -1, ticking = false;
|
||||
let laidOutW = window.innerWidth; // width the current layout was computed at (see onResize)
|
||||
|
||||
function layout() {
|
||||
vh = window.innerHeight;
|
||||
laidOutW = window.innerWidth;
|
||||
stageX = window.innerWidth > 860 ? 4 : 0;
|
||||
// Small viewports read a camera flight as faster than big ones do, so give each
|
||||
// segment more scroll distance on mobile (industry pattern: mobile scroll runs are
|
||||
// LONGER than desktop's for the same sequence). Override via scrollMobileFactor.
|
||||
const wf = isMobile() ? (config.scrollMobileFactor != null ? config.scrollMobileFactor : 1.2) : 1;
|
||||
let off = 0;
|
||||
SEGMENTS.forEach(s => { s.start = off * vh; off += s.w * wf; s.end = off * vh; });
|
||||
totalW = off;
|
||||
track.style.height = (totalW * vh + vh) + 'px'; // +1vh so the last flight completes
|
||||
read();
|
||||
}
|
||||
|
||||
function jumpTo(i) {
|
||||
const seg = SECTIONS[i]._seg;
|
||||
window.scrollTo({ top: seg.start + (seg.end - seg.start) * 0.5, behavior: reduce ? 'auto' : 'smooth' });
|
||||
}
|
||||
|
||||
function enterStillsMode() {
|
||||
if (stillsOnly) return;
|
||||
stillsOnly = true;
|
||||
SEGMENTS.forEach(s => {
|
||||
if (s.video) {
|
||||
try { s.video.pause(); } catch (e) {}
|
||||
try { URL.revokeObjectURL(s.video.src); } catch (e) {}
|
||||
s.video.remove();
|
||||
}
|
||||
s.el.classList.remove('has-clip');
|
||||
s.video = null; s.hasClip = false; s.ready = false; s.loading = false;
|
||||
});
|
||||
read();
|
||||
}
|
||||
|
||||
function loadClip(s) {
|
||||
if (stillsOnly || s.loading || !s.clip) return;
|
||||
s.loading = true;
|
||||
// Serve the lighter mobile encode on phone-class devices when one was provided
|
||||
// (tablets and desktops get the full master — see phoneClass above).
|
||||
const url = (phoneClass && s.clipM) ? s.clipM : s.clip;
|
||||
fetch(url).then(r => r.ok ? r.blob() : Promise.reject(new Error('404')))
|
||||
.then(blob => {
|
||||
const v = document.createElement('video');
|
||||
v.className = 'sw-scene__video';
|
||||
v.muted = true; v.playsInline = true; v.preload = 'auto';
|
||||
v.setAttribute('muted', ''); v.setAttribute('playsinline', '');
|
||||
v.src = URL.createObjectURL(blob);
|
||||
v.addEventListener('loadedmetadata', () => { s.ready = true; read(); });
|
||||
// Reveal the video (hide the still poster) only once a real frame has
|
||||
// painted — on iOS a seeked-but-never-played muted video stays blank, so
|
||||
// hiding the still on metadata alone would flash an empty scene.
|
||||
v.addEventListener('seeked', () => { s.el.classList.add('has-clip'); }, { once: true });
|
||||
v.addEventListener('loadeddata', () => { try { v.pause(); } catch (e) {} if (userReady) primeVideo(v); });
|
||||
s.el.appendChild(v); s.video = v; s.hasClip = true;
|
||||
}).catch(() => { s.loading = false; });
|
||||
}
|
||||
|
||||
function read() {
|
||||
const y = window.scrollY || window.pageYOffset;
|
||||
const fade = CROSSFADE * vh;
|
||||
let ci = 0;
|
||||
for (let i = 0; i < NSEG; i++) if (y >= SEGMENTS[i].start) ci = i;
|
||||
|
||||
// On a slow connection (Chromium signal only) shrink the prefetch window: fetch the
|
||||
// clip you're in, not the neighbourhood. Everyone else prefetches ±1.6 viewports.
|
||||
const lookahead = slowNet ? 0.4 : 1.6;
|
||||
for (let i = 0; i < NSEG; i++) {
|
||||
const s = SEGMENTS[i];
|
||||
if (y > s.start - lookahead * vh && y < s.end + lookahead * vh) loadClip(s);
|
||||
const local = clamp((y - s.start) / (s.end - s.start), 0, 1);
|
||||
s.target = s.linger ? lingerEase(local, s.linger) : local;
|
||||
let outside = 0;
|
||||
if (y < s.start) outside = s.start - y; else if (y > s.end) outside = y - s.end;
|
||||
const op = smooth(1 - outside / fade);
|
||||
s.el.style.opacity = op; s.visible = op > 0.001;
|
||||
s.el.style.zIndex = (i === ci) ? '120' : String(100 + Math.round(op * 10));
|
||||
// auteur scroll-dolly: push the camera INTO the scene and drift down as you scroll
|
||||
// through it. Manufactures forward/descent travel on top of near-static AI clips (the
|
||||
// clip's currentTime scrub still runs below). Applied to the scene (video + still).
|
||||
// dy stays inside the scale overscan so scene edges never reveal the page background.
|
||||
const push = reduce ? 1 : 1.05 + local * 0.14;
|
||||
const dy = reduce ? 0 : (0.5 - local) * 2.4;
|
||||
s.el.style.transform = `translateY(${dy.toFixed(2)}vh) scale(${push.toFixed(3)})`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const seg = SECTIONS[i]._seg;
|
||||
const pr = clamp((y - seg.start) / (seg.end - seg.start), 0, 1);
|
||||
const before = y < seg.start, after = y > seg.end;
|
||||
let cop;
|
||||
if (i === 0) cop = after ? 0 : smooth(1 - pr / 0.62); // greets on landing
|
||||
else if (i === N - 1) cop = before ? 0 : smooth(pr / 0.4); // holds CTA at the end
|
||||
else cop = (before || after) ? 0 : smooth(1 - Math.abs(pr - 0.5) / 0.5);
|
||||
const c = copies[i];
|
||||
c.style.opacity = cop;
|
||||
c.style.transform = reduce ? 'none' : `translateY(${(0.5 - pr) * 4}vh)`;
|
||||
c.style.pointerEvents = cop > 0.5 ? 'auto' : 'none';
|
||||
}
|
||||
|
||||
const cur = SEGMENTS[ci];
|
||||
const near = clamp(cur.kind === 'dive' ? cur.si
|
||||
: (((y - cur.start) / (cur.end - cur.start)) > 0.5 ? cur.si + 1 : cur.si), 0, N - 1);
|
||||
if (near !== activeIndex) {
|
||||
activeIndex = near;
|
||||
dots.forEach((d, k) => d.classList.toggle('is-active', k === near));
|
||||
nav.querySelectorAll('.sw-nav__item').forEach((n, k) => n.classList.toggle('is-active', k === near));
|
||||
container.style.setProperty('--sw-accent', SECTIONS[near].accent || '');
|
||||
}
|
||||
scrollbarFill.style.transform = `scaleX(${clamp(y / (totalW * vh))})`;
|
||||
hint.style.opacity = clamp(1 - y / (0.5 * vh));
|
||||
if (particles) particles.style.transform = `translate3d(0, ${-y * 0.05}px, 0)`;
|
||||
ticking = false;
|
||||
}
|
||||
|
||||
function raf() {
|
||||
const eps = isMobile() ? 0.02 : 0.008; // coarser seek step on phones = fewer decodes
|
||||
for (let i = 0; i < NSEG; i++) {
|
||||
const s = SEGMENTS[i];
|
||||
if (!s.hasClip || !s.ready || !s.video) continue;
|
||||
// Never queue a seek while the decoder is still resolving the last one.
|
||||
// On phones a fast flick would otherwise pile up seeks and freeze the clip;
|
||||
// cur keeps lerping, so we snap to the latest target the moment it's free.
|
||||
if (s.video.seeking) continue;
|
||||
if (!s.visible && Math.abs(s.cur - s.target) < 0.002) continue;
|
||||
s.cur += (s.target - s.cur) * (reduce ? 1 : 0.18);
|
||||
const dur = s.video.duration || 1;
|
||||
const t = clamp(s.cur, 0, 0.999) * dur;
|
||||
if (Math.abs(s.video.currentTime - t) > eps) { try { s.video.currentTime = t; } catch (e) {} }
|
||||
}
|
||||
requestAnimationFrame(raf);
|
||||
}
|
||||
|
||||
// iOS needs a user gesture before a muted video will decode/paint reliably. On the
|
||||
// first touch we prime every loaded clip (muted play→pause) so the first seek is
|
||||
// instant instead of showing a blank frame. `userReady` also makes freshly-loaded
|
||||
// clips prime themselves (see loadClip).
|
||||
let userReady = false;
|
||||
function primeVideo(v) {
|
||||
if (!isMobile() || !v) return;
|
||||
// A muted, playsinline play() that REJECTS on a user gesture means the OS is
|
||||
// blocking video — in practice iOS Low Power Mode, where currentTime scrubbing
|
||||
// doesn't work either. Fall back to stills for the whole page instead of showing
|
||||
// frozen/blank scenes.
|
||||
try { const p = v.play(); if (p && p.then) p.then(() => { try { v.pause(); } catch (e) {} }).catch(() => { enterStillsMode(); }); }
|
||||
catch (e) {}
|
||||
}
|
||||
function onFirstGesture() {
|
||||
if (userReady) return;
|
||||
userReady = true;
|
||||
SEGMENTS.forEach(s => primeVideo(s.video));
|
||||
}
|
||||
window.addEventListener('pointerdown', onFirstGesture, { once: true, passive: true });
|
||||
window.addEventListener('touchstart', onFirstGesture, { once: true, passive: true });
|
||||
|
||||
// Particles are a per-frame cost we can't afford alongside video scrubbing on a phone.
|
||||
seedParticles(particles, reduce || coarse);
|
||||
window.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(read); } }, { passive: true });
|
||||
// Mobile browsers fire `resize` every time the URL bar slides in/out. Re-running
|
||||
// layout() there rebuilds the track height and yanks the scroll position, so on
|
||||
// touch we ignore height-only changes and only relayout when the width actually
|
||||
// changes (rotation still comes through orientationchange). layout() records the
|
||||
// width it laid out at.
|
||||
function onResize() {
|
||||
if (coarse && window.innerWidth === laidOutW) return;
|
||||
layout();
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('orientationchange', layout);
|
||||
window.addEventListener('load', layout);
|
||||
layout();
|
||||
requestAnimationFrame(raf);
|
||||
|
||||
// ---- helpers ----
|
||||
function el(tag, cls) { const n = document.createElement(tag); if (cls) n.className = cls; return n; }
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
function ctaBtns(cta) {
|
||||
let h = '';
|
||||
if (cta.primary) h += `<a class="sw-btn sw-btn--primary" href="${esc(cta.primary.href || '#')}">${esc(cta.primary.label)}</a>`;
|
||||
if (cta.secondary) h += `<a class="sw-btn sw-btn--ghost" href="${esc(cta.secondary.href || '#')}">${esc(cta.secondary.label)}</a>`;
|
||||
return h;
|
||||
}
|
||||
}
|
||||
|
||||
function seedParticles(host, reduce) {
|
||||
if (!host || reduce) return;
|
||||
const kinds = ['dot', 'dot', 'ring'];
|
||||
const seeds = [7, 23, 41, 58, 71, 88, 12, 34, 52, 66, 83, 95, 18, 29, 47, 63, 77, 91, 5, 38, 55, 69, 82, 97];
|
||||
for (let k = 0; k < 20; k++) {
|
||||
const s = document.createElement('span');
|
||||
s.className = 'sw-pt sw-pt--' + kinds[k % kinds.length];
|
||||
s.style.left = seeds[k % seeds.length] + 'vw';
|
||||
s.style.top = ((seeds[(k * 3) % seeds.length] * 1.3) % 100) + 'vh';
|
||||
s.style.setProperty('--sw-sc', (0.5 + ((seeds[(k * 5) % seeds.length] % 60) / 60) * 1.1).toFixed(2));
|
||||
const dur = 14 + (seeds[(k * 7) % seeds.length] % 22);
|
||||
s.style.animationDuration = dur + 's';
|
||||
s.style.animationDelay = (-(seeds[(k * 2) % seeds.length] % dur)) + 's';
|
||||
host.appendChild(s);
|
||||
}
|
||||
}
|
||||
|
||||
function injectCSS() {
|
||||
if (document.getElementById('sw-css')) return;
|
||||
const css = `
|
||||
.sw-root{--sw-bg:#F5EDE0;--sw-ink:#241d2b;--sw-ink-soft:#6a6072;--sw-accent:#b8623a;
|
||||
--sw-font-display:ui-rounded,"SF Pro Rounded","Segoe UI",system-ui,sans-serif;
|
||||
--sw-font-body:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif;
|
||||
color:var(--sw-ink);font-family:var(--sw-font-body);}
|
||||
html,body{margin:0;background:var(--sw-bg,#F5EDE0);overflow-x:hidden;}
|
||||
.sw-sky{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:var(--sw-bg);}
|
||||
.sw-sky__grad{position:absolute;inset:-10%;background:linear-gradient(178deg,color-mix(in srgb,var(--sw-accent) 12%,var(--sw-bg)) 0%,var(--sw-bg) 55%,color-mix(in srgb,var(--sw-accent) 6%,var(--sw-bg)) 100%);}
|
||||
.sw-sky__glow{position:absolute;inset:0;background:radial-gradient(60% 42% at 74% 16%,color-mix(in srgb,var(--sw-accent) 22%,transparent),transparent 70%),radial-gradient(46% 34% at 50% 50%,color-mix(in srgb,#fff 45%,transparent),transparent 70%);}
|
||||
.sw-particles{position:absolute;inset:-6% -2%;will-change:transform;}
|
||||
.sw-pt{position:absolute;width:13px;height:13px;transform:scale(var(--sw-sc,1));opacity:0;animation:sw-drift linear infinite;}
|
||||
.sw-pt::before{content:"";position:absolute;inset:0;border-radius:50%;}
|
||||
.sw-pt--dot::before{background:radial-gradient(circle at 34% 30%,color-mix(in srgb,var(--sw-accent) 60%,#000),#000 82%);}
|
||||
.sw-pt--ring::before{background:transparent;border:2px solid color-mix(in srgb,var(--sw-accent) 55%,transparent);}
|
||||
@keyframes sw-drift{0%{opacity:0;transform:scale(var(--sw-sc)) translate(0,12vh) rotate(0)}12%{opacity:.5}88%{opacity:.45}100%{opacity:0;transform:scale(var(--sw-sc)) translate(4vw,-22vh) rotate(210deg)}}
|
||||
.sw-scrollbar{position:fixed;top:0;left:0;right:0;height:3px;z-index:60;background:color-mix(in srgb,var(--sw-accent) 14%,transparent);}
|
||||
.sw-scrollbar span{display:block;height:100%;width:100%;transform-origin:0 50%;transform:scaleX(0);background:var(--sw-accent);}
|
||||
.sw-topbar{position:fixed;top:0;left:0;right:0;z-index:50;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:clamp(14px,2.4vw,26px) clamp(18px,5vw,64px);}
|
||||
.sw-brand{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--sw-ink);}
|
||||
.sw-brand__mark{width:24px;height:28px;border-radius:7px 7px 10px 10px;background:linear-gradient(160deg,var(--sw-accent),color-mix(in srgb,var(--sw-accent) 60%,#000));box-shadow:0 6px 14px color-mix(in srgb,var(--sw-accent) 40%,transparent);}
|
||||
.sw-brand__name{font-family:var(--sw-font-display);font-weight:700;font-size:1.1rem;}
|
||||
.sw-nav{display:flex;gap:4px;padding:5px;background:color-mix(in srgb,#fff 55%,transparent);backdrop-filter:blur(10px);border:1px solid color-mix(in srgb,var(--sw-accent) 16%,transparent);border-radius:999px;}
|
||||
.sw-nav__item{font:inherit;font-size:.82rem;color:var(--sw-ink-soft);border:0;background:transparent;cursor:pointer;padding:7px 14px;border-radius:999px;transition:color .25s,background .25s;}
|
||||
.sw-nav__item:hover{color:var(--sw-ink);} .sw-nav__item.is-active{color:#fff;background:var(--sw-accent);}
|
||||
.sw-topcta{text-decoration:none;font-weight:600;font-size:.9rem;color:#fff;background:var(--sw-ink);padding:10px 20px;border-radius:999px;white-space:nowrap;}
|
||||
.sw-stage{position:fixed;inset:0;z-index:10;pointer-events:none;}
|
||||
.sw-scene{position:absolute;inset:0;opacity:0;overflow:hidden;will-change:opacity,transform;transform-origin:50% 46%;}
|
||||
.sw-scene__video,.sw-scene__still{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center 42%;}
|
||||
.sw-scene__still{will-change:transform;} .sw-scene.has-clip .sw-scene__still{opacity:0;} .sw-scene__video{z-index:1;}
|
||||
.sw-copylayer{position:fixed;inset:0;z-index:20;pointer-events:none;}
|
||||
.sw-copylayer::before{content:"";position:absolute;inset:0;width:min(58vw,780px);background:linear-gradient(90deg,var(--sw-bg) 0%,color-mix(in srgb,var(--sw-bg) 82%,transparent) 34%,color-mix(in srgb,var(--sw-bg) 40%,transparent) 62%,transparent 100%);}
|
||||
.sw-copy{position:absolute;left:clamp(18px,5vw,64px);top:50%;transform:translateY(-50%);width:min(42vw,460px);opacity:0;will-change:opacity,transform;}
|
||||
.sw-copy__num{font-family:ui-monospace,Menlo,monospace;font-size:.74rem;letter-spacing:.12em;color:var(--sw-ink-soft);}
|
||||
.sw-copy__eyebrow{display:block;margin-top:18px;font-family:var(--sw-font-display);font-weight:700;font-size:.8rem;letter-spacing:.16em;text-transform:uppercase;color:var(--sw-accent);}
|
||||
.sw-copy__title{font-family:var(--sw-font-display);font-weight:700;color:var(--sw-ink);font-size:clamp(2rem,4.4vw,3.5rem);line-height:1.03;margin:12px 0 0;letter-spacing:-.01em;text-shadow:0 2px 20px color-mix(in srgb,var(--sw-bg) 70%,transparent);}
|
||||
.sw-copy__body{margin-top:18px;font-size:clamp(1rem,1.25vw,1.14rem);line-height:1.55;color:color-mix(in srgb,var(--sw-ink) 78%,var(--sw-ink-soft));max-width:40ch;text-shadow:0 1px 12px color-mix(in srgb,var(--sw-bg) 90%,transparent);}
|
||||
.sw-copy__tags{list-style:none;display:flex;flex-wrap:wrap;gap:8px;margin:24px 0 0;padding:0;}
|
||||
.sw-copy__tags li{font-size:.82rem;font-weight:600;color:color-mix(in srgb,var(--sw-accent) 70%,#000);padding:7px 14px;border-radius:999px;background:color-mix(in srgb,var(--sw-accent) 14%,#fff);border:1px solid color-mix(in srgb,var(--sw-accent) 30%,transparent);}
|
||||
.sw-copy__cta{display:flex;flex-wrap:wrap;gap:12px;margin-top:28px;pointer-events:auto;}
|
||||
.sw-btn{text-decoration:none;font-weight:600;font-size:.95rem;padding:13px 24px;border-radius:999px;transition:transform .2s;}
|
||||
.sw-btn--primary{color:#fff;background:var(--sw-ink);} .sw-btn--primary:hover{transform:translateY(-2px);}
|
||||
.sw-btn--ghost{color:var(--sw-ink);border:1.5px solid color-mix(in srgb,var(--sw-ink) 25%,transparent);} .sw-btn--ghost:hover{transform:translateY(-2px);}
|
||||
.sw-route{position:fixed;right:clamp(14px,2.4vw,30px);top:50%;z-index:40;transform:translateY(-50%);display:flex;flex-direction:column;gap:22px;padding:18px 10px;}
|
||||
.sw-route::before{content:"";position:absolute;left:50%;top:22px;bottom:22px;width:2px;transform:translateX(-50%);background:var(--sw-accent);opacity:.28;}
|
||||
.sw-route__dot{position:relative;border:0;background:transparent;cursor:pointer;width:14px;height:14px;display:grid;place-items:center;}
|
||||
.sw-route__dot i{width:9px;height:9px;border-radius:50%;background:color-mix(in srgb,var(--sw-accent) 40%,transparent);transition:transform .3s,background .3s,box-shadow .3s;}
|
||||
.sw-route__dot:hover i{transform:scale(1.25);background:var(--sw-accent);}
|
||||
.sw-route__dot.is-active i{background:var(--sw-accent);transform:scale(1.4);box-shadow:0 0 0 5px color-mix(in srgb,var(--sw-accent) 22%,transparent);}
|
||||
.sw-route__label{position:absolute;right:24px;top:50%;transform:translateY(-50%) translateX(6px);white-space:nowrap;font-size:.78rem;font-weight:600;color:var(--sw-ink);background:color-mix(in srgb,#fff 85%,transparent);backdrop-filter:blur(6px);padding:5px 11px;border-radius:999px;opacity:0;pointer-events:none;transition:opacity .25s,transform .25s;border:1px solid color-mix(in srgb,var(--sw-accent) 14%,transparent);}
|
||||
.sw-route__dot:hover .sw-route__label,.sw-route__dot.is-active .sw-route__label{opacity:1;transform:translateY(-50%) translateX(0);}
|
||||
.sw-hint{position:fixed;left:50%;bottom:26px;z-index:30;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:10px;font-size:.76rem;letter-spacing:.14em;text-transform:uppercase;color:var(--sw-ink-soft);transition:opacity .3s;}
|
||||
.sw-hint i{width:22px;height:34px;border-radius:12px;border:2px solid color-mix(in srgb,var(--sw-ink) 28%,transparent);position:relative;}
|
||||
.sw-hint i::after{content:"";position:absolute;left:50%;top:7px;width:4px;height:7px;border-radius:2px;background:var(--sw-accent);transform:translateX(-50%);animation:sw-wheel 1.7s ease-in-out infinite;}
|
||||
@keyframes sw-wheel{0%{opacity:0;top:6px}40%{opacity:1}100%{opacity:0;top:17px}}
|
||||
.sw-track{position:relative;z-index:1;width:100%;pointer-events:none;}
|
||||
@media (max-width:860px){
|
||||
.sw-nav{display:none;}
|
||||
.sw-copylayer::before{width:100%;height:60%;top:auto;bottom:0;background:linear-gradient(0deg,var(--sw-bg) 8%,color-mix(in srgb,var(--sw-bg) 70%,transparent) 46%,transparent 100%);}
|
||||
/* Anchor copy to the bottom, clear of the home indicator / collapsing URL bar.
|
||||
dvh + env() are progressive: browsers that lack them keep the vh fallback line. */
|
||||
.sw-copy{left:clamp(18px,5vw,64px);right:clamp(18px,5vw,64px);top:auto;bottom:clamp(64px,14vh,120px);transform:none;width:auto;max-width:560px;}
|
||||
.sw-copy{bottom:calc(clamp(56px,12dvh,110px) + env(safe-area-inset-bottom));}
|
||||
.sw-copy__title{font-size:clamp(1.9rem,7.5vw,2.7rem);}
|
||||
.sw-copy__body{max-width:none;font-size:clamp(.98rem,3.6vw,1.1rem);} .sw-scene__video,.sw-scene__still{object-position:center 46%;}
|
||||
.sw-hint{bottom:calc(20px + env(safe-area-inset-bottom));}
|
||||
.sw-route{gap:16px;right:6px;} .sw-route__label{display:none;}
|
||||
}
|
||||
/* Portrait phones crop a 16:9 clip hard; keep the framing centred so the focal
|
||||
subject (which the camera dives toward) stays in view. */
|
||||
@media (max-width:860px) and (orientation:portrait){
|
||||
.sw-scene__video,.sw-scene__still{object-position:center 44%;}
|
||||
}
|
||||
/* Touch: give the route dots a finger-sized hit area without growing the visible dot. */
|
||||
@media (hover:none) and (pointer:coarse){
|
||||
.sw-route{padding:14px 6px;}
|
||||
.sw-route__dot{width:28px;height:28px;}
|
||||
.sw-btn{padding:15px 26px;}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){ .sw-hint i::after{animation:none;} .sw-pt{display:none;} }
|
||||
`;
|
||||
// Wrap in a cascade layer so the page's own theme tokens (unlayered
|
||||
// :root / .sw-root { --sw-bg / --sw-ink / --sw-accent … }) always win over
|
||||
// these defaults, regardless of injection order. Enables clean dark themes.
|
||||
const style = document.createElement('style'); style.id = 'sw-css';
|
||||
style.textContent = '@layer sw {\n' + css + '\n}';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Expose for module + global use.
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = { mountScrollWorld };
|
||||
if (typeof window !== 'undefined') window.mountScrollWorld = mountScrollWorld;
|
||||
98
tests/skills/test_auteur_skill.py
Normal file
98
tests/skills/test_auteur_skill.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for the auteur optional skill (ported from agiwhitelist/auteur, MIT)."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
def _find_skill_dir() -> Path:
|
||||
base = Path(__file__).resolve().parents[2]
|
||||
candidate = base / "optional-skills" / "creative" / "auteur"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
# fallback: glob from repo root for any auteur skill dir
|
||||
for p in base.glob("**/optional-skills/**/auteur"):
|
||||
if (p / "SKILL.md").exists():
|
||||
return p
|
||||
raise AssertionError("auteur skill directory not found relative to test file")
|
||||
|
||||
SKILL_DIR = _find_skill_dir()
|
||||
SKILL_MD = SKILL_DIR / "SKILL.md"
|
||||
|
||||
|
||||
def _frontmatter() -> dict:
|
||||
text = SKILL_MD.read_text(encoding="utf-8")
|
||||
assert text.startswith("---\n"), "SKILL.md must start with YAML frontmatter"
|
||||
fm = text.split("---\n", 2)[1]
|
||||
data = yaml.safe_load(fm)
|
||||
assert isinstance(data, dict)
|
||||
return data
|
||||
|
||||
|
||||
def test_frontmatter_parses():
|
||||
fm = _frontmatter()
|
||||
assert fm["name"] == "auteur"
|
||||
|
||||
|
||||
def test_description_length():
|
||||
fm = _frontmatter()
|
||||
desc = fm["description"]
|
||||
assert isinstance(desc, str) and desc.strip()
|
||||
assert len(desc) <= 60, f"description is {len(desc)} chars, must be <= 60"
|
||||
|
||||
|
||||
def test_required_fields():
|
||||
fm = _frontmatter()
|
||||
assert fm.get("license") == "MIT"
|
||||
assert fm.get("author"), "author missing"
|
||||
platforms = fm.get("platforms")
|
||||
assert isinstance(platforms, list) and platforms, "platforms missing"
|
||||
assert set(platforms) <= {"linux", "macos", "windows"}
|
||||
assert fm.get("version")
|
||||
|
||||
|
||||
def test_mentioned_paths_exist_or_annotated():
|
||||
"""Every references/scripts/templates path mentioned in SKILL.md exists on
|
||||
disk, or its line carries an 'upstream' / 'not vendored' annotation."""
|
||||
pattern = re.compile(r"(references|scripts|templates)/[A-Za-z0-9._-]+")
|
||||
missing = []
|
||||
for line in SKILL_MD.read_text(encoding="utf-8").splitlines():
|
||||
for m in pattern.finditer(line):
|
||||
rel = m.group(0).rstrip(".")
|
||||
if (SKILL_DIR / rel).exists():
|
||||
continue
|
||||
low = line.lower()
|
||||
if "upstream" in low or "not vendored" in low:
|
||||
continue
|
||||
missing.append(rel)
|
||||
assert not missing, f"paths mentioned but absent and unannotated: {missing}"
|
||||
|
||||
|
||||
def test_no_claude_residue():
|
||||
text = SKILL_MD.read_text(encoding="utf-8").lower()
|
||||
for token in ("claude", "allowed-tools", "argument-hint"):
|
||||
assert token not in text, f"residual '{token}' in SKILL.md"
|
||||
|
||||
|
||||
def test_related_skills_resolve():
|
||||
fm = _frontmatter()
|
||||
related = (fm.get("metadata") or {}).get("hermes", {}).get("related_skills", [])
|
||||
if not related:
|
||||
return # empty list is allowed
|
||||
staging_root = Path(__file__).resolve().parents[2]
|
||||
repo_root = Path.home() / ".hermes" / "hermes-agent"
|
||||
roots = [
|
||||
staging_root / "skills",
|
||||
staging_root / "optional-skills",
|
||||
repo_root / "skills",
|
||||
repo_root / "optional-skills",
|
||||
]
|
||||
for name in related:
|
||||
found = any(r.is_dir() and list(r.glob(f"**/{name}")) for r in roots)
|
||||
assert found, f"related skill '{name}' not found in skills/optional-skills trees"
|
||||
|
||||
|
||||
def test_vendored_tree_shape():
|
||||
assert (SKILL_DIR / "LICENSE").exists()
|
||||
assert len(list((SKILL_DIR / "references").glob("*.md"))) == 11
|
||||
assert len(list((SKILL_DIR / "scripts").glob("*.mjs"))) == 8
|
||||
assert len(list((SKILL_DIR / "templates").iterdir())) == 6
|
||||
@@ -61,6 +61,7 @@ hermes skills uninstall <skill-name>
|
||||
| [**archify**](/docs/user-guide/skills/optional/creative/creative-archify) | Validated interactive HTML diagrams, upstream-maintained. |
|
||||
| [**ascii-art**](/docs/user-guide/skills/optional/creative/creative-ascii-art) | ASCII art: pyfiglet, cowsay, boxes, image-to-ascii. |
|
||||
| [**audiocraft-audio-generation**](/docs/user-guide/skills/optional/creative/creative-audiocraft-audio-generation) | AudioCraft: MusicGen text-to-music, AudioGen text-to-sound. |
|
||||
| [**auteur**](/docs/user-guide/skills/optional/creative/creative-auteur) | Design and build cinematic, award-level web pages. |
|
||||
| [**baoyu-article-illustrator**](/docs/user-guide/skills/optional/creative/creative-baoyu-article-illustrator) | Article illustrations: type × style × palette consistency. |
|
||||
| [**baoyu-comic**](/docs/user-guide/skills/optional/creative/creative-baoyu-comic) | Knowledge comics (知识漫画): educational, biography, tutorial. |
|
||||
| [**comfyui**](/docs/user-guide/skills/optional/creative/creative-comfyui) | Generate images, video, and audio via diffusion workflows. |
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
title: "Auteur — Design and build cinematic, award-level web pages"
|
||||
sidebar_label: "Auteur"
|
||||
description: "Design and build cinematic, award-level web pages"
|
||||
---
|
||||
|
||||
{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
|
||||
|
||||
# Auteur
|
||||
|
||||
Design and build cinematic, award-level web pages.
|
||||
|
||||
## Skill metadata
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Source | Optional — install with `hermes skills install official/creative/auteur` |
|
||||
| Path | `optional-skills/creative/auteur` |
|
||||
| Version | `1.3.1` |
|
||||
| Author | agiwhitelist (upstream) / Hermes port |
|
||||
| License | MIT |
|
||||
| Platforms | linux, macos |
|
||||
| Tags | `web-design`, `cinematic`, `scroll-animation`, `design-system`, `anti-slop`, `frontend` |
|
||||
| Related skills | [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`design-md`](/docs/user-guide/skills/bundled/creative/creative-design-md), [`p5js`](/docs/user-guide/skills/bundled/creative/creative-p5js) |
|
||||
|
||||
## Reference: full SKILL.md
|
||||
|
||||
:::info
|
||||
The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active.
|
||||
:::
|
||||
|
||||
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 <brief>`** 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 <path-or-url>`** → 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 <src-dir>` 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 <url>` 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/<x>.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 <dir>` runs without npm deps; it prints per-rule findings and exits non-zero on failures (exit 0 when clean).
|
||||
@@ -359,6 +359,7 @@ const sidebars: SidebarsConfig = {
|
||||
'user-guide/skills/optional/creative/creative-ascii-art',
|
||||
'user-guide/skills/optional/creative/creative-archify',
|
||||
'user-guide/skills/optional/creative/creative-audiocraft-audio-generation',
|
||||
'user-guide/skills/optional/creative/creative-auteur',
|
||||
'user-guide/skills/optional/creative/creative-baoyu-article-illustrator',
|
||||
'user-guide/skills/optional/creative/creative-baoyu-comic',
|
||||
'user-guide/skills/optional/creative/creative-comfyui',
|
||||
|
||||
Reference in New Issue
Block a user