moonlantern1 commited on
Commit
580ec04
·
verified ·
1 Parent(s): 8b9b583

Keep review video moving through voiceover

Browse files
.env.example ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OPTIONAL — leave unset for default behavior.
2
+ #
3
+ # By default the matcha-moments app calls its OWN /api/public/reviews/* routes
4
+ # (mounted in this same Next.js app, see src/app/api/public/reviews/*). They
5
+ # mirror the contract of reference/src/app/api/public/reviews/* exactly but
6
+ # use an in-memory store instead of Supabase, so no backend setup is required.
7
+ #
8
+ # When Humeo deploys the public review endpoints to humeo.app, set this
9
+ # variable to redirect all traffic at the deployed backend with no code change:
10
+ #
11
+ # NEXT_PUBLIC_HUMEO_API_URL=https://humeo.app
12
+ #
13
+ # CORS: humeo.app must allow this app's deploy domain on /api/public/reviews/*.
14
+ NEXT_PUBLIC_HUMEO_API_URL=
15
+
16
+ # Standalone Supabase persistence. Real values belong in `.env.local` or
17
+ # deployment secrets only.
18
+ SUPABASE_URL=
19
+ NEXT_PUBLIC_SUPABASE_URL=
20
+ SUPABASE_ANON_KEY=
21
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=
22
+ SUPABASE_SERVICE_ROLE_KEY=
23
+ SUPABASE_REVIEW_VIDEO_BUCKET=review-videos
24
+
25
+ # Basic auth for /admin/*.
26
+ ADMIN_USERNAME=
27
+ ADMIN_PASSWORD=
28
+
29
+ # Optional AI/transcription providers. Keep real values in `.env.local` or
30
+ # deployment secrets only. Do not commit real API keys.
31
+ HUMEO_TRANSCRIBE_PROVIDER=elevenlabs
32
+ ELEVENLABS_API_KEY=
33
+ ELEVENLABS_MODEL_ID=scribe_v2
34
+ OPENROUTER_API_KEY=
35
+ GEMINI_MODEL=google/gemini-3.1-pro-preview
36
+ GEMINI_VISION_MODEL=google/gemini-3-flash-preview
37
+ OPENAI_API_KEY=
matcha-moments-prototype_2.html ADDED
@@ -0,0 +1,1747 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Matcha Moments — Click-through Prototype</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --cream: #F5EFE2;
13
+ --cream-deep: #EDE3D0;
14
+ --bg: #1c1814;
15
+ --bg-2: #25201a;
16
+ --matcha: #4A6B3D;
17
+ --matcha-deep: #324A2A;
18
+ --sage: #B8C9A8;
19
+ --ink: #2A2520;
20
+ --muted: #8B7E6E;
21
+ --warm: #D89A7A;
22
+ --paper: #FFFCF6;
23
+ --line: rgba(42, 37, 32, 0.12);
24
+ }
25
+
26
+ * { box-sizing: border-box; margin: 0; padding: 0; }
27
+
28
+ html, body {
29
+ background: var(--bg);
30
+ color: var(--cream);
31
+ font-family: 'DM Sans', sans-serif;
32
+ min-height: 100vh;
33
+ overflow-x: hidden;
34
+ }
35
+
36
+ /* === Layout === */
37
+ .stage {
38
+ min-height: 100vh;
39
+ display: grid;
40
+ grid-template-columns: 280px 1fr 320px;
41
+ gap: 0;
42
+ background:
43
+ radial-gradient(ellipse 800px 600px at 50% 30%, rgba(74, 107, 61, 0.12), transparent 60%),
44
+ radial-gradient(ellipse 400px 400px at 90% 90%, rgba(216, 154, 122, 0.08), transparent 60%),
45
+ var(--bg);
46
+ }
47
+
48
+ @media (max-width: 1100px) {
49
+ .stage { grid-template-columns: 1fr; grid-template-rows: auto auto auto; }
50
+ .nav-panel, .notes-panel { position: static !important; height: auto !important; padding: 24px !important; border: none !important; border-bottom: 1px solid rgba(245, 239, 226, 0.08) !important; }
51
+ .phone-stage { min-height: auto !important; padding: 40px 20px !important; }
52
+ }
53
+
54
+ /* === Left: Screen Navigator === */
55
+ .nav-panel {
56
+ position: sticky;
57
+ top: 0;
58
+ height: 100vh;
59
+ padding: 36px 28px;
60
+ border-right: 1px solid rgba(245, 239, 226, 0.08);
61
+ overflow-y: auto;
62
+ }
63
+
64
+ .brand {
65
+ display: flex;
66
+ align-items: baseline;
67
+ gap: 8px;
68
+ margin-bottom: 4px;
69
+ }
70
+ .brand-mark {
71
+ font-family: 'Fraunces', serif;
72
+ font-style: italic;
73
+ font-weight: 400;
74
+ font-size: 28px;
75
+ color: var(--cream);
76
+ letter-spacing: -0.02em;
77
+ }
78
+ .brand-mark em {
79
+ color: var(--sage);
80
+ font-style: italic;
81
+ }
82
+
83
+ .subtitle {
84
+ font-size: 11px;
85
+ text-transform: uppercase;
86
+ letter-spacing: 0.18em;
87
+ color: var(--muted);
88
+ margin-bottom: 32px;
89
+ font-family: 'DM Mono', monospace;
90
+ }
91
+
92
+ .nav-label {
93
+ font-size: 10px;
94
+ text-transform: uppercase;
95
+ letter-spacing: 0.2em;
96
+ color: var(--muted);
97
+ margin-bottom: 14px;
98
+ font-family: 'DM Mono', monospace;
99
+ }
100
+
101
+ .nav-list {
102
+ list-style: none;
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 2px;
106
+ }
107
+ .nav-item {
108
+ display: flex;
109
+ align-items: center;
110
+ gap: 12px;
111
+ padding: 10px 12px;
112
+ border-radius: 8px;
113
+ cursor: pointer;
114
+ transition: all 0.2s ease;
115
+ color: rgba(245, 239, 226, 0.6);
116
+ font-size: 13px;
117
+ border: 1px solid transparent;
118
+ }
119
+ .nav-item:hover {
120
+ background: rgba(245, 239, 226, 0.04);
121
+ color: var(--cream);
122
+ }
123
+ .nav-item.active {
124
+ background: rgba(184, 201, 168, 0.08);
125
+ color: var(--cream);
126
+ border-color: rgba(184, 201, 168, 0.2);
127
+ }
128
+ .nav-num {
129
+ font-family: 'DM Mono', monospace;
130
+ font-size: 11px;
131
+ color: var(--muted);
132
+ min-width: 22px;
133
+ }
134
+ .nav-item.active .nav-num { color: var(--sage); }
135
+
136
+ .nav-divider {
137
+ height: 1px;
138
+ background: rgba(245, 239, 226, 0.08);
139
+ margin: 20px 0;
140
+ }
141
+
142
+ /* === Center: Phone Stage === */
143
+ .phone-stage {
144
+ display: flex;
145
+ flex-direction: column;
146
+ align-items: center;
147
+ justify-content: center;
148
+ padding: 40px 20px;
149
+ min-height: 100vh;
150
+ position: relative;
151
+ }
152
+
153
+ .stage-header {
154
+ text-align: center;
155
+ margin-bottom: 28px;
156
+ }
157
+ .stage-header h1 {
158
+ font-family: 'Fraunces', serif;
159
+ font-weight: 300;
160
+ font-size: 32px;
161
+ letter-spacing: -0.02em;
162
+ color: var(--cream);
163
+ margin-bottom: 6px;
164
+ }
165
+ .stage-header h1 em {
166
+ font-style: italic;
167
+ color: var(--sage);
168
+ }
169
+ .stage-header p {
170
+ font-size: 13px;
171
+ color: var(--muted);
172
+ font-family: 'DM Mono', monospace;
173
+ letter-spacing: 0.05em;
174
+ }
175
+
176
+ /* Phone frame */
177
+ .phone {
178
+ width: 380px;
179
+ height: 800px;
180
+ background: #0a0908;
181
+ border-radius: 50px;
182
+ padding: 12px;
183
+ position: relative;
184
+ box-shadow:
185
+ 0 0 0 2px rgba(255,255,255,0.05),
186
+ 0 60px 120px rgba(0,0,0,0.5),
187
+ 0 30px 60px rgba(0,0,0,0.4);
188
+ }
189
+ .phone::before {
190
+ content: '';
191
+ position: absolute;
192
+ top: 24px;
193
+ left: 50%;
194
+ transform: translateX(-50%);
195
+ width: 120px;
196
+ height: 32px;
197
+ background: #0a0908;
198
+ border-radius: 20px;
199
+ z-index: 10;
200
+ }
201
+
202
+ .phone-screen {
203
+ width: 100%;
204
+ height: 100%;
205
+ background: var(--cream);
206
+ border-radius: 38px;
207
+ overflow: hidden;
208
+ position: relative;
209
+ }
210
+
211
+ /* Status bar */
212
+ .status-bar {
213
+ height: 50px;
214
+ display: flex;
215
+ justify-content: space-between;
216
+ align-items: center;
217
+ padding: 18px 32px 0;
218
+ font-size: 14px;
219
+ font-weight: 600;
220
+ color: var(--ink);
221
+ font-family: 'DM Sans', sans-serif;
222
+ }
223
+ .status-bar .right { display: flex; gap: 6px; align-items: center; }
224
+
225
+ /* Screen container */
226
+ .screens {
227
+ position: absolute;
228
+ inset: 12px;
229
+ border-radius: 38px;
230
+ overflow: hidden;
231
+ }
232
+ .screen {
233
+ position: absolute;
234
+ inset: 0;
235
+ display: none;
236
+ flex-direction: column;
237
+ background: var(--cream);
238
+ }
239
+ .screen.active { display: flex; animation: screenIn 0.35s ease; }
240
+
241
+ @keyframes screenIn {
242
+ from { opacity: 0; transform: translateX(8px); }
243
+ to { opacity: 1; transform: translateX(0); }
244
+ }
245
+
246
+ /* === Screen content shared === */
247
+ .screen-body {
248
+ flex: 1;
249
+ padding: 0 28px 28px;
250
+ display: flex;
251
+ flex-direction: column;
252
+ overflow-y: auto;
253
+ }
254
+
255
+ .screen-body::-webkit-scrollbar { display: none; }
256
+
257
+ h2.display {
258
+ font-family: 'Fraunces', serif;
259
+ font-weight: 300;
260
+ font-size: 38px;
261
+ line-height: 1.05;
262
+ letter-spacing: -0.025em;
263
+ color: var(--ink);
264
+ }
265
+ h2.display em {
266
+ font-style: italic;
267
+ color: var(--matcha);
268
+ }
269
+
270
+ .eyebrow {
271
+ font-family: 'DM Mono', monospace;
272
+ font-size: 10px;
273
+ letter-spacing: 0.2em;
274
+ text-transform: uppercase;
275
+ color: var(--matcha);
276
+ margin-bottom: 14px;
277
+ }
278
+
279
+ .body-text {
280
+ font-size: 15px;
281
+ line-height: 1.5;
282
+ color: var(--ink);
283
+ opacity: 0.7;
284
+ }
285
+
286
+ .btn {
287
+ display: flex;
288
+ align-items: center;
289
+ justify-content: center;
290
+ gap: 8px;
291
+ padding: 18px 24px;
292
+ border-radius: 100px;
293
+ border: none;
294
+ cursor: pointer;
295
+ font-family: inherit;
296
+ font-size: 15px;
297
+ font-weight: 500;
298
+ transition: all 0.2s ease;
299
+ width: 100%;
300
+ }
301
+ .btn-primary {
302
+ background: var(--matcha);
303
+ color: var(--cream);
304
+ }
305
+ .btn-primary:hover { background: var(--matcha-deep); }
306
+ .btn-secondary {
307
+ background: transparent;
308
+ color: var(--ink);
309
+ border: 1.5px solid var(--ink);
310
+ }
311
+ .btn-ghost {
312
+ background: transparent;
313
+ color: var(--ink);
314
+ opacity: 0.6;
315
+ }
316
+
317
+ .btn-stack {
318
+ display: flex;
319
+ flex-direction: column;
320
+ gap: 10px;
321
+ padding-top: 16px;
322
+ }
323
+
324
+ /* === SCREEN 1: Landing === */
325
+ .s1-hero {
326
+ flex: 1;
327
+ display: flex;
328
+ flex-direction: column;
329
+ justify-content: center;
330
+ padding-top: 20px;
331
+ position: relative;
332
+ }
333
+ .s1-cafe-name {
334
+ font-family: 'Fraunces', serif;
335
+ font-style: italic;
336
+ font-size: 14px;
337
+ color: var(--muted);
338
+ margin-bottom: 28px;
339
+ text-align: center;
340
+ }
341
+ .s1-cafe-name::before, .s1-cafe-name::after {
342
+ content: '⸺'; margin: 0 10px; opacity: 0.5;
343
+ }
344
+ .s1-matcha-circle {
345
+ width: 180px;
346
+ height: 180px;
347
+ margin: 0 auto 28px;
348
+ border-radius: 50%;
349
+ background:
350
+ radial-gradient(circle at 35% 30%, #95B485, var(--matcha) 60%, var(--matcha-deep) 100%);
351
+ position: relative;
352
+ box-shadow: 0 30px 60px rgba(74, 107, 61, 0.25);
353
+ }
354
+ .s1-matcha-circle::after {
355
+ content: '';
356
+ position: absolute;
357
+ top: 14%; left: 18%;
358
+ width: 35%; height: 18%;
359
+ background: radial-gradient(ellipse, rgba(255,255,255,0.5), transparent 70%);
360
+ border-radius: 50%;
361
+ filter: blur(4px);
362
+ }
363
+ .s1-foam {
364
+ position: absolute;
365
+ top: 28%;
366
+ left: 50%;
367
+ transform: translateX(-50%);
368
+ font-family: 'Fraunces', serif;
369
+ font-style: italic;
370
+ color: rgba(255,255,255,0.95);
371
+ font-size: 22px;
372
+ text-align: center;
373
+ line-height: 1;
374
+ pointer-events: none;
375
+ }
376
+ .s1-title {
377
+ text-align: center;
378
+ margin-bottom: 14px;
379
+ }
380
+ .s1-sub {
381
+ text-align: center;
382
+ font-size: 14px;
383
+ color: var(--ink);
384
+ opacity: 0.65;
385
+ margin-bottom: 24px;
386
+ line-height: 1.5;
387
+ }
388
+ .s1-meta-row {
389
+ display: flex;
390
+ justify-content: center;
391
+ gap: 18px;
392
+ margin-bottom: 28px;
393
+ font-family: 'DM Mono', monospace;
394
+ font-size: 10px;
395
+ text-transform: uppercase;
396
+ letter-spacing: 0.15em;
397
+ color: var(--muted);
398
+ }
399
+ .s1-meta-row span { display: flex; align-items: center; gap: 5px; }
400
+ .dot-sep { width: 3px; height: 3px; background: var(--muted); border-radius: 50%; }
401
+
402
+ /* === SCREEN 2: How it works === */
403
+ .steps-list {
404
+ display: flex;
405
+ flex-direction: column;
406
+ gap: 0;
407
+ margin: 24px 0;
408
+ }
409
+ .step-row {
410
+ display: flex;
411
+ gap: 16px;
412
+ padding: 16px 0;
413
+ border-bottom: 1px dashed var(--line);
414
+ }
415
+ .step-row:last-child { border-bottom: none; }
416
+ .step-num {
417
+ font-family: 'Fraunces', serif;
418
+ font-style: italic;
419
+ font-size: 28px;
420
+ color: var(--matcha);
421
+ line-height: 1;
422
+ min-width: 32px;
423
+ }
424
+ .step-content { flex: 1; }
425
+ .step-title {
426
+ font-size: 15px;
427
+ font-weight: 600;
428
+ color: var(--ink);
429
+ margin-bottom: 2px;
430
+ }
431
+ .step-desc {
432
+ font-size: 13px;
433
+ color: var(--ink);
434
+ opacity: 0.6;
435
+ line-height: 1.4;
436
+ }
437
+
438
+ .consent-box {
439
+ background: var(--paper);
440
+ border: 1px solid var(--line);
441
+ padding: 16px;
442
+ border-radius: 16px;
443
+ font-size: 12px;
444
+ line-height: 1.5;
445
+ color: var(--ink);
446
+ opacity: 0.75;
447
+ margin-bottom: 14px;
448
+ }
449
+ .consent-box strong { font-weight: 600; opacity: 1; }
450
+
451
+ .consent-inline {
452
+ font-size: 11px;
453
+ line-height: 1.5;
454
+ color: var(--ink);
455
+ opacity: 0.55;
456
+ text-align: center;
457
+ padding: 0 8px;
458
+ margin-bottom: 6px;
459
+ }
460
+
461
+ /* === SCREEN 3: Permission === */
462
+ .perm-icon-wrap {
463
+ flex: 1;
464
+ display: flex;
465
+ flex-direction: column;
466
+ align-items: center;
467
+ justify-content: center;
468
+ text-align: center;
469
+ }
470
+ .perm-icon {
471
+ width: 100px;
472
+ height: 100px;
473
+ border-radius: 28px;
474
+ background: var(--matcha);
475
+ color: var(--cream);
476
+ display: flex;
477
+ align-items: center;
478
+ justify-content: center;
479
+ margin-bottom: 28px;
480
+ position: relative;
481
+ animation: pulse 2.4s ease-in-out infinite;
482
+ }
483
+ .perm-icon svg { width: 48px; height: 48px; }
484
+ @keyframes pulse {
485
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(74, 107, 61, 0.35); }
486
+ 50% { box-shadow: 0 0 0 24px rgba(74, 107, 61, 0); }
487
+ }
488
+
489
+ /* === SCREEN 4-8: Camera/recording === */
490
+ .camera-screen {
491
+ background: #0e0d0b;
492
+ color: white;
493
+ }
494
+ .camera-screen .status-bar { color: white; }
495
+ .cam-viewfinder {
496
+ flex: 1;
497
+ position: relative;
498
+ background:
499
+ linear-gradient(180deg, rgba(14,13,11,0.4) 0%, transparent 25%, transparent 75%, rgba(14,13,11,0.7) 100%),
500
+ radial-gradient(ellipse at 50% 60%, #4a3a2a 0%, #2a1f15 60%, #14100c 100%);
501
+ overflow: hidden;
502
+ }
503
+
504
+ /* food image mockups (CSS-drawn) */
505
+ .food-mock {
506
+ position: absolute;
507
+ inset: 0;
508
+ display: flex;
509
+ align-items: center;
510
+ justify-content: center;
511
+ overflow: hidden;
512
+ }
513
+ .food-bowl {
514
+ width: 280px;
515
+ height: 280px;
516
+ border-radius: 50%;
517
+ background:
518
+ radial-gradient(circle at 50% 45%, #f4ad6a 0%, #d88845 30%, #a85a2a 70%, #6e3818 100%);
519
+ box-shadow:
520
+ inset 0 -20px 40px rgba(0,0,0,0.4),
521
+ inset 0 10px 30px rgba(255,220,180,0.3),
522
+ 0 30px 60px rgba(0,0,0,0.5);
523
+ position: relative;
524
+ }
525
+ .food-bowl::before {
526
+ content: '';
527
+ position: absolute;
528
+ inset: 30px;
529
+ border-radius: 50%;
530
+ background:
531
+ radial-gradient(circle at 30% 30%, #ffe4b5, #f0a868 40%, #c87844 70%);
532
+ box-shadow: inset 0 -10px 20px rgba(120, 60, 20, 0.5);
533
+ }
534
+ .food-bowl::after {
535
+ content: '🥗';
536
+ position: absolute;
537
+ inset: 0;
538
+ display: flex;
539
+ align-items: center;
540
+ justify-content: center;
541
+ font-size: 80px;
542
+ filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));
543
+ }
544
+
545
+ .person-silhouette {
546
+ position: absolute;
547
+ inset: 0;
548
+ display: flex;
549
+ align-items: center;
550
+ justify-content: center;
551
+ }
552
+ .person-silhouette::before {
553
+ content: '';
554
+ width: 200px;
555
+ height: 240px;
556
+ background: radial-gradient(ellipse at 50% 30%, #d4a785 0%, #8b6240 50%, #4a3220 90%);
557
+ border-radius: 100px 100px 80px 80px / 130px 130px 60px 60px;
558
+ box-shadow: inset -20px -20px 40px rgba(0,0,0,0.3);
559
+ position: relative;
560
+ top: 60px;
561
+ }
562
+
563
+ .cam-prompt-card {
564
+ position: absolute;
565
+ top: 70px;
566
+ left: 16px;
567
+ right: 16px;
568
+ background: rgba(20, 18, 14, 0.78);
569
+ backdrop-filter: blur(20px);
570
+ -webkit-backdrop-filter: blur(20px);
571
+ border: 1px solid rgba(255,255,255,0.1);
572
+ padding: 18px 20px;
573
+ border-radius: 22px;
574
+ color: white;
575
+ z-index: 5;
576
+ }
577
+ .cam-prompt-step {
578
+ font-family: 'DM Mono', monospace;
579
+ font-size: 10px;
580
+ letter-spacing: 0.2em;
581
+ text-transform: uppercase;
582
+ color: var(--sage);
583
+ margin-bottom: 6px;
584
+ }
585
+ .cam-prompt-title {
586
+ font-family: 'Fraunces', serif;
587
+ font-weight: 400;
588
+ font-size: 22px;
589
+ line-height: 1.2;
590
+ letter-spacing: -0.01em;
591
+ margin-bottom: 6px;
592
+ }
593
+ .cam-prompt-tip {
594
+ font-size: 12px;
595
+ color: rgba(255,255,255,0.6);
596
+ line-height: 1.4;
597
+ }
598
+
599
+ .cam-progress {
600
+ position: absolute;
601
+ top: 18px;
602
+ left: 16px;
603
+ right: 16px;
604
+ display: flex;
605
+ gap: 4px;
606
+ z-index: 5;
607
+ }
608
+ .cam-progress-pip {
609
+ flex: 1;
610
+ height: 3px;
611
+ background: rgba(255,255,255,0.2);
612
+ border-radius: 2px;
613
+ overflow: hidden;
614
+ }
615
+ .cam-progress-pip.done { background: var(--sage); }
616
+ .cam-progress-pip.current {
617
+ background: rgba(255,255,255,0.2);
618
+ position: relative;
619
+ }
620
+ .cam-progress-pip.current::after {
621
+ content: '';
622
+ position: absolute;
623
+ inset: 0;
624
+ background: var(--sage);
625
+ width: 40%;
626
+ }
627
+
628
+ .cam-controls {
629
+ position: absolute;
630
+ bottom: 30px;
631
+ left: 0;
632
+ right: 0;
633
+ display: flex;
634
+ justify-content: center;
635
+ align-items: center;
636
+ gap: 50px;
637
+ z-index: 5;
638
+ }
639
+
640
+ .cam-record-btn {
641
+ width: 76px;
642
+ height: 76px;
643
+ border-radius: 50%;
644
+ border: 4px solid white;
645
+ background: transparent;
646
+ cursor: pointer;
647
+ display: flex;
648
+ align-items: center;
649
+ justify-content: center;
650
+ transition: transform 0.15s ease;
651
+ }
652
+ .cam-record-btn:hover { transform: scale(0.96); }
653
+ .cam-record-btn .inner {
654
+ width: 56px;
655
+ height: 56px;
656
+ background: #ee4040;
657
+ border-radius: 50%;
658
+ transition: all 0.25s ease;
659
+ }
660
+ .cam-record-btn.recording .inner {
661
+ width: 26px;
662
+ height: 26px;
663
+ border-radius: 6px;
664
+ }
665
+
666
+ .cam-side-btn {
667
+ width: 44px;
668
+ height: 44px;
669
+ border-radius: 50%;
670
+ background: rgba(255,255,255,0.12);
671
+ backdrop-filter: blur(20px);
672
+ border: none;
673
+ color: white;
674
+ display: flex;
675
+ align-items: center;
676
+ justify-content: center;
677
+ cursor: pointer;
678
+ font-size: 18px;
679
+ }
680
+
681
+ .recording-badge {
682
+ position: absolute;
683
+ bottom: 130px;
684
+ left: 50%;
685
+ transform: translateX(-50%);
686
+ display: none;
687
+ align-items: center;
688
+ gap: 8px;
689
+ background: rgba(238, 64, 64, 0.95);
690
+ color: white;
691
+ padding: 8px 14px;
692
+ border-radius: 20px;
693
+ font-family: 'DM Mono', monospace;
694
+ font-size: 12px;
695
+ z-index: 6;
696
+ }
697
+ .recording-badge.show { display: flex; animation: fadeIn 0.3s ease; }
698
+ .recording-badge .red-dot {
699
+ width: 8px; height: 8px;
700
+ background: white;
701
+ border-radius: 50%;
702
+ animation: blink 1s ease infinite;
703
+ }
704
+ @keyframes blink { 50% { opacity: 0.3; } }
705
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
706
+
707
+ /* === SCREEN 9: Processing === */
708
+ .processing-screen {
709
+ background:
710
+ radial-gradient(ellipse at 50% 30%, rgba(184, 201, 168, 0.4), transparent 60%),
711
+ var(--cream);
712
+ }
713
+ .proc-emoji {
714
+ font-size: 56px;
715
+ text-align: center;
716
+ margin: 0 auto 20px;
717
+ animation: float 3s ease-in-out infinite;
718
+ }
719
+ @keyframes float {
720
+ 0%, 100% { transform: translateY(0); }
721
+ 50% { transform: translateY(-8px); }
722
+ }
723
+ .proc-list {
724
+ list-style: none;
725
+ margin-top: 28px;
726
+ display: flex;
727
+ flex-direction: column;
728
+ gap: 14px;
729
+ }
730
+ .proc-item {
731
+ display: flex;
732
+ align-items: center;
733
+ gap: 14px;
734
+ padding: 14px 18px;
735
+ background: var(--paper);
736
+ border: 1px solid var(--line);
737
+ border-radius: 16px;
738
+ font-size: 14px;
739
+ color: var(--ink);
740
+ }
741
+ .proc-icon {
742
+ width: 24px;
743
+ height: 24px;
744
+ border-radius: 50%;
745
+ display: flex;
746
+ align-items: center;
747
+ justify-content: center;
748
+ flex-shrink: 0;
749
+ font-size: 12px;
750
+ font-weight: 600;
751
+ }
752
+ .proc-icon.done { background: var(--matcha); color: white; }
753
+ .proc-icon.current {
754
+ background: transparent;
755
+ border: 2px solid var(--matcha);
756
+ border-top-color: transparent;
757
+ animation: spin 0.8s linear infinite;
758
+ }
759
+ .proc-icon.pending { background: var(--cream-deep); color: var(--muted); }
760
+ @keyframes spin { to { transform: rotate(360deg); } }
761
+ .proc-item.pending { opacity: 0.5; }
762
+
763
+ /* === SCREEN 10: Preview === */
764
+ .video-preview {
765
+ margin: 16px 0 20px;
766
+ border-radius: 22px;
767
+ overflow: hidden;
768
+ background: #0e0d0b;
769
+ aspect-ratio: 9/16;
770
+ position: relative;
771
+ max-height: 380px;
772
+ box-shadow: 0 20px 50px rgba(0,0,0,0.2);
773
+ }
774
+ .video-preview-bg {
775
+ position: absolute;
776
+ inset: 0;
777
+ background:
778
+ linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.7)),
779
+ radial-gradient(ellipse at 50% 50%, #d88845 0%, #6e3818 100%);
780
+ }
781
+ .video-preview-bg::after {
782
+ content: '🥗';
783
+ position: absolute;
784
+ top: 50%; left: 50%;
785
+ transform: translate(-50%, -50%);
786
+ font-size: 100px;
787
+ filter: drop-shadow(0 8px 20px rgba(0,0,0,0.5));
788
+ }
789
+ .video-caption {
790
+ position: absolute;
791
+ bottom: 60px;
792
+ left: 16px;
793
+ right: 16px;
794
+ background: rgba(255,255,255,0.95);
795
+ color: var(--ink);
796
+ padding: 10px 14px;
797
+ border-radius: 10px;
798
+ font-weight: 700;
799
+ font-size: 14px;
800
+ text-align: center;
801
+ box-shadow: 0 4px 16px rgba(0,0,0,0.3);
802
+ }
803
+ .video-caption .highlight { background: var(--sage); padding: 0 4px; border-radius: 2px; }
804
+ .video-play-btn {
805
+ position: absolute;
806
+ top: 50%; left: 50%;
807
+ transform: translate(-50%, -50%);
808
+ width: 64px; height: 64px;
809
+ border-radius: 50%;
810
+ background: rgba(255,255,255,0.9);
811
+ backdrop-filter: blur(10px);
812
+ border: none;
813
+ cursor: pointer;
814
+ display: flex;
815
+ align-items: center;
816
+ justify-content: center;
817
+ color: var(--ink);
818
+ font-size: 22px;
819
+ padding-left: 4px;
820
+ }
821
+ .video-time-bar {
822
+ position: absolute;
823
+ bottom: 14px;
824
+ left: 16px;
825
+ right: 16px;
826
+ display: flex;
827
+ align-items: center;
828
+ gap: 10px;
829
+ color: white;
830
+ font-family: 'DM Mono', monospace;
831
+ font-size: 10px;
832
+ }
833
+ .video-time-progress {
834
+ flex: 1;
835
+ height: 3px;
836
+ background: rgba(255,255,255,0.3);
837
+ border-radius: 2px;
838
+ overflow: hidden;
839
+ }
840
+ .video-time-progress::after {
841
+ content: '';
842
+ display: block;
843
+ width: 35%;
844
+ height: 100%;
845
+ background: white;
846
+ }
847
+
848
+ .preview-stats {
849
+ display: flex;
850
+ gap: 8px;
851
+ margin-bottom: 8px;
852
+ flex-wrap: wrap;
853
+ }
854
+ .preview-stat {
855
+ background: var(--paper);
856
+ border: 1px solid var(--line);
857
+ border-radius: 100px;
858
+ padding: 6px 12px;
859
+ font-size: 11px;
860
+ font-family: 'DM Mono', monospace;
861
+ color: var(--ink);
862
+ text-transform: uppercase;
863
+ letter-spacing: 0.05em;
864
+ }
865
+
866
+ /* === SCREEN 7: Preview with rendering-then-ready state === */
867
+ /* Default: rendering state visible, ready hidden. When .is-ready added, swap. */
868
+ #previewScreen .ready-only { display: none; }
869
+ #previewScreen.is-ready .rendering-only { display: none; }
870
+ #previewScreen.is-ready .ready-only { display: flex; }
871
+ #previewScreen.is-ready .video-caption.ready-only,
872
+ #previewScreen.is-ready .preview-header.ready-only { display: block; }
873
+ #previewScreen.is-ready .video-time-bar.ready-only { display: flex; }
874
+
875
+ .render-overlay {
876
+ position: absolute;
877
+ inset: 0;
878
+ background: rgba(14, 13, 11, 0.7);
879
+ backdrop-filter: blur(4px);
880
+ -webkit-backdrop-filter: blur(4px);
881
+ display: flex !important;
882
+ flex-direction: column;
883
+ align-items: center;
884
+ justify-content: center;
885
+ padding: 24px;
886
+ z-index: 5;
887
+ }
888
+ .render-shimmer {
889
+ position: absolute;
890
+ inset: 0;
891
+ background: linear-gradient(
892
+ 120deg,
893
+ transparent 0%,
894
+ rgba(184, 201, 168, 0.15) 40%,
895
+ rgba(184, 201, 168, 0.3) 50%,
896
+ rgba(184, 201, 168, 0.15) 60%,
897
+ transparent 100%
898
+ );
899
+ background-size: 200% 100%;
900
+ animation: shimmer 2.4s ease-in-out infinite;
901
+ }
902
+ @keyframes shimmer {
903
+ 0% { background-position: 200% 0; }
904
+ 100% { background-position: -100% 0; }
905
+ }
906
+ .render-list {
907
+ list-style: none;
908
+ width: 100%;
909
+ max-width: 240px;
910
+ display: flex;
911
+ flex-direction: column;
912
+ gap: 10px;
913
+ z-index: 1;
914
+ position: relative;
915
+ }
916
+ .render-item {
917
+ display: flex;
918
+ align-items: center;
919
+ gap: 12px;
920
+ background: rgba(245, 239, 226, 0.08);
921
+ border: 1px solid rgba(245, 239, 226, 0.12);
922
+ backdrop-filter: blur(8px);
923
+ padding: 10px 14px;
924
+ border-radius: 12px;
925
+ color: rgba(245, 239, 226, 0.9);
926
+ font-size: 13px;
927
+ }
928
+ .render-item.pending { opacity: 0.45; }
929
+ .render-item .proc-icon {
930
+ width: 20px; height: 20px; font-size: 11px;
931
+ }
932
+
933
+ /* === SCREEN 8: Reward === */
934
+ .success-screen {
935
+ background:
936
+ radial-gradient(ellipse at 50% 0%, rgba(184, 201, 168, 0.5), transparent 60%),
937
+ var(--cream);
938
+ }
939
+ .confetti {
940
+ position: absolute;
941
+ inset: 0;
942
+ overflow: hidden;
943
+ pointer-events: none;
944
+ }
945
+ .confetti span {
946
+ position: absolute;
947
+ font-size: 18px;
948
+ animation: confettiFall 4s linear infinite;
949
+ }
950
+ @keyframes confettiFall {
951
+ 0% { transform: translateY(-100px) rotate(0); opacity: 0; }
952
+ 10% { opacity: 1; }
953
+ 100% { transform: translateY(800px) rotate(720deg); opacity: 0; }
954
+ }
955
+
956
+ .success-content {
957
+ flex: 1;
958
+ display: flex;
959
+ flex-direction: column;
960
+ align-items: center;
961
+ text-align: center;
962
+ padding-top: 32px;
963
+ z-index: 2;
964
+ position: relative;
965
+ }
966
+
967
+ .success-emoji {
968
+ font-size: 64px;
969
+ margin-bottom: 16px;
970
+ animation: bounce 1.4s ease-in-out infinite;
971
+ }
972
+ @keyframes bounce {
973
+ 0%, 100% { transform: translateY(0); }
974
+ 50% { transform: translateY(-12px); }
975
+ }
976
+
977
+ .code-card {
978
+ background: var(--ink);
979
+ color: var(--cream);
980
+ border-radius: 24px;
981
+ padding: 24px;
982
+ margin: 28px 0 20px;
983
+ width: 100%;
984
+ text-align: center;
985
+ box-shadow: 0 20px 50px rgba(42, 37, 32, 0.25);
986
+ position: relative;
987
+ overflow: hidden;
988
+ }
989
+ .code-card::before {
990
+ content: '';
991
+ position: absolute;
992
+ top: -50%; left: -50%;
993
+ width: 200%; height: 200%;
994
+ background:
995
+ conic-gradient(from 0deg, transparent, rgba(184, 201, 168, 0.15), transparent 25%);
996
+ animation: rotateBg 8s linear infinite;
997
+ }
998
+ @keyframes rotateBg { to { transform: rotate(360deg); } }
999
+ .code-card > * { position: relative; }
1000
+ .code-label {
1001
+ font-family: 'DM Mono', monospace;
1002
+ font-size: 10px;
1003
+ letter-spacing: 0.2em;
1004
+ text-transform: uppercase;
1005
+ color: var(--sage);
1006
+ margin-bottom: 12px;
1007
+ }
1008
+ .code-value {
1009
+ font-family: 'Fraunces', serif;
1010
+ font-size: 42px;
1011
+ font-weight: 300;
1012
+ letter-spacing: 0.04em;
1013
+ margin-bottom: 8px;
1014
+ }
1015
+ .code-hint {
1016
+ font-size: 12px;
1017
+ opacity: 0.6;
1018
+ }
1019
+
1020
+ .show-server {
1021
+ background: var(--sage);
1022
+ color: var(--matcha-deep);
1023
+ padding: 14px 20px;
1024
+ border-radius: 16px;
1025
+ font-size: 13px;
1026
+ font-weight: 600;
1027
+ margin-bottom: 16px;
1028
+ display: flex;
1029
+ align-items: center;
1030
+ gap: 10px;
1031
+ width: 100%;
1032
+ }
1033
+
1034
+ /* === Right: Dev Notes === */
1035
+ .notes-panel {
1036
+ position: sticky;
1037
+ top: 0;
1038
+ height: 100vh;
1039
+ padding: 36px 28px;
1040
+ border-left: 1px solid rgba(245, 239, 226, 0.08);
1041
+ background: rgba(0,0,0,0.2);
1042
+ overflow-y: auto;
1043
+ }
1044
+ .notes-title {
1045
+ font-family: 'Fraunces', serif;
1046
+ font-size: 18px;
1047
+ font-style: italic;
1048
+ color: var(--cream);
1049
+ margin-bottom: 6px;
1050
+ }
1051
+ .notes-intro {
1052
+ font-size: 12px;
1053
+ color: var(--muted);
1054
+ line-height: 1.5;
1055
+ margin-bottom: 24px;
1056
+ padding-bottom: 20px;
1057
+ border-bottom: 1px solid rgba(245, 239, 226, 0.08);
1058
+ }
1059
+ .note-block {
1060
+ margin-bottom: 20px;
1061
+ }
1062
+ .note-screen-label {
1063
+ font-family: 'DM Mono', monospace;
1064
+ font-size: 9px;
1065
+ letter-spacing: 0.2em;
1066
+ text-transform: uppercase;
1067
+ color: var(--sage);
1068
+ margin-bottom: 8px;
1069
+ }
1070
+ .note-h {
1071
+ font-size: 13px;
1072
+ font-weight: 600;
1073
+ color: var(--cream);
1074
+ margin-bottom: 6px;
1075
+ }
1076
+ .note-list {
1077
+ list-style: none;
1078
+ font-size: 12px;
1079
+ line-height: 1.55;
1080
+ color: rgba(245, 239, 226, 0.65);
1081
+ }
1082
+ .note-list li {
1083
+ padding: 3px 0 3px 12px;
1084
+ position: relative;
1085
+ }
1086
+ .note-list li::before {
1087
+ content: '–';
1088
+ position: absolute;
1089
+ left: 0;
1090
+ color: var(--muted);
1091
+ }
1092
+
1093
+ /* Bottom controls bar (under phone) */
1094
+ .stage-controls {
1095
+ margin-top: 28px;
1096
+ display: flex;
1097
+ align-items: center;
1098
+ gap: 12px;
1099
+ padding: 8px 12px;
1100
+ background: rgba(245, 239, 226, 0.05);
1101
+ border: 1px solid rgba(245, 239, 226, 0.1);
1102
+ border-radius: 100px;
1103
+ }
1104
+ .ctrl-btn {
1105
+ background: transparent;
1106
+ border: none;
1107
+ color: var(--cream);
1108
+ cursor: pointer;
1109
+ padding: 10px 18px;
1110
+ border-radius: 100px;
1111
+ font-size: 13px;
1112
+ font-family: inherit;
1113
+ transition: all 0.2s;
1114
+ display: flex;
1115
+ align-items: center;
1116
+ gap: 6px;
1117
+ }
1118
+ .ctrl-btn:hover { background: rgba(245, 239, 226, 0.08); }
1119
+ .ctrl-btn.primary { background: var(--matcha); color: white; }
1120
+ .ctrl-btn.primary:hover { background: var(--matcha-deep); }
1121
+ .ctrl-counter {
1122
+ font-family: 'DM Mono', monospace;
1123
+ font-size: 11px;
1124
+ color: var(--muted);
1125
+ padding: 0 8px;
1126
+ min-width: 60px;
1127
+ text-align: center;
1128
+ }
1129
+
1130
+ /* QR scan animation on screen 0 (intro) */
1131
+ .intro-screen { background: var(--cream); }
1132
+ .qr-vibe {
1133
+ flex: 1;
1134
+ display: flex;
1135
+ flex-direction: column;
1136
+ align-items: center;
1137
+ justify-content: center;
1138
+ text-align: center;
1139
+ padding: 0 24px;
1140
+ }
1141
+ .qr-mock {
1142
+ width: 160px; height: 160px;
1143
+ background: white;
1144
+ border: 1px solid var(--line);
1145
+ border-radius: 20px;
1146
+ padding: 14px;
1147
+ margin-bottom: 20px;
1148
+ position: relative;
1149
+ }
1150
+ .qr-grid {
1151
+ width: 100%; height: 100%;
1152
+ background-image:
1153
+ linear-gradient(90deg, var(--ink) 25%, transparent 25% 50%, var(--ink) 50% 75%, transparent 75%),
1154
+ linear-gradient(var(--ink) 25%, transparent 25% 50%, var(--ink) 50% 75%, transparent 75%);
1155
+ background-size: 8px 8px;
1156
+ background-blend-mode: multiply;
1157
+ position: relative;
1158
+ border-radius: 8px;
1159
+ }
1160
+ .qr-grid::before, .qr-grid::after {
1161
+ content: '';
1162
+ position: absolute;
1163
+ width: 30px; height: 30px;
1164
+ border: 6px solid var(--ink);
1165
+ background: white;
1166
+ }
1167
+ .qr-grid::before { top: 0; left: 0; }
1168
+ .qr-grid::after { top: 0; right: 0; }
1169
+ .qr-corner {
1170
+ position: absolute;
1171
+ bottom: 14px; left: 14px;
1172
+ width: 30px; height: 30px;
1173
+ border: 6px solid var(--ink);
1174
+ background: white;
1175
+ }
1176
+ .qr-scan-line {
1177
+ position: absolute;
1178
+ inset: 14px;
1179
+ border-radius: 8px;
1180
+ overflow: hidden;
1181
+ pointer-events: none;
1182
+ }
1183
+ .qr-scan-line::after {
1184
+ content: '';
1185
+ position: absolute;
1186
+ left: 0; right: 0;
1187
+ height: 2px;
1188
+ background: linear-gradient(90deg, transparent, var(--matcha), transparent);
1189
+ animation: scan 2s ease-in-out infinite;
1190
+ }
1191
+ @keyframes scan {
1192
+ 0%, 100% { top: 0; }
1193
+ 50% { top: 100%; }
1194
+ }
1195
+
1196
+ /* Annotation badge that hovers near phone */
1197
+ .anno-badge {
1198
+ position: absolute;
1199
+ background: rgba(245, 239, 226, 0.06);
1200
+ border: 1px solid rgba(245, 239, 226, 0.15);
1201
+ color: var(--cream);
1202
+ padding: 8px 14px;
1203
+ border-radius: 100px;
1204
+ font-family: 'DM Mono', monospace;
1205
+ font-size: 10px;
1206
+ letter-spacing: 0.1em;
1207
+ text-transform: uppercase;
1208
+ backdrop-filter: blur(10px);
1209
+ }
1210
+
1211
+ .top-banner {
1212
+ position: absolute;
1213
+ top: 0; left: 0; right: 0;
1214
+ background: var(--matcha);
1215
+ color: var(--cream);
1216
+ text-align: center;
1217
+ padding: 8px 16px;
1218
+ font-family: 'DM Mono', monospace;
1219
+ font-size: 10px;
1220
+ letter-spacing: 0.15em;
1221
+ text-transform: uppercase;
1222
+ z-index: 20;
1223
+ }
1224
+
1225
+ </style>
1226
+ </head>
1227
+ <body>
1228
+
1229
+ <div class="top-banner">⌘ Click-through prototype · not production code · for engineering reference ⌘</div>
1230
+
1231
+ <div class="stage">
1232
+
1233
+ <!-- ============ LEFT NAVIGATOR ============ -->
1234
+ <aside class="nav-panel">
1235
+ <div class="brand">
1236
+ <div class="brand-mark">Matcha <em>Moments</em></div>
1237
+ </div>
1238
+ <div class="subtitle">prototype · v0.1</div>
1239
+
1240
+ <div class="nav-label">User flow</div>
1241
+ <ul class="nav-list" id="navList">
1242
+ <li class="nav-item active" data-screen="0"><span class="nav-num">00</span><span>QR scan trigger</span></li>
1243
+ <li class="nav-item" data-screen="1"><span class="nav-num">01</span><span>Landing — free matcha</span></li>
1244
+ <li class="nav-item" data-screen="2"><span class="nav-num">02</span><span>Clip 1 · the dish</span></li>
1245
+ <li class="nav-item" data-screen="3"><span class="nav-num">03</span><span>Clip 2 · what you ordered</span></li>
1246
+ <li class="nav-item" data-screen="4"><span class="nav-num">04</span><span>Clip 3 · take a bite</span></li>
1247
+ <li class="nav-item" data-screen="5"><span class="nav-num">05</span><span>Clip 4 · what you loved</span></li>
1248
+ <li class="nav-item" data-screen="6"><span class="nav-num">06</span><span>Clip 5 · would you recommend</span></li>
1249
+ <li class="nav-item" data-screen="7"><span class="nav-num">07</span><span>Preview & approve</span></li>
1250
+ <li class="nav-item" data-screen="8"><span class="nav-num">08</span><span>Reward — matcha code</span></li>
1251
+ </ul>
1252
+
1253
+ <div class="nav-divider"></div>
1254
+
1255
+ <div class="nav-label">Notes for build</div>
1256
+ <p style="font-size: 11px; color: var(--muted); line-height: 1.55;">
1257
+ Mobile-first PWA. Browser-based — no app install. Camera + mic permissions via <code style="font-family: 'DM Mono', monospace; color: var(--sage);">getUserMedia</code>. Server-side video stitching + caption generation. Code is single-use, scoped to this cafe + table session.
1258
+ </p>
1259
+ </aside>
1260
+
1261
+ <!-- ============ CENTER: PHONE ============ -->
1262
+ <main class="phone-stage">
1263
+ <div class="stage-header">
1264
+ <h1>The <em>matcha</em> moment</h1>
1265
+ <p>guided ugc capture · cafe pilot</p>
1266
+ </div>
1267
+
1268
+ <div class="phone">
1269
+ <div class="phone-screen">
1270
+ <div class="screens" id="screens">
1271
+
1272
+ <!-- ===== SCREEN 0: QR scan ===== -->
1273
+ <section class="screen intro-screen active" data-screen="0">
1274
+ <div class="status-bar"><span>9:41</span><span class="right">●●● 5G ◎</span></div>
1275
+ <div class="screen-body">
1276
+ <div class="qr-vibe">
1277
+ <div class="qr-mock">
1278
+ <div class="qr-grid"></div>
1279
+ <div class="qr-corner"></div>
1280
+ <div class="qr-scan-line"></div>
1281
+ </div>
1282
+ <div class="eyebrow" style="margin-bottom: 12px;">Step 00 · context</div>
1283
+ <h2 class="display" style="font-size: 26px; text-align: center; margin-bottom: 12px;">She scans the QR <em>on the table</em></h2>
1284
+ <p class="body-text" style="text-align: center; max-width: 280px;">Tabletop card reads: <em>"Free matcha for an honest review."</em> She points her camera. Browser opens.</p>
1285
+ </div>
1286
+ <div class="btn-stack">
1287
+ <button class="btn btn-primary" onclick="goTo(1)">Tap to load the page →</button>
1288
+ </div>
1289
+ </div>
1290
+ </section>
1291
+
1292
+ <!-- ===== SCREEN 1: Combined landing (hook + consent + permission CTA) ===== -->
1293
+ <section class="screen" data-screen="1">
1294
+ <div class="status-bar"><span>9:41</span><span class="right">●●● 5G ◎</span></div>
1295
+ <div class="screen-body s1-hero">
1296
+ <div class="s1-cafe-name">Sage &amp; Stone Café</div>
1297
+ <div class="s1-matcha-circle">
1298
+ <div class="s1-foam">~</div>
1299
+ </div>
1300
+ <h2 class="display s1-title">Free matcha,<br><em>on the house</em></h2>
1301
+ <p class="s1-sub">We'll guide you through a quick video review &mdash; and do all the editing for you.</p>
1302
+ <div class="consent-inline">
1303
+ By tapping below, you allow Sage &amp; Stone to use your video on social media. Your browser will ask for camera access.
1304
+ </div>
1305
+ <div class="btn-stack">
1306
+ <button class="btn btn-primary" onclick="goTo(2)">Get my matcha →</button>
1307
+ </div>
1308
+ </div>
1309
+ </section>
1310
+
1311
+ <!-- ===== CAMERA SCREENS 4-8 ===== -->
1312
+ <!-- Clip 1: the food -->
1313
+ <section class="screen camera-screen" data-screen="2">
1314
+ <div class="status-bar"><span>9:41</span><span class="right">●●● 5G ◎</span></div>
1315
+ <div class="cam-viewfinder">
1316
+ <div class="cam-progress">
1317
+ <div class="cam-progress-pip current"></div>
1318
+ <div class="cam-progress-pip"></div>
1319
+ <div class="cam-progress-pip"></div>
1320
+ <div class="cam-progress-pip"></div>
1321
+ <div class="cam-progress-pip"></div>
1322
+ </div>
1323
+ <div class="cam-prompt-card">
1324
+ <div class="cam-prompt-step">Clip 01 of 05</div>
1325
+ <div class="cam-prompt-title">Show us the dish.</div>
1326
+ <div class="cam-prompt-tip">Slow pan around your plate · 5–8 seconds · keep it steady</div>
1327
+ </div>
1328
+ <div class="food-mock"><div class="food-bowl"></div></div>
1329
+ <div class="recording-badge"><span class="red-dot"></span><span>REC 0:04</span></div>
1330
+ <div class="cam-controls">
1331
+ <button class="cam-side-btn" onclick="goBack()">←</button>
1332
+ <button class="cam-record-btn" onclick="handleRecord(this, 3)"><div class="inner"></div></button>
1333
+ <button class="cam-side-btn" onclick="goTo(3)">→</button>
1334
+ </div>
1335
+ </div>
1336
+ </section>
1337
+
1338
+ <!-- Clip 2: what you ordered -->
1339
+ <section class="screen camera-screen" data-screen="3">
1340
+ <div class="status-bar"><span>9:41</span><span class="right">●●● 5G ◎</span></div>
1341
+ <div class="cam-viewfinder">
1342
+ <div class="cam-progress">
1343
+ <div class="cam-progress-pip done"></div>
1344
+ <div class="cam-progress-pip current"></div>
1345
+ <div class="cam-progress-pip"></div>
1346
+ <div class="cam-progress-pip"></div>
1347
+ <div class="cam-progress-pip"></div>
1348
+ </div>
1349
+ <div class="cam-prompt-card">
1350
+ <div class="cam-prompt-step">Clip 02 of 05</div>
1351
+ <div class="cam-prompt-title">Tell us what you ordered.</div>
1352
+ <div class="cam-prompt-tip">Just the dish name · keep it short and natural</div>
1353
+ </div>
1354
+ <div class="person-silhouette"></div>
1355
+ <div class="recording-badge"><span class="red-dot"></span><span>REC 0:03</span></div>
1356
+ <div class="cam-controls">
1357
+ <button class="cam-side-btn" onclick="goTo(2)">←</button>
1358
+ <button class="cam-record-btn" onclick="handleRecord(this, 4)"><div class="inner"></div></button>
1359
+ <button class="cam-side-btn" onclick="goTo(4)">→</button>
1360
+ </div>
1361
+ </div>
1362
+ </section>
1363
+
1364
+ <!-- Clip 3: take a bite -->
1365
+ <section class="screen camera-screen" data-screen="4">
1366
+ <div class="status-bar"><span>9:41</span><span class="right">●●● 5G ◎</span></div>
1367
+ <div class="cam-viewfinder">
1368
+ <div class="cam-progress">
1369
+ <div class="cam-progress-pip done"></div>
1370
+ <div class="cam-progress-pip done"></div>
1371
+ <div class="cam-progress-pip current"></div>
1372
+ <div class="cam-progress-pip"></div>
1373
+ <div class="cam-progress-pip"></div>
1374
+ </div>
1375
+ <div class="cam-prompt-card">
1376
+ <div class="cam-prompt-step">Clip 03 of 05</div>
1377
+ <div class="cam-prompt-title">Take a bite — show us your reaction.</div>
1378
+ <div class="cam-prompt-tip">That first delicious moment · be expressive · ~5 sec</div>
1379
+ </div>
1380
+ <div class="person-silhouette"></div>
1381
+ <div class="recording-badge"><span class="red-dot"></span><span>REC 0:05</span></div>
1382
+ <div class="cam-controls">
1383
+ <button class="cam-side-btn" onclick="goTo(3)">←</button>
1384
+ <button class="cam-record-btn" onclick="handleRecord(this, 5)"><div class="inner"></div></button>
1385
+ <button class="cam-side-btn" onclick="goTo(5)">→</button>
1386
+ </div>
1387
+ </div>
1388
+ </section>
1389
+
1390
+ <!-- Clip 4: what you loved -->
1391
+ <section class="screen camera-screen" data-screen="5">
1392
+ <div class="status-bar"><span>9:41</span><span class="right">●●● 5G ◎</span></div>
1393
+ <div class="cam-viewfinder">
1394
+ <div class="cam-progress">
1395
+ <div class="cam-progress-pip done"></div>
1396
+ <div class="cam-progress-pip done"></div>
1397
+ <div class="cam-progress-pip done"></div>
1398
+ <div class="cam-progress-pip current"></div>
1399
+ <div class="cam-progress-pip"></div>
1400
+ </div>
1401
+ <div class="cam-prompt-card">
1402
+ <div class="cam-prompt-step">Clip 04 of 05</div>
1403
+ <div class="cam-prompt-title">What did you love about it?</div>
1404
+ <div class="cam-prompt-tip">The flavor? The plating? The vibe? · 8–10 sec</div>
1405
+ </div>
1406
+ <div class="person-silhouette"></div>
1407
+ <div class="recording-badge"><span class="red-dot"></span><span>REC 0:08</span></div>
1408
+ <div class="cam-controls">
1409
+ <button class="cam-side-btn" onclick="goTo(4)">←</button>
1410
+ <button class="cam-record-btn" onclick="handleRecord(this, 6)"><div class="inner"></div></button>
1411
+ <button class="cam-side-btn" onclick="goTo(6)">→</button>
1412
+ </div>
1413
+ </div>
1414
+ </section>
1415
+
1416
+ <!-- Clip 5: recommend -->
1417
+ <section class="screen camera-screen" data-screen="6">
1418
+ <div class="status-bar"><span>9:41</span><span class="right">●●● 5G ◎</span></div>
1419
+ <div class="cam-viewfinder">
1420
+ <div class="cam-progress">
1421
+ <div class="cam-progress-pip done"></div>
1422
+ <div class="cam-progress-pip done"></div>
1423
+ <div class="cam-progress-pip done"></div>
1424
+ <div class="cam-progress-pip done"></div>
1425
+ <div class="cam-progress-pip current"></div>
1426
+ </div>
1427
+ <div class="cam-prompt-card">
1428
+ <div class="cam-prompt-step">Clip 05 of 05</div>
1429
+ <div class="cam-prompt-title">Would you bring a friend back here?</div>
1430
+ <div class="cam-prompt-tip">Tell the camera what makes Sage &amp; Stone worth a return</div>
1431
+ </div>
1432
+ <div class="person-silhouette"></div>
1433
+ <div class="recording-badge"><span class="red-dot"></span><span>REC 0:09</span></div>
1434
+ <div class="cam-controls">
1435
+ <button class="cam-side-btn" onclick="goTo(5)">←</button>
1436
+ <button class="cam-record-btn" onclick="handleRecord(this, 7)"><div class="inner"></div></button>
1437
+ <button class="cam-side-btn" onclick="goTo(7)">→</button>
1438
+ </div>
1439
+ </div>
1440
+ </section>
1441
+
1442
+ <!-- ===== SCREEN 7: Preview (renders in place, then ready) ===== -->
1443
+ <section class="screen" data-screen="7" id="previewScreen">
1444
+ <div class="status-bar"><span>9:41</span><span class="right">●●● 5G ◎</span></div>
1445
+ <div class="screen-body" style="padding-top: 16px;">
1446
+
1447
+ <!-- Header swaps based on state -->
1448
+ <div class="preview-header rendering-only">
1449
+ <div class="eyebrow">rendering · ~ 30 seconds</div>
1450
+ <h2 class="display" style="font-size: 30px;">Putting it<br><em>together.</em></h2>
1451
+ </div>
1452
+ <div class="preview-header ready-only">
1453
+ <div class="eyebrow">your video — 0:42</div>
1454
+ <h2 class="display" style="font-size: 30px;">Looking <em>delicious.</em></h2>
1455
+ </div>
1456
+
1457
+ <!-- Video frame — always present, content swaps -->
1458
+ <div class="video-preview">
1459
+ <div class="video-preview-bg"></div>
1460
+
1461
+ <!-- Rendering overlay -->
1462
+ <div class="render-overlay rendering-only">
1463
+ <div class="render-shimmer"></div>
1464
+ <ul class="render-list">
1465
+ <li class="render-item"><div class="proc-icon done">✓</div><span>Stitching clips</span></li>
1466
+ <li class="render-item"><div class="proc-icon done">✓</div><span>Adding jumpcuts</span></li>
1467
+ <li class="render-item"><div class="proc-icon current"></div><span>Generating subtitles</span></li>
1468
+ <li class="render-item pending"><div class="proc-icon pending">·</div><span>Final polish</span></li>
1469
+ </ul>
1470
+ </div>
1471
+
1472
+ <!-- Ready overlay -->
1473
+ <button class="video-play-btn ready-only">▶</button>
1474
+ <div class="video-caption ready-only">"The miso eggplant — <span class="highlight">unreal</span> 🤤"</div>
1475
+ <div class="video-time-bar ready-only">
1476
+ <span>0:14</span>
1477
+ <div class="video-time-progress"></div>
1478
+ <span>0:42</span>
1479
+ </div>
1480
+ </div>
1481
+
1482
+ <div class="preview-stats ready-only">
1483
+ <div class="preview-stat">5 clips</div>
1484
+ <div class="preview-stat">subtitles on</div>
1485
+ <div class="preview-stat">9:16 vertical</div>
1486
+ </div>
1487
+
1488
+ <div class="btn-stack">
1489
+ <button class="btn btn-primary rendering-only" disabled style="opacity: 0.4; cursor: not-allowed;">Submit (rendering...)</button>
1490
+ <button class="btn btn-primary ready-only" onclick="goTo(8)">Submit &amp; get my matcha</button>
1491
+ <button class="btn btn-secondary ready-only" onclick="goTo(2)">↺ Re-record clips</button>
1492
+ <button class="btn btn-ghost rendering-only" onclick="completeRender()" style="font-size: 12px;">→ Skip render (demo only)</button>
1493
+ </div>
1494
+ </div>
1495
+ </section>
1496
+
1497
+ <!-- ===== SCREEN 8: Reward ===== -->
1498
+ <section class="screen success-screen" data-screen="8">
1499
+ <div class="confetti">
1500
+ <span style="left:5%; animation-delay: 0s;">🍵</span>
1501
+ <span style="left:18%; animation-delay: 0.4s;">✨</span>
1502
+ <span style="left:32%; animation-delay: 1s;">🥬</span>
1503
+ <span style="left:48%; animation-delay: 0.2s;">🍵</span>
1504
+ <span style="left:62%; animation-delay: 1.5s;">✨</span>
1505
+ <span style="left:78%; animation-delay: 0.7s;">🥬</span>
1506
+ <span style="left:90%; animation-delay: 1.8s;">🍵</span>
1507
+ </div>
1508
+ <div class="status-bar"><span>9:41</span><span class="right">●●● 5G ◎</span></div>
1509
+ <div class="screen-body">
1510
+ <div class="success-content">
1511
+ <div class="success-emoji">🎉</div>
1512
+ <div class="eyebrow">submitted · thank you</div>
1513
+ <h2 class="display" style="text-align: center;">You're a <em>star.</em></h2>
1514
+ <p class="body-text" style="text-align: center; margin-top: 10px; max-width: 280px;">Your video is on its way to Sage &amp; Stone's social. Now — about that matcha.</p>
1515
+
1516
+ <div class="code-card">
1517
+ <div class="code-label">Your code</div>
1518
+ <div class="code-value">MATCHA · 7K2Q</div>
1519
+ <div class="code-hint">single-use · expires in 30 min</div>
1520
+ </div>
1521
+
1522
+ <div class="show-server">
1523
+ <span style="font-size: 18px;">👋</span>
1524
+ <span>Show this screen to your server</span>
1525
+ </div>
1526
+
1527
+ <p style="font-size: 11px; color: var(--muted); text-align: center; line-height: 1.5;">
1528
+ Want a copy of your video? <span style="color: var(--matcha); text-decoration: underline;">Send it to my email →</span>
1529
+ </p>
1530
+ </div>
1531
+ <div class="btn-stack">
1532
+ <button class="btn btn-secondary" onclick="goTo(0)">↺ Restart prototype</button>
1533
+ </div>
1534
+ </div>
1535
+ </section>
1536
+
1537
+ </div>
1538
+ </div>
1539
+ </div>
1540
+
1541
+ <!-- Stage controls -->
1542
+ <div class="stage-controls">
1543
+ <button class="ctrl-btn" onclick="prev()">← Prev</button>
1544
+ <span class="ctrl-counter" id="counter">00 / 11</span>
1545
+ <button class="ctrl-btn primary" onclick="next()">Next →</button>
1546
+ </div>
1547
+
1548
+ </main>
1549
+
1550
+ <!-- ============ RIGHT: DEV NOTES ============ -->
1551
+ <aside class="notes-panel">
1552
+ <div class="notes-title">Notes for the dev</div>
1553
+ <p class="notes-intro">Per-screen behavior, edge cases, and what the backend needs to know. Tap screens on the left to inspect.</p>
1554
+
1555
+ <div id="noteContent">
1556
+ <!-- Populated by JS based on active screen -->
1557
+ </div>
1558
+ </aside>
1559
+
1560
+ </div>
1561
+
1562
+ <script>
1563
+ // ============ Screen state + nav ============
1564
+ let current = 0;
1565
+ const total = 9; // 0 through 8
1566
+
1567
+ const notes = {
1568
+ 0: {
1569
+ label: 'Screen 00 · Trigger',
1570
+ title: 'QR scan → web app',
1571
+ bullets: [
1572
+ 'QR code printed on a tabletop card. URL embeds cafe ID + table ID as query params (e.g. /?c=sageandstone&t=12).',
1573
+ 'No app install. PWA opens in mobile browser.',
1574
+ 'Backend uses cafe+table ID to scope the reward code.',
1575
+ 'No login required at this stage.'
1576
+ ]
1577
+ },
1578
+ 1: {
1579
+ label: 'Screen 01 · Landing',
1580
+ title: 'One-screen entry point',
1581
+ bullets: [
1582
+ 'Collapses what used to be three screens (hook + how-it-works + permission) into one. Less friction = better conversion.',
1583
+ 'Consent line above the CTA serves as the legal record. Tapping the button = recorded consent event (timestamp + IP).',
1584
+ 'CTA tap should immediately call <code>navigator.mediaDevices.getUserMedia({video, audio})</code> — the native browser prompt does the work the old "permission" screen used to do.',
1585
+ 'If permission is denied: show recovery state with browser-specific instructions to re-enable.',
1586
+ 'Cafe-branded — pull cafe name + accent color from cafe ID.',
1587
+ 'Track conversion: scans → CTA tap. This is your top of funnel.'
1588
+ ]
1589
+ },
1590
+ 2: {
1591
+ label: 'Screen 02 · Clip 1 · the dish',
1592
+ title: 'First recording',
1593
+ bullets: [
1594
+ 'Rear camera (the food shot). Switch to front camera from clip 2 onwards.',
1595
+ 'Prompt overlay stays visible the whole time.',
1596
+ 'Tap big record button → start MediaRecorder. Tap again → stop.',
1597
+ 'Soft target: 5-8 sec shown to user. Hard cap: 15 sec (auto-stop).',
1598
+ 'Show live recording indicator + elapsed timer.',
1599
+ 'On stop: store Blob locally, advance to next prompt automatically.'
1600
+ ]
1601
+ },
1602
+ 3: {
1603
+ label: 'Screen 03 · Clip 2 · what you ordered',
1604
+ title: 'Front camera',
1605
+ bullets: [
1606
+ 'Switch to front camera. Cache the stream switch — should not re-trigger permission prompt.',
1607
+ 'Same record/stop pattern. Hard cap: 8 sec.',
1608
+ 'Optional: real-time mic level meter so they know audio is being captured.'
1609
+ ]
1610
+ },
1611
+ 4: {
1612
+ label: 'Screen 04 · Clip 3 · taste reaction',
1613
+ title: 'The money shot',
1614
+ bullets: [
1615
+ 'Front camera. Hard cap: 8 sec.',
1616
+ 'Highest-value clip for social — make sure audio is clean.',
1617
+ 'Consider a 3-2-1 countdown before record starts so they can prep their bite.'
1618
+ ]
1619
+ },
1620
+ 5: {
1621
+ label: 'Screen 05 · Clip 4 · what you loved',
1622
+ title: 'Testimonial substance',
1623
+ bullets: [
1624
+ 'Front camera. Hard cap: 12 sec (longer than reaction clips).',
1625
+ 'Tip text examples ("the flavor? the plating?") prime substantive answers — vary these per session.',
1626
+ 'This clip drives trust on social — most likely to be quoted.'
1627
+ ]
1628
+ },
1629
+ 6: {
1630
+ label: 'Screen 06 · Clip 5 · recommend',
1631
+ title: 'Closing CTA',
1632
+ bullets: [
1633
+ 'Front camera. Hard cap: 12 sec.',
1634
+ 'On stop: upload all 5 clips to backend. Use chunked upload + retry — cafe wifi is unreliable.',
1635
+ 'Once uploaded, kick off processing job, navigate to screen 07.',
1636
+ 'Don\'t wait for upload to finish before navigating — show progress on screen 07.'
1637
+ ]
1638
+ },
1639
+ 7: {
1640
+ label: 'Screen 07 · Preview (renders in place)',
1641
+ title: 'Render + approval — single screen',
1642
+ bullets: [
1643
+ 'Two states: rendering (default on entry) and ready. Same screen, same heading area — content swaps.',
1644
+ 'No standalone "loading" screen — the preview frame itself contains the progress UI. Feels like watching the result come together vs waiting at a spinner.',
1645
+ 'Backend pipeline: stitch → speech-to-text → caption styling → jumpcuts → color grade → export 9:16.',
1646
+ 'Recommend FFmpeg + Whisper (or Deepgram) + a captioning lib. Target turnaround < 60 sec.',
1647
+ 'Use websocket or polling for render progress. Email/SMS fallback if user closes tab.',
1648
+ 'Submit button is disabled during render, enabled when complete.',
1649
+ 'Re-record (open Q): per-clip is much better UX, more dev work. Currently routes to clip 1 (full restart).'
1650
+ ]
1651
+ },
1652
+ 8: {
1653
+ label: 'Screen 08 · Reward',
1654
+ title: 'Code redemption',
1655
+ bullets: [
1656
+ 'Code is single-use, scoped to the cafe + table session.',
1657
+ 'Server validates when staff marks redeemed (staff-side dashboard, separate scope).',
1658
+ 'Expires in 30 min so it can\'t be saved + reused later.',
1659
+ 'Optional: email-the-video field — captures lead, lets user share their own UGC.'
1660
+ ]
1661
+ }
1662
+ };
1663
+
1664
+ function render() {
1665
+ document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
1666
+ document.querySelector(`.screen[data-screen="${current}"]`).classList.add('active');
1667
+
1668
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
1669
+ document.querySelector(`.nav-item[data-screen="${current}"]`)?.classList.add('active');
1670
+
1671
+ document.getElementById('counter').textContent = `${String(current).padStart(2, '0')} / 08`;
1672
+
1673
+ // Render note
1674
+ const note = notes[current];
1675
+ if (note) {
1676
+ document.getElementById('noteContent').innerHTML = `
1677
+ <div class="note-block">
1678
+ <div class="note-screen-label">${note.label}</div>
1679
+ <div class="note-h">${note.title}</div>
1680
+ <ul class="note-list">
1681
+ ${note.bullets.map(b => `<li>${b}</li>`).join('')}
1682
+ </ul>
1683
+ </div>
1684
+ `;
1685
+ }
1686
+
1687
+ // Reset any recording state on screen change
1688
+ document.querySelectorAll('.recording-badge').forEach(b => b.classList.remove('show'));
1689
+ document.querySelectorAll('.cam-record-btn').forEach(b => b.classList.remove('recording'));
1690
+
1691
+ // When entering preview screen, reset to rendering state and auto-complete after 4s
1692
+ const preview = document.getElementById('previewScreen');
1693
+ if (preview) {
1694
+ if (current === 7) {
1695
+ preview.classList.remove('is-ready');
1696
+ clearTimeout(window._renderTimer);
1697
+ window._renderTimer = setTimeout(() => {
1698
+ if (current === 7) preview.classList.add('is-ready');
1699
+ }, 4000);
1700
+ } else {
1701
+ clearTimeout(window._renderTimer);
1702
+ }
1703
+ }
1704
+ }
1705
+
1706
+ function goTo(n) { current = Math.max(0, Math.min(8, n)); render(); }
1707
+ function next() { goTo(current + 1); }
1708
+ function prev() { goTo(current - 1); }
1709
+ function goBack() { goTo(current - 1); }
1710
+
1711
+ function completeRender() {
1712
+ const preview = document.getElementById('previewScreen');
1713
+ if (preview) {
1714
+ clearTimeout(window._renderTimer);
1715
+ preview.classList.add('is-ready');
1716
+ }
1717
+ }
1718
+
1719
+ // Recording sim — tap once to "record", tap again to stop and advance
1720
+ function handleRecord(btn, nextScreen) {
1721
+ const badge = btn.closest('.cam-viewfinder').querySelector('.recording-badge');
1722
+ if (!btn.classList.contains('recording')) {
1723
+ btn.classList.add('recording');
1724
+ badge.classList.add('show');
1725
+ } else {
1726
+ btn.classList.remove('recording');
1727
+ badge.classList.remove('show');
1728
+ setTimeout(() => goTo(nextScreen), 200);
1729
+ }
1730
+ }
1731
+
1732
+ // Wire up navigator
1733
+ document.querySelectorAll('.nav-item').forEach(item => {
1734
+ item.addEventListener('click', () => goTo(parseInt(item.dataset.screen)));
1735
+ });
1736
+
1737
+ // Keyboard
1738
+ document.addEventListener('keydown', (e) => {
1739
+ if (e.key === 'ArrowRight') next();
1740
+ if (e.key === 'ArrowLeft') prev();
1741
+ });
1742
+
1743
+ render();
1744
+ </script>
1745
+
1746
+ </body>
1747
+ </html>
src/lib/server/serverClipRenderer.ts CHANGED
@@ -23,8 +23,10 @@ const WORK_DIR = path.join(process.cwd(), '.local-review-data', 'server-renders'
23
  const VIDEO_WIDTH = 540;
24
  const VIDEO_HEIGHT = 960;
25
  const VIDEO_FPS = 24;
26
- const MAX_VIDEO_CLIP_SECONDS = 5;
 
27
  const MAX_AUDIO_CLIP_SECONDS = 12;
 
28
 
29
  function safeExt(ext: string) {
30
  const normalized = ext.toLowerCase().replace(/[^a-z0-9]/g, '');
@@ -112,12 +114,20 @@ async function probeDuration(filePath: string) {
112
  });
113
  });
