reelme 0.2.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 (39) hide show
  1. package/package.json +34 -0
  2. package/src/cache.mjs +148 -0
  3. package/src/cli.mjs +57 -0
  4. package/src/render.mjs +47 -0
  5. package/template/package.json +29 -0
  6. package/template/pnpm-lock.yaml +3522 -0
  7. package/template/pnpm-workspace.yaml +4 -0
  8. package/template/public/.gitkeep +0 -0
  9. package/template/remotion.config.ts +4 -0
  10. package/template/src/Root.tsx +127 -0
  11. package/template/src/brief.json +131 -0
  12. package/template/src/brief.ts +195 -0
  13. package/template/src/components/primitives/Arrow.tsx +83 -0
  14. package/template/src/components/primitives/Caption.tsx +52 -0
  15. package/template/src/components/primitives/CodeBlock.tsx +159 -0
  16. package/template/src/components/primitives/Icon.tsx +68 -0
  17. package/template/src/components/primitives/KeyPill.tsx +44 -0
  18. package/template/src/components/primitives/Label.tsx +49 -0
  19. package/template/src/components/primitives/Terminal.tsx +110 -0
  20. package/template/src/components/scenes/BrowserFrame.tsx +139 -0
  21. package/template/src/components/scenes/CTA.tsx +118 -0
  22. package/template/src/components/scenes/CodeReveal.tsx +41 -0
  23. package/template/src/components/scenes/DataFlow.tsx +102 -0
  24. package/template/src/components/scenes/FeatureList.tsx +110 -0
  25. package/template/src/components/scenes/FileTree.tsx +125 -0
  26. package/template/src/components/scenes/HotkeyScene.tsx +77 -0
  27. package/template/src/components/scenes/MobileScreen.tsx +212 -0
  28. package/template/src/components/scenes/OSWindowScene.tsx +166 -0
  29. package/template/src/components/scenes/Problem.tsx +105 -0
  30. package/template/src/components/scenes/SplitComparison.tsx +136 -0
  31. package/template/src/components/scenes/StatCallout.tsx +110 -0
  32. package/template/src/components/scenes/TerminalScene.tsx +41 -0
  33. package/template/src/duration.ts +55 -0
  34. package/template/src/fonts.ts +49 -0
  35. package/template/src/index.ts +106 -0
  36. package/template/src/platforms.json +78 -0
  37. package/template/src/platforms.ts +36 -0
  38. package/template/src/theme.ts +88 -0
  39. package/template/tsconfig.json +15 -0
