Arslan1997 commited on
Commit
aeea10c
·
1 Parent(s): 3f79c45

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
backend/app/services/billing.py CHANGED
@@ -56,14 +56,15 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
56
  if m["limit"] < 0 or m["key"] == "sources":
57
  continue
58
  label = m["label"].lower()
 
59
  if m["pct"] >= 1:
60
  out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label} this month",
61
- f"You have used all {m['limit']} {m['unit'] or label}. Upgrade to keep launching.",
62
- "Refuel now", priority=100))
63
  elif m["pct"] >= LOW_FUEL:
64
- left = round(m["limit"] - m["used"], 1)
65
  out.append(_nudge(f"low-{m['key']}-{month}", "limit", f"Running low on {label}",
66
- f"Only {left:g} {m['unit'] or 'left'} left this month. Upgrade before you stall mid launch.",
67
  "See plans", priority=90))
68
  if not plan.voice_cloning:
69
  out.append(_nudge("perk-voice", "upgrade", "Narrate in your own voice",
@@ -71,7 +72,8 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
71
  "Unlock voice", priority=40))
72
  if not plan.brand_kit:
73
  out.append(_nudge("perk-brand", "upgrade", "Put your brand on every render",
74
- "Your colors and logo on videos, quote cards and carousels.", "Unlock brand kit", priority=30))
 
75
 
76
  sources = _count(db, Source, ws)
77
  if sources == 0:
 
56
  if m["limit"] < 0 or m["key"] == "sources":
57
  continue
58
  label = m["label"].lower()
59
+ amount = f"{m['limit']:g} min of {label}" if m["unit"] else f"{m['limit']:g} {label}"
60
  if m["pct"] >= 1:
61
  out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label} this month",
62
+ f"You have used all {amount}. Upgrade to keep launching.", "Refuel now",
63
+ priority=100))
64
  elif m["pct"] >= LOW_FUEL:
65
+ left = f"{m['limit'] - m['used']:g}" + (" min" if m["unit"] else "")
66
  out.append(_nudge(f"low-{m['key']}-{month}", "limit", f"Running low on {label}",
67
+ f"Only {left} left this month. Upgrade before you stall mid launch.",
68
  "See plans", priority=90))
69
  if not plan.voice_cloning:
70
  out.append(_nudge("perk-voice", "upgrade", "Narrate in your own voice",
 
72
  "Unlock voice", priority=40))
73
  if not plan.brand_kit:
74
  out.append(_nudge("perk-brand", "upgrade", "Put your brand on every render",
75
+ "Your colors and logo on videos, quote cards and carousels.", "Unlock brand kit",
76
+ priority=30))
77
 
78
  sources = _count(db, Source, ws)
79
  if sources == 0:
backend/app/services/plans.py CHANGED
@@ -146,7 +146,7 @@ def upgrade_for(plan: Plan, kind: str | None = None) -> str | None:
146
  if kind == "voice_cloning" and candidate.voice_cloning:
147
  return pid
148
  current, better = getattr(plan, kind, 0), getattr(candidate, kind, 0)
149
- if better < 0 or (isinstance(better, (int, float)) and better > current):
150
  return pid
151
  return None
152
 
 
146
  if kind == "voice_cloning" and candidate.voice_cloning:
147
  return pid
148
  current, better = getattr(plan, kind, 0), getattr(candidate, kind, 0)
149
+ if better < 0 or (isinstance(better, int | float) and better > current):
150
  return pid
151
  return None
152
 
frontend/src/api/client.ts CHANGED
@@ -5,13 +5,20 @@ const REFRESH = "ns_refresh";
5
  export class ApiError extends Error {
6
  status: number;
7
  code?: string;
8
- constructor(status: number, message: string, code?: string) {
 
9
  super(message);
10
  this.status = status;
11
  this.code = code;
 
12
  }
13
  }
14
 
 
 
 
 
 
15
  function read(key: string): string | null {
16
  try {
17
  return localStorage.getItem(key);
@@ -48,6 +55,7 @@ export const tokens = {
48
  async function parseError(res: Response): Promise<ApiError> {
49
  let message = res.statusText || "Something went wrong";
50
  let code: string | undefined;
 
51
  try {
52
  const body = await res.json();
53
  const detail = body.detail;
@@ -55,11 +63,15 @@ async function parseError(res: Response): Promise<ApiError> {
55
  else if (detail?.message) {
56
  message = detail.message;
57
  code = detail.code;
 
58
  }
59
  } catch {
60
  /* non JSON */
61
  }
62
- return new ApiError(res.status, message, code);
 
 
 
63
  }
64
 
65
  async function tryRefresh(): Promise<boolean> {
 
5
  export class ApiError extends Error {
6
  status: number;
7
  code?: string;
8
+ detail?: Record<string, unknown>;
9
+ constructor(status: number, message: string, code?: string, detail?: Record<string, unknown>) {
10
  super(message);
11
  this.status = status;
12
  this.code = code;
13
+ this.detail = detail;
14
  }
15
  }
16
 
17
+ /** Fired on every 402 plan_limit so the upgrade popup opens wherever the request came from. */
18
+ export const PLAN_LIMIT_EVENT = "ns:plan-limit";
19
+
20
+ export type PlanLimitDetail = { message: string; kind?: string; plan?: string; upgrade_to?: string | null };
21
+
22
  function read(key: string): string | null {
23
  try {
24
  return localStorage.getItem(key);
 
55
  async function parseError(res: Response): Promise<ApiError> {
56
  let message = res.statusText || "Something went wrong";
57
  let code: string | undefined;
58
+ let extra: Record<string, unknown> | undefined;
59
  try {
60
  const body = await res.json();
61
  const detail = body.detail;
 
63
  else if (detail?.message) {
64
  message = detail.message;
65
  code = detail.code;
66
+ extra = detail;
67
  }
68
  } catch {
69
  /* non JSON */
70
  }
71
+ if (res.status === 402 && code === "plan_limit") {
72
+ window.dispatchEvent(new CustomEvent<PlanLimitDetail>(PLAN_LIMIT_EVENT, { detail: { ...extra, message } }));
73
+ }
74
+ return new ApiError(res.status, message, code, extra);
75
  }
76
 
77
  async function tryRefresh(): Promise<boolean> {
frontend/src/api/endpoints.ts CHANGED
@@ -2,6 +2,7 @@ import { api, del, patch, post, put, qs, uploadFile } from "./client";
2
  import type {
3
  Artifact,
4
  ArtifactType,
 
5
  CalendarItem,
6
  ChatMessage,
7
  ChatSummary,
@@ -179,3 +180,11 @@ export const settingsApi = {
179
  update: (body: SettingsPatch) => patch<Settings>("/api/settings", body),
180
  usage: () => api<Usage>("/api/settings/usage"),
181
  };
 
 
 
 
 
 
 
 
 
2
  import type {
3
  Artifact,
4
  ArtifactType,
5
+ BillingStatus,
6
  CalendarItem,
7
  ChatMessage,
8
  ChatSummary,
 
180
  update: (body: SettingsPatch) => patch<Settings>("/api/settings", body),
181
  usage: () => api<Usage>("/api/settings/usage"),
182
  };
183
+
184
+ export type BillingCycle = "monthly" | "annual";
185
+
186
+ export const billingApi = {
187
+ status: () => api<BillingStatus>("/api/billing/status"),
188
+ checkout: (plan: string, cycle: BillingCycle) => post<{ url: string }>("/api/billing/checkout", { plan, cycle }),
189
+ portal: () => post<{ url: string }>("/api/billing/portal"),
190
+ };
frontend/src/api/types.ts CHANGED
@@ -310,3 +310,38 @@ export type Settings = {
310
  usage: Usage;
311
  integrations: { llm: boolean; elevenlabs: boolean; x: boolean; linkedin: boolean; storage: string; renderer: string };
312
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  usage: Usage;
311
  integrations: { llm: boolean; elevenlabs: boolean; x: boolean; linkedin: boolean; storage: string; renderer: string };
312
  };
313
+
314
+ export type PlanInfo = Plan & {
315
+ tagline: string;
316
+ price_monthly_usd: number;
317
+ price_annual_monthly_usd: number;
318
+ price_annual_usd: number;
319
+ annual_savings_pct: number;
320
+ features: string[];
321
+ };
322
+
323
+ export type Meter = { key: string; label: string; unit: string; used: number; limit: number; pct: number };
324
+
325
+ /** tone: limit (low or out of fuel), upgrade (locked perk), action (use what you have). No `to` opens the upgrade popup. */
326
+ export type Nudge = {
327
+ id: string;
328
+ tone: "limit" | "upgrade" | "action";
329
+ title: string;
330
+ body: string;
331
+ cta: string;
332
+ to: string | null;
333
+ priority: number;
334
+ };
335
+
336
+ export type BillingStatus = {
337
+ billing_enabled: boolean;
338
+ plan: PlanInfo;
339
+ plans: PlanInfo[];
340
+ next_plan: string | null;
341
+ can_upgrade: boolean;
342
+ has_billing_account: boolean;
343
+ period_end: string | null;
344
+ meters: Meter[];
345
+ since: string;
346
+ nudges: Nudge[];
347
+ };
frontend/src/components/AppShell.tsx CHANGED
@@ -1,8 +1,11 @@
1
  import { useState } from "react";
2
  import { NavLink, Outlet, Navigate, useLocation } from "react-router-dom";
3
  import { useAuth } from "../hooks/useAuth";
 
4
  import Logo from "./Logo";
 
5
  import SkyCanvas from "./SkyCanvas";
 
6
  import {
7
  AsteroidIcon,
8
  CometIcon,
@@ -62,52 +65,58 @@ export default function AppShell() {
62
  }
63
 
64
  return (
65
- <div className={`shell${collapsed ? " collapsed" : ""}`}>
66
- <SkyCanvas intensity={0.35} />
67
- <aside className="sidebar">
68
- <div className="sidebar-top">
69
- {collapsed ? (
70
- <button type="button" className="sidebar-brand sidebar-brand-btn" onClick={toggle} aria-label="Expand sidebar" title="Expand sidebar">
71
- <Logo size={28} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  </button>
73
- ) : (
74
- <NavLink to="/" className="sidebar-brand">
75
- <Logo size={28} />
76
- </NavLink>
77
- )}
78
- <button
79
- type="button"
80
- className="icon-btn sidebar-toggle"
81
- onClick={toggle}
82
- aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
83
- aria-expanded={!collapsed}
84
- title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
85
- >
86
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
87
- <rect x="3.5" y="4.5" width="17" height="15" rx="2.5" />
88
- <path d="M9 4.5v15" />
89
- <path d={collapsed ? "M13 10l2 2-2 2" : "M15 10l-2 2 2 2"} />
90
- </svg>
91
- </button>
92
- </div>
93
- <nav aria-label="App">
94
- {NAV.map(({ to, label, icon: Icon, end }) => (
95
- <NavLink key={to} to={to} end={end} title={collapsed ? label : undefined} className={({ isActive }) => `side-link${isActive ? " active" : ""}`}>
96
- <Icon />
97
- <span className="side-label">{label}</span>
98
- </NavLink>
99
- ))}
100
- </nav>
101
- <div className="sidebar-user">
102
- <span className="mono muted side-label">{user.email}</span>
103
- <button className="link-btn" onClick={logout} title="Sign out">
104
- Sign out
105
- </button>
106
- </div>
107
- </aside>
108
- <main className="shell-main">
109
- <Outlet />
110
- </main>
111
- </div>
112
  );
113
  }
 
1
  import { useState } from "react";
2
  import { NavLink, Outlet, Navigate, useLocation } from "react-router-dom";
3
  import { useAuth } from "../hooks/useAuth";
4
+ import { UpgradeProvider } from "../hooks/useUpgrade";
5
  import Logo from "./Logo";
6
+ import { FuelGauge, NudgeDock } from "./Nudges";
7
  import SkyCanvas from "./SkyCanvas";
8
+ import UpgradeModal from "./UpgradeModal";
9
  import {
10
  AsteroidIcon,
11
  CometIcon,
 
65
  }
66
 
67
  return (
68
+ <UpgradeProvider>
69
+ <div className={`shell${collapsed ? " collapsed" : ""}`}>
70
+ <SkyCanvas intensity={0.35} />
71
+ <aside className="sidebar">
72
+ <div className="sidebar-top">
73
+ {collapsed ? (
74
+ <button type="button" className="sidebar-brand sidebar-brand-btn" onClick={toggle} aria-label="Expand sidebar" title="Expand sidebar">
75
+ <Logo size={28} />
76
+ </button>
77
+ ) : (
78
+ <NavLink to="/" className="sidebar-brand">
79
+ <Logo size={28} />
80
+ </NavLink>
81
+ )}
82
+ <button
83
+ type="button"
84
+ className="icon-btn sidebar-toggle"
85
+ onClick={toggle}
86
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
87
+ aria-expanded={!collapsed}
88
+ title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
89
+ >
90
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
91
+ <rect x="3.5" y="4.5" width="17" height="15" rx="2.5" />
92
+ <path d="M9 4.5v15" />
93
+ <path d={collapsed ? "M13 10l2 2-2 2" : "M15 10l-2 2 2 2"} />
94
+ </svg>
95
  </button>
96
+ </div>
97
+ <nav aria-label="App">
98
+ {NAV.map(({ to, label, icon: Icon, end }) => (
99
+ <NavLink key={to} to={to} end={end} title={collapsed ? label : undefined} className={({ isActive }) => `side-link${isActive ? " active" : ""}`}>
100
+ <Icon />
101
+ <span className="side-label">{label}</span>
102
+ </NavLink>
103
+ ))}
104
+ </nav>
105
+ <FuelGauge collapsed={collapsed} />
106
+ <div className="sidebar-user">
107
+ <span className="mono muted side-label">{user.email}</span>
108
+ <button className="link-btn" onClick={logout} title="Sign out">
109
+ Sign out
110
+ </button>
111
+ </div>
112
+ </aside>
113
+ <main className="shell-main">
114
+ <Outlet />
115
+ </main>
116
+ </div>
117
+ {/* Outside .shell: its children get position: relative, and these are fixed overlays. */}
118
+ <NudgeDock />
119
+ <UpgradeModal />
120
+ </UpgradeProvider>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  );
122
  }
frontend/src/components/Nudges.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useNavigate } from "react-router-dom";
2
+ import { useUpgrade } from "../hooks/useUpgrade";
3
+ import { CometIcon, RocketIcon, SparkleIcon } from "./icons/Icons";
4
+
5
+ const TONE_ICON = { limit: RocketIcon, upgrade: SparkleIcon, action: CometIcon };
6
+ const TONE_LABEL = { limit: "Fuel check", upgrade: "Unlock", action: "Next mission" };
7
+
8
+ /** One floating card, bottom right: the most useful next step, or a limit warning. */
9
+ export function NudgeDock() {
10
+ const { nudge, dismissNudge, openUpgrade } = useUpgrade();
11
+ const navigate = useNavigate();
12
+ if (!nudge) return null;
13
+ const Icon = TONE_ICON[nudge.tone];
14
+ const go = () => {
15
+ dismissNudge(nudge.id);
16
+ if (nudge.to) navigate(nudge.to);
17
+ else openUpgrade();
18
+ };
19
+ return (
20
+ <aside key={nudge.id} className={`nudge nudge-${nudge.tone}`} role="status" aria-live="polite">
21
+ <span className="nudge-icon">
22
+ <Icon size={20} />
23
+ </span>
24
+ <div className="nudge-body">
25
+ <p className="eyebrow">{TONE_LABEL[nudge.tone]}</p>
26
+ <h3>{nudge.title}</h3>
27
+ <p className="muted small">{nudge.body}</p>
28
+ <div className="row">
29
+ <button className={`btn btn-small ${nudge.tone === "action" ? "" : "btn-primary"}`} onClick={go}>
30
+ {nudge.cta}
31
+ </button>
32
+ <button className="link-btn small" onClick={() => dismissNudge(nudge.id)}>
33
+ Not now
34
+ </button>
35
+ </div>
36
+ </div>
37
+ <button className="icon-btn nudge-close" onClick={() => dismissNudge(nudge.id)} aria-label="Dismiss">
38
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
39
+ <path d="M6 6l12 12M18 6L6 18" />
40
+ </svg>
41
+ </button>
42
+ </aside>
43
+ );
44
+ }
45
+
46
+ /** Sidebar card: plan, monthly meters and the upgrade button. Collapses to a rocket button. */
47
+ export function FuelGauge({ collapsed }: { collapsed: boolean }) {
48
+ const { status, openUpgrade } = useUpgrade();
49
+ if (!status) return null;
50
+ const meters = status.meters.filter((m) => m.key !== "sources");
51
+ const low = meters.some((m) => m.limit >= 0 && m.pct >= 0.8);
52
+ if (collapsed) {
53
+ return status.can_upgrade ? (
54
+ <button className={`icon-btn fuel-mini${low ? " low" : ""}`} onClick={() => openUpgrade()} title="Upgrade" aria-label="Upgrade">
55
+ <RocketIcon />
56
+ </button>
57
+ ) : null;
58
+ }
59
+ return (
60
+ <div className={`fuel${low ? " low" : ""}`}>
61
+ <div className="row between">
62
+ <span className="mono small">{status.plan.name} plan</span>
63
+ {low && status.can_upgrade && <span className="mono small fuel-warn">Low fuel</span>}
64
+ </div>
65
+ {meters.map((m) => (
66
+ <div key={m.key} className="fuel-row" title={`${m.used} / ${m.limit < 0 ? "unlimited" : m.limit} ${m.unit}`}>
67
+ <span className="mono">{m.label}</span>
68
+ <div className="fuel-track">
69
+ <div className={`fuel-fill${m.pct >= 0.8 ? " hot" : ""}`} style={{ width: `${m.limit < 0 ? 4 : Math.max(3, m.pct * 100)}%` }} />
70
+ </div>
71
+ </div>
72
+ ))}
73
+ {status.can_upgrade && (
74
+ <button className="btn btn-primary btn-small fuel-cta" onClick={() => openUpgrade()}>
75
+ <RocketIcon size={16} /> Upgrade now
76
+ </button>
77
+ )}
78
+ </div>
79
+ );
80
+ }
frontend/src/components/PricingTiers.tsx CHANGED
@@ -63,7 +63,7 @@ const FALLBACK: Plan[] = [
63
  },
64
  ];
65
 
66
- function money(n: number): string {
67
  return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
68
  }
69
 
@@ -72,7 +72,7 @@ function to99(amount: number): number {
72
  return amount <= 0 ? 0 : Math.max(0.99, Math.round(amount + 0.01) - 0.01);
73
  }
74
 
75
- function annualMonthly(plan: Plan): number {
76
  return plan.price_annual_monthly_usd ?? to99(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT));
77
  }
78
 
@@ -81,7 +81,7 @@ function savingsPct(plan: Plan): number {
81
  return plan.annual_savings_pct ?? Math.round((1 - annualMonthly(plan) / plan.price_monthly_usd) * 100);
82
  }
83
 
84
- function annualTotal(plan: Plan): number {
85
  return plan.price_annual_usd ?? Math.round(annualMonthly(plan) * 12 * 100) / 100;
86
  }
87
 
@@ -146,7 +146,10 @@ export default function PricingTiers() {
146
  </li>
147
  ))}
148
  </ul>
149
- <Link to="/auth?mode=signup" className={`btn ${featured ? "btn-primary" : ""} tier-cta`}>
 
 
 
150
  {billingEnabled && !free ? `Choose ${plan.name}` : "Start free"}
151
  </Link>
152
  </article>
 
63
  },
64
  ];
65
 
66
+ export function money(n: number): string {
67
  return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
68
  }
69
 
 
72
  return amount <= 0 ? 0 : Math.max(0.99, Math.round(amount + 0.01) - 0.01);
73
  }