114
  const duration = Number(output);
115
- return Number.isFinite(duration) ? Math.max(0, Math.round(duration)) : 0;
116
  } catch {
117
  return 0;
118
  }
119
  }
120
 
 
 
 
 
 
 
 
 
121
  export async function renderClipsOnServer(input: ServerRenderInput): Promise<ServerRenderResult> {
122
  if (input.videoClips.length === 0) {
123
  throw new Error('No video clips were uploaded.');
@@ -144,10 +154,31 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
144
  audioPaths.push(await prepareClipSource(clip, filePath));
145
  }
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  const inputArgs = [
148
  ...videoPaths.flatMap((filePath) => [
149
  '-t',
150
- String(MAX_VIDEO_CLIP_SECONDS),
151
  '-i',
152
  filePath,
153
  ]),
@@ -162,7 +193,7 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
162
  const videoFilters = videoPaths
163
  .map((_, i) => {
164
  return (
165
- `[${i}:v]trim=duration=${MAX_VIDEO_CLIP_SECONDS},setpts=PTS-STARTPTS,` +
166
  `scale=${VIDEO_WIDTH}:${VIDEO_HEIGHT}:force_original_aspect_ratio=decrease,` +
167
  `pad=${VIDEO_WIDTH}:${VIDEO_HEIGHT}:(ow-iw)/2:(oh-ih)/2,` +
168
  `setsar=1,fps=${VIDEO_FPS},format=yuv420p[v${i}]`
@@ -170,7 +201,12 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
170
  })
171
  .join(';');
172
  const videoInputs = videoPaths.map((_, i) => `[v${i}]`).join('');
173
- const videoConcat = `${videoInputs}concat=n=${videoPaths.length}:v=1:a=0[v]`;
 
 
 
 
 
174
 
175
  const audioOffset = videoPaths.length;
176
  const audioFilters = audioPaths
@@ -186,7 +222,9 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
186
  ? `${audioInputs}concat=n=${audioPaths.length}:v=0:a=1[a]`
187
  : '';
188
 
189
- const filterComplex = [videoFilters, videoConcat, audioFilters, audioConcat]
 
 
190
  .filter(Boolean)
191
  .join(';');
192
 
@@ -210,6 +248,8 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
210
  'zerolatency',
211
  '-crf',
212
  '30',
 
 
213
  ...(audioPaths.length ? ['-c:a', 'aac', '-b:a', '96k'] : ['-an']),
214
  '-movflags',
215
  '+faststart',
@@ -225,7 +265,7 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
225
 
226
  return {
227
  bytes,
228
- durationSeconds: await probeDuration(outputPath),
229
  filename: `matcha-server-${runId}.mp4`,
230
  };
231
  } finally {
 
23
  const VIDEO_WIDTH = 540;
24
  const VIDEO_HEIGHT = 960;
25
  const VIDEO_FPS = 24;
26
+ const MIN_VIDEO_CLIP_SECONDS = 5;
27
+ const MAX_VIDEO_CLIP_SECONDS = 7;
28
  const MAX_AUDIO_CLIP_SECONDS = 12;
29
+ const AUDIO_VIDEO_SAFETY_SECONDS = 0.2;
30
 
31
  function safeExt(ext: string) {
32
  const normalized = ext.toLowerCase().replace(/[^a-z0-9]/g, '');
 
114
  });
115
  });
