rikrok 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +255 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/readme/beat-flow.jpg +0 -0
  5. package/assets/readme/beat-headline.jpg +0 -0
  6. package/assets/readme/beat-next.jpg +0 -0
  7. package/assets/readme/beat-play.jpg +0 -0
  8. package/assets/readme/beat-status.jpg +0 -0
  9. package/assets/readme/beats.jpg +0 -0
  10. package/assets/wordmark.png +0 -0
  11. package/assets/wordmark.svg +1 -0
  12. package/bin/rikrok.mjs +63 -0
  13. package/launchd/com.rikrok.plist.tmpl +25 -0
  14. package/package.json +70 -0
  15. package/remotion/Reel.tsx +513 -0
  16. package/remotion/Root.tsx +71 -0
  17. package/remotion/index.ts +4 -0
  18. package/remotion/public/silence.wav +0 -0
  19. package/remotion/theme.ts +52 -0
  20. package/scripts/gen-icon.mjs +117 -0
  21. package/scripts/ui-check.mjs +76 -0
  22. package/server/feed.mjs +153 -0
  23. package/server/public/app.js +342 -0
  24. package/server/public/icon-180.png +0 -0
  25. package/server/public/icon-512.png +0 -0
  26. package/server/public/icon.svg +1 -0
  27. package/server/public/index.html +112 -0
  28. package/server/public/manifest.webmanifest +14 -0
  29. package/server/public/sw.js +22 -0
  30. package/src/cli/backfill.mjs +13 -0
  31. package/src/cli/config.mjs +29 -0
  32. package/src/cli/demo.mjs +14 -0
  33. package/src/cli/doctor.mjs +152 -0
  34. package/src/cli/feed.mjs +7 -0
  35. package/src/cli/hook.mjs +77 -0
  36. package/src/cli/install.mjs +66 -0
  37. package/src/cli/recap.mjs +66 -0
  38. package/src/cli/setup.mjs +162 -0
  39. package/src/cli/voice.mjs +214 -0
  40. package/src/cli/watch.mjs +5 -0
  41. package/src/hooks/comment.mjs +21 -0
  42. package/src/lib/backfill.mjs +40 -0
  43. package/src/lib/config.mjs +89 -0
  44. package/src/lib/evidence.mjs +21 -0
  45. package/src/lib/gitinfo.mjs +40 -0
  46. package/src/lib/llm.mjs +258 -0
  47. package/src/lib/narrate.mjs +148 -0
  48. package/src/lib/palette.mjs +27 -0
  49. package/src/lib/paths.mjs +29 -0
  50. package/src/lib/pipeline.mjs +180 -0
  51. package/src/lib/render-job.mjs +76 -0
  52. package/src/lib/script-claude.mjs +48 -0
  53. package/src/lib/state.mjs +19 -0
  54. package/src/lib/stt.mjs +26 -0
  55. package/src/lib/watcher.mjs +102 -0
  56. package/src/sources/claude.mjs +168 -0
  57. package/src/sources/index.mjs +26 -0
  58. package/src/voices/clone.mjs +66 -0
  59. package/src/voices/fx.mjs +22 -0
  60. package/src/voices/index.mjs +46 -0
  61. package/src/voices/module.mjs +18 -0
  62. package/src/voices/none.mjs +23 -0
  63. package/src/voices/openai-speech.mjs +34 -0
  64. package/src/voices/say.mjs +32 -0
  65. package/test/fixtures/claude-session.jsonl +23 -0