74
 
75
+ export function annualMonthly(plan: Plan): number {
76
  return plan.price_annual_monthly_usd ?? to99(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT));
77
  }
78
 
 
81
  return plan.annual_savings_pct ?? Math.round((1 - annualMonthly(plan) / plan.price_monthly_usd) * 100);
82
  }
83
 
84
+ export function annualTotal(plan: Plan): number {
85
  return plan.price_annual_usd ?? Math.round(annualMonthly(plan) * 12 * 100) / 100;
86
  }
87
 
 
146
  </li>
147
  ))}
148
  </ul>
149
+ <Link
150
+ to={billingEnabled && !free ? `/app?upgrade=${plan.id}` : "/auth?mode=signup"}
151
+ className={`btn ${featured ? "btn-primary" : ""} tier-cta`}
152
+ >
153
  {billingEnabled && !free ? `Choose ${plan.name}` : "Start free"}
154
  </Link>
155
  </article>
frontend/src/components/UpgradeModal.tsx ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import type { BillingCycle } from "../api/endpoints";
4
+ import type { Meter, PlanInfo } from "../api/types";
5
+ import { useUpgrade } from "../hooks/useUpgrade";
6
+ import { CheckIcon, RocketIcon } from "./icons/Icons";
7
+ import { annualMonthly, annualTotal, money } from "./PricingTiers";
8
+
9
+ const ORDER = ["free", "writer", "studio"];
10
+
11
+ const LIMIT_TITLES: Record<string, string> = {
12
+ audio_minutes: "Out of audio fuel",
13
+ video_minutes: "Out of render fuel",
14
+ launch_kits: "Launch Kits used up",
15
+ sources: "Your station is full",
16
+ voice_cloning: "Voice cloning is locked",
17
+ };
18
+
19
+ /** What the plan adds over the current one, as short boost chips ("20x audio minutes"). */
20
+ function boosts(plan: PlanInfo, current: PlanInfo): string[] {
21
+ const out: string[] = [];
22
+ const times = (a: number, b: number, label: string) => {
23
+ if (a < 0 && b >= 0) out.push(`Unlimited ${label}`);
24
+ else if (b > 0 && a > b) out.push(`${Math.round(a / b)}x ${label}`);
25
+ };
26
+ if (plan.sources > current.sources) out.push(`${plan.sources} sources`);
27
+ times(plan.audio_minutes, current.audio_minutes, "audio minutes");
28
+ times(plan.video_minutes, current.video_minutes, "video minutes");
29
+ times(plan.launch_kits, current.launch_kits, "Launch Kits");
30
+ if (plan.voice_cloning && !current.voice_cloning) out.push("Voice cloning");
31
+ if (plan.brand_kit && !current.brand_kit) out.push("Brand kit");
32
+ return out;
33
+ }
34
+
35
+ function resetDate(since: string | undefined): string {
36
+ const d = since ? new Date(since) : new Date();
37
+ const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
38
+ return next.toLocaleDateString(undefined, { month: "long", day: "numeric" });
39
+ }
40
+
41
+ function Scene({ mode }: { mode: "explore" | "limit" | "welcome" }) {
42
+ return (
43
+ <div className={`upg-scene upg-${mode}`} aria-hidden="true">
44
+ <div className="upg-stars l1" />
45
+ <div className="upg-stars l2" />
46
+ <div className="upg-stars l3" />
47
+ {mode === "explore" && <div className="upg-warp">{Array.from({ length: 14 }, (_, i) => <span key={i} />)}</div>}
48
+ {mode === "welcome" && <div className="upg-burst">{Array.from({ length: 16 }, (_, i) => <span key={i} />)}</div>}
49
+ <div className="upg-planet-wrap">
50
+ <svg className="upg-planet" viewBox="0 0 200 200">
51
+ <defs>
52
+ <radialGradient id="upg-body" cx="35%" cy="30%" r="75%">
53
+ <stop offset="0%" stopColor="#217cff" stopOpacity="0.95" />
54
+ <stop offset="55%" stopColor="#217cff" stopOpacity="0.35" />
55
+ <stop offset="100%" stopColor="#000" stopOpacity="1" />
56
+ </radialGradient>
57
+ </defs>
58
+ <ellipse cx="100" cy="104" rx="92" ry="20" className="upg-ring back" />
59
+ <circle cx="100" cy="100" r="52" fill="url(#upg-body)" />
60
+ <circle cx="84" cy="86" r="7" className="upg-crater" />
61
+ <circle cx="118" cy="112" r="4.5" className="upg-crater" />
62
+ <circle cx="106" cy="76" r="3" className="upg-crater" />
63
+ <path d="M8 104a92 20 0 0 0 184 0" className="upg-ring front" />
64
+ </svg>
65
+ <span className="upg-rocket">
66
+ <RocketIcon size={22} />
67
+ </span>
68
+ </div>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ function FuelMeter({ meter }: { meter: Meter }) {
74
+ const unlimited = meter.limit < 0;
75
+ return (
76
+ <div className="upg-fuel">
77
+ <div className="row between mono small">
78
+ <span>{meter.label}</span>
79
+ <span className="upg-empty">{meter.pct >= 1 ? "EMPTY" : `${Math.round(meter.pct * 100)}% used`}</span>
80
+ </div>
81
+ <div className="upg-fuel-track">
82
+ <div className="upg-fuel-fill" style={{ width: `${unlimited ? 4 : Math.max(4, meter.pct * 100)}%` }} />
83
+ </div>
84
+ <p className="mono muted small">
85
+ {meter.used} / {unlimited ? "unlimited" : meter.limit} {meter.unit}
86
+ </p>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ export default function UpgradeModal() {
92
+ const { status, modal, close, checkout, busy, error } = useUpgrade();
93
+ const [cycle, setCycle] = useState<BillingCycle>("annual");
94
+ const panel = useRef<HTMLDivElement>(null);
95
+
96
+ useEffect(() => {
97
+ if (!modal) return;
98
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
99
+ document.addEventListener("keydown", onKey);
100
+ panel.current?.focus();
101
+ return () => document.removeEventListener("keydown", onKey);
102
+ }, [modal, close]);
103
+
104
+ if (!modal) return null;
105
+ const current = status?.plan;
106
+ const rank = ORDER.indexOf(current?.id ?? "free");
107
+ let offers = (status?.plans ?? []).filter((p) => ORDER.indexOf(p.id) > rank);
108
+ if (!offers.length && status && !status.billing_enabled) offers = status.plans.filter((p) => p.price_monthly_usd > 0);
109
+ const limit = modal.mode === "limit" ? modal.limit : null;
110
+ const recommended =
111
+ (modal.mode === "explore" && modal.highlight) || limit?.upgrade_to || status?.next_plan || offers[0]?.id;
112
+ const meter = limit?.kind ? status?.meters.find((m) => m.key === limit.kind) : undefined;
113
+ const canBuy = modal.mode !== "welcome" && offers.length > 0 && (status?.can_upgrade || !status?.billing_enabled);
114
+ const title =
115
+ modal.mode === "welcome"
116
+ ? `Welcome to ${current?.name ?? "your new plan"}`
117
+ : limit
118
+ ? LIMIT_TITLES[limit.kind ?? ""] ?? "You are out of fuel"
119
+ : "Explore more with an upgrade";
120
+
121
+ return (
122
+ <div className="upg-backdrop" onMouseDown={(e) => e.target === e.currentTarget && close()}>
123
+ <div className={`upg card${canBuy ? " upg-wide" : ""}`} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} ref={panel}>
124
+ <button className="icon-btn upg-close" onClick={close} aria-label="Close">
125
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
126
+ <path d="M6 6l12 12M18 6L6 18" />
127
+ </svg>
128
+ </button>
129
+ <Scene mode={modal.mode} />
130
+
131
+ <header className="upg-head">
132
+ <p className="eyebrow">
133
+ {modal.mode === "welcome" ? "Orbit reached" : limit ? "Low fuel warning" : "Mission upgrade"}
134
+ </p>
135
+ <h2>{title}</h2>
136
+ <p className="muted">
137
+ {modal.mode === "welcome"
138
+ ? "Your new limits are live. Every source, minute and Launch Kit below is ready to use."
139
+ : limit
140
+ ? `${limit.message} ${canBuy ? "Upgrade and keep flying, or wait" : "Your allowance refills"} on ${resetDate(status?.since)}.`
141
+ : "Your archive has more orbits in it. More sources, more minutes and more Launch Kits, so every post gets a second life."}
142
+ </p>
143
+ {meter && <FuelMeter meter={meter} />}
144
+ </header>
145
+
146
+ {modal.mode === "welcome" && current && (
147
+ <>
148
+ <ul className="upg-features">
149
+ {current.features.map((f) => (
150
+ <li key={f}>
151
+ <CheckIcon size={16} />
152
+ {f}
153
+ </li>
154
+ ))}
155
+ </ul>
156
+ <div className="row end">
157
+ <Link to="/app/launch-kit" className="btn btn-primary" onClick={close}>
158
+ Launch something
159
+ </Link>
160
+ </div>
161
+ </>
162
+ )}
163
+
164
+ {canBuy && current && (
165
+ <>
166
+ <div className="upg-cycle">
167
+ <div className="cycle-toggle" role="radiogroup" aria-label="Billing cycle">
168
+ {(["monthly", "annual"] as const).map((c) => (
169
+ <button key={c} type="button" role="radio" aria-checked={cycle === c} className={cycle === c ? "on" : ""} onClick={() => setCycle(c)}>
170
+ {c === "monthly" ? "Monthly" : "Annual"}
171
+ {c === "annual" && offers[0]?.annual_savings_pct > 0 && <span className="save mono">Save {offers[0].annual_savings_pct}%</span>}
172
+ </button>
173
+ ))}
174
+ </div>
175
+ </div>
176
+ <div className="upg-plans">
177
+ {offers.map((plan) => {
178
+ const pick = plan.id === recommended;
179
+ const price = cycle === "annual" ? annualMonthly(plan) : plan.price_monthly_usd;
180
+ return (
181
+ <article key={plan.id} className={`upg-plan${pick ? " pick" : ""}`}>
182
+ {pick && <span className="badge upg-flag">{limit ? "Refuels you" : "Recommended"}</span>}
183
+ <h3>{plan.name}</h3>
184
+ <p className="muted small">{plan.tagline}</p>
185
+ <p className="upg-price">
186
+ {cycle === "annual" && <span className="tier-was muted">{money(plan.price_monthly_usd)}</span>}
187
+ <span className="tier-amount">{money(price)}</span>
188
+ <span className="muted">/month</span>
189
+ </p>
190
+ <p className="mono muted small">
191
+ {cycle === "annual" ? `${money(annualTotal(plan))} billed yearly` : "Billed monthly"}
192
+ </p>
193
+ <ul className="upg-boosts">
194
+ {boosts(plan, current).map((b) => (
195
+ <li key={b} className="mono">
196
+ {b}
197
+ </li>
198
+ ))}
199
+ </ul>
200
+ <button className={`btn ${pick ? "btn-primary" : ""} upg-cta`} disabled={busy} onClick={() => checkout(plan.id, cycle)}>
201
+ {busy ? "Opening checkout..." : `Launch with ${plan.name}`}
202
+ </button>
203
+ </article>
204
+ );
205
+ })}
206
+ </div>
207
+ </>
208
+ )}
209
+
210
+ {error && <p className="error-text">{error}</p>}
211
+
212
+ {modal.mode !== "welcome" && (
213
+ <footer className="upg-foot mono small muted">
214
+ <span>Cancel anytime. Keep everything you made.</span>
215
+ <span className="row">
216
+ <Link to="/pricing" onClick={close}>
217
+ Compare plans
218
+ </Link>
219
+ <button className="link-btn" onClick={close}>
220
+ {canBuy ? "Maybe later" : "Got it"}
221
+ </button>
222
+ </span>
223
+ </footer>
224
+ )}
225
+ </div>
226
+ </div>
227
+ );
228
+ }
frontend/src/hooks/useUpgrade.tsx ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { useLocation, useNavigate } from "react-router-dom";
3
+ import { PLAN_LIMIT_EVENT, type PlanLimitDetail } from "../api/client";
4
+ import { billingApi, type BillingCycle } from "../api/endpoints";
5
+ import type { BillingStatus, Nudge } from "../api/types";
6
+ import { errorMessage } from "../components/ui";
7
+ import { ONBOARDED_KEY } from "../pages/Welcome";
8
+
9
+ /**
10
+ * The upgrade system: billing status (plan, meters, nudges), the upgrade popup and nudge dismissals.
11
+ * Popup modes: explore (the opening pitch), limit (a request hit a 402 plan_limit), welcome (back from checkout).
12
+ */
13
+
14
+ export type UpgradeModal =
15
+ | { mode: "explore"; highlight?: string | null }
16
+ | { mode: "limit"; limit: PlanLimitDetail }
17
+ | { mode: "welcome" };
18
+
19
+ type UpgradeState = {
20
+ status: BillingStatus | null;
21
+ modal: UpgradeModal | null;
22
+ nudge: Nudge | null;
23
+ openUpgrade: (highlight?: string | null) => void;
24
+ close: () => void;
25
+ refresh: () => Promise<void>;
26
+ dismissNudge: (id: string) => void;
27
+ checkout: (plan: string, cycle: BillingCycle) => Promise<void>;
28
+ manageBilling: () => Promise<void>;
29
+ busy: boolean;
30
+ error: string | null;
31
+ };
32
+
33
+ const SEEN_KEY = "ns_upgrade_seen"; // last time the opening popup showed
34
+ const DISMISSED_KEY = "ns_nudges_dismissed"; // { nudgeId: dismissedAtMs }
35
+ const OPENING_EVERY_MS = 3 * 24 * 3600 * 1000; // opening popup at most every three days
36
+ const NUDGE_SNOOZE_MS = 5 * 24 * 3600 * 1000; // a dismissed nudge stays away for five days
37
+ const OPENING_DELAY_MS = 1400;
38
+ const NUDGE_DELAY_MS = 5000;
39
+ const REFRESH_EVERY_MS = 30_000;
40
+
41
+ function readJson<T>(key: string, fallback: T): T {
42
+ try {
43
+ const raw = localStorage.getItem(key);
44
+ return raw ? (JSON.parse(raw) as T) : fallback;
45
+ } catch {
46
+ return fallback;
47
+ }
48
+ }
49
+
50
+ function writeJson(key: string, value: unknown): void {
51
+ try {
52
+ localStorage.setItem(key, JSON.stringify(value));
53
+ } catch {
54
+ /* private mode: remember for this visit only */
55
+ }
56
+ }
57
+
58
+ const UpgradeContext = createContext<UpgradeState | null>(null);
59
+
60
+ export function UpgradeProvider({ children }: { children: ReactNode }) {
61
+ const [status, setStatus] = useState<BillingStatus | null>(null);
62
+ const [modal, setModal] = useState<UpgradeModal | null>(null);
63
+ const [dismissed, setDismissed] = useState<Record<string, number>>(() => readJson(DISMISSED_KEY, {}));
64
+ const [nudgeReady, setNudgeReady] = useState(false);
65
+ const [busy, setBusy] = useState(false);
66
+ const [error, setError] = useState<string | null>(null);
67
+ const lastFetch = useRef(0);
68
+ const openingDone = useRef(false);
69
+ const openingTimer = useRef<ReturnType<typeof setTimeout>>();
70
+ const location = useLocation();
71
+ const navigate = useNavigate();
72
+
73
+ const refresh = useCallback(async () => {
74
+ lastFetch.current = Date.now();
75
+ try {
76
+ setStatus(await billingApi.status());
77
+ } catch {
78
+ /* the app works without it; nudges just stay hidden */
79
+ }
80
+ }, []);
81
+
82
+ // Fresh meters on navigation (throttled), so nudges follow what the user just did.
83
+ useEffect(() => {
84
+ if (Date.now() - lastFetch.current > REFRESH_EVERY_MS) refresh();
85
+ setNudgeReady(false);
86
+ const t = setTimeout(() => setNudgeReady(true), NUDGE_DELAY_MS);
87
+ return () => clearTimeout(t);
88
+ }, [location.pathname, refresh]);
89
+
90
+ // Any request that hits a plan limit opens the out of fuel popup.
91
+ useEffect(() => {
92
+ const onLimit = (e: Event) => {
93
+ setError(null);
94
+ setModal({ mode: "limit", limit: (e as CustomEvent<PlanLimitDetail>).detail });
95
+ refresh();
96
+ };
97
+ window.addEventListener(PLAN_LIMIT_EVENT, onLimit);
98
+ return () => window.removeEventListener(PLAN_LIMIT_EVENT, onLimit);
99
+ }, [refresh]);
100
+
101
+ // ?upgrade=1 opens the pitch (links from emails, the pricing page); ?checkout=success celebrates.
102
+ useEffect(() => {
103
+ const params = new URLSearchParams(location.search);
104
+ const upgrade = params.get("upgrade");
105
+ const checkoutResult = params.get("checkout");
106
+ if (!upgrade && !checkoutResult) return;
107
+ if (upgrade) setModal({ mode: "explore", highlight: upgrade === "1" ? null : upgrade });
108
+ if (checkoutResult === "success") {
109
+ setModal({ mode: "welcome" });
110
+ refresh();
111
+ }
112
+ params.delete("upgrade");
113
+ params.delete("checkout");
114
+ const rest = params.toString();
115
+ navigate({ pathname: location.pathname, search: rest ? `?${rest}` : "" }, { replace: true });
116
+ openingDone.current = true;
117
+ }, [location.search, location.pathname, navigate, refresh]);
118
+
119
+ // The opening popup: once per few days for workspaces that can upgrade, after onboarding.
120
+ useEffect(() => {
121
+ if (!status || openingDone.current || !status.can_upgrade) return;
122
+ openingDone.current = true;
123
+ const onboarded = readJson<number | string>(ONBOARDED_KEY, 0) || status.meters.some((m) => m.key === "sources" && m.used > 0);
124
+ const seen = readJson<number>(SEEN_KEY, 0);
125
+ if (!onboarded || Date.now() - seen < OPENING_EVERY_MS) return;
126
+ // Not cleared when status refreshes; only on unmount (below), or it would never fire.
127
+ openingTimer.current = setTimeout(() => {
128
+ setModal((m) => m ?? { mode: "explore" });
129
+ writeJson(SEEN_KEY, Date.now());
130
+ }, OPENING_DELAY_MS);
131
+ }, [status]);
132
+
133
+ useEffect(() => () => clearTimeout(openingTimer.current), []);
134
+
135
+ const dismissNudge = useCallback((id: string) => {
136
+ setDismissed((d) => {
137
+ const next = { ...d, [id]: Date.now() };
138
+ writeJson(DISMISSED_KEY, next);
139
+ return next;
140
+ });
141
+ setNudgeReady(false); // the next one waits for the next page
142
+ }, []);
143
+
144
+ const nudge = useMemo(() => {
145
+ if (!status || !nudgeReady || modal) return null;
146
+ const now = Date.now();
147
+ return (
148
+ status.nudges.find(
149
+ (n) => !(dismissed[n.id] && now - dismissed[n.id] < NUDGE_SNOOZE_MS) && n.to !== location.pathname,
150
+ ) ?? null
151
+ );
152
+ }, [status, nudgeReady, modal, dismissed, location.pathname]);
153
+
154
+ const redirectTo = useCallback(async (get: () => Promise<{ url: string }>) => {
155
+ setBusy(true);
156
+ setError(null);
157
+ try {
158
+ window.location.assign((await get()).url);
159
+ } catch (e) {
160
+ setError(errorMessage(e));
161
+ setBusy(false);
162
+ }
163
+ }, []);
164
+
165
+ const value = useMemo<UpgradeState>(
166
+ () => ({
167
+ status,
168
+ modal,
169
+ nudge,
170
+ openUpgrade: (highlight) => {
171
+ setError(null);
172
+ setModal({ mode: "explore", highlight });
173
+ },
174
+ close: () => {
175
+ setModal(null);
176
+ setError(null);
177
+ },
178
+ refresh,
179
+ dismissNudge,
180
+ checkout: (plan, cycle) => redirectTo(() => billingApi.checkout(plan, cycle)),
181
+ manageBilling: () => redirectTo(billingApi.portal),
182
+ busy,
183
+ error,
184
+ }),
185
+ [status, modal, nudge, refresh, dismissNudge, redirectTo, busy, error],
186
+ );
187
+ return <UpgradeContext.Provider value={value}>{children}</UpgradeContext.Provider>;
188
+ }
189
+
190
+ export function useUpgrade(): UpgradeState {
191
+ const ctx = useContext(UpgradeContext);
192
+ if (!ctx) throw new Error("useUpgrade must be used inside UpgradeProvider");
193
+ return ctx;
194
+ }
frontend/src/main.tsx CHANGED
@@ -6,6 +6,7 @@ import { AuthProvider } from "./hooks/useAuth";
6
  import "./styles/global.css";
7
  import "./styles/app.css";
8
  import "./styles/pages.css";
 
9
  import { installClickSounds } from "./lib/sound";
10
 
11
  installClickSounds();
 
6
  import "./styles/global.css";
7
  import "./styles/app.css";
8
  import "./styles/pages.css";
9
+ import "./styles/upgrade.css";
10
  import { installClickSounds } from "./lib/sound";
11
 
12
  installClickSounds();
frontend/src/pages/Settings.tsx CHANGED
@@ -6,6 +6,7 @@ import { settingsApi, type SettingsPatch } from "../api/endpoints";
6
  import type { Settings as SettingsData } from "../api/types";
7
  import { ConfirmButton, errorMessage, Loading, PageHeader } from "../components/ui";
8
  import { useAuth } from "../hooks/useAuth";
 
9
  import { setSoundsEnabled, soundsEnabled } from "../lib/sound";
10
 
11
  function Meter({ label, used, limit, unit }: { label: string; used: number; limit: number; unit: string }) {
@@ -46,6 +47,7 @@ export default function Settings() {
46
  const logoRef = useRef<HTMLInputElement>(null);
47
  const [sounds, setSounds] = useState(soundsEnabled);
48
  const { logout } = useAuth();
 
49
  const navigate = useNavigate();
50
 
51
  const hydrate = (d: SettingsData) => {
@@ -177,6 +179,24 @@ export default function Settings() {
177
  <Meter label="Audio overviews" used={u.used.audio_minutes} limit={u.limits.audio_minutes} unit="min" />
178
  <Meter label="Video renders" used={u.used.video_minutes} limit={u.limits.video_minutes} unit="min" />
179
  <Meter label="Launch Kits" used={u.used.launch_kits} limit={u.limits.launch_kits} unit="" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  <p className="mono muted small">
181
  {s.plan.sources} sources · {s.plan.indexed_posts.toLocaleString()} indexed posts · {u.used.llm_tokens.toLocaleString()} LLM tokens this month
182
  </p>
 
6
  import type { Settings as SettingsData } from "../api/types";
7
  import { ConfirmButton, errorMessage, Loading, PageHeader } from "../components/ui";
8
  import { useAuth } from "../hooks/useAuth";
9
+ import { useUpgrade } from "../hooks/useUpgrade";
10
  import { setSoundsEnabled, soundsEnabled } from "../lib/sound";
11
 
12
  function Meter({ label, used, limit, unit }: { label: string; used: number; limit: number; unit: string }) {
 
47
  const logoRef = useRef<HTMLInputElement>(null);
48
  const [sounds, setSounds] = useState(soundsEnabled);
49
  const { logout } = useAuth();
50
+ const upgrade = useUpgrade();
51
  const navigate = useNavigate();
52
 
53
  const hydrate = (d: SettingsData) => {
 
179
  <Meter label="Audio overviews" used={u.used.audio_minutes} limit={u.limits.audio_minutes} unit="min" />
180
  <Meter label="Video renders" used={u.used.video_minutes} limit={u.limits.video_minutes} unit="min" />
181
  <Meter label="Launch Kits" used={u.used.launch_kits} limit={u.limits.launch_kits} unit="" />
182
+ <p className="mono muted small">
183
+ Refills on the 1st of every month.
184
+ </p>
185
+ {s.billing_enabled && (
186
+ <div className="row">
187
+ {upgrade.status?.can_upgrade && (
188
+ <button className="btn btn-primary btn-small" onClick={() => upgrade.openUpgrade()}>
189
+ Upgrade now
190
+ </button>
191
+ )}
192
+ {upgrade.status?.has_billing_account && (
193
+ <button className="btn btn-small" disabled={upgrade.busy} onClick={() => upgrade.manageBilling()}>
194
+ Manage billing
195
+ </button>
196
+ )}
197
+ </div>
198
+ )}
199
+ {upgrade.error && !upgrade.modal && <p className="error-text">{upgrade.error}</p>}
200
  <p className="mono muted small">
201
  {s.plan.sources} sources · {s.plan.indexed_posts.toLocaleString()} indexed posts · {u.used.llm_tokens.toLocaleString()} LLM tokens this month
202
  </p>
frontend/src/styles/upgrade.css ADDED
@@ -0,0 +1,603 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Upgrade system: the space popup, the nudge dock and the sidebar fuel gauge.
2
+ Strict palette: black, #217cff, white. */
3
+
4
+ /* ---------- Popup ---------- */
5
+ .upg-backdrop {
6
+ position: fixed;
7
+ inset: 0;
8
+ z-index: 60;
9
+ display: grid;
10
+ place-items: center;
11
+ padding: 16px;
12
+ overflow: auto;
13
+ background: rgba(0, 0, 0, 0.8);
14
+ backdrop-filter: blur(6px);
15
+ animation: upg-fade 0.25s ease both;
16
+ }
17
+
18
+ .upg {
19
+ position: relative;
20
+ width: min(540px, 100%);
21
+ max-height: calc(100vh - 32px);
22
+ overflow: auto;
23
+ display: grid;
24
+ gap: 18px;
25
+ padding: 0 22px 20px;
26
+ background: var(--black);
27
+ border-color: var(--blue-35);
28
+ box-shadow: var(--glow-lg), 0 0 0 1px var(--blue-10) inset;
29
+ animation: upg-rise 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both;
30
+ }
31
+
32
+ .upg:focus {
33
+ outline: none;
34
+ }
35
+
36
+ .upg-wide {
37
+ width: min(800px, 100%);
38
+ }
39
+
40
+ .upg-close {
41
+ position: absolute;
42
+ top: 12px;
43
+ right: 12px;
44
+ z-index: 3;
45
+ background: rgba(0, 0, 0, 0.5);
46
+ }
47
+
48
+ .upg-head {
49
+ display: grid;
50
+ gap: 8px;
51
+ text-align: center;
52
+ justify-items: center;
53
+ }
54
+
55
+ .upg-head h2 {
56
+ font-family: var(--font-display);
57
+ font-size: clamp(1.5rem, 4vw, 2.1rem);
58
+ line-height: 1.1;
59
+ margin: 0;
60
+ }
61
+
62
+ .upg-head > p.muted {
63
+ max-width: 52ch;
64
+ margin: 0;
65
+ }
66
+
67
+ /* Scene: drifting stars, a ringed planet and a rocket on orbit */
68
+ .upg-scene {
69
+ position: relative;
70
+ height: 190px;
71
+ margin: 0 -22px;
72
+ overflow: hidden;
73
+ background:
74
+ radial-gradient(60% 90% at 50% 100%, var(--blue-20), transparent 70%),
75
+ radial-gradient(40% 60% at 50% 60%, var(--blue-10), transparent 70%),
76
+ var(--black);
77
+ border-bottom: 1px solid var(--line);
78
+ mask-image: linear-gradient(to bottom, #000 70%, transparent);
79
+ }
80
+
81
+ .upg-stars {
82
+ position: absolute;
83
+ inset: -50% 0 0;
84
+ background-repeat: repeat;
85
+ animation: upg-drift linear infinite;
86
+ }
87
+
88
+ .upg-stars.l1 {
89
+ background-image:
90
+ radial-gradient(1px 1px at 12px 18px, rgba(255, 255, 255, 0.7), transparent),
91
+ radial-gradient(1px 1px at 70px 90px, rgba(255, 255, 255, 0.5), transparent),
92
+ radial-gradient(1px 1px at 130px 40px, rgba(255, 255, 255, 0.6), transparent);
93
+ background-size: 160px 120px;
94
+ animation-duration: 40s;
95
+ }
96
+
97
+ .upg-stars.l2 {
98
+ background-image:
99
+ radial-gradient(1.5px 1.5px at 40px 60px, rgba(255, 255, 255, 0.85), transparent),
100
+ radial-gradient(1.5px 1.5px at 190px 20px, rgba(33, 124, 255, 0.9), transparent);
101
+ background-size: 240px 150px;
102
+ animation-duration: 24s;
103
+ }
104
+
105
+ .upg-stars.l3 {
106
+ background-image:
107
+ radial-gradient(2px 2px at 80px 30px, #fff, transparent),
108
+ radial-gradient(2px 2px at 260px 110px, rgba(33, 124, 255, 1), transparent);
109
+ background-size: 340px 180px;
110
+ animation-duration: 14s;
111
+ }
112
+
113
+ .upg-planet-wrap {
114
+ position: absolute;
115
+ left: 50%;
116
+ top: 54%;
117
+ width: 170px;
118
+ height: 170px;
119
+ transform: translate(-50%, -50%);
120
+ }
121
+
122
+ .upg-planet {
123
+ width: 100%;
124
+ height: 100%;
125
+ filter: drop-shadow(0 0 24px var(--blue-35));
126
+ animation: upg-bob 6s ease-in-out infinite;
127
+ }
128
+
129
+ .upg-ring {
130
+ fill: none;
131
+ stroke: rgba(255, 255, 255, 0.55);
132
+ stroke-width: 1.5;
133
+ }
134
+
135
+ .upg-ring.back {
136
+ stroke: rgba(255, 255, 255, 0.2);
137
+ }
138
+
139
+ .upg-crater {
140
+ fill: rgba(0, 0, 0, 0.35);
141
+ stroke: rgba(255, 255, 255, 0.15);
142
+ }
143
+
144
+ /* The rocket flies an ellipse around the planet (the ring's plane). */
145
+ .upg-rocket {
146
+ position: absolute;
147
+ top: 0;
148
+ left: 0;
149
+ display: grid;
150
+ place-items: center;
151
+ width: 26px;
152
+ height: 26px;
153
+ color: var(--white);
154
+ filter: drop-shadow(0 0 6px var(--blue));
155
+ offset-path: path("M -20 92 a 105 32 0 1 0 210 0 a 105 32 0 1 0 -210 0");
156
+ offset-rotate: 0deg;
157
+ animation: upg-fly 9s linear infinite;
158
+ }
159
+
160
+ /* Explore: warp streaks racing past */
161
+ .upg-warp span {
162
+ position: absolute;
163
+ top: 50%;
164
+ left: 50%;
165
+ width: 90px;
166
+ height: 1px;
167
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.8));
168
+ transform-origin: 0 50%;
169
+ opacity: 0;
170
+ animation: upg-warp 2.6s ease-in infinite;
171
+ }
172
+
173
+ .upg-warp span:nth-child(odd) {
174
+ background: linear-gradient(90deg, transparent, var(--blue));
175
+ }
176
+
177
+ .upg-warp span:nth-child(1) { --a: 8deg; animation-delay: 0s; }
178
+ .upg-warp span:nth-child(2) { --a: 34deg; animation-delay: 0.3s; }
179
+ .upg-warp span:nth-child(3) { --a: 61deg; animation-delay: 1.1s; }
180
+ .upg-warp span:nth-child(4) { --a: 97deg; animation-delay: 0.7s; }
181
+ .upg-warp span:nth-child(5) { --a: 128deg; animation-delay: 1.6s; }
182
+ .upg-warp span:nth-child(6) { --a: 152deg; animation-delay: 0.2s; }
183
+ .upg-warp span:nth-child(7) { --a: 181deg; animation-delay: 1.3s; }
184
+ .upg-warp span:nth-child(8) { --a: 206deg; animation-delay: 0.5s; }
185
+ .upg-warp span:nth-child(9) { --a: 233deg; animation-delay: 1.9s; }
186
+ .upg-warp span:nth-child(10) { --a: 262deg; animation-delay: 0.9s; }
187
+ .upg-warp span:nth-child(11) { --a: 289deg; animation-delay: 2.2s; }
188
+ .upg-warp span:nth-child(12) { --a: 312deg; animation-delay: 0.4s; }
189
+ .upg-warp span:nth-child(13) { --a: 337deg; animation-delay: 1.4s; }
190
+ .upg-warp span:nth-child(14) { --a: 355deg; animation-delay: 2s; }
191
+
192
+ /* Limit: the rocket sputters, the planet dims */
193
+ .upg-limit .upg-planet {
194
+ filter: drop-shadow(0 0 10px var(--blue-20)) saturate(0.6);
195
+ }
196
+
197
+ .upg-limit .upg-rocket {
198
+ animation:
199
+ upg-fly 28s linear infinite,
200
+ upg-sputter 1.4s steps(2, jump-none) infinite;
201
+ }
202
+
203
+ /* Welcome: a burst of sparkles */
204
+ .upg-burst span {
205
+ position: absolute;
206
+ top: 54%;
207
+ left: 50%;
208
+ width: 6px;
209
+ height: 6px;
210
+ margin: -3px;
211
+ border-radius: 50%;
212
+ background: var(--white);
213
+ box-shadow: 0 0 10px var(--blue);
214
+ animation: upg-burst 1.6s cubic-bezier(0.1, 0.7, 0.2, 1) both;
215
+ }
216
+
217
+ .upg-burst span:nth-child(3n) {
218
+ background: var(--blue);
219
+ }
220
+
221
+ .upg-burst span:nth-child(1) { --a: 0deg; }
222
+ .upg-burst span:nth-child(2) { --a: 22deg; animation-delay: 0.05s; }
223
+ .upg-burst span:nth-child(3) { --a: 45deg; }
224
+ .upg-burst span:nth-child(4) { --a: 67deg; animation-delay: 0.1s; }
225
+ .upg-burst span:nth-child(5) { --a: 90deg; }
226
+ .upg-burst span:nth-child(6) { --a: 112deg; animation-delay: 0.05s; }
227
+ .upg-burst span:nth-child(7) { --a: 135deg; }
228
+ .upg-burst span:nth-child(8) { --a: 157deg; animation-delay: 0.1s; }
229
+ .upg-burst span:nth-child(9) { --a: 180deg; }
230
+ .upg-burst span:nth-child(10) { --a: 202deg; animation-delay: 0.05s; }
231
+ .upg-burst span:nth-child(11) { --a: 225deg; }
232
+ .upg-burst span:nth-child(12) { --a: 247deg; animation-delay: 0.1s; }
233
+ .upg-burst span:nth-child(13) { --a: 270deg; }
234
+ .upg-burst span:nth-child(14) { --a: 292deg; animation-delay: 0.05s; }
235
+ .upg-burst span:nth-child(15) { --a: 315deg; }
236
+ .upg-burst span:nth-child(16) { --a: 337deg; animation-delay: 0.1s; }
237
+
238
+ /* Fuel meter (limit mode) */
239
+ .upg-fuel {
240
+ width: min(360px, 100%);
241
+ display: grid;
242
+ gap: 6px;
243
+ margin-top: 6px;
244
+ text-align: left;
245
+ }
246
+
247
+ .upg-fuel p {
248
+ margin: 0;
249
+ }
250
+
251
+ .upg-empty {
252
+ color: var(--blue);
253
+ animation: upg-blink 1.2s steps(2, jump-none) infinite;
254
+ }
255
+
256
+ .upg-fuel-track {
257
+ height: 8px;
258
+ border-radius: 999px;
259
+ background: var(--surface-2);
260
+ overflow: hidden;
261
+ }
262
+
263
+ .upg-fuel-fill {
264
+ height: 100%;
265
+ border-radius: inherit;
266
+ background: linear-gradient(90deg, var(--blue-60), var(--blue));
267
+ box-shadow: var(--glow-sm);
268
+ }
269
+
270
+ /* Plans */
271
+ .upg-cycle {
272
+ display: flex;
273
+ justify-content: center;
274
+ }
275
+
276
+ .upg-plans {
277
+ display: grid;
278
+ gap: 14px;
279
+ }
280
+
281
+ @media (min-width: 640px) {
282
+ .upg-plans {
283
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
284
+ }
285
+ }
286
+
287
+ .upg-plan {
288
+ position: relative;
289
+ display: grid;
290
+ align-content: start;
291
+ gap: 6px;
292
+ padding: 18px;
293
+ border: 1px solid var(--line-strong);
294
+ border-radius: var(--radius);
295
+ background: var(--surface);
296
+ }
297
+
298
+ .upg-plan.pick {
299
+ border-color: var(--blue);
300
+ background: linear-gradient(180deg, var(--blue-10), transparent 60%);
301
+ box-shadow: var(--glow-md);
302
+ }
303
+
304
+ .upg-plan h3 {
305
+ margin: 0;
306
+ font-family: var(--font-display);
307
+ font-size: 1.25rem;
308
+ }
309
+
310
+ .upg-plan p {
311
+ margin: 0;
312
+ }
313
+
314
+ .upg-flag {
315
+ position: absolute;
316
+ top: -11px;
317
+ right: 14px;
318
+ background: var(--blue);
319
+ border-color: var(--blue);
320
+ }
321
+
322
+ .upg-price {
323
+ display: flex;
324
+ align-items: baseline;
325
+ gap: 4px;
326
+ margin-top: 6px !important;
327
+ }
328
+
329
+ .upg-price .tier-amount {
330
+ font-family: var(--font-display);
331
+ font-size: 2rem;
332
+ font-weight: 600;
333
+ }
334
+
335
+ .upg-boosts {
336
+ display: flex;
337
+ flex-wrap: wrap;
338
+ gap: 6px;
339
+ margin: 8px 0 12px;
340
+ padding: 0;
341
+ list-style: none;
342
+ }
343
+
344
+ .upg-boosts li {
345
+ padding: 3px 9px;
346
+ border: 1px solid var(--blue-35);
347
+ border-radius: 999px;
348
+ background: var(--blue-05);
349
+ font-size: 0.72rem;
350
+ }
351
+
352
+ .upg-cta {
353
+ width: 100%;
354
+ justify-content: center;
355
+ margin-top: auto;
356
+ }
357
+
358
+ .upg-features {
359
+ display: grid;
360
+ gap: 8px;
361
+ margin: 0;
362
+ padding: 0;
363
+ list-style: none;
364
+ }
365
+
366
+ .upg-features li {
367
+ display: flex;
368
+ gap: 10px;
369
+ align-items: center;
370
+ }
371
+
372
+ .upg-features .icon {
373
+ color: var(--blue);
374
+ flex: none;
375
+ }
376
+
377
+ .upg-foot {
378
+ display: flex;
379
+ flex-wrap: wrap;
380
+ justify-content: space-between;
381
+ align-items: center;
382
+ gap: 10px;
383
+ padding-top: 12px;
384
+ border-top: 1px solid var(--line);
385
+ }
386
+
387
+ .upg-foot .row {
388
+ gap: 16px;
389
+ }
390
+
391
+ .upg-foot a {
392
+ color: var(--white);
393
+ }
394
+
395
+ /* ---------- Nudge dock ---------- */
396
+ .nudge {
397
+ position: fixed;
398
+ right: 16px;
399
+ bottom: 16px;
400
+ left: 16px;
401
+ z-index: 40;
402
+ display: flex;
403
+ gap: 12px;
404
+ padding: 14px 36px 14px 14px;
405
+ border: 1px solid var(--line-strong);
406
+ border-radius: var(--radius);
407
+ background: rgba(0, 0, 0, 0.92);
408
+ backdrop-filter: blur(10px);
409
+ box-shadow: var(--glow-lg);
410
+ animation: nudge-in 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
411
+ }
412
+
413
+ @media (min-width: 640px) {
414
+ .nudge {
415
+ left: auto;
416
+ width: 360px;
417
+ right: 24px;
418
+ bottom: 24px;
419
+ }
420
+ }
421
+
422
+ .nudge-limit,
423
+ .nudge-upgrade {
424
+ border-color: var(--blue-60);
425
+ }
426
+
427
+ .nudge-icon {
428
+ flex: none;
429
+ display: grid;
430
+ place-items: center;
431
+ width: 38px;
432
+ height: 38px;
433
+ border-radius: 50%;
434
+ border: 1px solid var(--blue-35);
435
+ background: var(--blue-10);
436
+ color: var(--white);
437
+ }
438
+
439
+ .nudge-limit .nudge-icon {
440
+ background: var(--blue);
441
+ box-shadow: var(--glow-sm);
442
+ animation: pulse-ring 2s ease-out infinite;
443
+ }
444
+
445
+ .nudge-body {
446
+ display: grid;
447
+ gap: 6px;
448
+ min-width: 0;
449
+ }
450
+
451
+ .nudge-body h3 {
452
+ margin: 0;
453
+ font-size: 1rem;
454
+ }
455
+
456
+ .nudge-body p {
457
+ margin: 0;
458
+ }
459
+
460
+ .nudge-body .eyebrow {
461
+ font-size: 0.65rem;
462
+ }
463
+
464
+ .nudge-body .row {
465
+ margin-top: 4px;
466
+ gap: 14px;
467
+ }
468
+
469
+ .nudge-close {
470
+ position: absolute;
471
+ top: 8px;
472
+ right: 8px;
473
+ width: 26px;
474
+ height: 26px;
475
+ }
476
+
477
+ /* ---------- Sidebar fuel gauge ---------- */
478
+ .fuel,
479
+ .fuel-mini {
480
+ display: none;
481
+ }
482
+
483
+ @media (min-width: 900px) {
484
+ .fuel {
485
+ display: grid;
486
+ gap: 8px;
487
+ margin-top: auto;
488
+ padding: 12px;
489
+ border: 1px solid var(--line);
490
+ border-radius: var(--radius);
491
+ background: var(--surface);
492
+ }
493
+
494
+ .fuel + .sidebar-user {
495
+ margin-top: 0;
496
+ }
497
+
498
+ .fuel.low {
499
+ border-color: var(--blue-35);
500
+ box-shadow: var(--glow-sm);
501
+ }
502
+
503
+ .fuel-warn {
504
+ color: var(--blue);
505
+ }
506
+
507
+ .fuel-row {
508
+ display: grid;
509
+ gap: 4px;
510
+ font-size: 0.7rem;
511
+ color: var(--text-2);
512
+ }
513
+
514
+ .fuel-track {
515
+ height: 4px;
516
+ border-radius: 999px;
517
+ background: var(--surface-2);
518
+ overflow: hidden;
519
+ }
520
+
521
+ .fuel-fill {
522
+ height: 100%;
523
+ border-radius: inherit;
524
+ background: var(--blue-60);
525
+ }
526
+
527
+ .fuel-fill.hot {
528
+ background: var(--blue);
529
+ box-shadow: var(--glow-sm);
530
+ }
531
+
532
+ .fuel-cta {
533
+ width: 100%;
534
+ justify-content: center;
535
+ gap: 6px;
536
+ margin-top: 4px;
537
+ }
538
+
539
+ .shell.collapsed .fuel {
540
+ display: none;
541
+ }
542
+
543
+ .shell.collapsed .fuel-mini {
544
+ display: inline-grid;
545
+ margin-top: auto;
546
+ color: var(--white);
547
+ border-color: var(--blue-35);
548
+ }
549
+
550
+ .fuel-mini.low {
551
+ background: var(--blue);
552
+ box-shadow: var(--glow-sm);
553
+ }
554
+ }
555
+
556
+ /* ---------- Motion ---------- */
557
+ @keyframes upg-fade {
558
+ from { opacity: 0; }
559
+ }
560
+
561
+ @keyframes upg-rise {
562
+ from { opacity: 0; transform: translateY(24px) scale(0.97); }
563
+ }
564
+
565
+ @keyframes upg-drift {
566
+ to { transform: translateY(50%); }
567
+ }
568
+
569
+ @keyframes upg-bob {
570
+ 50% { transform: translateY(-5px); }
571
+ }
572
+
573
+ @keyframes upg-fly {
574
+ to { offset-distance: 100%; }
575
+ }
576
+
577
+ @keyframes upg-warp {
578
+ 0% { opacity: 0; transform: rotate(var(--a)) translateX(10px) scaleX(0.2); }
579
+ 30% { opacity: 1; }
580
+ 100% { opacity: 0; transform: rotate(var(--a)) translateX(360px) scaleX(1.4); }
581
+ }
582
+
583
+ @keyframes upg-burst {
584
+ 0% { opacity: 1; transform: rotate(var(--a)) translateX(0) scale(1.4); }
585
+ 100% { opacity: 0; transform: rotate(var(--a)) translateX(150px) scale(0.3); }
586
+ }
587
+
588
+ @keyframes upg-sputter {
589
+ 50% { opacity: 0.35; }
590
+ }
591
+
592
+ @keyframes upg-blink {
593
+ 50% { opacity: 0.3; }
594
+ }
595
+
596
+ @keyframes nudge-in {
597
+ from { opacity: 0; transform: translateY(20px); }
598
+ }
599
+
600
+ @keyframes pulse-ring {
601
+ 0% { box-shadow: 0 0 0 0 var(--blue-60); }
602
+ 100% { box-shadow: 0 0 0 12px transparent; }
603
+ }
frontend/tsconfig.tsbuildinfo CHANGED
@@ -1 +1 @@
1
- {"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
 
1
+ {"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/nudges.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/upgrademodal.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/hooks/useupgrade.tsx","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
renderer/public/demo-vo/durations.json CHANGED
@@ -1,10 +1,10 @@
1
  {
2
- "warp": 3.69,
3
- "paste": 8.34,
4
- "orbit": 5.96,
5
- "research": 4.39,
6
- "audio": 4.57,
7
- "launchkit": 5.83,
8
- "launchpad": 3.14,
9
- "outro": 2.56
10
  }
 
1
  {
2
+ "warp": 3.92,
3
+ "paste": 6.95,
4
+ "orbit": 6.61,
5
+ "research": 3.74,
6
+ "audio": 2.67,
7
+ "launchkit": 3.32,
8
+ "launchpad": 4.05,
9
+ "outro": 2.48
10
  }
renderer/public/launch-vo/durations.json CHANGED
@@ -1,14 +1,14 @@
1
  {
2
- "intro": 5.78,
3
- "launch": 3.03,
4
- "sources": 6.14,
5
  "research": 4.16,
6
- "map": 6.12,
7
- "voice": 4.57,
8
- "audio": 3.69,
9
- "video": 4.44,
10
- "launchkit": 4.7,
11
- "launchpad": 3.32,
12
- "resurface": 4.23,
13
- "outro": 5.17
14
  }
 
1
  {
2
+ "intro": 5.83,
3
+ "launch": 2.9,
4
+ "sources": 6.38,
5
  "research": 4.16,
6
+ "map": 4.47,
7
+ "voice": 3.56,
8
+ "audio": 3.19,
9
+ "video": 5.88,
10
+ "launchkit": 3.69,
11
+ "launchpad": 4.94,
12
+ "resurface": 4.44,
13
+ "outro": 6.3
14
  }
renderer/scripts/make_demo_vo.py CHANGED
@@ -1,8 +1,8 @@
1
  """Narration for the landing page demo (Nora Vale, Mission Control). Writes public/demo-vo/*.mp3 + durations.json.
2
 
3
- The screens show the product; the voice says what it feels like. Then copy the printed durations
4
- into VO_SECONDS in src/compositions/LandingDemo.tsx (and lengthen a scene if its line no longer fits).
5
- make_demo_music.py writes the 47.5 s score; rerun it with a larger TOTAL if the demo grows past that.
6
 
7
  cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_demo_vo.py
8
  """
@@ -17,13 +17,13 @@ OUT = Path(__file__).resolve().parents[1] / "public" / "demo-vo"
17
  OUT.mkdir(parents=True, exist_ok=True)
18
 
19
  LINES = {
20
- "warp": "Captain's log. Your knowledge... is now in orbit.",
21
- "paste": "Just point us at your writing. Any blog, any site, any folder of notes. Then sit back.",
22
- "orbit": "Watch years of work come home. Every idea you've had, finally within reach.",
23
- "research": "Ask, and hear your own best thinking answer back. With proof.",
24
- "audio": "Hear your ideas as a conversation. Like meeting your work for the first time.",
25
- "launchkit": "No more blank page. Next week's posts are already here, and they sound like you.",
26
- "launchpad": "Press schedule, and feel the weight lift off.",
27
  "outro": "Notestack. Clear skies ahead.",
28
  }
29
 
@@ -39,4 +39,8 @@ for i, key in enumerate(keys):
39
  (OUT / f"{key}.mp3").write_bytes(clip)
40
  durations[key] = round(tts.mp3_seconds(clip), 2)
41
  (OUT / "durations.json").write_text(json.dumps(durations, indent=2))
 
 
 
 
42
  print(json.dumps(durations))
 
1
  """Narration for the landing page demo (Nora Vale, Mission Control). Writes public/demo-vo/*.mp3 + durations.json.
2
 
3
+ The screens show the product; the voice never explains it, it only says how it feels: exploring,
4
+ excited, mesmerized. Also writes src/compositions/demoVo.ts; scenes stretch to fit their lines.
5
+ Then run make_demo_music.py, which reads the same file to size the score.
6
 
7
  cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_demo_vo.py
8
  """
 
17
  OUT.mkdir(parents=True, exist_ok=True)
18
 
19
  LINES = {
20
+ "warp": "Captain's log. Something wonderful is on the horizon.",
21
+ "paste": "Take a breath. That flutter of excitement? That's how every great expedition begins.",
22
+ "orbit": "And there it is. Everything you've made, shimmering... impossible to look away.",
23
+ "research": "A question sparks, and suddenly you're wide awake.",
24
+ "audio": "Lean back. Let it wash over you.",
25
+ "launchkit": "Your pulse quickens. You can't wait to share this.",
26
+ "launchpad": "Three, two, one... and you're flying.",
27
  "outro": "Notestack. Clear skies ahead.",
28
  }
29
 
 
39
  (OUT / f"{key}.mp3").write_bytes(clip)
40
  durations[key] = round(tts.mp3_seconds(clip), 2)
41
  (OUT / "durations.json").write_text(json.dumps(durations, indent=2))
42
+ (Path(__file__).resolve().parents[1] / "src" / "compositions" / "demoVo.ts").write_text(
43
+ "// Generated by scripts/make_demo_vo.py: narration clip lengths in seconds. Do not edit by hand.\n"
44
+ f"export const DEMO_VO_SECONDS = {json.dumps(durations)} as const;\n"
45
+ )
46
  print(json.dumps(durations))
renderer/scripts/make_launch_vo.py CHANGED
@@ -1,6 +1,6 @@
1
  """Narration for the launch trailer (Nora Vale, Mission Control). Writes public/launch-vo/*.mp3 + durations.json.
2
 
3
- The screens show what each feature does; the voice carries what it feels like to use it.
4
 
5
  Run from backend/ (uses its ElevenLabs client and .env), then run make_launch_music.py:
6
  cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_launch_vo.py
@@ -16,18 +16,18 @@ OUT = Path(__file__).resolve().parents[1] / "public" / "launch-vo"
16
  OUT.mkdir(parents=True, exist_ok=True)
17
 
18
  LINES = {
19
- "intro": "You have written more than you remember. Whole worlds of ideas... still uncharted.",
20
- "launch": "Take a breath. We're going exploring.",
21
- "sources": "Watch years of your writing drift back into view. Nothing lost. Nothing forgotten.",
22
- "research": "Ask a question, and hear your own best thinking answer back.",
23
- "map": "See the shape of your mind. The ideas you keep coming home to, and the ones still waiting.",
24
- "voice": "Everything it makes sounds like you. On your best day.",
25
- "audio": "Hear your ideas come alive, as a conversation.",
26
- "video": "Watch your words move, glow, and find new eyes.",
27
- "launchkit": "One post. A whole week of posts. And never a blank page.",
28
- "launchpad": "Press schedule, and feel the weight lift off.",
29
- "resurface": "And the gems you forgot you wrote? They find their way back to you.",
30
- "outro": "Notestack A.I. The galaxies of your untapped potential. Coming soon.",
31
  }
32
 
33
  voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
 
1
  """Narration for the launch trailer (Nora Vale, Mission Control). Writes public/launch-vo/*.mp3 + durations.json.
2
 
3
+ The voice never explains the product. It only says how the journey feels: exploring, excited, mesmerized.
4
 
5
  Run from backend/ (uses its ElevenLabs client and .env), then run make_launch_music.py:
6
  cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_launch_vo.py
 
16
  OUT.mkdir(parents=True, exist_ok=True)
17
 
18
  LINES = {
19
+ "intro": "There's a feeling you get at the edge of something vast. Curious. A little breathless.",
20
+ "launch": "Hold on. Here we go.",
21
+ "sources": "Everything you've ever made, glowing back at you. Like seeing Earth from orbit for the first time.",
22
+ "research": "That spark, when a thought you'd forgotten lights up again.",
23
+ "map": "You drift through your own ideas... and you can't look away.",
24
+ "voice": "It feels like you. Only braver.",
25
+ "audio": "Lean back. Let it wash over you.",
26
+ "video": "Color. Motion. Wonder. You'll want to watch it twice.",
27
+ "launchkit": "Your heart races a little. The good kind of nervous.",
28
+ "launchpad": "Three, two, one... and you're flying.",
29
+ "resurface": "And then, out of the dark, an old favorite shines again.",
30
+ "outro": "Notestack A.I. Come explore the galaxies of your untapped potential. Coming soon.",
31
  }
32
 
33
  voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
renderer/src/compositions/LaunchTrailer.tsx CHANGED
@@ -1,7 +1,8 @@
1
  import type { CSSProperties, ReactNode } from "react";
2
- import { AbsoluteFill, Audio as SoundTrack, Easing, Img, interpolate, random, Sequence, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
3
  import { theme } from "../theme";
4
  import { Flare, WarpField } from "./LandingDemo";
 
5
  import { LAUNCH_BPM, LAUNCH_SCENES, LAUNCH_SECONDS, type LaunchSceneId } from "./launchTimeline";
6
 
7
  /** Launch trailer: a journey of knowledge through the galaxies of untapped potential.
@@ -137,15 +138,15 @@ function typed(text: string, frame: number, start: number, cps = 30): string {
137
  // ---------------------------------------------------------------- journey HUD
138
 
139
  const STOPS: { id: LaunchSceneId; short: string; name: string; line: string }[] = [
140
- { id: "sources", short: "Sources", name: "The Source Nebula", line: "Any blog, newsletter, website or markdown, pulled into orbit." },
141
- { id: "research", short: "Research", name: "The Research Cluster", line: "Answers cited to the exact lines you wrote." },
142
- { id: "map", short: "Topic map", name: "The Constellation Field", line: "See which ideas are rising, and which went dark." },
143
- { id: "voice", short: "Voice", name: "The Voice Pulsar", line: "Everything it makes still sounds like you." },
144
- { id: "audio", short: "Audio", name: "The Audio Belt", line: "Two host audio overviews of any notebook." },
145
- { id: "video", short: "Studio", name: "The Studio Galaxy", line: "Shorts, explainers, audiograms and quote cards." },
146
- { id: "launchkit", short: "Launch Kit", name: "The Launch Kit Spiral", line: "Threads, LinkedIn, Notes, SEO and carousels." },
147
- { id: "launchpad", short: "Launchpad", name: "The Launchpad", line: "Schedule and auto post to X, LinkedIn and Bluesky." },
148
- { id: "resurface", short: "Resurface", name: "The Evergreen Belt", line: "Your best old posts, back in orbit." },
149
  ];
150
 
151
  function Hud({ index }: { index: number }) {
@@ -158,8 +159,8 @@ function Hud({ index }: { index: number }) {
158
  const px = (i: number) => x0 + ((x1 - x0) * i) / (STOPS.length - 1);
159
  return (
160
  <AbsoluteFill style={{ opacity: inn }}>
161
- <div style={{ position: "absolute", left: 80, top: 56, fontFamily: theme.mono, fontSize: 20, letterSpacing: 5, color: "rgba(255,255,255,0.6)" }}>
162
- NOTESTACK EXPEDITION
163
  </div>
164
  <div style={{ position: "absolute", right: 80, top: 56, fontFamily: theme.mono, fontSize: 20, letterSpacing: 5, color: BLUE }}>
165
  GALAXY {String(index + 1).padStart(2, "0")} / {String(STOPS.length).padStart(2, "0")}
@@ -200,8 +201,8 @@ function Stop({ id, children }: { id: LaunchSceneId; children: ReactNode }) {
200
  const visual = useIn(10, 17);
201
  return (
202
  <Space drift={1.4}>
203
- <div style={{ position: "absolute", inset: 0, transform: `scale(${approach})`, transformOrigin: "1260px 520px" }}>
204
- <Galaxy seed={`g-${id}`} x={1260} y={520} size={430} tilt={0.42 + random(`t-${id}`) * 0.3} arms={2 + (index % 2)} coreBoost={pulse * 0.25} />
205
  </div>
206
  <AbsoluteFill style={{ background: "linear-gradient(90deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.55) 38%, transparent 62%)" }} />
207
  <div style={{ position: "absolute", left: 120, top: 250, width: 700 }}>
@@ -225,6 +226,66 @@ function Stop({ id, children }: { id: LaunchSceneId; children: ReactNode }) {
225
 
226
  // ---------------------------------------------------------------- feature visuals (900 x 660 box)
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  function SourcesVisual() {
229
  const frame = useCurrentFrame();
230
  const kinds = ["Blog", "Newsletter", "Website", "Markdown", "PDF", "Substack", "Ghost", "WordPress"];
@@ -236,9 +297,9 @@ function SourcesVisual() {
236
  <svg width={900} height={660} style={{ position: "absolute", overflow: "visible" }}>
237
  <ellipse cx={cx} cy={cy} rx={330} ry={130} fill="none" stroke={`${BLUE}88`} strokeWidth={2} strokeDasharray="4 10" />
238
  <ellipse cx={cx} cy={cy} rx={220} ry={86} fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth={1.5} />
239
- <circle cx={cx} cy={cy} r={74} fill="#000" stroke={BLUE} strokeWidth={3} style={{ filter: `drop-shadow(0 0 30px ${BLUE})` }} />
240
- <circle cx={cx} cy={cy} r={40} fill={WHITE} opacity={0.9} style={{ filter: `drop-shadow(0 0 24px ${WHITE})` }} />
241
  </svg>
 
 
242
  {kinds.map((k, i) => {
243
  const arrive = interpolate(frame, [12 + i * 9, 50 + i * 9], [0, 1], { ...clamp, easing: ease });
244
  const ring = i % 2 ? 220 : 330;
@@ -545,10 +606,10 @@ function Intro() {
545
  </div>
546
  <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 28 }}>
547
  <h1 style={{ margin: 0, fontFamily: theme.display, fontWeight: 600, fontSize: 80, color: WHITE, opacity: a, transform: `translateY(${(1 - a) * 30}px)` }}>
548
- You have written more than you remember.
549
  </h1>
550
  <h2 style={{ margin: 0, fontFamily: theme.display, fontWeight: 500, fontSize: 64, color: "rgba(255,255,255,0.75)", opacity: b, transform: `translateY(${(1 - b) * 24}px)` }}>
551
- Whole worlds of ideas, <span style={{ color: WHITE, textShadow: `0 0 30px ${BLUE}, 0 0 70px ${BLUE}` }}>still uncharted.</span>
552
  </h2>
553
  <p style={{ margin: "30px 0 0", fontFamily: theme.mono, fontSize: 24, letterSpacing: 8, color: BLUE, opacity: c }}>A JOURNEY OF KNOWLEDGE</p>
554
  </AbsoluteFill>
@@ -628,10 +689,9 @@ function Outro() {
628
  </div>
629
  <AbsoluteFill style={{ background: "radial-gradient(45% 45% at 50% 50%, rgba(0,0,0,0.55), transparent 80%)" }} />
630
  <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center" }}>
631
- {/* The navbar mark (frontend/public/logo.svg, copied to public/logo.svg). */}
632
- <Img src={staticFile("logo.svg")} width={144} height={144} style={{ borderRadius: 32, opacity: logo, transform: `scale(${logo})`, boxShadow: `0 0 90px ${BLUE}, 0 0 30px ${WHITE}` }} /> <h1 style={{ margin: "34px 0 0", fontFamily: theme.display, fontWeight: 700, fontSize: 150, lineHeight: 1, color: WHITE, opacity: name, transform: `translateY(${(1 - name) * 30}px)`, textShadow: `0 0 50px ${BLUE}` }}>
633
- Notestack <span style={{ color: BLUE, textShadow: `0 0 30px ${BLUE}` }}>AI</span>
634
- </h1>
635
  <div style={{ marginTop: 44, display: "flex", gap: 6 }}>
636
  {letters.map((ch, i) => {
637
  const s = spring({ frame: frame - S(3.2) - i * 2, fps: LAUNCH_FPS, config: { damping: 11 } });
 
1
  import type { CSSProperties, ReactNode } from "react";
2
+ import { AbsoluteFill, Audio as SoundTrack, Easing, interpolate, random, Sequence, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
3
  import { theme } from "../theme";
4
  import { Flare, WarpField } from "./LandingDemo";
5
+ import { Lockup } from "./Lockup";
6
  import { LAUNCH_BPM, LAUNCH_SCENES, LAUNCH_SECONDS, type LaunchSceneId } from "./launchTimeline";
7
 
8
  /** Launch trailer: a journey of knowledge through the galaxies of untapped potential.
 
138
  // ---------------------------------------------------------------- journey HUD
139
 
140
  const STOPS: { id: LaunchSceneId; short: string; name: string; line: string }[] = [
141
+ { id: "sources", short: "Sources", name: "The Source Nebula", line: "Everything you made, glowing back at you." },
142
+ { id: "research", short: "Research", name: "The Research Cluster", line: "The spark of a thought you forgot you had." },
143
+ { id: "map", short: "Topic map", name: "The Constellation Field", line: "Drift through your own ideas. Stay a while." },
144
+ { id: "voice", short: "Voice", name: "The Voice Pulsar", line: "It feels like you. Only braver." },
145
+ { id: "audio", short: "Audio", name: "The Audio Belt", line: "Lean back. Let it wash over you." },
146
+ { id: "video", short: "Studio", name: "The Studio Galaxy", line: "Color, motion, wonder. Watch it twice." },
147
+ { id: "launchkit", short: "Launch Kit", name: "The Launch Kit Spiral", line: "Your heart races. The good kind of nervous." },
148
+ { id: "launchpad", short: "Launchpad", name: "The Launchpad", line: "Three, two, one, and you are flying." },
149
+ { id: "resurface", short: "Resurface", name: "The Evergreen Belt", line: "An old favorite, shining again." },
150
  ];
151
 
152
  function Hud({ index }: { index: number }) {
 
159
  const px = (i: number) => x0 + ((x1 - x0) * i) / (STOPS.length - 1);
160
  return (
161
  <AbsoluteFill style={{ opacity: inn }}>
162
+ <div style={{ position: "absolute", left: 80, top: 44 }}>
163
+ <Lockup size={44} glow={0.6} />
164
  </div>
165
  <div style={{ position: "absolute", right: 80, top: 56, fontFamily: theme.mono, fontSize: 20, letterSpacing: 5, color: BLUE }}>
166
  GALAXY {String(index + 1).padStart(2, "0")} / {String(STOPS.length).padStart(2, "0")}
 
201
  const visual = useIn(10, 17);
202
  return (
203
  <Space drift={1.4}>
204
+ <div style={{ position: "absolute", inset: 0, transform: `scale(${approach})`, transformOrigin: "1350px 520px" }}>
205
+ <Galaxy seed={`g-${id}`} x={1350} y={520} size={430} tilt={0.42 + random(`t-${id}`) * 0.3} arms={2 + (index % 2)} coreBoost={pulse * 0.25} />
206
  </div>
207
  <AbsoluteFill style={{ background: "linear-gradient(90deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.55) 38%, transparent 62%)" }} />
208
  <div style={{ position: "absolute", left: 120, top: 250, width: 700 }}>
 
226
 
227
  // ---------------------------------------------------------------- feature visuals (900 x 660 box)
228
 
229
+ /** A ringed planet, like the one in the logo: lit sphere, atmosphere, and a tilted ring whose far
230
+ * side passes behind the body and near side in front. Drawn in its own layer above the orbits. */
231
+ function Planet({ cx, cy, r }: { cx: number; cy: number; r: number }) {
232
+ const frame = useCurrentFrame();
233
+ const tilt = -18;
234
+ const rx = r * 1.75;
235
+ const ry = r * 0.36;
236
+ const shimmer = 0.75 + 0.25 * Math.sin(frame * 0.08);
237
+ const ring = (half: "back" | "front") => (
238
+ <path
239
+ d={half === "back" ? `M ${-rx} 0 A ${rx} ${ry} 0 0 1 ${rx} 0` : `M ${rx} 0 A ${rx} ${ry} 0 0 1 ${-rx} 0`}
240
+ fill="none"
241
+ stroke={half === "back" ? "#9cc4ff" : WHITE}
242
+ strokeOpacity={half === "back" ? 0.45 : 0.95}
243
+ strokeWidth={r * 0.1}
244
+ strokeLinecap="round"
245
+ />
246
+ );
247
+ return (
248
+ <svg width={900} height={660} style={{ position: "absolute", left: 0, top: 0, overflow: "visible", zIndex: 1 }}>
249
+ <defs>
250
+ <radialGradient id="planet-body" cx="36%" cy="32%" r="75%">
251
+ <stop offset="0%" stopColor="#ffffff" />
252
+ <stop offset="14%" stopColor="#bcd8ff" />
253
+ <stop offset="42%" stopColor={BLUE} />
254
+ <stop offset="78%" stopColor="#0b2f75" />
255
+ <stop offset="100%" stopColor="#020a1c" />
256
+ </radialGradient>
257
+ <radialGradient id="planet-air" cx="50%" cy="50%" r="50%">
258
+ <stop offset="78%" stopColor={BLUE} stopOpacity={0} />
259
+ <stop offset="90%" stopColor={BLUE} stopOpacity={0.55} />
260
+ <stop offset="100%" stopColor={BLUE} stopOpacity={0} />
261
+ </radialGradient>
262
+ <clipPath id="planet-clip">
263
+ <circle r={r} />
264
+ </clipPath>
265
+ </defs>
266
+ <g transform={`translate(${cx} ${cy})`}>
267
+ <circle r={r * 2.2} fill={BLUE} opacity={0.12 * shimmer} />
268
+ <g transform={`rotate(${tilt})`}>{ring("back")}</g>
269
+ <circle r={r} fill="url(#planet-body)" />
270
+ {/* Soft cloud bands, clipped to the sphere, drifting slowly. */}
271
+ <g clipPath="url(#planet-clip)" opacity={0.22}>
272
+ {[-0.45, -0.1, 0.3].map((y, i) => (
273
+ <ellipse key={i} cx={((frame * 0.6 + i * 40) % (r * 2)) - r} cy={y * r} rx={r * 1.1} ry={r * 0.09} fill={WHITE} />
274
+ ))}
275
+ </g>
276
+ {/* Night side. */}
277
+ <circle r={r} fill="url(#planet-shadow)" />
278
+ <radialGradient id="planet-shadow" cx="78%" cy="80%" r="70%">
279
+ <stop offset="0%" stopColor="#000" stopOpacity={0.55} />
280
+ <stop offset="100%" stopColor="#000" stopOpacity={0} />
281
+ </radialGradient>
282
+ <circle r={r * 1.14} fill="url(#planet-air)" />
283
+ <g transform={`rotate(${tilt})`}>{ring("front")}</g>
284
+ </g>
285
+ </svg>
286
+ );
287
+ }
288
+
289
  function SourcesVisual() {
290
  const frame = useCurrentFrame();
291
  const kinds = ["Blog", "Newsletter", "Website", "Markdown", "PDF", "Substack", "Ghost", "WordPress"];
 
297
  <svg width={900} height={660} style={{ position: "absolute", overflow: "visible" }}>
298
  <ellipse cx={cx} cy={cy} rx={330} ry={130} fill="none" stroke={`${BLUE}88`} strokeWidth={2} strokeDasharray="4 10" />
299
  <ellipse cx={cx} cy={cy} rx={220} ry={86} fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth={1.5} />
 
 
300
  </svg>
301
+ <Planet cx={cx} cy={cy} r={82} />
302
+
303
  {kinds.map((k, i) => {
304
  const arrive = interpolate(frame, [12 + i * 9, 50 + i * 9], [0, 1], { ...clamp, easing: ease });
305
  const ring = i % 2 ? 220 : 330;
 
606
  </div>
607
  <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 28 }}>
608
  <h1 style={{ margin: 0, fontFamily: theme.display, fontWeight: 600, fontSize: 80, color: WHITE, opacity: a, transform: `translateY(${(1 - a) * 30}px)` }}>
609
+ At the edge of something vast.
610
  </h1>
611
  <h2 style={{ margin: 0, fontFamily: theme.display, fontWeight: 500, fontSize: 64, color: "rgba(255,255,255,0.75)", opacity: b, transform: `translateY(${(1 - b) * 24}px)` }}>
612
+ Curious. <span style={{ color: WHITE, textShadow: `0 0 30px ${BLUE}, 0 0 70px ${BLUE}` }}>A little breathless.</span>
613
  </h2>
614
  <p style={{ margin: "30px 0 0", fontFamily: theme.mono, fontSize: 24, letterSpacing: 8, color: BLUE, opacity: c }}>A JOURNEY OF KNOWLEDGE</p>
615
  </AbsoluteFill>
 
689
  </div>
690
  <AbsoluteFill style={{ background: "radial-gradient(45% 45% at 50% 50%, rgba(0,0,0,0.55), transparent 80%)" }} />
691
  <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center" }}>
692
+ <div style={{ opacity: Math.min(1, logo), transform: `scale(${0.85 + 0.15 * logo}) translateY(${(1 - name) * 20}px)` }}>
693
+ <Lockup size={176} suffix="AI" glow={1 + pulse * 0.3} />
694
+ </div>
 
695
  <div style={{ marginTop: 44, display: "flex", gap: 6 }}>
696
  {letters.map((ch, i) => {
697
  const s = spring({ frame: frame - S(3.2) - i * 2, fps: LAUNCH_FPS, config: { damping: 11 } });
renderer/src/compositions/Lockup.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Img, staticFile } from "remotion";
2
+ import { theme } from "../theme";
3
+
4
+ /** The navbar logo: the mark (public/logo.svg, copied from frontend/public/logo.svg) with the
5
+ * "Notestack" wordmark beside it, in the same proportions as the site (32 px mark, 10 px gap,
6
+ * Space Grotesk 600 at 18 px). `suffix` adds a word after it in blue, such as "AI". */
7
+ export function Lockup({ size, suffix, glow = 1 }: { size: number; suffix?: string; glow?: number }) {
8
+ const text = (size * 18) / 32;
9
+ return (
10
+ <div style={{ display: "inline-flex", alignItems: "center", gap: (size * 10) / 32 }}>
11
+ <Img
12
+ src={staticFile("logo.svg")}
13
+ width={size}
14
+ height={size}
15
+ style={{ borderRadius: (size * 9) / 32, boxShadow: `0 0 ${size * 0.6 * glow}px ${theme.blue}, 0 0 ${size * 0.2 * glow}px ${theme.white}` }}
16
+ />
17
+ <span style={{ fontFamily: theme.display, fontWeight: 600, fontSize: text, letterSpacing: "0.01em", lineHeight: 1, color: theme.white, textShadow: glow ? `0 0 ${text * 0.5 * glow}px ${theme.blue}` : undefined }}>
18
+ Notestack
19
+ {suffix && <span style={{ color: theme.blue, marginLeft: text * 0.28 }}>{suffix}</span>}
20
+ </span>
21
+ </div>
22
+ );
23
+ }
renderer/src/compositions/demoVo.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ // Generated by scripts/make_demo_vo.py: narration clip lengths in seconds. Do not edit by hand.
2
+ export const DEMO_VO_SECONDS = {"warp": 3.92, "paste": 6.95, "orbit": 6.61, "research": 3.74, "audio": 2.67, "launchkit": 3.32, "launchpad": 4.05, "outro": 2.48} as const;
renderer/src/compositions/launchTimeline.ts CHANGED
@@ -1,20 +1,20 @@
1
  // Generated by make_launch_music.py from public/launch-vo/durations.json. Do not edit by hand.
2
  // 120 bpm; every scene starts on a bar line so cuts land on the beat.
3
  export const LAUNCH_BPM = 120;
4
- export const LAUNCH_SECONDS = 84.0;
5
  export const LAUNCH_SCENES = [
6
- { id: "intro", start: 0.0, len: 8.0, vo: 1.0, voLen: 5.78 },
7
- { id: "launch", start: 8.0, len: 4.0, vo: 0.35, voLen: 3.03 },
8
- { id: "sources", start: 12.0, len: 8.0, vo: 0.35, voLen: 6.14 },
9
  { id: "research", start: 20.0, len: 6.0, vo: 0.35, voLen: 4.16 },
10
- { id: "map", start: 26.0, len: 8.0, vo: 0.35, voLen: 6.12 },
11
- { id: "voice", start: 34.0, len: 6.0, vo: 0.35, voLen: 4.57 },
12
- { id: "audio", start: 40.0, len: 6.0, vo: 0.35, voLen: 3.69 },
13
- { id: "video", start: 46.0, len: 6.0, vo: 0.35, voLen: 4.44 },
14
- { id: "launchkit", start: 52.0, len: 6.0, vo: 0.35, voLen: 4.7 },
15
- { id: "launchpad", start: 58.0, len: 6.0, vo: 0.35, voLen: 3.32 },
16
- { id: "resurface", start: 64.0, len: 6.0, vo: 0.35, voLen: 4.23 },
17
- { id: "montage", start: 70.0, len: 4.0, vo: null, voLen: null },
18
- { id: "outro", start: 74.0, len: 10.0, vo: 1.2, voLen: 5.17 },
19
  ] as const;
20
  export type LaunchSceneId = (typeof LAUNCH_SCENES)[number]["id"];
 
1
  // Generated by make_launch_music.py from public/launch-vo/durations.json. Do not edit by hand.
2
  // 120 bpm; every scene starts on a bar line so cuts land on the beat.
3
  export const LAUNCH_BPM = 120;
4
+ export const LAUNCH_SECONDS = 82.0;
5
  export const LAUNCH_SCENES = [
6
+ { id: "intro", start: 0.0, len: 8.0, vo: 1.0, voLen: 5.83 },
7
+ { id: "launch", start: 8.0, len: 4.0, vo: 0.35, voLen: 2.9 },
8
+ { id: "sources", start: 12.0, len: 8.0, vo: 0.35, voLen: 6.38 },
9
  { id: "research", start: 20.0, len: 6.0, vo: 0.35, voLen: 4.16 },
10
+ { id: "map", start: 26.0, len: 6.0, vo: 0.35, voLen: 4.47 },
11
+ { id: "voice", start: 32.0, len: 6.0, vo: 0.35, voLen: 3.56 },
12
+ { id: "audio", start: 38.0, len: 4.0, vo: 0.35, voLen: 3.19 },
13
+ { id: "video", start: 42.0, len: 8.0, vo: 0.35, voLen: 5.88 },
14
+ { id: "launchkit", start: 50.0, len: 6.0, vo: 0.35, voLen: 3.69 },
15
+ { id: "launchpad", start: 56.0, len: 6.0, vo: 0.35, voLen: 4.94 },
16
+ { id: "resurface", start: 62.0, len: 6.0, vo: 0.35, voLen: 4.44 },
17
+ { id: "montage", start: 68.0, len: 4.0, vo: null, voLen: null },
18
+ { id: "outro", start: 72.0, len: 10.0, vo: 1.2, voLen: 6.3 },
19
  ] as const;
20
  export type LaunchSceneId = (typeof LAUNCH_SCENES)[number]["id"];