116
  const duration = Number(output);
117
+ return Number.isFinite(duration) ? Math.max(0, duration) : 0;
118
  } catch {
119
  return 0;
120
  }
121
  }
122
 
123
+ function formatDuration(seconds: number) {
124
+ return seconds.toFixed(3).replace(/\.?0+$/, '');
125
+ }
126
+
127
+ function clamp(value: number, min: number, max: number) {
128
+ return Math.max(min, Math.min(max, value));
129
+ }
130
+
131
  export async function renderClipsOnServer(input: ServerRenderInput): Promise<ServerRenderResult> {
132
  if (input.videoClips.length === 0) {
133
  throw new Error('No video clips were uploaded.');
 
154
  audioPaths.push(await prepareClipSource(clip, filePath));
155
  }
156
 
157
+ const [videoDurations, audioDurations] = await Promise.all([
158
+ Promise.all(videoPaths.map(probeDuration)),
159
+ Promise.all(audioPaths.map(probeDuration)),
160
+ ]);
161
+
162
+ const audioDurationSeconds = audioDurations.reduce((total, duration) => total + duration, 0);
163
+ const videoClipSeconds =
164
+ audioDurationSeconds > 0
165
+ ? clamp(Math.ceil(audioDurationSeconds / videoPaths.length), MIN_VIDEO_CLIP_SECONDS, MAX_VIDEO_CLIP_SECONDS)
166
+ : MIN_VIDEO_CLIP_SECONDS;
167
+ const cappedVideoDurations = videoDurations.map((duration) =>
168
+ duration > 0 ? Math.min(duration, videoClipSeconds) : videoClipSeconds,
169
+ );
170
+ const baseVideoDurationSeconds = cappedVideoDurations.reduce((total, duration) => total + duration, 0);
171
+ const renderTargetSeconds =
172
+ audioDurationSeconds > 0
173
+ ? Math.max(1, audioDurationSeconds + AUDIO_VIDEO_SAFETY_SECONDS)
174
+ : Math.max(1, baseVideoDurationSeconds);
175
+ const loopFrameCount = Math.max(1, Math.ceil(baseVideoDurationSeconds * VIDEO_FPS));
176
+ const needsVideoLoop = audioDurationSeconds > 0 && baseVideoDurationSeconds + 0.1 < renderTargetSeconds;
177
+
178
  const inputArgs = [
179
  ...videoPaths.flatMap((filePath) => [
180
  '-t',
181
+ formatDuration(videoClipSeconds),
182
  '-i',
183
  filePath,
184
  ]),
 
193
  const videoFilters = videoPaths
194
  .map((_, i) => {
195
  return (
196
+ `[${i}:v]trim=duration=${formatDuration(videoClipSeconds)},setpts=PTS-STARTPTS,` +
197
  `scale=${VIDEO_WIDTH}:${VIDEO_HEIGHT}:force_original_aspect_ratio=decrease,` +
198
  `pad=${VIDEO_WIDTH}:${VIDEO_HEIGHT}:(ow-iw)/2:(oh-ih)/2,` +
199
  `setsar=1,fps=${VIDEO_FPS},format=yuv420p[v${i}]`
 
201
  })
202
  .join(';');
203
  const videoInputs = videoPaths.map((_, i) => `[v${i}]`).join('');
204
+ const videoConcat = `${videoInputs}concat=n=${videoPaths.length}:v=1:a=0[vcat]`;
205
+ const videoFinalize = needsVideoLoop
206
+ ? `[vcat]loop=loop=-1:size=${loopFrameCount}:start=0,trim=duration=${formatDuration(
207
+ renderTargetSeconds,
208
+ )},setpts=PTS-STARTPTS[v]`
209
+ : `[vcat]trim=duration=${formatDuration(renderTargetSeconds)},setpts=PTS-STARTPTS[v]`;
210
 
211
  const audioOffset = videoPaths.length;
212
  const audioFilters = audioPaths
 
222
  ? `${audioInputs}concat=n=${audioPaths.length}:v=0:a=1[a]`
223
  : '';
224
 
225
+ // Native phone clips are often shorter than the voiceover. Loop the b-roll
226
+ // sequence to avoid the browser freezing on the last frame while audio plays.
227
+ const filterComplex = [videoFilters, videoConcat, videoFinalize, audioFilters, audioConcat]
228
  .filter(Boolean)
229
  .join(';');
230
 
 
248
  'zerolatency',
249
  '-crf',
250
  '30',
251
+ '-r',
252
+ String(VIDEO_FPS),
253
  ...(audioPaths.length ? ['-c:a', 'aac', '-b:a', '96k'] : ['-an']),
254
  '-movflags',
255
  '+faststart',
 
265
 
266
  return {
267
  bytes,
268
+ durationSeconds: Math.round(await probeDuration(outputPath)),
269
  filename: `matcha-server-${runId}.mp4`,
270
  };
271
  } finally {