feat(skills): scrollcraft — premium scroll-driven landing pages (port of nateherkai/scroll-craft, 1.2k★ MIT)

Optional skill: scroll-as-timeline landing pages on a deterministic
CSS/JS engine, with interview → page grammar → signature move workflow
and screenshot-based scroll verification. Engine and scripts vendored
verbatim; asset generation re-anchored on image_generate with the
upstream kie.ai flow kept as an optional path.
This commit is contained in:
Teknium
2026-08-29 10:11:26 -07:00
parent 09b74dea64
commit 56cc2bd814
26 changed files with 6884 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nate Herk
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.

View File

@@ -0,0 +1,242 @@
---
name: scrollcraft
description: "Premium scroll-driven landing pages; scroll = timeline."
version: 1.0.0
author: 'nateherkai (upstream scroll-craft), ported by Hermes Agent'
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [web-development, landing-page, scrollytelling, animation, design, frontend]
category: web-development
homepage: https://github.com/nateherkai/scroll-craft
related_skills: []
---
# scrollcraft
Scroll is the only input every visitor already knows. This skill treats it as a
timeline: the wheel is a scrubber, the page is a film with real text on top,
and each section behaves differently enough that the visitor keeps going.
**What you produce:** an interview brief, a page grammar, a customer-journey
map, a feeling curve with one engineered peak, a scroll score, one signature
move, assets, one real HTML page on a token-driven design floor, and a strip of
screenshots proving it holds up at every scroll position.
Use for: "scrollytelling", "scroll animation site", "a site where scrolling
plays a video", "Apple-style landing page", "3D scroll world", "make my brand a
scroll experience", "this looks like a template", or any request for a site
that should feel like an experience rather than a document.
## What this is not
It is not "generate a flythrough and drop text on it." That produces one device
applied to a whole page, recognisable at a glance. Four spine rules:
1. **Variety is the product.** At least four device families, never the same
device twice in a row. Read [references/devices.md](references/devices.md).
2. **The world is photographic** unless the brand is genuinely illustrated.
Clay/low-poly diorama is banned as a default. Read [references/worlds.md](references/worlds.md).
3. **No continuous chain** unless the brief is literally "one continuous
journey" (then see [references/worldflight.md](references/worldflight.md)).
4. **A different world is not a different page.** Structure is a separate axis;
decide it deliberately. Read [references/uniqueness.md](references/uniqueness.md).
## Step 0: The interview
**Always ask the user in chat before building anything.** Real questions, asked
and answered in the conversation, written down — not a brief inferred from the
brand name. Eight questions in one pass:
1. **Vibe in three to five words**, plus up to three references from any medium
(film, album cover, shop, magazine, game — not "sites you like").
2. **The scroll journey, section by section, in their words.**
3. **The energy curve** — where calm, where intense.
4. **How should someone feel while scrolling, stage by stage, and what is the
ONE moment they should remember?** Becomes the feeling curve and the peak.
See [references/feel.md](references/feel.md).
5. **One thing this site should do that no site they have seen does** — the
seed of the signature move.
6. **How far from premium-minimal?** Offer the range in
[references/uniqueness.md](references/uniqueness.md) §5: brutalist,
maximalist, playful, retro, dense, editorial, premium-minimal.
7. **One unbroken world, or distinct scenes?** The biggest structural fork, and
it is their call.
8. **What assets do they already have?** Footage, photos, product shots, brand
kit. "Nothing" is fine and means a fully generated world.
Write the answers verbatim into `<workspace>/builds/<name>/BRIEF.md` (use
write_file) before any act planning. BRIEF.md must contain the eight answers,
the feeling curve (one line per act: emotion, then cause), the peak (as the
sentence a visitor would say to a friend), the completed "It's the site where
___" sentence, and any authored silence. If the user is genuinely unreachable
in a fully autonomous run, self-author BRIEF.md, mark it
`Self-authored, not interviewed`, and say so in the report.
## Bootstrap
Run the preflight rather than checking by hand (it catches a stripped ffmpeg
that reports missing filters as syntax errors):
```bash
node <skill>/scripts/doctor.mjs
node <skill>/scripts/workspace.mjs --ensure # prints workspace, seeds registry
```
Workspace resolution order: `SCROLLCRAFT_HOME` env var; nearest
`.scrollcraft.json` (`{ "workspace": "..." }`) walking up from cwd;
`<project root>/scrollcraft`. Builds live at `<workspace>/builds/<name>/`, the
fingerprint registry at `<workspace>/FINGERPRINTS.md` (seeded from
[templates/FINGERPRINTS.md](templates/FINGERPRINTS.md), starts empty — the gate
stops you repeating *yourself*).
Copy `engine/scrollcraft.js` and `engine/scrollcraft.css` into the build
folder. **Never edit the engine per-project.** Theme with tokens; write your
own markup. Bespoke behaviour is bespoke JS in the page, driven off `--sc-p`
and your own `data-sc-*` attributes.
## Step 1: The brief, journey first
Ask the subject open, in plain prose. Then ask only what Step 0 did not cover:
what is this and who is it for; the one sentence the page installs; the one
next action (one label, used everywhere); what they already have; art
direction from [references/worlds.md](references/worlds.md). Then write the
**journey**: four to seven beats, each a shift in what the visitor knows or
feels. Beats are the spine; a section serving no beat is cut. Confirm the
journey with the user before generating assets — assets are the expensive part.
## Step 2: Grammar, gate, then score
Full detail in [references/uniqueness.md](references/uniqueness.md).
- **Pick a grammar.** Eight, mutually exclusive. Choosing filmic one-shot means
saying in the report why the other seven lost. Nav, hero and close follow
from the grammar.
- **Invent the signature move.** One bespoke interaction coded in the page, not
a parameter change to a kit device. Interview question 5 is the seed.
- **Run the fingerprint gate.** The planned build must differ from every row in
`<workspace>/FINGERPRINTS.md` on at least 4 of 6 dimensions: grammar, nav
treatment, hero device, act-sequence shape, close pattern, signature move.
If it fails, change the plan, not the log.
- **Write the feeling curve before the score table** (method:
[references/feel.md](references/feel.md)). Then assign each beat a device in
a written table (beat / device / why).
Checks before building: grammar bans hold; 4+ device families; no device
twice in a row; at most two `scrub` acts; no two adjacent acts with the same
feeling; one peak with the largest span; total page length 8–14
viewport-heights.
## Step 3: Assets
Full pipeline, prompt scaffolds and model notes: [references/assets.md](references/assets.md).
**Hermes-native paths first:**
- **User-supplied footage and photos** — no key, no spend, a first-class route.
Grade and encode them.
- **The `image_generate` tool** for stills: one style preamble reused verbatim
in every prompt is what makes six images look like one shoot. Inspect every
asset (vision_analyze) before use; rerolling beats shipping a bad frame.
**Optional upstream path — kie.ai** (vendored verbatim as
[scripts/kie.mjs](scripts/kie.mjs)): photoreal stills and camera-move clips.
Requires the `KIE_AI_API_KEY` environment variable (export it in your shell;
there is no bundled env file in this port). Check balance with
`node <skill>/scripts/kie.mjs probe`; a still costs cents, a 5s clip more.
```bash
node <skill>/scripts/kie.mjs still "<style preamble>\n\n<scene>" out/01-hero.png --ar 16:9
node <skill>/scripts/kie.mjs shot "<camera move>" out/01-hero.png out/01.mp4 --dur 5
bash <skill>/scripts/encode.sh out/01.mp4 assets/01.mp4
bash <skill>/scripts/encode.sh out/01.mp4 assets/01-m.mp4 mobile
```
**Encode for scrubbing, not playback.** `encode.sh` sets a dense GOP because
seeking walks from the previous keyframe; a normal web encode scrubs like mud.
It also strips audio.
## Step 4: Build the page
Write real HTML — real `<h1>`, real `<p>`, real reading order. The engine reads
`data-sc-*` attributes off your markup and drives it; it never generates DOM.
Start from [references/template.html](references/template.html). Device
patterns: [references/devices.md](references/devices.md). Spacing, type, depth,
colour: [references/taste.md](references/taste.md) — read it before writing
markup. Theme by overriding tokens, six values and two fonts:
```css
:root {
--sc-canvas: #0A0806; --sc-surface: #16110E;
--sc-ink: #F5EBDD; --sc-ink-soft: #A2968A;
--sc-accent: #FF5A3D; --sc-accent-ink: #15110F;
--sc-font-display: "Archivo", system-ui, sans-serif;
--sc-font-text: "Geist", system-ui, sans-serif;
}
```
## Step 5: Verify by scrolling it
Not optional. Every scroll position is a different frame; failures live between
the two you looked at. Full procedure: [references/verify.md](references/verify.md).
```bash
cd <build project> && npm i playwright-core # once
node <skill>/scripts/serve.mjs --root . --port 4500 &
node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/shots
node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/mobile --width 390 --height 844
node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/reduced --reduced-motion
```
The harness walks each act at six positions, waits for scrub video to settle,
reports dead scroll, cues that never reach full opacity, and composited
contrast; it writes a contact sheet. Then read `sheet.png` yourself
(vision_analyze) — the harness proves a clip advances, not that the page means
anything. Run the feel check ([references/feel.md](references/feel.md) §6):
scroll cold, one word per act, diff against BRIEF.md. Where they disagree the
page is wrong, not the brief.
A green run does not cover a real phone (video decoder, autoplay policy, Low
Power Mode). On any reported mobile defect, deploy
[references/device-diag.html](references/device-diag.html) beside the site on
the first round and let the device answer.
## Hard rules (ship-blockers)
No clay diorama default; no "scroll to explore" cues or animated mouse icons;
no `01 / 06` section counters; at most one eyebrow per three sections; no
visible em dashes; vary the copy anchor; no device twice in a row; never build
before the interview; one engineered peak, not zero or three; the close
resolves instead of fading to a footer; curve before devices; one bespoke
signature move; 4-of-6 fingerprint clearance against every row; never edit the
engine; no full-frame dark overlay for contrast (scrim only where text sits);
no text baked into images; no invented statistics; no `transition: all` or
animating width/height/top/left (`transform`/`opacity`; `clip-path` for
wipes); no gradient text or neon glow; no audio on scrub clips; never ship
without Step 5.
## Output
The build folder including BRIEF.md, then a short report: grammar and why the
other seven lost, signature move, fingerprint gate result per row, journey,
feeling curve and peak, feel-check diff, score table, what you generated, what
you verified with screenshots, and what you could not verify. Append the
build's row to `<workspace>/FINGERPRINTS.md`.
## Pitfalls
- `scripts/shoot.mjs` needs Playwright (`npm install playwright` or
`playwright-core` plus a Chrome install). Hermes' `browser_exec` tool is the
lighter alternative for scroll-screenshot verification: serve the build,
scroll in steps, capture screenshots, and inspect them yourself.
- `scripts/kie.mjs` needs `KIE_AI_API_KEY` and paid credit; prefer
`image_generate` or user assets when the budget is unclear.
- `encode.sh` and `doctor.mjs` expect a full ffmpeg build; distro-stripped
ffmpeg reports missing filters as command syntax errors — run
`scripts/doctor.mjs` first.
- Upstream script invocations above are copied from upstream docs and
unverified by this port beyond `node --check` syntax validation — trust
`--help`/source if drifted.
- The upstream repo ships worked examples and a change log that are not
vendored in this port; see the upstream repository if you want them.

View File

