diff --git a/brand/generated-assets.json b/brand/generated-assets.json
index 450753c..b792bcc 100644
--- a/brand/generated-assets.json
+++ b/brand/generated-assets.json
@@ -4,12 +4,15 @@
"brand/source/video2ctx-logo-192.png": "44472cbe3e7ef39bc557278efc808ebc038effe90b96c8089387f5f5a6bf35ac",
"brand/source/video2ctx-favicon-32.png": "77ae28a2e39a6020442a3ba136145f52bb1155b195d453595cb8d597c083f38a",
"brand/source/video2ctx-favicon.ico": "2ce82549c6b86fa66fa8c1510b0c85a404c14aa9aadead05b02d02a74b6201e5",
+ "brand/source/video2ctx-og-background-hybrid-icons.png": "089fb9fe62cf1dac7c243b46462d547def9c89da9f3c747284bb8520ed19b783",
"web/public/brand/logo-120.png": "d44f423ec5b91a584ed87ef41ffcb0a73a6ec69b13bc5d852f9d589723e03829",
"web/public/android-chrome-192x192.png": "44472cbe3e7ef39bc557278efc808ebc038effe90b96c8089387f5f5a6bf35ac",
"web/public/android-chrome-512x512.png": "4b3126c170ddeaded8d18d63e65be8a80ee2f58763cfd78a9d04f35afb2991af",
"web/app/icon.png": "4b3126c170ddeaded8d18d63e65be8a80ee2f58763cfd78a9d04f35afb2991af",
"web/app/apple-icon.png": "0fe814ed4ab3d78fea8bdb723b67218d89ccb2fa0e0f9a5d3d386d04cff99133",
"web/app/favicon.ico": "2ce82549c6b86fa66fa8c1510b0c85a404c14aa9aadead05b02d02a74b6201e5",
+ "web/app/opengraph-image.jpg": "12b0a4c3335a37c1ad3154b2db4753e9afb9e68b35cb8a6cb911ebe33e72311a",
+ "web/app/twitter-image.jpg": "12b0a4c3335a37c1ad3154b2db4753e9afb9e68b35cb8a6cb911ebe33e72311a",
"docs/favicon.png": "77ae28a2e39a6020442a3ba136145f52bb1155b195d453595cb8d597c083f38a",
"docs/logo/light.svg": "5744539b3df809e3ba7a20d4a1faae4e0f59aad5b9e31f9b5b2973c3f1c438d4",
"docs/logo/dark.svg": "9e72ae6153580322d7a42c2690cb341aa14fcdc7953bc69e5573a37c2f5bf0e5",
diff --git a/web/public/scene/fold-scene-poster.webp b/brand/generated/fold-scene-poster.webp
similarity index 100%
rename from web/public/scene/fold-scene-poster.webp
rename to brand/generated/fold-scene-poster.webp
diff --git a/web/public/scene/fold-scene.mp4 b/brand/generated/fold-scene.mp4
similarity index 100%
rename from web/public/scene/fold-scene.mp4
rename to brand/generated/fold-scene.mp4
diff --git a/web/public/scene/fold-scene.webm b/brand/generated/fold-scene.webm
similarity index 100%
rename from web/public/scene/fold-scene.webm
rename to brand/generated/fold-scene.webm
diff --git a/brand/prompts/voxel-horizon-fold-loop-seedance.txt b/brand/prompts/voxel-horizon-fold-loop-seedance.txt
new file mode 100644
index 0000000..dc5fc07
--- /dev/null
+++ b/brand/prompts/voxel-horizon-fold-loop-seedance.txt
@@ -0,0 +1,76 @@
+Fold loop: animate the voxel horizon still.
+2026-08-12. Higgsfield, seedance_2_0.
+
+SUPERSEDED. Nothing this produced still ships. Kept as the record of how the
+scene was originally animated, and because its four failures still apply to any
+future render of this landscape.
+
+ Its output is brand/generated/fold-scene.{webm,mp4,-poster.webp}, replaced on
+ desktop by fold-scene-symbols.* and, since 2026-08-22, in portrait too — the
+ portrait loop is a crop of whichever landscape loop is current:
+
+ crop=416:720:432:0,scale=440:762
+
+ (a native 9:16 render was tried and rejected — the same prompt invented
+ fires across the terraces and drifted the camera, loop gap 6.20 vs 1.63)
+
+Input
+ start image brand/source/voxel-horizon.webp, decoded to PNG for upload:
+ dwebp voxel-horizon.webp -o voxel-horizon-source.png
+ end image deliberately NOT set — see below
+
+Command
+ higgsfield generate create seedance_2_0 \
+ --prompt "$(cat prompt.txt)" \
+ --start-image voxel-horizon-source.png \
+ --duration 8 --resolution 720p --mode std --aspect_ratio 16:9 \
+ --generate_audio false --wait --wait-timeout 25m --json
+
+---- prompt ----
+
+Thick pale mist lies over the valley floor and between the stone terraces of this dark isometric voxel landscape. The mist rises upward, swells, and sinks back down again in a slow continuous cycle, clearly visible throughout the whole shot — always moving, never still. It is already present and thick in the very first frame.
+
+Embers rise from many separate places at once: from the ruined terraces at the far left edge, from the foreground ruins along the bottom centre, from the valley floor in the middle, and from the dark slopes at the far right edge. They lift slowly, glowing warm orange, and fade out into the black air. The right side of the frame has just as many embers as the left side. They are spread evenly across the entire width of the frame and never pour out of a single spot.
+
+New mist and new embers appear continuously, so the whole landscape is alive with slow movement at every moment, from the very first frame to the very last.
+
+The camera is completely locked on a tripod. It does not move, pan, tilt, drift or zoom at any point. The voxel terrain is solid rock and stays perfectly still.
+
+Night, deep charcoal shadows, volumetric light, cinematic.
+
+No text, no logos, no people.
+
+---- end prompt ----
+
+Four failures this prompt is shaped around. Full write-up in
+reference/design/HIGGSFIELD_ASSET_GUIDE.md §4.
+
+ Never pin the end frame equal to the start frame. Two identical endpoints
+ make "change nothing" a valid and cheap solution; the first attempt came back
+ completely static.
+
+ Do not qualify the motion. An earlier version asked for movement that was
+ "extremely slow", "weightless", "almost imperceptible", and got exactly that.
+
+ Anchor motion to named places. "Embers rise from all across the landscape"
+ put them all in one spot left of centre, 6x more motion in one quarter than
+ another. Naming four anchors and stating that the right side has as many as
+ the left is what fixed it.
+
+ Give the mist a cycle, not a state. Trying to stop it flooding the valley by
+ saying it "stays the same thickness throughout" froze it completely, valley
+ motion 0.062. Rise / swell / sink moves without transforming, and a particle
+ system in equilibrium loops by itself — raw loop gap 1.5 against 16.6 for the
+ flooding version.
+
+Post, all local. Trim, crossfade and encode in ONE ffmpeg pass straight from the
+download; an accidental intermediate re-encode cost 60% of the bitrate before
+the browser ever saw it.
+
+ TRIM=1.0 X=0.8 tail crossfaded back over the head
+ AV1 libsvtav1 crf 36 preset 4 -g 48
+ H264 libx264 crf 21 preset veryslow -g 48
+ poster from the shipped encode's own first frame
+
+Cost: 5th of 6 renders, ~166 credits for the session. Two of six shipped
+anything. Budget for that ratio.
diff --git a/brand/prompts/voxel-horizon-symbols-videoedit.txt b/brand/prompts/voxel-horizon-symbols-videoedit.txt
new file mode 100644
index 0000000..95e55c0
--- /dev/null
+++ b/brand/prompts/voxel-horizon-symbols-videoedit.txt
@@ -0,0 +1,59 @@
+Video edit: add the carved symbols to the existing fold loop.
+2026-08-22. Higgsfield job hf_20260822_143125_5e972ca2-931e-40c5-8f2f-27ff5469eaa5.
+
+The raw render is deliberately not kept in the repo — only the graded loop that
+ships, web/public/scene/fold-scene-symbols.{webm,mp4}. Re-run this prompt if the
+grade, trim, crossfade or codec ever needs changing.
+
+Inputs
+ video web/public/scene/fold-scene.mp4 (the previous fold loop, no symbols)
+ reference brand/source/voxel-horizon-video-icons-gpt.webp
+ (the scene still with five symbols carved in, made with GPT Image 2 —
+ prompt in voxel-horizon-video-icons-gpt-image.txt)
+
+Four lines below are scar tissue, not decoration. Earlier renders in this scene
+duplicated a symbol, rendered the glow bright pink, and scattered torch flames
+across the terraces unprompted. Keep the prohibitions that name those.
+
+---- prompt ----
+
+The reference image is this exact scene with stone symbols already carved into it.
+The video is the same landscape, same camera, same lighting. Add those carved
+symbols to the video and change nothing else.
+
+Carve five symbols into the terrain, exactly one of each, matching the reference
+image's placement, scale, perspective and weathering:
+- a play triangle inside a rectangular relief panel, on the upper-left terrace
+- a horizontal timeline with a round scrubber, on the long wall below it
+- a speaker with three sound arcs, on the lower-left terrace face
+- four crop-frame corner brackets, on the upper-right slope
+- a row of waveform bars, on the right slope below the crop marks
+
+Every symbol is solid stone in the same voxel masonry as the terrain around it:
+weathered, partially eroded, physically part of the wall. They are lit only by
+the scene's existing warm light, exactly as in the reference image. They hold
+perfectly still and keep identical shape and position in every frame.
+
+Preserve the rest of the shot completely: the locked tripod camera, the terrain
+geometry, the pale fog drifting through the central valley, the rising amber
+embers, the deep charcoal palette, the exposure, and the dark empty upper centre.
+The fog and embers must keep moving exactly as they already do.
+
+The symbols never illuminate, pulse, glow, flicker, duplicate, morph, drift or
+detach. No new light sources, no torches, no fire, no pink or magenta anywhere.
+No text, letters, numbers, logos, screens, interface panels, people or watermarks.
+
+---- end prompt ----
+
+What came back, measured against the previous loop
+
+ all five symbols present, one each, correct placement, stable across the clip
+ camera locked — tested for both translation and zoom, 0px / 1.000 scale win
+ black point lifted 4.0 -> 9.1 graded back
+ saturation dropped 8.9 -> 7.5 graded back
+ an AAC audio track dropped with -an
+ fog motion 0.263 -> 0.177 NOT recovered; see below
+
+The fog is the one real regression and it is not an encoding artefact. AV1 at
+crf 30 only reaches 0.194 for an extra 231 KB, so it was not worth the bytes.
+The delivered loop is calmer in the valley than its predecessor.
diff --git a/brand/prompts/voxel-horizon-video-icons-gpt-image.txt b/brand/prompts/voxel-horizon-video-icons-gpt-image.txt
new file mode 100644
index 0000000..ce42dad
--- /dev/null
+++ b/brand/prompts/voxel-horizon-video-icons-gpt-image.txt
@@ -0,0 +1,11 @@
+Use case: precise-object-edit
+Asset type: cinematic website hero background and source frame for later video generation
+Input image: voxel-horizon.webp is the edit target.
+
+Primary request: Make one surgical edit to the supplied image. Embed exactly five unique video-application motifs into existing stone terrace faces as ancient architectural carvings made from the same voxel masonry. Add exactly one of each: (1) a triangular play symbol inside a simple rectangular frame on an upper-left terrace face, (2) a horizontal video timeline with one circular scrubber on a middle-left terrace face, (3) a speaker with two sound-wave arcs on a lower-left terrace face, (4) four crop-corner brackets on an upper-right terrace face, and (5) a simple audio waveform on a lower-right terrace face.
+
+Integration: Each motif must be physically carved or built into a different existing stone wall, following that wall's perspective, voxel grid, depth, erosion, scale, occlusion, amber rim lighting, and charcoal shadows. They should feel like subtle archaeological reliefs discovered in the terrain, not modern UI overlays. Use restrained dim amber illumination only inside a few recessed grooves. No bright neon.
+
+Composition invariants: Preserve the original image's exact camera, centered composition, horizon, terrain silhouette, central valley, fog quality and density, black upper negative space, lighting direction, warm amber and charcoal palette, voxel scale, materials, texture, and all existing structures. Keep the central valley and upper-center copy area clear. Change only the local stone surfaces required to embed the five motifs.
+
+Constraints: Exactly five motifs total, exactly one instance of each specified motif. Every motif must remain clearly distinguishable. No duplicates, no extra symbols, no floating icons, no panels, no screens, no labels, no text, no letters, no numbers, no logo, no people, no torches, no visible flames, no watermark. Do not redesign, crop, reframe, brighten, or restyle the original image.
diff --git a/brand/source/video2ctx-og-background-hybrid-icons.png b/brand/source/video2ctx-og-background-hybrid-icons.png
new file mode 100644
index 0000000..7c2d37a
Binary files /dev/null and b/brand/source/video2ctx-og-background-hybrid-icons.png differ
diff --git a/web/public/scene/voxel-horizon-900.webp b/brand/source/voxel-horizon-900.webp
similarity index 100%
rename from web/public/scene/voxel-horizon-900.webp
rename to brand/source/voxel-horizon-900.webp
diff --git a/web/public/scene/voxel-horizon-portrait.webp b/brand/source/voxel-horizon-portrait.webp
similarity index 100%
rename from web/public/scene/voxel-horizon-portrait.webp
rename to brand/source/voxel-horizon-portrait.webp
diff --git a/brand/source/voxel-horizon-video-icons-gpt.png b/brand/source/voxel-horizon-video-icons-gpt.png
new file mode 100644
index 0000000..ab8ae13
Binary files /dev/null and b/brand/source/voxel-horizon-video-icons-gpt.png differ
diff --git a/brand/source/voxel-horizon-video-icons-gpt.webp b/brand/source/voxel-horizon-video-icons-gpt.webp
new file mode 100644
index 0000000..94fbf04
Binary files /dev/null and b/brand/source/voxel-horizon-video-icons-gpt.webp differ
diff --git a/web/public/scene/voxel-horizon.webp b/brand/source/voxel-horizon.webp
similarity index 100%
rename from web/public/scene/voxel-horizon.webp
rename to brand/source/voxel-horizon.webp
diff --git a/scripts/generate-brand-assets.mjs b/scripts/generate-brand-assets.mjs
index 8d4f278..15ba8f0 100644
--- a/scripts/generate-brand-assets.mjs
+++ b/scripts/generate-brand-assets.mjs
@@ -12,6 +12,11 @@ const sourcePaths = [
'brand/source/video2ctx-logo-192.png',
'brand/source/video2ctx-favicon-32.png',
'brand/source/video2ctx-favicon.ico',
+ // Purpose-built for the card: mass on the right, near-black on the left, and
+ // the same carved symbols as the landing fold. Not the hero still — that one
+ // fills the frame, so text over it needs a scrim, and a scrim over a scene
+ // this dark lifts the blacks rather than deepening them.
+ 'brand/source/video2ctx-og-background-hybrid-icons.png',
];
const outputPaths = [
'web/public/brand/logo-120.png',
@@ -20,6 +25,8 @@ const outputPaths = [
'web/app/icon.png',
'web/app/apple-icon.png',
'web/app/favicon.ico',
+ 'web/app/opengraph-image.jpg',
+ 'web/app/twitter-image.jpg',
'docs/favicon.png',
'docs/logo/light.svg',
'docs/logo/dark.svg',
@@ -57,6 +64,33 @@ function wordmark(logoDataUrl, color) {
`;
}
+/* Type here tracks `.craft-nav-wordmark` in craft.css: Geist at weight 670 with
+ * -0.035em tracking. Geist ships static TTFs at 600 and 700, and 670 sits
+ * between them, so the card uses Bold as the nearest neighbour rather than a
+ * variable axis — librsvg does not reliably honour `font-variation-settings`.
+ *
+ * The navbar renders its `ctx` suffix in Geist Pixel Grid. That cannot be
+ * reproduced here: geist ships the pixel family as woff2 only, and librsvg
+ * silently ignores woff2 @font-face and falls back. So the card's wordmark is
+ * Geist throughout. Converting the woff2 to TTF would fix it. */
+function openGraphOverlay(logoDataUrl, fontSemiBold, fontBold) {
+ return ``;
+}
+
async function generate() {
const sourceLogoPath = absolute(sourcePaths[0]);
await sharp(sourceLogoPath)
@@ -76,6 +110,35 @@ async function generate() {
.png({ palette: true, colours: 256, compressionLevel: 9 })
.toFile(absolute('web/app/apple-icon.png'));
+ const ogLogoDataUrl = `data:image/png;base64,${readFileSync(sourceLogoPath).toString('base64')}`;
+ const ogFontDataUrl = `data:font/ttf;base64,${readFileSync(
+ absolute('web/node_modules/geist/dist/fonts/geist-sans/Geist-SemiBold.ttf'),
+ ).toString('base64')}`;
+ const ogFontBoldDataUrl = `data:font/ttf;base64,${readFileSync(
+ absolute('web/node_modules/geist/dist/fonts/geist-sans/Geist-Bold.ttf'),
+ ).toString('base64')}`;
+ /* Centre-cropped and ungraded, both deliberate. This source is already
+ * composed and exposed for a card, unlike the hero still, which is graded to
+ * sit under a 64% dimmer and needs a brightness lift to survive a feed.
+ *
+ * There is also no scrim. The left of this frame is darker than #171513, so
+ * any scrim over it *raises* the black point instead of lowering it —
+ * measured, a 55% wipe took the text plate from 17.9 up to 23.8. */
+ await sharp(absolute(sourcePaths[4]))
+ .resize(1200, 630, { fit: 'cover', position: 'centre' })
+ .composite([{ input: Buffer.from(openGraphOverlay(ogLogoDataUrl, ogFontDataUrl, ogFontBoldDataUrl)) }])
+ /* JPEG, not PNG. The card is a dark photographic gradient with no flat
+ * colour and no transparency, which is PNG's worst case — it was 690 KB as
+ * a PNG against 152 KB here. Banding was the thing to check, since the
+ * frame is mostly near-black, and it does not appear: peak error against
+ * the lossless original measured 1/255 across the gradient.
+ *
+ * 4:4:4 is deliberate. The logo and the URL are saturated red, and chroma
+ * subsampling is exactly where those edges smear. */
+ .jpeg({ quality: 92, mozjpeg: true, chromaSubsampling: '4:4:4' })
+ .toFile(absolute('web/app/opengraph-image.jpg'));
+ copy('web/app/opengraph-image.jpg', 'web/app/twitter-image.jpg');
+
const navLogoDataUrl = `data:image/png;base64,${readFileSync(absolute('web/public/brand/logo-120.png')).toString('base64')}`;
write('docs/logo/light.svg', wordmark(navLogoDataUrl, '#191715'));
write('docs/logo/dark.svg', wordmark(navLogoDataUrl, '#f5f1eb'));
diff --git a/web/DESIGN.md b/web/DESIGN.md
index fdd0a85..35ace97 100644
--- a/web/DESIGN.md
+++ b/web/DESIGN.md
@@ -1,6 +1,6 @@
# Product design system
-Covers the live marketing surface (`/` and `/explore`) and the product workspace
+Covers the live marketing surface (`/`) and the product workspace
(`/dashboard`). The dashboard keeps dedicated `--color-dashboard-*` roles in
`tokens.css` because dense application surfaces need their own contrast tuning,
but those roles now resolve to Craft's warm dark paper, off-white ink, and
@@ -216,8 +216,8 @@ reduced motion **0 bytes of video**.
- **No invented metrics.** No adoption counts, no speed multiples, no
testimonials that were not said. Every number on the page is one the platform
actually produces.
-- **Name the cost, not just the benefit.** `/explore` lists each direction's bet
- *and* what it gives up.
+- **Name the cost, not just the benefit.** Each access path should say what it
+ gives up as well as what it provides.
- Plain language over jargon: "What was said" beats "Transcript"; header names
and response shapes belong in the API reference, not the landing page.
@@ -247,11 +247,8 @@ Recorded because it is real, not aspirational. Verified 2026-08-12.
8-state component discipline is not met.
3. **`--exit` is declared and never used.** The "exits faster than entrances"
rule is documented but enforced nowhere. Either apply it to the lens fade-out
- and the beat crossfade, or drop the token.
-4. **`--color-stage-*` in `tokens.css` is consumed only by `lens-home.css` and
- `cinematic.css`** — both Decomposition-only. If that direction is ever
- deleted, those tokens go with it.
-5. **The fold scene is 720p** upscaled ~1.16x at common desktop widths. Sharpness
+ or drop the token.
+4. **The fold scene is 720p** upscaled ~1.16x at common desktop widths. Sharpness
is currently fine because contrast, not resolution, was the real constraint —
but a wider display will expose it.
@@ -265,8 +262,6 @@ Resolved 2026-08-12: the stale `tokens.css` stamp, the missing
- Keep the resting-scene dimming and the `Flashlight` mount conditions **in
step**. The scene is only darkened where a light exists to recover it; dim it
anywhere the component does not mount and the fold is simply dark.
-- Promoting a different landing direction is one line: `DEFAULT_DIRECTION` in
- `_directions/registry.tsx`.
- Regenerating the fold scene: prompts, settings and encode commands are in
`reference/design/CINEMATIC_ASSETS.md` and `web/public/scene/README.md`. The reusable
method — model choice, prompting failures, the measurement harness — is in
diff --git a/web/app/_components/direction-switcher.tsx b/web/app/_components/direction-switcher.tsx
deleted file mode 100644
index eb2f85b..0000000
--- a/web/app/_components/direction-switcher.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-'use client';
-
-import { useEffect } from 'react';
-import { useRouter } from 'next/navigation';
-import { DIRECTIONS } from '../_directions/registry';
-
-/* A fixed pill for moving between contenders without going back to the index.
- * Arrow keys work too, so directions can be flicked through and compared from
- * the same scroll position rather than judged one at a time.
- *
- * Only ever rendered under /explore, so it cannot leak onto the live homepage.
- */
-
-export function DirectionSwitcher({ current }: { current: string }) {
- const router = useRouter();
- const index = DIRECTIONS.findIndex((direction) => direction.slug === current);
-
- useEffect(() => {
- const move = (step: number) => {
- const next = DIRECTIONS[(index + step + DIRECTIONS.length) % DIRECTIONS.length];
- router.push(`/explore/${next.slug}`, { scroll: false });
- };
-
- const onKey = (event: KeyboardEvent) => {
- const target = event.target as HTMLElement | null;
- if (target?.matches('input, textarea, [contenteditable="true"]')) return;
- if (event.key === 'ArrowRight') move(1);
- if (event.key === 'ArrowLeft') move(-1);
- };
-
- window.addEventListener('keydown', onKey);
- return () => window.removeEventListener('keydown', onKey);
- }, [index, router]);
-
- if (index < 0) return null;
- const previous = DIRECTIONS[(index - 1 + DIRECTIONS.length) % DIRECTIONS.length];
- const next = DIRECTIONS[(index + 1) % DIRECTIONS.length];
-
- return (
-
- );
-}
diff --git a/web/app/_directions/concepts.tsx b/web/app/_directions/concepts.tsx
deleted file mode 100644
index 01142db..0000000
--- a/web/app/_directions/concepts.tsx
+++ /dev/null
@@ -1,559 +0,0 @@
-'use client';
-
-/* The three original landing concepts, kept as reference directions.
- *
- * Preserved verbatim in composition and copy — they are the record of what was
- * tried before Craft, and the reason several of its decisions went the way they
- * did. Only the plumbing changed: each concept is now its own entry in the
- * directions registry rather than a `?variant=` switch on a separate /demo
- * route, so there is one place to compare landing work instead of two.
- */
-import { FormEvent, useState } from 'react';
-
-type DemoStatus = 'idle' | 'loading' | 'ready' | 'unavailable';
-
-interface Thumbnail {
- url: string;
- width?: number;
- height?: number;
-}
-
-interface DemoVideo {
- id: string;
- title: string;
- channel: { id: string; name: string; url: string };
- thumbnails: Thumbnail[];
- durationText?: string;
- viewCountText?: string;
- publishedTimeText?: string;
- url: string;
-}
-
-interface TranscriptSegment {
- startMs: number;
- durationMs: number;
- endMs: number;
- text: string;
-}
-
-interface DemoComment {
- id: string;
- author: { name: string };
- text: string;
- publishedTimeText?: string;
- likeCountText?: string;
- isPinned: boolean;
- isHearted: boolean;
-}
-
-interface DemoResponse {
- video: DemoVideo;
- transcript:
- | {
- status: 'ready';
- track: { name: string; languageCode: string };
- segmentCount: number;
- segments: TranscriptSegment[];
- }
- | { status: 'unavailable' };
- comments:
- | { status: 'ready'; totalCount?: number; comments: DemoComment[] }
- | { status: 'unavailable' };
- quota: { limit: number; remaining: number; resetAt: string; repeated: boolean };
- partial: boolean;
-}
-
-const demoApiBase =
- process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ??
- (process.env.NODE_ENV === 'production'
- ? 'https://api.video2ctx.dev'
- : 'http://localhost:8787');
-
-function ConceptShell({ hero }: { hero: 'orbit' | 'field' | 'engine' }) {
- const [url, setUrl] = useState('');
- const [state, setState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
- const [result, setResult] = useState(null);
- const [error, setError] = useState('');
-
- const inspectVideo = async (event: FormEvent) => {
- event.preventDefault();
- setState('loading');
- setResult(null);
- setError('');
-
- try {
- const response = await fetch(`${demoApiBase}/v1/demo/youtube/inspect`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ url }),
- });
- const payload = (await response.json()) as DemoResponse & {
- error?: { message?: string; details?: { resetAt?: string } };
- };
-
- if (!response.ok) {
- const reset = payload.error?.details?.resetAt;
- const retry = reset
- ? ` You can inspect another video after ${formatReset(reset)}.`
- : '';
- throw new Error(`${payload.error?.message ?? 'The video could not be inspected.'}${retry}`);
- }
-
- setResult(payload);
- setState('success');
- window.setTimeout(() => {
- document.getElementById('inspection-result')?.scrollIntoView({
- behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches
- ? 'auto'
- : 'smooth',
- block: 'start',
- });
- }, 50);
- } catch (cause) {
- setError(
- cause instanceof TypeError
- ? 'The inspection service could not be reached. Please try again.'
- : cause instanceof Error
- ? cause.message
- : 'The video could not be inspected.',
- );
- setState('error');
- }
- };
-
- const mapStatus = (section: 'video' | 'transcript' | 'comments'): DemoStatus => {
- if (state === 'loading') return 'loading';
- if (!result) return 'idle';
- if (section === 'video') return 'ready';
- return result[section].status;
- };
-
- const heroProps = {
- url,
- state,
- error,
- mapStatus,
- onUrlChange: setUrl,
- onSubmit: inspectVideo,
- };
-
- return (
-
-
-
-
- video2ctx
-
-
Everything your agent needs to understand videos.
diff --git a/web/app/_directions/decomposition.tsx b/web/app/_directions/decomposition.tsx
deleted file mode 100644
index 4bac7d4..0000000
--- a/web/app/_directions/decomposition.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-/* Direction: "Decomposition" — Hallmark Feature Stack.
- *
- * The first contender, kept verbatim so the comparison has a real baseline.
- * It is NOT the chosen answer; it sits at /explore/decomposition alongside the
- * others until one is picked and promoted.
- */
-
-import { Suspense } from 'react';
-import { LandingDemo } from '../_components/landing-demo';
-import { DecompositionCinematic } from '../_components/decomposition-cinematic';
-import { SiteNav } from '../_components/site-nav';
-
-/* Everything except the inspect form and the cinematic stage is server-rendered.
- * The headline and lede used to sit inside a client component, which put the
- * LCP element behind hydration for no reason. */
-
-/* The API is one surface of three. Naming all three in the hero is what stops
- * the page reading as an API-only product — the workspace and the npm package
- * are equal entry points, and the whole thing is self-hostable. */
-const WAYS_IN = [
- { label: 'Research workspace', name: 'Open the dashboard', href: '/dashboard' },
- {
- label: 'Hosted API',
- name: 'docs.video2ctx.dev',
- href: 'https://docs.video2ctx.dev/api-reference/introduction',
- },
- { label: 'TypeScript package', name: 'all-things-youtube', href: 'https://www.npmjs.com/package/all-things-youtube' },
-];
-
-/* The experience, not the endpoint list. These four stages are what the product
- * actually does once a video has been taken apart — inspect, collect, ask,
- * monitor — and they map to real surfaces (projects, imports, answers,
- * monitors) rather than aspiration. */
-const WORKFLOW = [
- {
- stage: 'Inspect',
- title: 'Start with one video',
- body: 'Paste a URL. Everything around it comes back together — the words, who published it, what the audience said, the series it belongs to.',
- },
- {
- stage: 'Collect',
- title: 'Keep the exact moment',
- body: 'Save the segments that matter into a private project, with the timestamp intact. Not a summary of the video — the part of it you actually needed.',
- },
- {
- stage: 'Ask',
- title: 'Get answers you can check',
- body: 'Ask questions across everything you have collected. Answers come back citing the moment they came from, so you can play the source and confirm it.',
- },
- {
- stage: 'Monitor',
- title: 'Watch it change',
- body: 'Follow channels and topics over time, compare which are gaining ground, and export the research when you need it somewhere else.',
- },
-];
-
-/* Reasons to trust it, in plain language. Deliberately no adoption counts, no
- * speed multiples, no invented metrics — and no header names or response shapes
- * either; those belong in the API reference, not on a landing page. */
-const GUARANTEES = [
- {
- title: 'Every moment keeps its source',
- body: 'A transcript segment does not only say what was said — it says when. Each one links back to that second in the video, so you can play it and hear it for yourself.',
- },
- {
- title: 'Missing data says so',
- body: 'Plenty of videos have comments switched off or no captions at all. You are told that plainly, instead of getting an empty result you have to interpret.',
- },
- {
- title: 'Try it without an account',
- body: 'Five videos a day, no sign-up, no key. Re-checking one you have already looked at does not use up another.',
- },
- {
- title: 'Nothing is locked in',
- body: 'Apache 2.0, and the workspace and API are the same codebase — so you can use the hosted version, install the package, or run the whole thing yourself.',
- },
-];
-
-export function DecompositionDirection() {
- return (
-
-
-
-
-
- {/* Kept from the Context-engine prototype — the most compressed line the
- project has. It sits above the headline rather than below it, so the
- hero still carries one display statement instead of two. */}
-
A video in. Context out.
-
A video is more than a transcript.
-
- Inspect a public video, then carry its exact moments, channel identity, and audience
- response into your agent — or into a research project you keep. Open source, and yours
- to run.
-
- Every route, request shape, and error is documented in the{' '}
-
- API reference
-
- .
-
-
-
-
-
-
What you can rely on
-
Context agents can verify.
-
- YouTube is the first source it supports, and deliberately not the last.
-
-
-
-
- {GUARANTEES.map((item) => (
-
-
{item.title}
-
{item.body}
-
- ))}
-
-
-
-
-
- );
-}
diff --git a/web/app/_directions/flashlight.tsx b/web/app/_directions/flashlight.tsx
index d51d150..c1e03e5 100644
--- a/web/app/_directions/flashlight.tsx
+++ b/web/app/_directions/flashlight.tsx
@@ -64,6 +64,8 @@ function chooseScene(): Scene {
export function Flashlight() {
const lensRef = useRef(null);
const videoRef = useRef(null);
+ const iconsRef = useRef(null);
+ const glowRef = useRef(null);
const [scene, setScene] = useState('none');
useEffect(() => {
@@ -90,9 +92,22 @@ export function Flashlight() {
// Read from the element rather than a constant, so the size lives in CSS
// only and the two can never drift apart.
let lensSize = 0;
+ let glowSize = 0;
const measure = () => {
lensSize = lens.offsetWidth;
+ // Re-read lazily in paint: the glow mounts a render later than this
+ // effect runs, so it may not exist yet the first time through.
+ glowSize = 0;
+ };
+
+ /* The symbols warm with the lens rather than on their own clock, so both
+ * states are driven from the same pointer and can never disagree. */
+ const setLive = (on: boolean) => {
+ const flag = on ? 'true' : 'false';
+ lens.dataset.live = flag;
+ const icons = iconsRef.current;
+ if (icons) icons.dataset.live = flag;
};
const paint = () => {
@@ -105,6 +120,17 @@ export function Flashlight() {
const half = lensSize / 2;
lens.style.transform = `translate3d(${x - half}px, ${y - half}px, 0)`;
+ /* The glow trails the same eased position. Keeping it a transform on a
+ * leaf element means the symbol mask above it is never re-rasterised —
+ * animating the gradient's position instead would repaint the whole
+ * masked layer on every frame. */
+ const glow = glowRef.current;
+ if (glow) {
+ if (!glowSize) glowSize = glow.offsetWidth;
+ const gh = glowSize / 2;
+ glow.style.transform = `translate3d(${x - gh}px, ${y - gh}px, 0)`;
+ }
+
// Keep going only while it is still catching up; a resting lens costs
// nothing.
if (live && (Math.abs(dx) > SETTLED || Math.abs(dy) > SETTLED)) schedule();
@@ -127,7 +153,7 @@ export function Flashlight() {
live = true;
x = targetX;
y = targetY;
- lens.dataset.live = 'true';
+ setLive(true);
}
schedule();
@@ -141,13 +167,13 @@ export function Flashlight() {
// wherever it was last left.
x = targetX = event.clientX - box.left;
y = targetY = event.clientY - box.top;
- lens.dataset.live = 'true';
+ setLive(true);
schedule();
};
const onLeave = () => {
live = false;
- lens.dataset.live = 'false';
+ setLive(false);
};
const host = field.parentElement ?? field;
@@ -206,7 +232,7 @@ export function Flashlight() {
poster={
scene === 'portrait'
? '/scene/fold-scene-portrait-poster.webp'
- : '/scene/fold-scene-poster.webp'
+ : '/scene/fold-scene-symbols-poster.webp'
}
aria-hidden='true'
>
@@ -217,8 +243,11 @@ export function Flashlight() {
>
) : (
<>
-
-
+ {/* AV1 first. The H.264 sibling is not redundant: Safari gates
+ * AV1 on M3 / A17 Pro hardware, so Intel, M1, M2 and older
+ * iPhones fall through to it. Only one is ever fetched. */}
+
+
>
)}
@@ -226,6 +255,18 @@ export function Flashlight() {
>
) : null}
+ {/* Above the lens, not below it. The lens is a `backdrop-filter`, so
+ * anything behind it gets multiplied by brightness(3) — and the glow is
+ * brightest exactly where the lens is, which is where that multiplication
+ * would blow it to white. In front, it adds a known quantity of light.
+ *
+ * Landscape only: this is lit by the pointer, and the other scenes are
+ * the ones chosen precisely because there is no pointer to light them. */}
+ {scene === 'landscape' ? (
+
+
+
+ ) : null}
>
);
}
diff --git a/web/app/_directions/registry.tsx b/web/app/_directions/registry.tsx
deleted file mode 100644
index ec85c92..0000000
--- a/web/app/_directions/registry.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import type { ReactNode } from 'react';
-import { DecompositionDirection } from './decomposition';
-import { CraftDirection } from './craft';
-import { ContextEngineConcept, OrbitConcept, SignalFieldConcept } from './concepts';
-
-/* Landing-page directions under consideration.
- *
- * One entry per contender. Each is a complete, scrollable page rather than a
- * mockup, because the point of the exercise is to judge them in a browser
- * instead of from a description.
- *
- * `notes` is deliberately honest about what each direction is betting on and
- * what it gives up — a comparison where every option sounds good is not a
- * comparison. Promoting a winner means changing DEFAULT_DIRECTION and deleting
- * the rest.
- */
-
-export interface Direction {
- slug: string;
- name: string;
- premise: string;
- macrostructure: string;
- bet: string;
- cost: string;
- render: () => ReactNode;
-}
-
-export const DIRECTIONS: Direction[] = [
- {
- slug: 'decomposition',
- name: 'Decomposition',
- premise: 'A video is taken apart on screen as you scroll, one kind of context at a time.',
- macrostructure: 'Feature Stack — sticky copy rail, scroll-synced stage',
- bet: 'That the product’s core idea is visual and worth pinning the viewport to show.',
- cost: 'Long scroll before the page states a benefit. Carries a substantial dark band that has to earn its space, and the choreography is invisible on phones.',
- render: () => ,
- },
- {
- slug: 'craft',
- name: 'Craft',
- premise:
- 'The fold is the input and nothing else. You paste a URL and the page answers with your own video; everything below is a footnote to what you just ran.',
- macrostructure: 'Marquee Hero — N2 floating chip, Ft4 colophon, dark paper',
- bet: 'That the fastest argument is letting someone run the thing, and that motion should be budgeted by how often each element is seen.',
- cost: 'Says less before you interact than a conventional hero would. Dark paper is a real departure from the current brand.',
- render: () => ,
- },
- {
- slug: 'orbit',
- name: 'Orbit',
- premise:
- 'The original hero: a split composition with metadata, transcript, channel and comments circling one video on counter-rotating rings.',
- macrostructure: 'Map / Diagram — N9 edge-aligned nav, Ft2 inline footer',
- bet: 'That the product is best explained by a diagram of its parts around a source.',
- cost: 'Orbit is the wrong metaphor — it implies satellites circling a body rather than one thing separating into its parts. The diagram is also decorative: it carries no information a caption would not.',
- render: () => ,
- },
- {
- slug: 'signal-field',
- name: 'Signal field',
- premise: 'The same source map, bled behind the copy as a spatial field rather than sat beside it.',
- macrostructure: 'Map / Diagram — full-bleed variant',
- bet: 'That letting the diagram occupy the whole fold reads as more confident than boxing it.',
- cost: 'The copy and the diagram compete for the same space, and the form ends up below both.',
- render: () => ,
- },
- {
- slug: 'context-engine',
- name: 'Context engine',
- premise:
- 'A CSS-3D still of a video slab being distilled into structured context, with transcript, channel, comments, playlist and search as labelled outputs.',
- macrostructure: 'Map / Diagram — 3D stage',
- bet: 'That showing the decomposition literally beats describing it.',
- cost: 'A static 3D still promises motion it never delivers. Its information model was the right one, though — it is the only concept that named all five surfaces, and it seeded both later directions.',
- render: () => ,
- },
-];
-
-export const DEFAULT_DIRECTION = 'craft';
-
-export function getDirection(slug: string): Direction | undefined {
- return DIRECTIONS.find((direction) => direction.slug === slug);
-}
diff --git a/web/app/craft.css b/web/app/craft.css
index c695ff7..416a2b6 100644
--- a/web/app/craft.css
+++ b/web/app/craft.css
@@ -298,6 +298,71 @@ html:has(.craft) body {
}
}
+/* The carved symbols taking heat from the light.
+ *
+ * The symbols are in the footage; this only warms them. That distinction is
+ * what makes all five work. An earlier version of this layer lit two, because
+ * it was trying to *reveal* symbols that were not in the video, and additive
+ * light needs dark stone to do that — the three on the left sit on stone at
+ * 62-68 luminance against 33-39 on the right, so they washed out. Brightening
+ * something already visible has no such floor, so the mask now covers all five
+ * and none of them is a special case.
+ *
+ * The mask shares the video's aspect ratio and its `cover` geometry, so both
+ * resolve to the same box at every viewport and cannot drift apart. It is
+ * derived from the difference between the scene still with symbols and the one
+ * without, which is why it lands exactly on the carvings. Replace the footage
+ * at a different aspect, or re-render the scene, and this silently
+ * misregisters — regenerate it from the same pair of stills.
+ *
+ * `plus-lighter` is load-bearing rather than decorative. It only ever adds
+ * light, so the drifting fog and embers underneath stay visible through a warm
+ * symbol. An opaque plate would freeze them, and the busiest symbol sits over
+ * the busiest region of the frame. */
+.voxel-icons {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+ mask-image: url('/scene/voxel-icon-glow-mask.webp');
+ mask-size: cover;
+ mask-position: 50% 100%;
+ mask-repeat: no-repeat;
+ mix-blend-mode: plus-lighter;
+ opacity: 0;
+ transition: opacity 420ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+
+.voxel-icons[data-live='true'] {
+ opacity: 1;
+}
+
+/* The travelling heat. Masked down to the symbols by the parent, so the only
+ * per-frame write is one transform on one element and nothing repaints.
+ *
+ * Wider than the lens on purpose. A symbol that warms exactly as the light
+ * arrives reads as a switch being thrown; one that begins to warm slightly
+ * before it reads as stone taking heat, which is the whole idea.
+ *
+ * Mixed from `--accent`, the same token as the lens core, so the light is one
+ * colour rather than two. */
+.voxel-icons__glow {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 560px;
+ height: 560px;
+ will-change: transform;
+ background: radial-gradient(
+ circle closest-side at center,
+ color-mix(in oklab, var(--accent) 92%, transparent) 0%,
+ color-mix(in oklab, var(--accent) 70%, transparent) 24%,
+ color-mix(in oklab, var(--accent) 36%, transparent) 46%,
+ color-mix(in oklab, var(--accent) 13%, transparent) 68%,
+ transparent 86%
+ );
+}
+
/* The flashlight. `backdrop-filter` brightens whatever is painted behind it, so
* one scene serves both states and there is no second copy to keep registered —
* which is what makes a moving scene possible at all. The feathered mask belongs
@@ -312,7 +377,11 @@ html:has(.craft) body {
overflow: hidden;
opacity: 0;
will-change: transform;
- backdrop-filter: brightness(3) saturate(1.28);
+ /* Saturation is pulled up with the brightness rather than left behind it.
+ * `brightness(3)` alone walks the lit terrain toward white, which is what
+ * made the pool read as pale rather than as a warm light — the tint below
+ * was fighting a backdrop that had already lost its colour. */
+ backdrop-filter: brightness(3) saturate(1.6);
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
/* A long, gaussian-ish falloff that reaches zero exactly at the element edge.
* A radial-gradient with few stops hitting transparent well inside the box
@@ -346,16 +415,21 @@ html:has(.craft) body {
}
/* A warm core, so it reads as a light source rather than as a brightness patch.
- * Static relative to the lens, so it costs nothing while the lens moves. */
+ * Static relative to the lens, so it costs nothing while the lens moves.
+ *
+ * Mixed from `--accent` rather than from a hand-picked oklch near it. The light
+ * and the primary button are the only two saturated things on this screen, so
+ * they should be the same colour by construction — if the brand hue is ever
+ * retuned, this follows it instead of drifting a few degrees away from it. */
.voxel-lens::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
circle closest-side at center,
- oklch(70% 0.16 33 / 0.18),
- oklch(68% 0.17 32 / 0.1) 30%,
- oklch(66% 0.18 31 / 0.04) 56%,
+ color-mix(in oklab, var(--accent) 34%, transparent),
+ color-mix(in oklab, var(--accent) 21%, transparent) 30%,
+ color-mix(in oklab, var(--accent) 9%, transparent) 56%,
transparent 82%
);
mix-blend-mode: plus-lighter;
@@ -371,12 +445,11 @@ html:has(.craft) body {
inset: 20% 0 -3%;
}
- .voxel-field img {
- object-position: 50% 88%;
- }
-
- /* The portrait clip is cropped to almost exactly this box's aspect, so it
- * needs no further anchoring — centring keeps the whole composed frame. */
+ /* Still and clip are the same frame now — the still IS the clip's frame 0 —
+ * so they share an anchor. This was 88% when the still was separate 3:4
+ * artwork that needed pulling down to find its horizon; a poster cropped to
+ * this box's aspect only needs centring. */
+ .voxel-field img,
.voxel-video {
object-position: 50% 50%;
}
@@ -518,6 +591,21 @@ html:has(.craft) body {
text-wrap: balance;
}
+.craft-hero-brand {
+ margin-bottom: var(--space-md);
+ display: block;
+ color: var(--accent);
+ font-family: var(--font-home-pixel);
+ font-size: clamp(0.78rem, 1.6vw, 0.95rem);
+ font-weight: 500;
+ letter-spacing: 0.045em;
+ line-height: 1;
+}
+
+.craft-hero-claim {
+ display: block;
+}
+
.craft-context-word {
display: inline-block;
position: relative;
diff --git a/web/app/explore.css b/web/app/explore.css
deleted file mode 100644
index 980c4d2..0000000
--- a/web/app/explore.css
+++ /dev/null
@@ -1,197 +0,0 @@
-/* Internal comparison surface. Deliberately plain — it is a workbench for
- * judging the directions, not itself a design to judge. */
-
-.explore {
- min-height: 100svh;
- padding: clamp(3rem, 8vw, 6rem) clamp(var(--space-md), 6vw, 5rem);
- background: var(--color-home-paper);
- color: var(--color-home-ink);
- font-family: var(--font-home-body);
-}
-
-.explore-head {
- max-width: 46rem;
-}
-
-.explore-eyebrow {
- margin: 0;
- color: var(--color-home-muted);
- font-family: var(--font-home-mono);
- font-size: 0.65rem;
- letter-spacing: 0.12em;
- text-transform: uppercase;
-}
-
-.explore h1 {
- margin: var(--space-md) 0 0;
- font-size: clamp(2rem, 5vw, 3rem);
- font-weight: 725;
- letter-spacing: -0.045em;
- line-height: 1;
-}
-
-.explore-lede {
- margin: var(--space-lg) 0 0;
- color: var(--color-home-ink-soft);
- font-size: 0.95rem;
- line-height: 1.65;
-}
-
-.explore-list {
- max-width: 62rem;
- margin: clamp(var(--space-2xl), 5vw, 4rem) 0 0;
- padding: 0;
- list-style: none;
- display: grid;
- gap: var(--space-xl);
-}
-
-.explore-list li {
- padding-top: var(--space-lg);
- border-top: var(--rule-home) solid var(--color-home-rule-strong);
- display: grid;
- grid-template-columns: 3.5rem minmax(0, 1fr);
- gap: var(--space-md);
-}
-
-.explore-index {
- margin: 0;
- color: var(--color-home-accent);
- font-family: var(--font-home-mono);
- font-size: 0.7rem;
-}
-
-.explore-list h2 {
- margin: 0;
- display: flex;
- align-items: baseline;
- flex-wrap: wrap;
- gap: var(--space-sm);
- font-size: 1.4rem;
- font-weight: 700;
- letter-spacing: -0.03em;
-}
-
-.explore-list h2 a {
- color: var(--color-home-ink);
- text-underline-offset: 0.2em;
-}
-
-.explore-tag {
- padding: 0.15rem var(--space-xs);
- border-radius: var(--radius-home-pill);
- background: color-mix(in oklch, var(--color-home-accent) 14%, transparent);
- color: var(--color-home-accent);
- font-family: var(--font-home-mono);
- font-size: 0.58rem;
- font-weight: 650;
- letter-spacing: 0.04em;
-}
-
-.explore-premise {
- max-width: 44rem;
- margin: var(--space-sm) 0 0;
- font-size: 0.95rem;
- line-height: 1.6;
-}
-
-.explore-meta {
- margin: var(--space-lg) 0 0;
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: var(--space-lg);
-}
-
-.explore-meta > div {
- min-width: 0;
-}
-
-.explore-meta dt {
- color: var(--color-home-muted);
- font-family: var(--font-home-mono);
- font-size: 0.58rem;
- letter-spacing: 0.1em;
- text-transform: uppercase;
-}
-
-.explore-meta dd {
- margin: var(--space-xs) 0 0;
- color: var(--color-home-ink-soft);
- font-size: 0.82rem;
- line-height: 1.55;
-}
-
-.explore-note {
- max-width: 44rem;
- margin: clamp(var(--space-2xl), 5vw, 4rem) 0 0;
- color: var(--color-home-muted);
- font-size: 0.85rem;
- line-height: 1.6;
-}
-
-/* ------------------------------------------------------------- switcher */
-
-.direction-switcher {
- min-height: 3rem;
- padding: var(--space-2xs);
- border-radius: var(--radius-home-pill);
- background: oklch(20% 0.012 75 / 0.94);
- box-shadow: 0 0.75rem 2.5rem oklch(20% 0.012 75 / 0.24);
- color: oklch(96.5% 0.012 83);
- display: flex;
- align-items: center;
- gap: var(--space-2xs);
- position: fixed;
- z-index: 40;
- inset: auto auto var(--space-md) 50%;
- transform: translateX(-50%);
- backdrop-filter: blur(0.5rem);
-}
-
-.direction-switcher a {
- min-width: 2.5rem;
- min-height: 2.5rem;
- border-radius: var(--radius-home-pill);
- background: oklch(96.5% 0.012 83 / 0.12);
- color: inherit;
- display: grid;
- place-items: center;
- font-size: 0.85rem;
- text-decoration: none;
-}
-
-.direction-switcher-index {
- padding-inline: var(--space-md);
- font-size: 0.7rem !important;
-}
-
-.direction-switcher span {
- min-width: 9rem;
- padding-inline: var(--space-sm);
- display: grid;
- gap: 0.1rem;
- text-align: center;
-}
-
-.direction-switcher b {
- font-size: 0.78rem;
- font-weight: 650;
-}
-
-.direction-switcher small {
- color: oklch(96.5% 0.012 83 / 0.6);
- font-family: var(--font-home-mono);
- font-size: 0.58rem;
-}
-
-@media (max-width: 720px) {
- .explore-list li {
- grid-template-columns: minmax(0, 1fr);
- gap: var(--space-xs);
- }
-
- .explore-meta {
- grid-template-columns: minmax(0, 1fr);
- gap: var(--space-md);
- }
-}
diff --git a/web/app/explore/[slug]/page.tsx b/web/app/explore/[slug]/page.tsx
deleted file mode 100644
index 4a1c43e..0000000
--- a/web/app/explore/[slug]/page.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { notFound } from 'next/navigation';
-import { DIRECTIONS, getDirection } from '../../_directions/registry';
-import { DirectionSwitcher } from '../../_components/direction-switcher';
-
-export function generateStaticParams() {
- return DIRECTIONS.map((direction) => ({ slug: direction.slug }));
-}
-
-export default async function DirectionPage({
- params,
-}: {
- params: Promise<{ slug: string }>;
-}) {
- const { slug } = await params;
- const direction = getDirection(slug);
- if (!direction) notFound();
-
- return (
- <>
- {direction.render()}
-
- >
- );
-}
diff --git a/web/app/explore/page.tsx b/web/app/explore/page.tsx
deleted file mode 100644
index 344d678..0000000
--- a/web/app/explore/page.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { DIRECTIONS, DEFAULT_DIRECTION } from '../_directions/registry';
-
-export const metadata = {
- title: 'Landing directions · video2ctx',
- robots: { index: false, follow: false },
-};
-
-export default function ExploreIndex() {
- return (
-
-
-
Internal · not indexed
-
Landing directions
-
- Each is a complete, scrollable page rather than a mockup — the whole point is to judge
- them in a browser instead of from a description. Arrow keys move between them.
-
-
-
-
- {DIRECTIONS.map((direction, index) => (
-
-
{String(index + 1).padStart(2, '0')}
-
-
- {direction.name}
- {direction.slug === DEFAULT_DIRECTION ? (
- currently on /
- ) : null}
-
-
{direction.premise}
-
-
-
Structure
-
{direction.macrostructure}
-
-
-
The bet
-
{direction.bet}
-
-
-
What it costs
-
{direction.cost}
-
-
-
-
- ))}
-
-
- {DIRECTIONS.length < 2 ? (
-
- Only one direction so far. The rest come after the reference study — this page is the
- mechanism, not the comparison.
-
- ) : null}
-
- );
-}
diff --git a/web/app/globals.css b/web/app/globals.css
index 1b0006e..954631b 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -1,10 +1,6 @@
@import "../tokens.css";
@import "tailwindcss";
-@import "./lens-home.css";
-@import "./cinematic.css";
-@import "./explore.css";
@import "./craft.css";
-@import "./concepts.css";
/* Hallmark · genre: modern-minimal · macrostructure: Workbench · theme: existing-brand-DNA
* tone: technical-utilitarian · nav: N3 side-rail · enrichment: none · designed-as-app
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
index 02a5906..03972c1 100644
--- a/web/app/layout.tsx
+++ b/web/app/layout.tsx
@@ -3,13 +3,14 @@ import { GeistSans } from 'geist/font/sans';
import { GeistMono } from 'geist/font/mono';
import { GeistPixelGrid } from 'geist/font/pixel';
import { Analytics } from '@vercel/analytics/next';
+import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from '../lib/site';
import './globals.css';
export const metadata: Metadata = {
- title: 'video2ctx',
- description:
- 'Paste a YouTube URL and get back the transcript, channel, comments, playlist and search context around it — normalised, with every moment still linked to its source.',
- applicationName: 'video2ctx',
+ metadataBase: new URL(SITE_URL),
+ title: SITE_NAME,
+ description: SITE_DESCRIPTION,
+ applicationName: SITE_NAME,
};
export default function RootLayout({
diff --git a/web/app/opengraph-image.alt.txt b/web/app/opengraph-image.alt.txt
new file mode 100644
index 0000000..65e7b1c
--- /dev/null
+++ b/web/app/opengraph-image.alt.txt
@@ -0,0 +1 @@
+video2ctx social card with the text "Video context for agents" beside a dark voxel landscape carved with video icons
diff --git a/web/app/opengraph-image.jpg b/web/app/opengraph-image.jpg
new file mode 100644
index 0000000..f29ff94
Binary files /dev/null and b/web/app/opengraph-image.jpg differ
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 0a92486..557ea00 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -1,11 +1,52 @@
-import { DEFAULT_DIRECTION, getDirection } from './_directions/registry';
+import type { Metadata } from 'next';
+import { HOME_TITLE, SITE_DESCRIPTION, SITE_NAME, SITE_URL } from '../lib/site';
+import { CraftDirection } from './_directions/craft';
-/* The homepage renders whichever direction is currently in front. Swapping the
- * winner is a one-line change to DEFAULT_DIRECTION in the registry — the point
- * of routing it this way is that no contender is load-bearing until it is
- * chosen. */
+export const metadata: Metadata = {
+ title: HOME_TITLE,
+ description: SITE_DESCRIPTION,
+ alternates: {
+ canonical: '/',
+ },
+ openGraph: {
+ title: HOME_TITLE,
+ description: SITE_DESCRIPTION,
+ url: '/',
+ siteName: SITE_NAME,
+ locale: 'en_US',
+ type: 'website',
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: HOME_TITLE,
+ description: SITE_DESCRIPTION,
+ },
+};
+
+const websiteStructuredData = {
+ '@context': 'https://schema.org',
+ '@type': 'WebSite',
+ name: SITE_NAME,
+ alternateName: 'video2ctx.dev',
+ url: `${SITE_URL}/`,
+ description: SITE_DESCRIPTION,
+ inLanguage: 'en',
+};
+
+/* The landing page went through a bake-off of seven directions, each built as a
+ * complete page and compared under /explore. `craft` won, the rest were deleted
+ * along with the harness, and the homepage now simply renders it. */
export default function HomePage() {
- const direction = getDirection(DEFAULT_DIRECTION) ?? null;
- return direction ? direction.render() : null;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/web/app/privacy/page.tsx b/web/app/privacy/page.tsx
index 70fcfb1..640d68d 100644
--- a/web/app/privacy/page.tsx
+++ b/web/app/privacy/page.tsx
@@ -5,6 +5,7 @@ import styles from '../legal-page.module.css';
export const metadata: Metadata = {
title: 'Privacy Policy · video2ctx',
description: 'How video2ctx collects, uses, protects, and deletes personal information and Google user data.',
+ alternates: { canonical: '/privacy' },
};
export default function PrivacyPage() {
diff --git a/web/app/robots.ts b/web/app/robots.ts
new file mode 100644
index 0000000..bb0dc3b
--- /dev/null
+++ b/web/app/robots.ts
@@ -0,0 +1,14 @@
+import type { MetadataRoute } from 'next';
+import { SITE_URL } from '../lib/site.ts';
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: {
+ userAgent: '*',
+ allow: '/',
+ disallow: ['/api/', '/dashboard', '/device'],
+ },
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ host: SITE_URL,
+ };
+}
diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts
new file mode 100644
index 0000000..03603d7
--- /dev/null
+++ b/web/app/sitemap.ts
@@ -0,0 +1,25 @@
+import type { MetadataRoute } from 'next';
+import { SITE_URL } from '../lib/site.ts';
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ return [
+ {
+ url: `${SITE_URL}/`,
+ lastModified: '2026-08-22',
+ changeFrequency: 'weekly',
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/privacy`,
+ lastModified: '2026-08-14',
+ changeFrequency: 'yearly',
+ priority: 0.2,
+ },
+ {
+ url: `${SITE_URL}/terms`,
+ lastModified: '2026-08-10',
+ changeFrequency: 'yearly',
+ priority: 0.2,
+ },
+ ];
+}
diff --git a/web/app/terms/page.tsx b/web/app/terms/page.tsx
index 3521100..bd9b999 100644
--- a/web/app/terms/page.tsx
+++ b/web/app/terms/page.tsx
@@ -4,6 +4,7 @@ import { LegalPage } from '../_components/legal-page';
export const metadata: Metadata = {
title: 'Terms of Service · video2ctx',
description: 'The terms governing access to and use of video2ctx.',
+ alternates: { canonical: '/terms' },
};
export default function TermsPage() {
diff --git a/web/app/twitter-image.alt.txt b/web/app/twitter-image.alt.txt
new file mode 100644
index 0000000..65e7b1c
--- /dev/null
+++ b/web/app/twitter-image.alt.txt
@@ -0,0 +1 @@
+video2ctx social card with the text "Video context for agents" beside a dark voxel landscape carved with video icons
diff --git a/web/app/twitter-image.jpg b/web/app/twitter-image.jpg
new file mode 100644
index 0000000..f29ff94
Binary files /dev/null and b/web/app/twitter-image.jpg differ
diff --git a/web/lib/metadata-routes.test.ts b/web/lib/metadata-routes.test.ts
new file mode 100644
index 0000000..566f5b8
--- /dev/null
+++ b/web/lib/metadata-routes.test.ts
@@ -0,0 +1,26 @@
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+import robots from '../app/robots.ts';
+import sitemap from '../app/sitemap.ts';
+import { SITE_URL } from './site.ts';
+
+describe('public discovery metadata', () => {
+ test('publishes a canonical sitemap and keeps private application routes out of crawlers', () => {
+ assert.deepEqual(robots(), {
+ rules: {
+ userAgent: '*',
+ allow: '/',
+ disallow: ['/api/', '/dashboard', '/device'],
+ },
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ host: SITE_URL,
+ });
+ });
+
+ test('lists only canonical public pages', () => {
+ assert.deepEqual(
+ sitemap().map(({ url }) => url),
+ [`${SITE_URL}/`, `${SITE_URL}/privacy`, `${SITE_URL}/terms`],
+ );
+ });
+});
diff --git a/web/lib/site.ts b/web/lib/site.ts
new file mode 100644
index 0000000..b63ba73
--- /dev/null
+++ b/web/lib/site.ts
@@ -0,0 +1,20 @@
+export const SITE_NAME = 'video2ctx';
+export const SITE_URL = 'https://www.video2ctx.dev';
+/* Renders as , og:description, twitter:description and
+ * the JSON-LD WebSite block. It is the search snippet and the link-preview body,
+ * not a tagline: the title already carries the positioning, so this should add
+ * rather than restate. Under ~158 characters, past which Google truncates.
+ *
+ * Two framings this has already been through and should not go back to. It once
+ * opened "Paste a YouTube URL", which reads as a single integration when
+ * provider routes are namespaced and a second source slots in beside YouTube.
+ * It then led with transcript, channel, comments and playlist, which sells the
+ * primitives as the product rather than the layer built on them.
+ *
+ * The surfaces named here are the ones that ship: skills, the @video2ctx/cli,
+ * and the hosted API. Do not call `all-things-youtube` an SDK — it is a
+ * server-side TypeScript client for YouTube data with no hosted service in the
+ * path, so listing it beside "hosted API" implies a client for that API. */
+export const SITE_DESCRIPTION =
+ 'Search, extract, and monitor video context with agent skills, a CLI, and a hosted API.';
+export const HOME_TITLE = 'video2ctx | video context for AI agents';
diff --git a/web/public/scene/README.md b/web/public/scene/README.md
index 10753a5..34ccf2d 100644
--- a/web/public/scene/README.md
+++ b/web/public/scene/README.md
@@ -1,33 +1,214 @@
# Fold background scene
-Source artwork for the landing fold, generated with GPT Image 2 and committed as
-webp only — the PNG originals are ~2 MB each and are not kept in the repo.
+A locked-camera voxel landscape with five carved video-tool symbols, looping
+behind the landing fold. Source material lives in `brand/`, not here — this
+directory holds only what is served.
-| File | Size | Serves |
-| --- | --- | --- |
-| `voxel-horizon.webp` | 1672 × 941 | desktop and laptop |
-| `voxel-horizon-portrait.webp` | 1086 × 1448 | portrait phones and tablets |
-| `voxel-horizon-900.webp` | 900 wide | narrow landscape, e.g. a rotated phone |
+## What ships
-`` resolves exactly one of these per visitor; the totals here are not
-page weight.
+| File | Serves |
+| --- | --- |
+| `fold-scene-symbols.webm` / `.mp4` | the loop, on pointer desktops |
+| `fold-scene-symbols-poster.webp` | first paint, and the resting still under the flashlight |
+| `voxel-horizon-symbols.webp` | 1672 × 941, the `` fallback |
+| `voxel-horizon-symbols-900.webp` | narrow landscape, e.g. a rotated phone |
+| `fold-scene-portrait.webm` / `.mp4` / `-poster.webp` | the portrait loop, a crop of the landscape one |
+| `voxel-icon-glow-mask.webp` | the flashlight's symbol glow, see below |
-## Regenerating
+`` resolves exactly one still per visitor, and `` order means
+each visitor fetches exactly one video. The totals above are repo size, not page
+weight. AV1 is served first; the H.264 sibling exists because Safari's AV1
+support is hardware-gated to M3 / A17 Pro and newer, so every Intel Mac, M1, M2
+and pre-15-Pro iPhone falls through to it.
-Method and prompting guidance: `reference/design/HIGGSFIELD_ASSET_GUIDE.md`.
+Both mp4s are crf 24, not a sharper number. Edge energy at crf 24 matches the
+AV1 the majority actually receive (23.73 against 23.72); the previous crf 21 was
+*sharper than the primary asset* at 24.28, spending 636 KB to over-encode a
+fallback.
+### The portrait loop
-Prompts and the exact settings are in `reference/design/CINEMATIC_ASSETS.md`. To re-encode a
-new PNG:
+Not its own render. Re-crop it whenever the landscape loop changes, or portrait
+silently keeps showing the previous scene:
```bash
-cwebp -quiet -q 82 -m 6 voxel-horizon.png -o voxel-horizon.webp
-cwebp -quiet -q 78 -m 6 -resize 900 0 voxel-horizon.png -o voxel-horizon-900.webp
+ffmpeg -y -i fold-scene-symbols.webm -vf "crop=416:720:240:0,scale=440:762" -an \
+ -c:v libsvtav1 -crf 36 -preset 4 -g 48 -pix_fmt yuv420p fold-scene-portrait.webm
+ffmpeg -y -i fold-scene-symbols.webm -vf "crop=416:720:240:0,scale=440:762" -an \
+ -c:v libx264 -crf 24 -preset veryslow -g 48 -pix_fmt yuv420p fold-scene-portrait.mp4
```
-Quality 82 rather than the visually-identical 72 is deliberate: the scene is
-mostly near-black, and dark gradients are where webp banding shows first. There
-is no automated build step — these are committed artefacts.
+The `x=240` offset is chosen, not incidental. It trades symbol coverage for copy
+legibility: further left holds more carvings but pushes brighter terrain up
+behind the headline. It keeps the timeline and speaker whole and clips the play
+panel at the frame edge. Earlier it sat at `x=432`, which caught only the
+speaker and gave phones the emptiest part of the scene.
-Compositing (dimming, mask, parallax, flashlight) is all in `web/app/craft.css`,
-so the look can be retuned without regenerating anything.
+Portrait phones get `fold-scene-portrait-poster.webp` as their resting still —
+literally the loop's own frame 0, so still and clip cannot disagree. There used
+to be separate 3:4 artwork here (`voxel-horizon-portrait.webp`, now in
+`brand/source/`), and it went stale the moment the symbols landed: phones saw a
+symbol-less still behind a loop that had them.
+
+Everything here is served. Superseded renders and the stills the mask is derived
+from live in `brand/generated/` and `brand/source/`.
+
+## How the current loop was made
+
+1. **The symbols** were carved into the scene still with the built-in GPT Image
+ editor, producing `brand/source/voxel-horizon-video-icons-gpt.webp`. It is a
+ surgical edit of `voxel-horizon.webp`: 1.85% of pixels change, all of them at
+ the five symbol sites, so terrain, palette and exposure are untouched.
+2. **The video edit** took the previous loop plus that still as a reference.
+ Prompt, settings and the measured result: `brand/prompts/voxel-horizon-symbols-videoedit.txt`.
+ **The raw render is not kept in the repo** — only the graded loop below. To
+ change the grade, the trim, the crossfade or the codec you need that raw file
+ back, which means re-running the video edit.
+3. **Grade, loop and encode**, one pass straight from the raw download. The
+ render came back with lifted blacks, lost saturation and an audio track:
+
+```bash
+SRC=
+GRADE="lutyuv=y='clip(val-6*max(0\,1-val/25)\,0\,255)',eq=saturation=1.12"
+FC="[0]${GRADE},setpts=PTS-STARTPTS,split=3[m][t][h];\
+[m]trim=0:6.2,setpts=PTS-STARTPTS[main];\
+[t]trim=6.2,setpts=PTS-STARTPTS[tail];\
+[h]trim=0:0.8,setpts=PTS-STARTPTS[head];\
+[tail][head]blend=all_expr='A*(1-(T/0.8))+B*(T/0.8)'[mix];\
+[main][mix]concat=n=2:v=1,fps=24"
+
+ffmpeg -y -ss 1.0 -i "$SRC" -filter_complex "$FC" -an \
+ -c:v libsvtav1 -crf 36 -preset 4 -g 48 -pix_fmt yuv420p -movflags +faststart \
+ fold-scene-symbols.webm
+ffmpeg -y -ss 1.0 -i "$SRC" -filter_complex "$FC" -an \
+ -c:v libx264 -crf 24 -preset veryslow -g 48 -pix_fmt yuv420p -movflags +faststart \
+ fold-scene-symbols.mp4
+ffmpeg -y -i fold-scene-symbols.webm -frames:v 1 /tmp/p.png \
+ && cwebp -quiet -q 86 -m 6 /tmp/p.png -o fold-scene-symbols-poster.webp
+```
+
+The grade is YUV-native on purpose. `curves` and `colorlevels` are RGB filters,
+and the round trip retags the stream as limited range, clamping blacks to 16 —
+the opposite of the correction being applied. The taper means the shadow pull
+reaches zero by luma 25, so midtones keep their level and low-contrast fog
+detail survives.
+
+Trim, crossfade and encode belong in **one** ffmpeg invocation. An accidental
+intermediate re-encode once cost 60% of the bitrate before the browser saw it.
+
+Stills are re-encoded from the GPT source at the same quality as before. 82
+rather than the visually identical 72 is deliberate: the scene is mostly
+near-black, and dark gradients are where webp banding shows first.
+
+```bash
+dwebp brand/source/voxel-horizon-video-icons-gpt.webp -o /tmp/src.png
+cwebp -quiet -q 82 -m 6 /tmp/src.png -o voxel-horizon-symbols.webp
+cwebp -quiet -q 78 -m 6 -resize 900 0 /tmp/src.png -o voxel-horizon-symbols-900.webp
+```
+
+## The symbol glow mask
+
+`voxel-icon-glow-mask.webp` is the alpha the flashlight uses to warm the
+symbols. It is **derived, not drawn** — the difference between the still with
+symbols and the one without, high-passed to keep the carved edges:
+
+```bash
+# 1. the difference between the two stills IS the symbol layer.
+# Both inputs live in brand/source/ — neither is served, and neither is
+# optional. Run from the repo root.
+ffmpeg -y -i brand/source/voxel-horizon.webp \
+ -i brand/source/voxel-horizon-video-icons-gpt.webp \
+ -filter_complex "[0][1]blend=all_mode=difference,format=gray" -frames:v 1 /tmp/raw.png
+
+# 2. drop the noise floor FIRST. Both stills are webp, and their compression
+# noise is a few counts everywhere; step 3 multiplies by 6, which turns that
+# into a field of speckle across the whole frame.
+ffmpeg -y -i /tmp/raw.png -vf "lut=y='if(gt(val,14),val,0)'" -frames:v 1 /tmp/floor.png
+
+# 3. high-pass to keep the carved edges and discard the broad panel lift
+ffmpeg -y -i /tmp/floor.png -filter_complex \
+ "[0]split[a][b];[b]gblur=sigma=7[lo];[a][lo]blend=all_mode=difference,format=gray,\
+ lut=y='min(255,val*6)',gblur=sigma=1.1,\
+ curves=all='0/0 0.10/0.10 0.30/0.62 0.55/0.95 1/1'" -frames:v 1 /tmp/dog.png
+
+# 4. region gate, in two halves. A single global gate cannot work here — see
+# the note below. Strict gate for the cluttered lit terrain:
+ffmpeg -y -i /tmp/raw.png -vf \
+ "lut=y='if(gt(val,14),255,0)',gblur=sigma=16,lut=y='if(gt(val,18),255,0)',\
+ dilation,dilation,gblur=sigma=5" -frames:v 1 /tmp/gate_tight.png
+
+# 5. permissive gate, admitted only where the stone is dark and therefore has
+# no terrain-edge clutter to reject
+ffmpeg -y -i brand/source/voxel-horizon-video-icons-gpt.webp -vf \
+ "format=gray,gblur=sigma=30,lut=y='clip((75-val)*8.5,0,255)',gblur=sigma=8" \
+ -frames:v 1 /tmp/dark.png
+ffmpeg -y -i /tmp/raw.png -vf \
+ "lut=y='if(gt(val,10),255,0)',gblur=sigma=14,lut=y='if(gt(val,8),255,0)',\
+ dilation,dilation,dilation,gblur=sigma=5" -frames:v 1 /tmp/gate_loose.png
+ffmpeg -y -i /tmp/gate_loose.png -i /tmp/dark.png \
+ -filter_complex "[0][1]blend=all_mode=multiply,format=gray" -frames:v 1 /tmp/loose_dark.png
+ffmpeg -y -i /tmp/gate_tight.png -i /tmp/loose_dark.png \
+ -filter_complex "[0][1]blend=all_mode=lighten,format=gray" -frames:v 1 /tmp/gate.png
+
+# 6. combine, then white-with-alpha at the video's native size
+ffmpeg -y -i /tmp/dog.png -i /tmp/gate.png \
+ -filter_complex "[0][1]blend=all_mode=multiply,format=gray" -frames:v 1 /tmp/mask.png
+ffmpeg -y -i /tmp/mask.png -filter_complex \
+ "[0]scale=1280:720,format=gray[m];color=c=white:s=1280x720[w];[w][m]alphamerge,format=rgba" \
+ -frames:v 1 /tmp/final.png
+cwebp -quiet -q 88 -alpha_q 100 -m 6 /tmp/final.png -o voxel-icon-glow-mask.webp
+```
+
+### Why the gate is split
+
+A single global gate cannot serve this frame, and trying costs the crop-frame.
+
+The speckle is on the **lit left**, where terrain edges are dense and the diff's
+webp noise has plenty of structure to latch onto. The crop-frame is on the
+**dark right**: four thin strokes with wide gaps, low local density, nothing
+around it. Any gate strict enough to reject the first also rejects the second.
+
+Measured on the shipped 1280 × 720 masks, mean alpha inside each symbol's box,
+and the fraction of the frame above alpha 8:
+
+| gate | play | timeline | speaker | crop | wave | spread |
+| --- | --- | --- | --- | --- | --- | --- |
+| loose everywhere | 43.7 | 51.2 | 28.5 | 11.9 | 20.0 | 2.32% |
+| strict everywhere | 47.6 | 59.4 | 30.9 | **9.9** | 19.7 | 2.00% |
+| split (shipped) | 47.8 | 59.6 | 31.8 | **14.3** | 21.2 | 2.14% |
+
+Strict-everywhere looks like the right answer on the aggregate and is not: the
+crop-frame stops reading on screen at 9.9. Density-based variants were worse
+still, one dropped it to 0.7 and two zeroed it. The crop-frame is the canary
+here; it is the weakest of the five by a wide margin and it fails first.
+
+When regenerating, check all five boxes individually. Spread near 2.3% means the
+noise floor in step 2 was skipped; a crop-frame under about 12 means the gate is
+too strict regardless of how clean the footprint looks.
+
+Two things this depends on, both silent if broken:
+
+- **`brand/source/voxel-horizon.webp` must stay.** It is the pre-symbol scene
+ still, the "before" half of the diff. Nothing serves it and nothing imports
+ it, so it looks like dead weight in a cleanup pass. Delete it and this mask
+ can never be regenerated from source.
+- **The mask shares the video's aspect ratio**, so `mask-size: cover` resolves to
+ the same box the video does. Replace the footage at a different aspect and the
+ symbols misregister with no error.
+
+Re-render the scene and this must be regenerated from the same pair of stills.
+
+## Known state
+
+Fog motion in the current loop measures 0.177 against the previous loop's 0.240.
+That is the render, not the encode — crf 30 only recovers to 0.194 for an extra
+231 KB. The valley is calmer than it used to be.
+
+## Compositing
+
+Dimming, the fade mask, parallax, the flashlight and the symbol glow all live in
+`web/app/craft.css`, so the look can be retuned without regenerating anything.
+The pointer loop is in `web/app/_directions/flashlight.tsx`.
+
+Method and prompting guidance for new renders:
+`reference/design/HIGGSFIELD_ASSET_GUIDE.md`.
diff --git a/web/public/scene/fold-scene-portrait-poster.webp b/web/public/scene/fold-scene-portrait-poster.webp
index b951463..7873566 100644
Binary files a/web/public/scene/fold-scene-portrait-poster.webp and b/web/public/scene/fold-scene-portrait-poster.webp differ
diff --git a/web/public/scene/fold-scene-portrait.mp4 b/web/public/scene/fold-scene-portrait.mp4
index 0467dd2..67040be 100644
Binary files a/web/public/scene/fold-scene-portrait.mp4 and b/web/public/scene/fold-scene-portrait.mp4 differ
diff --git a/web/public/scene/fold-scene-portrait.webm b/web/public/scene/fold-scene-portrait.webm
index 55ff8b3..27453e1 100644
Binary files a/web/public/scene/fold-scene-portrait.webm and b/web/public/scene/fold-scene-portrait.webm differ
diff --git a/web/public/scene/fold-scene-symbols-poster.webp b/web/public/scene/fold-scene-symbols-poster.webp
new file mode 100644
index 0000000..a9b4715
Binary files /dev/null and b/web/public/scene/fold-scene-symbols-poster.webp differ
diff --git a/web/public/scene/fold-scene-symbols.mp4 b/web/public/scene/fold-scene-symbols.mp4
new file mode 100644
index 0000000..6e3fa0e
Binary files /dev/null and b/web/public/scene/fold-scene-symbols.mp4 differ
diff --git a/web/public/scene/fold-scene-symbols.webm b/web/public/scene/fold-scene-symbols.webm
new file mode 100644
index 0000000..85c8525
Binary files /dev/null and b/web/public/scene/fold-scene-symbols.webm differ
diff --git a/web/public/scene/voxel-horizon-symbols-900.webp b/web/public/scene/voxel-horizon-symbols-900.webp
new file mode 100644
index 0000000..a1e38be
Binary files /dev/null and b/web/public/scene/voxel-horizon-symbols-900.webp differ
diff --git a/web/public/scene/voxel-horizon-symbols.webp b/web/public/scene/voxel-horizon-symbols.webp
new file mode 100644
index 0000000..db214bb
Binary files /dev/null and b/web/public/scene/voxel-horizon-symbols.webp differ
diff --git a/web/public/scene/voxel-icon-glow-mask.webp b/web/public/scene/voxel-icon-glow-mask.webp
new file mode 100644
index 0000000..077ee5e
Binary files /dev/null and b/web/public/scene/voxel-icon-glow-mask.webp differ
diff --git a/web/tokens.css b/web/tokens.css
index ca50c15..6526b81 100644
--- a/web/tokens.css
+++ b/web/tokens.css
@@ -1,7 +1,7 @@
/* Shared tokens. Two systems draw on this file:
*
* dashboard --color-dashboard-* warm dark paper, the application shell
- * landing --color-home-* light paper, used by /explore contenders
+ * landing --color-home-* light paper, used by shared marketing components
*
* The live landing direction (Craft) declares its own dark palette locally in
* app/craft.css and is stamped there. Its tokens are NOT here — see debt 1 in