// iPhone onboarding — fully interactive 8-step funnel.
// Shared chrome + step bodies. All controls wired to parent state via props.
const M = window.MS_TOKENS;
const { useState, useEffect, useRef } = React;
// -----------------------------------------------------------------------------
// Shared libraries (exported so App can derive defaults/labels)
// -----------------------------------------------------------------------------
const TOPICS_LIBRARY = [
"AI", "Longevity", "Health", "Geopolitics", "Research", "Nutrition",
"Exercise", "Markets", "Biotech", "Climate", "Space", "Policy",
];
const SOURCES_LIBRARY = [
{ id: "ft", name: "Financial Times", mark: "FT", bg: "#FFF1E5", fg: "#000" },
{ id: "econ", name: "The Economist", mark: "TE", bg: "#E3120B", fg: "#fff" },
{ id: "nature", name: "Nature", mark: "N", bg: "#006633", fg: "#fff" },
{ id: "wiki", name: "Wikipedia", mark: "W", bg: "#fff", fg: "#111" },
{ id: "arxiv", name: "arXiv", mark: "aX", bg: "#b31b1b", fg: "#fff" },
{ id: "reuters", name: "Reuters", mark: "R", bg: "#FA6400", fg: "#fff" },
{ id: "nejm", name: "NEJM", mark: "NE", bg: "#cc0000", fg: "#fff" },
{ id: "bloomb", name: "Bloomberg", mark: "B", bg: "#000", fg: "#fff" },
{ id: "mittr", name: "MIT TR", mark: "MIT", bg: "#A41F35", fg: "#fff" },
{ id: "guard", name: "Guardian", mark: "G", bg: "#052962", fg: "#fff" },
{ id: "strat", name: "Stratechery", mark: "ST", bg: "#1a1a1a", fg: "#fff" },
{ id: "semafor", name: "Semafor", mark: "Sm", bg: "#1f1f1f", fg: "#fff" },
];
const DEFAULT_SOURCE_IDS = ["ft", "econ", "nature", "wiki", "reuters", "mittr"];
const STYLES_LIBRARY = [
{ id: "original", name: "Original", italic: "Signal" },
{ id: "editorial", name: "Editorial" },
{ id: "developer", name: "Developer" },
{ id: "research", name: "Research" },
{ id: "custom", name: "Custom", locked: true },
];
const HOURS = [4, 5, 6, 7, 8, 9, 10];
const MINUTES = [0, 15, 30, 45];
const DAY_LABELS = ["M", "T", "W", "T", "F", "S", "S"];
const DAY_NAMES_FULL = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTH_ABBR = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const pad2 = (n) => String(n).padStart(2, "0");
// -----------------------------------------------------------------------------
// Shared chrome
// -----------------------------------------------------------------------------
function MScreen({ step, total = 8, children, scroll = true, dark = false }) {
const bg = dark ? M.ink : M.paper;
const fg = dark ? M.paper : M.ink;
return (
{!dark &&
}
{pad2(step)} / {pad2(total)}
{Array.from({ length: total }).map((_, i) => (
))}
{children}
);
}
function MKicker({ num, children }) {
return (
{num && {num} }
{children}
);
}
function MHed({ children, italic, size = 32 }) {
return (
{children}{italic && {italic} }
);
}
function MDeck({ children }) {
return {children}
;
}
function MBtn({ children, primary = true, arrow = true, amber, onClick, disabled }) {
const bg = disabled ? M.rule2 : (amber ? M.amber : (primary ? M.ink : "transparent"));
const fg = disabled ? M.mid : (amber ? M.ink : (primary ? M.paper : M.ink));
return (
{children}
{arrow && → }
);
}
// =============================================================================
// 01 · Entry — collect email + choose Default or Custom
// =============================================================================
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function MStep01({ email, onSetEmail, onPick }) {
const valid = EMAIL_RE.test(email || "");
const tryPick = (mode) => {
if (!valid) return;
onPick(mode);
};
return (
How we begin
Where to send your
Your inbox is your account. Change anything later by reply.
▸ Step one · your email
{valid ? "✓ READY" : "REQUIRED"}
onSetEmail(e.target.value)}
placeholder="you@example.com"
style={{
width: "100%", marginTop: 10, padding: "10px 0",
fontFamily: M.display, fontSize: 24, letterSpacing: -0.5,
border: "none", borderBottom: `2px solid ${valid ? M.ink : M.amber}`,
background: "transparent", outline: "none", color: M.ink,
transition: "border-color 200ms",
}}
/>
{valid
? "Looks good. Pick a path below — we'll send your first edition to this address."
: "We'll send your full briefing here. Reply to any edition to change anything."}
— then choose how —
tryPick("default")}
style={{
margin: "8px 20px 0", border: `1px solid ${M.rule}`, background: M.paper2,
padding: 18, cursor: valid ? "pointer" : "not-allowed", opacity: valid ? 1 : 0.5,
}}>
RECOMMENDED
60 SEC
The Default
Our editor's curation. Daily at 07:00.
▸ 6 trusted sources ▸ Original style ▸ Editable any time
{ e.stopPropagation(); tryPick("default"); }}>Use the Default
tryPick("custom")}
style={{
margin: "12px 20px 24px", border: `1px solid ${M.ink}`, background: M.ink, color: M.paper,
padding: 18, cursor: valid ? "pointer" : "not-allowed", opacity: valid ? 1 : 0.55,
}}>
~ 3 MIN
Custom
Shape it yourself — topics, sources, style, cadence.
{ e.stopPropagation(); tryPick("custom"); }}
style={{
marginTop: 14, width: "100%", background: "transparent", color: M.paper,
border: `1px solid ${M.paper}`, padding: "14px 18px",
fontFamily: M.display, fontSize: 16, fontWeight: 700,
cursor: valid ? "pointer" : "not-allowed",
display: "flex", justifyContent: "space-between", alignItems: "center",
}}>
Start Custom
→
);
}
// =============================================================================
// 02 · Topics — text input + voice + library chips
// =============================================================================
function MStep02({ topics, onAddTopic, onRemoveTopic }) {
const [draft, setDraft] = useState("");
const [recording, setRecording] = useState(false);
const recogRef = useRef(null);
const inputRef = useRef(null);
const commit = () => {
const t = draft.trim();
if (t && !topics.includes(t)) onAddTopic(t);
setDraft("");
};
const toggleVoice = () => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
alert("Voice input not supported in this browser. Try Chrome or Safari on iOS.");
inputRef.current?.focus();
return;
}
if (recording) {
recogRef.current?.stop();
setRecording(false);
return;
}
const r = new SR();
r.lang = "en-US";
r.interimResults = true;
r.continuous = false;
r.onresult = (e) => {
const txt = Array.from(e.results).map(x => x[0].transcript).join(" ");
setDraft(txt);
};
r.onend = () => setRecording(false);
r.onerror = () => setRecording(false);
recogRef.current = r;
setRecording(true);
r.start();
};
return (
What you want to know
Write it,
Three inputs, one canvas. Use any or all.
▸ Your topics · {topics.length}
{topics.length === 0 && (
Nothing yet. Type, speak, or pick below.
)}
{topics.map(t => (
onRemoveTopic(t)}
style={{
display: "inline-flex", alignItems: "center", gap: 5,
background: M.ink, color: M.paper, padding: "6px 12px",
fontFamily: M.display, fontSize: 13, fontWeight: 700, borderRadius: 999,
cursor: "pointer", userSelect: "none",
}}>
{t}×
))}
›
setDraft(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") commit(); }}
placeholder="Type a topic…"
style={{
flex: 1, fontFamily: M.display, fontSize: 18, letterSpacing: -0.4,
borderBottom: `1.5px solid ${M.ink}`, paddingBottom: 3,
border: "none", borderBottomWidth: 1.5, borderBottomStyle: "solid", borderBottomColor: M.ink,
background: "transparent", outline: "none", color: M.ink, minWidth: 0,
}}
/>
Add
{recording && (
● LISTENING · tap to stop
)}
— or start from the library —
{TOPICS_LIBRARY.map(t => {
const sel = topics.includes(t);
return (
sel ? onRemoveTopic(t) : onAddTopic(t)}
style={{
padding: "6px 12px", borderRadius: 999,
fontFamily: M.display, fontSize: 13, cursor: "pointer", userSelect: "none",
border: `1px solid ${sel ? M.ink : M.rule}`,
background: sel ? M.ink : "transparent",
color: sel ? M.paper : M.ink,
fontWeight: sel ? 700 : 400,
}}>
{t}{sel && ✓ }
);
})}
);
}
// =============================================================================
// 03 · Sources
// =============================================================================
function MStep03({ sources, onToggleSource, onPickForMe }) {
const selCount = sources.length;
const coverage = selCount === 0 ? "—" : selCount < 3 ? "Narrow" : selCount < 7 ? "Broad" : "Very broad";
const trust = selCount === 0 ? "—" : selCount < 3 ? "B" : selCount < 6 ? "A" : "A+";
const desc = (() => {
if (selCount === 0) return "pick at least one";
const tags = new Set();
sources.forEach(id => {
if (["ft","econ","bloomb"].includes(id)) tags.add("fin");
if (["nature","arxiv","nejm","mittr"].includes(id)) tags.add("research");
if (["reuters","guard","semafor"].includes(id)) tags.add("news");
if (["wiki","strat"].includes(id)) tags.add("analysis");
});
return Array.from(tags).slice(0, 2).join(" · ") || "mixed";
})();
return (
Whom you trust
The voices that
Pick a few, pick many, or let us choose.
{SOURCES_LIBRARY.map(s => {
const sel = sources.includes(s.id);
return (
onToggleSource(s.id)}
style={{
display: "inline-flex", alignItems: "center", gap: 7, padding: "5px 12px 5px 5px",
background: sel ? M.ink : M.paper, color: sel ? M.paper : M.ink,
border: `1px solid ${sel ? M.ink : M.rule}`, borderRadius: 999,
fontFamily: M.display, fontSize: 12.5, fontWeight: sel ? 700 : 400,
cursor: "pointer", userSelect: "none",
}}>
{s.mark}
{s.name}
{sel && ✓ }
);
})}
✦ Select for me
{[
["SELECTED", String(selCount), `of ${SOURCES_LIBRARY.length}`],
["COVERAGE", coverage, desc],
["TRUST", trust, "weighted"],
].map(([l,v,s]) => (
))}
);
}
// -----------------------------------------------------------------------------
// Tiny style previews (unchanged visual)
// -----------------------------------------------------------------------------
function OriginalMini() {
return (
▸ VOL 01 · 022 ● LIVE
Fusion's quiet breakthrough
› net-energy confirmed at NIF
› read ▸ Nature · 14 min
);
}
function EditorialMini() {
return (
The Signal · Thursday
Fusion Crosses Threshold
a correspondent's note
Livermore reported a third net-energy shot this week, marking the clearest sign yet that…
);
}
function DeveloperMini() {
return (
# signal.md
## Fusion breakthrough
> net-energy × 3 at NIF
- [x] confirmed
- [ ] commercial path
[source](nature.com) 14m
$ next ▸
);
}
function ResearchMini() {
return (
Brief · 24 Apr 2026
Fusion net-energy, repeated
— 3rd successful shot[1]
— 3.15 MJ yield[2]
— peer review pending
[1] Nature [2] LLNL brief
);
}
function LockedMini() {
return (
◐
Your style
Premium · soon
);
}
const STYLE_MINIS = {
original: ,
editorial: ,
developer: ,
research: ,
custom: ,
};
// =============================================================================
// 04 · Style (tap-to-select carousel)
// =============================================================================
function MStep04({ style, onPickStyle }) {
return (
How it looks in your inbox
The form of intelligence
Same signal. Four voices. Tap to select.
{STYLES_LIBRARY.map((s, i) => {
const sel = style === s.id;
return (
{ if (!s.locked) onPickStyle(s.id); }}
style={{
minWidth: 220, marginLeft: i === 0 ? 20 : 0, marginRight: i === STYLES_LIBRARY.length - 1 ? 20 : 0,
border: `1.5px solid ${sel ? M.ink : M.rule}`, background: M.paper2,
outline: sel ? `3px solid ${M.amber}` : "none", outlineOffset: 2,
scrollSnapAlign: "start", position: "relative",
opacity: s.locked ? 0.7 : 1,
cursor: s.locked ? "not-allowed" : "pointer",
flexShrink: 0,
}}>
{s.locked && (
◐ PREMIUM
)}
{STYLE_MINIS[s.id]}
{s.name}{s.italic && {s.italic} }
{sel &&
✓ SELECTED
}
);
})}
{STYLES_LIBRARY.map(s => (
))}
);
}
// =============================================================================
// 05 · Frequency — segmented + day grid + live preview
// =============================================================================
function computeNextEditions(days, hour, minute, count = 4) {
if (!days || days.length === 0) return [];
const out = [];
const now = new Date();
const start = new Date(now);
start.setSeconds(0, 0);
for (let off = 0; out.length < count && off < 30; off++) {
const d = new Date(start);
d.setDate(d.getDate() + off);
const dow = (d.getDay() + 6) % 7;
if (!days.includes(dow)) continue;
d.setHours(hour, minute, 0, 0);
if (off === 0 && d <= now) continue;
out.push(d);
}
return out;
}
function MStep05({ frequency, days, hour, minute, onSetFrequency, onToggleDay }) {
const editions = computeNextEditions(days, hour, minute);
const sendCount = days.length;
const weekdays = days.every(d => d < 5) && sendCount === 5;
const desc = sendCount === 0 ? "no sends yet"
: sendCount === 7 ? "every day"
: weekdays ? "weekends off"
: `${sendCount} send${sendCount === 1 ? "" : "s"}/wk`;
return (
Your rhythm
Daily, or once
Tap a segment; toggle days below.
{["daily", "weekly"].map(f => {
const sel = frequency === f;
return (
onSetFrequency(f)}
style={{
flex: 1,
background: sel ? M.ink : "transparent",
color: sel ? M.paper : M.mid,
padding: "12px 0", textAlign: "center",
fontFamily: M.display, fontSize: 16, fontWeight: sel ? 700 : 400,
cursor: "pointer", userSelect: "none",
textTransform: "capitalize",
}}>
{f} {sel && ✓ }
);
})}
{DAY_LABELS.map((label, i) => {
const sel = days.includes(i);
const today = new Date();
const dowToday = (today.getDay() + 6) % 7;
const offset = (i - dowToday + 7) % 7;
const date = new Date(today);
date.setDate(today.getDate() + offset);
return (
onToggleDay(i)}
style={{
aspectRatio: "1/1.1", cursor: "pointer", userSelect: "none",
background: sel ? M.ink : M.paper,
color: sel ? M.paper : M.ink,
border: `1px solid ${sel ? M.ink : M.rule}`,
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
}}>
{label}
{date.getDate()}
{sel ? "SEND" : "—"}
);
})}
▸ {sendCount} send{sendCount === 1 ? "" : "s"} · {desc}
▸ Next {editions.length} edition{editions.length === 1 ? "" : "s"}
{editions.length === 0 ? (
Select at least one day.
) : (
{editions.map(d => {
const dow = (d.getDay() + 6) % 7;
const label = `${DAY_NAMES_FULL[dow]} ${d.getDate()} ${MONTH_ABBR[d.getMonth()]}`;
const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
return (
{label}
{time}
);
})}
)}
);
}
// =============================================================================
// 06 · Time — tap-to-select hour + minute
// =============================================================================
function MStep06({ hour, minute, evening, onSetHour, onSetMinute, onToggleEvening }) {
return (
When it arrives
The hour it
Tap to choose. One send, or add an evening digest.
{pad2(hour)}: {pad2(minute)}
local · {Intl.DateTimeFormat().resolvedOptions().timeZone}
{HOURS.map(h => {
const sel = h === hour;
return (
onSetHour(h)}
style={{
padding: "8px 0", textAlign: "center", cursor: "pointer", userSelect: "none",
fontFamily: M.display, fontSize: sel ? 30 : 20,
fontWeight: sel ? 700 : 400,
color: sel ? M.ink : M.mid,
background: sel ? `rgba(184,117,26,0.1)` : "transparent",
borderTop: sel ? `1px solid ${M.ink}` : "none",
borderBottom: sel ? `1px solid ${M.ink}` : "none",
letterSpacing: -1,
}}>
{pad2(h)}
);
})}
:
{MINUTES.map(m => {
const sel = m === minute;
return (
onSetMinute(m)}
style={{
padding: "8px 0", textAlign: "center", cursor: "pointer", userSelect: "none",
fontFamily: M.display, fontSize: sel ? 30 : 20,
fontWeight: sel ? 700 : 400,
color: sel ? M.ink : M.mid,
background: sel ? `rgba(184,117,26,0.1)` : "transparent",
borderTop: sel ? `1px solid ${M.ink}` : "none",
borderBottom: sel ? `1px solid ${M.ink}` : "none",
letterSpacing: -1,
}}>
{pad2(m)}
);
})}
▸ {evening ? "Evening on" : "Add evening"}
A closing note at 17:30.
{evening ? "✓" : "+"}
);
}
// =============================================================================
// 07 · Processing — auto-advance
// =============================================================================
function MStep07({ onDone, sources }) {
const [progress, setProgress] = useState(0);
useEffect(() => {
const start = Date.now();
const dur = 2800;
const tick = setInterval(() => {
const p = Math.min(1, (Date.now() - start) / dur);
setProgress(p);
if (p >= 1) {
clearInterval(tick);
setTimeout(() => onDone && onDone(), 200);
}
}, 50);
return () => clearInterval(tick);
}, []);
const srcLabels = (sources || []).slice(0, 4).map(id => {
const s = SOURCES_LIBRARY.find(x => x.id === id);
return s?.name || id;
});
return (
{[40,72,104,136,160].map((r,i) => (
))}
{Array.from({ length: 18 }).map((_, i) => {
const a = (i / 18) * 2 * Math.PI;
return ;
})}
{srcLabels[0] &&
{srcLabels[0]}
}
{srcLabels[1] &&
{srcLabels[1]}
}
{srcLabels[2] &&
{srcLabels[2]}
}
{srcLabels[3] &&
{srcLabels[3]}
}
▸ PHASE {pad2(Math.min(4, Math.ceil(progress * 4)))} / 04 · SYNTHESIS
Listening for the hum.
[0705Z] ✓ {Math.max(1, Math.floor(progress * 6))} patterns detected
[0705Z] ▸ forming headline
);
}
// =============================================================================
// 08 · Confirmation
// =============================================================================
function MStep08({ prefs, onFinish, sendStatus, sendError }) {
const styleMeta = STYLES_LIBRARY.find(s => s.id === prefs.style) || STYLES_LIBRARY[0];
const topicsPreview = prefs.topics.length === 0 ? "—" : prefs.topics.slice(0, 2).join(" · ") + (prefs.topics.length > 2 ? " · …" : "");
const sourcesPreview = (() => {
const names = prefs.sources.map(id => SOURCES_LIBRARY.find(s => s.id === id)?.name).filter(Boolean);
if (names.length === 0) return "—";
return names.slice(0, 2).join(" · ") + (names.length > 2 ? " · …" : "");
})();
const dayDesc = (() => {
const n = prefs.days.length;
if (n === 0) return "no days";
if (n === 7) return "every day";
if (n === 5 && prefs.days.every(d => d < 5)) return "Mon–Fri";
return prefs.days.map(d => DAY_NAMES_FULL[d].slice(0,2)).join(" ");
})();
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return (
It's set
You'll receive your
✦ Subscription · №{String(Math.floor(Math.random() * 900 + 100))} ✦
The Signal , for you
{[
["Frequency", prefs.frequency === "daily" ? "Daily" : "Weekly", dayDesc],
["Delivery", `${pad2(prefs.hour)}:${pad2(prefs.minute)}`, tz.split("/").pop().replace("_"," ")],
["Style", styleMeta.name, styleMeta.italic ? styleMeta.italic.toLowerCase() : "—"],
["Topics", String(prefs.topics.length), topicsPreview],
["Sources", String(prefs.sources.length), sourcesPreview],
["Edition", "Ready", "on demand"],
].map(([l,v,s], i) => (
))}
Edit any of this, any time →
✦ {sendStatus === "sending" ? "Queueing your research…" : sendStatus === "error" ? "Did not queue" : "Run your signal"}
{sendStatus === "sending"
? <>Researching now… >
: <>Real research. PDF in your inbox. >
}
Going to {prefs.email || "your inbox"} · A4 Blueprint PDF · arrives in 10–20 min
{sendError && (
{sendError}
)}
{sendStatus === "sending" ? "Queueing…" : sendStatus === "error" ? "Try again" : "Generate my signal"}
);
}
Object.assign(window, {
MStep01, MStep02, MStep03, MStep04, MStep05, MStep06, MStep07, MStep08,
MScreen, MKicker, MHed, MDeck, MBtn,
MS_TOPICS_LIBRARY: TOPICS_LIBRARY,
MS_SOURCES_LIBRARY: SOURCES_LIBRARY,
MS_DEFAULT_SOURCE_IDS: DEFAULT_SOURCE_IDS,
MS_STYLES_LIBRARY: STYLES_LIBRARY,
});