@@ -0,0 +1,513 @@
1
+ import React from "react";
2
+ import {
3
+ AbsoluteFill,
4
+ Audio,
5
+ Easing,
6
+ Sequence,
7
+ interpolate,
8
+ staticFile,
9
+ useCurrentFrame,
10
+ } from "remotion";
11
+ import { theme, Beat, Flow, ReelProps } from "./theme";
12
+
13
+ const { colors, fonts, bezier, safe } = theme;
14
+
15
+ const easeEnter = Easing.bezier(...bezier.enter);
16
+ const easeExit = Easing.bezier(...bezier.exit);
17
+
18
+ // Shared in/out motion per beat: rise + fade in, settle, fade out (build up, tear down).
19
+ function useBeatMotion(durationFrames: number) {
20
+ const frame = useCurrentFrame();
21
+ const inF = Math.min(14, durationFrames / 3);
22
+ const outF = Math.min(10, durationFrames / 4);
23
+ const enter = interpolate(frame, [0, inF], [0, 1], {
24
+ extrapolateRight: "clamp",
25
+ easing: easeEnter,
26
+ });
27
+ const exit = interpolate(frame, [durationFrames - outF, durationFrames], [1, 0], {
28
+ extrapolateLeft: "clamp",
29
+ easing: easeExit,
30
+ });
31
+ return { opacity: Math.min(enter, exit), rise: (1 - enter) * 46 };
32
+ }
33
+
34
+ const ScoreBug: React.FC<{
35
+ projectName: string;
36
+ accent: string;
37
+ scoreBug: ReelProps["scoreBug"];
38
+ handle?: string;
39
+ }> = ({ projectName, accent, scoreBug, handle }) => {
40
+ const frame = useCurrentFrame();
41
+ const opacity = interpolate(frame, [8, 24], [0, 1], {
42
+ extrapolateLeft: "clamp",
43
+ extrapolateRight: "clamp",
44
+ easing: easeEnter,
45
+ });
46
+ return (
47
+ <div
48
+ style={{
49
+ position: "absolute",
50
+ top: safe.top,
51
+ left: safe.x,
52
+ right: safe.x,
53
+ display: "flex",
54
+ alignItems: "center",
55
+ gap: 22,
56
+ opacity,
57
+ fontFamily: fonts.mono,
58
+ fontSize: 26,
59
+ letterSpacing: "0.12em",
60
+ textTransform: "uppercase",
61
+ }}
62
+ >
63
+ <div
64
+ style={{
65
+ width: 16,
66
+ height: 16,
67
+ borderRadius: 3,
68
+ background: accent,
69
+ flexShrink: 0,
70
+ }}
71
+ />
72
+ <div style={{ color: accent, fontWeight: 600, whiteSpace: "nowrap" }}>{projectName}</div>
73
+ <div style={{ flex: 1, height: 1, background: colors.line }} />
74
+ <div style={{ color: colors.textDim, whiteSpace: "nowrap" }}>
75
+ {scoreBug.durationMin >= 100
76
+ ? `${Math.floor(scoreBug.durationMin / 60)}h${scoreBug.durationMin % 60 ? ` ${scoreBug.durationMin % 60}m` : ""}`
77
+ : `${scoreBug.durationMin}min`}
78
+ </div>
79
+ <div style={{ color: colors.cyan, whiteSpace: "nowrap" }}>{scoreBug.done} done</div>
80
+ <div style={{ color: colors.red, whiteSpace: "nowrap" }}>{scoreBug.open} open</div>
81
+ {handle && <div style={{ position: "absolute", top: 44, right: 0, color: colors.textDim, fontSize: 22, letterSpacing: "0.08em", textTransform: "none" }}>{handle}</div>}
82
+ </div>
83
+ );
84
+ };
85
+
86
+
87
+ const HeadlineCard: React.FC<{
88
+ headline: string;
89
+ accent: string;
90
+ durationFrames: number;
91
+ where?: string;
92
+ }> = ({ headline, accent, durationFrames, where }) => {
93
+ const { opacity, rise } = useBeatMotion(durationFrames);
94
+ const frame = useCurrentFrame();
95
+ const rule = interpolate(frame, [4, 20], [0, 1], {
96
+ extrapolateLeft: "clamp",
97
+ extrapolateRight: "clamp",
98
+ easing: easeEnter,
99
+ });
100
+ const [project, ...rest] = headline.split(":");
101
+ const outcome = rest.join(":").trim();
102
+ return (
103
+ <AbsoluteFill style={{ justifyContent: "center", padding: `0 ${safe.x}px`, opacity }}>
104
+ <div style={{ transform: `translateY(${rise}px)` }}>
105
+ <div
106
+ style={{
107
+ fontFamily: fonts.mono,
108
+ fontSize: 30,
109
+ letterSpacing: "0.16em",
110
+ textTransform: "uppercase",
111
+ color: accent,
112
+ marginBottom: 34,
113
+ }}
114
+ >
115
+ Session Recap
116
+ </div>
117
+ <div
118
+ style={{
119
+ fontFamily: fonts.display,
120
+ fontWeight: 700,
121
+ fontSize: outcome.length > 34 ? 88 : 104,
122
+ lineHeight: 1.04,
123
+ color: colors.text,
124
+ }}
125
+ >
126
+ {outcome ? (
127
+ <>
128
+ <span style={{ color: accent }}>{project.trim()}.</span> {outcome}
129
+ </>
130
+ ) : (
131
+ headline
132
+ )}
133
+ </div>
134
+ <div
135
+ style={{
136
+ marginTop: 44,
137
+ height: 6,
138
+ width: `${rule * 38}%`,
139
+ background: accent,
140
+ borderRadius: 3,
141
+ }}
142
+ />
143
+ {where && (
144
+ <div
145
+ style={{
146
+ marginTop: 30,
147
+ fontFamily: fonts.mono,
148
+ fontSize: 28,
149
+ letterSpacing: "0.06em",
150
+ color: colors.textDim,
151
+ }}
152
+ >
153
+ {where}
154
+ </div>
155
+ )}
156
+ </div>
157
+ </AbsoluteFill>
158
+ );
159
+ };
160
+
161
+ const visualLabel: Record<string, string> = {
162
+ files: "Files touched",
163
+ commits: "Git log",
164
+ commands: "Commands",
165
+ text: "From the session",
166
+ };
167
+
168
+ const PlayBeat: React.FC<{ beat: Beat; accent: string; index: number }> = ({
169
+ beat,
170
+ accent,
171
+ index,
172
+ }) => {
173
+ const { opacity, rise } = useBeatMotion(beat.durationFrames);
174
+ const frame = useCurrentFrame();
175
+ const lines = beat.visual?.lines ?? [];
176
+ return (
177
+ <AbsoluteFill style={{ justifyContent: "center", padding: `0 ${safe.x}px`, opacity }}>
178
+ <div style={{ transform: `translateY(${rise}px)` }}>
179
+ <div
180
+ style={{
181
+ fontFamily: fonts.mono,
182
+ fontSize: 28,
183
+ letterSpacing: "0.16em",
184
+ color: accent,
185
+ marginBottom: 26,
186
+ }}
187
+ >
188
+ PLAY {index}
189
+ </div>
190
+ <div
191
+ style={{
192
+ fontFamily: fonts.display,
193
+ fontWeight: 650,
194
+ fontSize: 74,
195
+ lineHeight: 1.08,
196
+ color: colors.text,
197
+ marginBottom: lines.length ? 54 : 0,
198
+ }}
199
+ >
200
+ {beat.caption}
201
+ </div>
202
+ {lines.length > 0 && (
203
+ <div
204
+ style={{
205
+ borderLeft: `5px solid ${accent}`,
206
+ background: colors.panel,
207
+ borderRadius: "0 14px 14px 0",
208
+ padding: "34px 38px",
209
+ }}
210
+ >
211
+ <div
212
+ style={{
213
+ fontFamily: fonts.mono,
214
+ fontSize: 24,
215
+ letterSpacing: "0.14em",
216
+ textTransform: "uppercase",
217
+ color: colors.textDim,
218
+ marginBottom: 20,
219
+ }}
220
+ >
221
+ {visualLabel[beat.visual?.type ?? "text"] ?? "Evidence"}
222
+ </div>
223
+ {lines.map((l, i) => {
224
+ const lineIn = interpolate(frame, [10 + i * 5, 20 + i * 5], [0, 1], {
225
+ extrapolateLeft: "clamp",
226
+ extrapolateRight: "clamp",
227
+ easing: easeEnter,
228
+ });
229
+ return (
230
+ <div
231
+ key={i}
232
+ style={{
233
+ fontFamily: fonts.mono,
234
+ fontSize: 30,
235
+ lineHeight: 1.7,
236
+ color: colors.text,
237
+ opacity: lineIn,
238
+ transform: `translateX(${(1 - lineIn) * 20}px)`,
239
+ whiteSpace: "nowrap",
240
+ overflow: "hidden",
241
+ textOverflow: "ellipsis",
242
+ }}
243
+ >
244
+ {l}
245
+ </div>
246
+ );
247
+ })}
248
+ </div>
249
+ )}
250
+ </div>
251
+ </AbsoluteFill>
252
+ );
253
+ };
254
+
255
+
256
+ // Flow beat: what moves. Nodes laid out in the order things happen, edges drawn in one
257
+ // after another as the narration reaches them, a dot travelling along each edge, and the
258
+ // nodes this session changed lit in the accent.
259
+ const KIND_LABEL: Record<string, string> = { ui: "UI", api: "API", data: "DATA", service: "SERVICE", job: "JOB", external: "EXTERNAL" };
260
+
261
+ function layoutFlow(flow: Flow) {
262
+ // Order: follow edges from nodes that nothing points at; fall back to given order.
263
+ const incoming = new Map(flow.nodes.map((n) => [n.id, 0]));
264
+ for (const e of flow.edges) incoming.set(e.to, (incoming.get(e.to) ?? 0) + 1);
265
+ const order: string[] = [];
266
+ const seen = new Set<string>();
267
+ const visit = (id: string) => {
268
+ if (seen.has(id)) return;
269
+ seen.add(id);
270
+ order.push(id);
271
+ for (const e of flow.edges) if (e.from === id) visit(e.to);
272
+ };
273
+ for (const n of flow.nodes) if ((incoming.get(n.id) ?? 0) === 0) visit(n.id);
274
+ for (const n of flow.nodes) visit(n.id);
275
+ // One column, top to bottom: the natural reading order on a 9:16 canvas.
276
+ const n = order.length;
277
+ const boxW = theme.canvas.width - safe.x * 2;
278
+ const boxH = n <= 4 ? 128 : 108;
279
+ const gap = n <= 4 ? 96 : 76;
280
+ const pos = new Map<string, { x: number; y: number }>();
281
+ order.forEach((id, i) => pos.set(id, { x: 0, y: i * (boxH + gap) }));
282
+ return { order, pos, boxW, boxH, totalH: n * boxH + (n - 1) * gap };
283
+ }
284
+
285
+ const FlowBeat: React.FC<{ beat: Beat; accent: string }> = ({ beat, accent }) => {
286
+ const { opacity, rise } = useBeatMotion(beat.durationFrames);
287
+ const frame = useCurrentFrame();
288
+ const flow = beat.flow ?? { nodes: [], edges: [], changed: [] };
289
+ const { pos, boxW, boxH, totalH } = layoutFlow(flow);
290
+ const byId = new Map(flow.nodes.map((n) => [n.id, n]));
291
+ const changed = new Set(flow.changed);
292
+ // edges appear across the middle 70% of the beat, evenly spaced
293
+ const edgeCount = Math.max(1, flow.edges.length);
294
+ const span = beat.durationFrames * 0.7;
295
+ const perEdge = span / edgeCount;
296
+ const edgeProgress = (i: number) => interpolate(frame, [12 + i * perEdge, 12 + (i + 1) * perEdge], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: easeEnter });
297
+ const W = theme.canvas.width - safe.x * 2;
298
+ const H = totalH + 40;
299
+ const center = (id: string) => {
300
+ const p = pos.get(id) ?? { x: 0, y: 0 };
301
+ return { x: p.x + boxW / 2, y: p.y + boxH / 2 };
302
+ };
303
+ const edgePath = (a: { x: number; y: number }, b: { x: number; y: number }) => {
304
+ // vertical column: leave the bottom of one box, enter the top of the next; arrows that
305
+ // go back up run along the right edge instead
306
+ const down = b.y > a.y;
307
+ if (down) return { sx: a.x, sy: a.y + boxH / 2, ex: b.x, ey: b.y - boxH / 2 };
308
+ return { sx: a.x + boxW / 2 - 40, sy: a.y - boxH / 2, ex: b.x + boxW / 2 - 40, ey: b.y + boxH / 2 };
309
+ };
310
+ return (
311
+ <AbsoluteFill style={{ justifyContent: "center", padding: `0 ${safe.x}px`, opacity }}>
312
+ <div style={{ transform: `translateY(${rise}px)` }}>
313
+ <div style={{ fontFamily: fonts.mono, fontSize: 28, letterSpacing: "0.16em", color: accent, marginBottom: 26 }}>HOW IT MOVES</div>
314
+ <div style={{ fontFamily: fonts.display, fontWeight: 650, fontSize: 60, lineHeight: 1.1, color: colors.text, marginBottom: 44 }}>{beat.caption}</div>
315
+ <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ overflow: "visible" }}>
316
+ <defs>
317
+ <marker id="flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
318
+ <path d="M0 0L10 5L0 10z" fill={colors.textDim} />
319
+ </marker>
320
+ </defs>
321
+ {flow.edges.map((e, i) => {
322
+ const t = edgeProgress(i);
323
+ if (t <= 0) return null;
324
+ const { sx, sy, ex, ey } = edgePath(center(e.from), center(e.to));
325
+ const x = sx + (ex - sx) * t, y = sy + (ey - sy) * t;
326
+ const mx = (sx + ex) / 2, my = (sy + ey) / 2;
327
+ return (
328
+ <g key={i}>
329
+ <line x1={sx} y1={sy} x2={x} y2={y} stroke={colors.textDim} strokeWidth={5} strokeLinecap="round" markerEnd={t >= 0.98 ? "url(#flow-arrow)" : undefined} />
330
+ <circle cx={x} cy={y} r={11} fill={accent} />
331
+ {e.label && t >= 0.98 && (
332
+ <text x={mx + 28} y={my + 8} textAnchor="start" fontFamily={fonts.mono} fontSize={24} fill={colors.textDim} letterSpacing="0.1em">
333
+ {e.label}
334
+ </text>
335
+ )}
336
+ </g>
337
+ );
338
+ })}
339
+ {flow.nodes.map((n, i) => {
340
+ const p = pos.get(n.id) ?? { x: 0, y: 0 };
341
+ const lit = changed.has(n.id);
342
+ const nodeIn = interpolate(frame, [4 + i * 4, 16 + i * 4], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: easeEnter });
343
+ return (
344
+ <g key={n.id} transform={`translate(${p.x} ${p.y})`} opacity={nodeIn}>
345
+ <rect width={boxW} height={boxH} rx={18} fill={lit ? accent : colors.panel} stroke={lit ? accent : colors.line} strokeWidth={3} />
346
+ <text x={28} y={38} fontFamily={fonts.mono} fontSize={22} letterSpacing="0.14em" fill={lit ? "rgba(0,0,0,0.6)" : colors.textDim}>
347
+ {KIND_LABEL[n.kind] ?? "SERVICE"}
348
+ </text>
349
+ <text x={28} y={boxH - 34} fontFamily={fonts.display} fontWeight={650} fontSize={boxH > 110 ? 44 : 38} fill={lit ? "#000" : colors.text}>
350
+ {n.label}
351
+ </text>
352
+ </g>
353
+ );
354
+ })}
355
+ </svg>
356
+ </div>
357
+ </AbsoluteFill>
358
+ );
359
+ };
360
+
361
+ const StatusBeat: React.FC<{ beat: Beat; accent: string }> = ({ beat, accent }) => {
362
+ const { opacity, rise } = useBeatMotion(beat.durationFrames);
363
+ const col = (
364
+ label: string,
365
+ items: string[],
366
+ color: string,
367
+ mark: string,
368
+ ) => (
369
+ <div style={{ flex: 1 }}>
370
+ <div
371
+ style={{
372
+ fontFamily: fonts.mono,
373
+ fontSize: 26,
374
+ letterSpacing: "0.16em",
375
+ textTransform: "uppercase",
376
+ color,
377
+ marginBottom: 24,
378
+ }}
379
+ >
380
+ {label}
381
+ </div>
382
+ {(items.length ? items : ["—"]).map((it, i) => (
383
+ <div
384
+ key={i}
385
+ style={{
386
+ fontFamily: fonts.serif,
387
+ fontSize: 34,
388
+ lineHeight: 1.5,
389
+ color: colors.text,
390
+ marginBottom: 14,
391
+ display: "flex",
392
+ gap: 14,
393
+ }}
394
+ >
395
+ <span style={{ color, flexShrink: 0 }}>{mark}</span>
396
+ <span>{it}</span>
397
+ </div>
398
+ ))}
399
+ </div>
400
+ );
401
+ return (
402
+ <AbsoluteFill style={{ justifyContent: "center", padding: `0 ${safe.x}px`, opacity }}>
403
+ <div style={{ transform: `translateY(${rise}px)` }}>
404
+ <div
405
+ style={{
406
+ fontFamily: fonts.mono,
407
+ fontSize: 28,
408
+ letterSpacing: "0.16em",
409
+ color: accent,
410
+ marginBottom: 26,
411
+ }}
412
+ >
413
+ STATUS
414
+ </div>
415
+ <div
416
+ style={{
417
+ fontFamily: fonts.display,
418
+ fontWeight: 650,
419
+ fontSize: 66,
420
+ lineHeight: 1.1,
421
+ color: colors.text,
422
+ marginBottom: 56,
423
+ }}
424
+ >
425
+ {beat.caption}
426
+ </div>
427
+ <div style={{ display: "flex", flexDirection: "column", gap: 44 }}>
428
+ {col("Shipped", beat.achieved ?? [], colors.cyan, "✓")}
429
+ {col("Open", beat.open ?? [], colors.red, "○")}
430
+ </div>
431
+ </div>
432
+ </AbsoluteFill>
433
+ );
434
+ };
435
+
436
+ const NextStepCard: React.FC<{ beat: Beat; accent: string }> = ({ beat, accent }) => {
437
+ const { opacity, rise } = useBeatMotion(beat.durationFrames);
438
+ return (
439
+ <AbsoluteFill style={{ justifyContent: "center", padding: `0 ${safe.x}px`, opacity }}>
440
+ <div style={{ transform: `translateY(${rise}px)` }}>
441
+ <div
442
+ style={{
443
+ background: accent,
444
+ borderRadius: 22,
445
+ padding: "64px 56px",
446
+ boxShadow: "0 30px 80px rgba(0,0,0,0.45)",
447
+ }}
448
+ >
449
+ <div
450
+ style={{
451
+ fontFamily: fonts.mono,
452
+ fontSize: 28,
453
+ letterSpacing: "0.18em",
454
+ textTransform: "uppercase",
455
+ color: "rgba(0, 0, 0, 0.72)",
456
+ marginBottom: 28,
457
+ }}
458
+ >
459
+ Next step
460
+ </div>
461
+ <div
462
+ style={{
463
+ fontFamily: fonts.display,
464
+ fontWeight: 700,
465
+ fontSize: 68,
466
+ lineHeight: 1.12,
467
+ color: colors.bg,
468
+ }}
469
+ >
470
+ {beat.caption}
471
+ </div>
472
+ </div>
473
+ </div>
474
+ </AbsoluteFill>
475
+ );
476
+ };
477
+
478
+ export const Reel: React.FC<ReelProps> = (props) => {
479
+ let playIdx = 0;
480
+ return (
481
+ <AbsoluteFill style={{ background: colors.bg }}>
482
+ <Audio src={staticFile(props.audioFile)} />
483
+ {props.beats.map((beat, i) => {
484
+ let content: React.ReactNode = null;
485
+ if (beat.kind === "headline") {
486
+ content = (
487
+ <HeadlineCard
488
+ headline={props.headline}
489
+ accent={props.accent}
490
+ durationFrames={beat.durationFrames}
491
+ where={props.where}
492
+ />
493
+ );
494
+ } else if (beat.kind === "play") {
495
+ playIdx++;
496
+ content = <PlayBeat beat={beat} accent={props.accent} index={playIdx} />;
497
+ } else if (beat.kind === "flow") {
498
+ content = <FlowBeat beat={beat} accent={props.accent} />;
499
+ } else if (beat.kind === "status") {
500
+ content = <StatusBeat beat={beat} accent={props.accent} />;
501
+ } else {
502
+ content = <NextStepCard beat={beat} accent={props.accent} />;
503
+ }
504
+ return (
505
+ <Sequence key={i} from={beat.startFrame} durationInFrames={beat.durationFrames}>
506
+ {content}
507
+ </Sequence>
508
+ );
509
+ })}
510
+ <ScoreBug projectName={props.projectName} accent={props.accent} scoreBug={props.scoreBug} handle={props.handle} />
511
+ </AbsoluteFill>
512
+ );
513
+ };
@@ -0,0 +1,71 @@
1
+ import React from "react";
2
+ import { Composition } from "remotion";
3
+ import "@fontsource-variable/bricolage-grotesque";
4
+ import "@fontsource/spectral/400.css";
5
+ import "@fontsource/spectral/600.css";
6
+ import "@fontsource/ibm-plex-mono/400.css";
7
+ import "@fontsource/ibm-plex-mono/600.css";
8
+ import { Reel } from "./Reel";
9
+ import { theme, ReelProps } from "./theme";
10
+
11
+ const defaultProps: ReelProps = {
12
+ projectName: "example-app",
13
+ accent: "#69C9D0",
14
+ headline: "example-app: signup form guarded and tested",
15
+ where: "~/example-app · main",
16
+ scoreBug: { durationMin: 42, done: 3, open: 1 },
17
+ beats: [
18
+ { kind: "headline", caption: "", startFrame: 0, durationFrames: 90 },
19
+ {
20
+ kind: "play",
21
+ caption: "Double submit fixed",
22
+ startFrame: 90,
23
+ durationFrames: 120,
24
+ visual: { type: "files", lines: ["src/components/SignupForm.tsx", "src/hooks/useSubmitGuard.ts"] },
25
+ },
26
+ {
27
+ kind: "flow",
28
+ caption: "Signup submit, guarded",
29
+ startFrame: 210,
30
+ durationFrames: 200,
31
+ flow: {
32
+ nodes: [
33
+ { id: "btn", label: "Sign up button", kind: "ui" },
34
+ { id: "guard", label: "submit guard", kind: "service" },
35
+ { id: "api", label: "POST /signup", kind: "api" },
36
+ { id: "db", label: "users table", kind: "data" },
37
+ ],
38
+ edges: [
39
+ { from: "btn", to: "guard", label: "tap" },
40
+ { from: "guard", to: "api", label: "once" },
41
+ { from: "api", to: "db", label: "writes" },
42
+ ],
43
+ changed: ["guard"],
44
+ },
45
+ },
46
+ {
47
+ kind: "status",
48
+ caption: "3 done, 1 open",
49
+ startFrame: 410,
50
+ durationFrames: 100,
51
+ achieved: ["Guard on signup form", "4 tests green", "1 commit on main"],
52
+ open: ["Checkout guard test"],
53
+ },
54
+ { kind: "next", caption: "Deploy to staging and verify on a phone", startFrame: 510, durationFrames: 110 },
55
+ ],
56
+ audioFile: "silence.wav",
57
+ totalFrames: 620,
58
+ };
59
+
60
+ export const RemotionRoot: React.FC = () => (
61
+ <Composition
62
+ id="Reel"
63
+ component={Reel}
64
+ width={theme.canvas.width}
65
+ height={theme.canvas.height}
66
+ fps={theme.canvas.fps}
67
+ durationInFrames={620}
68
+ defaultProps={defaultProps}
69
+ calculateMetadata={({ props }) => ({ durationInFrames: props.totalFrames })}
70
+ />
71
+ );
@@ -0,0 +1,4 @@
1
+ import { registerRoot } from "remotion";
2
+ import { RemotionRoot } from "./Root";
3
+
4
+ registerRoot(RemotionRoot);
Binary file
@@ -0,0 +1,52 @@
1
+ // Rik Rok look: black canvas, white type, cyan for what shipped, red for what is
2
+ // still open, and a cyan/red offset ghost on the headline. Colour is semantic,
3
+ // one idea per beat, motion only through interpolate/spring with named curves.
4
+ export const theme = {
5
+ colors: {
6
+ bg: "#000000",
7
+ text: "#ffffff",
8
+ textDim: "#9a9a9a",
9
+ cyan: "#69C9D0", // done, the good state
10
+ red: "#EE1D52", // open, the costly state
11
+ line: "rgba(255, 255, 255, 0.18)",
12
+ panel: "rgba(255, 255, 255, 0.06)",
13
+ },
14
+ fonts: {
15
+ display: "'Bricolage Grotesque Variable', 'Helvetica Neue', sans-serif",
16
+ serif: "'Spectral', Georgia, serif",
17
+ mono: "'IBM Plex Mono', ui-monospace, monospace",
18
+ },
19
+ canvas: { width: 1080, height: 1920, fps: 30 },
20
+ bezier: {
21
+ enter: [0.16, 1, 0.3, 1] as const,
22
+ exit: [0.7, 0, 0.84, 0] as const,
23
+ },
24
+ safe: { top: 140, bottom: 220, x: 84 },
25
+ };
26
+
27
+ export type FlowNode = { id: string; label: string; kind: "ui" | "api" | "data" | "service" | "job" | "external" };
28
+ export type FlowEdge = { from: string; to: string; label: string };
29
+ export type Flow = { nodes: FlowNode[]; edges: FlowEdge[]; changed: string[] };
30
+
31
+ export type Beat = {
32
+ kind: "headline" | "play" | "flow" | "status" | "next";
33
+ caption: string;
34
+ startFrame: number;
35
+ durationFrames: number;
36
+ visual?: { type: string; lines: string[] };
37
+ achieved?: string[];
38
+ open?: string[];
39
+ flow?: Flow;
40
+ };
41
+
42
+ export type ReelProps = {
43
+ projectName: string;
44
+ accent: string;
45
+ headline: string;
46
+ where?: string; // "~/my-app · main": repo path + branch
47
+ handle?: string; // optional handle shown under the score bug
48
+ scoreBug: { durationMin: number; done: number; open: number };
49
+ beats: Beat[];
50
+ audioFile: string;
51
+ totalFrames: number;
52
+ };