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:
21
optional-skills/web-development/scrollcraft/LICENSE.txt
Normal file
21
optional-skills/web-development/scrollcraft/LICENSE.txt
Normal 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.
|
||||
242
optional-skills/web-development/scrollcraft/SKILL.md
Normal file
242
optional-skills/web-development/scrollcraft/SKILL.md
Normal 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.
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
1167
optional-skills/web-development/scrollcraft/engine/scrollcraft.js
Normal file
1167
optional-skills/web-development/scrollcraft/engine/scrollcraft.js
Normal file
File diff suppressed because it is too large
Load Diff
286
optional-skills/web-development/scrollcraft/references/assets.md
Normal file
286
optional-skills/web-development/scrollcraft/references/assets.md
Normal 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.
|
||||
@@ -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) B: suspect clip, direct file 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>
|
||||
@@ -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.
|
||||
277
optional-skills/web-development/scrollcraft/references/feel.md
Normal file
277
optional-skills/web-development/scrollcraft/references/feel.md
Normal 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.
|
||||
304
optional-skills/web-development/scrollcraft/references/taste.md
Normal file
304
optional-skills/web-development/scrollcraft/references/taste.md
Normal 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.
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
381
optional-skills/web-development/scrollcraft/references/verify.md
Normal file
381
optional-skills/web-development/scrollcraft/references/verify.md
Normal 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.
|
||||
@@ -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.
|
||||
178
optional-skills/web-development/scrollcraft/references/worlds.md
Normal file
178
optional-skills/web-development/scrollcraft/references/worlds.md
Normal 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.
|
||||
177
optional-skills/web-development/scrollcraft/scripts/doctor.mjs
Normal file
177
optional-skills/web-development/scrollcraft/scripts/doctor.mjs
Normal 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); }
|
||||
}
|
||||
@@ -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}"
|
||||
202
optional-skills/web-development/scrollcraft/scripts/kie.mjs
Normal file
202
optional-skills/web-development/scrollcraft/scripts/kie.mjs
Normal 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);
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
644
optional-skills/web-development/scrollcraft/scripts/shoot.mjs
Normal file
644
optional-skills/web-development/scrollcraft/scripts/shoot.mjs
Normal 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}`);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
92
tests/skills/test_scrollcraft_skill.py
Normal file
92
tests/skills/test_scrollcraft_skill.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user