What changed, and why

The landing was a static pitch. This embeds the demo reel so the home page shows the game in motion, and adds a dark variant so it fits whichever theme you're in.

Two pieces carry the change. The reel now renders in both themes from one source (marketing/reel/reel.html parametrized by ?theme=), and pages/index.vue embeds the two renders so they crossfade — and keep playing — when you toggle the theme. The interesting bit is that last part: the swap is mid-playback, not a restart.

The embed: two renders, stacked

Both MP4s are mounted at once, absolutely stacked in a fixed-ratio frame. Each is a muted autoplay loop; the one matching the theme is faded in via the is-on class, the other faded out. They serve from public/reel/.

The reel panel — light + dark <video>, shown by theme.

pages/index.vue · 173 lines
pages/index.vue173 lines · Vue
⋯ 32 lines hidden (lines 1–32)
1<template>
2 <!-- Landing: a short orientation before the board. What the game is, how a turn
3 works, and the one promise that gets you in — the first run is free. Kept to a
4 single scannable screen in the warm-ledger aesthetic; "Begin" is one tap in. -->
5 <div class="min-h-screen rv-bg flex flex-col">
6 <header class="border-b rv-line">
7 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
8 <GameTitle />
9 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
10 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
11 </div>
12 </header>
13 
14 <main class="flex-1 px-5 py-10 sm:py-14">
15 <div class="max-w-2xl mx-auto">
16 <!-- Hero -->
17 <p class="rv-label">A history you are writing</p>
18 <h2 class="rv-serif rv-fg text-4xl sm:text-5xl font-bold mt-2 leading-[1.05]">
19 Rewrite history,<br >one message at a time.
20 </h2>
21 <p class="rv-muted text-base sm:text-lg mt-4 leading-relaxed max-w-xl">
22 Send a 160-character message to anyone who ever lived. A roll of the dice and an
23 AI decide how the timeline bends — for better or worse.
24 </p>
25 
26 <div class="mt-7 flex flex-col sm:flex-row sm:items-center gap-3">
27 <NuxtLink to="/play" data-testid="begin-cta" class="rv-btn rv-btn--primary text-base">
28 Begin — your first run is free <span aria-hidden="true"></span>
29 </NuxtLink>
30 <span class="rv-faint text-xs">More runs are one-time packs · they never expire.</span>
31 </div>
32 
33 <!-- The reel — light + dark renders stacked; the one matching the theme shows.
34 The two are frame-identical, so toggling theme crossfades AND hands playback
35 over at the same timestamp (see the watch in the script): the reel keeps
36 playing through the swap, as if it re-colored with the page. Reduced-motion
37 users get the static poster (autoplay off). -->
38 <div data-testid="reel" class="reel mt-10 rounded-lg overflow-hidden border rv-line">
39 <video ref="lightVid" class="reel-vid" :class="{ 'is-on': !isDark }"
40 autoplay loop muted playsinline preload="auto"
41 poster="/reel/reel-light-poster.png" aria-label="A demo of Revisionist gameplay">
42 <source src="/reel/reel-light.mp4" type="video/mp4" />
43 </video>
44 <video ref="darkVid" class="reel-vid" :class="{ 'is-on': isDark }"
45 autoplay loop muted playsinline preload="auto"
46 poster="/reel/reel-dark-poster.png" aria-hidden="true">
47 <source src="/reel/reel-dark.mp4" type="video/mp4" />
48 </video>
49 </div>
50 
⋯ 123 lines hidden (lines 51–173)
51 <!-- How it works — three ledger beats -->
52 <div class="mt-12 border-t rv-line">
53 <div v-for="(step, i) in steps" :key="step.title" class="flex items-start gap-4 py-4 border-b rv-line">
54 <span class="rv-mono rv-accent text-sm font-semibold mt-0.5 w-5 shrink-0">{{ i + 1 }}</span>
55 <span class="text-2xl leading-none mt-0.5 w-7 shrink-0 text-center select-none" aria-hidden="true">{{ step.glyph }}</span>
56 <span class="min-w-0">
57 <h3 class="rv-fg font-semibold">{{ step.title }}</h3>
58 <p class="rv-muted text-sm mt-0.5 leading-relaxed">{{ step.body }}</p>
59 </span>
60 </div>
61 </div>
62 
63 <p class="rv-faint text-xs leading-relaxed mt-8 max-w-xl">
64 Figures are played by AI, not real people, and every reply is AI-generated
65 <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>.
66 Intended for adults (18+).
67 </p>
68 </div>
69 </main>
70 
71 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
72 <div class="max-w-2xl mx-auto flex items-center gap-2">
73 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
74 <span class="rv-hair-c" aria-hidden="true">·</span>
75 <span class="rv-serif italic">the past is not fixed</span>
76 </div>
77 </footer>
78 </div>
79</template>
80 
81<script setup lang="ts">
82/**
83 * Landing — the orientation screen at /. Explains the premise and a turn's shape,
84 * then sends the player into the game at /play (the first run is free). Deliberately
85 * static: no store, no AI calls; the dark toggle mirrors the game's so the choice
86 * carries across.
87 */
88import { computed, ref, watch } from 'vue'
89 
90const steps = [
91 {
92 glyph: '✶',
93 title: 'Pick a cause',
94 body: 'A wrong to right, an era to remake. Choose from curated objectives — or have a fresh one composed for you.'
95 },
96 {
97 glyph: '✎',
98 title: 'Write to history',
99 body: 'Choose a figure and a year, then send up to 160 characters. Five messages to a run — make them count.'
100 },
101 {
102 glyph: '🎲',
103 title: 'Watch it bend',
104 body: 'A D20 and the Timeline Engine resolve each move. The world rewrites itself, told back to you in a living chronicle.'
105 }
107 
108// Dark-mode toggle, shared with the game via @nuxtjs/color-mode (persists + respects
109// the OS preference), so a choice made here carries into /play and back.
110const colorMode = useColorMode()
111const isDark = computed(() => colorMode.value === 'dark')
112function toggleDark() {
113 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
115 
116// The reel: a light and a dark render of the SAME (frame-identical) animation,
117// stacked and crossfaded by theme. On a theme toggle, hand playback to the
118// now-visible video at the same timestamp so it keeps playing through the swap —
119// it reads as the reel re-coloring with the page rather than restarting. Both
120// videos play in lockstep; this corrects any drift at the moment of the switch.
121const reduced = usePrefersReducedMotion()
122const lightVid = ref<HTMLVideoElement | null>(null)
123const darkVid = ref<HTMLVideoElement | null>(null)
124watch(isDark, (dark) => {
125 if (reduced.value) return
126 const from = dark ? lightVid.value : darkVid.value
127 const to = dark ? darkVid.value : lightVid.value
128 if (!from || !to) return
129 try { to.currentTime = from.currentTime } catch { /* metadata not ready yet */ }
130 const p = to.play()
131 if (p) p.catch(() => { /* autoplay race — ignore */ })
132})
133 
134// Respect reduced-motion: the videos `autoplay` (so they start without waiting on
135// JS), but if the user prefers reduced motion we stop them and rest on the first
136// frame — the title card, which is also the poster. Detected after mount, so this
137// fires once matchMedia resolves.
138watch(reduced, (r) => {
139 if (!r) return
140 for (const v of [lightVid.value, darkVid.value]) {
141 if (!v) continue
142 v.pause()
143 try { v.currentTime = 0 } catch { /* not ready */ }
144 }
145}, { immediate: true })
146 
147useHead({
148 title: 'Revisionist — Rewrite History',
149 meta: [
150 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends. Your first run is free.' }
151 ]
152})
153</script>
154 
155<style scoped>
156/* The reel frame holds the two stacked renders at the reel's native 1000:560. The
157 matching theme is faded in; both play, so the crossfade morphs the palette. */
158.reel { position: relative; aspect-ratio: 1000 / 560; background: var(--rv-bg); }
159.reel-vid {
160 position: absolute;
161 inset: 0;
162 width: 100%;
163 height: 100%;
164 object-fit: cover;
165 display: block;
166 opacity: 0;
167 transition: opacity 240ms ease;
169.reel-vid.is-on { opacity: 1; }
170@media (prefers-reduced-motion: reduce) {
171 .reel-vid { transition: none; }
173</style>

The frame holds the native 1000:560; both videos stack and crossfade on opacity.

pages/index.vue · 173 lines
pages/index.vue173 lines · Vue
⋯ 155 lines hidden (lines 1–155)
1<template>
2 <!-- Landing: a short orientation before the board. What the game is, how a turn
3 works, and the one promise that gets you in — the first run is free. Kept to a
4 single scannable screen in the warm-ledger aesthetic; "Begin" is one tap in. -->
5 <div class="min-h-screen rv-bg flex flex-col">
6 <header class="border-b rv-line">
7 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
8 <GameTitle />
9 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
10 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
11 </div>
12 </header>
13 
14 <main class="flex-1 px-5 py-10 sm:py-14">
15 <div class="max-w-2xl mx-auto">
16 <!-- Hero -->
17 <p class="rv-label">A history you are writing</p>
18 <h2 class="rv-serif rv-fg text-4xl sm:text-5xl font-bold mt-2 leading-[1.05]">
19 Rewrite history,<br >one message at a time.
20 </h2>
21 <p class="rv-muted text-base sm:text-lg mt-4 leading-relaxed max-w-xl">
22 Send a 160-character message to anyone who ever lived. A roll of the dice and an
23 AI decide how the timeline bends — for better or worse.
24 </p>
25 
26 <div class="mt-7 flex flex-col sm:flex-row sm:items-center gap-3">
27 <NuxtLink to="/play" data-testid="begin-cta" class="rv-btn rv-btn--primary text-base">
28 Begin — your first run is free <span aria-hidden="true"></span>
29 </NuxtLink>
30 <span class="rv-faint text-xs">More runs are one-time packs · they never expire.</span>
31 </div>
32 
33 <!-- The reel — light + dark renders stacked; the one matching the theme shows.
34 The two are frame-identical, so toggling theme crossfades AND hands playback
35 over at the same timestamp (see the watch in the script): the reel keeps
36 playing through the swap, as if it re-colored with the page. Reduced-motion
37 users get the static poster (autoplay off). -->
38 <div data-testid="reel" class="reel mt-10 rounded-lg overflow-hidden border rv-line">
39 <video ref="lightVid" class="reel-vid" :class="{ 'is-on': !isDark }"
40 autoplay loop muted playsinline preload="auto"
41 poster="/reel/reel-light-poster.png" aria-label="A demo of Revisionist gameplay">
42 <source src="/reel/reel-light.mp4" type="video/mp4" />
43 </video>
44 <video ref="darkVid" class="reel-vid" :class="{ 'is-on': isDark }"
45 autoplay loop muted playsinline preload="auto"
46 poster="/reel/reel-dark-poster.png" aria-hidden="true">
47 <source src="/reel/reel-dark.mp4" type="video/mp4" />
48 </video>
49 </div>
50 
51 <!-- How it works — three ledger beats -->
52 <div class="mt-12 border-t rv-line">
53 <div v-for="(step, i) in steps" :key="step.title" class="flex items-start gap-4 py-4 border-b rv-line">
54 <span class="rv-mono rv-accent text-sm font-semibold mt-0.5 w-5 shrink-0">{{ i + 1 }}</span>
55 <span class="text-2xl leading-none mt-0.5 w-7 shrink-0 text-center select-none" aria-hidden="true">{{ step.glyph }}</span>
56 <span class="min-w-0">
57 <h3 class="rv-fg font-semibold">{{ step.title }}</h3>
58 <p class="rv-muted text-sm mt-0.5 leading-relaxed">{{ step.body }}</p>
59 </span>
60 </div>
61 </div>
62 
63 <p class="rv-faint text-xs leading-relaxed mt-8 max-w-xl">
64 Figures are played by AI, not real people, and every reply is AI-generated
65 <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>.
66 Intended for adults (18+).
67 </p>
68 </div>
69 </main>
70 
71 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
72 <div class="max-w-2xl mx-auto flex items-center gap-2">
73 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
74 <span class="rv-hair-c" aria-hidden="true">·</span>
75 <span class="rv-serif italic">the past is not fixed</span>
76 </div>
77 </footer>
78 </div>
79</template>
80 
81<script setup lang="ts">
82/**
83 * Landing — the orientation screen at /. Explains the premise and a turn's shape,
84 * then sends the player into the game at /play (the first run is free). Deliberately
85 * static: no store, no AI calls; the dark toggle mirrors the game's so the choice
86 * carries across.
87 */
88import { computed, ref, watch } from 'vue'
89 
90const steps = [
91 {
92 glyph: '✶',
93 title: 'Pick a cause',
94 body: 'A wrong to right, an era to remake. Choose from curated objectives — or have a fresh one composed for you.'
95 },
96 {
97 glyph: '✎',
98 title: 'Write to history',
99 body: 'Choose a figure and a year, then send up to 160 characters. Five messages to a run — make them count.'
100 },
101 {
102 glyph: '🎲',
103 title: 'Watch it bend',
104 body: 'A D20 and the Timeline Engine resolve each move. The world rewrites itself, told back to you in a living chronicle.'
105 }
107 
108// Dark-mode toggle, shared with the game via @nuxtjs/color-mode (persists + respects
109// the OS preference), so a choice made here carries into /play and back.
110const colorMode = useColorMode()
111const isDark = computed(() => colorMode.value === 'dark')
112function toggleDark() {
113 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
115 
116// The reel: a light and a dark render of the SAME (frame-identical) animation,
117// stacked and crossfaded by theme. On a theme toggle, hand playback to the
118// now-visible video at the same timestamp so it keeps playing through the swap —
119// it reads as the reel re-coloring with the page rather than restarting. Both
120// videos play in lockstep; this corrects any drift at the moment of the switch.
121const reduced = usePrefersReducedMotion()
122const lightVid = ref<HTMLVideoElement | null>(null)
123const darkVid = ref<HTMLVideoElement | null>(null)
124watch(isDark, (dark) => {
125 if (reduced.value) return
126 const from = dark ? lightVid.value : darkVid.value
127 const to = dark ? darkVid.value : lightVid.value
128 if (!from || !to) return
129 try { to.currentTime = from.currentTime } catch { /* metadata not ready yet */ }
130 const p = to.play()
131 if (p) p.catch(() => { /* autoplay race — ignore */ })
132})
133 
134// Respect reduced-motion: the videos `autoplay` (so they start without waiting on
135// JS), but if the user prefers reduced motion we stop them and rest on the first
136// frame — the title card, which is also the poster. Detected after mount, so this
137// fires once matchMedia resolves.
138watch(reduced, (r) => {
139 if (!r) return
140 for (const v of [lightVid.value, darkVid.value]) {
141 if (!v) continue
142 v.pause()
143 try { v.currentTime = 0 } catch { /* not ready */ }
144 }
145}, { immediate: true })
146 
147useHead({
148 title: 'Revisionist — Rewrite History',
149 meta: [
150 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends. Your first run is free.' }
151 ]
152})
153</script>
154 
155<style scoped>
156/* The reel frame holds the two stacked renders at the reel's native 1000:560. The
157 matching theme is faded in; both play, so the crossfade morphs the palette. */
158.reel { position: relative; aspect-ratio: 1000 / 560; background: var(--rv-bg); }
159.reel-vid {
160 position: absolute;
161 inset: 0;
162 width: 100%;
163 height: 100%;
164 object-fit: cover;
165 display: block;
166 opacity: 0;
167 transition: opacity 240ms ease;
169.reel-vid.is-on { opacity: 1; }
170@media (prefers-reduced-motion: reduce) {
171 .reel-vid { transition: none; }
⋯ 1 line hidden (lines 173–173)
173</style>

Keeping it playing through the theme swap

The point of the request: switching theme should look like the reel re-coloring, not a new clip starting. Both videos autoplay from mount, so they run in lockstep. When the theme toggles, we copy currentTime from the outgoing video to the incoming one and call play() — so the now-visible render picks up at the exact same frame and continues. The CSS opacity crossfade hides the handoff.

On a theme change: sync currentTime old→new, then play the now-visible video.

pages/index.vue · 173 lines
pages/index.vue173 lines · Vue
⋯ 117 lines hidden (lines 1–117)
1<template>
2 <!-- Landing: a short orientation before the board. What the game is, how a turn
3 works, and the one promise that gets you in — the first run is free. Kept to a
4 single scannable screen in the warm-ledger aesthetic; "Begin" is one tap in. -->
5 <div class="min-h-screen rv-bg flex flex-col">
6 <header class="border-b rv-line">
7 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
8 <GameTitle />
9 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
10 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
11 </div>
12 </header>
13 
14 <main class="flex-1 px-5 py-10 sm:py-14">
15 <div class="max-w-2xl mx-auto">
16 <!-- Hero -->
17 <p class="rv-label">A history you are writing</p>
18 <h2 class="rv-serif rv-fg text-4xl sm:text-5xl font-bold mt-2 leading-[1.05]">
19 Rewrite history,<br >one message at a time.
20 </h2>
21 <p class="rv-muted text-base sm:text-lg mt-4 leading-relaxed max-w-xl">
22 Send a 160-character message to anyone who ever lived. A roll of the dice and an
23 AI decide how the timeline bends — for better or worse.
24 </p>
25 
26 <div class="mt-7 flex flex-col sm:flex-row sm:items-center gap-3">
27 <NuxtLink to="/play" data-testid="begin-cta" class="rv-btn rv-btn--primary text-base">
28 Begin — your first run is free <span aria-hidden="true"></span>
29 </NuxtLink>
30 <span class="rv-faint text-xs">More runs are one-time packs · they never expire.</span>
31 </div>
32 
33 <!-- The reel — light + dark renders stacked; the one matching the theme shows.
34 The two are frame-identical, so toggling theme crossfades AND hands playback
35 over at the same timestamp (see the watch in the script): the reel keeps
36 playing through the swap, as if it re-colored with the page. Reduced-motion
37 users get the static poster (autoplay off). -->
38 <div data-testid="reel" class="reel mt-10 rounded-lg overflow-hidden border rv-line">
39 <video ref="lightVid" class="reel-vid" :class="{ 'is-on': !isDark }"
40 autoplay loop muted playsinline preload="auto"
41 poster="/reel/reel-light-poster.png" aria-label="A demo of Revisionist gameplay">
42 <source src="/reel/reel-light.mp4" type="video/mp4" />
43 </video>
44 <video ref="darkVid" class="reel-vid" :class="{ 'is-on': isDark }"
45 autoplay loop muted playsinline preload="auto"
46 poster="/reel/reel-dark-poster.png" aria-hidden="true">
47 <source src="/reel/reel-dark.mp4" type="video/mp4" />
48 </video>
49 </div>
50 
51 <!-- How it works — three ledger beats -->
52 <div class="mt-12 border-t rv-line">
53 <div v-for="(step, i) in steps" :key="step.title" class="flex items-start gap-4 py-4 border-b rv-line">
54 <span class="rv-mono rv-accent text-sm font-semibold mt-0.5 w-5 shrink-0">{{ i + 1 }}</span>
55 <span class="text-2xl leading-none mt-0.5 w-7 shrink-0 text-center select-none" aria-hidden="true">{{ step.glyph }}</span>
56 <span class="min-w-0">
57 <h3 class="rv-fg font-semibold">{{ step.title }}</h3>
58 <p class="rv-muted text-sm mt-0.5 leading-relaxed">{{ step.body }}</p>
59 </span>
60 </div>
61 </div>
62 
63 <p class="rv-faint text-xs leading-relaxed mt-8 max-w-xl">
64 Figures are played by AI, not real people, and every reply is AI-generated
65 <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>.
66 Intended for adults (18+).
67 </p>
68 </div>
69 </main>
70 
71 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
72 <div class="max-w-2xl mx-auto flex items-center gap-2">
73 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
74 <span class="rv-hair-c" aria-hidden="true">·</span>
75 <span class="rv-serif italic">the past is not fixed</span>
76 </div>
77 </footer>
78 </div>
79</template>
80 
81<script setup lang="ts">
82/**
83 * Landing — the orientation screen at /. Explains the premise and a turn's shape,
84 * then sends the player into the game at /play (the first run is free). Deliberately
85 * static: no store, no AI calls; the dark toggle mirrors the game's so the choice
86 * carries across.
87 */
88import { computed, ref, watch } from 'vue'
89 
90const steps = [
91 {
92 glyph: '✶',
93 title: 'Pick a cause',
94 body: 'A wrong to right, an era to remake. Choose from curated objectives — or have a fresh one composed for you.'
95 },
96 {
97 glyph: '✎',
98 title: 'Write to history',
99 body: 'Choose a figure and a year, then send up to 160 characters. Five messages to a run — make them count.'
100 },
101 {
102 glyph: '🎲',
103 title: 'Watch it bend',
104 body: 'A D20 and the Timeline Engine resolve each move. The world rewrites itself, told back to you in a living chronicle.'
105 }
107 
108// Dark-mode toggle, shared with the game via @nuxtjs/color-mode (persists + respects
109// the OS preference), so a choice made here carries into /play and back.
110const colorMode = useColorMode()
111const isDark = computed(() => colorMode.value === 'dark')
112function toggleDark() {
113 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
115 
116// The reel: a light and a dark render of the SAME (frame-identical) animation,
117// stacked and crossfaded by theme. On a theme toggle, hand playback to the
118// now-visible video at the same timestamp so it keeps playing through the swap —
119// it reads as the reel re-coloring with the page rather than restarting. Both
120// videos play in lockstep; this corrects any drift at the moment of the switch.
121const reduced = usePrefersReducedMotion()
122const lightVid = ref<HTMLVideoElement | null>(null)
123const darkVid = ref<HTMLVideoElement | null>(null)
124watch(isDark, (dark) => {
125 if (reduced.value) return
126 const from = dark ? lightVid.value : darkVid.value
127 const to = dark ? darkVid.value : lightVid.value
128 if (!from || !to) return
129 try { to.currentTime = from.currentTime } catch { /* metadata not ready yet */ }
130 const p = to.play()
131 if (p) p.catch(() => { /* autoplay race — ignore */ })
132})
133 
134// Respect reduced-motion: the videos `autoplay` (so they start without waiting on
135// JS), but if the user prefers reduced motion we stop them and rest on the first
136// frame — the title card, which is also the poster. Detected after mount, so this
⋯ 37 lines hidden (lines 137–173)
137// fires once matchMedia resolves.
138watch(reduced, (r) => {
139 if (!r) return
140 for (const v of [lightVid.value, darkVid.value]) {
141 if (!v) continue
142 v.pause()
143 try { v.currentTime = 0 } catch { /* not ready */ }
144 }
145}, { immediate: true })
146 
147useHead({
148 title: 'Revisionist — Rewrite History',
149 meta: [
150 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends. Your first run is free.' }
151 ]
152})
153</script>
154 
155<style scoped>
156/* The reel frame holds the two stacked renders at the reel's native 1000:560. The
157 matching theme is faded in; both play, so the crossfade morphs the palette. */
158.reel { position: relative; aspect-ratio: 1000 / 560; background: var(--rv-bg); }
159.reel-vid {
160 position: absolute;
161 inset: 0;
162 width: 100%;
163 height: 100%;
164 object-fit: cover;
165 display: block;
166 opacity: 0;
167 transition: opacity 240ms ease;
169.reel-vid.is-on { opacity: 1; }
170@media (prefers-reduced-motion: reduce) {
171 .reel-vid { transition: none; }
173</style>

Reduced motion

The videos carry autoplay so they start without waiting on JS. But autoplay can't stop an already-playing video, so reduced-motion is handled explicitly: when the preference is detected (after mount, via matchMedia), both videos are paused and reset to frame 0 — which is the title card, the same image as the poster. So a reduced-motion visitor sees a still, theme-correct frame, no motion.

Pause + rest on the title-card frame when prefers-reduced-motion is set.

pages/index.vue · 173 lines
pages/index.vue173 lines · Vue
⋯ 137 lines hidden (lines 1–137)
1<template>
2 <!-- Landing: a short orientation before the board. What the game is, how a turn
3 works, and the one promise that gets you in — the first run is free. Kept to a
4 single scannable screen in the warm-ledger aesthetic; "Begin" is one tap in. -->
5 <div class="min-h-screen rv-bg flex flex-col">
6 <header class="border-b rv-line">
7 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
8 <GameTitle />
9 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
10 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
11 </div>
12 </header>
13 
14 <main class="flex-1 px-5 py-10 sm:py-14">
15 <div class="max-w-2xl mx-auto">
16 <!-- Hero -->
17 <p class="rv-label">A history you are writing</p>
18 <h2 class="rv-serif rv-fg text-4xl sm:text-5xl font-bold mt-2 leading-[1.05]">
19 Rewrite history,<br >one message at a time.
20 </h2>
21 <p class="rv-muted text-base sm:text-lg mt-4 leading-relaxed max-w-xl">
22 Send a 160-character message to anyone who ever lived. A roll of the dice and an
23 AI decide how the timeline bends — for better or worse.
24 </p>
25 
26 <div class="mt-7 flex flex-col sm:flex-row sm:items-center gap-3">
27 <NuxtLink to="/play" data-testid="begin-cta" class="rv-btn rv-btn--primary text-base">
28 Begin — your first run is free <span aria-hidden="true"></span>
29 </NuxtLink>
30 <span class="rv-faint text-xs">More runs are one-time packs · they never expire.</span>
31 </div>
32 
33 <!-- The reel — light + dark renders stacked; the one matching the theme shows.
34 The two are frame-identical, so toggling theme crossfades AND hands playback
35 over at the same timestamp (see the watch in the script): the reel keeps
36 playing through the swap, as if it re-colored with the page. Reduced-motion
37 users get the static poster (autoplay off). -->
38 <div data-testid="reel" class="reel mt-10 rounded-lg overflow-hidden border rv-line">
39 <video ref="lightVid" class="reel-vid" :class="{ 'is-on': !isDark }"
40 autoplay loop muted playsinline preload="auto"
41 poster="/reel/reel-light-poster.png" aria-label="A demo of Revisionist gameplay">
42 <source src="/reel/reel-light.mp4" type="video/mp4" />
43 </video>
44 <video ref="darkVid" class="reel-vid" :class="{ 'is-on': isDark }"
45 autoplay loop muted playsinline preload="auto"
46 poster="/reel/reel-dark-poster.png" aria-hidden="true">
47 <source src="/reel/reel-dark.mp4" type="video/mp4" />
48 </video>
49 </div>
50 
51 <!-- How it works — three ledger beats -->
52 <div class="mt-12 border-t rv-line">
53 <div v-for="(step, i) in steps" :key="step.title" class="flex items-start gap-4 py-4 border-b rv-line">
54 <span class="rv-mono rv-accent text-sm font-semibold mt-0.5 w-5 shrink-0">{{ i + 1 }}</span>
55 <span class="text-2xl leading-none mt-0.5 w-7 shrink-0 text-center select-none" aria-hidden="true">{{ step.glyph }}</span>
56 <span class="min-w-0">
57 <h3 class="rv-fg font-semibold">{{ step.title }}</h3>
58 <p class="rv-muted text-sm mt-0.5 leading-relaxed">{{ step.body }}</p>
59 </span>
60 </div>
61 </div>
62 
63 <p class="rv-faint text-xs leading-relaxed mt-8 max-w-xl">
64 Figures are played by AI, not real people, and every reply is AI-generated
65 <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>.
66 Intended for adults (18+).
67 </p>
68 </div>
69 </main>
70 
71 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
72 <div class="max-w-2xl mx-auto flex items-center gap-2">
73 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
74 <span class="rv-hair-c" aria-hidden="true">·</span>
75 <span class="rv-serif italic">the past is not fixed</span>
76 </div>
77 </footer>
78 </div>
79</template>
80 
81<script setup lang="ts">
82/**
83 * Landing — the orientation screen at /. Explains the premise and a turn's shape,
84 * then sends the player into the game at /play (the first run is free). Deliberately
85 * static: no store, no AI calls; the dark toggle mirrors the game's so the choice
86 * carries across.
87 */
88import { computed, ref, watch } from 'vue'
89 
90const steps = [
91 {
92 glyph: '✶',
93 title: 'Pick a cause',
94 body: 'A wrong to right, an era to remake. Choose from curated objectives — or have a fresh one composed for you.'
95 },
96 {
97 glyph: '✎',
98 title: 'Write to history',
99 body: 'Choose a figure and a year, then send up to 160 characters. Five messages to a run — make them count.'
100 },
101 {
102 glyph: '🎲',
103 title: 'Watch it bend',
104 body: 'A D20 and the Timeline Engine resolve each move. The world rewrites itself, told back to you in a living chronicle.'
105 }
107 
108// Dark-mode toggle, shared with the game via @nuxtjs/color-mode (persists + respects
109// the OS preference), so a choice made here carries into /play and back.
110const colorMode = useColorMode()
111const isDark = computed(() => colorMode.value === 'dark')
112function toggleDark() {
113 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
115 
116// The reel: a light and a dark render of the SAME (frame-identical) animation,
117// stacked and crossfaded by theme. On a theme toggle, hand playback to the
118// now-visible video at the same timestamp so it keeps playing through the swap —
119// it reads as the reel re-coloring with the page rather than restarting. Both
120// videos play in lockstep; this corrects any drift at the moment of the switch.
121const reduced = usePrefersReducedMotion()
122const lightVid = ref<HTMLVideoElement | null>(null)
123const darkVid = ref<HTMLVideoElement | null>(null)
124watch(isDark, (dark) => {
125 if (reduced.value) return
126 const from = dark ? lightVid.value : darkVid.value
127 const to = dark ? darkVid.value : lightVid.value
128 if (!from || !to) return
129 try { to.currentTime = from.currentTime } catch { /* metadata not ready yet */ }
130 const p = to.play()
131 if (p) p.catch(() => { /* autoplay race — ignore */ })
132})
133 
134// Respect reduced-motion: the videos `autoplay` (so they start without waiting on
135// JS), but if the user prefers reduced motion we stop them and rest on the first
136// frame — the title card, which is also the poster. Detected after mount, so this
137// fires once matchMedia resolves.
138watch(reduced, (r) => {
139 if (!r) return
140 for (const v of [lightVid.value, darkVid.value]) {
141 if (!v) continue
142 v.pause()
143 try { v.currentTime = 0 } catch { /* not ready */ }
144 }
145}, { immediate: true })
146 
147useHead({
148 title: 'Revisionist — Rewrite History',
149 meta: [
150 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends. Your first run is free.' }
⋯ 23 lines hidden (lines 151–173)
151 ]
152})
153</script>
154 
155<style scoped>
156/* The reel frame holds the two stacked renders at the reel's native 1000:560. The
157 matching theme is faded in; both play, so the crossfade morphs the palette. */
158.reel { position: relative; aspect-ratio: 1000 / 560; background: var(--rv-bg); }
159.reel-vid {
160 position: absolute;
161 inset: 0;
162 width: 100%;
163 height: 100%;
164 object-fit: cover;
165 display: block;
166 opacity: 0;
167 transition: opacity 240ms ease;
169.reel-vid.is-on { opacity: 1; }
170@media (prefers-reduced-motion: reduce) {
171 .reel-vid { transition: none; }
173</style>

One reel source, both themes

Rather than maintain two near-identical reels, reel.html takes a ?theme= query. The dark palette is a .dark override of the same CSS variables (the app's near-black tokens + a warmer center glow), so every element — text, meter, die, bursts — adapts automatically. The die's subtle fill/facets, which were hardcoded terracotta, now use color-mix on --accent so they re-tint with the theme too.

The dark palette override + theme-aware die fill/facets.

marketing/reel/reel.html · 409 lines
marketing/reel/reel.html409 lines · HTML
⋯ 34 lines hidden (lines 1–34)
1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8" />
5<title>Revisionist — reel (light)</title>
6<style>
7 :root {
8 --bg: #faf6ee; /* warm paper */
9 --fg: #2b241c; /* sepia ink */
10 --muted: #6b5d45;
11 --faint: #7d6f57;
12 --line: #e6dcc8; /* sepia hairline */
13 --track: #ece1cb;
14 --accent: #b1532e; /* terracotta / sealing wax */
15 --accent-deep: #8f3f20;
16 --ok: #466632; /* muted olive */
17 --serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, ui-serif, serif;
18 --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
19 }
20 * { margin: 0; padding: 0; box-sizing: border-box; }
21 html, body { background: #d8cfbd; }
22 #stage {
23 position: relative;
24 width: 1000px; height: 560px;
25 background: var(--bg);
26 color: var(--fg);
27 overflow: hidden;
28 font-family: var(--serif);
29 /* a faint warm bloom up top + a soft paper vignette at the edges */
30 background-image:
31 radial-gradient(120% 80% at 50% 30%, rgba(177,83,46,0.06), rgba(250,246,238,0) 55%),
32 radial-gradient(140% 130% at 50% 50%, rgba(250,246,238,0) 62%, rgba(43,36,28,0.07) 100%);
33 }
34 /* Dark theme — same layout, the app's near-black palette + a warmer center
35 glow / stronger vignette (effects read better on dark). Selected with
36 ?theme=dark; every element uses the vars so it adapts automatically. */
37 #stage.dark {
38 --bg: #14110d; --fg: #ece4d6; --muted: #b8ab93; --faint: #978c77;
39 --line: #2f2820; --track: #241e16; --accent: #db7a4f; --accent-deep: #b1532e; --ok: #74b35f;
40 background-image:
41 radial-gradient(120% 90% at 50% 38%, rgba(219,122,79,0.10), rgba(20,17,13,0) 55%),
42 radial-gradient(140% 120% at 50% 50%, rgba(20,17,13,0) 60%, rgba(0,0,0,0.55) 100%);
43 }
44 #dieSvg .face { fill: color-mix(in srgb, var(--accent) 6%, transparent); }
45 #dieSvg .facets { stroke: color-mix(in srgb, var(--accent) 42%, transparent); }
46 #content { position: absolute; inset: 0; }
⋯ 363 lines hidden (lines 47–409)
47 .abs { position: absolute; will-change: transform, opacity; }
48 .mono { font-family: var(--mono); }
49 .accent { color: var(--accent); }
50 .muted { color: var(--muted); }
51 .faint { color: var(--faint); }
52 
53 #brand { top: 30px; left: 38px; display: flex; align-items: center; gap: 10px; }
54 #brand .name { font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.18em; font-size: 13px; font-weight: 700; color: var(--fg); }
55 
56 #hook { left: 0; right: 0; top: 158px; text-align: center; }
57 #hook .emblem { display: block; margin: 0 auto 20px; }
58 #hook h1 { font-size: 76px; font-weight: 700; letter-spacing: -0.5px; line-height: 1; color: var(--fg); }
59 #hook .sub { margin-top: 16px; font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.34em; font-size: 13px; color: var(--accent); }
60 
61 #obj { left: 64px; right: 64px; top: 70px; }
62 #obj .row { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
63 #obj .title { font-size: 26px; font-weight: 700; }
64 #obj .era { font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.14em; font-size: 12px; color: var(--faint); }
65 #obj .pct { font-family: var(--mono); font-size: 22px; font-weight: 700; color: var(--accent); }
66 #titleRule { height: 2px; background: var(--accent); width: 0; margin-top: 4px; border-radius: 2px; }
67 #meterTrack { margin-top: 12px; height: 8px; border-radius: 999px; background: var(--track); overflow: hidden; box-shadow: inset 0 1px 2px rgba(43,36,28,0.08); }
68 #meterFill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--accent-deep), var(--accent)); }
69 
70 #compose { left: 120px; right: 120px; top: 224px; }
71 #compose .to { font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.16em; font-size: 13px; color: var(--faint); }
72 #compose .to b { color: var(--fg); }
73 #compose .msg { margin-top: 14px; font-size: 27px; line-height: 1.34; font-style: italic; color: var(--fg); }
74 #compose .caret { display: inline-block; width: 2px; height: 1.0em; vertical-align: -2px; background: var(--accent); margin-left: 2px; }
75 #compose .count { margin-top: 16px; font-family: var(--mono); font-size: 12px; color: var(--faint); letter-spacing: 0.08em; }
76 
77 #die { left: 500px; top: 286px; width: 0; height: 0; }
78 #dieWrap { position: absolute; left: -70px; top: -70px; width: 140px; height: 140px; transform-style: preserve-3d; }
79 #dieSvg { position: absolute; inset: 0; }
80 #dieNum { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--mono); font-weight: 700; font-size: 46px; color: var(--fg); }
81 #verdict { left: 0; right: 0; top: 400px; text-align: center; }
82 #verdict .word { display: inline-block; font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.3em; font-size: 22px; font-weight: 700; color: var(--ok); border: 2px solid var(--ok); padding: 6px 18px; border-radius: 6px; background: rgba(70,102,50,0.06); }
83 
84 #ledger { left: 120px; right: 120px; top: 300px; }
85 #ledger .line { display: flex; gap: 12px; align-items: baseline; margin-top: 14px; }
86 #ledger .tick { color: var(--accent); font-family: var(--mono); font-size: 14px; }
87 #ledger .txt { font-size: 22px; line-height: 1.3; color: var(--fg); }
88 #ledger .delta { margin-left: auto; font-family: var(--mono); font-weight: 700; color: var(--ok); white-space: nowrap; }
89 
90 #caption { left: 0; right: 0; bottom: 46px; text-align: center; }
91 #caption .c { font-size: 25px; font-style: italic; color: var(--fg); }
92 #caption .rule { width: 34px; height: 2px; background: var(--accent); margin: 14px auto 0; }
93 
94 #victory { left: 0; right: 0; top: 150px; text-align: center; }
95 #victory .seal { font-size: 64px; color: var(--accent); line-height: 1; }
96 #victory h2 { margin-top: 12px; font-size: 60px; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); }
97 #victory .cta { margin-top: 22px; font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.2em; font-size: 14px; color: var(--accent); }
98 
99 .ring { position: absolute; border: 2px solid var(--accent); border-radius: 999px; }
100 .pt { position: absolute; width: 7px; height: 7px; border-radius: 999px; background: var(--accent); }
101</style>
102</head>
103<body>
104<div id="stage">
105 <div id="content">
106 <div id="brand" class="abs">
107 <svg width="30" height="22" viewBox="0 0 32 24" fill="none" stroke="var(--accent)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
108 <path d="M0.9 14.2h8.5M22.6 9.4h8.5" />
109 <circle cx="2.4" cy="14.2" r="1.3" fill="var(--accent)" stroke="none" />
110 <circle cx="29.6" cy="9.4" r="1.3" fill="var(--accent)" stroke="none" />
111 <path d="M16 3.4 22.6 7.2v7.6L16 18.6l-6.6-3.8V7.2Z" />
112 <path d="M16 7.4 20.2 14.3h-8.4Z" />
113 <path d="M16 3.4v4" />
114 </svg>
115 <span class="name">Revisionist</span>
116 </div>
117 
118 <div id="hook" class="abs">
119 <svg class="emblem" width="72" height="54" viewBox="0 0 32 24" fill="none" stroke="var(--accent)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
120 <path d="M0.9 14.2h8.5M22.6 9.4h8.5" />
121 <circle cx="2.4" cy="14.2" r="1.3" fill="var(--accent)" stroke="none" />
122 <circle cx="29.6" cy="9.4" r="1.3" fill="var(--accent)" stroke="none" />
123 <path d="M16 3.4 22.6 7.2v7.6L16 18.6l-6.6-3.8V7.2Z" />
124 <path d="M16 7.4 20.2 14.3h-8.4Z" />
125 <path d="M16 3.4v4" />
126 </svg>
127 <h1>Rewrite history.</h1>
128 <div class="sub">one message at a time</div>
129 </div>
130 
131 <div id="obj" class="abs">
132 <div class="row">
133 <span><span class="title">Prevent the Great War</span> <span class="era">· Europe, 1914</span></span>
134 <span class="pct" id="pct">0%</span>
135 </div>
136 <div id="titleRule"></div>
137 <div id="meterTrack"><div id="meterFill"></div></div>
138 </div>
139 
140 <div id="compose" class="abs">
141 <div class="to">To &nbsp;<b id="toName">Wilhelm II</b> &nbsp;·&nbsp; 1914</div>
142 <div class="msg"><span id="msgText"></span><span class="caret" id="caret"></span></div>
143 <div class="count"><span id="countNum">0</span> / 160</div>
144 </div>
145 
146 <div id="die" class="abs">
147 <div id="dieWrap">
148 <svg id="dieSvg" width="140" height="140" viewBox="0 0 140 140" fill="none" stroke="var(--accent)" stroke-width="2.2" stroke-linejoin="round">
149 <path class="face" d="M70 8 122 38 122 102 70 132 18 102 18 38 Z" />
150 <path class="facets" d="M70 8 70 44 M70 44 122 38 M70 44 18 38 M70 44 96 118 M70 44 44 118 M18 102 44 118 M122 102 96 118 M44 118 96 118" stroke-width="1.3" />
151 </svg>
152 <div id="dieNum">12</div>
153 </div>
154 </div>
155 
156 <div id="verdict" class="abs"><span class="word" id="verdictWord">SUCCESS</span></div>
157 
158 <div id="ledger" class="abs"></div>
159 
160 <div id="victory" class="abs">
161 <div class="seal"></div>
162 <h2>History Rewritten</h2>
163 <div class="cta">play free · revisionist.mseeks.me</div>
164 </div>
165 
166 <div id="fx" class="abs" style="left:0;top:0;right:0;bottom:0;"></div>
167 
168 <div id="caption" class="abs">
169 <div class="c" id="capText"></div>
170 <div class="rule" id="capRule"></div>
171 </div>
172 </div>
173</div>
174 
175<script>
176const clamp = (x, a=0, b=1) => Math.max(a, Math.min(b, x));
177const seg = (t, a, b) => clamp((t - a) / (b - a));
178const band = (t, a, b, inDur=0.3, outDur=0.3) => Math.min(seg(t, a, a + inDur), 1 - seg(t, b - outDur, b));
179const easeOutCubic = x => 1 - Math.pow(1 - x, 3);
180const easeOutBack = x => { const c1=1.70158, c3=c1+1; return 1 + c3*Math.pow(x-1,3) + c1*Math.pow(x-1,2); };
181const lerp = (a, b, x) => a + (b - a) * x;
182const $ = id => document.getElementById(id);
183const set = (el, { o, x=0, y=0, s=1, rot=0, extra='' } = {}) => {
184 if (o !== undefined) el.style.opacity = o;
185 el.style.transform = `translate(${x}px, ${y}px) scale(${s}) rotate(${rot}deg) ${extra}`;
186};
187 
188const MSG = "Restrain Vienna — let no border quarrel drag the world into the trenches.";
189const DUR = 16.75;
190 
191const B = {
192 hook: [0.0, 2.9], // held from t=0 (the poster frame), fades out as the run begins
193 obj: [2.9, 15.9],
194 compose: [4.4, 7.7],
195 roll: [7.6, 10.0],
196 victory: [14.0, 15.9],
197};
198 
199const LEDGER = [
200 { tick: "I", txt: "The Kaiser hesitates; Vienna is reined in.", delta: "+38%", at: 10.05 },
201 { tick: "II", txt: "Grey's mediation cools the July crisis.", delta: "+34%", at: 12.05 },
202 { tick: "III", txt: "The mobilizations halt. The guns fall silent.", delta: "+28%", at: 12.85 },
203];
204(function buildLedger(){
205 const box = $('ledger'); box.innerHTML = '';
206 LEDGER.forEach((l, i) => {
207 const d = document.createElement('div'); d.className = 'line'; d.id = 'led' + i;
208 d.innerHTML = `<span class="tick">${l.tick}</span><span class="txt">${l.txt}</span><span class="delta">${l.delta}</span>`;
209 box.appendChild(d);
210 });
211})();
212 
213const CAPS = [
214 { a: 2.9, b: 4.3, t: "Pick a cause to set right." },
215 { a: 4.6, b: 7.5, t: "Send a message to anyone who ever lived." },
216 { a: 7.8, b: 9.7, t: "A D20 and fate decide how it bends." },
217 { a: 10.0, b: 11.8, t: "Watch the timeline rewrite itself." },
218 { a: 12.0, b: 13.7, t: "Five messages to remake a world." },
219 { a: 14.2, b: 15.8, t: "History, rewritten." },
220];
221 
222// burst pools + rings
223(function buildFx(){
224 const fx = $('fx'); fx.innerHTML = '';
225 ['ring1','ring2'].forEach(id => { const r = document.createElement('div'); r.className='ring'; r.id=id; fx.appendChild(r); });
226 ['pt','qt'].forEach(pool => { for (let i=0;i<14;i++){ const p=document.createElement('div'); p.className='pt'; p.id=pool+i; fx.appendChild(p);} });
227})();
228 
229function ringAt(el, cx, cy, p, max, alpha) {
230 if (p <= 0 || p >= 1) { el.style.opacity = 0; return; }
231 const e = easeOutCubic(p);
232 const r = lerp(8, max, e);
233 el.style.width = el.style.height = (r*2) + 'px';
234 el.style.left = (cx-r) + 'px'; el.style.top = (cy-r) + 'px';
235 el.style.opacity = (1-p) * alpha;
236 el.style.borderWidth = lerp(2.6, 0.4, e) + 'px';
238function particlesAt(pool, n, cx, cy, p, spread) {
239 for (let i=0;i<14;i++){
240 const el = $(pool+i);
241 if (i>=n || p<=0 || p>=1) { el.style.opacity = 0; continue; }
242 const e = easeOutCubic(p);
243 const ang = (i/n)*Math.PI*2 + 0.5;
244 const dist = lerp(0, spread*(0.55 + 0.45*(((i*37)%10)/10)), e);
245 const x = cx + Math.cos(ang)*dist, y = cy + Math.sin(ang)*dist + e*e*16; // slight gravity
246 el.style.left = x+'px'; el.style.top = y+'px';
247 el.style.opacity = (1-p)*0.9;
248 el.style.transform = `scale(${lerp(1.1,0.2,e)})`;
249 }
251 
252// burst events — assigned to pools/rings so temporally-close ones don't collide
253const BURSTS = [
254 { a: 9.35, dur: 0.75, cx: 500, cy: 272, max: 100, alpha: 0.65, spread: 90, n: 7, pool: 'qt', ring: 'ring2' }, // die settle (kept tight to the die)
255 { a: 9.95, dur: 1.0, cx: 500, cy: 330, max: 200, alpha: 0.8, spread: 220, n: 14, pool: 'pt', ring: 'ring1' }, // bend impact (ring capped under the text)
256 { a: 11.95,dur: 0.7, cx: 500, cy: 330, max: 170, alpha: 0.7, spread: 170, n: 12, pool: 'pt', ring: 'ring1' }, // montage 1
257 { a: 12.75,dur: 0.7, cx: 500, cy: 330, max: 170, alpha: 0.7, spread: 170, n: 12, pool: 'qt', ring: 'ring2' }, // montage 2
258 { a: 14.05,dur: 1.1, cx: 500, cy: 214, max: 320, alpha: 0.85, spread: 300, n: 14, pool: 'pt', ring: 'ring1' }, // victory
259 { a: 14.20,dur: 1.0, cx: 500, cy: 214, max: 230, alpha: 0.6, spread: 240, n: 14, pool: 'qt', ring: 'ring2' }, // victory inner
260];
261 
262function render(t) {
263 // No global fade-to-blank: the loop is bookended by the HOOK title card instead,
264 // so the first frame is a strong, branded poster (vital for GIF/social/ad reuse,
265 // where the first frame IS the thumbnail) while the wrap still matches (card→card).
266 
267 // small top-left wordmark — only during the action, gone at both ends
268 set($('brand'), { o: clamp(Math.min(seg(t, 2.4, 3.0), 1 - seg(t, 15.7, 16.0))) });
269 
270 // hook (the bookend title card): held full at t=0, fades out as the run begins,
271 // fades back in by the final frame so frame[last] === frame[0]. Settled (no
272 // entrance motion) so the poster frame is crisp.
273 {
274 const hookVis = Math.max(1 - seg(t, 2.3, 2.9), seg(t, 16.05, 16.6));
275 set($('hook'), { o: clamp(hookVis) });
276 }
277 
278 // objective + meter
279 {
280 const [a, b] = B.obj;
281 const introP = easeOutCubic(seg(t, a, a + 0.7));
282 const vis = Math.min(seg(t, a, a + 0.5), 1 - seg(t, b, b + 0.3));
283 set($('obj'), { o: clamp(vis), y: lerp(-12, 0, introP) });
284 // the title rule draws in as the objective lands (a small pop of intent)
285 $('titleRule').style.width = lerp(0, 132, easeOutCubic(seg(t, a + 0.25, a + 0.95))) + 'px';
286 
287 let pct;
288 if (t < 9.95) pct = 0;
289 else if (t < 11.95) pct = lerp(0, 38, easeOutCubic(seg(t, 9.95, 10.9)));
290 else if (t < 12.75) pct = lerp(38, 72, easeOutCubic(seg(t, 11.95, 12.6)));
291 else pct = lerp(72, 100, easeOutCubic(seg(t, 12.75, 13.6)));
292 $('meterFill').style.width = pct + '%';
293 $('pct').textContent = Math.round(pct) + '%';
294 const pulse = Math.max(band(t,9.95,11.0,0.12,0.6), band(t,11.95,12.7,0.1,0.5), band(t,12.75,13.7,0.1,0.5));
295 const full = seg(t, 13.4, 13.8);
296 $('meterFill').style.boxShadow = `0 0 ${lerp(0,20,Math.max(pulse,full))}px rgba(177,83,46,${lerp(0,0.7,Math.max(pulse,full))})`;
297 $('pct').style.color = pct >= 100 ? 'var(--ok)' : 'var(--accent)';
298 }
299 
300 // compose
301 {
302 const [a, b] = B.compose;
303 const vis = Math.min(seg(t, a, a + 0.4), 1 - seg(t, b - 0.4, b));
304 // commit "lift" as it sends, right before the roll
305 const lift = easeOutCubic(seg(t, b - 0.4, b));
306 set($('compose'), { o: clamp(vis), y: lerp(10, 0, easeOutCubic(seg(t, a, a + 0.5))) - lift*14 });
307 const typeP = seg(t, a + 0.6, b - 0.75);
308 const n = Math.floor(typeP * MSG.length);
309 $('msgText').textContent = MSG.slice(0, n);
310 $('countNum').textContent = n;
311 const blink = (Math.floor(t * 2) % 2 === 0) ? 1 : 0.15;
312 $('caret').style.opacity = vis * (typeP >= 1 ? 0 : blink);
313 }
314 
315 // die roll
316 {
317 const a = B.roll[0];
318 const appear = seg(t, a, a + 0.35);
319 const rollP = seg(t, a + 0.25, 9.35);
320 const settle = seg(t, 9.35, 9.8);
321 const wrap = $('dieWrap');
322 if (t > a - 0.1 && t < 10.35) {
323 const vis = Math.min(easeOutBack(appear), 1 - seg(t, 9.95, 10.35));
324 $('die').style.opacity = clamp(vis);
325 const spin = easeOutCubic(rollP) * 1080;
326 const wob = (1 - rollP) * 24 * Math.sin(rollP * Math.PI * 7);
327 const rx = (1 - rollP) * 38 * Math.sin(rollP * Math.PI * 5);
328 // "thunk" overshoot on settle
329 const thunk = settle > 0 && settle < 1 ? Math.sin(settle * Math.PI) * 0.13 : 0;
330 const sc = lerp(0.6, 1, easeOutBack(appear)) + thunk;
331 wrap.style.transform = `rotateX(${rx}deg) rotateZ(${spin + wob}deg) scale(${sc})`;
332 let num = 17;
333 if (rollP < 0.86) num = ((Math.floor(t * 41) * 13) % 20) + 1;
334 $('dieNum').textContent = num;
335 const g = easeOutCubic(settle) * (1 - seg(t, 10.0, 10.35));
336 $('dieSvg').style.filter = `drop-shadow(0 2px 5px rgba(43,36,28,0.14)) drop-shadow(0 0 ${lerp(0,16,g)}px rgba(177,83,46,${lerp(0,0.8,g)}))`;
337 $('dieNum').style.color = g > 0.4 ? 'var(--accent)' : 'var(--fg)';
338 } else {
339 $('die').style.opacity = 0;
340 }
341 const vP = seg(t, 9.5, 9.85);
342 const vVis = Math.min(easeOutBack(vP), 1 - seg(t, 10.0, 10.35));
343 set($('verdict'), { o: clamp(vVis), s: lerp(0.7, 1, easeOutBack(vP)), rot: lerp(-6, -3, vP) });
344 }
345 
346 // ledger
347 LEDGER.forEach((l, i) => {
348 const el = $('led' + i);
349 const p = seg(t, l.at, l.at + 0.4);
350 // clear the ledger BEFORE the victory block fades in (14.0) so the seal /
351 // "History Rewritten" / CTA never render over these lines.
352 const out = 1 - seg(t, 13.6, 13.95);
353 // a small ink-press: scale from 1.05 → 1 as it stamps
354 set(el, { o: clamp(easeOutCubic(p) * out), x: lerp(-14, 0, easeOutCubic(p)), s: lerp(1.05, 1, easeOutCubic(p)) });
355 });
356 
357 // bursts
358 {
359 const usedRing = {}, usedPool = {};
360 for (const bz of BURSTS) {
361 const p = seg(t, bz.a, bz.a + bz.dur);
362 if (p > 0 && p < 1) {
363 ringAt($(bz.ring), bz.cx, bz.cy, p, bz.max, bz.alpha);
364 particlesAt(bz.pool, bz.n, bz.cx, bz.cy, p, bz.spread);
365 usedRing[bz.ring] = true; usedPool[bz.pool] = true;
366 }
367 }
368 ['ring1','ring2'].forEach(r => { if (!usedRing[r]) $(r).style.opacity = 0; });
369 ['pt','qt'].forEach(pl => { if (!usedPool[pl]) for (let i=0;i<14;i++) $(pl+i).style.opacity = 0; });
370 }
371 
372 // victory
373 {
374 const [a, b] = B.victory;
375 const vis = Math.min(seg(t, a, a + 0.4), 1 - seg(t, b, b + 0.3));
376 const pop = easeOutBack(seg(t, a, a + 0.55));
377 set($('victory'), { o: clamp(vis) });
378 $('victory').querySelector('.seal').style.transform = `scale(${lerp(0.4, 1, pop)}) rotate(${lerp(-12,0,pop)}deg)`;
379 const h2rise = easeOutCubic(seg(t, a + 0.3, a + 1.0));
380 const h2 = $('victory').querySelector('h2');
381 h2.style.opacity = clamp(seg(t, a + 0.3, a + 0.65));
382 h2.style.transform = `translateY(${lerp(16, 0, h2rise)}px)`;
383 $('victory').querySelector('.cta').style.opacity = clamp(seg(t, a + 0.8, a + 1.2));
384 }
385 
386 // caption
387 {
388 let active = null;
389 for (const c of CAPS) { if (t >= c.a - 0.3 && t <= c.b + 0.3) { active = c; break; } }
390 if (active) {
391 $('capText').textContent = active.t;
392 const vis = Math.min(seg(t, active.a - 0.25, active.a + 0.1), 1 - seg(t, active.b - 0.05, active.b + 0.25));
393 set($('caption'), { o: clamp(vis), y: lerp(8, 0, easeOutCubic(seg(t, active.a - 0.25, active.a + 0.25))) });
394 $('capRule').style.width = lerp(8, 34, easeOutCubic(seg(t, active.a - 0.1, active.a + 0.5))) + 'px';
395 } else {
396 $('caption').style.opacity = 0;
397 }
398 }
400 
401// Theme: ?theme=dark renders the dark palette (capture.mjs captures both); default light.
402if (new URLSearchParams(location.search).get('theme') === 'dark') $('stage').classList.add('dark');
403 
404window.__render = render;
405window.__DUR = DUR;
406render(0);
407</script>
408</body>
409</html>

A one-liner at the end of the script reads ?theme=dark and adds the .dark class; capture.mjs renders each theme by loading the page with each value.

Capture loops over both themes into frames-<theme>/ (frame-identical timing).

marketing/reel/capture.mjs · 46 lines
marketing/reel/capture.mjs46 lines · JavaScript
⋯ 25 lines hidden (lines 1–25)
1/**
2 * Frame capture for the marketing reel — both themes.
3 *
4 * Loads reel.html (once per theme, via ?theme=light|dark) in headless Chromium and
5 * screenshots one PNG per frame by seeking the reel's deterministic clock
6 * (window.__render(t)) — NOT by recording in real time. That makes the output
7 * frame-exact and reproducible, and guarantees the light and dark renders are
8 * FRAME-IDENTICAL in timing (same source, same clock) so they can be swapped
9 * mid-playback on the page. build.sh assembles the frames into reel-<theme>.*.
10 *
11 * Requires the repo's dev deps (playwright comes via @nuxt/test-utils):
12 * npm ci && npx playwright install chromium
13 * Then: node marketing/reel/capture.mjs (or run build.sh, which calls this).
14 */
15import { createRequire } from 'module'
16import { fileURLToPath } from 'url'
17import { dirname, join } from 'path'
18import { mkdirSync } from 'fs'
19 
20const require = createRequire(import.meta.url)
21const { chromium } = require('playwright')
22 
23const here = dirname(fileURLToPath(import.meta.url))
24const FPS = 25
25const browser = await chromium.launch()
26 
27for (const theme of ['light', 'dark']) {
28 const outDir = join(here, 'frames-' + theme)
29 mkdirSync(outDir, { recursive: true })
30 const page = await browser.newPage({ viewport: { width: 1000, height: 560 }, deviceScaleFactor: 2 })
31 await page.goto('file://' + join(here, 'reel.html') + '?theme=' + theme, { waitUntil: 'load' })
32 await page.evaluate(() => document.fonts && document.fonts.ready)
33 const DUR = await page.evaluate(() => window.__DUR)
34 const frames = Math.round(DUR * FPS)
35 const stage = page.locator('#stage')
36 console.log(`${theme}: capturing ${frames} frames @ ${FPS}fps (${DUR}s)`)
37 for (let i = 0; i < frames; i++) {
38 await page.evaluate((tt) => window.__render(tt), i / FPS)
39 await stage.screenshot({ path: join(outDir, String(i).padStart(4, '0') + '.png') })
40 if (i % 50 === 0) console.log(` ${theme} ${i}/${frames}`)
⋯ 6 lines hidden (lines 41–46)
41 }
42 await page.close()
44 
45await browser.close()
46console.log('capture done (light + dark)')