@@ -0,0 +1,41 @@
1
+ import React from "react";
2
+ import { AbsoluteFill } from "remotion";
3
+ import { CodeRevealScene as CodeRevealBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { CodeBlock } from "../primitives/CodeBlock";
6
+ import { Caption } from "../primitives/Caption";
7
+
8
+ interface Props {
9
+ scene: CodeRevealBrief;
10
+ theme: Theme;
11
+ }
12
+
13
+ export const CodeReveal: React.FC<Props> = ({ scene, theme }) => {
14
+ const lines = scene.code.split("\n");
15
+ // Caption appears after all lines are revealed: startFrame=14, framesPerLine=9, plus 20-frame buffer
16
+ const captionStart = 14 + lines.length * 9 + 20;
17
+
18
+ return (
19
+ <AbsoluteFill
20
+ style={{
21
+ background: theme.bg,
22
+ display: "flex",
23
+ alignItems: "center",
24
+ justifyContent: "center",
25
+ padding: "60px 100px",
26
+ }}
27
+ >
28
+ <div style={{ width: "100%" }}>
29
+ <CodeBlock
30
+ code={scene.code}
31
+ language={scene.language}
32
+ highlightLine={scene.highlightLine}
33
+ theme={theme}
34
+ startFrame={14}
35
+ framesPerLine={9}
36
+ />
37
+ </div>
38
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
39
+ </AbsoluteFill>
40
+ );
41
+ };
@@ -0,0 +1,102 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { DataFlowScene as DataFlowBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Arrow } from "../primitives/Arrow";
6
+ import { Caption } from "../primitives/Caption";
7
+
8
+ interface Props {
9
+ scene: DataFlowBrief;
10
+ theme: Theme;
11
+ }
12
+
13
+ const NODE_W = 200;
14
+ const NODE_H = 64;
15
+
16
+ export const DataFlow: React.FC<Props> = ({ scene, theme }) => {
17
+ const frame = useCurrentFrame();
18
+ const { fps, width, height } = useVideoConfig();
19
+
20
+ const nodeCount = scene.nodes.length;
21
+ const spacing = Math.min(320, (width - 240) / nodeCount);
22
+ const startX = (width - spacing * (nodeCount - 1) - NODE_W) / 2;
23
+ const centerY = height / 2 - NODE_H / 2;
24
+
25
+ const positions: Record<string, { x: number; y: number }> = {};
26
+ scene.nodes.forEach((node, i) => {
27
+ positions[node.id] = { x: startX + i * spacing, y: centerY };
28
+ });
29
+
30
+ const NODE_SETTLE = 18;
31
+ const ARROW_DRAW = 22;
32
+ const STEP = NODE_SETTLE + ARROW_DRAW; // 40 frames per pair
33
+
34
+ const nodeStartFrame = (i: number) => i * STEP;
35
+ const arrowStartFrame = (i: number) => i * STEP + NODE_SETTLE;
36
+
37
+ // Caption appears after the last node settles
38
+ const captionStart = (nodeCount - 1) * STEP + NODE_SETTLE + 20;
39
+
40
+ return (
41
+ <AbsoluteFill style={{ background: theme.bg }}>
42
+ {/* SVG layer for arrows */}
43
+ <svg width={width} height={height} style={{ position: "absolute", inset: 0 }}>
44
+ {scene.edges.map((edge, i) => {
45
+ const from = positions[edge.from];
46
+ const to = positions[edge.to];
47
+ if (!from || !to) return null;
48
+ return (
49
+ <Arrow
50
+ key={i}
51
+ x1={from.x + NODE_W}
52
+ y1={from.y + NODE_H / 2}
53
+ x2={to.x}
54
+ y2={to.y + NODE_H / 2}
55
+ label={edge.label}
56
+ theme={theme}
57
+ startFrame={arrowStartFrame(i)}
58
+ />
59
+ );
60
+ })}
61
+ </svg>
62
+
63
+ {/* Node boxes */}
64
+ {scene.nodes.map((node, i) => {
65
+ const pos = positions[node.id];
66
+ const progress = spring({ frame: frame - nodeStartFrame(i), fps, config: theme.motion });
67
+ const opacity = interpolate(progress, [0, 1], [0, 1]);
68
+ const scale = interpolate(progress, [0, 1], [0.85, 1]);
69
+
70
+ return (
71
+ <div
72
+ key={node.id}
73
+ style={{
74
+ position: "absolute",
75
+ left: pos.x,
76
+ top: pos.y,
77
+ width: NODE_W,
78
+ height: NODE_H,
79
+ background: theme.surface,
80
+ border: `1.5px solid ${theme.border}`,
81
+ borderRadius: 10,
82
+ display: "flex",
83
+ alignItems: "center",
84
+ justifyContent: "center",
85
+ fontFamily: theme.fontSans,
86
+ fontSize: 18,
87
+ fontWeight: 600,
88
+ color: theme.text,
89
+ opacity,
90
+ transform: `scale(${scale})`,
91
+ boxShadow: `0 4px 24px rgba(0,0,0,0.3)`,
92
+ }}
93
+ >
94
+ {node.label}
95
+ </div>
96
+ );
97
+ })}
98
+
99
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
100
+ </AbsoluteFill>
101
+ );
102
+ };
@@ -0,0 +1,110 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { FeatureListScene as FeatureListBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Label } from "../primitives/Label";
6
+ import { Caption } from "../primitives/Caption";
7
+ import { Icon } from "../primitives/Icon";
8
+
9
+ interface Props {
10
+ scene: FeatureListBrief;
11
+ theme: Theme;
12
+ }
13
+
14
+ const HEADLINE_FRAMES = 20;
15
+ const FRAMES_PER_ITEM = 25;
16
+
17
+ export const FeatureList: React.FC<Props> = ({ scene, theme }) => {
18
+ const frame = useCurrentFrame();
19
+ const { fps } = useVideoConfig();
20
+
21
+ const captionStart = HEADLINE_FRAMES + scene.items.length * FRAMES_PER_ITEM + 20;
22
+
23
+ return (
24
+ <AbsoluteFill
25
+ style={{
26
+ background: theme.bg,
27
+ display: "flex",
28
+ flexDirection: "column",
29
+ alignItems: "center",
30
+ justifyContent: "center",
31
+ padding: "0 200px",
32
+ }}
33
+ >
34
+ {scene.headline && (
35
+ <div style={{ marginBottom: 48 }}>
36
+ <Label text={scene.headline} theme={theme} size="lg" startFrame={0} />
37
+ </div>
38
+ )}
39
+
40
+ <div style={{ display: "flex", flexDirection: "column", gap: 28, width: "100%" }}>
41
+ {scene.items.map((item, i) => {
42
+ const text = typeof item === "string" ? item : item.text;
43
+ const icon = typeof item === "string" ? undefined : item.icon;
44
+
45
+ const itemStart = HEADLINE_FRAMES + i * FRAMES_PER_ITEM;
46
+ const progress = spring({ frame: frame - itemStart, fps, config: theme.motion });
47
+ const opacity = interpolate(Math.max(0, progress), [0, 1], [0, 1]);
48
+ const translateX = interpolate(Math.max(0, progress), [0, 1], [-24, 0]);
49
+
50
+ return (
51
+ <div
52
+ key={i}
53
+ style={{
54
+ display: "flex",
55
+ alignItems: "center",
56
+ gap: 24,
57
+ opacity,
58
+ transform: `translateX(${translateX}px)`,
59
+ }}
60
+ >
61
+ <div
62
+ style={{
63
+ width: 36,
64
+ height: 36,
65
+ borderRadius: "50%",
66
+ background: theme.accentMuted,
67
+ border: `1.5px solid ${theme.accent}`,
68
+ display: "flex",
69
+ alignItems: "center",
70
+ justifyContent: "center",
71
+ flexShrink: 0,
72
+ }}
73
+ >
74
+ {icon ? (
75
+ <Icon name={icon} size={18} color={theme.accent} />
76
+ ) : (
77
+ <span
78
+ style={{
79
+ fontFamily: theme.fontMono,
80
+ fontSize: 14,
81
+ fontWeight: 700,
82
+ color: theme.accent,
83
+ lineHeight: 1,
84
+ }}
85
+ >
86
+ {i + 1}
87
+ </span>
88
+ )}
89
+ </div>
90
+ <span
91
+ style={{
92
+ fontFamily: theme.fontSans,
93
+ fontSize: 28,
94
+ fontWeight: 500,
95
+ color: theme.text,
96
+ lineHeight: 1.4,
97
+ letterSpacing: "-0.01em",
98
+ }}
99
+ >
100
+ {text}
101
+ </span>
102
+ </div>
103
+ );
104
+ })}
105
+ </div>
106
+
107
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
108
+ </AbsoluteFill>
109
+ );
110
+ };
@@ -0,0 +1,125 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { FileTreeScene as FileTreeBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Label } from "../primitives/Label";
6
+ import { Caption } from "../primitives/Caption";
7
+
8
+ interface Props {
9
+ scene: FileTreeBrief;
10
+ theme: Theme;
11
+ }
12
+
13
+ const HEADLINE_FRAMES = 20;
14
+ const FRAMES_PER_ENTRY = 20;
15
+ const INDENT = 28;
16
+
17
+ function getDepth(path: string): number {
18
+ const clean = path.endsWith("/") ? path.slice(0, -1) : path;
19
+ const segments = clean.split("/").filter(Boolean);
20
+ return Math.max(0, segments.length - 1);
21
+ }
22
+
23
+ function getName(path: string): string {
24
+ const clean = path.endsWith("/") ? path.slice(0, -1) : path;
25
+ const parts = clean.split("/").filter(Boolean);
26
+ return parts[parts.length - 1] ?? path;
27
+ }
28
+
29
+ const FolderIcon: React.FC<{ color: string }> = ({ color }) => (
30
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
31
+ <path
32
+ d="M1 4.5C1 3.67 1.67 3 2.5 3H7L9 5h6.5c.83 0 1.5.67 1.5 1.5v7c0 .83-.67 1.5-1.5 1.5h-13C1.67 15 1 14.33 1 13.5v-9z"
33
+ fill={color}
34
+ opacity={0.9}
35
+ />
36
+ </svg>
37
+ );
38
+
39
+ const FileIcon: React.FC<{ color: string }> = ({ color }) => (
40
+ <svg width="15" height="18" viewBox="0 0 15 18" fill="none">
41
+ <path d="M2 1h8l4 4v12a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1z" stroke={color} strokeWidth="1.5" fill="none" />
42
+ <path d="M9 1v5h5" stroke={color} strokeWidth="1.5" />
43
+ </svg>
44
+ );
45
+
46
+ export const FileTree: React.FC<Props> = ({ scene, theme }) => {
47
+ const frame = useCurrentFrame();
48
+ const { fps } = useVideoConfig();
49
+
50
+ const captionStart = HEADLINE_FRAMES + scene.entries.length * FRAMES_PER_ENTRY + 20;
51
+
52
+ return (
53
+ <AbsoluteFill
54
+ style={{
55
+ background: theme.bg,
56
+ display: "flex",
57
+ flexDirection: "column",
58
+ alignItems: "center",
59
+ justifyContent: "center",
60
+ padding: "60px 240px",
61
+ }}
62
+ >
63
+ {scene.headline && (
64
+ <div style={{ marginBottom: 48, alignSelf: "flex-start" }}>
65
+ <Label text={scene.headline} theme={theme} size="lg" startFrame={0} align="left" />
66
+ </div>
67
+ )}
68
+
69
+ <div style={{ display: "flex", flexDirection: "column", gap: 2, width: "100%" }}>
70
+ {scene.entries.map((entry, i) => {
71
+ const entryStart = HEADLINE_FRAMES + i * FRAMES_PER_ENTRY;
72
+ const progress = spring({
73
+ frame: frame - entryStart,
74
+ fps,
75
+ config: theme.motion,
76
+ });
77
+ const opacity = interpolate(Math.max(0, progress), [0, 1], [0, 1]);
78
+ const translateX = interpolate(Math.max(0, progress), [0, 1], [-20, 0]);
79
+
80
+ const depth = getDepth(entry.path);
81
+ const name = getName(entry.path);
82
+ const isDir = entry.type === "dir" || entry.path.endsWith("/");
83
+ const isHighlighted = !!entry.highlight;
84
+ const textColor = isHighlighted ? theme.accent : isDir ? theme.text : theme.textMuted;
85
+ const iconColor = isHighlighted ? theme.accent : isDir ? theme.accent : theme.textMuted;
86
+
87
+ return (
88
+ <div
89
+ key={i}
90
+ style={{
91
+ display: "flex",
92
+ alignItems: "center",
93
+ gap: 10,
94
+ opacity,
95
+ transform: `translateX(${translateX}px)`,
96
+ paddingTop: isHighlighted ? 6 : 4,
97
+ paddingBottom: isHighlighted ? 6 : 4,
98
+ paddingLeft: depth * INDENT + 8,
99
+ paddingRight: 12,
100
+ background: isHighlighted ? theme.accentMuted : "transparent",
101
+ borderRadius: isHighlighted ? 6 : 0,
102
+ border: isHighlighted ? `1px solid ${theme.accent}` : "1px solid transparent",
103
+ }}
104
+ >
105
+ {isDir ? <FolderIcon color={iconColor} /> : <FileIcon color={iconColor} />}
106
+ <span
107
+ style={{
108
+ fontFamily: theme.fontMono,
109
+ fontSize: 22,
110
+ fontWeight: isDir ? 600 : 400,
111
+ color: textColor,
112
+ letterSpacing: "-0.01em",
113
+ }}
114
+ >
115
+ {name}{isDir ? "/" : ""}
116
+ </span>
117
+ </div>
118
+ );
119
+ })}
120
+ </div>
121
+
122
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
123
+ </AbsoluteFill>
124
+ );
125
+ };
@@ -0,0 +1,77 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
3
+ import { HotkeyScene as HotkeyBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { KeyPill } from "../primitives/KeyPill";
6
+ import { Caption } from "../primitives/Caption";
7
+
8
+ interface Props {
9
+ scene: HotkeyBrief;
10
+ theme: Theme;
11
+ }
12
+
13
+ const FRAMES_PER_KEY = 20;
14
+ const ACTION_DELAY = 15;
15
+
16
+ export const Hotkey: React.FC<Props> = ({ scene, theme }) => {
17
+ const frame = useCurrentFrame();
18
+ const { fps } = useVideoConfig();
19
+
20
+ const actionStart = scene.keys.length * FRAMES_PER_KEY + ACTION_DELAY;
21
+ const captionStart = actionStart + 50;
22
+
23
+ const actionProgress = spring({ frame: frame - actionStart, fps, config: theme.motion });
24
+ const actionOpacity = interpolate(Math.max(0, actionProgress), [0, 1], [0, 1]);
25
+ const actionY = interpolate(Math.max(0, actionProgress), [0, 1], [16, 0]);
26
+
27
+ return (
28
+ <AbsoluteFill
29
+ style={{
30
+ background: theme.bg,
31
+ display: "flex",
32
+ flexDirection: "column",
33
+ alignItems: "center",
34
+ justifyContent: "center",
35
+ gap: 40,
36
+ }}
37
+ >
38
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
39
+ {scene.keys.map((key, i) => (
40
+ <React.Fragment key={i}>
41
+ {i > 0 && (
42
+ <span
43
+ style={{
44
+ fontFamily: theme.fontSans,
45
+ fontSize: 28,
46
+ color: theme.textMuted,
47
+ opacity: frame >= i * FRAMES_PER_KEY ? 1 : 0,
48
+ }}
49
+ >
50
+ +
51
+ </span>
52
+ )}
53
+ <KeyPill label={key} theme={theme} startFrame={i * FRAMES_PER_KEY} />
54
+ </React.Fragment>
55
+ ))}
56
+ </div>
57
+
58
+ {scene.action && (
59
+ <div
60
+ style={{
61
+ opacity: actionOpacity,
62
+ transform: `translateY(${actionY}px)`,
63
+ fontFamily: theme.fontSans,
64
+ fontSize: 28,
65
+ fontWeight: 400,
66
+ color: theme.textMuted,
67
+ letterSpacing: "-0.01em",
68
+ }}
69
+ >
70
+ {scene.action}
71
+ </div>
72
+ )}
73
+
74
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={captionStart} />}
75
+ </AbsoluteFill>
76
+ );
77
+ };
@@ -0,0 +1,212 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img, staticFile } from "remotion";
3
+ import { MobileScene as MobileBrief } from "../../brief";
4
+ import { Theme } from "../../theme";
5
+ import { Caption } from "../primitives/Caption";
6
+
7
+ interface Props {
8
+ scene: MobileBrief;
9
+ theme: Theme;
10
+ }
11
+
12
+ const PHONE_W = 340;
13
+ const PHONE_H = 720;
14
+ const NOTCH_H = 30;
15
+ const RADIUS = 40;
16
+
17
+ export const MobileScreen: React.FC<Props> = ({ scene, theme }) => {
18
+ const frame = useCurrentFrame();
19
+ const { fps } = useVideoConfig();
20
+
21
+ const progress = spring({ frame, fps, config: theme.motion });
22
+ const opacity = interpolate(progress, [0, 1], [0, 1]);
23
+ const translateY = interpolate(progress, [0, 1], [60, 0]);
24
+ const scale = interpolate(progress, [0, 1], [0.88, 1]);
25
+
26
+ return (
27
+ <AbsoluteFill
28
+ style={{
29
+ background: theme.bg,
30
+ display: "flex",
31
+ alignItems: "center",
32
+ justifyContent: "center",
33
+ }}
34
+ >
35
+ <div
36
+ style={{
37
+ width: PHONE_W,
38
+ height: PHONE_H,
39
+ borderRadius: RADIUS,
40
+ border: `3px solid ${theme.border}`,
41
+ background: theme.surface,
42
+ boxShadow: `0 40px 100px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(255,255,255,0.05)`,
43
+ position: "relative",
44
+ overflow: "hidden",
45
+ opacity,
46
+ transform: `translateY(${translateY}px) scale(${scale})`,
47
+ }}
48
+ >
49
+ {/* Notch */}
50
+ <div
51
+ style={{
52
+ position: "absolute",
53
+ top: 0,
54
+ left: "50%",
55
+ transform: "translateX(-50%)",
56
+ width: 110,
57
+ height: NOTCH_H,
58
+ background: theme.surface,
59
+ borderBottomLeftRadius: 16,
60
+ borderBottomRightRadius: 16,
61
+ zIndex: 10,
62
+ }}
63
+ />
64
+
65
+ {/* Screen */}
66
+ <div
67
+ style={{
68
+ position: "absolute",
69
+ inset: 0,
70
+ background: theme.bg,
71
+ display: "flex",
72
+ flexDirection: "column",
73
+ }}
74
+ >
75
+ {/* Status bar */}
76
+ <div
77
+ style={{
78
+ height: NOTCH_H + 2,
79
+ display: "flex",
80
+ alignItems: "flex-end",
81
+ justifyContent: "space-between",
82
+ paddingLeft: 20,
83
+ paddingRight: 20,
84
+ paddingBottom: 4,
85
+ fontFamily: theme.fontSans,
86
+ fontSize: 11,
87
+ fontWeight: 600,
88
+ color: theme.textMuted,
89
+ }}
90
+ >
91
+ <span>9:41</span>
92
+ <span>●●●</span>
93
+ </div>
94
+
95
+ {/* App header */}
96
+ {scene.title && (
97
+ <div
98
+ style={{
99
+ height: 48,
100
+ display: "flex",
101
+ alignItems: "center",
102
+ justifyContent: "center",
103
+ borderBottom: `1px solid ${theme.border}`,
104
+ fontFamily: theme.fontSans,
105
+ fontSize: 16,
106
+ fontWeight: 600,
107
+ color: theme.text,
108
+ flexShrink: 0,
109
+ }}
110
+ >
111
+ {scene.title}
112
+ </div>
113
+ )}
114
+
115
+ {/* Content */}
116
+ <div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
117
+ {scene.image ? (
118
+ <Img
119
+ src={staticFile(scene.image)}
120
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
121
+ />
122
+ ) : (
123
+ <div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 10 }}>
124
+ {/* Search bar */}
125
+ <div
126
+ style={{
127
+ height: 34,
128
+ background: theme.surface,
129
+ borderRadius: 8,
130
+ border: `1px solid ${theme.border}`,
131
+ display: "flex",
132
+ alignItems: "center",
133
+ paddingLeft: 12,
134
+ paddingRight: 12,
135
+ fontFamily: theme.fontSans,
136
+ fontSize: 12,
137
+ color: theme.textMuted,
138
+ }}
139
+ >
140
+ Search...
141
+ </div>
142
+ {/* List items */}
143
+ {[72, 56, 60, 54, 58].map((_, i) => (
144
+ <div
145
+ key={i}
146
+ style={{
147
+ height: 58,
148
+ background: theme.surface,
149
+ borderRadius: 10,
150
+ border: `1px solid ${theme.border}`,
151
+ display: "flex",
152
+ alignItems: "center",
153
+ gap: 10,
154
+ paddingLeft: 10,
155
+ paddingRight: 10,
156
+ }}
157
+ >
158
+ <div
159
+ style={{
160
+ width: 32,
161
+ height: 32,
162
+ borderRadius: "50%",
163
+ background: theme.accentMuted,
164
+ border: `1.5px solid ${theme.accent}`,
165
+ flexShrink: 0,
166
+ }}
167
+ />
168
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 6 }}>
169
+ <div style={{ height: 11, background: theme.border, borderRadius: 4, width: `${58 + i * 8}%` }} />
170
+ <div style={{ height: 9, background: theme.border, borderRadius: 4, width: `${38 + i * 6}%`, opacity: 0.6 }} />
171
+ </div>
172
+ </div>
173
+ ))}
174
+ </div>
175
+ )}
176
+ </div>
177
+
178
+ {/* Bottom nav */}
179
+ <div
180
+ style={{
181
+ height: 52,
182
+ borderTop: `1px solid ${theme.border}`,
183
+ display: "flex",
184
+ alignItems: "center",
185
+ justifyContent: "space-around",
186
+ flexShrink: 0,
187
+ }}
188
+ >
189
+ {["⌂", "⊞", "♡", "◉"].map((icon, i) => (
190
+ <div
191
+ key={i}
192
+ style={{
193
+ width: 36,
194
+ height: 36,
195
+ display: "flex",
196
+ alignItems: "center",
197
+ justifyContent: "center",
198
+ fontSize: 18,
199
+ color: i === 0 ? theme.accent : theme.textMuted,
200
+ }}
201
+ >
202
+ {icon}
203
+ </div>
204
+ ))}
205
+ </div>
206
+ </div>
207
+ </div>
208
+
209
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={50} />}
210
+ </AbsoluteFill>
211
+ );
212
+ };