@@ -0,0 +1,432 @@
/* ============================================================================
scrollcraft.css: the taste floor
----------------------------------------------------------------------------
Two layers, and the split matters:
TOKENS what you override per brand. Colour roles, the type ramp, the
spacing scale, elevation, motion. A brand is ~12 values.
DEVICES what the engine drives. Do not restyle these to taste; they are
the mechanism. Style your own markup instead.
Nothing here draws a "component". There are no card, badge, or pill classes,
because a stylesheet that ships those is how every page built on it ends up
with the same shapes. You get a floor and a vocabulary; the composition is
yours.
========================================================================== */
/* ---------------------------------------------------------------- tokens -- */
:root {
/* --- colour roles. Override these six and the page is rebranded. -------- */
--sc-canvas: #08090b; /* the page ground. Drift interpolates this. */
--sc-surface: #101217; /* anything raised off the ground */
--sc-ink: #f4f2ef; /* primary text */
--sc-ink-soft: #9a9ba1; /* secondary text. Tinted, never flat gray. */
--sc-accent: #d8ff3e; /* ONE accent. It owns a region, not confetti. */
--sc-accent-ink: #08090b; /* text that sits on the accent */
--sc-hairline: color-mix(in oklab, var(--sc-ink) 12%, transparent);
--sc-hairline-strong: color-mix(in oklab, var(--sc-ink) 22%, transparent);
/* --- type. Two families max. Display carries voice, text carries prose. - */
--sc-font-display: "Instrument Sans", "Geist", system-ui, sans-serif;
--sc-font-text: "Geist", system-ui, sans-serif;
--sc-font-mono: "Geist Mono", ui-monospace, monospace;
/* Fluid ramp. Tracking tightens as size grows because a face set at 6rem
with 0 tracking reads loose; this is optical correction, not decoration. */
--sc-t-xs: clamp(0.75rem, 0.72rem + 0.15vw, 0.82rem);
--sc-t-sm: clamp(0.875rem, 0.84rem + 0.18vw, 0.95rem);
--sc-t-base: clamp(1rem, 0.96rem + 0.22vw, 1.125rem);
--sc-t-lg: clamp(1.2rem, 1.1rem + 0.5vw, 1.5rem);
--sc-t-xl: clamp(1.6rem, 1.35rem + 1.2vw, 2.25rem);
--sc-t-2xl: clamp(2.1rem, 1.6rem + 2.4vw, 3.4rem);
--sc-t-3xl: clamp(2.8rem, 1.9rem + 4.2vw, 5rem);
--sc-t-4xl: clamp(3.4rem, 1.9rem + 6.6vw, 7.5rem);
--sc-track-tight: -0.035em;
--sc-track-snug: -0.02em;
--sc-track-normal: -0.005em;
--sc-track-wide: 0.08em;
--sc-leading-none: 0.94;
--sc-leading-tight: 1.06;
--sc-leading-body: 1.62;
--sc-measure: 62ch;
/* --- space. 4px base: the useful middle steps an 8-only scale misses. --- */
--sc-1: 0.25rem; --sc-2: 0.5rem; --sc-3: 0.75rem; --sc-4: 1rem;
--sc-5: 1.5rem; --sc-6: 2rem; --sc-7: 3rem; --sc-8: 4rem;
--sc-9: 6rem; --sc-10: 8rem; --sc-11: 12rem;
/* Section rhythm is fluid so a phone doesn't inherit desktop air. */
--sc-section: clamp(4.5rem, 9vw, 11rem);
--sc-gutter: clamp(1.25rem, 5vw, 5.5rem);
--sc-maxw: 82rem;
/* --- radius. Pick one scale for the page and hold it. ------------------- */
--sc-r-sm: 6px; --sc-r-md: 12px; --sc-r-lg: 20px; --sc-r-pill: 999px;
/* --- elevation. Every shadow has an offset AND a soft blur, and is tinted
to the canvas hue. A zero-offset coloured halo is decoration, not
depth, so there isn't one here. ---------------------------------- */
--sc-shadow-color: 220 40% 2%;
--sc-e1: 0 1px 2px hsl(var(--sc-shadow-color) / 0.28),
0 2px 6px -1px hsl(var(--sc-shadow-color) / 0.20);
--sc-e2: 0 2px 4px hsl(var(--sc-shadow-color) / 0.24),
0 8px 18px -4px hsl(var(--sc-shadow-color) / 0.30);
--sc-e3: 0 4px 8px hsl(var(--sc-shadow-color) / 0.22),
0 18px 40px -8px hsl(var(--sc-shadow-color) / 0.38),
0 48px 90px -24px hsl(var(--sc-shadow-color) / 0.30);
/* Edge light: a 1px top highlight sells a raised surface better than any
amount of blur, because real raised things catch light on their lip. */
--sc-edge: inset 0 1px 0 color-mix(in oklab, var(--sc-ink) 10%, transparent);
/* --- motion. Built-in CSS easings are too weak for UI. ------------------ */
--sc-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--sc-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--sc-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
--sc-d-fast: 160ms; --sc-d-base: 240ms; --sc-d-slow: 420ms;
--sc-z-stage: 1; --sc-z-copy: 20; --sc-z-chrome: 60;
}
/* --------------------------------------------------------------- reset -- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
body {
margin: 0;
background: var(--sc-canvas);
color: var(--sc-ink);
font-family: var(--sc-font-text);
font-size: var(--sc-t-base);
line-height: var(--sc-leading-body);
letter-spacing: var(--sc-track-normal);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-synthesis-weight: none;
overflow-x: clip;
}
img, video, canvas, svg { display: block; max-width: 100%; }
button, input, select, textarea { font: inherit; color: inherit; }
/* ------------------------------------------------- the browser surfaces --
The parts you did not draw still carry the design. Selection, caret, focus
ring, scrollbar and tabular numerals all ship with defaults that belong to no
design system. Theming them is the cheapest signal that a page was built
rather than assembled, and it is the step that gets skipped most reliably. */
::selection { background: var(--sc-accent); color: var(--sc-accent-ink); }
:root { caret-color: var(--sc-accent); accent-color: var(--sc-accent); }
:focus-visible {
outline: 2px solid var(--sc-accent);
outline-offset: 3px;
border-radius: var(--sc-r-sm);
}
:where(a) { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 0.22em; }
@supports (scrollbar-color: auto) {
html { scrollbar-color: color-mix(in oklab, var(--sc-ink) 26%, transparent) transparent; scrollbar-width: thin; }
}
:where(table, .sc-nums, [data-sc-count]) { font-variant-numeric: tabular-nums; }
/* ----------------------------------------------------------- typography -- */
.sc-display {
font-family: var(--sc-font-display);
font-weight: 500;
line-height: var(--sc-leading-none);
letter-spacing: var(--sc-track-tight);
text-wrap: balance;
margin: 0;
}
.sc-display--xl { font-size: var(--sc-t-4xl); }
.sc-display--lg { font-size: var(--sc-t-3xl); }
.sc-display--md { font-size: var(--sc-t-2xl); letter-spacing: var(--sc-track-snug); }
.sc-lede {
font-size: var(--sc-t-lg);
line-height: 1.44;
letter-spacing: var(--sc-track-snug);
color: var(--sc-ink);
max-width: 46ch;
text-wrap: pretty;
margin: 0;
}
.sc-body { max-width: var(--sc-measure); color: var(--sc-ink-soft); text-wrap: pretty; margin: 0; }
.sc-label {
font-family: var(--sc-font-mono);
font-size: var(--sc-t-xs);
letter-spacing: var(--sc-track-wide);
text-transform: uppercase;
color: var(--sc-ink-soft);
}
/* ---------------------------------------------------------------- layout -- */
.sc-wrap { width: 100%; max-width: var(--sc-maxw); margin-inline: auto; padding-inline: var(--sc-gutter); }
.sc-section { padding-block: var(--sc-section); }
/* More space above a heading than below it: the gap belongs to the boundary,
not to the pair. */
.sc-stack > * + * { margin-top: var(--sc-4); }
.sc-stack > :is(h1,h2,h3) { margin-top: var(--sc-7); }
.sc-stack > :is(h1,h2,h3):first-child { margin-top: 0; }
.sc-rule { height: 1px; border: 0; background: var(--sc-hairline); margin: 0; }
/* =============================================================== DEVICES ==
Driven by the engine. Restyle your own markup, not these. */
.sc-act--pinned { position: relative; }
.sc-stage {
position: sticky; top: 0;
height: 100vh; height: 100svh;
overflow: clip;
z-index: var(--sc-z-stage);
}
/* scrub / sequence media */
.sc-stage :is(video[data-sc-scrub], canvas[data-sc-sequence], .sc-stage__poster) {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
}
video[data-sc-scrub] { opacity: 0; transition: opacity 380ms var(--sc-ease-out); }
.sc-has-clip video[data-sc-scrub] { opacity: 1; }
/* The poster is a live frame-holder, not a placeholder: it stays up until a
real video frame has painted, which is what stops the blank-stage flash. */
.sc-stage__poster { z-index: 0; transition: opacity 380ms var(--sc-ease-out); }
.sc-has-clip .sc-stage__poster { opacity: 0; }
/* A scrim only where text sits. A full-frame darkening layer flattens the
image everywhere to fix contrast in one corner. */
.sc-scrim {
position: absolute; inset: 0; pointer-events: none; z-index: 1;
background: linear-gradient(
var(--sc-scrim-angle, 180deg),
color-mix(in oklab, var(--sc-canvas) var(--sc-scrim-a, 78%), transparent) 0%,
color-mix(in oklab, var(--sc-canvas) 30%, transparent) 42%,
transparent 68%);
}
/* Point the scrim at the copy. A scrim gradient that darkens the top while the
copy sits at the bottom fixes contrast nowhere and flattens the image
everywhere, which is the most common way text-over-video goes wrong. */
.sc-scrim--bottom { --sc-scrim-angle: 0deg; }
.sc-scrim--left { --sc-scrim-angle: 90deg; }
.sc-scrim--right { --sc-scrim-angle: 270deg; }
/* Corner scrims, for copy anchored to a corner. An edge gradient has to darken
a whole band across the frame to cover one corner; a corner gradient puts the
density where the text is and leaves the rest of the image alone. Pair with
.sc-copy--lead / .sc-copy--trail. */
.sc-scrim--lead {
background: linear-gradient(to top right,
color-mix(in oklab, var(--sc-canvas) 94%, transparent) 0%,
color-mix(in oklab, var(--sc-canvas) 72%, transparent) 30%,
color-mix(in oklab, var(--sc-canvas) 30%, transparent) 52%,
transparent 72%);
}
.sc-scrim--trail {
background: linear-gradient(to top left,
color-mix(in oklab, var(--sc-canvas) 94%, transparent) 0%,
color-mix(in oklab, var(--sc-canvas) 72%, transparent) 30%,
color-mix(in oklab, var(--sc-canvas) 30%, transparent) 52%,
transparent 72%);
}
/* A band across the bottom, for copy that spans the full width of the frame.
That is what both corner anchors become below 860px, and it is also the right
shape whenever the copy block is wider than a corner. */
.sc-scrim--band {
background: linear-gradient(to top,
color-mix(in oklab, var(--sc-canvas) 94%, transparent) 0%,
color-mix(in oklab, var(--sc-canvas) 74%, transparent) 22%,
color-mix(in oklab, var(--sc-canvas) 30%, transparent) 42%,
transparent 58%);
}
.sc-scrim--vignette {
background: radial-gradient(120% 90% at 50% 45%, transparent 40%,
color-mix(in oklab, var(--sc-canvas) 70%, transparent) 100%);
}
/* copy over a stage.
max-width is in rem, never ch. A `ch` on this container resolves against the
CONTAINER's font-size (body size), not the display size of the heading inside
it, so `max-width: 20ch` here silently produces a ~180px column and wraps a
hero headline to six lines. Set a ch measure on the text element itself. */
.sc-copy {
position: absolute; z-index: var(--sc-z-copy);
inset-inline: var(--sc-gutter);
max-width: min(46rem, 76vw);
will-change: opacity, transform;
}
.sc-copy--lead { left: var(--sc-gutter); bottom: clamp(3rem, 12vh, 9rem); }
/* inset-inline FIRST. It is the shorthand for left+right, so declaring it after
`left: 50%` resets left to auto and the block drifts off the left edge. */
.sc-copy--center { inset-inline: auto; left: 50%; top: 50%; translate: -50% -50%; text-align: center; }
.sc-copy--trail { inset-inline: auto; right: var(--sc-gutter); bottom: clamp(3rem, 12vh, 9rem); text-align: right; }
/* cue defaults: the engine writes opacity/transform inline, these are the
pre-paint state so nothing flashes before the first read() */
[data-sc-cue] { opacity: 0; will-change: opacity, transform; }
.sc-ready [data-sc-cue] { transition: none; }
/* kinetic split */
.sc-split { display: inline-block; overflow: hidden; vertical-align: top; }
/* Descenders live below the baseline; a line mask clipped to the line box eats
the tails off g, y, p, j. The padding buys them room back. */
.sc-split--line { display: block; padding-bottom: 0.14em; margin-bottom: -0.14em; }
.sc-split__i { display: inline-block; will-change: transform, opacity; }
.sc-is-split { opacity: 1 !important; }
/* horizontal rail */
[data-sc-pan] { display: flex; will-change: transform; }
/* reveal + parallax */
[data-sc-reveal] { will-change: clip-path; }
[data-sc-parallax] { will-change: transform; }
/* flow reveal (fires once) */
[data-sc-in], [data-sc-stagger] > * {
opacity: 0;
transform: translate3d(0, 14px, 0);
transition: opacity 620ms var(--sc-ease-out), transform 620ms var(--sc-ease-out);
}
[data-sc-in].sc-in, [data-sc-stagger] > .sc-in { opacity: 1; transform: none; }
/* pointer devices */
[data-sc-tilt] { transform-style: preserve-3d; will-change: transform; }
[data-sc-magnet] { will-change: transform; }
/* isolation always; position only if nothing more specific already set it.
Written with :where() (zero specificity) on purpose: a plain `position:
relative` here has the same weight as `.sc-stage` and, being later in the
file, silently wins. A stage carrying both attributes then stops being
sticky and the whole act scrolls away instead of pinning. */
[data-sc-spotlight] { isolation: isolate; }
:where([data-sc-spotlight]) { position: relative; }
[data-sc-spotlight]::after {
content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 2;
background: radial-gradient(
28rem 28rem at calc(var(--sc-mx, 0.5) * 100%) calc(var(--sc-my, 0.5) * 100%),
color-mix(in oklab, var(--sc-accent) 16%, transparent), transparent 70%);
opacity: 0; transition: opacity var(--sc-d-slow) var(--sc-ease-out);
}
[data-sc-spotlight]:hover::after { opacity: 1; }
/* =========================================================== WORLDFLIGHT ==
One fixed stage for the whole page. The only thing in document flow is the
spacer, and it is empty. If you find yourself adding a section here, you want
act mode instead: the moment a real block scrolls past the fixed stage, the
page has a seam again and the whole point is gone. */
[data-sc-mode="worldflight"] { position: relative; }
.sc-world {
position: fixed; inset: 0;
overflow: clip;
z-index: var(--sc-z-stage);
background: var(--sc-canvas);
}
/* Every leg is mounted for the life of the page and stacked in the same box.
The engine owns opacity, visibility and z-index here; do not set them. */
.sc-world__seg {
position: absolute; inset: 0;
opacity: 0;
will-change: opacity;
}
.sc-world__seg > :is(video, img, canvas) {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
}
/* The poster is the frame-holder AND the first camera move: the engine pushes
it in slowly until a real decoded frame is available to replace it. */
.sc-world__poster {
z-index: 0;
transform-origin: 50% 50%;
transition: opacity 420ms var(--sc-ease-out);
}
.sc-world__seg > video { z-index: 1; }
.sc-world__seg.sc-has-clip .sc-world__poster { opacity: 0; }
/* The act stylesheet lights a clip through its ancestor act. A worldflight leg
has no act, so match the class the engine also writes on the clip itself. */
video[data-sc-scrub].sc-has-clip { opacity: 1; }
/* The copy layer is fixed too, and inert by default: the engine hands
pointer-events back to a block only while it is actually legible. */
.sc-world__copy {
position: fixed; inset: 0;
z-index: var(--sc-z-copy);
pointer-events: none;
}
.sc-world__scrim { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
[data-sc-copy] {
opacity: 0;
pointer-events: none;
will-change: opacity, transform;
}
.sc-ready [data-sc-copy] { transition: none; }
/* The track. Empty on purpose. */
.sc-world__spacer { pointer-events: none; }
/* scroll progress */
[data-sc-progress] {
position: fixed; inset: 0 0 auto 0; height: 2px; z-index: var(--sc-z-chrome);
background: var(--sc-accent); transform-origin: 0 50%; transform: scaleX(0);
will-change: transform;
}
/* ----------------------------------------------------------- atmosphere --
Depth is not only shadow. Grain keeps a flat dark ground from banding, and
it is the difference between "a dark page" and "a lit room". */
.sc-grain { position: fixed; inset: 0; pointer-events: none; z-index: 3; opacity: 0.045; }
.sc-grain::before {
content: ""; position: absolute; inset: -50%;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/></filter><rect width='140' height='140' filter='url(%23n)'/></svg>");
}
/* -------------------------------------------------------- reduced motion -- */
@media (prefers-reduced-motion: reduce) {
/* Fewer and gentler, not zero. Opacity still carries the reveal so the page
is comprehensible; every position change is dropped. */
[data-sc-in], [data-sc-stagger] > * { transform: none; transition-duration: 220ms; }
[data-sc-parallax], [data-sc-pan], .sc-split__i { transform: none !important; }
/* A wipe is a position change too. Show reveal content settled. */
[data-sc-reveal] { clip-path: none !important; }
/* A rail's transform IS its navigation, not decoration. Zeroing it the way
parallax and kinetic type are zeroed parks the act on its first screenful
and makes everything past the fold unreachable: a device degrading into
missing content. Hand the travel back to the reader as an ordinary scroll
region instead, so the same items are all still gettable without motion.
A build that would rather re-lay the rail out as a grid can still do that;
this is the floor, not the ceiling. */
[data-sc-act="pan"] .sc-stage,
[data-sc-act="pan"] [data-sc-stage] {
overflow-x: auto; overflow-y: hidden;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
}
[data-sc-pan] > * { scroll-snap-align: center; }
[data-sc-spotlight]::after { display: none; }
/* A worldflight keeps its whole story here. No clip is ever fetched, so the
posters are the film, and they cross-dissolve through exactly the same
seams at exactly the same scroll positions. The push-in and the copy drift
are the only things that go. */
.sc-world__poster, [data-sc-copy] { transform: none !important; }
}
/* --------------------------------------------------------------- mobile -- */
@media (max-width: 860px) {
.sc-copy { inset-inline: var(--sc-gutter); max-width: none; }
.sc-copy--trail { text-align: left; }
.sc-stage { height: 100svh; }
/* .sc-copy--trail re-anchors to the left and spans the full width here, so a
bottom-RIGHT corner gradient darkens the one corner the copy just left and
guarantees a contrast failure over any bright clip. Point the density at
the copy: the same band .sc-scrim--band paints. */
.sc-scrim--trail {
background: linear-gradient(to top,
color-mix(in oklab, var(--sc-canvas) 94%, transparent) 0%,
color-mix(in oklab, var(--sc-canvas) 74%, transparent) 22%,
color-mix(in oklab, var(--sc-canvas) 30%, transparent) 42%,
transparent 58%);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,286 @@
# Assets
Generation through kie.ai, then encoding for scrubbing. All of it via
`scripts/kie.mjs` and `scripts/encode.sh`.
```bash
node <skill>/scripts/kie.mjs probe # credit check first
node <skill>/scripts/kie.mjs still "<prompt>" out/01.png --ar 16:9 [--ref brand.png]
node <skill>/scripts/kie.mjs shot "<move>" out/01.png out/01.mp4 --dur 5 [--tail end.png]
bash <skill>/scripts/encode.sh out/01.mp4 assets/01.mp4
bash <skill>/scripts/encode.sh out/01.mp4 assets/01-m.mp4 mobile
```
Models: `seedream/5-pro-text-to-image` (and `-image-to-image` when you pass
`--ref`) for stills, `kling/v2-1-pro` for camera moves. `aspect_ratio`,
`quality` and `output_format` are all required by seedream; omitting any one
returns a bare "This field is required" that does not name the field.
**seedream rejects most aspect ratios, and does not say which it takes.** An
unsupported value fails at `createTask` with `"This aspect_ratio is not within
the range of allowed options"` and no list, which reads like a malformed request
rather than a menu problem. Nothing is charged, so the cost is a wasted round
trip and the time to work out that the string itself was fine.
| `--ar` | Status | Returns |
|---|---|---|
| `16:9` | works | 2736x1520 |
| `9:16` | works | 1520x2736 |
| `3:4` | works | 1776x2352 |
| `4:5` | **rejected** | n/a |
The returned pixel dimensions are near the ratio rather than exactly it, so
derive layout from the file, not from the string you asked for. Treat the table
as the verified set rather than as the whole allowed list: **use a value from it,
and if you need another, send one throwaway call before writing a wave of
prompts around it.** `4:5` is the one that catches people, because it is the
standard portrait social ratio and every other generator takes it. `3:4` is the
portrait to reach for here.
---
## How many assets
Fewer than you think. A six-act page needs roughly:
- **2 clips.** One hero move, one texture or detail move. That is the cap from
SKILL.md, and it is a quality rule as much as a budget one.
- **4 to 6 stills.** Posters for the clips, plus whatever the flow and rail acts
show.
Every clip also needs a poster, and the poster must be **the clip's own first
frame**, not a separate generation. Pull it with ffmpeg rather than generating
a lookalike; a poster that does not match causes a visible jump the moment the
video paints.
```bash
ffmpeg -y -i out/01.mp4 -frames:v 1 -q:v 2 assets/01-poster.png
```
**Use the same ffmpeg `encode.sh` resolved, not bare `ffmpeg`.** `encode.sh`
goes looking for a full build precisely because the one on PATH may be stripped,
but this poster line and the PSNR seam check below both call `ffmpeg` directly.
On a stripped build the WebP muxer is missing and you get `Unable to choose an
output format for 'poster.webp'`, which reads like a bad filename rather than a
missing encoder, and `-lavfi psnr` fails with `No such filter: 'psnr'`. Resolve
it once and reuse it:
```bash
FF=$(ls -d "$HOME"/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_*/ffmpeg-*-full_build/bin/ffmpeg.exe 2>/dev/null | head -1)
"$FF" -y -i assets/01.mp4 -frames:v 1 -vf scale=1600:-2 -c:v libwebp -quality 82 assets/p01.webp
```
---
## Stills
1. Pick a world in [worlds.md](worlds.md) and write its preamble once.
2. Every prompt is: **preamble, blank line, scene**. Verbatim preamble, every
time. This is the single thing that makes separately generated images look
like one shoot.
3. Name where the empty space goes, because copy sits on these images.
4. **Read every PNG before using it.** Generation is cheap; shipping a bad frame
is not.
**Pass the real brand object as `--ref`.** For anything with packaging, a logo,
or a product, hand seedream the actual asset. A label that drifts between shots
is the first thing a client notices. It holds up remarkably well: the same can
reads correctly across a dark kitchen, a hard-light flat-lay and a studio
backdrop when the cutout is passed every time.
---
**Published unit costs** (kie.ai, as of August 2026): `seedream/5-pro` still =
**28 credits**, `kling/v2-1-pro` 5s clip = **160 credits**. A seven-still,
two-clip page is therefore around 520 credits. `kie.mjs probe` reports a balance,
not a delta, so plan the spend from these numbers and probe before and after to
confirm. Do not trust a probe delta as this build's cost if anything else is
running against the same key (see [verify.md](verify.md)).
**Plan against those rates; expect the ledger to debit roughly 0.4x of them.**
Two reconciliations against the account with no other consumer put actual debits
at about 38% of the per-call sum: 1447 summed against 530 debited, and 2252
summed against 856 debited. That puts a still nearer ~11 credits and a 5s kling
clip nearer ~61, though those are back-derived from two samples rather than
published, so do not quote them as rates.
The practical consequence: **every budget cap in this skill is conservative, and
a build that comes in at its per-call cap has real headroom.** Keep planning at
28 and 160, because a ceiling that never under-claims is the right one to hold a
build to, and because the ratio is an observation about billing rather than a
published price that will hold. But do not refuse a justified reroll on a cap
computed at the documented rates, and do not report a per-call sum as measured
spend.
---
## Real footage the client already has
Supplied footage is usually **flat**: shot log-ish or picture-profiled, with no
white point. Downscaled straight into the page it produces washed-out acts that
no amount of scrim tuning rescues, and the instinct to fix it with a CSS filter
over full-bleed media is the thing taste.md warns against, because a filter
flattens the whole frame.
Grade it into a pre-encode intermediate instead:
```bash
# 1. measure. YMIN/YMAX/YAVG tell you whether the clip ever reaches white
ffmpeg -i raw.mov -vf signalstats,metadata=print:key=lavfi.signalstats.YMAX -f null -
# 2. expand levels + a small saturation lift, and land the fps you want
ffmpeg -y -i raw.mov -vf "colorlevels=rimin=0.09:gimin=0.09:bimin=0.09:\
rimax=0.64:gimax=0.64:bimax=0.64,eq=saturation=1.08,fps=30,scale=1920:-2" \
-c:v libx264 -crf 16 -an graded.mp4
# 3. then the normal dense-GOP encode for scrubbing
bash <skill>/scripts/encode.sh graded.mp4 assets/01.mp4
```
Three more things real footage needs that generated clips do not:
- **Trim before anyone enters frame.** "Nothing enters or leaves" is a hard
requirement for a clip the reader can park anywhere in, and real recordings
routinely have someone walk through at second nine.
- **Target 24 to 30fps before `encode.sh`.** A 60fps phone clip at a dense GOP is
several times the size for motion no hand can resolve. Decimate with `fps=30`
in the intermediate; the dense keyframes then cost half as much.
- **Re-encode, never stream-copy.** Cuts made with `-c copy` decode badly under
a scrubber.
---
## Camera moves
`shot` takes a still and moves the camera through it.
What makes a clip scrub well is not the same as what makes it watch well:
- **One continuous move, one direction.** A dolly-in, a drift down, a slow
orbit. Any cut, snap or direction reversal becomes a jolt under the wheel,
because the reader controls the playhead and will sit on the reversal.
- **Slower than feels right when previewed.** The move is spread over two or
three viewport-heights of scroll. A move that looks sedate at 24fps feels
correct under a hand.
- **The subject stays in frame throughout.** The reader may park anywhere.
- **Nothing enters or leaves.** A person walking in is a different shot at
frame 1 and frame 120, and the poster will match neither.
Prompt shape: what continues, how the camera moves, then the negatives.
> The camera pushes slowly and steadily forward toward the can, a smooth
> continuous dolly-in with a very slight downward tilt. The blurred window
> slides past on the left as parallax. The can stays perfectly still and in
> frame throughout. One single continuous take, no cuts, no camera shake, no
> zoom snap. Slow, cinematic, controlled.
The script already sends a negative prompt covering judder, warping, morphing,
flicker and scene changes, which are the failure modes that specifically wreck a
scrub.
### Seam locking, if you actually need a chain
`--tail` pins the last frame as well as the first. Leg N's tail is leg N+1's
head, so the joint is frame-identical:
```bash
ffmpeg -y -sseof -0.05 -i leg1.mp4 -frames:v 1 -q:v 2 seam1.png
node kie.mjs shot "<move>" seam1.png leg2.mp4 --dur 5
```
Extract the seam frame from the **encoded** clip, not the source: re-encoding
shifts frames slightly, and a seam built from the wrong file is a one-frame pop.
**Chaining on pre-generated anchors makes the legs parallel.** Extracting each
leg's head from the previous leg's *encoded* file forces the whole flight to
generate serially, which is roughly 45 minutes for ten legs. Generating every
anchor still first, then giving leg N `--tail` of anchor N+1 and head of anchor
N, means all ten clips run at once and every joint is still frame-locked to an
image both sides were built from. `orrery` did this for a ten-leg flight and
measured 28.5-39.8 dB across all nine joints, inside the band the serial
`descent` chain shipped at. The cost is that you must author the anchors as a
coherent descent, because the model resolves a head-and-tail conflict by pulling
the camera back.
Most pages should not do this at all. A chain exists only to hide cuts between
scenes, and varying the device between acts removes the cut instead of hiding
it, for free and with no failure mode. Chain only when the brief is literally
"one continuous journey".
---
## Encoding
`encode.sh` sets a dense GOP (`-g 8` desktop, `-g 4` mobile), strips audio, and
adds `+faststart`.
The reason is the whole trick: **a normal web encode places a keyframe every two
to five seconds.** Seeking to an arbitrary time makes the decoder walk forward
from the previous keyframe, so a sparse-GOP file plays perfectly and scrubs like
mud. Dense keyframes cost file size and buy responsiveness.
Expect roughly 3MB for a 5s 1080p desktop clip and 1.5MB for the 720p mobile
one. Two clips is about 9MB of video on the page, and the engine fetches each
only as its act approaches.
**Grain-heavy worlds run about double that.** Documentary grain, moving foliage
and film texture gave 5 to 6MB per desktop clip at the script's `-crf 20`;
`-crf 22` is a reasonable dial if a page needs the megabytes back. Longer real
footage costs proportionally more, so a page carrying 15 seconds of supplied
video will sit above the 9MB guideline. That can be the right trade, but make it
deliberately rather than discovering it at the end.
Audio is stripped because these clips are scrubbed, never played. A muted track
is dead weight and an autoplay-policy hazard.
**A stripped ffmpeg will fail here.** Some toolchains put an ffmpeg on PATH with
about 50 filters and no `scale`, `fps` or `tile`. It reports `No option name
near ...`, which reads like a syntax error in your command rather than a missing
filter. `encode.sh` counts filters and goes looking for a real build; override
with `SCROLLCRAFT_FFMPEG`.
---
## Portrait
A 16:9 clip covering a 9:16 viewport crops to the middle third, and a
composition built around negative space on the left loses exactly that space.
Options, in order of cost:
1. **Compose for both.** Keep the subject in the centre third and the copy in a
bottom band rather than in side negative space, because the bottom band
survives a centre crop and side space does not. Cheapest, and usually enough.
2. **Native portrait renders.** Generate a 9:16 still and clip for the hero only,
wire them as `data-sc-src-mobile`, and swap the poster with `<picture>` so the
frame-holder matches the clip it is holding for:
```html
<picture>
<source media="(max-width: 860px)" srcset="assets/01-hero-p.webp">
<img class="sc-stage__poster" src="assets/01-hero.webp" alt="">
</picture>
<video data-sc-scrub data-sc-src="assets/01.mp4"
data-sc-src-mobile="assets/01-p.mp4" playsinline muted></video>
```
A portrait poster with a landscape clip (or the reverse) jumps visibly the
moment the video paints, which is the whole failure the poster exists to
prevent. `encode.sh` has no portrait mode; do it by hand:
```bash
ffmpeg -y -i src.mp4 -vf "scale=720:-2" -c:v libx264 -crf 20 -g 4 \
-pix_fmt yuv420p -an -movflags +faststart assets/01-p.mp4
# cropping a 16:9 source to 9:16 instead of rendering native:
# -vf "crop=ih*9/16:ih,scale=720:-2"
```
3. **Drop the clip on phones.** Serve the poster and let the copy carry the act.
Under reduced motion the engine already does exactly this, so the layout is
known to work.
Also step the hero display size down on phones. `--sc-t-4xl` floors at 3.4rem,
which is a desktop floor: it wraps a normal hero headline to six lines at 390px.
See [taste.md](taste.md).
Do not solve it with `object-fit: contain`. Letterboxed video on a landing page
reads as a broken embed.

View File

@@ -0,0 +1,214 @@
<!doctype html>
<!--
device-diag: real-device scrub diagnostic for scrollcraft builds.
Headless verification cannot reproduce a phone's video decoder, autoplay
policy, Low Power Mode, or touch scrolling. When a clip is reported frozen
on a real device, deploy this page beside the site (same origin as the
clips), open it on the device, scroll up and down a few times, and read the
verdicts. One screenshot isolates the failing layer:
suspect-blob FROZEN, suspect-direct MOVING -> the blob: URL path is the problem
suspect FROZEN, known-good MOVING -> something specific to that clip's lifecycle
all MOVING -> the video is fine; the fault is in the
engine's reveal/tick path on the real page
all FROZEN -> the device refuses scrub decode; use the
poster fallback for that act
To use: edit the TESTS array below. Point the first two entries at the
suspect clip (same file, loaded two ways) and the third at a clip known to
work on the device. Paths resolve against this page's URL.
Scrubbing is driven by a rAF poll of scrollY, not scroll events, because a
driven browser can move the page without firing them.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>scrub diagnostic</title>
<style>
* { margin: 0; box-sizing: border-box; }
html { background: #0b0f14; color: #e8edf2; font: 13px/1.45 ui-monospace, Menlo, Consolas, monospace; }
body { height: 520vh; }
.panel { position: fixed; inset: 0; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
h1 { font-size: 14px; font-weight: 600; }
.hint { color: #8fa0b3; }
.row { display: flex; gap: 8px; flex: 0 0 auto; }
.cell { flex: 1 1 0; min-width: 0; }
.cell video { width: 100%; aspect-ratio: 9 / 16; object-fit: cover; background: #000; display: block; border: 1px solid #26303c; border-radius: 6px; }
.name { font-weight: 600; margin: 6px 0 2px; }
.verdict { font-size: 15px; font-weight: 700; padding: 2px 6px; border-radius: 4px; display: inline-block; }
.wait { background: #26303c; }
.ok { background: #0f5132; color: #b6f0cd; }
.bad { background: #6b1220; color: #ffc2cd; }
.stats { color: #aab7c4; font-size: 11.5px; white-space: pre-line; }
#meta { color: #8fa0b3; font-size: 11.5px; }
#log { flex: 1 1 auto; overflow: auto; background: #10161d; border: 1px solid #26303c; border-radius: 6px; padding: 8px; font-size: 11px; color: #9fb4c8; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="panel">
<h1>scrub diagnostic — SCROLL UP AND DOWN A FEW TIMES, then read the verdicts</h1>
<div class="hint">A: suspect clip, blob URL (how the engine loads it) &nbsp; B: suspect clip, direct file &nbsp; C: a clip that works on this device, blob URL</div>
<div class="row" id="cells"></div>
<div id="meta"></div>
<div id="log"></div>
</div>
<script>
(function () {
'use strict';
// EDIT THESE for the build under test.
var SUSPECT = 'assets/01-suspect-m.mp4';
var KNOWN_GOOD = 'assets/02-known-good-m.mp4';
var TESTS = [
{ key: 'A', label: 'A suspect blob', src: SUSPECT, mode: 'blob' },
{ key: 'B', label: 'B suspect direct', src: SUSPECT, mode: 'direct' },
{ key: 'C', label: 'C known-good blob', src: KNOWN_GOOD, mode: 'blob' }
];
var logEl = document.getElementById('log');
function log(m) {
logEl.textContent += m + '\n';
logEl.scrollTop = logEl.scrollHeight;
}
window.addEventListener('error', function (e) { log('JS ERROR: ' + e.message); });
var maxScrolled = 0;
var cellsEl = document.getElementById('cells');
var canvas = document.createElement('canvas');
canvas.width = 16; canvas.height = 16;
var ctx = canvas.getContext('2d', { willReadFrequently: true });
function make(t) {
var cell = document.createElement('div');
cell.className = 'cell';
cell.innerHTML = '<div class="name">' + t.label + '</div>' +
'<span class="verdict wait" id="v' + t.key + '">…</span>' +
'<video muted playsinline preload="auto" id="el' + t.key + '"></video>' +
'<div class="stats" id="s' + t.key + '"></div>';
cellsEl.appendChild(cell);
var T = {
key: t.key, el: cell.querySelector('video'),
verdict: cell.querySelector('.verdict'),
stats: cell.querySelector('.stats'),
seeked: 0, hashes: {}, hashCount: 0, prime: 'not tried',
primed: false, priming: false, err: ''
};
T.el.muted = true; T.el.playsInline = true;
T.el.addEventListener('seeked', function () { T.seeked++; });
T.el.addEventListener('error', function () {
var e = T.el.error;
T.err = 'MediaError code ' + (e ? e.code : '?');
log(t.key + ': ' + T.err);
});
T.el.addEventListener('loadedmetadata', function () {
log(t.key + ': metadata, duration ' + T.el.duration.toFixed(2) + 's');
try { T.el.currentTime = 0.001; } catch (e) {}
prime(T, 'load'); // same as the engine: try with no gesture first
});
if (t.mode === 'blob') {
fetch(t.src)
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.blob(); })
.then(function (b) {
log(t.key + ': fetched ' + (b.size / 1048576).toFixed(2) + ' MB, blob URL set');
T.el.src = URL.createObjectURL(b);
})
.catch(function (e) { T.err = 'fetch failed: ' + e.message; log(t.key + ': ' + T.err); });
} else {
T.el.src = t.src;
log(t.key + ': direct src set');
}
return T;
}
function prime(T, why) {
if (T.primed || T.priming || !T.el.src) return;
T.priming = true;
var pr;
try { pr = T.el.play(); } catch (e) { T.priming = false; T.prime = 'threw (' + why + ')'; return; }
setTimeout(function () {
if (T.priming) { T.priming = false; if (T.prime === 'not tried') T.prime = 'play() never settled (' + why + ')'; }
}, 2000);
if (pr && pr.then) {
pr.then(function () {
T.priming = false; T.primed = true; T.prime = 'OK (' + why + ')';
try { T.el.pause(); } catch (e) {}
log(T.key + ': primed via ' + why);
}, function (e) {
T.priming = false; T.prime = (e && e.name ? e.name : 'rejected') + ' (' + why + ')';
log(T.key + ': play() rejected via ' + why + ': ' + (e && e.name));
});
} else { T.priming = false; T.primed = true; T.prime = 'OK sync (' + why + ')'; try { T.el.pause(); } catch (e) {} }
}
var tests = TESTS.map(make);
function primeAll(ev) { tests.forEach(function (T) { prime(T, ev); }); }
['touchstart', 'touchend', 'pointerdown', 'click', 'scroll'].forEach(function (ev) {
addEventListener(ev, function () { primeAll(ev); }, { passive: true });
});
// scroll position drives every clip, exactly like the engine. Polled from a
// rAF loop rather than a scroll listener, so the diagnostic cannot be blinded
// by an environment that moves the page without firing scroll events.
var lastY = -1, lastPrimeTry = 0;
function loop() {
var maxY = document.body.scrollHeight - innerHeight;
var p = maxY > 0 ? Math.max(0, Math.min(1, scrollY / maxY)) : 0;
if (scrollY !== lastY) {
if (lastY !== -1 && performance.now() - lastPrimeTry > 600) {
lastPrimeTry = performance.now();
primeAll('scrollmove');
}
lastY = scrollY;
maxScrolled = Math.max(maxScrolled, p);
}
tests.forEach(function (T) {
if (!T.el.duration || T.el.seeking) return;
var t = p * T.el.duration * 0.98;
if (Math.abs(T.el.currentTime - t) > 0.02) {
try { T.el.currentTime = t; } catch (e) {}
}
});
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
function frameHash(T) {
if (T.el.readyState < 2) return null;
try {
ctx.drawImage(T.el, 0, 0, 16, 16);
var d = ctx.getImageData(0, 0, 16, 16).data, h = 0;
for (var i = 0; i < d.length; i += 7) h = (h * 31 + d[i]) >>> 0;
return h;
} catch (e) { return null; }
}
setInterval(function () {
tests.forEach(function (T) {
var h = frameHash(T);
if (h !== null && !T.hashes[h]) { T.hashes[h] = 1; T.hashCount++; }
T.stats.textContent =
'prime: ' + T.prime +
'\nreadyState ' + T.el.readyState + ' · seeks done ' + T.seeked +
'\ntime ' + T.el.currentTime.toFixed(2) + 's · frames seen ' + T.hashCount +
(T.err ? '\n' + T.err : '');
if (T.hashCount >= 3) { T.verdict.textContent = 'MOVING'; T.verdict.className = 'verdict ok'; }
else if (T.err) { T.verdict.textContent = 'ERROR'; T.verdict.className = 'verdict bad'; }
else if (maxScrolled > 0.35) { T.verdict.textContent = 'FROZEN'; T.verdict.className = 'verdict bad'; }
});
}, 250);
document.getElementById('meta').textContent =
navigator.userAgent + ' · ' + innerWidth + 'x' + innerHeight +
' · dpr ' + devicePixelRatio;
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,466 @@
# The device kit
Nine ways for scroll to change the page. Each one is a different answer to "what
does the visitor's hand actually do here."
Pick per beat, never per page. The variety law from SKILL.md Step 2 applies:
four or more families, never the same one twice in a row.
Every act publishes `--sc-p` (0 to 1) on its own element, so anything you want
to drive that the kit does not cover, you can drive from CSS with `calc()`
against that variable. Reach for that before asking for a new device.
---
## 1. `scrub`: the wheel is a scrubber
The anchor device. A pre-rendered camera move plays under the reader's hand,
one frame per notch. This is the thing people screenshot and send to each other,
so spend it on the open.
```html
<section data-sc-act="scrub" data-sc-span="2.6" data-sc-dwell="0.35"
data-sc-drift="#0A0806">
<div data-sc-stage>
<img class="sc-stage__poster" src="assets/01-hero.webp" alt="">
<video data-sc-scrub data-sc-src="assets/01.mp4"
data-sc-src-mobile="assets/01-m.mp4" playsinline muted></video>
<div class="sc-scrim"></div>
<div class="sc-copy sc-copy--lead" data-sc-cue="0.08 0.62 0.34">
<h1 class="sc-display sc-display--xl" data-sc-kinetic="lines">
Your morning shouldn't need two drinks.
</h1>
</div>
</div>
</section>
```
- `data-sc-span` is the act's scroll length in viewport-heights. 2.2 to 3.0 for
a hero. Below 1.8 the clip flies past; above 3.5 the reader starts wondering
whether the page is broken.
- `data-sc-dwell` (0 to 0.6) remaps time so the camera settles mid-act, exactly
where the copy peaks, and moves quicker at the edges. It is the difference
between a clip that plays and a shot that lands. Keep it at or below 0.6.
- `data-sc-src` (not `src`) is deliberate: the engine fetches the clip as a Blob
so it seeks without needing HTTP range support, and skips the fetch entirely
under reduced motion.
- The poster is a live frame-holder. It stays up until a real video frame has
painted, because iOS keeps a seeked-but-never-played muted video blank and
hiding the poster on metadata alone flashes an empty stage.
**At most two scrub acts per page.** The third one is no longer a surprise, and
it is the heaviest thing on the page.
### Clip time is not cue time
The single most damaging bug this device has, and it is invisible in every
screenshot taken one at a time.
A pinned stage is on screen for **one viewport before** its pinned travel begins,
sliding up into view, and **one viewport after** it ends, sliding off the top.
The act's progress `p` is 0 through the whole entry and 1 through the whole exit.
So a clip driven by `p` sits frozen on its first frame while it slides in, and
frozen on its last frame while it slides out. The reader has been scrubbing a
film with their hand, the film stops, and then the whole page slides a still
photograph past them. It reads as the site breaking, and it is the fastest way to
make an expensive page feel cheap.
The engine therefore maps the clip across the stage's **entire visible life**, not
across its pinned travel, and this is the **default**. Both ends are clamped to
scroll that actually exists, so a hero at the top of the document still starts on
frame one and an act near the bottom still reaches its last frame. Cues keep
using `p`, because cues belong to the pin.
**Pair it with `data-sc-dwell`.** Dwell moves quickly at the edges and settles in
the middle, which is exactly the shape this mapping wants: the fast motion lands
on the two slides, and the settle lands inside the pin where the copy is. The two
were built for each other.
`data-sc-clip-map="travel"` restores the old pinned-travel mapping. There is
almost no reason to reach for it, and reaching for it reintroduces the freeze.
The harness checks this now (see [verify.md](verify.md)), so a frozen clip fails
verification instead of shipping. Do not rely on noticing it by eye: every
individual frame of a frozen clip looks completely correct.
### The playhead is lerped
Scroll never writes `currentTime`. It writes a target, and a standalone rAF loop
walks the clip toward that target at a fixed fraction per frame. Wheel events do
not arrive at a constant rate, so a 1:1 write reproduces every gap in them and
the clip reads as a stutter rather than a glide. Three mechanisms, all on by
default:
- **Lerp 0.18 per frame.** `data-sc-lerp` overrides it, on the mount root for the
whole page or on one `<video>`. Clamped to 0.02 to 1, and never read as 0, since
a 0 lerp is a playhead that never moves. Reach for it only when a page's clips
are short enough that 0.18 visibly lags the hand. Under reduced motion the
rate is 1.0, which is no smoothing at all.
- **Deadband** of 8ms desktop, 20ms mobile. A write smaller than that costs a
seek and shows nothing; on a phone it costs more than it shows.
- **Seek coalescing.** No seek is queued while the decoder is still resolving the
last one. A fast flick otherwise piles seeks up and freezes the clip.
An offscreen clip that has already reached its target stops being touched at all.
This applies to every scrub clip on the page, act or worldflight. It is also why
`shoot.mjs` waits for the playhead to arrive before each shot: a frame captured
mid-lerp is a frame the page never actually holds.
---
## 2. `pin`: the frame holds, the content advances
The workhorse, and the cheapest premium effect there is. The stage sticks for a
few viewport-heights while copy states cross over inside it. Use it when the
beat is an argument rather than an image.
```html
<section data-sc-act="pin" data-sc-span="3" data-sc-drift="#12100E">
<div data-sc-stage class="pf-argument">
<p class="sc-lede" data-sc-cue="0.02 0.34">A cup of coffee. Then a shake.</p>
<p class="sc-lede" data-sc-cue="0.30 0.66">Two things to buy, carry and wash.</p>
<p class="sc-lede" data-sc-cue="0.62">Or one can.</p>
</div>
</section>
```
**Minimum useful span is about 1.2.** A pinned act's travel is
`max(height - viewport, 1)`, so at a span of 1 or below that is one pixel:
progress jumps 0 to 1 between two scroll notches and every cue, reveal and
`--sc-p`-driven animation inside the act snaps instead of running. A short quiet
act is exactly when an author reaches for a small span, which is exactly when
this bites. If the beat genuinely wants less than a screen of travel, it is a
`flow` act, not a pinned one.
Cue windows overlap by design: the outgoing line is still fading while the next
arrives, so the reader never faces empty space. A gap between cues reads as a
loading failure. Overlap by roughly 15% of the act.
The last cue takes one value and holds, so the act ends on a statement rather
than fading to nothing before the next section arrives.
### The cue contract
`data-sc-cue="from [to [rampIn [rampOut]]]"`, all in act progress (0 to 1).
| Form | Behaviour |
|---|---|
| `"0.2"` | fades in at 0.2 and **holds to the end of the act** |
| `"0.1 0.6"` | in, plateau, out. Ramps default to 30% of the window each |
| `"0 0.78 0"` | **greet**: already at full opacity when the act begins, then fades |
| `"0.1 0.9 0.15 0.4"` | fast in, long slow out |
| `"0 1 0 0"` | **greet and hold**: full at p = 0, no ramp at either end |
The plateau is the point. Without one a cue is a triangle that touches full
opacity for a single instant, so the reader has to stop on exactly the right
pixel to see the line at full strength and every heading reads slightly faded.
Rules the verification pass will catch you on:
- **A hero cue needs the greet form.** `"0 0.7"` ramps up from nothing, which
means the landing view, the one screen every visitor sees, has no headline on
it. Use a third value of `0`.
- **The last act's cue must hold.** Give it one value. A closing CTA on a
two-value cue fades out before the page ends, and the final screen is empty.
- **Only the last act may hold.** This is the inverse of the rule above and it
is the one that bites. The engine parks a cue only once its act is a viewport
and a quarter out of range, so a one-value cue on a *middle* pinned act stays
lit through the entire un-pin slide: the line travels a full viewport upward,
crosses any fixed header, and overlaps the section that follows. It is
invisible until you measure it, and it shows up as a contrast failure on a
headline nobody meant to still be on screen. Every act except the last closes
its final cue with a two-value window ending at 1.
- **Ground or greet.** A pinned stage becomes fully visible roughly a viewport
*before* its own progress leaves 0, so any pinned act whose first content is a
plain two-value cue shows an empty stage for that whole travel. Give the act
either a ground that is already there (an image, a held frame, a colour that
is doing work) or a first cue in the greet form. The closing act needs a
ground, because its hold cue cannot also greet unless you use `"0 1 0 0"`.
The rule covers *any* progress-gated content on a pinned act, not just engine
cues: a bespoke panel that populates from scroll state has the same empty-stage
window and needs the same ground.
---
## 3. `pan`: vertical scroll, lateral travel
Sideways movement reads as *breadth* where vertical reads as *argument*. Use it
for a range, a lineup, a timeline. Do not use it for a hierarchy: the first item
in a rail is not read as the most important one.
```html
<section data-sc-act="pan" data-sc-span="3.2">
<div data-sc-stage>
<div class="pf-rail" data-sc-pan="0.08">
<article class="pf-flavour">…</article>
<article class="pf-flavour">…</article>
<article class="pf-flavour">…</article>
</div>
</div>
</section>
```
The engine measures `scrollWidth` against the viewport and travels exactly the
overflow, so the last item lands flush at the right edge. `data-sc-pan="0.08"`
adds 8% overshoot if you want a breath after the final card.
Span rule: roughly 1 viewport-height per item, plus 1. Four cards want ~5.
**Measure the overflow, do not assume it.** The engine travels exactly
`scrollWidth - viewport`, so a rail narrower than the viewport travels **zero**
and the act becomes a pinned stage holding one motionless screen for its whole
span. Three cards at `clamp(16rem, 26vw, 24rem)` measured 1368px against a
1440px viewport: overflow **-72px**, travel 0, and the reader turns the wheel
through two viewport-heights of nothing. It is width-dependent, so it can be
correct on a phone and dead on a desktop at the same time, which is exactly how
it survives review: the mobile sheet pans and the desktop sheet looks like a
still. **The harness did not catch it** and reported `no dead scroll detected`
on every pass, so this is a manual measurement, not something a green run
covers:
```js
const rail = document.querySelector(".rail");
rail.scrollWidth - innerWidth // must be a healthy positive number
```
Aim for at least half a viewport of overflow. If three items do not reach it,
the fix is not wider cards, it is **more rail**: put the act's heading in as the
first item and a closing note as the last. Both earn their place (the heading
stops competing with fixed chrome, the note gives the rail a resolution instead
of an end), and they add the width the travel needs.
**Give the rail's items a staggered settle driven from `--sc-p`.** Lateral
travel alone reads as a slideshow on rails; items that arrive in sequence read
as a drawer being pulled. Exempt the first item, because a pan act needs its
opening content already present, and floor the opacity around 0.55 so a
not-yet-settled card reads as arriving rather than as failing to load. Gate the
whole thing to `prefers-reduced-motion: no-preference`, or the scroll-region
fallback inherits a page of half-faded items.
**Card copy is read while cropped.** Items enter and leave through the viewport
edges, so a heading is half-shown for most of its life and two half-headings
side by side can read as a third word (`TRACE` beside `EVALUATE` becoming
`RE EVALUATE`). Keep card headings to one short word or two, and expect every
line of card copy to be read partially cut off.
**Reduced motion.** The rail's transform is not decoration, it is the
navigation, so it cannot simply be zeroed the way parallax is: that parks the
act on its first screenful and makes every item past the fold unreachable. The
engine handles the floor for you, turning the stage into a native
`overflow-x: auto` scroll region with proximity snapping, so the same items stay
gettable without motion. If your rail reads better stacked or as a grid at that
point, override it in your own CSS under `prefers-reduced-motion` (the agency
reference build relays its three phases out as a grid at desktop widths).
---
## 4. `reveal`: a wipe is a change of state
`clip-path` eating in from an edge. It costs nothing and it reads as
transformation, which makes it right for the beat where something becomes
something else. Wrong for merely introducing an image, where a cue is enough.
```html
<figure data-sc-reveal="up" data-sc-reveal-at="0.15 0.55">
<img src="assets/03-carry.webp" alt="…">
</figure>
```
`up` `down` `left` `right` `iris`. Reach for `iris` roughly once per page; it is
the loudest of the five and stops reading as intentional if repeated.
A wipe that runs edge to edge across a full-bleed image is a transition. A wipe
on a small element is a fidget. Use it big.
**`clip-path` is relative to the border box, not to the ink.** A reveal on type
set with `line-height` below 1 has a border box shorter than the glyphs, so the
wipe clips the ascenders off the top and the descenders off the bottom and every
figure renders as a plain bar. Display numerals and drop figures are the usual
casualties, because those are the ones set tight. Either give the reveal element
room (`line-height: 1` and padding to cover the overshoot) or put
`data-sc-reveal` on a wrapper and leave the type's own box alone. No static
audit catches this; only a rendered screenshot does.
---
## 5. `kinetic`: type that assembles
Splits a heading into lines, words or characters and staggers them across the
cue window. Lines are almost always right; words for a short punch line;
characters approximately never, because it turns reading into waiting.
```html
<h2 class="sc-display sc-display--lg" data-sc-cue="0.1 0.7" data-sc-kinetic="lines">
Coffee that pulls its weight.
</h2>
```
Each unit slides up from behind a mask, so it enters from a clean edge rather
than simply fading. Line masks reserve room for descenders; a mask clipped to
the line box shears the tails off g, y, p and j, and that is the single most
common way this effect looks broken.
Line splitting measures real line boxes, so it re-runs after `document.fonts.ready`.
Do not call it on text that is still loading its face.
**One kinetic headline per act, at most.** Two competing for attention is noise,
and every heading assembling the same way is the templated rhythm this skill
exists to avoid.
---
## 6. `parallax`: layers at different rates
Depth from differential movement. Subtle or nothing: past roughly 200px of total
travel it stops reading as depth and starts reading as a bug.
```html
<div class="pf-layer pf-layer--back" data-sc-parallax="-1.4">…</div>
<div class="pf-layer pf-layer--mid" data-sc-parallax="-0.6">…</div>
<div class="pf-layer pf-layer--front" data-sc-parallax="0.35">…</div>
```
**The rate is in hundreds of pixels, not viewport fractions.** The engine writes
`rate * (p - 0.5) * 100` px, so the total travel across a whole act is
`rate * 100` px regardless of screen height. At 0.35 that is 35px across three
viewport-heights of scroll, which is invisible: usable values are roughly 0.3 to
1.5 for a layer inside a frame and 1 to 2 for a full-bleed bed.
Negative moves up faster than the scroll, which pushes an element back. Three
layers is plenty; five is a diorama.
Never put body copy on a parallax layer. Text the reader is trying to read
should not move relative to the thing they are reading it against.
---
## 7. `count`: numbers that land
```html
<span class="sc-nums" data-sc-count="0 4200" data-sc-count-at="0.1 0.5">0</span>
```
Formatting is inferred from the target: decimals from its decimal places,
thousands separators above 10,000 **or whenever the target itself is written
with one**. Write the target exactly as it should render, commas included
(`data-sc-count="0 3,500"`); the engine strips them before parsing. The element
gets `tabular-nums` so the layout does not jitter while the digits change.
**Only real numbers.** A counter is a truth claim with motion attached, which is
what makes it persuasive and what makes an invented one a liability. If the
brand has no verified figure, there is no counter. Check the brand's rules
first; several forbid this outright.
**A concept, fictional or pre-launch brand has no verified figures, so it has no
counters.** The device suits a SaaS or agency page and it will look good in the
score table, which is exactly the trap: every number you could put in it would
be invented. Decide this before you design an act around a number, not after.
Real brand, real stats, or a different device.
---
## 8. `flow` + `in`: ordinary sections, done well
Not everything should be pinned. A page of nothing but pinned acts is
exhausting, and the contrast is what makes the pinned ones land. Normal
document sections with a reveal-on-entry are the rest of the page.
```html
<section class="sc-section">
<div class="sc-wrap sc-stack" data-sc-stagger="70">
<h2 class="sc-display sc-display--md">What is actually in it</h2>
<p class="sc-body">…</p>
</div>
</section>
```
This fires **once**, on entry, via IntersectionObserver. Content that re-hides
when the reader scrolls back up is a defect, not an effect. Stagger between 30
and 80ms; longer feels slow.
**A flow section directly after a pinned act takes reduced padding.** The pinned
stage needs a full viewport to scroll off, and full `--sc-section` padding on
top of that delays the flow section's first content by another screen. The
harness will not call it dead scroll, correctly, because the stage is moving.
The reader still sees a near-empty screen. Cut the block padding there and start
the first reveal near `p = 0`.
---
## 9. Pointer devices: interactivity that is not scroll
Scroll is a one-dimensional input. A page that only responds to scroll is a
film. These make it respond to the reader being *present*.
```html
<div class="pf-card" data-sc-tilt="7">…</div>
<a class="pf-cta" data-sc-magnet="0.28">Find a stockist</a>
<section data-sc-spotlight>…</section>
```
- `tilt`: 3D rotation toward the pointer. 5 to 9 degrees. Past 12 it is a toy.
- `magnet`: the element drifts toward the pointer. 0.2 to 0.35. Primary CTA
only; a page of magnetic elements is unusable.
- `spotlight`: publishes `--sc-mx` / `--sc-my` for a light that follows the
pointer across a surface.
**`magnet`, `parallax` and `cue` all write `transform`, so they cannot share an
element.** The magnet writes every frame in its own rAF loop, so it silently
wins and the cue's entrance rise is discarded. On a magnetic CTA, set
`data-sc-rise="0"` so the cue writes a no-op instead of losing a visible
animation to a race. A cued element that wants its own continuous transform
should drive an inner wrapper from `--sc-p` in CSS rather than stacking a second
device on the same node.
All three interpolate toward the pointer rather than tracking it directly.
Direct tracking reads as artificial because it carries no momentum. All three
are gated to `(hover: hover) and (pointer: fine)` and disabled under reduced
motion, so touch never fires a false hover.
---
## 10. `drift`: the ground moves with you
Not an act. A property of acts, and the thing that makes a page feel like one
continuous place rather than a stack of slides.
```html
<section data-sc-act="scrub" data-sc-drift="#0A0806"> …
<section data-sc-act="pin" data-sc-drift="#161210"> …
<section data-sc-act="pan" data-sc-drift="#0E1412"> …
```
The page ground interpolates between the values as each act takes over. Keep the
whole set inside one theme family. Drifting from near-black to cream mid-page
is not atmosphere, it is the reader wondering whether they clicked something.
Three to five stops across a page. Small steps. The effect should be invisible
frame to frame and obvious top to bottom.
**Scoping: drift belongs to the first act whose progress is strictly between 0
and 1.** That is the right pick when acts are long enough that only one is ever
part-way through, and it is wrong the moment several short acts satisfy it at
once. On a page of twelve short cuts the ground shown belongs to a section the
visitor left a screen ago, so a colour arrives late and reads as a bug rather
than as a slow lag. The advice above ("three to five stops") is written for six
long acts.
**If several acts can be part-way through at the same time, paint grounds per
section instead of drifting.** Set an opaque background on each section and let
the change land on a hard edge. That is also what a cutlist or a chaptered page
wants on its own terms: a cut is not an interpolation, and interpolating between
two chapter grounds is precisely the softness those grammars exist to refuse.
Drift is for pages that are one continuous place.
---
## Composing an act
Devices stack inside one act. A pinned stage can hold a scrubbing clip, a
parallax layer, a kinetic headline and a spotlight at once. The limit is
attention, not the engine: **one thing should be the reason each act exists**,
and everything else in it is support.
If you cannot say in one sentence what an act's moment is, it does not have one.

View File

@@ -0,0 +1,277 @@
# The emotion axis
A page is not sections. It is a sequence of states a person passes through with
their hand on a wheel. The device kit decides how a page looks, the grammar
decides what a page is, and this file decides what it does to somebody.
Design the feeling before the acts. An act list written first will always be a
list of things that happen, and a page of things happening is a page nobody can
describe afterwards.
Read this after the interview, alongside [uniqueness.md](uniqueness.md), before
the score table in SKILL.md Step 2.
---
## 1. The feeling curve
Write the curve as its own artifact, in BRIEF.md, before a single act exists.
One line per act: the emotion, then the thing on screen that causes it.
The emotion column is the constraint. The cause column is the only place a
device name may appear, and it appears second, because the feeling picks the
device and never the other way round.
Useful states, not a closed list: curiosity, recognition, unease, doubt,
tension, awe, delight, relief, intimacy, confidence, resolve, calm.
**If two adjacent acts produce the same feeling, one of them is filler.** Cut it
or change what it does. Two acts of awe in a row is one act of awe followed by a
reader who has adjusted. Every emotion is defined by what preceded it, which is
why the curve matters more than any single peak: relief needs tension in front
of it, awe needs quiet in front of it, intimacy needs scale in front of it.
The curve also outranks the journey beats from Step 1. Beats say what the
visitor learns. The curve says what they feel while learning it. When they
disagree, the curve wins, because nobody remembers what they learned on a page
that made them feel nothing.
### Worked curve: a canned drink brand
```
1 Recognition their own kitchen counter at 7am, shot at eye height
2 Fatigue the two containers, the mess, held still while copy names it
3 Delight a wipe, and the whole frame is one cold can, condensation running
4 Trust macro texture at a scale the eye cannot get in a shop
5 Appetite the flavours travelling sideways, each one landing whole
6 Resolve everything stops, one can, one line, one place to buy it
```
### Worked curve: an infrastructure product for engineers
```
1 Familiar dread the alert channel at 3am, real markup, already scrolling
2 Doubt the log fills and nothing in it explains anything
3 Clarity one panel resolves the whole trace, the noise falls away
4 Control the visitor moves a selection and the surface answers
5 Competence the real numbers arrive on telemetry they can check
6 Readiness a live input with a cursor in it, not a button
```
### Worked curve: a landscape design-build firm
```
1 Stillness a garden at dawn, almost nothing moving, held long
2 Longing copy naming the space they actually have, small and honest
3 Curiosity the drawing builds itself, survey to plan to planting
4 Weight material facts as museum labels, stone, cedar, water
5 Warmth the same garden five years on, people in it
6 Intent a quiet line of running text, not a CTA island
```
### Worked curve: a live event or festival brand
```
1 Pulse a cut before the reader has settled, sound implied not played
2 Appetite faces, close, one per screen, gone
3 Envy the year before, at speed, twelve cuts in a viewport-height
4 Urgency the real date and the real capacity, counting
5 Belonging one held frame, the crowd, the only slow moment on the page
6 Decision abrupt, full bleed, the ticket line and nothing else
```
Note what the fourth curve does that the others do not: its one slow act is the
peak, because on a page made entirely of cuts, stopping is the loudest thing
available. The peak is defined by contrast with its own page, not by an absolute
amount of spectacle.
---
## 2. The peak
People remember one peak moment and the ending. The middle compresses into a
general impression and then goes. This is the peak-end rule and it is the single
most useful thing known about how anybody experiences a sequence.
So every build engineers **one deliberate peak**. Name it in BRIEF.md as the
sentence a visitor would say to a friend:
> the screen went black and then the whole ocean lit up under me
Not "the hero is impressive". A described moment, with a before and an after.
The peak gets three things, and it gets them at the expense of other acts:
| It gets | Because |
|---|---|
| The asset budget | The best generated frames or the only real footage go here, not to act two |
| The silence before it | An act of quiet, or an empty viewport, so the change has something to be a change from |
| The most scroll room | The largest `data-sc-span` on the page, and the `data-sc-dwell` that makes the camera settle exactly on it |
**A page with three peaks has none.** Three impressive acts flatten each other,
and the visitor leaves able to say the site was nice and unable to say what
happened. If a second act is competing, demote it: shorter span, less asset,
plainer device. Something has to be the biggest thing.
**The ending must resolve.** The last feeling is the one they carry, and a page
that trails off into a footer overwrites everything the peak did. Resolution
means the page arrives somewhere and stops: the divider collapses, the world
lands at a place, the type shrinks to its quietest setting, the surface hands
over an input. The close cue holds (see the cue contract in
[devices.md §2](devices.md)) so the final screen still has something on it. A
closing act that fades to an empty stage is the page apologising for existing.
---
## 3. The tell-someone test
Before building, complete this sentence:
> it's the site where ___
Then look at what filled the blank.
- "it has a scrub video" is a device name. No memory hook yet.
- "the background changes colour" is a device name wearing a description.
- "you dive to the bottom of the ocean and the pressure readout keeps climbing"
is an experience. That is a hook.
- "you drag the letters of the logo apart and they snap back perfectly" is an
experience. That is a hook.
The blank has to be something that happened **to the visitor**, phrased from
their side. If the sentence only makes sense to someone who has read the build
folder, it fails.
This sentence goes in BRIEF.md, and the signature move from
[uniqueness.md §3](uniqueness.md) usually lives inside it. If the signature move
and the tell-someone sentence point at different moments, one of them is
decoration. Merge them, or cut the one that is not the peak.
The test is also the fastest fingerprint check available. If the sentence would
be true of an existing build in
`<workspace>/FINGERPRINTS.md`, the page is not new yet.
---
## 4. Being in it, not watching it
A film plays whether you are there or not. The difference between a viewer and a
participant is whether the page acknowledges that somebody specific is here: how
fast they are moving, where their pointer is, whether they stopped.
Concrete techniques, in this skill's vocabulary:
- **Pointer parallax that moves the world, not a card.** `data-sc-spotlight`
publishes `--sc-mx` / `--sc-my`. Drive a background layer's transform off them
instead of a highlight, and the environment shifts as the visitor moves,
slightly, the way a real space does when you lean.
- **Dwell-triggered detail.** Hold still on an act and something further arrives:
a caption, a second line, a small annotation. Reward for stopping. Read
`--sc-p` staying constant across a few frames, in the page's own JS, and reveal
something that was never needed for comprehension.
- **Scroll velocity shaping intensity.** Fast scrolling raises grain, blur,
chromatic offset, ground saturation. Slow scrolling settles it. The page feels
like it is being driven rather than played back, and it costs one derived
custom property.
- **The page addressing "you" at one moment that lands.** Not throughout, which
is just copywriting. One line, at the emotional turn, in second person, when
the visitor is already implicated. It works because it is the only time.
- **A trace of where they have been.** Anything that accumulates as they travel,
so arriving at the end means having a record rather than reaching a footer.
**Embodiment is seasoning. One or two per page.** A page that reacts to
everything feels haunted, not alive: the visitor stops reading and starts
poking, which is the opposite of what any of this is for. Pick the one that
serves the peak and leave the rest.
Everything here is gated to `(hover: hover) and (pointer: fine)` and off under
reduced motion, same as the pointer devices. A technique that only exists on
desktop cannot be the thing that carries the page's meaning.
---
## 5. Pacing as emotion
Scroll distance is emotional time. It is the only clock this medium has, and it
is fully under your control, which makes it the cheapest emotional instrument in
the kit and the one most often left at default.
| Pacing | Reads as | Built with |
|---|---|---|
| Short acts, hard cuts | Adrenaline, pulse, impatience | Acts under 1.4vh, no `pin`, `dwell` at 0 |
| A long pin | Held breath, pressure, attention | `data-sc-span` 3+, overlapping cues, one idea |
| An empty viewport before a reveal | Silence before the drop | A ground-only act, no cue until the next one |
| A slow settle mid-act | The shot landing | `data-sc-dwell` 0.35 to 0.6 with the cue peak on the settle |
| A fast cue with a long plateau | Confidence, arrival | `data-sc-cue="0.1 0.9 0.08 0.4"` |
| A slow ramp in | Hesitation, dawning | Long `rampIn`, and use it once, because it is close to feeling broken |
Three rules follow.
**A continuous world is the exception to pacing variety.** Everything in this
section is about a page of acts, where varying the length is how you vary the
feeling. A worldflight is one camera move, and a camera that changes speed
between legs reads as broken rather than as expressive. There, hold one pace and
let the peak carry the shape by being the single long leg. See worldflight.md
section 7c.
**Give the peak room.** The peak act should have the largest span on the page by
a visible margin. If every act is 2.2vh, the page has no shape, whatever the
curve in BRIEF.md says.
**Compress the administrative parts.** Specs, logistics, FAQ, credentials: these
are information, not experience. Flow sections at short stagger, not pinned acts
with dwell. Spending scroll on them is spending the visitor's patience on the
part they will not remember.
**Silence has to be authored, not left over.** An empty screen you meant reads
as anticipation. An empty screen you did not mean reads as a page that failed to
load, and the harness reports both as dead scroll. If you are using the empty
viewport before the peak, say so in BRIEF.md so the verification pass knows the
difference.
The total-length budget from SKILL.md still holds at 8 to 14 viewport-heights.
Pacing is how that budget is spent, not permission to spend more of it. A page
that needs 20vh to land its curve has too many acts, not too little room.
---
## 6. The feel check
A verification pass, run after the harness in SKILL.md Step 5, against the
contact sheets and a live scroll. The harness measures whether the page works.
This measures whether it does what it was for.
Run it in this order, and do not reread BRIEF.md first. The whole value is in
arriving cold.
1. **Scroll the page top to bottom at a normal reading pace.** Once. No stopping
to fix things.
2. **Write down what you felt, act by act.** One word per act, before looking at
anything. If an act produces no word, write nothing for it, because nothing is
the finding.
3. **Now open BRIEF.md and diff the two curves.**
**Where they disagree, the page is wrong, not the brief.** Rewriting the
intended curve to match what got built is the same failure as rewriting a
fingerprint row: it turns the artifact into a description of the accident.
Then three specific checks:
- **Does the peak read as the peak?** On the contact sheet it should be the
largest visual change on the page and it should occupy the most scroll room.
If a different act is the biggest thing on the sheet, that act is the real
peak and the plan lost. Fix the page or admit the new peak in BRIEF.md and
give it the budget.
- **Is there silence in front of the peak?** Look at the act before it. If it is
as loud as the peak, the peak has nothing to arrive from.
- **Does the end resolve?** The last screen should be able to stand still with
content on it. Blank final frame, a cue that faded out, or a footer that just
begins means the page ended rather than finished.
Two adjacent acts that produced the same word in step 2 is the filler finding
from §1, caught late. Cutting one is almost always right, and almost always
improves the total length budget at the same time.
Report the diff in the final output: the intended curve, the felt curve, and
what you changed. A build that reports them as identical on the first pass
either got lucky or did not do the check cold.

View File

@@ -0,0 +1,304 @@
# The taste floor
Read this before writing markup, not after. Build without announcing the
checklist.
Everything here is a check on the **rendered result**, not on intention. "I used
a spacing scale" is not evidence; a computed value is.
---
## Spacing
Rhythm comes from the contrast between tight and generous, never from one value
repeated until everything weighs the same. If you can't point at which intervals
are the tight ones and which are the breaks, the page has no rhythm.
- Use the 4px-base scale (`--sc-1` … `--sc-11`). A 4-base gives the useful
middle steps an 8-only scale misses.
- **More space above a heading than below it.** The gap belongs to the boundary
between sections, not to the heading-and-body pair. Getting this backwards is
the single most common spacing error, and it makes a page read as a list.
- Section padding is fluid (`--sc-section`). A phone should not inherit desktop
air; 8rem of padding on a 375px screen is a scroll tax.
- Group by proximity before reaching for a container. If you added a border to
show two things are related, the spacing was wrong first.
- Gutters scale with viewport (`--sc-gutter`). Full-bleed media goes edge to
edge; text never does.
**Optical, not mathematical.** Equal computed padding around a shape with
uneven visual weight looks wrong. Correct against the render, not the number.
---
## Typography
- **Two families maximum.** Display carries voice, text carries prose. A third
is a costume.
- **Tracking tightens as size grows.** A face set at 6rem with default tracking
reads loose and amateur. The ramp handles this: `--sc-track-tight` on display,
`--sc-track-normal` on body. This is optical correction, not decoration.
- **Body measure 45 to 75ch.** `--sc-measure` is 62ch. A full-width paragraph on
a 1600px monitor is unreadable regardless of font size.
- **Line height inverse to measure.** Wider lines need more leading. Display at
0.94 to 1.06, body at 1.6.
- **Light text on dark needs compensation on three axes**: slightly more line
height, a touch more tracking, one step more weight. Dark-mode type set with
light-mode metrics looks thin and blurry, and this is why.
- `text-wrap: balance` on headings, `pretty` on body. Free, and it removes the
orphan word that makes a headline look accidental.
- Display max ~6rem outside a genuine hero moment. Bigger is not more confident.
- **Step the hero down one rung below ~700px.** `--sc-t-4xl` floors at 3.4rem,
which is a *desktop* floor: at 390px it wraps a normal hero headline to six
lines. `--sc-t-2xl` on the hero inside a phone media query fixes it. The
portrait crop of the image is covered in assets.md; this is the portrait crop
of the type, and it is missed more often.
**Font choice.** Inter is discouraged as a default: it is the most-used face in
AI-generated pages and it reads as a non-decision. Reach first for Geist,
Archivo, Outfit, Satoshi, Cabinet Grotesk, or the brand's own face. Inter is
correct when the brand asks for neutral, or when accessibility is the brief.
**Serif is not a synonym for premium.** "It feels editorial" is not a reason.
Use one only when the brand names it, or when the work is genuinely editorial,
luxury, or heritage and you can say why *this* serif fits *this* brand.
**Emphasis inside a headline** uses italic or bold of the same family. Dropping
a serif word into a sans headline for visual interest is amateur.
---
## Colour
- **Six roles, one accent.** Canvas, surface, ink, ink-soft, accent, accent-ink.
The accent owns a region or a role; scattered tiny accents are confetti.
- **Lock the accent for the whole page.** A warm-grey site does not grow a blue
CTA in section seven. **The one exception is a page that hard-cuts between
light and dark grounds**, which physically cannot clear 4.5:1 on both with a
single stop. That page carries a two-stop accent: one hue, two lightnesses,
keyed to the ground family, redefined per section alongside the ink. Still one
accent per ground, and still one hue for the page. Two different hues is not
what this licenses.
- **Secondary text is tinted, never flat gray.** Derive it from the foreground
or surface hue. `#888` on a warm dark ground looks dirty.
- **No pure black.** `#000` has no air in it. Off-black at minimum.
- Contrast, measured on the render: body ≥4.5:1, large text ≥3:1, controls and
focus indicators ≥3:1.
- Drift keeps the whole page in one theme family. See devices.md §10.
**Redefining `--sc-ink` on a subtree does not re-ink the text under it.**
`color` is inherited as a *computed value*, so text whose `color` already
resolved on `<body>` keeps the body's ink no matter what the section redefines
the token to. Every page that inverts a ground mid-page hits this, and it fails
silently: an inverted section renders bone type on concrete at 1.15:1 while the
harness correctly classifies the line as light-on-dark and grades it in the
wrong direction. The fix is one declaration on the same subtree:
```css
.section--light { --sc-ink: #14110C; --sc-ink-soft: #4A443A; color: var(--sc-ink); }
```
Restate `color` wherever you restate the token. The same applies to any other
inherited property you drive from a token on a subtree.
**The premium-consumer palette trap.** Warm cream background, brass or clay
accent, espresso near-black text is the default reach for every artisan, food,
wellness and craft brief, and it makes every such brand look identical. Do not
default to it. Rotate: cold silver and chrome; deep forest with bone and amber;
true off-black with warm tan; cobalt against a single neutral; olive with brick.
Use cream-and-brass only when the brand names those colours.
**The AI-purple trap.** Violet-to-blue gradients, neon glow, glowing buttons.
Not unless the brand asks.
---
## Text over media
"No full-frame overlay" is the rule. Here is what to do instead, because the
rule on its own sends people to a slightly weaker full-frame overlay.
There are three shapes, and which one is right depends only on where the copy is:
1. **A corner** of density, sized to the copy block. `.sc-scrim--lead` /
`.sc-scrim--trail`. Right when the copy is anchored to a corner on a wide
screen. An edge gradient has to darken a whole band across the frame to cover
one corner; a corner gradient puts the density where the text is and leaves
the photograph alone.
2. **A band**, `.sc-scrim--band`, transparent above roughly 58%. Right whenever
the copy spans the full width of the frame, which is what *both* corner
anchors become below 860px. The engine already switches `.sc-scrim--trail` to
a band there for exactly that reason.
3. **A column** of density under a text column, on an act where the copy holds
one side of a full-bleed image. Leaves the other half of the frame untouched.
**`width` and `height` attributes are presentational hints, and they come in
pairs.** The reference template ships every `<img>` with both, correctly, because
they reserve the aspect ratio and stop the page reflowing as media arrives. The
trap is that overriding only one of them in CSS leaves the other resolving to the
attribute's raw pixel value, so `width: 100%` on a 1920x1080 image inside a
narrow column renders it 1080px tall and pushes everything under it off the fold.
It looks like a layout bug three elements away from its cause. **Override both or
neither**, usually `width: 100%; height: auto`, or an explicit height plus
`object-fit: cover` when the frame's shape is the design.
And the positive case behind all three: when a photographic ground sits behind a
text column, **mask the image away from the text** rather than laying anything
over it. A `mask-image` or a clip that ends where the column begins gives the
type a clean ground and gives the photograph its full contrast back, and it is
better than any scrim.
**A scrim must not be a child of the text it protects.** The verification pass
hides the copy element and everything inside it to photograph the frame
underneath, so a `::before` on the copy block is hidden too and the scrim is
never measured. Put it in a sibling element. See verify.md.
Then measure it. A scrim tuned by eye is routinely 9:1 where 4.5:1 was needed,
which is a photograph thrown away for nothing, or 2.8:1 on the one frame the
clip brightens under the copy. Both are invisible until the harness reports the
number.
---
## Depth
Depth is the axis that separates a premium page from a styled document, and it
is not one property. Five tools, used together:
1. **Shadow with offset and blur.** Real raised things cast light downward.
A zero-offset coloured halo is decoration, not depth. Tint the shadow to the
canvas hue; pure black shadows on a coloured ground look like dirt.
2. **Edge light.** A 1px top highlight (`--sc-edge`) sells a raised surface
better than any amount of blur, because real lips catch light.
3. **Scale and blur as distance.** Things further away are smaller, softer, and
lower contrast. Parallax without those reads as sliding, not depth.
4. **Overlap.** One element crossing another's boundary establishes more depth
than any shadow. Free, and underused.
5. **Grain.** A flat dark ground bands on real displays. `.sc-grain` at 4-5%
opacity is the difference between "a dark page" and "a lit room".
Three elevation steps (`--sc-e1/2/3`) and no more. If everything is elevated,
nothing is.
---
## Cards
Cards are the lazy container. Before using one, ask what it is doing that
proximity, a hairline, or space could not.
- **Never a grid of identical icon + heading + text cards as the page
structure.** It is the most recognisable AI-page tell there is.
- **Never nest cards.**
- **Never three equal columns of feature cards.** Use an asymmetric grid, a
two-column zigzag (max two in a row), a rail, or plain type on space.
- If a multi-cell grid has an empty trailing cell, the grid was planned wrong.
Reshape it; do not paste a blank tile.
- Pick one corner-radius scale and hold it across the page. Pill buttons on a
square-card page is broken, not eclectic.
---
## Motion
The scroll devices are the page's motion. Everything else is small and fast.
- `transform` and `opacity` only for anything continuous. `clip-path` is the
sanctioned third for wipes. Never animate width, height, margin, padding, top
or left, and never `transition: all`.
- **Never `ease-in` on UI.** It delays the moment the eye is already on.
`ease-out` at 200ms feels faster than `ease-in` at 200ms.
- Built-in CSS easings are too weak. Use `--sc-ease-out`
(`cubic-bezier(0.23, 1, 0.32, 1)`).
- **UI transitions under 300ms.** Hover 120-180ms, buttons 100-160ms. Scroll
devices are exempt: they are paced by the hand, not by a duration.
- **Never `scale(0)`.** Enter from `scale(0.95)` + `opacity: 0`. Nothing in the
real world appears from nothing.
- Press feedback on anything pressable: `scale(0.97)` or `translateY(1px)`.
- Stagger group entrances 30 to 80ms. Longer feels slow.
- Gate hover motion to `(hover: hover) and (pointer: fine)`; touch fires false
hovers on tap.
- Reduced motion means **fewer and gentler, not zero**. Keep the opacity that
carries comprehension, drop every position change.
---
## States and content
- Every interactive element gets hover, focus-visible, active and disabled.
A page with only the resting state is half-built.
- **Focus-visible must be visible.** Themed to the accent, with offset.
- **Button text fits on one line at desktop.** A wrapped CTA is broken. Primary
CTA labels are one to three words.
- **One label per intent.** "Get in touch" in the nav and "Let's talk" in the
footer are the same button with two names. Pick one and use it everywhere.
- **Check button contrast.** White text on a light button, or a ghost button on
a photo with no scrim, fails.
- Real copy, not lorem. Real names, not "John Doe". Real numbers or no numbers.
- **No invented statistics.** Fake precision (`4.1×`, `92%`, `48k`) is a legal
and credibility liability, not a design element.
---
## Browser surfaces
The parts you did not draw still carry the design, and this is the cheapest
signal that a page was built rather than assembled. It is also the step that
gets skipped most reliably. `scrollcraft.css` themes all of these; if you fork
it, keep them:
selection colour, caret colour, focus ring, scrollbar, underline offset and
thickness, tabular numerals in anything that counts or tabulates.
---
## The refuse list
Category defaults, not bans on principle. The brief's own words can earn any of
them; reaching for one when the axis is free means you were not deciding.
**Structure**
- Identical cards as page structure. Nested cards. Three equal feature columns.
- The hero-metric template: big number, small label, supporting stats, accent.
- More than two consecutive image-left / text-right zigzag sections.
- The same layout family twice on one page.
- A split header: giant headline left, small explainer paragraph floating right.
**Labels**
- An eyebrow above every section heading. At most one per three sections.
- Section numbers (`01 / 06`, `002 · Capabilities`) unless the sequence itself
is information the reader needs.
- Scroll cues: "scroll", "↓ scroll", "scroll to explore", animated mouse icons.
They are looking at the hero. They know.
- Decoration text strips (`BRAND. MOTION. SPATIAL.`) across the hero bottom.
- Locale, time and weather strips unless the brand is genuinely about a place.
- Pills and tags overlaid on photos. Version stamps on a marketing page.
**Surface**
- Gradient text. Neon and outer glows. Hard offset zero-blur shadows outside a
world that is actually neobrutalist.
- Glass and blur as decoration rather than as a specific effect.
- Coloured `border-left` above 1px on cards, callouts or list items.
- Monospace as a costume for "technical" rather than for code, data, or labels.
- Emoji standing in for an icon system. Use a real icon library.
- Custom cursors.
**Content**
- Em dash anywhere visible. Period, comma, colon, or parentheses.
- Div-built fake screenshots, fake dashboards, fake terminals.
- Text baked into a generated image. Real markup, always.
- Filler verbs: elevate, seamless, unleash, next-gen, revolutionize, supercharge.
- A hero that overflows the viewport. Headline max two lines, subtext max 20
words, CTA visible without scrolling.
- More than four text elements in the hero. Trust logos, pricing teasers and
micro-taglines move to their own section below it.
---
## The squint test
Blur the page until detail is gone. You should still be able to name the
primary element, the secondary element, and the major groups, in that order.
If everything greys into one even field, the problem is hierarchy, and no amount
of shadow, gradient or motion will fix it.

View File

@@ -0,0 +1,138 @@
<!doctype html>
<!--
scrollcraft skeleton.
Start here, then delete what you do not need and write your own markup for
what you keep. This is a starting point, not a layout: a page that keeps this
structure verbatim will look like every other page that kept it verbatim,
which is the failure this skill exists to avoid.
The act order below is one score (scrub > pin > flow > scrub > pan > pin).
Score YOUR journey per SKILL.md Step 2 and rearrange accordingly. The rules
that do carry over: four or more device families, never the same one twice in
a row, at most two scrub acts, and the closing act is the last element on the
page.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>BRAND · the one-line promise</title>
<meta name="description" content="One sentence a search result would show.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%230A0807'/></svg>">
<link rel="stylesheet" href="scrollcraft.css">
<style>
/* The whole rebrand: six colours and two faces. Everything else in
scrollcraft.css derives from these. */
:root {
--sc-canvas: #08090b;
--sc-surface: #101217;
--sc-ink: #f4f2ef;
--sc-ink-soft: #9a9ba1;
--sc-accent: #d8ff3e;
--sc-accent-ink: #08090b;
--sc-font-display: "Archivo", system-ui, sans-serif;
--sc-font-text: "Geist", system-ui, sans-serif;
}
/* Your page's own classes go here. Do not restyle .sc-stage, .sc-copy or any
[data-sc-*] selector: those are the mechanism. */
</style>
</head>
<body>
<span data-sc-progress></span>
<div class="sc-grain" aria-hidden="true"></div>
<header class="site-bar"><!-- mark + ONE cta label, used everywhere --></header>
<main id="top">
<!-- 1 · RECOGNITION: scrub -->
<section data-sc-act="scrub" data-sc-span="2.6" data-sc-dwell="0.34" data-sc-drift="#08090b">
<div data-sc-stage>
<img class="sc-stage__poster" src="assets/01-poster.webp" alt="">
<video data-sc-scrub data-sc-src="assets/01.mp4"
data-sc-src-mobile="assets/01-m.mp4" muted playsinline></video>
<div class="sc-scrim sc-scrim--lead" aria-hidden="true"></div>
<!-- third value 0 = greet: on screen the instant the page lands -->
<div class="sc-copy sc-copy--lead" data-sc-cue="0 0.78 0">
<h1 class="sc-display sc-display--xl" data-sc-kinetic="lines">The promise, in under nine words.</h1>
<p class="sc-body">One plain sentence. Twenty words at the outside.</p>
</div>
</div>
</section>
<!-- 2 · TENSION: pin. Overlap the windows ~15% so there is never a gap. -->
<section data-sc-act="pin" data-sc-span="3.4" data-sc-drift="#101217">
<div data-sc-stage class="argument">
<p data-sc-cue="0 0.32 0">The situation they recognise.</p>
<p data-sc-cue="0.26 0.60">What it actually costs them.</p>
<p data-sc-cue="0.54 0.88">Why it keeps happening.</p>
<p data-sc-cue="0.82">The line that turns it.</p>
</div>
</section>
<!-- 3 · TURN: flow + reveal -->
<section class="sc-section" data-sc-act="flow" data-sc-drift="#0b0c0f">
<div class="sc-wrap split">
<div class="sc-stack" data-sc-in data-sc-stagger="70">
<h2 class="sc-display sc-display--lg">What changes.</h2>
<p class="sc-body">Two short paragraphs. This is the only act that reads like a document, which is exactly why it belongs here.</p>
</div>
<figure data-sc-reveal="left" data-sc-reveal-at="0.18 0.62">
<img src="assets/03.webp" width="1920" height="1080" alt="Describe the image, not the brand.">
</figure>
</div>
</section>
<!-- 4 · SUBSTANCE: scrub. The second and LAST clip. -->
<section data-sc-act="scrub" data-sc-span="2.2" data-sc-dwell="0.28" data-sc-drift="#0e0f13">
<div data-sc-stage>
<img class="sc-stage__poster" src="assets/04-poster.webp" alt="">
<video data-sc-scrub data-sc-src="assets/04.mp4"
data-sc-src-mobile="assets/04-m.mp4" muted playsinline></video>
<div class="sc-scrim sc-scrim--trail" aria-hidden="true"></div>
<div class="sc-copy sc-copy--trail" data-sc-cue="0.14 0.86">
<h2 class="sc-display sc-display--lg" data-sc-kinetic="lines">The proof, in one line.</h2>
</div>
</div>
</section>
<!-- 5 · RANGE: pan. Span ≈ one viewport-height per item, plus one. -->
<section data-sc-act="pan" data-sc-span="3.2" data-sc-drift="#0a0b0d">
<div data-sc-stage>
<div class="rail" data-sc-pan="0.06">
<div class="rail__lead sc-stack">
<h2 class="sc-display sc-display--md">The set.</h2>
<p class="sc-body">One line on how to choose.</p>
</div>
<article class="item"><div data-sc-tilt="6"><img src="assets/item-1.webp" alt=""></div><h3>One</h3><p>…</p></article>
<article class="item"><div data-sc-tilt="6"><img src="assets/item-2.webp" alt=""></div><h3>Two</h3><p>…</p></article>
<article class="item"><div data-sc-tilt="6"><img src="assets/item-3.webp" alt=""></div><h3>Three</h3><p>…</p></article>
</div>
</div>
</section>
<!-- 6 · COMMITMENT: pin + pointer. LAST element on the page, short span,
one-value cues so nothing fades out before the page ends, footer inside
the stage so there is no dead tail after the CTA. -->
<section id="join" data-sc-act="pin" data-sc-span="1.15" data-sc-drift="#08090b">
<div data-sc-stage class="close" data-sc-spotlight>
<div class="close__inner">
<h2 class="sc-display sc-display--lg" data-sc-cue="0.06" data-sc-kinetic="lines">The last thing you want them to feel.</h2>
<!-- magnet and cue both write transform, and the magnet writes it every
frame. data-sc-rise="0" makes the cue's rise a no-op so the
entrance is not silently lost to the race. Opens early so a
keyboard user cannot land on it at opacity 0. -->
<a class="cta" href="#" data-sc-magnet="0.26" data-sc-cue="0.06" data-sc-rise="0">Same label as the nav</a>
</div>
<footer class="foot"><!-- mark + the small print --></footer>
</div>
</section>
</main>
<script src="scrollcraft.js"></script>
<script>ScrollCraft.mount(document.body);</script>
</body>
</html>

View File

@@ -0,0 +1,479 @@
# The structure axis
## 1. The template trap
Four sites were built with this skill: a protein coffee brand, a personal brand,
a landscape design-build firm, and an agent observability product. Four
industries, four worlds, one light canvas and three dark. The owner looked at
them side by side and said they felt like a template. He was right, and the
evidence is in the files.
All four open with a full-bleed `scrub` under a fixed minimal top bar carrying a
wordmark and one CTA. All four anchor the hero headline in the lead corner with
a greet cue and kinetic lines. All four run a pinned type act where lines
crossfade. All four hand off to a flow section, pan a card rail with a
`data-sc-tilt="6"` on each card, and close on `data-sc-act="pin"` with
`data-sc-span="1.15"`, `data-sc-spotlight` on the stage and
`data-sc-magnet="0.26"` on the CTA. All four land between 13.6 and 13.8
viewport-heights across 6 or 7 acts with exactly one accent colour.
What actually varied was the order of the middle acts and the palette.
The device kit is an **aesthetic** axis. It changes how a page looks. It has no
opinion on what a page *is*, so every build reached for the same shape, because
the shape was never a decision anybody made.
> The world changes how a page LOOKS. The grammar changes what a page IS.
> A build that only changes world is a re-skin.
This file is the structure axis. Read it after the interview and before the
score table. It has three parts that are not optional: pick a **grammar**,
invent a **signature move**, and pass the **fingerprint gate**.
---
## 2. Page grammars
A grammar is the page's organising logic: what a section is, what the chrome is
for, how the visitor knows where they are, and what the ending is. Two pages in
the same grammar will feel related no matter how far apart their palettes are.
That is the whole finding above.
Each grammar below names what it **forbids**. The forbids are the point. They
are what stops a build drifting back to the filmic default halfway through,
which is what happens when a grammar is a preference instead of a constraint.
Pick one. Do not blend two: a chaptered page with a continuous world underneath
is a filmic one-shot with extra headings.
---
### 2.1 Filmic one-shot
The original skeleton, and now one choice among eight rather than the house
style.
**Fits:** a single linear argument with one emotional arc. Consumer products,
launches, anything where the visitor should feel carried rather than
navigating.
**The scroll feels like:** a film you are pushing through. Continuous, no seams,
each act handing off before the last has left.
**Forbids:** visible sequence (chapter numbers, an index, a progress readout);
hard cuts between grounds; any chrome that implies the page is a tool; more than
one entry point. If the visitor can jump, it is not one shot.
**Nav, hero, close:** fixed minimal bar, wordmark and one CTA. Full-bleed scrub
hero, corner-anchored kinetic headline on a greet cue. Pinned close with a
spotlight and a magnetic CTA.
**Leans on:** `scrub`, `pin`, `drift`, `kinetic`. **Bans:** nothing structural,
which is exactly why it is the default drift and why four builds landed here.
**Use it when the interview earns it, and say in the report why the other seven
did not fit.** This grammar now carries a burden of proof the others do not.
---
### 2.2 Chaptered editorial
The page is a printed feature. Chapters are the unit, not acts.
**Fits:** long-form substance. A method, a manifesto, a founder story, a
research-backed product, anything where the visitor should feel they read
something rather than watched something.
**The scroll feels like:** turning pages. Full-stop intertitles between
chapters, then dense asymmetric spreads. Hard cuts, not crossfades. Each chapter
lands on its own ground and stays there.
**Forbids:** `drift` as a continuous gradient (each chapter is a hard change of
ground, not an interpolation); the full-bleed scrub hero; pinned crossfade type
acts; a magnetic CTA; centred hero copy. Media never bleeds under type here, it
sits in its own column with a caption.
**Nav, hero, close:** no fixed bar. A folio in the margin, chapter number and
title, updating as chapters pass. The hero is a **title page**: type on the
paper ground, no media above the fold, the media starts in chapter one. The
close is a colophon or masthead plate, small type, the CTA set as a line of
running text rather than a button island.
**Leans on:** `flow` + `in`, `reveal` at chapter boundaries, `parallax` inside a
media column, `count` for real figures inside prose. **Bans:** `scrub` beyond
one chapter, `spotlight`, `magnet`.
---
### 2.3 Live surface
The page behaves like the product. Not a screenshot of it, and not a div-built
fake: the actual surface, running, with scroll driving its state.
**Fits:** software, tools, dashboards, editors, anything where the demo is the
argument. If the honest pitch is "watch what it does", this is the grammar.
**The scroll feels like:** operating something. Panels populate, a log fills, a
graph advances, a selection moves. The visitor is inside the thing.
**Forbids:** marketing chrome of any kind. No wordmark-plus-CTA bar, no scrims,
no full-bleed photography, no kinetic headline stacks, no hero claim laid over
footage. Copy lives in the surface's own idiom: labels, tooltips, empty states,
status lines, a help panel. A section heading in 6rem display type breaks this
grammar instantly.
**Nav, hero, close:** app chrome replaces nav. A sidebar, a tab strip, a status
bar, a breadcrumb, whatever the real product would have, and it is real enough
to be the navigation. The hero is the surface already in a state, not a title.
The close is an **actual input**: a command line, a field, a first-run step,
something the visitor puts a cursor in. A magnetic button is the wrong ending
for a page that spent its whole length being a tool.
**Leans on:** `pin` (the surface holds while state advances), `count` on real
telemetry, pointer devices where the real product would have them, and `--sc-p`
driven CSS for anything the kit does not cover. **Bans:** `scrub`, `kinetic`,
`spotlight`, `drift` past two stops.
**The honesty rule.** taste.md forbids fake dashboards and fake terminals, and
that rule is not suspended here. The surface has to be real markup running real
logic on real or clearly-labelled sample data. That labelled-sample escape is
how a concept product can still use this grammar: every panel is operable
markup computing its state from data arrays in the page, and the page says on
its face that the scenario is a demo. What stays banned is the painting of a
surface, an image or dummy divs posing as something that runs. If the panels
cannot actually compute, the grammar is unavailable. Pick another.
---
### 2.4 Continuous world
One canvas, fixed for the entire scroll, and the page travels through it.
Waypoints, not sections.
> **This grammar REQUIRES worldflight mode.** `data-sc-mode="worldflight"`, one
> fixed stage, one spacer, legs that crossfade. See references/worldflight.md.
>
> Building it out of pinned acts is not a lesser version of this grammar, it is
> a different and worse page, and it has already been tried. The owner's verdict
> on the act-based attempt: "awful... you're literally going from scrolling down
> to static page and then you start scrolling down again... weird clear page
> lines scrolling up... very cheap looking." Every one of those is the same
> defect. A pinned act is a block in the document; a document made of blocks has
> seams; and a world with seams is not a world. Do not reach for `scrub` acts
> here, however long you make the spans.
**Fits:** a journey with real geography. A supply chain, a process with physical
stages, a place, a build, anything where "where you are" is meaningful.
**The scroll feels like:** moving through a single space that never cuts. The
visitor never leaves the frame.
**Forbids:** section boundaries of any kind. No `sc-section` blocks, no acts at
all, no second stage, no `drift` steps (one continuous grade across the whole
travel, authored into the world, not interpolated between legs). Nothing may
scroll *over* the canvas: copy arrives inside it, at waypoints, in the fixed
copy layer. The only element in document flow is the spacer.
**Nav, hero, close:** the nav is a **map**. A waypoint list, a depth readout, a
position marker, and it is clickable, because a world you cannot skip around in
is a video. The hero is an establishing position inside the world, not a
separate title stage. The close is arrival at a place in the same canvas, and
the CTA is an object in that place.
**Leans on:** worldflight legs with `data-sc-linger`, copy windows against the
whole track, the `sc:waypoint` event driving a rail the page draws itself.
**Bans:** every act device, `flow`, `pan`, hard cuts, `src` swapping.
**This is the expensive one.** The chain warning in SKILL.md applies: a single
unbroken flight is the most fragile thing you can build, and the seam law in
worldflight.md section 6 is not optional. Choose this grammar only when the
brief is literally about travel through a place, and budget for the reroll.
---
### 2.5 Typographic poster
Type is the imagery. Media is minimal or entirely absent, and scale contrast
does every job that photography would have done.
**Fits:** a brand whose asset is a sentence. Manifestos, agencies with strong
verbal identity, launches with one claim, anything where a stock-looking image
would weaken the page rather than support it. Also the right answer when there
are no good assets and generating them would produce eight plausible, forgettable
frames.
**The scroll feels like:** words arriving at wildly different weights. A word at
40vw, then a paragraph at 16px, then silence. Rhythm comes from scale, not
motion.
**Forbids:** photographic ground, `scrub`, scrims (nothing to scrim), cards of
any kind, and decorative motion. If a device is doing the work instead of the
typography, the grammar has already failed.
**Nav, hero, close:** the wordmark is set as part of the composition, at
composition scale, not as a 14px bar item. There may be no persistent nav at
all. The hero is a single word or one line at extreme scale, filling the
viewport, with a real `<h1>` behind it. The close inverts the whole page: the
smallest type on the site, the CTA as a plain underlined link, quiet after all
that volume.
**Leans on:** `kinetic` (this is the one grammar where character splitting can
be right), `pin` with scale driven from `--sc-p`, `reveal` as a wipe across
letterforms, `drift` doing heavy lifting because the ground is most of the
frame. **Bans:** `scrub`, `pan` rails of cards, `tilt`, `parallax` on text.
**The typography floor doubles here.** taste.md caps display at ~6rem outside a
hero moment. This grammar is one continuous hero moment, so the cap lifts, but
the tracking, measure and optical-correction rules tighten: at 40vw, default
tracking is a visible defect and one bad kern is the whole page.
---
### 2.6 Gallery / catalog
Objects in a walkable collection. Museum labels, not marketing copy.
**Fits:** a range. Products with variants, a portfolio, a menu, a materials
library, case studies, anything where the visitor's real question is "what are
the options" rather than "should I believe you".
**The scroll feels like:** walking a room. Lateral drift with vertical scroll,
objects entering and leaving at their own pace, each one labelled with fact
rather than pitch.
**Forbids:** the argument-shaped pinned type act; a single hero claim; scrim
copy over media; persuasion in the object labels. A label reads
`Cedar. Air-dried 18 months. Kiln-finished.` and not `Craftsmanship you can
feel.` Every object gets the same label schema, no exceptions, because the
schema is what makes it a collection instead of a grid.
**Nav, hero, close:** the nav is an **index of objects**, and it jumps. The hero
is object one, already in view, already labelled, with no separate title
treatment: the collection starts at the top of the page. The close is either the
last object or an inquiry plate typeset exactly like a label, so the ask reads
as part of the collection.
**Leans on:** `pan` as the spine rather than as one act, `reveal` per object,
`tilt` on objects the visitor would pick up, `count` for real specs. **Bans:**
`kinetic` headlines, `spotlight`, `magnet`, more than one `scrub`.
**The rail copy constraint from devices.md §3 becomes structural here**, not a
caveat. Labels are read cropped for most of their life, so the schema has to
survive being half-visible.
---
### 2.7 Split stage
Two columns held in tension for the whole page, resolved by scroll.
**Fits:** any argument with two sides. Before and after, cost and saving, manual
and automated, what you have and what you would have. The comparison is the
product.
**The scroll feels like:** watching a balance tip. Both halves are always
present, both move, and the page is going somewhere specific: the moment one
side wins.
**Forbids:** full-bleed anything before the resolve; centred copy; the
corner-anchored hero; a symmetric close. Neither column may be decorative, both
carry real content the whole way down. The instant one side becomes a caption
for the other, this collapses into a zigzag layout with extra steps.
**Nav, hero, close:** no bar. The **divider is the chrome**, and it carries the
labels for both sides plus the progress of the argument. The hero establishes
the split at 50/50 on the first screen, with both headlines readable at once, so
the visitor understands the format before they scroll. The close is the
**collapse**: the divider travels to one edge, one column takes the full width,
and the CTA lives in the winning column. That collapse is the ending, and it
should be the single most satisfying moment on the page.
**Leans on:** `pin` with divider position driven from `--sc-p`, `reveal` per
side, `count` for the comparison figures if they are real. **Bans:** `pan`,
`spotlight`, `magnet`, more than one `scrub`, `drift` (two grounds, one per
side, and they hold).
---
### 2.8 Rhythmic cutlist
Short hard-cut acts at speed. No pinning, no dwell, no crossfades.
**Fits:** energy brands. Streetwear, sport, events, music, drinks, youth
products, anything where the visitor should feel a pulse rather than follow an
argument.
**The scroll feels like:** a cut every second. Twelve to twenty short sections
rather than six long ones, each one landing whole and gone. Total page length
stays inside the 8 to 14 viewport-height budget precisely because nothing is
held.
**Forbids:** any act over ~1.4 viewport-heights; `data-sc-dwell` above 0.1;
`pin` entirely; overlapping cue windows; slow easing. This grammar is the exact
inverse of the filmic one-shot: where that one hides its seams, this one is
made of them.
**Nav, hero, close:** the bar is loud, not minimal. Full-width, high-contrast,
possibly a marquee, possibly the CTA at the same weight as the wordmark. The
hero is one screen that cuts to the next in under a viewport, so there is no
settling shot and no greet-and-hold. The close is abrupt: the last cut is the
CTA, at full bleed, no spotlight, no drift-down.
**Leans on:** `flow` + `in` at short stagger, `reveal` on nearly every section,
`count` if the figures are real, hard `drift` steps between adjacent grounds.
**Bans:** `pin`, `spotlight`, `magnet`, `dwell`, `parallax`.
**The taste floor still applies at speed.** Fast is not an excuse for a
1.2 second entrance that the reader outruns. Cue windows here are short *and*
front-loaded, so a section is fully legible within the first third of its own
span.
**The peak problem, and how to resolve it.** This grammar bans `pin` and `dwell`
outright while feel.md insists the peak gets the most scroll room and the
biggest hold. Those pull in opposite directions, and the quiet failure is a
build that reaches for `pin` at its peak and still calls itself a cutlist.
**Hold in the fixed chrome layer, and keep every act short and unpinned.** The
loud bar this grammar already asks for is a persistent element that does not
belong to any act, so it can unfurl, run a long choreography and hold as long as
the peak needs while the acts underneath keep cutting at full speed. Drive it
from page scroll rather than from an act's `--sc-p`, since the whole point is
that it outlives the act it started in. The airfield build's departures board
runs its entire peak (unfurl, populate, cascade, reveal, hold, collapse) in
the chrome, with no pinned act anywhere on the page and nothing over 1.3vh.
The general form: **when a grammar bans the device your peak wants, move the
peak out of the act stack rather than breaking the grammar.** The bans are on
what the acts do, not on what the page can do.
---
## 3. The signature move
Every build must invent **one bespoke interaction that exists on that site
alone**. Not in the device kit, not in any prior build, not a parameter change.
Coded in the page, with `data-sc-*` attributes of your own naming or plain
inline JS reading `--sc-p`. The engine stays untouched, always.
This is the thing that makes a page memorable after the visitor closes the tab,
and it is the only part of a build that cannot be arrived at by following rules.
### What counts
- **Scroll-as-playhead over a persistent trace rail.** A thin horizontal trace
fixed at the bottom edge, present the whole page, drawing a real waveform or
route or timeline. Scroll position is the playhead. Passing an act stamps a
marker on the trace that stays. By the footer the trace is a complete record
of what the visitor just went through, and it doubles as navigation.
- **A wordmark the pointer can pull apart.** The letters follow the cursor with
different masses, separate under a drag, and settle back into perfect lockup
when released. Only on the hero, only once, and the settle has to be exact.
- **A line drawing that builds itself.** An SVG technical illustration whose
`stroke-dashoffset` is driven from `--sc-p`, so scrolling literally draws the
object, then the dimension lines arrive, then the callouts. Pairs with the
technical-drawing world in worlds.md.
- **A running receipt.** A small fixed panel that accumulates a line every time
the visitor passes a claim, with real numbers, so the close arrives with a
totalled ledger of the argument they just read. Only works with real figures,
which is the check on it.
- **One control that regrades the whole page.** A time-of-day handle, a
temperature, a load level: one input, and every image, ground and accent on
the page shifts together. It has to affect everything at once or it is a
widget.
### What does not count
- A recoloured spotlight. A spotlight at a different radius. Two spotlights.
- `data-sc-tilt="9"` instead of `6`. Any parameter change to any kit device.
- A different easing curve on kinetic lines.
- Five cards in the rail instead of three, or the rail scrolling the other way.
- A third `scrub` act. More of a device is not a new device.
- Something the engine already does, given a project-specific class name.
The test: **describe the move to someone who has seen the other builds. If they
cannot tell it apart from something the kit already does, it is not a signature
move.** Reaching for a kit parameter here is the same failure as reaching for
the filmic default in §2, one level down.
---
## 4. The fingerprint gate
The registry lives at `<workspace>/FINGERPRINTS.md`, where `<workspace>` is
whatever `node <skill>/scripts/workspace.mjs` prints. It is per-user and it
starts empty: the gate is about not repeating **yourself**, so your first build
has nothing to clear and every build after it does.
A worked twelve-row registry ships as `EXAMPLES.md` in the upstream scroll-craft repository *(upstream repo — not vendored in this port)*. Read it to see what a filled table looks like and which shapes tend
to collide. It is illustration, not constraint: those are somebody else's
builds and they do not gate yours.
**Before building:** read it. Every row is a shape that is now taken.
**Before writing markup:** check the planned build against every existing row on
these six dimensions.
| # | Dimension | What it records |
|---|---|---|
| 1 | Grammar | Which of §2, or a named new one |
| 2 | Nav treatment | What the chrome is and what it is for |
| 3 | Hero device | What the first screen does |
| 4 | Act-sequence shape | The device order, act count, total viewport-heights |
| 5 | Close pattern | How the last screen behaves and what the CTA sits in |
| 6 | Signature move | The one bespoke interaction, in a phrase |
**The gate: a new build must differ from EVERY existing row on at least 4 of the
6.** Not 4 of 6 on average across the table. Four against each row, individually.
Dimension 6 is free, because a signature move is unique by definition. So the
gate really asks for three more out of the remaining five, against each row, and
a build that changes only grammar and world will fail it.
**If the planned build fails the gate, change the plan, not the log.** Rewriting
a fingerprint row to make a new build fit is the one thing that makes this file
worthless. It is a record of what exists, not a description of what you wish
existed.
**After shipping:** append one row. Fill all six dimensions plus world and port.
Say plainly what it shares with prior rows, because the shared columns are what
the next build has to avoid.
---
## 5. Aesthetic range
Premium-minimal is a choice. It is not the costume this skill wears by default,
and four dark-or-paper pages with one accent each is what happens when nobody
decides otherwise.
The full range is available when the brand's vibe asks for it:
| Family | Reads as | Earned by |
|---|---|---|
| Brutalist | Blunt, structural, unstyled on purpose | Tools, infrastructure, anything anti-marketing |
| Maximalist | Dense, layered, loud, generous | Culture brands, events, food, anything abundant |
| Playful | Bouncy, coloured, informal | Kids, games, consumer apps, community |
| Retro | Specific to a decade, not vaguely nostalgic | Heritage brands, music, anything with a real lineage |
| Dense | Information-forward, small type, high count | Data products, catalogues, reference, finance |
| Editorial | Paper, folios, measure, restraint | Long-form substance |
| Premium-minimal | Quiet, dark, one accent, air | Luxury, and only when asked for |
Go where the interview points. If the human says "loud" and the page comes back
in charcoal with one accent, the interview was decorative.
**What does not flex:** the taste floor. Spacing scale and rhythm, type metrics
and measure, contrast ratios measured on the render, motion built from
`transform` and `opacity`, focus-visible on everything, reduced motion that
keeps meaning, real copy and real numbers. Every item in taste.md holds in every
aesthetic family.
A brutalist page still needs 4.5:1 body contrast. A maximalist page still needs a
spacing scale, and needs it more, because density without rhythm is just noise. A
playful page still cannot animate `top`. **The floor is what separates a chosen
aesthetic from a sloppy one**, and it is the reason range is safe to offer at
all.
Two specific traps stay banned in every family, because they are not aesthetics,
they are defaults with a look: the cream-and-brass artisan palette
(taste.md, Colour) and violet-to-blue AI gradients. Both are what a page reaches
for when nobody chose.

View File

@@ -0,0 +1,381 @@
# Verify
A scroll page cannot be checked by looking at it. It has no single state: every
scroll position is a different frame, and the failures live between the two you
happened to look at. So walk it mechanically.
```bash
cd <build project>
npm i playwright-core # once
node <skill>/scripts/serve.mjs --root . --port 4500 &
node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/shots
node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/mobile --width 390 --height 844
node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/reduced --reduced-motion
```
Then **read `sheet.png`**. The whole point of shooting contiguously is looking
at the frames side by side; a folder of PNGs does not get looked at that way.
Two setup facts that will otherwise waste a pass:
- **Serve it.** `file://` blocks the Blob fetch the engine uses for clips, so
the page silently falls back to posters and proves nothing.
- **Real Chrome, not bundled Chromium.** Chromium ships without an h264
decoder, so every clip fails to paint and the run "passes" against posters.
`shoot.mjs` already resolves installed Chrome; override with
`SCROLLCRAFT_CHROME`.
---
## What the harness reports
It samples **within each act** (default 6 positions per act) rather than
uniformly down the document. Uniform sampling moves every position whenever you
change any section's height, so findings appear and vanish with unrelated edits.
**DEAD SCROLL**: consecutive positions where nothing changed: no cue moved, no
clip time advanced, no rail travelled, no wipe progressed, no stage shifted.
Real dead scroll means the reader is turning the wheel and being given nothing.
Fix by shortening the act's span or adding a cue.
**Bespoke fixed stages must report their visible state.** A split stage, live
canvas, or other page-local system can use ordinary `flow` acts only as scroll
markers while every visible change happens on a fixed layer outside the engine.
The harness cannot infer that layer's semantics. Put `data-sc-verify-state` on
the fixed stage and update its value to a compact signature of the values that
actually paint: divider position, scene opacity, canvas phase, custom film
time, or similar. The detector then checks those flow spans too.
Do not publish raw scroll progress just to make the check green. If progress is
changing while the composition is not, that is the exact failure this path is
meant to catch. Round and publish the rendered values. For an intentional
resolved hold, set `data-sc-verify-hold="true"` only while the hold is active.
Reduced-motion fixed stages may use the same attribute for deliberately stable
frames, which still require manual contact-sheet review.
**FROZEN CLIP**: a scrub stage is on screen, the reader is scrolling, and the
clip's playhead is not moving. Dead scroll cannot see this, because the stage
itself *is* moving: a still photograph is sliding up the page, which is the
worst-looking failure this kit can produce and the one that most reliably makes
a page feel broken.
The harness samples each scrub act's **entry and exit slides**, not only its
pinned travel. That gap is why this went undetected for four builds: a pinned
act's samples were taken at `top + (h - vh) * p`, which never visits the viewport
of scroll on either side where the stage is visible and the clip is parked. A
hold on the first or last frame is always reported. A hold in the middle is only
reported once it outlasts any plausible `data-sc-dwell` settle, since that settle
is a deliberate effect. The check is skipped under reduced motion, where no clip
is ever fetched on purpose.
The fix is almost never per-page: the engine maps clip time across the stage's
whole visible life by default. A page that reports this has usually opted out
with `data-sc-clip-map="travel"`, or is running an engine copy from before that
default existed. See [devices.md §1](devices.md).
**CUES THAT NEVER PEAK**: an element that never reaches full opacity anywhere.
Usually a cue window too narrow for its act, or ramps that eat the whole window.
Widen the window or set explicit ramps. A kinetic heading is read through its
line units, not through the element: the engine forces the element itself to
opacity 1 and carries the real value on `.sc-split__i`, so reading the element
reports every kinetic headline as fully present even on frames where every line
is at 0.
**CONTRAST**: measured on the **composited page**, not on the source video. The
harness hides the text, re-shoots the same frame, and samples the real
background under each line, so scrims, gradients and blends are all included.
Elements with their own opaque background are graded against that fill instead.
Three things it gets right that a hand-rolled version usually does not:
- **The direction is picked per line.** Light type on a dark page fails on the
brightest patch under it; dark type on a light page fails on the *darkest*
one, and grading that against the brightest patch is the most lenient reading
available, so a high-key page can report clean over text that is failing. The
harness compares the ink to the mean background and grades against whichever
extreme is on the ink's own side.
- **The sampled rect is clamped to the viewport.** The part of a pinned act's
copy that has scrolled above the fold is not on screen, so what sits in those
pixels is not behind anything the reader can see.
- **Fixed chrome is hidden with the text.** A fixed bar paints in *front* of
what scrolls under it, so its own mark is not the background behind a headline
passing beneath it.
This is the check no static audit can do: the frame under a headline changes as
the clip scrubs, so text can clear 4.5:1 against the poster and fail badly three
hundred pixels later.
### The scrim has to be a SIBLING of the copy, never a child
The pass hides `[data-sc-cue],[data-sc-cue] *,[data-sc-copy],[data-sc-copy] *`
before photographing the frame underneath a line. `visibility: hidden` hides an
element's pseudo-elements too, so a scrim written as `.mycopy::before` is hidden
along with the text it exists to protect, and the pass grades the line against
the raw film every time.
The tell is unmistakable once you know it: **you strengthen the scrim and the
reported numbers do not move at all.** Not "improve slightly", not "move by a
tenth": byte-identical, because the thing you changed was never in the
measurement. If a contrast number is unchanged to two decimals after a real
change, stop tuning and check what is actually being composited.
A very high mean against a very low worst (`1.21:1 (mean 12.83)`) is the same
finding seen from the other side: the type is fine almost everywhere and there
is a bright patch under it that nothing is covering.
Two shapes that work:
- `.sc-world__scrim` in the copy layer, which is what worldflight.md ships and
which survives the hide because it carries no `data-sc-copy`.
- One plate per block, mounted as a sibling and driven from the page's own JS.
`orrery` sizes each plate off its block's untransformed box (set
`transform:'none'`, read the rect, put it back, so the engine's ±2vh copy
drift does not skew the measurement) and each frame copies the block's own
inline opacity onto its plate, so the plate tracks the engine's window with no
duplicated window maths.
### Known limitations of the contrast pass
Real, and worth knowing before you trust a green run:
- **Cues are keyed by their text.** Two cues that share a string, which
taste.md's "one label per intent" rule actively encourages, are collapsed into
one row, and the reported worst frame is the worse of the two.
- **Lines under 0.85 opacity are skipped.** A headline parked at 0.6 over a
bright frame is never graded, so "contrast clean" can still hide a legibility
problem. Look at the sheet for anything that reads washed out.
**Author the fade-outs to land between sample positions.** The harness samples
a fixed number of positions per act, so a ramp-out that happens to straddle one
puts a half-faded headline on the sheet: graded by nobody, and read by eye as
ghost type over the frame. **Fix the ramp, not the sampling.** Shorten
`rampOut` so the cue is at full opacity at one sample and gone by the next,
rather than sitting at 0.5 on the sample in between. Widening the sample count
only finds more half-faded frames; it does not make the page look better,
because a real reader stopping on that pixel sees exactly what the sheet
shows. A cue caught mid-fade over a bright frame is a real defect, not a
sampling artefact.
- **The floor is not size-aware.** It reports below 3:1 as a failure and 3:1 to
4.5:1 as thin. WCAG allows 3:1 for large text, so a display headline in the
thin band is usually fine and a 16px caption in it is not.
- **Acts with no `[data-sc-cue]` elements are not graded at all.** Copy on plain
canvas is a static case, but it is unmeasured.
- **Ordinary `flow` acts are excluded from dead-scroll checks.** Static flow is
normally correct. A bespoke fixed experience built over flow markers must use
`data-sc-verify-state`, or the harness will skip its visible timeline and can
report a dead opening as healthy.
- **A `pan` act whose rail does not overflow is reported as healthy.** The
`pigment` build ran a rail measuring 1368px inside a 1440px viewport, so it
travelled zero for its entire 2.1vh span, and every pass printed `no dead
scroll detected`. Measure `rail.scrollWidth - innerWidth` yourself; a green run
does not cover it. See devices.md §3.
**Console errors and failed requests**: a 404 on a clip degrades to a poster
silently, which looks fine and is not.
---
## What the harness cannot tell you
Read the sheet for these. They are the ones that matter most.
- **Whether the composition is any good.** Copy landing on the busiest part of
the frame, a subject cropped at an unfortunate point, an act whose end frame
is a dark empty corner.
- **Whether the motion is smooth.** Contiguous frames prove the clip advances;
they do not prove it advances evenly. Watch the contact sheet for a move that
lurches, reverses, or stalls in the middle.
- **Whether the page means anything.** Six acts that each work and together say
nothing is the most expensive failure available here.
---
## The manual passes
**Reduced motion.** Clips are never fetched, posters hold, copy still cues. The
page must remain comprehensible, not merely not-crash. This doubles as the
low-bandwidth check.
Comprehensible includes **reachable**: check that no content was deleted rather
than merely stilled. A `pan` rail is the case that bites, because zeroing its
transform parks it on its first screenful. The engine now hands the stage back
as a native scroll region, so confirm on the sheet that the rail shows real
content and that items past the fold can still be got to. Nothing in the harness
reports this; it reads as a page behaving correctly.
**Credit accounting.** `kie.mjs probe` reports a balance, not a delta, so a
build's spend is a before-and-after subtraction. That subtraction is only valid
if nothing else is generating against the same key. When builds run in parallel,
or when a settlement lands late, the deltas overlap and each build will claim
some of another's spend (three parallel builds each read the same 7597 → 7067 and
each reported 530). Either serialise generation, or cost the build from the
per-call model prices in [assets.md](assets.md) against the calls you actually
made, and treat the probe delta as a ceiling.
**And the per-call sum overstates real spend in the other direction.** Two
reconciliations against the account ledger, each with no other consumer, put
actual debits at roughly **0.4x** the documented unit rates: a fleet whose
per-call sums came to ~1447 credits was debited 530, and a three-build run whose
per-call sums came to 2252 was debited 856. Both land near the same ratio. So a
build report should say what the per-call sum is *and* that it is a planning
ceiling rather than the amount billed. Reporting the sum as the cost is the
honest default, because it never under-claims; reporting it as *measured* spend
is wrong. Neither number is the other's substitute: the probe delta bounds a
parallel run from above, the per-call sum bounds a serial one from above, and
only a ledger read with a single consumer settles it.
**Mobile.** Pinned stages use `100svh` so the URL bar does not cause a jump.
Copy reflows and does not collide with the fixed bar. Confirm the phone encodes
actually load. Check the portrait crop of every clip: a 16:9 move composed
around left-hand negative space loses exactly that space at 9:16
(see [assets.md](assets.md)). Mobile is a first-class target, not a check at
the end: the phone clips are cut portrait, the lerp is retuned for touch, tap
targets are grown, and every one of those is authored, not inherited.
### The phone is a different machine
Headless Chrome on the build box cannot reproduce an iPhone's video decoder,
its autoplay policy, Low Power Mode, or touch scrolling. On one build every
probe reported the hero clip scrubbing perfectly for **four consecutive
rounds while the real phone showed a frozen frame**. A green harness run says
the page is correct where the harness runs. It says nothing about iOS video.
What iOS does to a scrub clip, and what the engine now handles for you:
- iOS will not *paint* a muted video that has never been played. Seeks land,
`seeked` fires, and the picture stays on one frame. The decoder has to be
primed with one `play()`/`pause()`.
- The engine primes each clip at `loadedmetadata` (a muted inline `play()`
needs no gesture outside Low Power Mode) and retries on `touchstart`,
`touchend`, `pointerdown`, `click` and `scroll`. `touchend` matters: the
HTML spec's activation-triggering events include `touchend` but **not**
`touchstart`, so a Low Power Mode phone that rejects the touchstart attempt
gets a valid one when the finger lifts.
- A prime must be re-attemptable per clip. A one-shot prime on first touch
loses a race: the reader touches to scroll within the first second, while
the hero's megabytes are still downloading, and the shot is spent on a
sourceless element. The tell is exactly "the first clip is frozen and every
later one works".
- iOS may leave a `play()` promise pending forever, and may leave `seeking`
true forever. Both were permanent silent freezes; the engine now releases
the priming flag on a timer and re-issues any seek stuck past 700ms. The
reveal also fires on a 2.5s timeout, never only on `seeked`.
Do not re-implement any of that in page JS, and do not strip it when copying
the engine. If a phone still shows a frozen clip, the cause is past what this
machine can measure, which is what the next section is for.
### Ship the diagnostic with the site
You get one question per round with a real device, so make the round count.
`references/device-diag.html` is a standalone page that scrubs the suspect
clip two ways (blob URL, exactly as the engine loads it, and direct file src)
beside a known-good clip, prints a MOVING / FROZEN verdict over each pane,
and reports prime results, seek counts and distinct painted frames. Edit its
`TESTS` array to point at the build's own clips, deploy it next to the site,
and one screenshot from the phone isolates the layer: blob loading, the file,
the device's decode policy, or the engine's lifecycle. Deploy it **with** the
first mobile fix, not after the fourth.
### Ask what differs before asking what's broken
The debugging lesson that cost three wasted rounds: "desktop works, the phone
does not" reads as a platform difference and invites platform theories
(codecs, keyframes, resolution). **"One clip works and another does not, on
the same device"** cannot be a platform difference. Before theorising, write
down every way the working case differs from the broken one; the bug lives in
that list. On the build above the list had one entry: the hero is first, so
it loads while the first touch is being spent.
**Keyboard.** Tab through. Focus order matches visual order, the focus ring is
visible against every ground it crosses, and nothing reachable is parked at
opacity 0. Cues set `pointer-events: none` when faded, but a focusable element
inside a faded cue is still a trap.
The engine helps here but does not finish the job, and the gap is specific:
- **It handles the ordinary case.** On `focusin`, if the focused element is
inside a `[data-sc-act]` and its own cue computes under 0.85, the engine
scrolls it to the centre of the viewport with `behavior: 'instant'`
(`smooth` would animate a multi-screen glide with focus off screen the whole
way). On a `flow` act, centring the element also opens its cue, because the
element's viewport position and the act's progress move together.
- **It does not fix a pinned act, and cannot with this approach.** A pinned
stage is `position: sticky`, so the control holds *one* viewport position for
the entire act. Centring it is then only achievable by scrolling backwards out
of the act, which parks progress at 0 and leaves the cue dark. Measured: a CTA
cued at 0.75 on a 3vh pinned act sits at viewport y=70 from progress 0 to
0.875; `scrollIntoView({block:'center'})` from inside the act lands *before*
the act's top, at progress 0, cue opacity 0. The control is on screen and
still invisible.
**On a pinned act, park the act at the progress where the focused element's own
cue is open.** That is page-local work, because only the page knows which cue
belongs to which control, and because act progress runs through `dwell()` when
the act has any, so the scroll target is not a straight inverse of the cue
window. The descent build does exactly this. If a pinned act carries a focusable
control, write that handler and assert it; do not assume the engine covered you.
**Fresh eyes.** Look again later. Timing you tuned for twenty minutes reads
differently when you have forgotten what it is supposed to do.
---
## Failures worth knowing about
Each of these shipped once during this skill's own build, and each looked fine
until it was measured.
| Symptom | Cause |
|---|---|
| A hero headline wrapped to six lines | `max-width` in `ch` on a **container**: `ch` resolves against the container's font-size, not the display size of the heading inside it |
| Centred copy hanging off the left edge | `inset-inline` declared **after** `left: 50%`; the shorthand resets `left` to auto |
| An act that never pins, silently | An author rule setting `position` on the stage. The engine now warns in the console |
| A stray headline painted over a later section | Cues frozen at their last value when their act scrolled out of range |
| A clip stuck on its poster at the top of its act | The reveal waits for a `seeked` event, and a clip already at time 0 never seeks |
| A closing CTA that fades out before the page ends | A two-value cue on the last act, plus a tall section after it |
| Copied headings reading "even whenbreakfast" | Line-split spans abutting with no whitespace between them |
| A headline from act 2 overlapping act 3, failing contrast on the way | A one-value hold cue on a middle act. Only the last act may hold |
| A phone-only contrast failure on a trail-anchored act | The trail scrim aimed at the corner the copy leaves below 860px. The engine now switches it to a band |
| A rail act that shows one frozen screenful under reduced motion | `[data-sc-pan] { transform: none }` deleting the navigation. The engine now falls back to a scroll region |
| Two washed-out video acts no scrim tuning could rescue | Flat supplied footage with no white point. Grade the intermediate, not the CSS |
| A blank stage for the first viewport of a pinned act | A two-value first cue with no ground. Ground or greet |
| A rail heading dragged off-screen under reduced motion | The scroll-region fallback snap-centres a single wide track; keep the act heading outside the region, or give the rail multiple snap stops |
| Keyboard focus landing on a control nobody can see | The browser's scroll-into-view parks the element barely on screen, which is where its cue has not opened, and the opacity check still passes. **The engine now centres it on `focusin`** when the element is inside a `[data-sc-act]` and its cue is under 0.85. That fixes the off-screen half. See the note below for what it does not fix |
| A figure or drop numeral rendering as a plain bar | `data-sc-reveal` on type with `line-height` below 1. `clip-path` is relative to the border box, so the wipe eats the ascender and descender. See devices.md §4 |
| An image three times too tall, pushing its own label off the fold | `width` overridden in CSS while `height` still resolves to the HTML attribute. Override both or neither. See taste.md |
| An inverted section rendering its old ink, graded in the wrong direction | `--sc-ink` redefined on the subtree without restating `color`. See taste.md |
| A ground colour arriving a section late | `drift` on a page of short acts; several are part-way through at once. Paint grounds per section. See devices.md §10 |
| Every cue and reveal in a quiet act snapping 0 to 1 | A pinned act at `data-sc-span` ≤ 1, which is one pixel of travel. Minimum useful pinned span is ~1.2 |
| A clip that scrubs beautifully, stops, and then slides up the page as a still photograph | The clip was mapped to the act's pinned travel, which is 0 through the entire entry slide and 1 through the entire exit slide. The engine now maps clip time across the stage's whole visible life by default. See devices.md §1 |
| A custom fixed stage passing while its first screens do nothing | The page used `flow` markers, which are intentionally excluded from ordinary dead-scroll checks, but published no `data-sc-verify-state`. Report the actual rendered state and declare only genuine resolved holds |
| The hero clip frozen on a real iPhone, later clips fine, every probe green | iOS never paints an unplayed muted video, and the one-shot gesture prime was spent while the hero was still downloading. The engine now primes per clip at `loadedmetadata` and retries on every gesture, including `touchend` |
| A phone clip soft and stuttering while the same file is smooth on desktop | A landscape mobile encode in a portrait viewport: cover-fit decoded the full frame and threw three quarters of it away. Cut the phone clips portrait from the masters (see assets.md) |
| Four rounds of mobile fixes verified green, phone still broken | Headless Chrome cannot reproduce the iOS decoder, Low Power Mode, or touch. Deploy `references/device-diag.html` beside the site on the first mobile report and let the phone answer |
The first three are invisible to every check except looking at rendered output.
That is the argument for this whole pass.
Operational note: a `shoot.mjs` run can take the background server process down
with it when it finishes. Check the port before the next pass and restart
`serve.mjs` if it dropped.
## The harness will photograph the wrong site without telling you
`serve.mjs` fails with `EADDRINUSE` if something already holds the port. When
that server was started in the background, the failure is in a log nobody is
reading, and `shoot.mjs` then gets a perfectly good `200` from **whatever else
is on that port**. It walks that page, finds its worldflight, and writes a full
contact sheet and a clean report for a site you did not build.
Confirm the port is serving YOUR build before trusting any run:
```bash
curl -s http://localhost:45XX | grep -o "<title>.*</title>"
curl -s -o /dev/null -w "%{http_code}
" http://localhost:45XX/assets/leg01.mp4
```
A 404 on an asset you know exists is the fastest tell.

View File

@@ -0,0 +1,349 @@
# Worldflight: the continuous-world page mode
Act mode cuts the page into pinned blocks. That is the right shape for a page of
chapters and the wrong shape for one unbroken camera move, and building a
continuous world out of acts produces exactly the page an owner described as
"awful": you scroll down, the stage unsticks, a static page slides past, clean
horizontal edges travel up the screen, and then you start scrolling down again.
Every one of those defects is the same defect. A pinned act is a block in the
document, and a document made of blocks has seams.
Worldflight removes the seams by removing the blocks.
There is **one** `position: fixed` stage for the whole page. Every leg of the
flight is mounted in it at once and stays mounted. The only element in document
flow is an empty spacer. Scroll drives two things and nothing else: the film
timeline and the opacity of the overlay. Nothing travels, nothing pins, nothing
unpins, and there is no boundary anywhere for a seam to show at.
---
## 1. The markup
```html
<div data-sc-mode="worldflight" data-sc-seam="0.12">
<div data-sc-world>
<div data-sc-segment data-sc-w="0.95" data-sc-linger="0.3"
data-sc-waypoint="Surface">
<img class="sc-world__poster" src="assets/p1.webp" alt="" decoding="async">
<video data-sc-src="assets/leg1.mp4"
data-sc-src-mobile="assets/leg1-m.mp4"></video>
</div>
<div data-sc-segment data-sc-w="0.9" data-sc-linger="0.42"
data-sc-waypoint="Thermocline">
<img class="sc-world__poster" src="assets/p2.webp" alt="" decoding="async">
<video data-sc-src="assets/leg2.mp4"
data-sc-src-mobile="assets/leg2-m.mp4"></video>
</div>
<!-- legs in flight order, as many as the world has -->
</div>
<div data-sc-world-copy>
<div class="sc-world__scrim sc-scrim sc-scrim--band"></div>
<div class="sc-copy sc-copy--lead" data-sc-copy data-sc-window="hero"> … </div>
<div class="sc-copy sc-copy--trail" data-sc-copy data-sc-window="0.38 0.66"> … </div>
<div class="sc-copy sc-copy--lead" data-sc-copy data-sc-window="finale"> … </div>
</div>
<div data-sc-spacer aria-hidden="true"></div>
</div>
```
`ScrollCraft.mount(document)` as usual. The mode composes with nothing else on
the page: a worldflight page has no acts.
### Attributes
| Attribute | On | Default | What it does |
|---|---|---|---|
| `data-sc-mode="worldflight"` | mode root | n/a | Turns the page into one flight. |
| `data-sc-seam` | mode root | `0.12` | Crossfade band, in viewport-heights of scroll. Clamped 0.02 to 0.4. |
| `data-sc-world` | stage | n/a | The single fixed stage. Gets `.sc-world`. |
| `data-sc-segment` | leg | n/a | One leg. Holds a poster and a clip. |
| `data-sc-w` | leg | `1.3` | Scroll this leg owns, in viewport-heights. |
| `data-sc-linger` | leg | `0` | Dwell remap for this leg only. Clamped to 0.6. |
| `data-sc-waypoint` | leg | n/a | Label published on the waypoint event. |
| `data-sc-world-copy` | copy layer | n/a | Fixed overlay. Gets `.sc-world__copy`. |
| `data-sc-copy` | copy block | n/a | A windowed block of type. |
| `data-sc-window` | copy block | n/a | `hero` \| `finale` \| `from to [in [out]]`. |
| `data-sc-spacer` | spacer | n/a | The scroll track. Engine sets its height. |
| `data-sc-lerp` | root or `<video>` | `0.18` | Playhead smoothing. See devices.md. |
The engine generates no DOM here, the same as in act mode. It sets the spacer's
height, and it writes opacity, visibility and z-index on the legs. Everything
else is markup you wrote.
---
## 2. The scroll track
The spacer's height is **(sum of the leg weights + 1) viewport-heights**, set in
pixels so it and the stage are measured on the same ruler (the stage is sized in
`svh`, and on a phone `vh` and `svh` are different numbers).
The `+ 1` is not padding. Without it the track ends at the exact scroll position
where the last leg reaches progress 1, so the final second of the last clip is a
place the reader can never come to rest. One extra viewport gives the last
flight room to land.
Position along the track, `t`, is measured in viewport-heights, which is the
same unit the weights are written in. Leg *i* owns `[c_i, c_i + w_i)`, and its
local progress is `(t - c_i) / w_i`, remapped through `lingerEase`.
---
## 3. The seam
Two things make a boundary between clips invisible, and both are required.
**The assets have to match at the seam** (section 6). The engine cannot fix a
mismatched cut.
**The crossfade has to be one-sided.** Over the seam band the incoming leg fades
up from 0 to 1 while the outgoing leg holds at full strength underneath it, and
the outgoing leg only drops to zero once it is completely covered. Fading both
sides at once puts the page ground through the middle of every seam, which reads
as a flash, and it is the obvious implementation. z-index favours the current
leg (120) over the rest (100 + opacity × 10).
Each side of the band is half a seam width, so each leg holds its seam frame for
about 0.06vh of scroll. Those are exactly the frames the seam law matched, so a
held frame there is invisible by construction.
Nothing ever swaps a `src`. A src swap is a black frame, and a black frame is
the cut this mode exists to remove.
---
## 4. The copy contract
Copy lives in one fixed layer above the stage. Each block declares a window
against the **whole track**, not against a leg.
- `data-sc-window="hero"`: present from the first pixel, fades out by 0.62 of
the first leg. A hero that fades IN has to fade in over an empty first screen,
which is the one moment on the page with nothing else to look at.
- `data-sc-window="finale"`: fades in from 0.4 of the last leg and holds to the
end.
- `data-sc-window="0.38 0.66"`: a plateau window across those track fractions:
ramps in over the first 30%, holds at full opacity, ramps out over the last
30%. Add a third and fourth number to set the ramps yourself. The plateau is
not decoration: a pure triangle touches opacity 1 for one instant, so the
reader has to stop on exactly the right pixel to see the line at full strength
and every heading reads slightly faded.
**The only transform on the copy side is `translateY`, and it is capped at 4vh
across the whole window** (from +2vh to -2vh). Anything larger stops reading as
a layer over a moving world and starts reading as a second page scrolling at a
different speed, which is the cheapness this mode replaces. Pointer events are
handed back to a block only above opacity 0.5.
`.sc-world__scrim` is provided for a scrim div on the copy side. Shape it to
where the copy actually sits. The stock `.sc-scrim--band` tops out at 58% of the
frame, and footage that stays bright past that will fail the contrast pass even
though the page looks fine.
---
## 5. The route rail
The engine publishes the current leg index as `--sc-seg` and its local progress
as `--sc-segp`, on the mode root and on `:root`, and fires a `sc:waypoint`
CustomEvent (bubbling, `detail: { index, count, label, el, progress }`) whenever
the leg changes.
It renders no rail. A gauge, a map, a depth readout, a leg counter and a set of
chapter dots are all the same two numbers, and a runtime that ships one of them
ships it to every page that uses this mode. Build the rail in the page:
```js
addEventListener('sc:waypoint', (e) => {
document.querySelectorAll('.rail__leg').forEach((el) => {
el.setAttribute('aria-current', String(+el.dataset.leg === e.detail.index));
});
});
```
---
## 6. The seam law for assets
A worldflight is only as good as the joins between its clips. Two architectures
work; nothing else does.
**Architecture A (preferred): chain on start images only.** Each leg is
generated from a start image and left to end wherever it ends. The next leg's
start image is a frame pulled from the previous leg's **encoded** mp4. Never
force an end-image wide shot: an image-to-image model asked to hit both ends
resolves the conflict by pulling the camera back, and every leg ends up as the
same wide establishing shot.
**Architecture B: connector legs.** Where two existing clips have to meet, cut a
short connector whose start frame comes from the previous leg and whose end
frame is the next leg's actual first frame.
Extract from the ENCODED mp4, not the source render. The encode changes the
pixels, and a poster or a chain frame taken from the pre-encode master does not
match the frame the browser will actually decode:
```bash
# last frame of the previous leg, as the next leg's start image
ffmpeg -sseof -0.15 -i legN.mp4 -frames:v 1 -q:v 2 chainN.png
# first frame of a leg, for its poster
ffmpeg -i legN.mp4 -frames:v 1 -q:v 3 pN.webp
```
### Encoding
Same rules as any scrub clip, and they matter more here because a worldflight
has more of them mounted at once.
- **GOP 8 desktop, GOP 4 mobile.** Scrubbing is random access; a long GOP means
every seek decodes a run of frames and the playhead lags behind the hand.
- Ship `data-sc-src-mobile` for every leg. The engine picks it on coarse
pointers and narrow viewports.
- Posters as WebP, extracted as above.
---
## 7. Loading
A leg is fetched only while the reader is within **±1.6vh** of it. Loading the
whole flight up front is tens of megabytes before the first frame paints;
loading on arrival means arriving at a poster.
Until a leg's first real frame has painted, its poster carries the move with a
push-in (`scale(1.03 + local × 0.14)`). A still that sits perfectly still while
the page scrolls announces itself as a placeholder; a slow push reads as the
camera already flying.
Under reduced motion **no clip is ever fetched**. The posters are the film, they
cross-dissolve through exactly the same seams at exactly the same scroll
positions, the same copy windows open and close, and every transform is dropped.
The whole story still reads.
---
## 7b. The spacer is sized once, at mount
`layout()` writes the spacer height as `(total + 1) * innerHeight`. If
`innerHeight` reports 0 at that moment the spacer is set to **0px**, the page
has no scroll track, and the flight never advances.
It fails silently and it looks like success. The engine mounted, every leg
registered, the clips fetched and decoded, `sc-has-clip` is on the segments, and
there is nothing in the console. The page is simply a still image that cannot be
scrolled. Embedded preview panes and some early loads do exactly this.
Do not fix it in the engine. One resize makes it re-measure correctly, so send
one from the page once the window and the fonts have settled:
```js
function relayout() { dispatchEvent(new Event('resize')); }
addEventListener('load', relayout);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(relayout);
```
The `fonts.ready` half earns its place independently: a webfont swapping in
changes the measured height of every copy block, and anything the page sized
against those blocks (a scrim plate, a rail) is wrong until it re-measures.
Check it with one line, and check it before blaming anything else:
```js
document.documentElement.scrollHeight // must be ~(sum of weights + 1) * innerHeight
```
## 7c. Pace: one speed, and slower than you think
Two separate faults get described as "it doesn't feel smooth", and only one of
them is smoothing.
**Inconsistent pace is the worse one.** Leg weight divided by clip length is how
fast the world moves under the reader's hand. If that number varies from leg to
leg, the world surges and drags for no reason the reader can see, and it reads
as a fault in the page rather than as pacing. Give every leg with the same clip
length the **same weight**, and give a longer clip a proportional one. On
`orrery` that number varied by 36% across ten legs on the first cut, and the
owner's word for it was "not smooth". Evened to a 6% spread, the same footage
reads as one continuous move.
```
rate = weight / clip_seconds // hold this within a few percent everywhere
```
**Then slow it down.** The instinct is to spend as little scroll as possible. A
fly-through wants the opposite: the reader is steering a camera, and a camera
that answers too eagerly feels twitchy. **0.21 to 0.22vh per second of film is a
good floor for a world you fly through.** 0.14 to 0.19, which is what the per-8s
line yields, is noticeably fast.
That line is a **dead-scroll guardrail, not a taste ceiling.** Exceeding it is
fine and often correct; exceeding it without checking is not. The harness
defines dead scroll mechanically and will tell you. At 0.216vh/s a 0.12vh sample
gap still advances the clip by half a second, nowhere near dead.
**Damp the playhead and widen the joins.** `data-sc-lerp` defaults to 0.18;
**0.12 is the better default for a worldflight**, because a flight has more legs
mounted and more seams than an act page, and the extra damping is what actually
removes wheel-event judder. Widen `data-sc-seam` from 0.12 to ~0.16 for the same
reason: a longer crossfade band gives each join more room to disappear in.
Changing weights moves every leg boundary, so **every `data-sc-window` has to be
recomputed** against the new track and then re-checked on screen. A copy window
is tuned to a frame of film, not to a number.
## 8. Hard rules
| Rule | Why |
|---|---|
| **Nothing in document flow but the spacer.** | The moment a real block scrolls past the fixed stage, the page has a seam and the mode is pointless. If you want a section, you want act mode. |
| **Copy translate ≤ 4vh across a window.** | Larger reads as a second page scrolling at a different speed. |
| **The lerp is never disabled** except under reduced motion. | A 1:1 playhead reproduces every gap in the wheel event stream as a stutter. |
| **One pace for the whole flight**, and slower than feels necessary. | Weight divided by clip length must match across legs, or the world surges and drags. ~1.5vh per 8s is the dead-scroll guardrail, not the target. See section 7c. |
| **Every clip stays mounted. Never swap a `src`.** | A src swap is a black frame. |
| **Seam frames come from the encoded mp4.** | The encode changes the pixels. |
| **One accent, one scrim shape, copy anchored off the bright centre.** | Verified by the contrast pass, which grades copy blocks exactly like cues, at the worst frame each line is ever shown on. |
---
## 9. Verifying
`shoot.mjs` detects `[data-sc-mode="worldflight"]` and switches modes. It samples
across the spacer track at the same density it samples acts, plus four extra
positions across every seam, and it waits for the lerp to settle before each
shot (a screenshot taken mid-lerp is a frame the page never actually holds, and
it makes the run unrepeatable).
It reports:
- **dead scroll**, defined here as no leg advancing its `currentTime`, no
crossfade progress, and no copy-window opacity change between two samples more
than 0.12vh apart. Skipped under reduced motion, where each leg legitimately
holds one still frame.
- **legs that never reach full opacity**: a weight or a seam that is wrong: the
reader is shown a permanent dissolve and never the leg itself.
- **legs stuck on poster**: a clip that never loaded or never decoded. It passes
every other check, because a poster looks exactly like a paused film.
- **contrast** on visible copy blocks, through the same direction-aware
compositing path as cues.
```bash
node scripts/serve.mjs --root builds/<name> --port 45XX
node scripts/shoot.mjs --url http://localhost:45XX --out lab/<name>-shots --per-act 8
node scripts/shoot.mjs --url http://localhost:45XX --out lab/<name>-reduced --reduced-motion
```
The mechanical assertions ship with the skill as
`scripts/worldflight-assert.mjs` and run against **any** worldflight page, not a
special rig: spacer height, fixed stage, nothing in document flow, lerp
convergence and non-overshoot, seam monotonicity, the copy transform cap, and
the reduced-motion contract.
```bash
node <skill>/scripts/worldflight-assert.mjs --url http://localhost:45XX
```
Run it against your own build before the contact sheet. It answers "does the
mode actually hold" in a way a screenshot cannot.

View File

@@ -0,0 +1,178 @@
# Worlds
The art direction the whole page lives inside. Pick one, write it as a **style
preamble**, and paste that preamble verbatim at the top of every image and video
prompt. Reusing it word for word is what makes eight separately generated assets
look like one shoot. Paraphrasing it is what makes them look like eight prompts.
---
## The default is photographic
Soft matte low-poly clay diorama, isometric miniature, tilt-shift toy world:
**banned as a default.** It is the house style of AI scroll sites, it announces
that nothing on the page is real, and for any brand selling an actual product it
actively works against the sale. A clay render of a can does not make anyone
thirsty.
Use an illustrated world only when the brand is genuinely illustrated: a
children's product, a game, a brand whose existing identity is drawn. Then
commit to it properly, matched to the brand's real illustration style, not to
the generic diorama look.
---
## The eight
Each is a starting preamble. Tune the light, lens and grade to the brand; keep
the structure.
### 1. Low-key cinematic: the default
Dark, controlled, one light source. Works for almost anything premium: spirits,
coffee, tools, apparel, software, professional services.
> Cinematic product photography shot on 35mm anamorphic lenses. Shallow depth of
> field, high dynamic range, true blacks, matte film grain. Low-key lighting:
> one warm key, cool ambient fill, deep falloff into shadow. Colour grade of deep
> charcoal, warm amber highlights, desaturated mid-tones. Photographic realism.
> NOT 3D render, NOT clay, NOT illustration, NOT CGI, no digital glow, no plastic
> sheen.
### 2. High-key editorial
Bright, airy, shadowless. Wellness, skincare, home goods, healthcare, fintech
that wants to feel calm.
> Editorial still-life photography on a seamless bone-white cyclorama. Large soft
> overhead source, huge white bounce, near-shadowless with one soft contact
> shadow. High key, gentle contrast, colour grade of warm white and pale
> neutrals. Medium-format sharpness, fine grain. Photographic realism, no CGI.
### 3. Natural documentary
Real people, real places, available light. Service businesses, trades,
restaurants, anything where trust comes from "these are actual humans."
> Documentary photography, available light only, handheld 35mm. Natural skin
> tones, honest imperfect surfaces, slight motion in the frame. Muted realistic
> grade, no colour cast, visible grain. Candid, unposed, nobody looking at
> camera. Absolutely not stock-photo styling, no fake smiles, no CGI.
### 4. Hard-light graphic
Direct sun, saturated ground, sharp shadows as composition. Streetwear, energy
drinks, sports, youth brands.
> Studio product photography with a single hard undiffused source. Crisp
> high-contrast shadows used as graphic shapes. Saturated seamless colour
> backdrop. Punchy contrast, slight halation on specular highlights. Shot on
> digital medium format, sharp throughout. Photographic, not rendered.
### 5. Macro texture
Closer than the eye gets. Food, drink, materials, ingredients, craft.
> Extreme macro photography, 100mm macro lens at high magnification. Razor-thin
> plane of focus, everything else falling to soft black. Backlit so edges glow.
> Visible surface texture, condensation, grain of the material. Near-black
> negative space. Photographic realism, no CGI, no illustration.
### 6. Architectural
Space, scale, geometry, almost no people. Real estate, agencies, manufacturing,
B2B infrastructure.
> Architectural photography, tilt-shift corrected verticals, wide 24mm. Vast
> negative space, strong linear geometry, raking daylight through structure.
> Cool neutral grade with one warm accent. Long exposure stillness. Photographic,
> no render, no CGI.
### 7. Nocturne
Night, practical lights, wet surfaces, reflection. Nightlife, automotive,
gaming, security, anything with edge.
> Night photography, practical light sources only: neon, sodium, screen glow.
> Wet reflective surfaces doubling every light. Deep blue-black shadows, warm
> point highlights, heavy atmosphere. Anamorphic flare, visible grain.
> Photographic, cinematic, not rendered.
### 8. Technical drawing
The one non-photographic world that reads as premium rather than cheap, because
it is honest about being a diagram. Engineering, hardware, complex services.
> Precise technical illustration in the style of a patent drawing or exploded
> assembly diagram. Fine consistent line weight, no fills, monochrome ink on
> warm paper ground, dimension lines and leader callouts. Orthographic
> projection. Restrained, engineered, no shading, no gradients, no 3D render.
---
## Writing your own
Every preamble names five things. Miss one and the set drifts.
1. **Medium and lens**: "35mm anamorphic", "100mm macro", "handheld 35mm".
This is what sets depth of field and perspective.
2. **Light**: count the sources and place them. "One warm key, cool ambient
fill" is directable. "Beautiful lighting" is not.
3. **Grade**: the colour story in three words. "Deep charcoal, warm amber,
desaturated mid-tones."
4. **Texture**: grain, halation, condensation, imperfection. This is what makes
an image read as photographed rather than generated. Skipping it is the
single biggest cause of the plastic AI look.
5. **The negative list**: what it must not be. "NOT 3D render, NOT clay, NOT
illustration, no digital glow, no plastic sheen." Models drift toward
rendered-looking output; the negative list is what holds them.
---
## If the canvas is light
Every worked example above is dark, and a high-key build inverts four things at
once. Getting them wrong costs a full iteration:
1. **The scrim washes toward the canvas, it does not darken.** Over footage on a
paper ground the density has to go *up*, not down, or the type has nothing to
sit on.
2. **The type over media stays ink.** Light text on a light page over a bright
frame is unrecoverable.
3. **`--sc-edge` is an ink-tinted inset highlight** tuned for a near-black
ground. On paper it reads as dirt along the top lip. Invert it to white, or
drop it and carry the raise with a hairline.
4. **The `--sc-e1/2/3` shadow alphas are tuned for a near-black ground** too.
Halve them and re-tint `--sc-shadow-color` toward the canvas hue.
The contrast direction flips with all of this: dark type fails on the *darkest*
patch under it, not the brightest. The harness picks the direction per line, so
it grades a high-key page correctly (see [verify.md](verify.md)).
---
## Composition, per shot
The preamble sets the world. Each shot prompt then names the subject, the frame,
and **where the empty space is**.
**Name the empty space in every scene prompt, not only in the preamble.** This
is a requirement, not advice. It is the single highest-leverage line in a prompt:
seven stills across three aspect ratios came back on-grade and cohesive with zero
rerolls on the build that did it every time. Copy sits on these images, so
composition has to leave room for it:
- "large empty shadowed space across the upper left of the frame"
- "the subject low and to the right, negative space above"
- "centred with even empty space on both sides"
Generate the space, do not crop for it later. And never ask for text in the
image: markup is selectable, translatable, sharp at every density, and editable
after the fact.
---
## Cohesion checks
Lay every generated asset side by side and look for:
- **One light direction** across the set, or a deliberate reason it changes.
- **One grade.** If one image is cooler than the rest, reroll it rather than
correcting it in CSS; a filter over a full-bleed image flattens it.
- **One level of realism.** A photoreal hero followed by a rendered-looking
product shot is worse than either style used consistently.
- **The brand object identical everywhere.** Pass the real packaging, logo or
product shot as `--ref` on every prompt that includes it. A label that drifts
between shots is the thing a client notices first.

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env node
/**
* Preflight. Run this BEFORE the interview, not after the first failure.
*
* node scripts/doctor.mjs check everything
* node scripts/doctor.mjs --probe also spend one API call to read the balance
*
* Every check that can fail deep inside a build with a misleading message is
* checked here with an honest one. The three that actually bite:
*
* - a STRIPPED ffmpeg on PATH. It carries ~50 filters and silently lacks
* scale, fps, psnr and the webp muxer, then fails with "No option name
* near ..." or "Unable to choose an output format", both of which read as
* a mistake in your command rather than a missing feature.
* - no KIE_AI_API_KEY, which only matters if you are generating assets.
* - playwright-core resolving from the wrong directory. It is required from
* the BUILD folder, not from the skill.
*/
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { paths } from "./workspace.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const rows = [];
const add = (sev, name, ok, detail, fix) => rows.push({ sev, name, ok, detail, fix });
const run = (cmd, args) => {
try {
return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
} catch {
return null;
}
};
// ---------------------------------------------------------------- node ----
const major = Number(process.versions.node.split(".")[0]);
add("required", "node", major >= 18, `v${process.versions.node}`,
"Install Node 18 or newer.");
// -------------------------------------------------------------- ffmpeg ----
function globWinGet() {
const home = process.env.USERPROFILE || process.env.HOME || "";
const base = path.join(home, "AppData/Local/Microsoft/WinGet/Packages");
if (!fs.existsSync(base)) return [];
const out = [];
for (const d of fs.readdirSync(base)) {
if (!/^Gyan\.FFmpeg/i.test(d)) continue;
const inner = path.join(base, d);
for (const e of fs.readdirSync(inner)) {
const p = path.join(inner, e, "bin/ffmpeg.exe");
if (fs.existsSync(p)) out.push(p);
}
}
return out;
}
const candidates = [
process.env.SCROLLCRAFT_FFMPEG,
"ffmpeg",
...globWinGet(),
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg",
"/usr/bin/ffmpeg",
"/snap/bin/ffmpeg",
].filter(Boolean);
let ffmpeg = null, filterCount = 0;
for (const c of candidates) {
const out = run(c, ["-hide_banner", "-filters"]);
if (!out) continue;
const n = out.split("\n").length;
if (n > filterCount) { filterCount = n; ffmpeg = c; }
if (n > 200) break;
}
add("required", "ffmpeg (full build)", filterCount > 200,
ffmpeg ? `${ffmpeg} (${filterCount} filters)` : "not found",
"A stripped ffmpeg lacks scale/fps/psnr and the webp muxer. Install a full build (Windows: winget install Gyan.FFmpeg) or set SCROLLCRAFT_FFMPEG to one.");
if (ffmpeg && filterCount > 200) {
const enc = run(ffmpeg, ["-hide_banner", "-encoders"]) || "";
add("optional", " └ libwebp encoder", /libwebp/.test(enc),
/libwebp/.test(enc) ? "present" : "missing",
"Posters fall back to JPEG. Not fatal, just heavier.");
}
// ---------------------------------------------------------- playwright ----
let pw = false, pwWhere = "";
try {
createRequire(path.join(process.cwd(), "package.json"))("playwright-core");
pw = true; pwWhere = "resolves from cwd";
} catch {
try {
createRequire(path.join(HERE, "package.json"))("playwright-core");
pw = true; pwWhere = "resolves from the skill, but NOT from cwd";
} catch { pwWhere = "not installed"; }
}
add("verify", "playwright-core", pw, pwWhere,
"Run `npm i playwright-core` inside the build folder. Only needed for the verification pass.");
const chrome = [
process.env.SCROLLCRAFT_CHROME,
// Windows
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
// macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
// Linux
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
].find((p) => p && fs.existsSync(p));
add("verify", "Chrome", Boolean(chrome), chrome || "not found",
"Install Chrome, or set SCROLLCRAFT_CHROME to an executable.");
// ------------------------------------------------------------- kie key ----
function findKey() {
if (process.env.KIE_AI_API_KEY) return "env";
let dir = process.cwd();
for (let i = 0; i < 8; i++) {
const p = path.join(dir, ".env");
if (fs.existsSync(p) && /^\s*KIE_AI_API_KEY\s*=\s*\S+/m.test(fs.readFileSync(p, "utf8"))) return p;
const up = path.dirname(dir);
if (up === dir) break;
dir = up;
}
return null;
}
const keyWhere = findKey();
add("optional", "KIE_AI_API_KEY", Boolean(keyWhere), keyWhere || "not set",
"Only needed to GENERATE imagery. Building from your own photos and footage needs no key and no spend. Copy .env.example to .env to set one.");
// ----------------------------------------------------------- workspace ----
let ws = null;
try {
ws = paths();
add("required", "workspace", true, `${ws.workspace}\n via ${ws.via}`, "");
add("optional", " └ registry", fs.existsSync(ws.fingerprints),
fs.existsSync(ws.fingerprints) ? "present" : "not created yet",
"Run `node scripts/workspace.mjs --ensure` to create it.");
} catch (e) {
add("required", "workspace", false, e.message, "Fix or delete the offending .scrollcraft.json.");
}
// -------------------------------------------------------------- report ----
const mark = (r) => (r.ok ? "\u001b[32m ok \u001b[0m" : r.sev === "required" ? "\u001b[31mFAIL\u001b[0m" : "\u001b[33mwarn\u001b[0m");
console.log("\nscrollcraft preflight\n");
for (const r of rows) {
console.log(` [${mark(r)}] ${r.name.padEnd(22)} ${r.detail}`);
if (!r.ok && r.fix) console.log(` ${"\u001b[2m"}${r.fix}${"\u001b[0m"}`);
}
const hardFails = rows.filter((r) => !r.ok && r.sev === "required");
const softFails = rows.filter((r) => !r.ok && r.sev !== "required");
console.log("");
if (hardFails.length) {
console.log(`\u001b[31m${hardFails.length} required check(s) failed. Fix these before building.\u001b[0m\n`);
process.exit(1);
}
console.log(softFails.length
? `\u001b[33mReady, with ${softFails.length} optional item(s) missing (see above).\u001b[0m\n`
: "\u001b[32mReady.\u001b[0m\n");
if (process.argv.includes("--probe") && keyWhere) {
const { execFileSync: x } = await import("node:child_process");
try {
console.log("credit: " + x(process.execPath, [path.join(HERE, "kie.mjs"), "probe"], { encoding: "utf8" }).trim().replace(/^credit:\s*/, ""));
} catch (e) { console.log("balance probe failed: " + e.message); }
}

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# scrollcraft: encode a clip for scrubbing, not for playback.
#
# A normal web encode puts a keyframe every 2-5 seconds. Scrubbing seeks to an
# arbitrary time, and the decoder must walk from the previous keyframe to get
# there, so a sparse-GOP file feels like mud under the wheel while playing back
# perfectly. Dense keyframes cost file size and buy responsiveness. That trade
# is the entire point of this script.
#
# ./encode.sh in.mp4 out.mp4 desktop master (1080p, -g 8, crf 20)
# ./encode.sh in.mp4 out-m.mp4 mobile phone variant (720p, -g 4, crf 24)
#
# CRF override, for the grain-heavy worlds assets.md warns about (marine snow,
# bioluminescence, smoke, film grain). A dense GOP doubles the cost of grain, so
# a world like that wants 22-23 rather than the default 20:
#
# ./encode.sh in.mp4 out.mp4 desktop 23 fourth positional argument
# SCROLLCRAFT_CRF=23 ./encode.sh in.mp4 out.mp4 or the env var
#
# The positional argument wins over the env var; with neither, the defaults
# below are unchanged.
#
# Audio is stripped: these clips are scrubbed, never played, and a muted track
# is dead weight plus an autoplay-policy hazard.
set -euo pipefail
# Resolve a FULL ffmpeg build. Several toolchains (Remotion, some Electron apps)
# put a stripped ffmpeg on PATH that carries maybe 50 filters and silently lacks
# scale/fps/tile. It fails with "No option name near ..." on any filter chain,
# which reads like a syntax error in your command rather than a missing filter.
# Count the filters and go looking for a real build if the count is low.
pick_ffmpeg() {
local cand
for cand in \
"${SCROLLCRAFT_FFMPEG:-}" \
"$(command -v ffmpeg 2>/dev/null || true)" \
"${HOME:-/nonexistent}"/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_*/ffmpeg-*-full_build/bin/ffmpeg.exe \
/usr/local/bin/ffmpeg /opt/homebrew/bin/ffmpeg /usr/bin/ffmpeg /snap/bin/ffmpeg
do
[ -n "$cand" ] && [ -x "$cand" ] || continue
if [ "$("$cand" -hide_banner -filters 2>/dev/null | wc -l)" -gt 200 ]; then
echo "$cand"; return 0
fi
done
echo "ERROR: no full ffmpeg build found. Set SCROLLCRAFT_FFMPEG to one." >&2
return 1
}
FFMPEG="$(pick_ffmpeg)"
FFPROBE="$(dirname "$FFMPEG")/ffprobe"
[ -x "$FFPROBE" ] || FFPROBE="$(command -v ffprobe)"
IN="${1:?usage: encode.sh <in> <out> [mobile|desktop] [crf]}"
OUT="${2:?usage: encode.sh <in> <out> [mobile|desktop] [crf]}"
MODE="${3:-desktop}"
if [ "$MODE" = "mobile" ]; then
SCALE="scale=-2:720"; GOP=4; CRF=24
else
SCALE="scale=-2:1080"; GOP=8; CRF=20
fi
# Positional beats env var beats the mode default.
CRF="${4:-${SCROLLCRAFT_CRF:-$CRF}}"
case "$CRF" in
''|*[!0-9]*) echo "ERROR: crf must be an integer, got '$CRF'" >&2; exit 1 ;;
esac
mkdir -p "$(dirname "$OUT")"
"$FFMPEG" -y -hide_banner -loglevel error -i "$IN" \
-an \
-vf "${SCALE}:flags=lanczos,format=yuv420p" \
-c:v libx264 -profile:v high -preset slow -crf "$CRF" \
-g "$GOP" -keyint_min "$GOP" -sc_threshold 0 \
-movflags +faststart \
"$OUT"
SIZE=$(du -h "$OUT" | cut -f1)
DUR=$("$FFPROBE" -v error -show_entries format=duration -of csv=p=0 "$OUT")
echo "$OUT ${SIZE} ${DUR}s gop=${GOP} crf=${CRF}"

View File

@@ -0,0 +1,202 @@
#!/usr/bin/env node
/**
* scrollcraft asset generator: kie.ai unified jobs API.
*
* POST https://api.kie.ai/api/v1/jobs/createTask { model, input }
* GET https://api.kie.ai/api/v1/jobs/recordInfo?taskId=...
*
* COMMANDS
* still <prompt> <out.png> [--ar 16:9] [--ref a.png]
* seedream/5-pro-text-to-image (or -image-to-image with --ref).
* Photoreal by default. Stills are cheap; generate, look, reroll.
*
* shot <prompt> <in.png> <out.mp4> [--tail b.png] [--dur 5]
* kling/v2-1-pro image-to-video. --tail pins the LAST frame, which is
* the whole trick behind a seamless chain: leg N's tail is leg N+1's
* head, so the cut between them is frame-identical and invisible.
*
* probe print account credit and exit.
*
* Env: KIE_AI_API_KEY, read from the project-root .env if not already set.
*/
import fs from "node:fs";
import path from "node:path";
const API = "https://api.kie.ai";
const UPLOAD = "https://kieai.redpandaai.co/api/file-base64-upload";
const MODELS = {
still: "seedream/5-pro-text-to-image",
stillEdit: "seedream/5-pro-image-to-image",
shot: "kling/v2-1-pro",
};
// ---------------------------------------------------------------- key ----
function findEnv(start) {
let dir = path.resolve(start);
for (let i = 0; i < 8; i++) {
const p = path.join(dir, ".env");
if (fs.existsSync(p)) return p;
const up = path.dirname(dir);
if (up === dir) break;
dir = up;
}
return null;
}
function loadKey() {
if (process.env.KIE_AI_API_KEY) return process.env.KIE_AI_API_KEY;
const envPath = findEnv(process.cwd());
if (!envPath) throw new Error("KIE_AI_API_KEY not set and no .env found walking up from " + process.cwd());
for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*KIE_AI_API_KEY\s*=\s*(.+?)\s*$/);
if (m) return m[1].replace(/^["']|["']$/g, "");
}
throw new Error("KIE_AI_API_KEY not found in " + envPath);
}
const KEY = loadKey();
const H = { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` };
// ------------------------------------------------------------- helpers ----
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function uploadLocal(file) {
const abs = path.resolve(file);
if (!fs.existsSync(abs)) throw new Error("input not found: " + abs);
const ext = path.extname(abs).slice(1).toLowerCase();
const mime = ext === "jpg" ? "image/jpeg" : `image/${ext}`;
const dataUrl = `data:${mime};base64,${fs.readFileSync(abs).toString("base64")}`;
const res = await fetch(UPLOAD, {
method: "POST", headers: H,
body: JSON.stringify({ base64Data: dataUrl, uploadPath: "scrollcraft", fileName: path.basename(abs) }),
});
const j = await res.json();
const url = j?.data?.downloadUrl || j?.data?.fileUrl || j?.data?.url;
if (!url) throw new Error("upload failed: " + JSON.stringify(j));
return url;
}
// A local path becomes a hosted URL; an http(s) string passes straight through.
const asUrl = (v) => (/^https?:\/\//i.test(v) ? Promise.resolve(v) : uploadLocal(v));
async function createTask(model, input) {
const res = await fetch(`${API}/api/v1/jobs/createTask`, {
method: "POST", headers: H, body: JSON.stringify({ model, input }),
});
const j = await res.json();
if (j.code !== 200 || !j?.data?.taskId) throw new Error(`createTask ${model}: ${JSON.stringify(j)}`);
return j.data.taskId;
}
async function waitTask(taskId, { label = "job", timeoutMs = 15 * 60 * 1000 } = {}) {
const t0 = Date.now();
let delay = 4000;
for (;;) {
if (Date.now() - t0 > timeoutMs) throw new Error(`${label}: timed out after ${Math.round((Date.now() - t0) / 1000)}s`);
const res = await fetch(`${API}/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`, { headers: H });
const j = await res.json();
const d = j?.data || {};
const state = d.state || d.status;
if (state === "success") {
let out = d.resultJson;
if (typeof out === "string") { try { out = JSON.parse(out); } catch {} }
const urls = out?.resultUrls || out?.result_urls || out?.urls || [];
if (!urls.length) throw new Error(`${label}: success with no result url: ${JSON.stringify(d)}`);
return urls;
}
if (state === "fail" || state === "failed") {
throw new Error(`${label} failed: ${d.failMsg || d.failCode || JSON.stringify(d)}`);
}
process.stderr.write(` ${label}: ${state || "queued"} (${Math.round((Date.now() - t0) / 1000)}s)\n`);
await sleep(delay);
delay = Math.min(delay * 1.25, 15000);
}
}
async function download(url, out) {
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
const res = await fetch(url);
if (!res.ok) throw new Error(`download ${res.status} ${url}`);
fs.writeFileSync(path.resolve(out), Buffer.from(await res.arrayBuffer()));
return out;
}
function flag(argv, name, dflt = null) {
const i = argv.indexOf(name);
return i > -1 && argv[i + 1] ? argv[i + 1] : dflt;
}
function flags(argv, name) {
const out = [];
argv.forEach((a, i) => { if (a === name && argv[i + 1]) out.push(argv[i + 1]); });
return out;
}
// ---------------------------------------------------------------- main ----
const [cmd, ...rest] = process.argv.slice(2);
try {
if (cmd === "probe") {
const r = await fetch(`${API}/api/v1/chat/credit`, { headers: H });
const j = await r.json();
console.log("credit:", j.data);
} else if (cmd === "still") {
const [prompt, out] = rest;
if (!prompt || !out) throw new Error('usage: kie.mjs still "<prompt>" <out.png> [--ar 16:9] [--ref a.png]');
const ar = flag(rest, "--ar", "16:9");
const refs = flags(rest, "--ref");
let model = MODELS.still;
// aspect_ratio, quality and output_format are all required by seedream;
// omitting any one returns a bare "This field is required" that does not
// name the field, so keep them explicit rather than relying on defaults.
const input = {
prompt,
aspect_ratio: ar,
quality: flag(rest, "--quality", "high"),
output_format: "png",
nsfw_checker: false,
};
if (refs.length) {
model = MODELS.stillEdit;
input.image_urls = await Promise.all(refs.map(asUrl));
}
const id = await createTask(model, input);
const urls = await waitTask(id, { label: path.basename(out) });
await download(urls[0], out);
console.log(out);
} else if (cmd === "shot") {
const [prompt, head, out] = rest;
if (!prompt || !head || !out) {
throw new Error('usage: kie.mjs shot "<prompt>" <head.png> <out.mp4> [--tail b.png] [--dur 5]');
}
const dur = flag(rest, "--dur", "5");
const tail = flag(rest, "--tail");
const input = {
prompt,
image_url: await asUrl(head),
duration: String(dur),
// Camera-move clips are graded on smoothness, so the negative prompt
// targets exactly what breaks a scrub: judder, warping, cuts.
negative_prompt: "blur, distortion, low quality, warping, morphing, jitter, flicker, text, watermark, cut, scene change",
cfg_scale: 0.5,
};
if (tail) input.tail_image_url = await asUrl(tail);
const id = await createTask(MODELS.shot, input);
const urls = await waitTask(id, { label: path.basename(out), timeoutMs: 20 * 60 * 1000 });
await download(urls[0], out);
console.log(out);
} else {
console.error(`scrollcraft asset generator
node kie.mjs probe
node kie.mjs still "<prompt>" <out.png> [--ar 16:9] [--ref ref.png]
node kie.mjs shot "<prompt>" <head.png> <out.mp4> [--tail tail.png] [--dur 5]
`);
process.exit(1);
}
} catch (err) {
console.error("ERROR:", err.message);
process.exit(1);
}

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* scrollcraft static server.
*
* A scrollcraft page cannot be verified from file://. The engine fetches each
* clip as a Blob, and file:// fetches are blocked by CORS in every browser, so
* the page silently falls back to posters and looks fine while proving nothing.
* Serve it.
*
* node serve.mjs --root builds/perkform --port 4500
*/
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
const argv = process.argv.slice(2);
const arg = (n, d) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
const ROOT = path.resolve(arg("--root", "."));
const PORT = parseInt(arg("--port", "4500"), 10);
const TYPES = {
".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8", ".json": "application/json",
".mp4": "video/mp4", ".webm": "video/webm",
".webp": "image/webp", ".png": "image/png", ".jpg": "image/jpeg",
".svg": "image/svg+xml", ".woff2": "font/woff2",
};
http.createServer((req, res) => {
const url = decodeURIComponent(req.url.split("?")[0]);
let file = path.join(ROOT, url === "/" ? "/index.html" : url);
// Refuse to serve outside the root even if the path walks up.
if (!file.startsWith(ROOT)) { res.writeHead(403).end("forbidden"); return; }
if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html");
if (!fs.existsSync(file)) { res.writeHead(404).end("not found"); return; }
const ext = path.extname(file).toLowerCase();
const stat = fs.statSync(file);
res.writeHead(200, {
"Content-Type": TYPES[ext] || "application/octet-stream",
"Content-Length": stat.size,
// No caching: verification loops re-shoot the same URLs after edits, and a
// cached clip or stylesheet makes you screenshot the previous build.
"Cache-Control": "no-store",
"Accept-Ranges": "bytes",
});
fs.createReadStream(file).pipe(res);
}).listen(PORT, () => {
console.log(`scrollcraft: ${ROOT}\n http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,644 @@
#!/usr/bin/env node
/**
* scrollcraft verification harness: shoot the page's own scroll.
*
* Walks the page in N evenly spaced scroll positions, waits for the scrub video
* to actually settle at each one, screenshots it, and reports what the engine
* thinks is on screen. Then tiles the frames into one contact sheet, because a
* dead middle only shows up in contiguous frames: any single screenshot is a
* frame the transition may not survive.
*
* node shoot.mjs --url http://localhost:4500 --out lab/shots --steps 12
* node shoot.mjs --url ... --width 375 --height 812 --out lab/mobile
* node shoot.mjs --url ... --reduced-motion --out lab/reduced
*
* Uses the INSTALLED Chrome, not bundled Chromium: Chromium ships without the
* h264 decoder, so every scrub clip would silently fail to paint and the run
* would "pass" against posters.
*/
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
// The skill lives outside the project it is building, so resolve playwright
// from the BUILD project's node_modules (cwd), not from next to this file.
// Run `npm i playwright-core` in the build project once.
let chromium;
try {
({ chromium } = createRequire(path.join(process.cwd(), "package.json"))("playwright-core"));
} catch {
console.error("playwright-core not found. Run this in the build project after:\n npm i playwright-core");
process.exit(1);
}
const argv = process.argv.slice(2);
const arg = (n, d) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
const has = (n) => argv.includes(n);
const URL = arg("--url", "http://localhost:4500");
const OUT = path.resolve(arg("--out", "lab/shots"));
const STEPS = parseInt(arg("--per-act", arg("--steps", "6")), 10); // samples PER ACT
const W = parseInt(arg("--width", "1440"), 10);
const H = parseInt(arg("--height", "900"), 10);
const REDUCED = has("--reduced-motion");
const CHROME = [
process.env.SCROLLCRAFT_CHROME,
// Windows
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
// macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
// Linux
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
].find((p) => p && fs.existsSync(p));
if (!CHROME) {
console.error("No installed Chrome found. Set SCROLLCRAFT_CHROME to its path.");
process.exit(1);
}
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({
viewport: { width: W, height: H },
deviceScaleFactor: 2,
reducedMotion: REDUCED ? "reduce" : "no-preference",
});
const consoleErrors = [];
page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
page.on("pageerror", (e) => consoleErrors.push(String(e)));
const failed = [];
page.on("requestfailed", (r) => failed.push(`${r.failure()?.errorText} ${r.url()}`));
// Not networkidle: the engine keeps clips in flight as you scroll, and a
// webfont connection can stay open, so idle may never arrive. Wait for the
// engine's own ready signal and for the faces to land, since line splitting
// measures real line boxes and is wrong before the real face is applied.
await page.goto(URL, { waitUntil: "domcontentloaded" });
await page.waitForSelector("html.sc-ready", { timeout: 15000 });
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(700);
const doc = await page.evaluate(() => {
const world = document.querySelector('[data-sc-mode="worldflight"]');
return {
height: document.body.scrollHeight,
vh: innerHeight,
acts: [...document.querySelectorAll("[data-sc-act]")].map((a) => a.dataset.scAct),
world: world
? {
seam: parseFloat(world.dataset.scSeam) || 0.12,
segs: [...world.querySelectorAll("[data-sc-segment]")].map((s) => ({
w: parseFloat(s.dataset.scW) || 1.3,
linger: parseFloat(s.dataset.scLinger) || 0,
label: s.dataset.scWaypoint || "",
})),
}
: null,
};
});
const WORLD = doc.world;
const maxScroll = doc.height - doc.vh;
if (WORLD) {
const total = WORLD.segs.reduce((s, g) => s + g.w, 0);
console.log(`page: worldflight, ${WORLD.segs.length} legs over ${total.toFixed(2)}vh ` +
`(track ${(doc.height / doc.vh).toFixed(1)} viewport-heights), seam ${WORLD.seam}vh`);
console.log(` legs: ${WORLD.segs.map((g, i) => `${i}:${g.label || "-"}@${g.w}vh`).join(" ")}`);
} else {
console.log(`page: ${(doc.height / doc.vh).toFixed(1)} viewport-heights, acts: ${doc.acts.join(" > ")}`);
}
// Wait for the playhead to ARRIVE, not merely to stop seeking. The engine lerps
// currentTime toward a target on its own rAF loop, so after any scroll jump
// there is a stretch of ~15 frames during which every clip on the page is
// somewhere it will never be again. Screenshot in that window and the sheet is a
// set of frames the reader is never shown, the dead-scroll comparison runs on
// mid-lerp noise, and the whole run is unrepeatable.
async function settle(timeout = 4000) {
const t0 = Date.now();
let last = null;
for (;;) {
const now = await page.evaluate(() => {
const insts = (window.ScrollCraft && window.ScrollCraft.instances) || [];
const clips = [].concat(...insts.map((i) => i.clips || [])).filter((c) => c.ready);
// Pages on an older engine expose no instances; fall back to watching
// currentTime go quiet, which reaches the same state more slowly.
if (clips.length) {
const arrived = clips.every((c) => Math.abs(c.cur - c.target) < 0.002 && !c.el.seeking);
return arrived ? "arrived" : "moving";
}
return [...document.querySelectorAll("video[data-sc-scrub]")]
.map((v) => (v.seeking ? "seeking" : v.currentTime.toFixed(3))).join("|");
});
if (now === "arrived") return true;
if (now !== "moving" && now === last && !now.includes("seeking")) return true;
if (Date.now() - t0 > timeout) return false;
last = now;
await page.waitForTimeout(60);
}
}
// Sample WITHIN each act, not uniformly down the document. Uniform sampling
// distributes positions by page length, so a short act gets one sample that
// lands wherever it lands, and adding a section elsewhere silently moves every
// sample. That produces "this cue never reaches full opacity" reports that come
// and go with unrelated edits. Per-act sampling hits the same fractions of
// every act every run, so the findings mean something.
//
// A worldflight has no acts to sample within; its unit is the leg, and its
// geometry lives entirely in the weights, so positions are computed from the
// track rather than measured off the DOM. Both sides of every seam are added on
// top: the crossfade is the frame this mode is judged on, and it occupies about
// a tenth of a viewport, so uniform sampling steps straight over it.
const positions = WORLD ? await page.evaluate((perSeg) => {
const root = document.querySelector('[data-sc-mode="worldflight"]');
const segs = [...root.querySelectorAll("[data-sc-segment]")];
const top = root.getBoundingClientRect().top + scrollY;
const seam = parseFloat(root.dataset.scSeam) || 0.12;
const fracs = Array.from({ length: perSeg }, (_, i) => (perSeg === 1 ? 0.5 : i / (perSeg - 1)));
const out = [];
let c = 0;
segs.forEach((s, i) => {
const w = parseFloat(s.dataset.scW) || 1.3;
fracs.forEach((f) => {
const p = 0.02 + f * 0.96;
out.push(Math.round(top + (c + w * p) * innerHeight));
});
c += w;
if (i < segs.length - 1) {
[-0.5, -0.2, 0.2, 0.5].forEach((k) => out.push(Math.round(top + (c + k * seam) * innerHeight)));
}
});
const max = document.body.scrollHeight - innerHeight;
out.push(0, max);
return [...new Set(out.map((y) => Math.max(0, Math.min(max, y))))].sort((a, b) => a - b);
}, Math.max(2, Math.round(STEPS))) : await page.evaluate((perAct) => {
const out = [];
const fracs = Array.from({ length: perAct }, (_, i) => (perAct === 1 ? 0.5 : i / (perAct - 1)));
document.querySelectorAll("[data-sc-act]").forEach((el) => {
const top = el.getBoundingClientRect().top + scrollY;
const h = el.offsetHeight;
const pinned = ["scrub", "pin", "pan"].includes(el.dataset.scAct);
fracs.forEach((f) => {
// Nudge off the exact endpoints: p=0 and p=1 sit on the seam between two
// acts, where which one you are "in" is ambiguous.
const p = 0.02 + f * 0.96;
out.push(Math.round(pinned ? top + (h - innerHeight) * p : top - innerHeight + (h + innerHeight) * p));
});
// A pinned stage is on screen for a viewport BEFORE its pinned travel begins
// and a viewport AFTER it ends, and the loop above samples only inside the
// travel. Those two slides are exactly where a clip mapped to pinned
// progress sits frozen on its first or last frame, so not sampling them is
// why a frozen clip could pass this harness. Sample them.
if (el.dataset.scAct === "scrub") {
// `v` is the fraction of the viewport the stage covers at that position.
// Sample the part of each slide where the stage is still MOSTLY on screen,
// because that is where a frozen frame is conspicuous, and because the
// frozen-clip check needs consecutive samples that are both well past its
// visibility gate before it will call anything.
[0.6, 0.75, 0.9].forEach((v) => {
out.push(Math.round(top - innerHeight * (1 - v))); // sliding in
out.push(Math.round(top + h - innerHeight * v)); // sliding out
});
}
});
const max = document.body.scrollHeight - innerHeight;
out.push(max);
return [...new Set(out.map((y) => Math.max(0, Math.min(max, y))))].sort((a, b) => a - b);
}, Math.max(2, Math.round(STEPS)));
const report = [];
for (let i = 0; i < positions.length; i++) {
const y = positions[i];
const p = maxScroll ? y / maxScroll : 0;
await page.evaluate((y) => scrollTo({ top: y, behavior: "instant" }), y);
await page.waitForTimeout(180);
const settled = await settle();
const state = await page.evaluate(() => {
// A kinetic heading carries its real opacity on the split line units; the
// engine forces the element itself to 1. Reading the element therefore
// reports every kinetic headline as fully present, including on frames
// where every one of its lines is at 0. Take the strongest line instead:
// the heading is "peaked" when at least one unit has arrived.
const cueOpacity = (el) => {
const o = parseFloat(getComputedStyle(el).opacity) || 0;
const units = el.querySelectorAll(".sc-split__i");
if (!units.length) return o;
let m = 0;
units.forEach((u) => { m = Math.max(m, parseFloat(getComputedStyle(u).opacity) || 0); });
return o * m;
};
const vis = [];
// A worldflight's copy blocks are windowed against the whole track rather
// than an act's progress, but they are the same thing to a reader: type that
// has to arrive, hold, and leave. Grade them identically.
document.querySelectorAll("[data-sc-cue],[data-sc-copy]").forEach((el) => {
const o = cueOpacity(el);
if (o <= 0.02) return;
// On screen, not merely non-transparent. An element parked off-viewport
// at opacity 1 is not a visible cue, and counting it produces phantom
// findings that send you chasing a bug the reader never sees.
const r = el.getBoundingClientRect();
if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) return;
vis.push({ t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 46), o: +o.toFixed(2) });
});
const clips = [...document.querySelectorAll("video[data-sc-scrub]")].map((v) => ({
// A continuous world legitimately keeps its clip chain outside the act
// stack, driven by the page's own scroll value rather than by an act's
// progress. Falling back to the clip's own class keeps that case
// reporting instead of taking the whole run down before it writes
// anything.
painted: (v.closest("[data-sc-act]") ?? v).classList.contains("sc-has-clip"),
t: +(v.currentTime || 0).toFixed(2),
dur: +(v.duration || 0).toFixed(2),
// How much of the viewport this clip's stage actually covers. A frozen
// playhead only matters while the reader can see the stage.
vis: (() => {
const st = v.closest("[data-sc-stage]") || v.parentElement;
if (!st) return 0;
const b = st.getBoundingClientRect();
return +(Math.max(0, Math.min(b.bottom, innerHeight) - Math.max(b.top, 0)) / innerHeight).toFixed(3);
})(),
}));
// Rails and wipes move without changing any cue or clip time, so without
// these a panning section reads as dead scroll.
const rails = [...document.querySelectorAll("[data-sc-pan]")]
.map((r) => Math.round(new DOMMatrixReadOnly(getComputedStyle(r).transform).m41));
const wipes = [...document.querySelectorAll("[data-sc-reveal]")]
.map((r) => getComputedStyle(r).clipPath);
// Which act owns the middle of the viewport right now.
let act = "-";
document.querySelectorAll("[data-sc-act]").forEach((a) => {
const r = a.getBoundingClientRect();
if (r.top <= innerHeight / 2 && r.bottom >= innerHeight / 2) act = a.dataset.scAct;
});
// Where each pinned stage physically sits. Before an act reaches its pin
// point the stage slides up the screen while its progress is still clamped
// to 0, so the clip and cues are frozen and yet the view is very much
// moving. Without this the run-up to every pinned act reads as dead scroll.
const stages = [...document.querySelectorAll("[data-sc-stage]")]
.map((s) => Math.round(s.getBoundingClientRect().top));
// Worldflight legs. Opacity IS the crossfade, so it is state, not styling:
// two samples with the same clip times but different leg opacities are a
// dissolve in progress, not dead scroll.
const segs = [...document.querySelectorAll("[data-sc-segment]")].map((el, i) => {
const v = el.querySelector("video");
return {
i, label: el.dataset.scWaypoint || "",
op: +(parseFloat(getComputedStyle(el).opacity) || 0).toFixed(3),
painted: el.classList.contains("sc-has-clip"),
t: v ? +(v.currentTime || 0).toFixed(3) : null,
};
});
const world = document.querySelector('[data-sc-mode="worldflight"]');
// Bespoke fixed stages can use flow markers for document travel while all
// visible motion happens outside the engine's pin/scrub devices. Those
// pages publish a compact representation of their actual visual state so
// dead-scroll verification does not silently skip the whole experience.
const customEls = [...document.querySelectorAll("[data-sc-verify-state]")];
const custom = customEls.map((el) => el.getAttribute("data-sc-verify-state") || "");
const customHold = customEls.some((el) => el.getAttribute("data-sc-verify-hold") === "true");
return {
cues: vis, clips, rails, wipes, act, stages, segs, custom, customHold,
seg: world ? +(world.style.getPropertyValue("--sc-seg") || -1) : null,
segp: world ? +(world.style.getPropertyValue("--sc-segp") || 0) : null,
bg: getComputedStyle(document.documentElement).getPropertyValue("--sc-canvas").trim(),
};
});
// Flat NN.png so ffmpeg can read the set as a numbered sequence for the
// contact sheet. The scroll offset lives in report.json, not the filename.
const name = `${String(i).padStart(2, "0")}.png`;
await page.screenshot({ path: path.join(OUT, name) });
// Contrast, measured on the COMPOSITED page rather than on the source media.
// Sampling the video directly ignores every scrim, gradient and blend on top
// of it, so a page can read as failing while looking fine, or the reverse.
// Hide the text, shoot the same frame, hand the pixels back to the page, and
// sample the real background under each line. Text over a scrubbing clip is
// the one contrast case a static audit cannot cover: the frame beneath a
// headline changes as you scroll, so it can pass on the poster and fail three
// hundred pixels later. The direction is picked per line: light type fails on
// the brightest patch, dark type on the darkest one.
//
// Fixed chrome is hidden along with the text. A fixed bar paints in FRONT of
// whatever scrolls under it, so its own mark is not the background behind a
// headline passing beneath it, and leaving it in reports a spurious failure
// on an act that is fine.
await page.evaluate(() => {
document.querySelectorAll("body *").forEach((el) => {
if (getComputedStyle(el).position !== "fixed") return;
// A worldflight's stage and copy layer are fixed too, and they are the
// exact opposite case: the stage IS the background behind every line, and
// the copy layer carries the scrim that makes the line legible. Hiding
// them samples the page ground instead of the film and reports the whole
// page as failing while it looks fine.
if (el.closest("[data-sc-world],[data-sc-world-copy]")) return;
el.setAttribute("data-sc-shot-fixed", "");
});
});
await page.addStyleTag({
content: "[data-sc-cue],[data-sc-cue] *,[data-sc-copy],[data-sc-copy] *," +
"[data-sc-shot-fixed]{visibility:hidden!important}",
});
const bare = (await page.screenshot({ type: "jpeg", quality: 80 })).toString("base64");
const contrast = await page.evaluate(async ({ b64, dpr }) => {
const img = new Image();
img.src = "data:image/jpeg;base64," + b64;
await img.decode();
const c = document.createElement("canvas");
const g = c.getContext("2d", { willReadFrequently: true });
const lum = (r, gr, b) => {
const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
return 0.2126 * f(r) + 0.7152 * f(gr) + 0.0722 * f(b);
};
const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const cueOpacity = (el) => {
const o = parseFloat(getComputedStyle(el).opacity) || 0;
const units = el.querySelectorAll(".sc-split__i");
if (!units.length) return o;
let m = 0;
units.forEach((u) => { m = Math.max(m, parseFloat(getComputedStyle(u).opacity) || 0); });
return o * m;
};
const out = [];
document.querySelectorAll("[data-sc-cue],[data-sc-copy]").forEach((el) => {
if (cueOpacity(el) < 0.85) return;
if (!(el.textContent || "").trim()) return;
const r = el.getBoundingClientRect();
if (r.width < 8 || r.height < 8 || r.bottom < 0 || r.top > innerHeight) return;
// Clamp the sampled rect to the viewport. The part of a pinned act's copy
// that has scrolled above the fold is not on screen, so whatever sits in
// those pixels is not the background behind anything the reader can see.
const vl = Math.max(0, r.left), vt = Math.max(0, r.top);
const vr = Math.min(innerWidth, r.right), vb = Math.min(innerHeight, r.bottom);
if (vr - vl < 8 || vb - vt < 8) return;
const x = vl * dpr, y2 = vt * dpr;
const w = Math.min((vr - vl) * dpr, img.width - x), h = Math.min((vb - vt) * dpr, img.height - y2);
if (w < 2 || h < 2) return;
c.width = 32; c.height = 16;
g.drawImage(img, x, y2, w, h, 0, 0, 32, 16);
const d = g.getImageData(0, 0, 32, 16).data;
let maxL = 0, minL = 1, sum = 0, n = 0;
for (let k = 0; k < d.length; k += 4) {
const L = lum(d[k], d[k + 1], d[k + 2]);
if (L > maxL) maxL = L;
if (L < minL) minL = L;
sum += L; n++;
}
const cs = getComputedStyle(el);
const fg = cs.color.match(/[\d.]+/g).map(Number);
const fl = lum(fg[0], fg[1], fg[2]);
// An element that paints its own opaque background (a button, a chip) is
// an ordinary static contrast case: grade its text against that fill, not
// against whatever the page happens to show behind it. Hiding the element
// to sample the backdrop necessarily hides its background too, so without
// this every solid CTA reports a spurious failure.
const bg = (cs.backgroundColor.match(/[\d.]+/g) || []).map(Number);
const opaqueBg = bg.length >= 3 && (bg.length < 4 || bg[3] > 0.5);
if (opaqueBg) {
const bl = lum(bg[0], bg[1], bg[2]);
out.push({
t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),
dir: "own-fill",
worst: +ratio(fl, bl).toFixed(2), mean: +ratio(fl, bl).toFixed(2),
});
return;
}
// Pick the direction from the foreground. Light type on a dark page fails
// on the brightest patch under it; dark type on a light page (a high-key
// world, ink over media) fails on the DARKEST patch, and grading that
// against maxL is the most lenient reading available, so a page can report
// clean over text that is failing. Compare the ink to the mean background
// and grade against whichever extreme is on the ink's own side.
const meanL = sum / n;
const dark = fl < meanL;
out.push({
t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),
dir: dark ? "dark-on-light" : "light-on-dark",
worst: +ratio(fl, dark ? minL : maxL).toFixed(2),
mean: +ratio(fl, meanL).toFixed(2),
});
});
return out;
}, { b64: bare, dpr: 2 });
await page.evaluate(() => {
const t = [...document.querySelectorAll("style")].pop();
if (t && t.textContent.includes("data-sc-cue")) t.remove();
document.querySelectorAll("[data-sc-shot-fixed]").forEach((el) => el.removeAttribute("data-sc-shot-fixed"));
});
report.push({ i, y, pct: +(p * 100).toFixed(0), settled, contrast, ...state });
if (WORLD) {
console.log(` ${name} settled=${settled} copy=${state.cues.length} leg=${state.seg}@${state.segp} ` +
`legs=${state.segs.map((g) => `${g.op > 0.002 ? (g.painted ? g.t : "poster") : "-"}${g.op > 0.002 && g.op < 0.998 ? "*" + g.op : ""}`).join(",")}`);
} else {
console.log(` ${name} settled=${settled} cues=${state.cues.length} clips=${state.clips.map((c) => (c.painted ? c.t : "poster")).join(",")}`);
}
}
fs.writeFileSync(path.join(OUT, "report.json"), JSON.stringify({ doc, report, consoleErrors, failed }, null, 2));
if (consoleErrors.length) console.log("\nCONSOLE ERRORS:\n " + consoleErrors.join("\n "));
if (failed.length) console.log("\nFAILED REQUESTS:\n " + failed.join("\n "));
// Dead-scroll detector: consecutive positions where nothing visibly changed.
// This is the failure the eye misses and the reason to shoot contiguously.
// Opacity counts as change: a cue mid-fade is motion, and comparing only which
// cues exist would call a crossfade dead.
const sig = (s) => JSON.stringify([
s.cues.map((c) => c.t + ":" + c.o),
s.clips.map((c) => c.t),
s.rails,
s.wipes,
s.stages,
s.custom || [],
]);
// Only inside pinned acts. A flow section or a footer that holds still across
// two sample positions is a page behaving correctly, not dead scroll, and
// flagging it trains you to ignore the signal.
const PINNED = new Set(["scrub", "pin", "pan"]);
const dead = [];
if (WORLD) {
// Every pixel of a worldflight track is pinned by construction, so there is
// no "correctly still" region to exclude and the whole page is fair game.
// Three independent things can carry the motion: the film advancing, a leg
// dissolving into the next, and a copy window opening or closing. Dead scroll
// is all three holding at once.
const wsig = (s) => JSON.stringify([
s.segs.map((g) => g.t),
s.segs.map((g) => g.op),
s.cues.map((c) => c.t + ":" + c.o),
]);
// Not under reduced motion. There the film is deliberately never fetched, so
// the middle of a leg holds a single still frame and every pair of samples in
// it is identical BY DESIGN. Flagging that reports the accessibility path as
// broken every single run, which is how a real finding gets ignored. What
// matters here is whether the story still reads, and the copy-window and
// contrast passes below answer that.
for (let i = 1; !REDUCED && i < report.length; i++) {
const a = report[i - 1], b = report[i];
// Tighter than the act gate: a leg is about one viewport of scroll, so a
// quarter-viewport window would only ever compare four points per leg.
if (b.y - a.y < doc.vh * 0.12) continue;
if (wsig(a) === wsig(b)) {
dead.push(`${a.pct}% -> ${b.pct}% (leg ${a.seg} > ${b.seg})`);
}
}
} else {
for (let i = 1; i < report.length; i++) {
const a = report[i - 1], b = report[i];
const hasCustomState = (a.custom?.length || 0) > 0 || (b.custom?.length || 0) > 0;
if (!PINNED.has(a.act) && !PINNED.has(b.act) && !hasCustomState) continue;
// A page may explicitly declare an authored hold, such as a resolved close
// or the stable accessibility frame under reduced motion. It has to be
// declared by the visible stage; ordinary flow content stays exempt as it
// was before this custom-state path existed.
if (a.customHold && b.customHold) continue;
// Two samples a few dozen pixels apart SHOULD look the same. Only flag a gap
// wide enough that a reader would notice nothing happening in it.
if (b.y - a.y < doc.vh * 0.25) continue;
if (sig(a) === sig(b)) dead.push(`${a.pct}% -> ${b.pct}% (${a.act} > ${b.act})`);
}
}
console.log(dead.length ? `\nDEAD SCROLL between: ${dead.join(", ")}`
: WORLD && REDUCED ? "\ndead-scroll check skipped: reduced motion holds each leg on one still frame by design"
: "\nno dead scroll detected");
// FROZEN CLIP. The reader is scrolling, a scrub stage is on screen, and its
// playhead is not moving: a still photograph sliding up the page. Dead scroll
// cannot see this, because the stage IS moving, which is the whole problem.
//
// A hold on the first or last frame is always a defect. A hold in the middle
// can be an intentional `dwell` settle, so it only counts once it outlasts one.
// Skipped under reduced motion, where no clip is ever fetched on purpose.
if (!REDUCED) {
const nClips = report[0]?.clips?.length || 0;
const VIS = 0.55, EPS = 0.012, MIN = doc.vh * 0.15;
const frozen = [];
for (let c = 0; c < nClips; c++) {
let run = null;
const flush = () => {
if (!run) return;
const kind = run.t < 0.05 ? "entry" : (run.dur && run.t > run.dur - 0.08 ? "exit" : "mid");
const need = kind === "mid" ? doc.vh * 0.5 : MIN;
if (run.to - run.from >= need) frozen.push({ c, kind, ...run });
run = null;
};
for (let i = 1; i < report.length; i++) {
const a = report[i - 1].clips?.[c], b = report[i].clips?.[c];
if (!a || !b) { flush(); continue; }
const seen = a.vis >= VIS && b.vis >= VIS;
const stuck = Math.abs(b.t - a.t) < EPS;
if (seen && stuck && b.painted) {
if (!run) run = { from: report[i - 1].y, to: report[i].y, t: b.t, dur: b.dur };
else run.to = report[i].y;
} else flush();
}
flush();
}
if (frozen.length) {
console.log("\nFROZEN CLIP (still image while the page moves):\n " + frozen.map((f) => {
const px = f.to - f.from;
const where = f.kind === "entry" ? "held on its FIRST frame while the stage slides in"
: f.kind === "exit" ? "held on its LAST frame while the stage slides out"
: `held mid-clip at ${f.t.toFixed(2)}s, longer than a dwell settle`;
return `clip ${f.c}: ${px}px (${(px / doc.vh).toFixed(2)} viewports) ${where}`;
}).join("\n ") + "\n Fix: let the clip map across the stage's whole visible life. That is the\n engine default; data-sc-clip-map=\"travel\" turns it off. See devices.md.");
} else if (nClips) {
console.log(`all ${nClips} scrub clip(s) keep moving whenever they are on screen`);
}
}
// Worldflight findings. A leg that never reaches full opacity is a weight or a
// seam that is wrong: the reader is shown a permanent dissolve between two
// clips and never the leg itself. A leg stuck on its poster is a clip that
// never loaded or never decoded, and it passes every other check on this page
// because a poster looks exactly like a paused film.
if (WORLD) {
const segPeak = {};
report.forEach((s) => (s.segs || []).forEach((g) => {
const k = `${g.i}${g.label ? ' "' + g.label + '"' : ""}`;
segPeak[k] = segPeak[k] || { op: 0, painted: false, hasClip: g.t !== null };
segPeak[k].op = Math.max(segPeak[k].op, g.op);
segPeak[k].painted = segPeak[k].painted || g.painted;
}));
const faint = Object.entries(segPeak).filter(([, v]) => v.op < 0.99);
// Under reduced motion no clip is ever fetched, on purpose. Every leg is
// legitimately on its poster, and reporting that as a fault buries the one
// finding this pass exists for: whether the story still reads without motion.
const posters = REDUCED ? [] : Object.entries(segPeak).filter(([, v]) => v.hasClip && !v.painted);
if (faint.length) console.log("\nLEGS THAT NEVER REACH FULL OPACITY:\n " +
faint.map(([k, v]) => `${v.op.toFixed(2)} leg ${k}`).join("\n "));
if (posters.length) console.log("\nLEGS STUCK ON POSTER (clip never painted):\n " +
posters.map(([k]) => `leg ${k}`).join("\n "));
if (!faint.length && !posters.length)
console.log(`all ${Object.keys(segPeak).length} legs reach full opacity` +
(REDUCED ? " (posters only, as reduced motion requires)" : " and paint a real frame"));
}
// Cues that never reach full strength anywhere on the page. A headline peaking
// at 0.6 is a mis-set cue window, and it is invisible as a bug because the
// element IS there, just never quite arriving.
const peak = {};
report.forEach((s) => s.cues.forEach((c) => { peak[c.t] = Math.max(peak[c.t] || 0, c.o); }));
const weak = Object.entries(peak).filter(([, o]) => o < 0.8);
if (weak.length) console.log("\nCUES THAT NEVER PEAK:\n " + weak.map(([t, o]) => `${o} "${t}"`).join("\n "));
// Contrast over media, graded at the worst frame each line is ever shown on.
const worstBy = {};
report.forEach((s) => (s.contrast || []).forEach((c) => {
if (!worstBy[c.t] || c.worst < worstBy[c.t].worst) worstBy[c.t] = c;
}));
const fails = Object.values(worstBy).filter((c) => c.worst < 3);
const thin = Object.values(worstBy).filter((c) => c.worst >= 3 && c.worst < 4.5);
if (fails.length) console.log("\nCONTRAST FAIL (worst frame < 3:1):\n " +
fails.map((c) => `${c.worst}:1 (mean ${c.mean}) "${c.t}"`).join("\n "));
if (thin.length) console.log("\nCONTRAST THIN (3:1 to 4.5:1, ok for large display type only):\n " +
thin.map((c) => `${c.worst}:1 "${c.t}"`).join("\n "));
if (!fails.length && !thin.length && Object.keys(worstBy).length)
console.log("\ncontrast over media: all cues clear 4.5:1 at their worst frame");
await browser.close();
// Contact sheet. The point of shooting contiguously is to look at the frames
// side by side; a folder of 20 PNGs does not get looked at that way.
const FFMPEG = [
process.env.SCROLLCRAFT_FFMPEG,
...(fs.existsSync(path.join(process.env.HOME || "", "AppData/Local/Microsoft/WinGet/Packages"))
? fs.readdirSync(path.join(process.env.HOME, "AppData/Local/Microsoft/WinGet/Packages"))
.filter((d) => d.startsWith("Gyan.FFmpeg"))
.flatMap((d) => {
const base = path.join(process.env.HOME, "AppData/Local/Microsoft/WinGet/Packages", d);
return fs.readdirSync(base).map((v) => path.join(base, v, "bin/ffmpeg.exe"));
})
: []),
"/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg", "ffmpeg",
].find((p) => p && (p === "ffmpeg" || fs.existsSync(p)));
if (FFMPEG) {
const cols = Math.min(5, report.length);
const rows = Math.ceil(report.length / cols);
const { spawnSync } = await import("node:child_process");
const r = spawnSync(FFMPEG, [
"-y", "-v", "error", "-i", path.join(OUT, "%02d.png"),
"-vf", `scale=520:-1,tile=${cols}x${rows}`, "-frames:v", "1",
path.join(OUT, "sheet.png"),
]);
if (r.status === 0) console.log(`contact sheet: ${path.join(OUT, "sheet.png")}`);
else console.log("contact sheet skipped (needs a full ffmpeg build; scale/tile are missing from stripped ones)");
}
console.log(`\nshots + report.json in ${OUT}`);

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* Resolve the scrollcraft workspace: the one directory that holds this user's
* builds and their fingerprint registry.
*
* The skill used to hardcode one author's repo layout, which meant the
* fingerprint gate dead-ended on every other machine. Nothing is hardcoded now.
* Resolution order, first hit wins:
*
* 1. SCROLLCRAFT_HOME env var, absolute or relative to cwd
* 2. .scrollcraft.json nearest one walking up from cwd,
* { "workspace": "some/path" } relative to
* the file that declares it
* 3. <project root>/scrollcraft project root = nearest ancestor with .git,
* otherwise cwd
*
* USAGE
* node scripts/workspace.mjs print the workspace path
* node scripts/workspace.mjs --json print every resolved path
* node scripts/workspace.mjs --ensure create it and seed the registry
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SKILL = path.resolve(HERE, "..");
function findUp(startDir, predicate) {
let dir = path.resolve(startDir);
for (let i = 0; i < 12; i++) {
const hit = predicate(dir);
if (hit) return { dir, hit };
const up = path.dirname(dir);
if (up === dir) break;
dir = up;
}
return null;
}
export function resolveWorkspace(cwd = process.cwd()) {
if (process.env.SCROLLCRAFT_HOME) {
return { root: path.resolve(cwd, process.env.SCROLLCRAFT_HOME), via: "SCROLLCRAFT_HOME" };
}
const cfg = findUp(cwd, (d) => {
const p = path.join(d, ".scrollcraft.json");
return fs.existsSync(p) ? p : null;
});
if (cfg) {
let parsed = {};
try {
parsed = JSON.parse(fs.readFileSync(cfg.hit, "utf8"));
} catch (e) {
throw new Error(`.scrollcraft.json is not valid JSON: ${cfg.hit}\n ${e.message}`);
}
if (parsed.workspace) {
return { root: path.resolve(cfg.dir, parsed.workspace), via: cfg.hit };
}
}
const git = findUp(cwd, (d) => (fs.existsSync(path.join(d, ".git")) ? d : null));
const base = git ? git.hit : path.resolve(cwd);
return { root: path.join(base, "scrollcraft"), via: git ? "project root (.git)" : "cwd" };
}
export function paths(cwd = process.cwd()) {
const { root, via } = resolveWorkspace(cwd);
return {
via,
workspace: root,
builds: path.join(root, "builds"),
lab: path.join(root, "lab"),
fingerprints: path.join(root, "FINGERPRINTS.md"),
};
}
export function ensure(cwd = process.cwd()) {
const p = paths(cwd);
fs.mkdirSync(p.builds, { recursive: true });
fs.mkdirSync(p.lab, { recursive: true });
let seeded = false;
if (!fs.existsSync(p.fingerprints)) {
const seed = path.join(SKILL, "templates", "FINGERPRINTS.md");
fs.copyFileSync(seed, p.fingerprints);
seeded = true;
}
return { ...p, seeded };
}
// ------------------------------------------------------------------- cli ----
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
const argv = process.argv.slice(2);
const p = argv.includes("--ensure") ? ensure() : paths();
if (argv.includes("--json")) {
console.log(JSON.stringify(p, null, 2));
} else if (argv.includes("--ensure")) {
console.log(p.workspace);
console.error(` builds ${p.builds}`);
console.error(` registry ${p.fingerprints}${p.seeded ? " (seeded, empty)" : " (already present)"}`);
console.error(` resolved via ${p.via}`);
} else {
console.log(p.workspace);
}
}

View File

@@ -0,0 +1,273 @@
#!/usr/bin/env node
/**
* Worldflight rig assertions. Not a contact sheet: these are the mechanical
* claims the mode makes, checked one at a time.
*
* node lab/worldflight-assert.mjs --url http://localhost:4520
*/
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
const { chromium } = createRequire(path.join(process.cwd(), "package.json"))("playwright-core");
const argv = process.argv.slice(2);
const arg = (n, d) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
const URL = arg("--url", "http://localhost:4520");
const CHROME = [
process.env.SCROLLCRAFT_CHROME,
// Windows
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
// macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
// Linux
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
].find((p) => p && fs.existsSync(p));
let pass = 0, fail = 0;
const ok = (name, cond, note = "") => {
if (cond) { pass++; console.log(` PASS ${name}${note ? " " + note : ""}`); }
else { fail++; console.log(` FAIL ${name}${note ? " " + note : ""}`); }
};
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
// ============================================================ desktop ======
{
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
const errs = [];
page.on("console", (m) => { if (m.type() === "error") errs.push(m.text()); });
page.on("pageerror", (e) => errs.push(String(e)));
await page.goto(URL, { waitUntil: "domcontentloaded" });
await page.waitForSelector("html.sc-ready", { timeout: 15000 });
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(900);
const geom = await page.evaluate(() => {
const segs = [...document.querySelectorAll("[data-sc-segment]")];
const total = segs.reduce((s, el) => s + (parseFloat(el.dataset.scW) || 1.3), 0);
const spacer = document.querySelector("[data-sc-spacer]");
const stage = document.querySelector("[data-sc-world]");
return {
vh: innerHeight, total,
weights: segs.map((el) => parseFloat(el.dataset.scW) || 1.3),
spacerH: spacer.getBoundingClientRect().height,
wantH: Math.round((total + 1) * innerHeight),
docH: document.body.scrollHeight,
stagePos: getComputedStyle(stage).position,
stageRect: (({ top, left, width, height }) => ({ top, left, width, height }))(stage.getBoundingClientRect()),
copyPos: getComputedStyle(document.querySelector("[data-sc-world-copy]")).position,
seam: parseFloat(document.querySelector('[data-sc-mode="worldflight"]').dataset.scSeam),
};
});
console.log(`\nDESKTOP vh=${geom.vh} weights=[${geom.weights}] total=${geom.total}vh`);
ok("spacer height = (sum weights + 1) x vh",
Math.abs(geom.spacerH - geom.wantH) <= 1, `got ${Math.round(geom.spacerH)}px want ${geom.wantH}px`);
ok("document scroll height is the spacer",
Math.abs(geom.docH - geom.wantH) <= 2, `doc ${geom.docH}px`);
ok("stage is position:fixed and fills the viewport",
geom.stagePos === "fixed" && geom.stageRect.height === geom.vh && geom.stageRect.top === 0);
ok("copy layer is position:fixed", geom.copyPos === "fixed");
// Nothing in flow but the spacer: at any scroll position, the only static or
// relative element that extends past the fold is the spacer and its ancestors.
const flow = await page.evaluate(() => {
const bad = [];
document.querySelectorAll("body *").forEach((el) => {
const cs = getComputedStyle(el);
if (cs.position === "fixed" || cs.position === "absolute") return;
if (el.hasAttribute("data-sc-spacer") || el.hasAttribute("data-sc-mode")) return;
const r = el.getBoundingClientRect();
if (r.height > 4 && r.bottom > innerHeight + 4) {
bad.push(el.tagName + "." + (el.className || "").toString().slice(0, 30));
}
});
return bad;
});
ok("nothing in document flow but the spacer", flow.length === 0, flow.join(", "));
// ---- lerp convergence, inside one leg ----------------------------------
const seg0 = geom.weights[0];
const A = Math.round(0.10 * seg0 * geom.vh);
const B = Math.round(0.85 * seg0 * geom.vh);
await page.evaluate((y) => scrollTo({ top: y, behavior: "instant" }), A);
await page.waitForTimeout(1400);
const trace = await page.evaluate(({ y }) => new Promise((res) => {
const v = document.querySelectorAll("[data-sc-segment] video")[0];
const clip = window.__sc.clips.find((c) => c.el === v);
const s = [];
scrollTo({ top: y, behavior: "instant" });
let n = 0;
(function f() {
s.push(+v.currentTime.toFixed(4));
if (++n < 70) requestAnimationFrame(f);
else res({ s, target: clip.target * (v.duration || 1), dur: v.duration, lerp: clip.lerp });
})();
}), { y: B });
const seq = trace.s;
const start = seq[0], end = seq[seq.length - 1];
const distinct = [...new Set(seq)];
const monotone = seq.every((v, i) => i === 0 || v >= seq[i - 1] - 1e-6);
const overshoot = Math.max(...seq) - trace.target;
console.log(` playhead ${start.toFixed(3)}s -> ${end.toFixed(3)}s target ${trace.target.toFixed(3)}s lerp ${trace.lerp} ${distinct.length} distinct values`);
ok("playhead is lerped, not written 1:1", distinct.length >= 4, `${distinct.length} distinct steps across 70 frames`);
ok("playhead is strictly monotone toward its target", monotone);
ok("playhead does not overshoot", overshoot <= 0.02, `max excess ${overshoot.toFixed(4)}s`);
ok("playhead converges on the target", Math.abs(end - trace.target) < 0.05,
`residual ${Math.abs(end - trace.target).toFixed(4)}s`);
ok("lerp rate is the 0.18 default", Math.abs(trace.lerp - 0.18) < 1e-9);
// ---- deadband: no seek thrash while the page is still -------------------
await page.waitForTimeout(900);
const seeks = await page.evaluate(() => new Promise((res) => {
let n = 0;
const vs = [...document.querySelectorAll("video")];
const h = () => n++;
vs.forEach((v) => v.addEventListener("seeked", h));
setTimeout(() => { vs.forEach((v) => v.removeEventListener("seeked", h)); res(n); }, 1000);
}));
ok("deadband holds: no seeks over 1s of stillness", seeks === 0, `${seeks} seeked events`);
// ---- crossfade band ----------------------------------------------------
const boundary = seg0 * geom.vh;
const half = (geom.seam / 2) * geom.vh;
const band = [];
for (let i = 0; i < 5; i++) {
const y = Math.round(boundary - half + (2 * half) * (i / 4));
await page.evaluate((y) => scrollTo({ top: y, behavior: "instant" }), y);
await page.waitForTimeout(120);
band.push(await page.evaluate(() => {
const segs = [...document.querySelectorAll("[data-sc-segment]")];
return segs.map((s) => +(parseFloat(getComputedStyle(s).opacity) || 0).toFixed(4));
}));
}
const incoming = band.map((b) => b[1]);
const outgoing = band.map((b) => b[0]);
console.log(` seam band incoming=[${incoming}] outgoing=[${outgoing}]`);
ok("incoming leg opacity is strictly monotone across the seam",
incoming.every((v, i) => i === 0 || v > incoming[i - 1]));
ok("incoming leg starts at 0 and ends at 1", incoming[0] <= 0.02 && incoming[4] >= 0.98);
// The outgoing leg must hold at full strength for the whole dissolve and only
// release once the incoming one covers it completely. A crossfade where BOTH
// sides are partly transparent shows the page ground through the middle of
// the seam, which is the flash this mode exists to remove.
ok("outgoing leg holds full opacity while the incoming one is still arriving",
incoming.every((v, i) => v >= 0.999 || outgoing[i] >= 0.999), `[${outgoing}]`);
ok("outgoing leg only releases once it is fully covered",
outgoing[4] === 0 && incoming[4] >= 0.999);
// ---- copy transform cap ------------------------------------------------
const maxT = [];
for (let i = 0; i <= 12; i++) {
const y = Math.round((geom.docH - geom.vh) * (i / 12));
await page.evaluate((y) => scrollTo({ top: y, behavior: "instant" }), y);
await page.waitForTimeout(60);
maxT.push(await page.evaluate(() => {
let m = 0;
document.querySelectorAll("[data-sc-copy]").forEach((el) => {
const t = new DOMMatrixReadOnly(getComputedStyle(el).transform);
m = Math.max(m, Math.abs(t.m42), Math.abs(t.m41));
});
return +m.toFixed(2);
}));
}
const worstT = Math.max(...maxT);
ok("copy never translates past 4vh", worstT <= geom.vh * 0.04 + 1,
`worst ${worstT}px, cap ${(geom.vh * 0.04).toFixed(0)}px`);
// ---- every leg reaches full opacity, every clip leaves its poster -------
const reach = {};
for (let i = 0; i <= 24; i++) {
const y = Math.round((geom.docH - geom.vh) * (i / 24));
await page.evaluate((y) => scrollTo({ top: y, behavior: "instant" }), y);
await page.waitForTimeout(260);
const s = await page.evaluate(() => [...document.querySelectorAll("[data-sc-segment]")].map((el, i) => ({
i, op: parseFloat(getComputedStyle(el).opacity) || 0,
painted: el.classList.contains("sc-has-clip"),
})));
s.forEach((x) => {
reach[x.i] = reach[x.i] || { op: 0, painted: false };
reach[x.i].op = Math.max(reach[x.i].op, x.op);
reach[x.i].painted = reach[x.i].painted || x.painted;
});
}
ok("every leg reaches full opacity",
Object.values(reach).every((r) => r.op >= 0.99),
Object.entries(reach).map(([i, r]) => `${i}:${r.op.toFixed(2)}`).join(" "));
ok("every leg paints a real frame (no clip stuck on poster)",
Object.values(reach).every((r) => r.painted),
Object.entries(reach).map(([i, r]) => `${i}:${r.painted ? "clip" : "POSTER"}`).join(" "));
ok("no console errors", errs.length === 0, errs.slice(0, 3).join(" | "));
await page.close();
}
// ==================================================== reduced motion =======
{
const page = await browser.newPage({
viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, reducedMotion: "reduce",
});
const media = [];
page.on("request", (r) => { if (/\.(mp4|webm)(\?|$)/.test(r.url())) media.push(r.url()); });
await page.goto(URL, { waitUntil: "domcontentloaded" });
await page.waitForSelector("html.sc-ready", { timeout: 15000 });
await page.waitForTimeout(800);
console.log("\nREDUCED MOTION");
const rmSeen = { legs: {}, copy: {} };
for (let i = 0; i <= 20; i++) {
const h = await page.evaluate(() => document.body.scrollHeight - innerHeight);
await page.evaluate((y) => scrollTo({ top: y, behavior: "instant" }), Math.round(h * (i / 20)));
await page.waitForTimeout(90);
const s = await page.evaluate(() => ({
legs: [...document.querySelectorAll("[data-sc-segment]")].map((el) => ({
op: parseFloat(getComputedStyle(el).opacity) || 0,
poster: (() => { const p = el.querySelector(".sc-world__poster"); return p ? getComputedStyle(p).transform : "none"; })(),
})),
copy: [...document.querySelectorAll("[data-sc-copy]")].map((el) => ({
t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 24),
op: parseFloat(getComputedStyle(el).opacity) || 0,
tf: getComputedStyle(el).transform,
})),
}));
s.legs.forEach((l, k) => {
rmSeen.legs[k] = rmSeen.legs[k] || { op: 0, tf: new Set() };
rmSeen.legs[k].op = Math.max(rmSeen.legs[k].op, l.op);
rmSeen.legs[k].tf.add(l.poster);
});
s.copy.forEach((c) => {
rmSeen.copy[c.t] = rmSeen.copy[c.t] || { op: 0, tf: new Set() };
rmSeen.copy[c.t].op = Math.max(rmSeen.copy[c.t].op, c.op);
rmSeen.copy[c.t].tf.add(c.tf);
});
}
await page.waitForTimeout(500);
ok("no clip is ever fetched", media.length === 0, media.join(", "));
ok("every leg's poster still reaches full opacity",
Object.values(rmSeen.legs).every((l) => l.op >= 0.99),
Object.entries(rmSeen.legs).map(([i, l]) => `${i}:${l.op.toFixed(2)}`).join(" "));
ok("no poster transform",
Object.values(rmSeen.legs).every((l) => [...l.tf].every((t) => t === "none")));
ok("every copy block still reaches full opacity",
Object.values(rmSeen.copy).every((c) => c.op >= 0.99),
Object.entries(rmSeen.copy).map(([t, c]) => `${c.op.toFixed(2)} "${t}"`).join(" | "));
ok("no copy transform",
Object.values(rmSeen.copy).every((c) => [...c.tf].every((t) => t === "none")));
await page.close();
}
await browser.close();
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);

View File

@@ -0,0 +1,65 @@
# Fingerprints
Every site you build with **scrollcraft** gets one row here, appended after it
ships. The registry exists so your next build can prove it is a different page
rather than a re-skin of one you already made.
This file is **yours**. It starts empty on purpose: the gate is about not
repeating *yourself*, so it has nothing to say until you have built something.
The rules and the gate live in the skill's
`references/uniqueness.md`. Short version:
**A new build must differ from EVERY row below on at least 4 of the 6
dimensions.** Four against each row individually, not four on average across the
table. If a planned build fails, change the plan. Never edit a row to make room
for it.
The six dimensions are: **grammar**, **nav treatment**, **hero device**,
**act-sequence shape**, **close pattern**, **signature move**.
Dimension 6 is free, because a signature move is unique by definition. So the
gate really asks for three more out of the remaining five, and a build that
changes only grammar and world will fail it.
---
## The registry
| Build | Grammar | Nav treatment | Hero device | Act-sequence shape | Close pattern | Signature move | World | Port |
|---|---|---|---|---|---|---|---|---|
*(empty: your first build has nothing to clear, so build whatever the interview
points at. From the second onwards, this table is the constraint.)*
---
## What is taken
Add a bullet here whenever a build claims something a later build should avoid
reusing: a grammar, a nav treatment, a close pattern, a signature move, an
act-count-and-length band. The shared columns are what the next build inherits
as a constraint, so writing them down is the whole point.
Nothing is taken yet.
---
## Appending a row
After shipping, add one line to the table and one bullet to **What is taken** if
the build claimed something new. Fill every column. Say what the build shares
with existing rows.
Rows are append-only. A build that has been superseded stays in the table,
because the space it occupies is still occupied.
---
## Worked example
The skill's author kept a registry of twelve builds across eight page grammars.
If you want to see what a filled-in table looks like, and which shapes tend to
collide, read `EXAMPLES.md` in the upstream scroll-craft repository *(upstream repo — not vendored in this port)*. Treat it as
illustration only: those rows are somebody else's builds and they do **not**
constrain yours.

View File

@@ -0,0 +1,92 @@
"""Tests for the scrollcraft optional skill (ported from nateherkai/scroll-craft)."""
import re
from pathlib import Path
import yaml
_STAGING = Path(__file__).resolve().parents[2] / "optional-skills/web-development/scrollcraft"
_FALLBACK = (
Path.home() / ".hermes/hermes-agent/optional-skills/web-development/scrollcraft"
)
SKILL_DIR = _STAGING if _STAGING.is_dir() else _FALLBACK
SKILL_MD = SKILL_DIR / "SKILL.md"
def _frontmatter() -> dict:
text = SKILL_MD.read_text(encoding="utf-8")
match = re.match(r"\A---\n(.*?)\n---\n", text, re.DOTALL)
assert match, "SKILL.md must start with YAML frontmatter"
data = yaml.safe_load(match.group(1))
assert isinstance(data, dict)
return data
def test_frontmatter_parses():
fm = _frontmatter()
assert fm["name"] == "scrollcraft"
def test_description_length_and_period():
desc = _frontmatter()["description"]
assert isinstance(desc, str)
assert len(desc) <= 60, f"description is {len(desc)} chars"
assert desc.endswith(".")
def test_platforms_present():
fm = _frontmatter()
assert "platforms" in fm
assert isinstance(fm["platforms"], list) and fm["platforms"]
def test_license_is_mit():
assert _frontmatter()["license"] == "MIT"
def test_license_file_exists():
lic = SKILL_DIR / "LICENSE.txt"
assert lic.is_file()
assert "MIT License" in lic.read_text(encoding="utf-8")
def test_mentioned_paths_exist_or_marked_upstream():
"""Every vendored-tree relative path mentioned in SKILL.md exists on disk,
or its line is marked as upstream / not vendored."""
path_re = re.compile(
r"(?:references|engine|scripts|templates)/[\w.-]+\.(?:md|html|mjs|css|js)"
)
for line in SKILL_MD.read_text(encoding="utf-8").splitlines():
for rel in path_re.findall(line):
if (SKILL_DIR / rel).is_file():
continue
low = line.lower()
assert "upstream" in low or "not vendored" in low, (
f"SKILL.md mentions missing path {rel!r} without an "
f"upstream/not-vendored marker on the same line: {line!r}"
)
def test_no_foreign_agent_residue():
"""Prose files carry no upstream-agent-specific residue."""
residue = re.compile(r"claude|allowed-tools|AskUserQuestion", re.IGNORECASE)
prose = [SKILL_MD] + sorted((SKILL_DIR / "references").glob("*.md"))
for path in prose:
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
assert not residue.search(line), f"{path.name}:{lineno}: {line!r}"
def test_scripts_present_and_nonempty():
scripts_dir = SKILL_DIR / "scripts"
mjs = sorted(scripts_dir.glob("*.mjs"))
expected = {
"doctor.mjs",
"kie.mjs",
"serve.mjs",
"shoot.mjs",
"workspace.mjs",
"worldflight-assert.mjs",
}
assert {p.name for p in mjs} == expected
for p in mjs:
assert p.stat().st_size > 0, f"{p.name} is empty"

View File

@@ -280,6 +280,7 @@ hermes skills uninstall <skill-name>
| [**har-derived-api-client**](/docs/user-guide/skills/optional/web-development/web-development-har-derived-api-client) | Record a site's XHR into a HAR, derive an HTTP client. |
| [**page-agent**](/docs/user-guide/skills/optional/web-development/web-development-page-agent) | Embed an in-page natural-language GUI copilot in web apps. |
| [**publish-site**](/docs/user-guide/skills/optional/web-development/web-development-publish-site) | Versioned site deploys to GitHub/Cloudflare/Netlify Pages. |
| [**scrollcraft**](/docs/user-guide/skills/optional/web-development/web-development-scrollcraft) | Premium scroll-driven landing pages; scroll = timeline. |
## yuanbao

View File

@@ -0,0 +1,257 @@
---
title: "Scrollcraft — Premium scroll-driven landing pages; scroll = timeline"
sidebar_label: "Scrollcraft"
description: "Premium scroll-driven landing pages; scroll = timeline"
---
{/* 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. */}
# Scrollcraft
Premium scroll-driven landing pages; scroll = timeline.
## Skill metadata
| | |
|---|---|
| Source | Optional — install with `hermes skills install official/web-development/scrollcraft` |
| Path | `optional-skills/web-development/scrollcraft` |
| Version | `1.0.0` |
| Author | nateherkai (upstream scroll-craft), ported by Hermes Agent |
| License | MIT |
| Platforms | linux, macos, windows |
| Tags | `web-development`, `landing-page`, `scrollytelling`, `animation`, `design`, `frontend` |
## 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.
:::
# scrollcraft
Scroll is the only input every visitor already knows. This skill treats it as a
timeline: the wheel is a scrubber, the page is a film with real text on top,
and each section behaves differently enough that the visitor keeps going.
**What you produce:** an interview brief, a page grammar, a customer-journey
map, a feeling curve with one engineered peak, a scroll score, one signature
move, assets, one real HTML page on a token-driven design floor, and a strip of
screenshots proving it holds up at every scroll position.
Use for: "scrollytelling", "scroll animation site", "a site where scrolling
plays a video", "Apple-style landing page", "3D scroll world", "make my brand a
scroll experience", "this looks like a template", or any request for a site
that should feel like an experience rather than a document.
## What this is not
It is not "generate a flythrough and drop text on it." That produces one device
applied to a whole page, recognisable at a glance. Four spine rules:
1. **Variety is the product.** At least four device families, never the same
device twice in a row. Read [references/devices.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/devices.md).
2. **The world is photographic** unless the brand is genuinely illustrated.
Clay/low-poly diorama is banned as a default. Read [references/worlds.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/worlds.md).
3. **No continuous chain** unless the brief is literally "one continuous
journey" (then see [references/worldflight.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/worldflight.md)).
4. **A different world is not a different page.** Structure is a separate axis;
decide it deliberately. Read [references/uniqueness.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/uniqueness.md).
## Step 0: The interview
**Always ask the user in chat before building anything.** Real questions, asked
and answered in the conversation, written down — not a brief inferred from the
brand name. Eight questions in one pass:
1. **Vibe in three to five words**, plus up to three references from any medium
(film, album cover, shop, magazine, game — not "sites you like").
2. **The scroll journey, section by section, in their words.**
3. **The energy curve** — where calm, where intense.
4. **How should someone feel while scrolling, stage by stage, and what is the
ONE moment they should remember?** Becomes the feeling curve and the peak.
See [references/feel.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/feel.md).
5. **One thing this site should do that no site they have seen does** — the
seed of the signature move.
6. **How far from premium-minimal?** Offer the range in
[references/uniqueness.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/uniqueness.md) §5: brutalist,
maximalist, playful, retro, dense, editorial, premium-minimal.
7. **One unbroken world, or distinct scenes?** The biggest structural fork, and
it is their call.
8. **What assets do they already have?** Footage, photos, product shots, brand
kit. "Nothing" is fine and means a fully generated world.
Write the answers verbatim into `<workspace>/builds/<name>/BRIEF.md` (use
write_file) before any act planning. BRIEF.md must contain the eight answers,
the feeling curve (one line per act: emotion, then cause), the peak (as the
sentence a visitor would say to a friend), the completed "It's the site where
___" sentence, and any authored silence. If the user is genuinely unreachable
in a fully autonomous run, self-author BRIEF.md, mark it
`Self-authored, not interviewed`, and say so in the report.
## Bootstrap
Run the preflight rather than checking by hand (it catches a stripped ffmpeg
that reports missing filters as syntax errors):
```bash
node <skill>/scripts/doctor.mjs
node <skill>/scripts/workspace.mjs --ensure # prints workspace, seeds registry
```
Workspace resolution order: `SCROLLCRAFT_HOME` env var; nearest
`.scrollcraft.json` (`{ "workspace": "..." }`) walking up from cwd;
`<project root>/scrollcraft`. Builds live at `<workspace>/builds/<name>/`, the
fingerprint registry at `<workspace>/FINGERPRINTS.md` (seeded from
[templates/FINGERPRINTS.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/templates/FINGERPRINTS.md), starts empty — the gate
stops you repeating *yourself*).
Copy `engine/scrollcraft.js` and `engine/scrollcraft.css` into the build
folder. **Never edit the engine per-project.** Theme with tokens; write your
own markup. Bespoke behaviour is bespoke JS in the page, driven off `--sc-p`
and your own `data-sc-*` attributes.
## Step 1: The brief, journey first
Ask the subject open, in plain prose. Then ask only what Step 0 did not cover:
what is this and who is it for; the one sentence the page installs; the one
next action (one label, used everywhere); what they already have; art
direction from [references/worlds.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/worlds.md). Then write the
**journey**: four to seven beats, each a shift in what the visitor knows or
feels. Beats are the spine; a section serving no beat is cut. Confirm the
journey with the user before generating assets — assets are the expensive part.
## Step 2: Grammar, gate, then score
Full detail in [references/uniqueness.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/uniqueness.md).
- **Pick a grammar.** Eight, mutually exclusive. Choosing filmic one-shot means
saying in the report why the other seven lost. Nav, hero and close follow
from the grammar.
- **Invent the signature move.** One bespoke interaction coded in the page, not
a parameter change to a kit device. Interview question 5 is the seed.
- **Run the fingerprint gate.** The planned build must differ from every row in
`<workspace>/FINGERPRINTS.md` on at least 4 of 6 dimensions: grammar, nav
treatment, hero device, act-sequence shape, close pattern, signature move.
If it fails, change the plan, not the log.
- **Write the feeling curve before the score table** (method:
[references/feel.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/feel.md)). Then assign each beat a device in
a written table (beat / device / why).
Checks before building: grammar bans hold; 4+ device families; no device
twice in a row; at most two `scrub` acts; no two adjacent acts with the same
feeling; one peak with the largest span; total page length 8–14
viewport-heights.
## Step 3: Assets
Full pipeline, prompt scaffolds and model notes: [references/assets.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/assets.md).
**Hermes-native paths first:**
- **User-supplied footage and photos** — no key, no spend, a first-class route.
Grade and encode them.
- **The `image_generate` tool** for stills: one style preamble reused verbatim
in every prompt is what makes six images look like one shoot. Inspect every
asset (vision_analyze) before use; rerolling beats shipping a bad frame.
**Optional upstream path — kie.ai** (vendored verbatim as
[scripts/kie.mjs](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/scripts/kie.mjs)): photoreal stills and camera-move clips.
Requires the `KIE_AI_API_KEY` environment variable (export it in your shell;
there is no bundled env file in this port). Check balance with
`node <skill>/scripts/kie.mjs probe`; a still costs cents, a 5s clip more.
```bash
node <skill>/scripts/kie.mjs still "<style preamble>\n\n<scene>" out/01-hero.png --ar 16:9
node <skill>/scripts/kie.mjs shot "<camera move>" out/01-hero.png out/01.mp4 --dur 5
bash <skill>/scripts/encode.sh out/01.mp4 assets/01.mp4
bash <skill>/scripts/encode.sh out/01.mp4 assets/01-m.mp4 mobile
```
**Encode for scrubbing, not playback.** `encode.sh` sets a dense GOP because
seeking walks from the previous keyframe; a normal web encode scrubs like mud.
It also strips audio.
## Step 4: Build the page
Write real HTML — real `<h1>`, real `<p>`, real reading order. The engine reads
`data-sc-*` attributes off your markup and drives it; it never generates DOM.
Start from [references/template.html](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/template.html). Device
patterns: [references/devices.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/devices.md). Spacing, type, depth,
colour: [references/taste.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/taste.md) — read it before writing
markup. Theme by overriding tokens, six values and two fonts:
```css
:root {
--sc-canvas: #0A0806; --sc-surface: #16110E;
--sc-ink: #F5EBDD; --sc-ink-soft: #A2968A;
--sc-accent: #FF5A3D; --sc-accent-ink: #15110F;
--sc-font-display: "Archivo", system-ui, sans-serif;
--sc-font-text: "Geist", system-ui, sans-serif;
}
```
## Step 5: Verify by scrolling it
Not optional. Every scroll position is a different frame; failures live between
the two you looked at. Full procedure: [references/verify.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/verify.md).
```bash
cd <build project> && npm i playwright-core # once
node <skill>/scripts/serve.mjs --root . --port 4500 &
node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/shots
node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/mobile --width 390 --height 844
node <skill>/scripts/shoot.mjs --url http://localhost:4500 --out lab/reduced --reduced-motion
```
The harness walks each act at six positions, waits for scrub video to settle,
reports dead scroll, cues that never reach full opacity, and composited
contrast; it writes a contact sheet. Then read `sheet.png` yourself
(vision_analyze) — the harness proves a clip advances, not that the page means
anything. Run the feel check ([references/feel.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/feel.md) §6):
scroll cold, one word per act, diff against BRIEF.md. Where they disagree the
page is wrong, not the brief.
A green run does not cover a real phone (video decoder, autoplay policy, Low
Power Mode). On any reported mobile defect, deploy
[references/device-diag.html](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/web-development/scrollcraft/references/device-diag.html) beside the site on
the first round and let the device answer.
## Hard rules (ship-blockers)
No clay diorama default; no "scroll to explore" cues or animated mouse icons;
no `01 / 06` section counters; at most one eyebrow per three sections; no
visible em dashes; vary the copy anchor; no device twice in a row; never build
before the interview; one engineered peak, not zero or three; the close
resolves instead of fading to a footer; curve before devices; one bespoke
signature move; 4-of-6 fingerprint clearance against every row; never edit the
engine; no full-frame dark overlay for contrast (scrim only where text sits);
no text baked into images; no invented statistics; no `transition: all` or
animating width/height/top/left (`transform`/`opacity`; `clip-path` for
wipes); no gradient text or neon glow; no audio on scrub clips; never ship
without Step 5.
## Output
The build folder including BRIEF.md, then a short report: grammar and why the
other seven lost, signature move, fingerprint gate result per row, journey,
feeling curve and peak, feel-check diff, score table, what you generated, what
you verified with screenshots, and what you could not verify. Append the
build's row to `<workspace>/FINGERPRINTS.md`.
## Pitfalls
- `scripts/shoot.mjs` needs Playwright (`npm install playwright` or
`playwright-core` plus a Chrome install). Hermes' `browser_exec` tool is the
lighter alternative for scroll-screenshot verification: serve the build,
scroll in steps, capture screenshots, and inspect them yourself.
- `scripts/kie.mjs` needs `KIE_AI_API_KEY` and paid credit; prefer
`image_generate` or user assets when the budget is unclear.
- `encode.sh` and `doctor.mjs` expect a full ffmpeg build; distro-stripped
ffmpeg reports missing filters as command syntax errors — run
`scripts/doctor.mjs` first.
- Upstream script invocations above are copied from upstream docs and
unverified by this port beyond `node --check` syntax validation — trust
`--help`/source if drifted.
- The upstream repo ships worked examples and a change log that are not
vendored in this port; see the upstream repository if you want them.

View File

@@ -632,6 +632,7 @@ const sidebars: SidebarsConfig = {
'user-guide/skills/optional/web-development/web-development-har-derived-api-client',
'user-guide/skills/optional/web-development/web-development-page-agent',
'user-guide/skills/optional/web-development/web-development-publish-site',
'user-guide/skills/optional/web-development/web-development-scrollcraft',
],
},
{