Arslan1997 commited on
Commit
4c105cb
·
1 Parent(s): bfe94d3

adding new demo + landing page improvements

Browse files
frontend/src/components/FeatureShowcase.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
2
  import { Link } from "react-router-dom";
3
 
4
  /** Landing page feature tour: one full section per feature, alternating sides, each with a small
@@ -33,36 +33,180 @@ function ChatVisual() {
33
  );
34
  }
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  function MapVisual() {
37
- const nodes = [
38
- { x: 50, y: 48, r: 14, label: "Pricing" },
39
- { x: 22, y: 30, r: 9, label: "Habits" },
40
- { x: 78, y: 26, r: 10, label: "Audience" },
41
- { x: 30, y: 76, r: 8, label: "Craft" },
42
- { x: 74, y: 74, r: 11, label: "Paid tier" },
43
- ];
44
- const edges = [
 
 
 
 
 
45
  [0, 1],
46
  [0, 2],
 
47
  [0, 4],
48
- [1, 3],
49
- [2, 4],
 
 
 
 
 
50
  ];
51
  return (
52
- <div className="fx fx-map">
53
- <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
54
- {edges.map(([a, b], i) => (
55
- <line key={i} className="fx-edge" style={{ ["--d" as string]: `${i * 0.15}s` }} x1={nodes[a].x} y1={nodes[a].y} x2={nodes[b].x} y2={nodes[b].y} />
 
 
56
  ))}
57
- {nodes.map((n, i) => (
58
- <g key={n.label} className="fx-node" style={{ ["--d" as string]: `${0.3 + i * 0.12}s` }}>
59
- <circle cx={n.x} cy={n.y} r={n.r * 0.9} className="fx-halo" />
60
- <circle cx={n.x} cy={n.y} r={n.r * 0.3} fill="#fff" />
61
- <text x={n.x} y={n.y + n.r * 0.9 + 5} textAnchor="middle">
62
- {n.label}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  </text>
64
  </g>
65
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  </svg>
67
  </div>
68
  );
@@ -89,19 +233,57 @@ function AudioVisual() {
89
  }
90
 
91
  function VideoVisual() {
 
92
  return (
93
  <div className="fx fx-video">
94
- <div className="fx-frame v916">
95
- <span className="mono">9:16</span>
96
- <b>I doubled my price.</b>
 
 
 
 
97
  </div>
98
  <div className="fx-frame v169">
99
- <span className="mono">16:9</span>
100
- <b>Why price is a signal</b>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  </div>
102
- <div className="fx-frame v11">
103
- <span className="mono">1:1</span>
104
- <i className="fx-mini-wave" />
 
 
 
 
 
 
 
 
 
105
  </div>
106
  </div>
107
  );
@@ -178,33 +360,49 @@ const FEATURES: Feature[] = [
178
  eyebrow: "Research notebook",
179
  title: "Ask your archive anything. It answers with receipts.",
180
  body: "Notestack reads your posts the way a careful researcher would: it searches, opens the right post, and cites the exact lines. If you never wrote it, it tells you so instead of guessing.",
181
- points: ["Every sentence links to the lines it came from", "Chats are saved per notebook", "Summaries of whole notebooks, cited"],
182
  visual: <ChatVisual />,
183
  },
 
 
 
 
 
 
 
 
184
  {
185
  id: "map",
186
- eyebrow: "Topic map",
187
- title: "See the constellations in your writing.",
188
- body: "Every post is tagged with what it is really about, then linked to the topics it travels with. Zoom through years of work and spin any topic into a notebook.",
189
- points: ["Topics merge automatically as you publish", "Spot the themes you keep coming back to", "One click from a topic to a notebook"],
190
  visual: <MapVisual />,
191
  },
192
  {
193
  id: "audio",
194
  eyebrow: "Audio overview",
195
  title: "Two hosts talk through your ideas.",
196
- body: "Turn a post or a whole notebook into a natural conversation, voiced by ElevenLabs. Pick from a library of commercial voices, or clone your own with consent.",
197
- points: ["Deep dive, brief or debate formats", "Every line grounded in your posts", "Your cloned voice as host, if you want"],
198
  visual: <AudioVisual />,
199
  },
200
  {
201
  id: "video",
202
  eyebrow: "Video studio",
203
- title: "Shorts, explainers and audiograms, from one post.",
204
- body: "Notestack storyboards the post, narrates it and renders it with word by word captions and your brand colors. Vertical for Reels and TikTok, wide for YouTube, square for feeds.",
205
- points: ["9:16 shorts with a hook in two seconds", "16:9 explainers and 1:1 audiograms", "Your logo and accent color on every frame"],
206
  visual: <VideoVisual />,
207
  },
 
 
 
 
 
 
 
 
208
  {
209
  id: "kit",
210
  eyebrow: "Launch Kit",
@@ -214,12 +412,12 @@ const FEATURES: Feature[] = [
214
  visual: <KitVisual />,
215
  },
216
  {
217
- id: "voice",
218
- eyebrow: "Voice profile",
219
- title: "Sounds like you, on the page and out loud.",
220
- body: "Notestack learns your tone from your own posts, so drafts read like you wrote them. For audio, tune two hosts or record a couple of minutes to clone your voice.",
221
- points: ["Editable writing voice", "Commercially licensed voice library", "Consented cloning you can revoke anytime"],
222
- visual: <VoiceVisual />,
223
  },
224
  {
225
  id: "pad",
 
1
+ import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
2
  import { Link } from "react-router-dom";
3
 
4
  /** Landing page feature tour: one full section per feature, alternating sides, each with a small
 
33
  );
34
  }
35
 
36
+ const SHAPES = ["Orbit", "Spiral", "Figure", "Cluster"];
37
+ const SHAPE_MS = 3500; // how long each shape is shown on its own
38
+ const MORPH_MS = 1300;
39
+
40
+ /** Where the four outer topics sit in each shape (the hub, Gravity, stays put so the dive lines up). */
41
+ const LAYOUTS: [number, number][][] = [
42
+ [[74, 30], [20, 24], [26, 82], [78, 76]], // orbit: around the hub
43
+ [[55, 56], [42, 74], [16, 62], [18, 30]], // spiral: winding out
44
+ [[56, 36], [72, 48], [66, 68], [84, 78]], // figure: one long chain of stars
45
+ [[72, 28], [84, 36], [20, 76], [30, 86]], // cluster: two tight groups
46
+ ];
47
+
48
+ /** Cycles the shape on a timer while the visual is on screen. Clicking a pill jumps to that shape and pauses
49
+ * the cycle for a while. With reduced motion the shape only changes when the reader clicks. */
50
+ function useShapeCycle(ref: RefObject<HTMLElement>) {
51
+ const [view, setView] = useState({ shape: 0, prev: 0, p: 1, t: 0 });
52
+ const go = useRef<(i: number) => void>(() => undefined);
53
+ useEffect(() => {
54
+ const node = ref.current;
55
+ if (!node) return;
56
+ const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
57
+ let raf = 0;
58
+ let last = 0;
59
+ let shape = 0;
60
+ let prev = 0;
61
+ let t0 = -MORPH_MS;
62
+ let nextAuto = 0;
63
+ const show = (now: number) => setView({ shape, prev, p: Math.min(1, (now - t0) / MORPH_MS), t: now / 1000 });
64
+ const jump = (i: number, now: number) => {
65
+ if (i === shape) return;
66
+ prev = shape;
67
+ shape = i;
68
+ t0 = now;
69
+ };
70
+ go.current = (i) => {
71
+ const now = performance.now();
72
+ jump(i, now);
73
+ nextAuto = now + 9000;
74
+ show(now);
75
+ if (still) setTimeout(() => show(performance.now() + MORPH_MS), MORPH_MS);
76
+ };
77
+ const tick = (now: number) => {
78
+ if (now >= nextAuto) {
79
+ jump((shape + 1) % SHAPES.length, now);
80
+ nextAuto = now + SHAPE_MS;
81
+ }
82
+ if (now - last > 33) {
83
+ last = now;
84
+ show(now);
85
+ }
86
+ raf = requestAnimationFrame(tick);
87
+ };
88
+ const io = new IntersectionObserver(([entry]) => {
89
+ cancelAnimationFrame(raf);
90
+ if (entry.isIntersecting && !still) {
91
+ nextAuto = performance.now() + SHAPE_MS;
92
+ raf = requestAnimationFrame(tick);
93
+ }
94
+ });
95
+ io.observe(node);
96
+ return () => {
97
+ io.disconnect();
98
+ cancelAnimationFrame(raf);
99
+ };
100
+ }, [ref]);
101
+ return { view, go };
102
+ }
103
+
104
  function MapVisual() {
105
+ const ref = useRef<HTMLDivElement>(null);
106
+ const { view, go } = useShapeCycle(ref);
107
+ const e = view.p < 0.5 ? 2 * view.p * view.p : 1 - (-2 * view.p + 2) ** 2 / 2;
108
+ // Three levels of depth: topics, the sub-topics inside Gravity, and the posts behind each sub-topic.
109
+ const hub = { x: 38, y: 52, r: 9, label: "Gravity" };
110
+ const names = ["Quantum", "Entropy", "Optics", "Relativity"];
111
+ const outer = names.map((label, i) => {
112
+ const [fx, fy] = LAYOUTS[view.prev][i];
113
+ const [tx, ty] = LAYOUTS[view.shape][i];
114
+ return { label, r: [7, 6, 6, 7][i], x: fx + (tx - fx) * e, y: fy + (ty - fy) * e };
115
+ });
116
+ const topics = [hub, ...outer];
117
+ const links = [
118
  [0, 1],
119
  [0, 2],
120
+ [0, 3],
121
  [0, 4],
122
+ [1, 4],
123
+ [2, 3],
124
+ ];
125
+ const subs = [
126
+ { x: 52, y: 40, label: "Spacetime", posts: [[59, 35], [58, 45], [47, 35]] },
127
+ { x: 24, y: 62, label: "Black holes", posts: [[17, 66], [22, 70], [17, 57]] },
128
+ { x: 50, y: 67, label: "Orbits", posts: [[58, 70], [46, 73], [56, 62]] },
129
  ];
130
  return (
131
+ <div className="fx fx-map" ref={ref}>
132
+ <div className="fx-pills mono" role="group" aria-label="Shape of the map">
133
+ {SHAPES.map((name, i) => (
134
+ <button key={name} type="button" className={i === view.shape ? "on" : ""} onClick={() => go.current(i)}>
135
+ {name}
136
+ </button>
137
  ))}
138
+ </div>
139
+ <div className="fx-crumb mono">
140
+ <span>Archive</span>
141
+ <span className="fx-crumb-deep"> › Gravity › Spacetime</span>
142
+ </div>
143
+ <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
144
+ <g className="fx-cam">
145
+ <g className="fx-dim">
146
+ {links.map(([a, b], i) => (
147
+ <line key={i} className="fx-edge" style={{ ["--d" as string]: `${i * 0.15}s` }} x1={topics[a].x} y1={topics[a].y} x2={topics[b].x} y2={topics[b].y} />
148
+ ))}
149
+ {links.map(([a, b], i) => {
150
+ const u = (view.t * 0.3 + i * 0.37) % 1; // a pulse of light travelling along the link
151
+ return <circle key={`p${i}`} className="fx-pulse" r="1" fill="#fff" fillOpacity={Math.sin(u * Math.PI)} cx={topics[b].x + (topics[a].x - topics[b].x) * u} cy={topics[b].y + (topics[a].y - topics[b].y) * u} />;
152
+ })}
153
+ {outer.map((n, i) => (
154
+ <g key={n.label}>
155
+ {[0, 1, 2].map((k) => {
156
+ const angle = i * 1.7 + k * 2.1;
157
+ return <circle key={k} className="fx-sat" cx={n.x + Math.cos(angle) * (n.r + 6)} cy={n.y + Math.sin(angle) * (n.r + 6)} r="1.1" />;
158
+ })}
159
+ <g className="fx-node" style={{ ["--d" as string]: `${0.5 + i * 0.12}s` }}>
160
+ <circle cx={n.x} cy={n.y} r={n.r * 0.9} className="fx-halo" style={{ ["--t" as string]: `${(i + 1) * 0.7}s` }} />
161
+ <circle cx={n.x} cy={n.y} r={n.r * 0.3} fill="#fff" />
162
+ <text x={n.x} y={n.y + n.r * 0.9 + 5} textAnchor="middle">
163
+ {n.label}
164
+ </text>
165
+ </g>
166
+ </g>
167
+ ))}
168
+ </g>
169
+ <g className="fx-deep">
170
+ <ellipse className="fx-ring" cx={hub.x} cy={hub.y} rx="15" ry="12.5" />
171
+ {subs.map((sub) => (
172
+ <g key={sub.label}>
173
+ <line className="fx-deep-edge" x1={hub.x} y1={hub.y} x2={sub.x} y2={sub.y} />
174
+ {sub.posts.map(([px, py]) => (
175
+ <g key={`${px}-${py}`}>
176
+ <line className="fx-deep-edge" x1={sub.x} y1={sub.y} x2={px} y2={py} />
177
+ <circle className="fx-post" cx={px} cy={py} r="0.9" />
178
+ </g>
179
+ ))}
180
+ <circle className="fx-halo" cx={sub.x} cy={sub.y} r="3.4" />
181
+ <circle cx={sub.x} cy={sub.y} r="1.5" fill="#fff" />
182
+ <text className="fx-sub" x={sub.x} y={sub.y + 5.4} textAnchor="middle">
183
+ {sub.label}
184
+ </text>
185
+ </g>
186
+ ))}
187
+ </g>
188
+ <g className="fx-node" style={{ ["--d" as string]: "0.3s" }}>
189
+ <circle cx={hub.x} cy={hub.y} r={hub.r * 0.9} className="fx-halo" />
190
+ <circle cx={hub.x} cy={hub.y} r={hub.r * 0.3} fill="#fff" />
191
+ <text x={hub.x} y={hub.y - hub.r * 0.9 - 2} textAnchor="middle">
192
+ {hub.label}
193
  </text>
194
  </g>
195
+ </g>
196
+ <g className="fx-panel">
197
+ <rect x="68" y="12" width="30" height="34" rx="2" />
198
+ <text className="fx-panel-h" x="71" y="19">
199
+ Gravity
200
+ </text>
201
+ <text className="fx-panel-k" x="71" y="26">
202
+ FROM YOUR POSTS
203
+ </text>
204
+ {["Why Things Fall", "Curved Space", "Notes on Orbits"].map((title, i) => (
205
+ <text key={title} className="fx-panel-t" x="71" y={32 + i * 5}>
206
+ {title}
207
+ </text>
208
+ ))}
209
+ </g>
210
  </svg>
211
  </div>
212
  );
 
233
  }
234
 
235
  function VideoVisual() {
236
+ const scenes = ["Price is a signal", "The numbers", "Why it worked"];
237
  return (
238
  <div className="fx fx-video">
239
+ <div className="fx-scenes">
240
+ {scenes.map((title, i) => (
241
+ <span key={title} className={i === 1 ? "on" : ""}>
242
+ <i className="mono">{i + 1}</i>
243
+ {title}
244
+ </span>
245
+ ))}
246
  </div>
247
  <div className="fx-frame v169">
248
+ <span className="mono">Live preview</span>
249
+ <b>Paid readers, doubled</b>
250
+ </div>
251
+ <div className="fx-render mono">
252
+ <i /> Rendering MP4
253
+ </div>
254
+ </div>
255
+ );
256
+ }
257
+
258
+ function MemoryVisual() {
259
+ const notes = [
260
+ ["audience", "I write for indie founders"],
261
+ ["tone", "Short sentences, no jargon"],
262
+ ["avoid", "Never name competitors"],
263
+ ];
264
+ return (
265
+ <div className="fx fx-memory">
266
+ {notes.map(([key, value]) => (
267
+ <div key={key} className="fx-profile">
268
+ <span className="mono">{key}</span>
269
+ <p>{value}</p>
270
+ </div>
271
+ ))}
272
+ <div className="fx-clone mono">
273
+ <span className="fx-rec" /> Saved from your last chat
274
  </div>
275
+ </div>
276
+ );
277
+ }
278
+
279
+ function HelpVisual() {
280
+ return (
281
+ <div className="fx fx-help">
282
+ <div className="fx-bubble">How do I make a video from one post?</div>
283
+ <p className="fx-answer">Open Create, choose Video, then pick the post, a template and a voice. You can edit every scene before you render.</p>
284
+ <div className="fx-source">
285
+ <span className="mono">Still stuck?</span>
286
+ <span className="fx-line">Send it to the team. We will email you back.</span>
287
  </div>
288
  </div>
289
  );
 
360
  eyebrow: "Research notebook",
361
  title: "Ask your archive anything. It answers with receipts.",
362
  body: "Notestack reads your posts the way a careful researcher would: it searches, opens the right post, and cites the exact lines. If you never wrote it, it tells you so instead of guessing.",
363
+ points: ["Every sentence links to the lines it came from", "Chats are saved per notebook", "Thumbs up or down on any answer"],
364
  visual: <ChatVisual />,
365
  },
366
+ {
367
+ id: "memory",
368
+ eyebrow: "Memory",
369
+ title: "It remembers who you write for.",
370
+ body: "Notestack keeps short notes about your audience, tone and preferences, learned as you chat, and uses them in every answer and draft. Read, edit or delete any note in Settings.",
371
+ points: ["Notes saved from your conversations", "Edit or delete any note, anytime", "Shared across your notebooks"],
372
+ visual: <MemoryVisual />,
373
+ },
374
  {
375
  id: "map",
376
+ eyebrow: "Mind Constellation",
377
+ title: "Fly through the galaxy of your ideas.",
378
+ body: "Open a notebook as a constellation: topics orbit the ideas they share, and every star lists the posts it comes from. Switch the shape, dive in and see what you keep coming back to.",
379
+ points: ["Orbit, spiral, figure or cluster layouts", "Scroll to dive in, click a star to fly to it", "Every star lists the posts behind it"],
380
  visual: <MapVisual />,
381
  },
382
  {
383
  id: "audio",
384
  eyebrow: "Audio overview",
385
  title: "Two hosts talk through your ideas.",
386
+ body: "Turn a post or a whole notebook into a natural conversation, voiced by ElevenLabs. Pick two hosts from a library of voices, or use a voice you made yourself.",
387
+ points: ["Deep dive, brief or debate formats", "Every line grounded in your posts", "Square audiograms from any overview"],
388
  visual: <AudioVisual />,
389
  },
390
  {
391
  id: "video",
392
  eyebrow: "Video studio",
393
+ title: "Narrated videos from one post, edited scene by scene.",
394
+ body: "Pick a post or paste a link, choose a template, style and voice, and Notestack writes the script, narrates it and builds the scenes. Edit any scene and watch the live preview update, then render an MP4.",
395
+ points: ["Landscape for YouTube, vertical for Reels and TikTok", "Templates, stock footage, captions and 16 languages", "Square audiograms from any audio overview"],
396
  visual: <VideoVisual />,
397
  },
398
+ {
399
+ id: "voice",
400
+ eyebrow: "Voices",
401
+ title: "Sounds like you, on the page and out loud.",
402
+ body: "Notestack learns your writing voice from your own posts. For narration, describe a voice, pick its options, or record a couple of minutes to clone your own, then reuse it in every audio overview and video.",
403
+ points: ["Describe it, pick options or clone yours", "Fine tune stability, similarity and style", "Consented cloning you can revoke anytime"],
404
+ visual: <VoiceVisual />,
405
+ },
406
  {
407
  id: "kit",
408
  eyebrow: "Launch Kit",
 
412
  visual: <KitVisual />,
413
  },
414
  {
415
+ id: "help",
416
+ eyebrow: "Help",
417
+ title: "Stuck? Ask Notestack.",
418
+ body: "A help assistant in the app answers how-to questions about every screen. If it can't, send the question to the team and we will email you back.",
419
+ points: ["Answers about any feature, in plain words", "Hand off to a person in one click", "Always one click away"],
420
+ visual: <HelpVisual />,
421
  },
422
  {
423
  id: "pad",
frontend/src/pages/Landing.tsx CHANGED
@@ -9,9 +9,11 @@ import { blogPosts } from "../content/blogPosts";
9
  import { useAuth } from "../hooks/useAuth";
10
  import {
11
  ArrowRightIcon,
 
12
  HelmetIcon,
13
  LaunchWindowIcon,
14
  LaunchpadIcon,
 
15
  RocketIcon,
16
  SignalIcon,
17
  TelescopeIcon,
@@ -19,7 +21,7 @@ import {
19
 
20
  export const PENDING_SOURCE_KEY = "ns_pending_source";
21
  // Bump after re-rendering public/demo.mp4 so browsers and the CDN fetch the new file.
22
- const DEMO_VERSION = 4;
23
 
24
  const FEATURES = [
25
  {
@@ -28,6 +30,12 @@ const FEATURES = [
28
  title: "Ask your archive anything",
29
  body: "Every answer cites the exact passage it came from. If you never wrote it, Notestack says so.",
30
  },
 
 
 
 
 
 
31
  {
32
  icon: SignalIcon,
33
  label: "Audio overview",
@@ -37,8 +45,8 @@ const FEATURES = [
37
  {
38
  icon: LaunchWindowIcon,
39
  label: "Video",
40
- title: "Shorts, explainers, audiograms",
41
- body: "Storyboards generated from your post, rendered with accurate captions in 9:16, 16:9 or 1:1.",
42
  },
43
  {
44
  icon: RocketIcon,
@@ -48,9 +56,15 @@ const FEATURES = [
48
  },
49
  {
50
  icon: HelmetIcon,
51
- label: "Voice profile",
52
  title: "Sounds like you",
53
- body: "Notestack learns your tone from your own posts and scores every draft against it.",
 
 
 
 
 
 
54
  },
55
  {
56
  icon: LaunchpadIcon,
 
9
  import { useAuth } from "../hooks/useAuth";
10
  import {
11
  ArrowRightIcon,
12
+ ConstellationIcon,
13
  HelmetIcon,
14
  LaunchWindowIcon,
15
  LaunchpadIcon,
16
+ OrbitIcon,
17
  RocketIcon,
18
  SignalIcon,
19
  TelescopeIcon,
 
21
 
22
  export const PENDING_SOURCE_KEY = "ns_pending_source";
23
  // Bump after re-rendering public/demo.mp4 so browsers and the CDN fetch the new file.
24
+ const DEMO_VERSION = 5;
25
 
26
  const FEATURES = [
27
  {
 
30
  title: "Ask your archive anything",
31
  body: "Every answer cites the exact passage it came from. If you never wrote it, Notestack says so.",
32
  },
33
+ {
34
+ icon: ConstellationIcon,
35
+ label: "Mind Constellation",
36
+ title: "Fly through your ideas",
37
+ body: "Open a notebook as a galaxy of topics. Switch the shape, dive in and see the posts behind every star.",
38
+ },
39
  {
40
  icon: SignalIcon,
41
  label: "Audio overview",
 
45
  {
46
  icon: LaunchWindowIcon,
47
  label: "Video",
48
+ title: "Narrated videos you can edit",
49
+ body: "Pick a post and a template, then edit every scene with a live preview. Landscape for YouTube, vertical for Reels.",
50
  },
51
  {
52
  icon: RocketIcon,
 
56
  },
57
  {
58
  icon: HelmetIcon,
59
+ label: "Voices",
60
  title: "Sounds like you",
61
+ body: "Notestack learns your tone from your posts, and you can describe, pick or clone a voice for narration.",
62
+ },
63
+ {
64
+ icon: OrbitIcon,
65
+ label: "Memory",
66
+ title: "Remembers who you write for",
67
+ body: "Short notes about your audience and style, learned from your chats and yours to edit anytime.",
68
  },
69
  {
70
  icon: LaunchpadIcon,
frontend/src/styles/showcase.css CHANGED
@@ -185,8 +185,8 @@
185
  stroke: var(--white);
186
  stroke-opacity: 0.3;
187
  stroke-width: 0.4;
188
- stroke-dasharray: 60;
189
- stroke-dashoffset: 60;
190
  transition-delay: var(--d);
191
  }
192
 
@@ -216,6 +216,141 @@
216
 
217
  .showcase-row.arrived .fx-halo {
218
  animation: pulse-halo 4s ease-in-out infinite;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }
220
 
221
  @keyframes pulse-halo {
@@ -290,10 +425,31 @@
290
 
291
  /* Video */
292
  .fx-video {
293
- grid-template-columns: 0.55fr 1.2fr 0.8fr;
294
  align-items: center;
295
  }
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  .fx-frame {
298
  display: grid;
299
  gap: 6px;
@@ -311,22 +467,51 @@
311
  line-height: 1.2;
312
  }
313
 
314
- .v916 {
315
- aspect-ratio: 9 / 16;
316
- }
317
-
318
  .v169 {
319
  aspect-ratio: 16 / 9;
320
  }
321
 
322
- .v11 {
323
- aspect-ratio: 1;
 
 
324
  }
325
 
326
- .fx-mini-wave {
327
  display: block;
328
- height: 18px;
329
- background: repeating-linear-gradient(90deg, var(--blue) 0 3px, transparent 3px 6px);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  }
331
 
332
  /* Launch Kit */
@@ -469,9 +654,19 @@
469
 
470
  .fx-wave span,
471
  .fx-halo,
 
 
 
 
 
472
  .fx-rec {
473
  animation: none !important;
474
  }
 
 
 
 
 
475
  }
476
 
477
  /* Demo video sound controls */
 
185
  stroke: var(--white);
186
  stroke-opacity: 0.3;
187
  stroke-width: 0.4;
188
+ stroke-dasharray: 150;
189
+ stroke-dashoffset: 150;
190
  transition-delay: var(--d);
191
  }
192
 
 
216
 
217
  .showcase-row.arrived .fx-halo {
218
  animation: pulse-halo 4s ease-in-out infinite;
219
+ animation-delay: var(--t, 0s);
220
+ }
221
+
222
+ /* Depth: the camera dives into Gravity, the other topics fall back, its sub-topics and posts light up,
223
+ then it pulls back out. One 14 s loop, started once the row has arrived. */
224
+ .fx-cam {
225
+ transform-origin: 38px 52px;
226
+ transform-box: view-box;
227
+ }
228
+
229
+ .fx-deep,
230
+ .fx-panel,
231
+ .fx-crumb-deep {
232
+ opacity: 0;
233
+ }
234
+
235
+ .showcase-row.arrived .fx-cam {
236
+ animation: fx-dive 14s ease-in-out 2.5s infinite;
237
+ }
238
+
239
+ .showcase-row.arrived .fx-dim {
240
+ animation: fx-dim 14s ease-in-out 2.5s infinite;
241
+ }
242
+
243
+ .showcase-row.arrived .fx-deep,
244
+ .showcase-row.arrived .fx-panel,
245
+ .showcase-row.arrived .fx-crumb-deep {
246
+ animation: fx-in 14s ease-in-out 2.5s infinite;
247
+ }
248
+
249
+ @keyframes fx-dive {
250
+ 0%,
251
+ 26%,
252
+ 86%,
253
+ 100% {
254
+ transform: none;
255
+ }
256
+ 40%,
257
+ 72% {
258
+ transform: translate(-4px, -2px) scale(2.1);
259
+ }
260
+ }
261
+
262
+ @keyframes fx-dim {
263
+ 0%,
264
+ 26%,
265
+ 86%,
266
+ 100% {
267
+ opacity: 1;
268
+ }
269
+ 40%,
270
+ 72% {
271
+ opacity: 0.1;
272
+ }
273
+ }
274
+
275
+ @keyframes fx-in {
276
+ 0%,
277
+ 32%,
278
+ 80%,
279
+ 100% {
280
+ opacity: 0;
281
+ }
282
+ 42%,
283
+ 70% {
284
+ opacity: 1;
285
+ }
286
+ }
287
+
288
+ .fx-sat {
289
+ fill: var(--white);
290
+ opacity: 0.5;
291
+ }
292
+
293
+ .fx-ring {
294
+ fill: none;
295
+ stroke: var(--blue);
296
+ stroke-opacity: 0.5;
297
+ stroke-width: 0.25;
298
+ stroke-dasharray: 1.2 1.6;
299
+ }
300
+
301
+ .fx-deep-edge {
302
+ stroke: var(--white);
303
+ stroke-opacity: 0.35;
304
+ stroke-width: 0.2;
305
+ }
306
+
307
+ .fx-post {
308
+ fill: var(--white);
309
+ }
310
+
311
+ .fx-sub {
312
+ fill: var(--white);
313
+ font: 500 2.4px var(--font-body);
314
+ }
315
+
316
+ .fx-panel rect {
317
+ fill: rgba(0, 0, 0, 0.88);
318
+ stroke: var(--blue);
319
+ stroke-opacity: 0.5;
320
+ stroke-width: 0.25;
321
+ }
322
+
323
+ .fx-panel text {
324
+ fill: var(--white);
325
+ }
326
+
327
+ .fx-panel-h {
328
+ font: 600 3.8px var(--font-display);
329
+ }
330
+
331
+ .fx-panel-k {
332
+ fill: var(--blue) !important;
333
+ font: 500 1.9px var(--font-mono);
334
+ letter-spacing: 0.2px;
335
+ }
336
+
337
+ .fx-panel-t {
338
+ font: 500 2.7px var(--font-body);
339
+ }
340
+
341
+ .fx-crumb {
342
+ display: flex;
343
+ min-height: 1.2em;
344
+ }
345
+
346
+ .fx-pulse {
347
+ opacity: 0;
348
+ filter: drop-shadow(0 0 1.5px var(--blue));
349
+ transition: opacity 1s ease 1.5s;
350
+ }
351
+
352
+ .showcase-row.arrived .fx-pulse {
353
+ opacity: 0.9;
354
  }
355
 
356
  @keyframes pulse-halo {
 
425
 
426
  /* Video */
427
  .fx-video {
428
+ grid-template-columns: 0.8fr 1.3fr;
429
  align-items: center;
430
  }
431
 
432
+ .fx-scenes {
433
+ display: grid;
434
+ gap: 8px;
435
+ }
436
+
437
+ .fx-scenes span {
438
+ display: flex;
439
+ align-items: center;
440
+ gap: 10px;
441
+ padding: 9px 12px;
442
+ border-radius: 10px;
443
+ border: 1px solid var(--line-strong);
444
+ font-size: 0.8rem;
445
+ }
446
+
447
+ .fx-scenes span.on {
448
+ border-color: var(--blue);
449
+ background: var(--blue-10);
450
+ box-shadow: 0 0 18px var(--blue-20);
451
+ }
452
+
453
  .fx-frame {
454
  display: grid;
455
  gap: 6px;
 
467
  line-height: 1.2;
468
  }
469
 
 
 
 
 
470
  .v169 {
471
  aspect-ratio: 16 / 9;
472
  }
473
 
474
+ .fx-render {
475
+ grid-column: 1 / -1;
476
+ display: grid;
477
+ gap: 6px;
478
  }
479
 
480
+ .fx-render i {
481
  display: block;
482
+ height: 4px;
483
+ border-radius: 99px;
484
+ background: linear-gradient(90deg, var(--blue) 70%, var(--surface-2) 70%);
485
+ }
486
+
487
+ /* Mind Constellation shapes */
488
+ .fx-pills {
489
+ display: flex;
490
+ gap: 6px;
491
+ }
492
+
493
+ .fx-pills button {
494
+ padding: 3px 10px;
495
+ border-radius: 99px;
496
+ border: 1px solid var(--line-strong);
497
+ background: transparent;
498
+ color: var(--text-2);
499
+ font: inherit;
500
+ cursor: pointer;
501
+ transition:
502
+ border-color 0.3s ease,
503
+ background 0.3s ease,
504
+ color 0.3s ease;
505
+ }
506
+
507
+ .fx-pills button:hover {
508
+ color: var(--white);
509
+ }
510
+
511
+ .fx-pills button.on {
512
+ border-color: var(--blue);
513
+ background: var(--blue-10);
514
+ color: var(--white);
515
  }
516
 
517
  /* Launch Kit */
 
654
 
655
  .fx-wave span,
656
  .fx-halo,
657
+ .fx-cam,
658
+ .fx-dim,
659
+ .fx-deep,
660
+ .fx-panel,
661
+ .fx-crumb-deep,
662
  .fx-rec {
663
  animation: none !important;
664
  }
665
+
666
+ .fx-pulse {
667
+ display: none;
668
+ }
669
+
670
  }
671
 
672
  /* Demo video sound controls */
renderer/src/compositions/LandingDemo.tsx CHANGED
@@ -296,8 +296,8 @@ function Warp() {
296
  const SOURCES = ["Substack", "Ghost", "WordPress", "Medium", "RSS", "Markdown"];
297
 
298
  const POSTS = [
299
- "On Pricing", "Writing Every Day", "The Paid Tier", "Why I Quit Twitter",
300
- "Notes on Craft", "Letters to Readers", "The Long Game", "Small Audiences",
301
  ];
302
  const POST_DATES = ["Mar 12", "Mar 04", "Feb 21", "Feb 09", "Jan 30", "Jan 18", "Jan 03", "Dec 20"];
303
 
@@ -361,12 +361,12 @@ function Sync() {
361
  function Ask() {
362
  const frame = useCurrentFrame();
363
  const { fps } = useVideoConfig();
364
- const q = typed("What have I written about pricing?", frame, 8, 30);
365
  const answer = spring({ frame: frame - 66, fps, config: { damping: 18 } });
366
  const cite = spring({ frame: frame - 92, fps, config: { damping: 16 } });
367
  const open = spring({ frame: frame - 114, fps, config: { damping: 18 } });
368
- const steps = ["Searching for pric(e|ing)|paid tier", "Reading On Pricing, lines 12 to 20", "Reading The Paid Tier, lines 4 to 9"];
369
- const context = ["I tried a lot of numbers before this one.", "The paid tier doubled after I", "raised the price to ten dollars.", "Readers took the work seriously.", "Nobody left. That surprised me."];
370
  return (
371
  <AbsoluteFill>
372
  <WarpField speed={0.7} count={300} />
@@ -384,19 +384,19 @@ function Ask() {
384
  ))}
385
  </div>
386
  <p style={{ margin: 0, fontFamily: theme.body, fontSize: 32, lineHeight: 1.5, color: theme.white, opacity: answer, transform: `translateY(${(1 - answer) * 16}px)` }}>
387
- You raised prices twice and the <b>paid tier doubled</b> <Marker n={1} glow={cite} /> because readers took the work more seriously <Marker n={2} glow={cite} />.
388
  </p>
389
  </div>
390
  {[
391
- { n: 1, label: "On Pricing · L12 to 14" },
392
- { n: 2, label: "The Paid Tier · L4 to 9" },
393
  ].map((c, i) => (
394
  <Chip key={c.n} on={cite > 0.5 && (i === 0 ? frame >= 112 : false)} press={i === 0 ? pulse(frame, 112) : 0} style={{ position: "absolute", left: 80 + i * 450, top: 460, width: 420, height: 60, fontFamily: theme.mono, fontSize: 21, opacity: cite, gap: 10 }}>
395
  <span style={{ color: BLUE }}>[{c.n}]</span> {c.label}
396
  </Chip>
397
  ))}
398
  <div style={{ ...panel, position: "absolute", left: 1010, top: 36, width: 560, padding: 30, opacity: open, transform: `translateX(${(1 - open) * 60}px)` }}>
399
- <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 20, color: BLUE, letterSpacing: 2 }}>[1] ON PRICING · LINES 12 TO 14</p>
400
  {context.map((l, i) => {
401
  const hit = i >= 1 && i <= 3;
402
  return (
@@ -426,11 +426,11 @@ function Marker({ n, glow }: { n: number; glow: number }) {
426
  }
427
 
428
  const TOPICS = [
429
- "Pricing", "Paid tier", "Audience", "Craft", "Habits", "Newsletters", "Growth", "Writing",
430
- "Focus", "Trust", "Launches", "Editing", "Community", "Ideas", "Archive",
431
  ];
432
  const MAP_SHAPES = ["Orbit", "Spiral", "Figure", "Cluster"];
433
- type Shape = "orbit" | "spiral" | "cluster";
434
 
435
  /** Where topic `i` sits in each shape of the Mind Constellation (window coordinates). */
436
  function place(shape: Shape, i: number): { x: number; y: number } {
@@ -445,10 +445,14 @@ function place(shape: Shape, i: number): { x: number; y: number } {
445
  }
446
  if (shape === "spiral") {
447
  const t = i / (n - 1);
448
- const a = t * Math.PI * 2.6 + 0.4;
449
- const r = 30 + t * 340;
450
  return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 };
451
  }
 
 
 
 
452
  const centers = [{ x: 330, y: 250 }, { x: 800, y: 220 }, { x: 640, y: 490 }];
453
  const c = centers[i % 3];
454
  const k = Math.floor(i / 3);
@@ -457,70 +461,97 @@ function place(shape: Shape, i: number): { x: number; y: number } {
457
  return { x: c.x + Math.cos(a) * r * 1.3, y: c.y + Math.sin(a) * r };
458
  }
459
 
 
 
 
460
  function mapPos(i: number, frame: number): { x: number; y: number } {
461
- const a = place("orbit", i);
462
- const b = place("spiral", i);
463
- const c = place("cluster", i);
464
- const s1 = ease(clamp01(frame, 64, 96));
465
- const s2 = ease(clamp01(frame, 112, 144));
466
- return { x: a.x + (b.x - a.x) * s1 + (c.x - (a.x + (b.x - a.x) * s1)) * s2, y: a.y + (b.y - a.y) * s1 + (c.y - (a.y + (b.y - a.y) * s1)) * s2 };
 
 
467
  }
468
 
469
  /** 03: the Mind Constellation, switching shapes and diving into a topic. */
470
  function MindMap() {
471
  const frame = useCurrentFrame();
472
  const { fps } = useVideoConfig();
473
- const activeShape = frame < 62 ? 0 : frame < 110 ? 1 : 3;
474
  const panelIn = spring({ frame: frame - 158, fps, config: { damping: 18 } });
475
- const hub = at(place("cluster", 0).x, place("cluster", 0).y);
 
 
 
 
 
476
  return (
477
  <AbsoluteFill>
478
  <WarpField speed={0.6} count={260} />
479
  <Caption eyebrow="03 · Map" title="See your whole archive." />
480
  <Win>
481
  <svg width={1640} height={668} style={{ position: "absolute", inset: 0 }}>
482
- {TOPICS.map((_, i) => {
483
- if (i === 0) return null;
484
- const from = mapPos(i, frame);
485
- const to = mapPos(i < 4 ? 0 : i - 3, frame);
486
- const on = clamp01(frame, 6 + i * 3, 20 + i * 3);
487
- return <line key={i} x1={from.x} y1={from.y} x2={to.x} y2={to.y} stroke={i % 2 ? BLUE : "#ffffff"} strokeOpacity={0.3 * on} strokeWidth={1.5} />;
488
- })}
489
- {TOPICS.map((name, i) => {
490
- const p = mapPos(i, frame);
491
- const born = spring({ frame: frame - 2 - i * 3, fps, config: { damping: 12 } });
492
- const r = (i === 0 ? 17 : 7 + ((i * 7) % 5)) * born;
493
- const picked = i === 0 && frame >= 158;
494
- return (
495
- <g key={name}>
496
- <circle cx={p.x} cy={p.y} r={r * 2.6} fill={BLUE} opacity={0.18 * born} style={{ filter: "blur(8px)" }} />
497
- <circle cx={p.x} cy={p.y} r={r} fill="#ffffff" style={{ filter: `drop-shadow(0 0 10px ${BLUE})` }} />
498
- {picked && <circle cx={p.x} cy={p.y} r={r + 14} fill="none" stroke={BLUE} strokeWidth={3} />}
499
- <text x={p.x} y={p.y + r + 26} textAnchor="middle" fontFamily={theme.body} fontSize={i === 0 ? 26 : 21} fill="#ffffff" opacity={0.85 * born}>
500
- {name}
501
- </text>
502
- </g>
503
- );
504
- })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  </svg>
506
  <div style={{ position: "absolute", left: 40, top: 26, display: "flex", gap: 10 }}>
507
  {MAP_SHAPES.map((name, i) => (
508
- <Chip key={name} on={i === activeShape} press={i === 1 ? pulse(frame, 62) : i === 3 ? pulse(frame, 110) : 0} style={{ width: 120, height: 48, fontSize: 22 }}>
509
  {name}
510
  </Chip>
511
  ))}
512
  </div>
513
- <p style={{ position: "absolute", left: 40, bottom: 20, margin: 0, fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)" }}>Scroll to dive in. Drag to move. Click a star to fly to it.</p>
514
  <div style={{ ...panel, position: "absolute", right: 40, top: 26, width: 380, padding: 28, opacity: panelIn, transform: `translateX(${(1 - panelIn) * 60}px)` }}>
515
- <p style={{ margin: 0, fontFamily: theme.display, fontSize: 40, color: theme.white }}>Pricing</p>
516
  <Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>Orbiting here</Label>
517
  <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
518
- {["Paid tier", "Audience", "Trust"].map((t) => (
519
  <Chip key={t} on style={{ padding: "4px 16px", fontSize: 20 }}>{t}</Chip>
520
  ))}
521
  </div>
522
  <Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>From your posts</Label>
523
- {["On Pricing", "The Paid Tier", "Small Audiences"].map((t) => (
524
  <p key={t} style={{ margin: "10px 0 0", fontFamily: theme.body, fontSize: 24, color: theme.white }}>{t}</p>
525
  ))}
526
  </div>
@@ -528,14 +559,16 @@ function MindMap() {
528
  <Cursor
529
  keys={[
530
  { f: 0, x: 1560, y: 880 },
531
- { f: 50, ...at(220, 50) },
532
- { f: 64, ...at(220, 50) },
533
- { f: 98, ...at(470, 50) },
534
- { f: 112, ...at(470, 50) },
535
- { f: 150, x: hub.x, y: hub.y },
536
- { f: 170, x: hub.x + 30, y: hub.y + 120 },
 
 
537
  ]}
538
- clicks={[62, 110, 156]}
539
  />
540
  </AbsoluteFill>
541
  );
@@ -550,9 +583,9 @@ function Audio() {
550
  const bar = clamp01(frame, 52, 84);
551
  const playing = clamp01(frame, 84, 96);
552
  const lines = [
553
- { who: "A", text: "So the big idea here is that price is a signal." },
554
- { who: "B", text: "Right, and she has the numbers: the paid tier doubled." },
555
- { who: "A", text: "Which she wrote about back in March, in On Pricing." },
556
  ];
557
  const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame - 92) / 24));
558
  return (
@@ -602,9 +635,9 @@ const TEMPLATE_LOOKS: CSSProperties[] = [
602
  { background: "linear-gradient(135deg, #000, #0b1730)" },
603
  { background: `linear-gradient(135deg, ${BLUE}, #0a2a66)` },
604
  { background: "linear-gradient(135deg, #ffffff, #cfe0ff)" },
605
- { background: "rgba(255,255,255,0.04)", border: `2px dashed ${BLUE}88` },
606
  ];
607
- const TEMPLATE_NAMES = ["Night", "Signal", "Paper", "From your website"];
608
  const VOICES = ["Nora Vale", "Your voice (clone)", "+ Create a voice"];
609
 
610
  /** 05: wizard: pick a style, a template and a voice, create. */
@@ -669,7 +702,7 @@ function VideoCreate() {
669
  );
670
  }
671
 
672
- const SCENE_TITLES = ["Price is a signal", "The numbers", "Why it worked", "Try it yourself"];
673
 
674
  /** 06: the scene editor, then render the MP4. */
675
  function VideoEdit() {
@@ -709,15 +742,15 @@ function VideoEdit() {
709
  {active === 0 ? (
710
  <div style={{ position: "absolute", left: 70, top: 160, opacity: pop, transform: `translateY(${(1 - pop) * 30}px)` }}>
711
  <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 22, letterSpacing: 5, color: BLUE }}>SCENE 1</p>
712
- <p style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 86, lineHeight: 1.05, color: theme.white, textShadow: `0 0 30px ${BLUE}` }}>Price is<br />a signal.</p>
713
  </div>
714
  ) : (
715
  <>
716
- <p style={{ position: "absolute", left: 70, top: 56, margin: 0, fontFamily: theme.display, fontSize: 52, color: theme.white, opacity: pop }}>Paid readers, doubled</p>
717
- {[0.28, 0.5, 1].map((h, i) => (
718
- <div key={i} style={{ position: "absolute", left: 90 + i * 250, bottom: 90, width: 170, height: 340 * h * ease(clamp01(since, 8 + i * 7, 34 + i * 7)), borderRadius: "12px 12px 0 0", background: i === 2 ? "#ffffff" : BLUE, boxShadow: `0 0 30px ${BLUE}` }} />
719
  ))}
720
- <p style={{ position: "absolute", left: 70, bottom: 30, margin: 0, fontFamily: theme.body, fontSize: 26, color: "rgba(255,255,255,0.85)", opacity: pop }}>You raised prices twice and the paid tier doubled.</p>
721
  </>
722
  )}
723
  {render > 0 && (
@@ -750,10 +783,10 @@ function LaunchKit() {
750
  const frame = useCurrentFrame();
751
  const { fps } = useVideoConfig();
752
  const cards = [
753
- { label: "X THREAD", body: "I doubled my price. My paid tier doubled too. Here is what I learned." },
754
- { label: "LINKEDIN", body: "Most writers underprice. I did for two years. Then I ran an experiment." },
755
- { label: "SUBSTACK NOTES", body: "Price is a signal. Readers told me so, with their wallets." },
756
- { label: "BLUESKY", body: "Raised my price to $10. Paid readers went up, not down." },
757
  ];
758
  const days = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
759
  const slots: Record<number, string> = { 0: "X", 1: "LinkedIn", 2: "Notes", 3: "Bluesky", 4: "X", 5: "LinkedIn", 6: "Notes" };
 
296
  const SOURCES = ["Substack", "Ghost", "WordPress", "Medium", "RSS", "Markdown"];
297
 
298
  const POSTS = [
299
+ "Why Things Fall", "Curved Space", "Notes on Orbits", "What Is Entropy?",
300
+ "The Quantum Leap", "Light as a Wave", "Time Is Strange", "Black Hole Basics",
301
  ];
302
  const POST_DATES = ["Mar 12", "Mar 04", "Feb 21", "Feb 09", "Jan 30", "Jan 18", "Jan 03", "Dec 20"];
303
 
 
361
  function Ask() {
362
  const frame = useCurrentFrame();
363
  const { fps } = useVideoConfig();
364
+ const q = typed("What have I written about gravity?", frame, 8, 30);
365
  const answer = spring({ frame: frame - 66, fps, config: { damping: 18 } });
366
  const cite = spring({ frame: frame - 92, fps, config: { damping: 16 } });
367
  const open = spring({ frame: frame - 114, fps, config: { damping: 18 } });
368
+ const steps = ["Searching for gravity|spacetime", "Reading Why Things Fall, lines 12 to 20", "Reading Curved Space, lines 4 to 9"];
369
+ const context = ["Newton called it a force pulling things down.", "Einstein saw something different: gravity is", "the curvature of spacetime itself.", "Mass tells space how to bend.", "Objects follow the straightest path."];
370
  return (
371
  <AbsoluteFill>
372
  <WarpField speed={0.7} count={300} />
 
384
  ))}
385
  </div>
386
  <p style={{ margin: 0, fontFamily: theme.body, fontSize: 32, lineHeight: 1.5, color: theme.white, opacity: answer, transform: `translateY(${(1 - answer) * 16}px)` }}>
387
+ You wrote that <b>gravity is the curvature of spacetime</b> <Marker n={1} glow={cite} /> and that orbits are objects falling around that curve <Marker n={2} glow={cite} />.
388
  </p>
389
  </div>
390
  {[
391
+ { n: 1, label: "Why Things Fall · L12 to 14" },
392
+ { n: 2, label: "Curved Space · L4 to 9" },
393
  ].map((c, i) => (
394
  <Chip key={c.n} on={cite > 0.5 && (i === 0 ? frame >= 112 : false)} press={i === 0 ? pulse(frame, 112) : 0} style={{ position: "absolute", left: 80 + i * 450, top: 460, width: 420, height: 60, fontFamily: theme.mono, fontSize: 21, opacity: cite, gap: 10 }}>
395
  <span style={{ color: BLUE }}>[{c.n}]</span> {c.label}
396
  </Chip>
397
  ))}
398
  <div style={{ ...panel, position: "absolute", left: 1010, top: 36, width: 560, padding: 30, opacity: open, transform: `translateX(${(1 - open) * 60}px)` }}>
399
+ <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 20, color: BLUE, letterSpacing: 2 }}>[1] WHY THINGS FALL · L12 TO 14</p>
400
  {context.map((l, i) => {
401
  const hit = i >= 1 && i <= 3;
402
  return (
 
426
  }
427
 
428
  const TOPICS = [
429
+ "Gravity", "Spacetime", "Black holes", "Orbits", "Relativity", "Quantum", "Entropy", "Optics",
430
+ "Waves", "Energy", "Chaos", "Cosmology", "Fields", "Symmetry", "Time",
431
  ];
432
  const MAP_SHAPES = ["Orbit", "Spiral", "Figure", "Cluster"];
433
+ type Shape = "orbit" | "spiral" | "figure" | "cluster";
434
 
435
  /** Where topic `i` sits in each shape of the Mind Constellation (window coordinates). */
436
  function place(shape: Shape, i: number): { x: number; y: number } {
 
445
  }
446
  if (shape === "spiral") {
447
  const t = i / (n - 1);
448
+ const a = t * Math.PI * 2.4 + 0.4;
449
+ const r = 110 + t * 260;
450
  return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 };
451
  }
452
+ if (shape === "figure") {
453
+ // A constellation figure: one long wave of stars across the sky.
454
+ return { x: 140 + i * 78, y: cy + (i % 2 ? -1 : 1) * (90 + (i % 3) * 30) };
455
+ }
456
  const centers = [{ x: 330, y: 250 }, { x: 800, y: 220 }, { x: 640, y: 490 }];
457
  const c = centers[i % 3];
458
  const k = Math.floor(i / 3);
 
461
  return { x: c.x + Math.cos(a) * r * 1.3, y: c.y + Math.sin(a) * r };
462
  }
463
 
464
+ /** Frame windows in which the layout morphs to the next shape (after each pill click). */
465
+ const MORPHS: [Shape, number, number][] = [["spiral", 58, 82], ["figure", 94, 118], ["cluster", 126, 150]];
466
+
467
  function mapPos(i: number, frame: number): { x: number; y: number } {
468
+ let { x, y } = place("orbit", i);
469
+ for (const [shape, from, to] of MORPHS) {
470
+ const t = ease(clamp01(frame, from, to));
471
+ const next = place(shape, i);
472
+ x += (next.x - x) * t;
473
+ y += (next.y - y) * t;
474
+ }
475
+ return { x, y };
476
  }
477
 
478
  /** 03: the Mind Constellation, switching shapes and diving into a topic. */
479
  function MindMap() {
480
  const frame = useCurrentFrame();
481
  const { fps } = useVideoConfig();
482
+ const activeShape = frame < 56 ? 0 : frame < 92 ? 1 : frame < 124 ? 2 : 3;
483
  const panelIn = spring({ frame: frame - 158, fps, config: { damping: 18 } });
484
+ const hubWin = place("cluster", 0);
485
+ const hub = at(hubWin.x, hubWin.y);
486
+ const selected = frame >= 158;
487
+ const ring = clamp01(frame, 4, 40);
488
+ const dive = 1 + 0.14 * ease(clamp01(frame, 158, 195)); // the camera drifts in on the picked star
489
+ const burst = ease(clamp01(frame, 158, 182));
490
  return (
491
  <AbsoluteFill>
492
  <WarpField speed={0.6} count={260} />
493
  <Caption eyebrow="03 · Map" title="See your whole archive." />
494
  <Win>
495
  <svg width={1640} height={668} style={{ position: "absolute", inset: 0 }}>
496
+ <g transform={`translate(${hubWin.x} ${hubWin.y}) scale(${dive}) translate(${-hubWin.x} ${-hubWin.y})`}>
497
+ {/* Orbit rings draw in, then fade as the stars leave them. */}
498
+ {[170, 270, 360].map((r, i) => (
499
+ <ellipse key={r} cx={620} cy={350} rx={r * 1.5} ry={r * 0.58} fill="none" stroke={i % 2 ? "#ffffff" : BLUE} strokeOpacity={0.22 * ring * (1 - ease(clamp01(frame, 58, 82)))} strokeWidth={1.5} strokeDasharray="6 10" />
500
+ ))}
501
+ {TOPICS.map((_, i) => {
502
+ if (i === 0) return null;
503
+ const parent = i < 4 ? 0 : i - 3;
504
+ const from = mapPos(i, frame);
505
+ const to = mapPos(parent, frame);
506
+ const on = clamp01(frame, 6 + i * 3, 24 + i * 3);
507
+ const lit = !selected || parent === 0;
508
+ const t = (frame * 0.018 + i * 0.37) % 1; // a pulse of light travelling toward the hub
509
+ return (
510
+ <g key={i} opacity={lit ? 1 : 0.25}>
511
+ <line x1={from.x} y1={from.y} x2={to.x} y2={to.y} pathLength={1} stroke={i % 2 ? BLUE : "#ffffff"} strokeOpacity={selected && parent === 0 ? 0.8 : 0.3} strokeWidth={selected && parent === 0 ? 2.5 : 1.5} strokeDasharray={1} strokeDashoffset={1 - on} />
512
+ {on >= 1 && <circle cx={from.x + (to.x - from.x) * t} cy={from.y + (to.y - from.y) * t} r={3.5} fill="#ffffff" opacity={Math.sin(t * Math.PI)} style={{ filter: `drop-shadow(0 0 6px ${BLUE})` }} />}
513
+ </g>
514
+ );
515
+ })}
516
+ {TOPICS.map((name, i) => {
517
+ const base = mapPos(i, frame);
518
+ const p = { x: base.x + Math.sin(frame * 0.03 + i * 1.9) * 5, y: base.y + Math.cos(frame * 0.025 + i * 1.3) * 5 };
519
+ const born = spring({ frame: frame - 2 - i * 3, fps, config: { damping: 12 } });
520
+ const r = (i === 0 ? 17 : 7 + ((i * 7) % 5)) * born;
521
+ const twinkle = 1 + 0.25 * Math.sin(frame * 0.14 + i * 1.7);
522
+ const near = i === 0 || (i >= 1 && i <= 3);
523
+ const dim = selected && !near ? 0.3 : 1;
524
+ return (
525
+ <g key={name} opacity={dim}>
526
+ <circle cx={p.x} cy={p.y} r={r * 2.6 * twinkle} fill={BLUE} opacity={0.18 * born} style={{ filter: "blur(8px)" }} />
527
+ <circle cx={p.x} cy={p.y} r={r} fill="#ffffff" style={{ filter: `drop-shadow(0 0 10px ${BLUE})` }} />
528
+ {i === 0 && selected && <circle cx={p.x} cy={p.y} r={r + 14 + 22 * burst} fill="none" stroke={BLUE} strokeWidth={3} opacity={1 - 0.6 * burst} />}
529
+ <text x={p.x} y={p.y + r + 26} textAnchor="middle" fontFamily={theme.body} fontSize={i === 0 ? 26 : 21} fill="#ffffff" opacity={0.85 * born}>
530
+ {name}
531
+ </text>
532
+ </g>
533
+ );
534
+ })}
535
+ </g>
536
  </svg>
537
  <div style={{ position: "absolute", left: 40, top: 26, display: "flex", gap: 10 }}>
538
  {MAP_SHAPES.map((name, i) => (
539
+ <Chip key={name} on={i === activeShape} press={i === 0 ? 0 : pulse(frame, [0, 56, 92, 124][i])} style={{ width: 120, height: 48, fontSize: 22 }}>
540
  {name}
541
  </Chip>
542
  ))}
543
  </div>
544
+ <p style={{ position: "absolute", left: 40, bottom: 20, margin: 0, fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)", opacity: 1 - clamp01(frame, 150, 165) }}>Scroll to dive in. Drag to move. Click a star to fly to it.</p>
545
  <div style={{ ...panel, position: "absolute", right: 40, top: 26, width: 380, padding: 28, opacity: panelIn, transform: `translateX(${(1 - panelIn) * 60}px)` }}>
546
+ <p style={{ margin: 0, fontFamily: theme.display, fontSize: 40, color: theme.white }}>Gravity</p>
547
  <Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>Orbiting here</Label>
548
  <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
549
+ {["Spacetime", "Black holes", "Orbits"].map((t) => (
550
  <Chip key={t} on style={{ padding: "4px 16px", fontSize: 20 }}>{t}</Chip>
551
  ))}
552
  </div>
553
  <Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>From your posts</Label>
554
+ {["Why Things Fall", "Curved Space", "Notes on Orbits"].map((t) => (
555
  <p key={t} style={{ margin: "10px 0 0", fontFamily: theme.body, fontSize: 24, color: theme.white }}>{t}</p>
556
  ))}
557
  </div>
 
559
  <Cursor
560
  keys={[
561
  { f: 0, x: 1560, y: 880 },
562
+ { f: 42, ...at(230, 50) },
563
+ { f: 58, ...at(230, 50) },
564
+ { f: 84, ...at(360, 50) },
565
+ { f: 94, ...at(360, 50) },
566
+ { f: 114, ...at(490, 50) },
567
+ { f: 126, ...at(490, 50) },
568
+ { f: 152, x: hub.x, y: hub.y },
569
+ { f: 172, x: hub.x + 30, y: hub.y + 120 },
570
  ]}
571
+ clicks={[56, 92, 124, 156]}
572
  />
573
  </AbsoluteFill>
574
  );
 
583
  const bar = clamp01(frame, 52, 84);
584
  const playing = clamp01(frame, 84, 96);
585
  const lines = [
586
+ { who: "A", text: "So the big idea here is that gravity is curved spacetime." },
587
+ { who: "B", text: "Right, and orbits are just objects falling around the curve." },
588
+ { who: "A", text: "Which she wrote about back in March, in Curved Space." },
589
  ];
590
  const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame - 92) / 24));
591
  return (
 
635
  { background: "linear-gradient(135deg, #000, #0b1730)" },
636
  { background: `linear-gradient(135deg, ${BLUE}, #0a2a66)` },
637
  { background: "linear-gradient(135deg, #ffffff, #cfe0ff)" },
638
+ { background: "linear-gradient(135deg, #0b1730, #217cff55)" },
639
  ];
640
+ const TEMPLATE_NAMES = ["Night", "Signal", "Paper", "Studio"];
641
  const VOICES = ["Nora Vale", "Your voice (clone)", "+ Create a voice"];
642
 
643
  /** 05: wizard: pick a style, a template and a voice, create. */
 
702
  );
703
  }
704
 
705
+ const SCENE_TITLES = ["Gravity is geometry", "Inverse square", "Orbits", "Try it yourself"];
706
 
707
  /** 06: the scene editor, then render the MP4. */
708
  function VideoEdit() {
 
742
  {active === 0 ? (
743
  <div style={{ position: "absolute", left: 70, top: 160, opacity: pop, transform: `translateY(${(1 - pop) * 30}px)` }}>
744
  <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 22, letterSpacing: 5, color: BLUE }}>SCENE 1</p>
745
+ <p style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 86, lineHeight: 1.05, color: theme.white, textShadow: `0 0 30px ${BLUE}` }}>Gravity is<br />a curve.</p>
746
  </div>
747
  ) : (
748
  <>
749
+ <p style={{ position: "absolute", left: 70, top: 56, margin: 0, fontFamily: theme.display, fontSize: 52, color: theme.white, opacity: pop }}>Gravity fades with distance</p>
750
+ {[1, 0.25, 0.11].map((h, i) => (
751
+ <div key={i} style={{ position: "absolute", left: 90 + i * 250, bottom: 90, width: 170, height: 340 * h * ease(clamp01(since, 8 + i * 7, 34 + i * 7)), borderRadius: "12px 12px 0 0", background: i === 0 ? "#ffffff" : BLUE, boxShadow: `0 0 30px ${BLUE}` }} />
752
  ))}
753
+ <p style={{ position: "absolute", left: 70, bottom: 30, margin: 0, fontFamily: theme.body, fontSize: 26, color: "rgba(255,255,255,0.85)", opacity: pop }}>Double the distance and gravity drops to a quarter.</p>
754
  </>
755
  )}
756
  {render > 0 && (
 
783
  const frame = useCurrentFrame();
784
  const { fps } = useVideoConfig();
785
  const cards = [
786
+ { label: "X THREAD", body: "Gravity is not a force. It is geometry. A thread on how Einstein changed everything." },
787
+ { label: "LINKEDIN", body: "Most of us learned gravity wrong. Here is the picture physicists actually use." },
788
+ { label: "SUBSTACK NOTES", body: "Mass tells space how to bend. Space tells mass how to move." },
789
+ { label: "BLUESKY", body: "Orbits are just falling, forever, around a curve." },
790
  ];
791
  const days = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
792
  const slots: Record<number, string> = { 0: "X", 1: "LinkedIn", 2: "Notes", 3: "Bluesky", 4: "X", 5: "LinkedIn", 6: "Notes" };