This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady
My inbox has 124 unread newsletters. I subscribed to every one of them on purpose. Neo Kim's System Design Newsletter teaches me something every day, and Gregor Ojstersek's Engineering Leadership is the closest thing I have to a mentor. They're important. I'm just too lazy to read them.
I am not too lazy to listen, though. I have a 40-minute commute to the office and my ears are free the whole way. So I built Unread: It picks up your newsletters to a private address, and turns them into one short podcast episode, with every story heard once, every claim cited.
What I Built
Unread turns the newsletters you never get to read into a short, private podcast.
-
Forward once. You get a private address like
k7m2q9x4ab@in.getunread.site. Add one Gmail filter that forwards your newsletters to it. Gmail sends a confirmation code first, so Unread catches that email and shows the code in the app. After that you never touch it again. - Unread does the reading. Every issue is cleaned (footers, unsubscribe blocks, tracking pixels and sponsor sections removed) and split into separate stories.
-
You hear each story once. This is the part I care about most, and it's really two features:
- The same story from several newsletters becomes one chapter that credits all of them. When two newsletters cover the same launch in the same week, you hear it once: "Engineering Leadership and The System Design Newsletter both covered this."
- A story you already heard is skipped. If a newsletter brings it up again next week, Unread remembers and leaves it out.
- Nothing is made up. Every line of the script must cite the newsletter it came from. Any number or quote that isn't in that source is rewritten once, and cut if it still fails. Every chapter lists its sources, with links.
- It plays where you already listen. Each episode has chapters, a transcript that highlights the line being spoken, and the sources. Episodes go to a private RSS feed that works in any podcast app that accepts a feed URL (the Sources page has one-tap buttons for Apple Podcasts and Pocket Casts), and to a web player on phone and desktop.
Who gets to be lazier? Anyone with multiple newsletter subscriptions and a commute, a gym session or a pile of dishes. After two minutes of setup, the effort is zero. No app to open, no "read later" queue to feel guilty about, no reading the same funding round four times.
Slow and steady, on purpose. The Free plan makes one episode a week, voiced by an open-weight model on a CPU. A 10-minute episode takes the worker a few minutes to record, and nobody minds, because it's made on a schedule while you sleep. It only runs when there's something new: fewer than two new stories means no episode and no cost.
Demo
Live: getunread.site. Sign in, copy your forwarding address, and forward one newsletter by hand to try it.
The video's narrator and the episode's host are both ElevenLabs voices. The screens are rebuilt from the app's own components and strings.
Code
Unread
Forward your newsletters to Unread and get one short, deduplicated podcast episode with the parts that matter, in a voice you choose, delivered to the podcast app you already use.
- Product spec: docs/prd-unread.md · Design: docs/design/ · Decisions: docs/decisions/ · Deploy: docs/deploy.md
How it works
newsletter ─► Cloudflare Email Routing ─► Email Worker ─signed─► /inbound/email
(SPF/DKIM) (apps/inbound-worker) verified sender? clean, split into stories
│
Temporal schedule / "Make one now" ─► make-episode workflow (apps/worker)
collect → EmbeddingGemma + pgvector clustering → Gemma plan → Gemma script → fact check
→ Kokoro (Free) / ElevenLabs (Plus) → ffmpeg −16 LUFS → seeded cover → publish
│
private RSS feed (/feed/<token>.xml) + web player (Next.js, apps/web)
Repo
| Path | What |
|---|---|
apps/web |
Next.js 16: onboarding, Home, Now playing, Sources, Settings, admin; inbound, feed, audio, billing routes |
apps/worker |
Temporal worker (or poller), CLIs: brief, make-episode, dispatch, promo, warm-models
|
apps/inbound-worker |
Cloudflare |
| Path | What's in it |
|---|---|
apps/web |
Next.js 16 app: onboarding, Today, Now playing, Sources, Settings; the inbound-email, feed, audio and billing routes |
apps/worker |
Temporal worker (or a polling fallback) that runs the episode pipeline, plus CLIs (pnpm brief, pnpm make-episode) |
apps/inbound-worker |
Cloudflare Email Worker that relays forwarded mail, signed |
packages/core |
All the logic: email parsing, clustering, planning, scripting, fact check, voices, audio, cover, RSS feed, plans, promo codes |
packages/db |
Drizzle schema and migrations: Postgres with pgvector and a Timescale hypertable |
The repo was started on October 23, inside the challenge window. Every third-party choice is written up as a decision record in docs/decisions/, and the product spec is in docs/prd-unread.md. Integration tests run against real EmbeddingGemma, real Kokoro, real ffmpeg and Temporal's time-skipping test server, not mocks.
How I Built It
The open-source AI at the centre
| Model | Licence | What it does in Unread | Where it runs |
|---|---|---|---|
Gemma 4 31B (gemma-4-31b-it) |
Gemma open weights | Every editorial decision: confirms borderline "same story?" pairs, ranks stories, writes each chapter as cited JSON, repairs lines that fail the fact check | Served by Google's Gemini API, with OpenRouter (the same open model) as the fallback |
EmbeddingGemma 300M (onnx-community/embeddinggemma-300m-ONNX, q8) |
Gemma open weights | Turns every story into a 768-dimension vector, to find the same story across newsletters and stories you've already heard | On our own worker's CPU, through transformers.js 3.8.1 (ONNX Runtime) |
Kokoro-82M (onnx-community/Kokoro-82M-v1.0-ONNX, q8) |
Apache-2.0 | Reads every Free episode aloud, in four curated voices | On our own worker's CPU, through kokoro-js |
Around them it's open source all the way down: Postgres with pgvector from Tiger Data, Temporal, ffmpeg, Mozilla's Readability, resvg for covers, and Next.js.
The pipeline
newsletter ─► Cloudflare Email Worker ─signed─► /inbound/email (Next.js on Render)
(HMAC over the raw MIME) verified sender? SPF/DKIM pass?
clean → split into stories → items
│
Temporal schedule (or "Make one now") ─► makeEpisodeWorkflow (worker on Render)
collect → EmbeddingGemma + pgvector → Gemma confirms borderline pairs → skip heard
→ Gemma ranks → Gemma writes cited JSON → deterministic fact check (+1 repair)
→ Kokoro (Free) / ElevenLabs (Plus) → ffmpeg, −16 LUFS → seeded cover → publish
│
private RSS feed /feed/<token>.xml + web player with chapters and transcript
1. Getting mail in safely. Cloudflare Email Routing hands each forwarded message to a small Email Worker, which signs the raw MIME with an HMAC (X-Unread-Signature) and posts it to the app. The app accepts mail only from the user's verified addresses and only when SPF or DKIM passes. Everything else is dropped and logged before any model sees it. Gmail's forwarding-confirmation email is recognised by its subject, and its code is stored and shown in onboarding as soon as it arrives. Retries are idempotent on Message-ID.
2. Cleaning and splitting. HTML becomes text, with footers, unsubscribe blocks, tracking pixels and identifiable sponsor sections removed and links kept. Each issue is split into stories at its headings, where a short line that's all bold counts as a heading. The newsletter's name comes from the sender or List-Id, for citations.
3. Same story, heard once. EmbeddingGemma embeds every story on the worker. Measured on the dev container, the same story from two different outlets scores about 0.93 cosine similarity and unrelated stories about 0.30, so the thresholds are:
| Cosine similarity | What happens |
|---|---|
| ≥ 0.83 | Same story: merged into one chapter |
| 0.72 to 0.83 | Borderline: Gemma decides ("same event, announcement or finding, not just the same topic"), at most 30 pairs per episode |
| ≥ 0.85 against anything you've heard | Already heard: skipped |
"Already heard" is a single pgvector query against the stories in your past episodes:
const [nearest] = await db.execute<{ similarity: number }>(sql`
select 1 - (i.embedding <=> ${vector}::vector) as similarity
from ${schema.heardItems} h
join ${schema.items} i on i.id = h.item_id
where h.user_id = ${userId} and i.embedding is not null and i.id <> ${c.id}
order by i.embedding <=> ${vector}::vector
limit 1`);
if (nearest && Number(nearest.similarity) >= HEARD) heardItemIds.push(c.id);
4. Planning. Gemma ranks the stories within your priority tiers ("always first", "normal", "only if room"). It factors in how often you skip each source, read from a Timescale hypertable of listening events over the last 60 days, then freshness. It fits the result to a character budget for your plan and episode length (about 900 characters per spoken minute).
5. Writing, with a leash. Gemma writes each chapter as JSON, { title, lines: [{ text, sourceIds }] }, from the sources in that chapter only. Gemma on the Gemini API has no JSON mode, so the prompt states the contract. The reply is validated with Zod plus semantic checks (length budget, known source ids), and errors are fed back for up to three attempts. The intro and outro are templates, not model output, so the part that names your newsletters can't hallucinate: "This is Unread for Monday, 26 October. 4 stories today, from…"
6. The fact check is code, not vibes. Every line goes through a deterministic check:
export function checkLine(line: ScriptLine, allowed: Set<string>, sourceText: Map<string, string>): string[] {
const problems: string[] = [];
const cited = line.sourceIds.filter((id) => allowed.has(id));
if (cited.length === 0) problems.push("cites no valid source");
const words = line.text.trim().split(/\s+/).length;
if (words > MAX_WORDS_PER_LINE) problems.push(`${words} words (max ${MAX_WORDS_PER_LINE})`);
const haystack = normalizeText(cited.map((id) => sourceText.get(id) ?? "").join("\n"));
const sourceNumbers = new Set(numbersIn(haystack));
for (const n of numbersIn(line.text)) {
if (!sourceNumbers.has(n)) problems.push(`number ${n} is not in the cited sources`);
}
for (const q of quotesIn(line.text)) {
if (!haystack.includes(q)) problems.push(`quote "${q}" is not in the cited sources`);
}
return problems;
}
A failing line goes back to Gemma once, along with the exact problems. If it still fails, it's dropped. A shorter episode beats a wrong one.
7. Voices. A VoiceProvider interface has two implementations, chosen by the user's plan. Voice code never checks plans itself.
- Kokoro voices the Free plan on the worker's CPU. Measured: 8.6 s of speech in 6.6 s on one thread. A 10-minute episode is about 7.8 CPU-minutes, or about 4 minutes with two jobs in parallel.
-
ElevenLabs voices Plus. Standard voices use
eleven_flash_v2_5; Studio voices useeleven_v4, with neighbouring lines passed as context so the delivery flows.
Every line is cached by sha256(provider | model | voice | text), so a retry never pays twice. ffmpeg joins the lines in one pass and normalises to −16 LUFS. Chapter and per-line start times come from exact sample counts, which is how the transcript highlights the line being spoken.
8. Cover and feed. Each episode gets a seeded SVG cover (your date and newsletters), rendered to PNG with resvg. The private RSS feed has iTunes and Podcasting 2.0 tags, chapter JSON per episode and itunes:block, behind a 192-bit token you can rotate.
9. Durable by default. makeEpisodeWorkflow runs the steps as Temporal activities: start → script → voice (with heartbeats) → cover → publish. Each step stores its output and checks for it before redoing work, so a retried activity never re-buys voice or re-asks Gemma. Two Temporal Schedules drive it: a dispatcher every 10 minutes (who is due, in their own timezone) and hourly maintenance (raw mail is deleted after 30 days, and trials expire). If Temporal is unavailable, the same worker falls back to a FOR UPDATE SKIP LOCKED poller that runs the same step functions. The workflow is tested with Temporal's time-skipping environment: steps run in order, the voice step is retried after a transient failure, and nothing is published when the request is skipped.
10. Hosting. Both halves run on Render. The web app is a Node service on getunread.site, which runs the migrations before each deploy. The worker is a Docker background worker with ffmpeg and the ONNX models baked into the image, so a cold start doesn't download the models first.
Plans, without surprises
| Free | Plus ($12/mo) | |
|---|---|---|
| Voice | Kokoro (open-weight, 4 voices) | ElevenLabs Standard or Studio voices |
| Cadence | Weekly, on your chosen day | Daily, only when there's something new |
| Length | Up to 10 minutes | 5, 10 or 20 minutes |
The effective plan comes from one function: paid Plus, or an unexpired promo trial, or Free. Promo codes give a Plus trial with no card. A trial is capped at 3 ElevenLabs episodes, redemption is atomic, and it needs a verified email. Payments go through Bachs, and plans change only from its signed, idempotent webhooks, never from a redirect URL.
What fought back
-
Two ONNX runtimes in one process crash. kokoro-js ships with transformers.js 3.x, so EmbeddingGemma had to run on the same pinned
@huggingface/transformers@3.8.1, or Node fails withVERS_1.21.0 not found. - No JSON mode for Gemma on the Gemini API. Hence the contract-in-the-prompt, a Zod check and the three-attempt repair loop. Gemma follows a stated schema well once the error message tells it exactly what was wrong.
- Gmail won't auto-forward until you confirm by email. That would have been the onboarding killer: users would have to go find a code in a mailbox they can't see. So Unread catches the confirmation email and puts the code on the setup screen.
Why Does Open Innovation Matter?
Open models are what make a free plan possible at all. Voice is the expensive part of a podcast. With ElevenLabs, a 10-minute episode is about 9,000 characters, roughly 36 cents at Flash prices or 72 cents for a Studio voice, every time. If every listener cost that much, Unread would have to be paid-only. Kokoro is Apache-2.0 and runs on our own worker, so a Free episode costs CPU time and nothing per character. ElevenLabs became the upgrade instead of the foundation.
The model that reads your newsletters runs where we choose. EmbeddingGemma reads every story you forward, and it runs on our worker's CPU. There's no per-call bill, no rate limit, and your newsletter text isn't sent to a separate embeddings service just to be compared.
No single vendor holds the off switch. Gemma is served by Google today, but it's open weights. That's why the fallback works: when the Gemini API returns 429s, the same model is called through OpenRouter, and the prompts and the fact check behave the same. With a closed model there's no second provider to fall back to. The model client already supports the OpenAI-compatible protocol, so pointing it at a self-hosted or fine-tuned Gemma is a small change, not a rewrite.
Trust is checkable. The part that protects listeners from made-up numbers is open code (checkLine above), not a promise in a model card. Anyone can read exactly what "every claim is cited" means.
My Agent Session
I couldn't get the first session due to Claude limit on that account, so here's the others
<ide_opened_file>The user opened the file [REDACTED]\hacks\unread\design\README.md in the IDE. This may or may not be related to the current task.</ide_opened_file>
“Build the landing page from docs/design/Landing.dc.html.”
$ git ls-files; ls -la; ls design docs 2>&1; wc -c design/*; cat design/README.md
README.md
design/Main.dc.html
design/Onboarding.dc.html
design/Player.dc.html
design/README.md
design/Sources.dc.html
prd-unread.md
total 45
drwxr-xr-x 1 HP 197609 0 Oct 23 19:00 .
drwxr-xr-x 1 HP 197609 0 Oct 23 18:58 ..
drwxr-xr-x 1 HP 197609 0 Oct 24 09:39 .git
-rw-r--r-- 1 HP 197609 13 Oct 23 18:59 README.md
drwxr-xr-x 1 HP 197609 0 Oct 24 09:43 design
-rw-r--r-- 1 HP 197609 23146 Oct 24 09:43 prd-unread.md
ls: cannot access 'docs': No such file or directory
design:
Landing.dc.html
Main.dc.html
Onboarding.dc.html
Player.dc.html
README.md
Sources.dc.html
19988 design/Landing.dc.html
9035 design/Main.dc.html
5788 design/Onboarding.dc.html
7322 design/Player.dc.html
1435 design/README.md
7775 design/Sources.dc.html
51343 total
# Unread design reference
Four phone screens (390x844) from the design canvas. They are the visual source of truth for the UI in prd-unread.md §8.
| File | Screen |
|---|---|
| Onboarding.dc.html | 1 · Pick topics, episode length, news edition |
| Main.dc.html | 2 · Home: today's episode + chapters |
| Player.dc.html | 3 · Now playing: transcript + source + controls |
| Sources.dc.html | 4 · Private feed link, paste a link, forwarding address, saved items |
How to use them:
- These are design files, not app code. Read the markup and inline styles for layout, spacing, sizes, colours and copy; rebuild them as React components (Tailwind + shadcn/ui) in the app.
- `{{accent}}` = the accent token (default #5B9BFF). The list data inside each `renderVals()` is sample content.
- Tokens: ground #0E0F12, surface #17181D, surface-2 #1F2128, line #2A2D35, text #F2F3F5, text-2 #D6D9DF, muted #A3A8B3, accent #5B9BFF, on-accent #0A1A33. Fonts: Geist, Geist Mono.
- Desktop (>= 1024px) layout is described in prd-unread.md §8; it is not drawn here.
## Landing page
| File | Page |
|---|---|
| Landing.dc.html | Marketing landing page for getunread.site (fluid, responsive; desktop 1440 wide, stacks on phone) |
Build it as the `/` route (marketing) with the same tokens as the app. Copy in the file is final unless noted ([PRICE] is a placeholder). All primary buttons lead to sign-up / onboarding ("Make my first episode").
$ cat design/Landing.dc.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Unread · Your newsletters as a podcast</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
body{margin:0;background:#0E0F12}
a{color:#F2F3F5}a:hover{color:#FFFFFF}
</style>
</helmet>
<div style="background: #0E0F12; color: #F2F3F5; font-family: Geist, system-ui, sans-serif; min-height: 100vh">
<header style="border-bottom: 1px solid #1F2128">
<div style="max-width: 1200px; margin: 0 auto; padding: 18px 24px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px">
<a href="#top" style="display: flex; align-items: center; gap: 8px; text-decoration: none">
<span style="width: 10px; height: 10px; border-radius: 50%; background: {{accent}}; display: inline-block"></span>
<span style="font-size: 18px; font-weight: 700; letter-spacing: -0.02em">Unread</span>
</a>
<nav aria-label="Main" style="display: flex; flex-wrap: wrap; align-items: center; gap: 24px; font-size: 14px">
<a href="#how" style="text-decoration: none; color: #A3A8B3">How it works</a>
<a href="#pricing" style="text-decoration: none; color: #A3A8B3">Pricing</a>
<a href="#faq" style="text-decoration: none; color: #A3A8B3">FAQ</a>
<a href="Onboarding.dc.html" style="text-decoration: none; background: {{accent}}; color: #0A1A33; font-weight: 600; padding: 10px 16px; border-radius: 10px">Start free</a>
</nav>
</div>
</header>
<section id="top" style="max-width: 1200px; margin: 0 auto; padding: 72px 24px 64px; display: flex; flex-wrap: wrap; gap: 48px; align-items: center">
<div style="flex: 1 1 520px; min-width: 0; display: flex; flex-direction: column; gap: 24px">
<span style="font: 500 12px 'Geist Mono', monospace; color: {{accent}}; letter-spacing: 0.08em">FOR EVERYONE WITH TOO MANY NEWSLETTERS</span>
<h1 style="margin: 0; font-size: clamp(44px, 6.4vw, 84px); line-height: 0.98; font-weight: 700; letter-spacing: -0.045em">Your newsletters, as one short <span style="color: transparent; -webkit-text-stroke: 1.5px #F2F3F5">podcast.</span></h1>
<p style="margin: 0; max-width: 520px; font-size: 18px; line-height: 1.55; color: #A3A8B3">Forward the newsletters piling up in your inbox. Each morning, Unread turns them into one short episode for your commute, walk or workout.</p>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 12px">
<a href="Onboarding.dc.html" style="text-decoration: none; background: {{accent}}; color: #0A1A33; font-weight: 600; font-size: 15px; padding: 14px 20px; border-radius: 12px">Make my first episode</a>
<a href="#sample" style="text-decoration: none; border: 1px solid #2A2D35; color: #F2F3F5; font-weight: 500; font-size: 15px; padding: 14px 20px; border-radius: 12px">Play a sample</a>
</div>
<p style="margin: -12px 0 0; font-size: 13px; color: #A3A8B3">Free to start · No card needed · Set up in 2 minutes</p>
<div id="sample" style="display: flex; align-items: center; gap: 14px; background: #17181D; border: 1px solid #2A2D35; border-radius: 16px; padding: 12px 16px 12px 12px; max-width: 520px">
<button aria-label="Play sample episode" style="width: 44px; height: 44px; border-radius: 50%; border: 0; background: {{accent}}; display: flex; align-items: center; justify-content: center; cursor: pointer; flex: none">
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.5v15l13-7.5z" fill="#0A1A33"></path></svg></button>
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px">
<div style="display: flex; justify-content: space-between; gap: 12px; font-size: 13px"><span style="font-weight: 600">Sample: 6 newsletters in 9 minutes</span><span style="font-family: 'Geist Mono', monospace; color: #A3A8B3">0:00 / 8:40</span></div>
<div style="height: 4px; border-radius: 2px; background: #2A2D35"><div style="width: 14%; height: 4px; border-radius: 2px; background: {{accent}}"></div></div>
</div>
</div>
</div>
<div style="flex: 1 1 380px; min-width: 0">
<svg role="img" aria-label="Unread episode cover art" viewBox="0 0 480 480" style="width: 100%; height: auto; display: block; border-radius: 28px; background: #17181D">
<rect width="480" height="480" rx="28" fill="#17181D"></rect>
<g fill="{{accent}}">
<rect x="40" y="250" width="22" height="190" rx="11"></rect><rect x="76" y="190" width="22" height="250" rx="11" opacity="0.5"></rect>
<rect x="112" y="300" width="22" height="140" rx="11"></rect><rect x="148" y="150" width="22" height="290" rx="11" opacity="0.3"></rect>
<rect x="184" y="230" width="22" height="210" rx="11"></rect><rect x="220" y="330" width="22" height="110" rx="11" opacity="0.5"></rect>
<rect x="256" y="200" width="22" height="240" rx="11"></rect>
</g>
<g fill="#F2F3F5" opacity="0.12">
<rect x="292" y="270" width="22" height="170" rx="11"></rect><rect x="328" y="320" width="22" height="120" rx="11"></rect>
<rect x="364" y="240" width="22" height="200" rx="11"></rect><rect x="400" y="350" width="22" height="90" rx="11"></rect>
</g>
<text x="440" y="96" text-anchor="end" fill="#F2F3F5" font-family="Geist, sans-serif" font-size="88" font-weight="700" letter-spacing="-4">27</text>
<text x="440" y="126" text-anchor="end" fill="#A3A8B3" font-family="Geist Mono, monospace" font-size="16">OCT · TUESDAY BRIEF</text>
</svg>
</div>
</section>
<section id="how" style="border-top: 1px solid #1F2128">
<div style="max-width: 1200px; margin: 0 auto; padding: 88px 24px; display: flex; flex-direction: column; gap: 48px">
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: space-between; align-items: flex-end">
<span style="font: 500 12px 'Geist Mono', monospace; color: {{accent}}; letter-spacing: 0.08em">HOW IT WORKS</span>
<h2 style="margin: 0; flex: 0 1 620px; font-size: clamp(34px, 4.2vw, 56px); line-height: 1.02; font-weight: 700; letter-spacing: -0.035em">Set it up once. Listen every day.</h2>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px">
<sc-for list="{{steps}}" as="step" hint-placeholder-count="3">
<div style="background: #17181D; border: 1px solid #2A2D35; border-radius: 20px; padding: 28px; display: flex; flex-direction: column; gap: 14px">
<span style="font: 500 13px 'Geist Mono', monospace; color: {{accent}}">{{step.number}}</span>
<h3 style="margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.02em">{{step.title}}</h3>
<p style="margin: 0; font-size: 15px; line-height: 1.55; color: #A3A8B3">{{step.body}}</p>
</div>
</sc-for>
</div>
</div>
</section>
<section style="border-top: 1px solid #1F2128; background: #121318">
<div style="max-width: 1200px; margin: 0 auto; padding: 88px 24px; display: flex; flex-direction: column; gap: 48px">
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: space-between; align-items: flex-end">
<span style="font: 500 12px 'Geist Mono', monospace; color: {{accent}}; letter-spacing: 0.08em">NO REPEATS</span>
<div style="flex: 0 1 620px; display: flex; flex-direction: column; gap: 14px">
<h2 style="margin: 0; font-size: clamp(34px, 4.2vw, 56px); line-height: 1.02; font-weight: 700; letter-spacing: -0.035em">Hear each story once.</h2>
<p style="margin: 0; font-size: 16px; line-height: 1.55; color: #A3A8B3">Five newsletters covering the same news? You hear it once, and every chapter links back to the original issues.</p>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px">
<sc-for list="{{chapters}}" as="chapter" hint-placeholder-count="3">
<article style="display: flex; flex-direction: column; gap: 16px">
<div style="position: relative; aspect-ratio: 4 / 3; border-radius: 18px; overflow: hidden; background: #17181D; border: 1px solid #2A2D35">
<svg aria-hidden="true" viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice" style="width: 100%; height: 100%; display: block">
<g fill="{{accent}}" opacity="{{chapter.strength}}">
<circle cx="{{chapter.cx}}" cy="150" r="120"></circle>
</g>
<g fill="none" stroke="#F2F3F5" stroke-opacity="0.12" stroke-width="2">
<circle cx="{{chapter.cx}}" cy="150" r="150"></circle><circle cx="{{chapter.cx}}" cy="150" r="190"></circle><circle cx="{{chapter.cx}}" cy="150" r="230"></circle>
</g>
</svg>
<span style="position: absolute; left: 16px; bottom: 16px; font: 500 12px 'Geist Mono', monospace; background: #0E0F12; color: #F2F3F5; padding: 6px 10px; border-radius: 8px">{{chapter.merged}}</span>
<button aria-label="Play chapter" style="position: absolute; right: 16px; bottom: 16px; width: 44px; height: 44px; border-radius: 50%; border: 0; background: {{accent}}; display: flex; align-items: center; justify-content: center; cursor: pointer">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.5v15l13-7.5z" fill="#0A1A33"></path></svg></button>
</div>
<div style="display: flex; flex-direction: column; gap: 6px">
<span style="font: 500 12px 'Geist Mono', monospace; color: #A3A8B3">{{chapter.meta}}</span>
<h3 style="margin: 0; font-size: 19px; font-weight: 600; letter-spacing: -0.015em">{{chapter.title}}</h3>
</div>
</article>
</sc-for>
</div>
<p style="margin: 0; font-size: 13px; color: #8A909C">Example episode.</p>
</div>
</section>
<section style="border-top: 1px solid #1F2128">
<div style="max-width: 1200px; margin: 0 auto; padding: 88px 24px; display: flex; flex-wrap: wrap; gap: 48px">
<div style="flex: 1 1 320px; display: flex; flex-direction: column; gap: 16px">
<span style="font: 500 12px 'Geist Mono', monospace; color: {{accent}}; letter-spacing: 0.08em">OPTIONAL · NEWS</span>
<h2 style="margin: 0; font-size: clamp(34px, 4.2vw, 56px); line-height: 1.02; font-weight: 700; letter-spacing: -0.035em">Want the news too?</h2>
<p style="margin: 0; font-size: 16px; line-height: 1.55; color: #A3A8B3">Pick a few topics and we'll add the day's biggest stories. Prefer just your newsletters? Leave it off.</p>
</div>
<ul style="flex: 1 1 520px; list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column">
<sc-for list="{{topics}}" as="topic" hint-placeholder-count="7">
<li style="display: flex; justify-content: space-between; align-items: center; padding: 16px 0; border-bottom: 1px solid #1F2128">
<span style="font-size: clamp(22px, 2.6vw, 30px); font-weight: 600; letter-spacing: -0.02em">{{topic}}</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#A3A8B3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17L17 7"></path><path d="M8 7h9v9"></path></svg>
</li>
</sc-for>
</ul>
</div>
</section>
<section id="pricing" style="border-top: 1px solid #1F2128; background: #121318">
<div style="max-width: 1200px; margin: 0 auto; padding: 88px 24px; display: flex; flex-direction: column; gap: 48px">
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: space-between; align-items: flex-end">
<span style="font: 500 12px 'Geist Mono', monospace; color: {{accent}}; letter-spacing: 0.08em">PRICING</span>
<h2 style="margin: 0; flex: 0 1 620px; font-size: clamp(34px, 4.2vw, 56px); line-height: 1.02; font-weight: 700; letter-spacing: -0.035em">Free every week. Every day on Plus.</h2>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px">
<div style="background: #17181D; border: 1px solid #2A2D35; border-radius: 20px; padding: 28px; display: flex; flex-direction: column; gap: 20px">
<div style="display: flex; flex-direction: column; gap: 6px">
<h3 style="margin: 0; font-size: 22px; font-weight: 600">Free</h3>
<p style="margin: 0; font-size: 40px; font-weight: 700; letter-spacing: -0.03em">$0</p>
</div>
<ul style="margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 12px; font-size: 15px; color: #D6D9DF">
<li>One episode a week</li><li>Up to 10 minutes</li><li>4 voices to choose from</li><li>Optional news topics</li><li>Works in your podcast app</li>
</ul>
<a href="Onboarding.dc.html" style="margin-top: auto; text-align: center; text-decoration: none; border: 1px solid #2A2D35; padding: 14px; border-radius: 12px; font-weight: 600">Start free</a>
</div>
<div style="background: #17181D; border: 1px solid {{accent}}; border-radius: 20px; padding: 28px; display: flex; flex-direction: column; gap: 20px">
<div style="display: flex; flex-direction: column; gap: 6px">
<div style="display: flex; justify-content: space-between; align-items: center"><h3 style="margin: 0; font-size: 22px; font-weight: 600">Plus</h3><span style="font: 500 12px 'Geist Mono', monospace; color: {{accent}}">STUDIO VOICES</span></div>
<p style="margin: 0; font-size: 40px; font-weight: 700; letter-spacing: -0.03em">[PRICE]<span style="font-size: 16px; font-weight: 500; color: #A3A8B3"> / month</span></p>
</div>
<ul style="margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 12px; font-size: 15px; color: #D6D9DF">
<li>A fresh episode every morning</li><li>5, 10 or 20 minutes</li><li>Studio-quality voices, and a library to pick from</li><li>Everything in Free</li>
</ul>
<a href="Onboarding.dc.html" style="margin-top: auto; text-align: center; text-decoration: none; background: {{accent}}; color: #0A1A33; padding: 14px; border-radius: 12px; font-weight: 600">Upgrade to Plus</a>
<p style="margin: 0; font-size: 13px; color: #A3A8B3; text-align: center">Got a promo code? Try Plus free. No card needed.</p>
</div>
</div>
</div>
</section>
<section id="faq" style="border-top: 1px solid #1F2128">
<div style="max-width: 1200px; margin: 0 auto; padding: 88px 24px; display: flex; flex-wrap: wrap; gap: 48px">
<div style="flex: 1 1 320px; display: flex; flex-direction: column; gap: 16px">
<span style="font: 500 12px 'Geist Mono', monospace; color: {{accent}}; letter-spacing: 0.08em">FAQ</span>
<h2 style="margin: 0; font-size: clamp(34px, 4.2vw, 56px); line-height: 1.02; font-weight: 700; letter-spacing: -0.035em">Good questions.</h2>
</div>
<div style="flex: 1 1 520px; display: flex; flex-direction: column">
<sc-for list="{{faqs}}" as="faq" hint-placeholder-count="4">
<details style="border-bottom: 1px solid #1F2128; padding: 20px 0">
<summary style="cursor: pointer; font-size: 18px; font-weight: 600; list-style-position: outside">{{faq.q}}</summary>
<p style="margin: 12px 0 0; font-size: 15px; line-height: 1.6; color: #A3A8B3">{{faq.a}}</p>
</details>
</sc-for>
</div>
</div>
</section>
<section style="background: {{accent}}; color: #0A1A33">
<div style="max-width: 1200px; margin: 0 auto; padding: 88px 24px; display: flex; flex-direction: column; align-items: center; gap: 20px; text-align: center">
<h2 style="margin: 0; font-size: clamp(36px, 5vw, 64px); line-height: 1.02; font-weight: 700; letter-spacing: -0.04em">Catch up on every newsletter without reading one.</h2>
<p style="margin: 0; max-width: 520px; font-size: 17px; line-height: 1.55">Setup takes two minutes. Your first episode is ready as soon as a newsletter arrives.</p>
<a href="Onboarding.dc.html" style="text-decoration: none; background: #0A1A33; color: #F2F3F5; font-weight: 600; font-size: 15px; padding: 14px 22px; border-radius: 12px">Make my first episode</a>
<p style="margin: 0; font-size: 13px">Free to start · No card needed</p>
</div>
</section>
<footer style="border-top: 1px solid #1F2128">
<div style="max-width: 1200px; margin: 0 auto; padding: 40px 24px; display: flex; flex-wrap: wrap; gap: 24px; justify-content: space-between; align-items: center; font-size: 14px; color: #A3A8B3">
<div style="display: flex; align-items: center; gap: 8px; color: #F2F3F5">
<span style="width: 8px; height: 8px; border-radius: 50%; background: {{accent}}; display: inline-block"></span><span style="font-weight: 700">Unread</span>
<span style="color: #8A909C; font-weight: 400">· getunread.site</span>
</div>
<nav aria-label="Footer" style="display: flex; flex-wrap: wrap; gap: 20px">
<a href="#how" style="text-decoration: none; color: #A3A8B3">How it works</a>
<a href="#pricing" style="text-decoration: none; color: #A3A8B3">Pricing</a>
<a href="#" style="text-decoration: none; color: #A3A8B3">Privacy</a>
<a href="#" style="text-decoration: none; color: #A3A8B3">Terms</a>
</nav>
</div>
</footer>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"accent":{"editor":"color","default":"#5B9BFF","options":["#5B9BFF","#FF7A1A","#7DD3A8","#F5B547"]},"$preview":{"width":1440,"height":5200}}'>
class Component extends DCLogic {
renderVals() {
return {
accent: this.props.accent ?? '#5B9BFF',
steps: [
{ number: '01', title: 'Forward your newsletters', body: 'Send them to your Unread address, or let Gmail, Outlook or Apple Mail forward them for you.' },
{ number: '02', title: 'We do the reading', body: 'Unread picks out what matters, cuts the repeats and keeps it short.' },
{ number: '03', title: 'Press play', body: 'Listen here, or in Apple Podcasts or Pocket Casts. New episodes show up on their own.' }
],
chapters: [
{ title: 'Why AI pricing changed again this week', meta: 'CHAPTER 1 · 2:10', merged: 'FROM 4 NEWSLETTERS', cx: 120, strength: 0.85 },
{ title: 'The fundraising round everyone wrote about', meta: 'CHAPTER 2 · 1:45', merged: 'FROM 3 NEWSLETTERS', cx: 280, strength: 0.55 },
{ title: 'A design essay you saved and never opened', meta: 'CHAPTER 3 · 2:30', merged: 'YOUR SAVED LINK', cx: 200, strength: 0.3 }
],
topics: ['AI', 'Startups', 'Markets', 'Science', 'Football', 'Design', 'Climate'],
faqs: [
{ q: 'Which newsletters work?', a: 'Any newsletter that arrives by email: Substack, Beehiiv, Ghost, company updates and more. You choose which senders go into episodes.' },
{ q: 'Who sees my newsletters?', a: 'We use them only to make your episodes. They are never sold or shared for ads, and deleting your account deletes them.' },
{ q: 'Is it the full newsletter read aloud?', a: 'No. You get the highlights in a few minutes, with links to the full issues when you want more.' },
{ q: 'Can I listen in my own podcast app?', a: 'Yes. Add your private link to Apple Podcasts, Pocket Casts or most podcast apps once, and new episodes arrive there.' }
]
};
}
}
</script>
</body>
</html>
$ grep -n -i -E "^#|next|vite|react|tailwind|shadcn|stack|framework|landing|getunread|route" prd-unread.md | head -80; cat README.md; node -v; npm -v
The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.
Note: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions.
[Request interrupted by user for tool use]
continue
(?i)next\.js|vite|react|tailwind|shadcn|stack|landing|route|framework C:\Users\HP\hacks\unread\prd-unread.md
Path does not exist: C:\Users\HP\hacks\unread\prd-unread.md. Note: your current working directory is C:\Users\HP\hacks\unread.
$ ls -la . design
Exit code 2
ls: cannot access 'design': No such file or directory
.:
total 425
drwxr-xr-x 1 HP 197609 0 Oct 24 09:49 .
drwxr-xr-x 1 HP 197609 0 Oct 23 18:58 ..
drwxr-xr-x 1 HP 197609 0 Oct 24 09:46 .claude
-rw-r--r-- 1 HP 197609 84 Oct 24 09:46 .dockerignore
-rw-r--r-- 1 HP 197609 3270 Oct 24 09:46 .env.example
drwxr-xr-x 1 HP 197609 0 Oct 24 09:51 .git
-rw-r--r-- 1 HP 197609 126 Oct 24 09:46 .gitignore
-rw-r--r-- 1 HP 197609 4 Oct 24 09:46 .nvmrc
-rw-r--r-- 1 HP 197609 2482 Oct 24 09:46 CLAUDE.md
-rw-r--r-- 1 HP 197609 3084 Oct 24 09:46 README.md
drwxr-xr-x 1 HP 197609 0 Oct 24 09:46 apps
drwxr-xr-x 1 HP 197609 0 Oct 24 09:46 docs
-rw-r--r-- 1 HP 197609 744 Oct 24 09:46 package.json
drwxr-xr-x 1 HP 197609 0 Oct 24 09:46 packages
-rw-r--r-- 1 HP 197609 386346 Oct 24 09:46 pnpm-lock.yaml
-rw-r--r-- 1 HP 197609 291 Oct 24 09:46 pnpm-workspace.yaml
-rw-r--r-- 1 HP 197609 2496 Oct 24 09:46 render.yaml
-rw-r--r-- 1 HP 197609 521 Oct 24 09:46 tsconfig.base.json
The repo just changed under me (a monorepo scaffold landed). Re-orienting.
$ cat CLAUDE.md README.md package.json pnpm-workspace.yaml; git log --oneline -5; git status --short; find docs apps packages -path '*/node_modules' -prune -o -type f -print | head -150
# Unread
Your newsletters as a podcast. Spec: `docs/prd-unread.md` (v2). UI reference: `docs/design/` (phone canvases; desktop rules in PRD §9). Decisions: `docs/decisions/`. Path-scoped rules: `.claude/rules/`.
Current status, environment facts and open items (keep it updated):
@docs/project-memory.md
## Layout
- `apps/web`: Next.js 16 App Router (UI + route handlers). Auth0 via `src/proxy.ts`.
- `packages/db`: Drizzle schema, migrations, `createDb`, id helpers.
- `packages/core`: all domain logic, by subpath (`@unread/core/inbound`, `/pipeline`, `/episodes`, `/voice`, `/plans`, `/promo`, `/billing`, …). Web imports narrow subpaths only (never `/episodes` or `/voice` barrels: they pull ONNX/ffmpeg code).
- `apps/inbound-worker`: Cloudflare Email Worker that relays forwarded mail to `/inbound/email` (ADR 0004).
- `apps/worker`: Temporal worker or poller for the episode pipeline, plus CLIs.
## Commands
- `pnpm dev` / `pnpm build`
- `pnpm typecheck`, `pnpm lint`, `pnpm test`
- `pnpm db:generate` (after schema edits; drizzle-kit needs a TTY for rename prompts), `pnpm db:migrate` (needs `PG_URL`; `DATABASE_URL` is the fallback)
- `pnpm brief --user <id>`, `pnpm make-episode --user <id>`, `pnpm worker`, `pnpm promo create|list`, `pnpm generate-assets` (ElevenLabs SFX, once)
- Integration tests: `TEST_DATABASE_URL=… [RUN_EMBEDDING_TESTS=1] [RUN_TEMPORAL_TESTS=1] pnpm test`
## Code standards
- Strict TypeScript, no `any`. Validate with Zod at every boundary (env, session, request bodies, model output, webhooks).
- Kebab-case file names. Feature folders under `apps/web/src/features/<feature>`; shadcn primitives in `src/components/ui`.
- Functions with 3+ parameters take an options object.
- Server-only modules import `server-only`. Check the session in pages and route handlers with `requireUser()`, not only in the proxy.
- Auth links (`/auth/*`) are plain `<a>`, never prefetched `<Link>`.
- Use the design tokens in `src/app/globals.css` (`bg-surface`, `border-line`, `text-muted-foreground`, `bg-brand`, …), never raw hex.
- Touch targets ≥ 44 px, `aria-label` on icon buttons, contrast ≥ 4.5:1.
- Record every third-party choice in `docs/decisions/`.
- The effective plan comes only from `effectivePlan()`; voice code never checks plans; plan changes only from Bachs webhooks.
- In drizzle `sql` templates, don't interpolate `Date` values (postgres-js can't serialise them there); use `lt/gt/eq` operators.
# Unread
Forward your newsletters to Unread and get one short, deduplicated podcast episode with the parts that matter, in a voice you choose, delivered to the podcast app you already use.
- Product spec: [docs/prd-unread.md](docs/prd-unread.md) · Design: [docs/design/](docs/design/README.md) · Decisions: [docs/decisions/](docs/decisions/README.md) · Deploy: [docs/deploy.md](docs/deploy.md)
## How it works
```
newsletter ─► Cloudflare Email Routing ─► Email Worker ─signed─► /inbound/email
(SPF/DKIM) (apps/inbound-worker) verified sender? clean, split into stories
│
Temporal schedule / "Make one now" ─► make-episode workflow (apps/worker)
collect → EmbeddingGemma + pgvector clustering → Gemma plan → Gemma script → fact check
→ Kokoro (Free) / ElevenLabs (Plus) → ffmpeg −16 LUFS → seeded cover → publish
│
private RSS feed (/feed/<token>.xml) + web player (Next.js, apps/web)
```
## Repo
| Path | What |
|---|---|
| `apps/web` | Next.js 16: onboarding, Home, Now playing, Sources, Settings, admin; inbound, feed, audio, billing routes |
| `apps/worker` | Temporal worker (or poller), CLIs: `brief`, `make-episode`, `dispatch`, `promo`, `warm-models` |
| `apps/inbound-worker` | Cloudflare Email Worker relaying signed raw MIME |
| `packages/core` | Inbound parsing, pipeline, model client, voices, audio, cover, feed, plans, promos, billing, news, links |
| `packages/db` | Drizzle schema + migrations (Postgres on Tiger Data, pgvector, Timescale) |
## Status
| Milestone | State |
|---|---|
| U0 Repo, Next.js, schema, Auth0, tokens | Done |
| U1 Inbound email (Cloudflare), verified senders, cleaning, Gmail code | Done (needs Cloudflare deploy) |
| U2 Cluster, plan, script, check + `pnpm brief` | Done (needs a Gemma key for real runs) |
| U3 Voice, cover, publish, feed | Done (Kokoro + ElevenLabs); run `pnpm generate-assets` once for SFX |
| U4 UI, responsive, global player | Done |
| U5 Links, news, plans, promos, Bachs, Temporal, deploy config | Done in code; needs provider accounts + deploy |
| U6 Submission | Yours |
## Run locally
Node 22, pnpm 10, Postgres 16 with pgvector, ffmpeg.
```sh
pnpm install
cp .env.example apps/web/.env.local # fill in what you have
PG_URL=… pnpm db:migrate
pnpm dev # web on :3000
PG_URL=… pnpm worker # episodes (polls without Temporal)
PG_URL=… pnpm brief --user <id> # script JSON only (needs GEMINI_API_KEY or OPENROUTER_API_KEY)
PG_URL=… pnpm make-episode --user <id> # full episode in-process
```
## Tests
```sh
pnpm typecheck && pnpm lint && pnpm test
# integration (Postgres): TEST_DATABASE_URL=postgres://…/unread_test pnpm test
# + real EmbeddingGemma/Kokoro/ffmpeg: RUN_EMBEDDING_TESTS=1
# + Temporal time-skipping: RUN_TEMPORAL_TESTS=1
```
{
"name": "unread",
"private": true,
"packageManager": "pnpm@10.28.0",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "pnpm --filter @unread/web dev",
"build": "pnpm --filter @unread/web build",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"db:generate": "pnpm --filter @unread/db generate",
"db:migrate": "pnpm --filter @unread/db migrate",
"brief": "pnpm --filter @unread/worker brief",
"make-episode": "pnpm --filter @unread/worker make-episode",
"worker": "pnpm --filter @unread/worker start",
"promo": "pnpm --filter @unread/worker promo",
"generate-assets": "pnpm --filter @unread/worker generate-assets"
}
}
packages:
- apps/*
- packages/*
onlyBuiltDependencies:
- esbuild
- sharp
- unrs-resolver
# transformers.js 3.x imports onnxruntime-common without declaring it.
packageExtensions:
"@huggingface/transformers@3":
dependencies:
onnxruntime-common: "1.21.0"
60a7298 Merge branch 'claude/gifted-albattani-gk5mjz' of https://github.com/codewithveek/unread
d593a81 added landing design
b7a311f Update project memory: ElevenLabs test passed; Tiger Data still unreachable from this container
b6683ee Add project memory for new sessions: CLAUDE.md import + path-scoped rules
dd34861 ElevenLabs voices (Plus, trials, previews, SFX); read PG_URL for the database
docs/decisions/0001-monorepo-and-web-stack.md
docs/decisions/0002-auth0-sign-in.md
docs/decisions/0003-database-and-ids.md
docs/decisions/0004-inbound-email-cloudflare.md
docs/decisions/0005-model-and-embeddings.md
docs/decisions/0006-voices.md
docs/decisions/0007-payments-and-promos.md
docs/decisions/0008-workflow-and-hosting.md
docs/decisions/0009-news-links-storage-mail.md
docs/decisions/README.md
docs/deploy.md
docs/design/Landing.dc.html
docs/design/Main.dc.html
docs/design/Onboarding.dc.html
docs/design/Player.dc.html
docs/design/README.md
docs/design/Sources.dc.html
docs/prd-unread.md
docs/project-memory.md
docs/screenshots/u0/landing-phone.png
docs/screenshots/u0/today-desktop.png
docs/screenshots/u0/today-phone.png
docs/screenshots/u4/d-sources.png
docs/screenshots/u4/d-today-playing.png
docs/screenshots/u4/p-nowplaying.png
docs/screenshots/u4/p-onboarding.png
docs/screenshots/u4/p-today.png
apps/inbound-worker/package.json
apps/inbound-worker/src/index.ts
apps/inbound-worker/src/relay.test.ts
apps/inbound-worker/src/relay.ts
apps/inbound-worker/tsconfig.json
apps/inbound-worker/wrangler.jsonc
apps/web/components.json
apps/web/eslint.config.mjs
apps/web/next.config.ts
apps/web/package.json
apps/web/postcss.config.mjs
apps/web/public/podcast-cover.png
apps/web/public/voices/af_bella.mp3
apps/web/public/voices/af_heart.mp3
apps/web/public/voices/am_michael.mp3
apps/web/public/voices/bf_emma.mp3
apps/web/src/app/(app)/admin/page.tsx
apps/web/src/app/(app)/billing/return/page.tsx
apps/web/src/app/(app)/layout.tsx
apps/web/src/app/(app)/settings/page.tsx
apps/web/src/app/(app)/sources/page.tsx
apps/web/src/app/(app)/today/page.tsx
apps/web/src/app/api/listen/route.ts
apps/web/src/app/api/status/route.ts
apps/web/src/app/audio/[token]/[file]/route.ts
apps/web/src/app/billing/checkout/route.ts
apps/web/src/app/billing/portal/route.ts
apps/web/src/app/billing/webhooks/bachs/route.ts
apps/web/src/app/feed/[token]/chapters/[file]/route.ts
apps/web/src/app/feed/[token]/cover/[file]/route.ts
apps/web/src/app/feed/[token]/route.ts
apps/web/src/app/globals.css
apps/web/src/app/icon.svg
apps/web/src/app/inbound/email/route.ts
apps/web/src/app/layout.tsx
apps/web/src/app/onboarding/page.tsx
apps/web/src/app/page.tsx
apps/web/src/app/privacy/page.tsx
apps/web/src/app/voice-preview/[voiceId]/route.ts
apps/web/src/components/ui/button.tsx
apps/web/src/components/ui/copy-button.tsx
apps/web/src/components/ui/field-row.tsx
apps/web/src/components/ui/section-card.tsx
apps/web/src/features/action-state.ts
apps/web/src/features/admin/actions.ts
apps/web/src/features/admin/create-promo-form.tsx
apps/web/src/features/admin/guard.ts
apps/web/src/features/billing/bachs.ts
apps/web/src/features/episodes/queries.ts
apps/web/src/features/episodes/start-episode.ts
apps/web/src/features/feed/feed-data.ts
apps/web/src/features/feed/serve-object.ts
apps/web/src/features/forwarding/forwarding-guides.tsx
apps/web/src/features/forwarding/live-status.tsx
apps/web/src/features/inbound/queries.ts
apps/web/src/features/links/extract-queued-links.ts
apps/web/src/features/news/actions.ts
apps/web/src/features/news/news-form.tsx
apps/web/src/features/news/topics.ts
apps/web/src/features/onboarding/actions.ts
apps/web/src/features/onboarding/finish-form.tsx
apps/web/src/features/player/controls.tsx
apps/web/src/features/player/format.ts
apps/web/src/features/player/icons.tsx
apps/web/src/features/player/mini-player.tsx
apps/web/src/features/player/now-playing.tsx
apps/web/src/features/player/player-bar.tsx
apps/web/src/features/player/player-context.tsx
apps/web/src/features/player/progress-bar.tsx
apps/web/src/features/player/transcript-view.tsx
apps/web/src/features/settings/actions.ts
apps/web/src/features/settings/episode-prefs-fields.tsx
apps/web/src/features/settings/forms.tsx
apps/web/src/features/settings/plan-context.ts
apps/web/src/features/settings/rules.ts
apps/web/src/features/settings/studio-voice-form.tsx
apps/web/src/features/settings/studio-voices.ts
apps/web/src/features/settings/voice-picker.tsx
apps/web/src/features/shell/app-shell.tsx
apps/web/src/features/shell/brand-mark.tsx
apps/web/src/features/shell/mobile-header.tsx
apps/web/src/features/shell/nav-icons.tsx
apps/web/src/features/shell/nav-items.ts
apps/web/src/features/shell/page-container.tsx
apps/web/src/features/shell/page-heading.tsx
apps/web/src/features/shell/sidebar-nav.tsx
apps/web/src/features/shell/tab-bar.tsx
apps/web/src/features/sources/actions.ts
apps/web/src/features/sources/forms.tsx
apps/web/src/features/sources/relative-time.ts
apps/web/src/features/today/auto-refresh.tsx
apps/web/src/features/today/chapter-list-empty.tsx
apps/web/src/features/today/chapter-list.tsx
apps/web/src/features/today/detail-panel-empty.tsx
apps/web/src/features/today/empty-episode-card.tsx
apps/web/src/features/today/episode-card.tsx
apps/web/src/features/today/episode-cover.tsx
apps/web/src/features/today/failure-banner.tsx
apps/web/src/features/today/format-date.test.ts
apps/web/src/features/today/format-date.ts
apps/web/src/features/today/in-progress-card.tsx
apps/web/src/features/today/make-now-button.tsx
apps/web/src/features/today/pipeline-steps.ts
apps/web/src/features/today/schedule.test.ts
apps/web/src/features/today/schedule.ts
apps/web/src/features/today/step-progress.tsx
apps/web/src/features/today/transcript-panel.tsx
apps/web/src/features/users/current-user.ts
apps/web/src/features/users/forwarding-address.ts
apps/web/src/features/users/initials.test.ts
apps/web/src/features/users/initials.ts
apps/web/src/lib/auth0.ts
apps/web/src/lib/db.ts
apps/web/src/lib/env.ts
apps/web/src/lib/storage.ts
apps/web/src/lib/use-browser-value.ts
apps/web/src/lib/utils.ts
apps/web/src/proxy.ts
apps/web/tsconfig.json
apps/web/vitest.config.ts
apps/worker/Dockerfile
apps/worker/package.json
apps/worker/src/cli/args.ts
$ cat docs/project-memory.md; ls .claude .claude/rules 2>/dev/null; cat .claude/rules/* 2>/dev/null; cat apps/web/src/app/page.tsx apps/web/src/app/layout.tsx apps/web/src/app/globals.css apps/web/src/components/ui/button.tsx apps/web/src/features/shell/brand-mark.tsx apps/web/src/proxy.ts
# Unread · project memory (handoff)
Living state of the project for new sessions. Update it when milestones, decisions or blockers change. Last updated 2026-10-24.
## What it is
Forward newsletters to `<alias>@in.getunread.site` → one short, deduplicated, cited podcast episode → private RSS feed + web player. DEV Hacktoberfest 2026 Week 3 entry; deadline **Mon Oct 26, 07:59 WAT**. Owner: Lucky Victory (@CodewithVeek), repo `codewithveek/unread`, working branch `claude/gifted-albattani-gk5mjz`. Spec: `docs/prd-unread.md` (v2 + Kokoro/Bachs/promo/trial-limit updates). The canvas phone designs in `docs/design/` are the UI source of truth.
## Status (build order, PRD §13)
| | State |
|---|---|
| U0 repo, Next.js, schema, Auth0, tokens | Done. Auth0 tenant not created yet (login untested against a real tenant). |
| U1 inbound email | Done in code + tests. Cloudflare Email Routing/Worker not deployed. |
| U2 cluster → plan → script → check, `pnpm brief` | Done. **Never run against real Gemma** (no `GEMINI_API_KEY`/`OPENROUTER_API_KEY` yet); tests use a scripted fake model. |
| U3 voice, cover, publish, feed | Done. Kokoro verified end to end; ElevenLabs code done; key verified and the CLI test request succeeded (user approved, 2026-10-24); SFX not generated yet (`pnpm generate-assets`, ask first: it spends credits). |
| U4 UI | Done; screenshots in `docs/screenshots/u4`. |
| U5 links, news, plans, promos, Bachs, Temporal, deploy config | Done in code; no provider accounts/deploy yet. |
| U6 submission (video, write-up) | User's. |
## Architecture in one breath
`apps/inbound-worker` (Cloudflare Email Worker) HMAC-signs raw MIME → `POST /inbound/email` (`apps/web`) → `processInbound` (`packages/core/inbound`): alias → user, verified-sender + SPF/DKIM gate, Gmail forwarding-confirmation code, sender verification by code, bare links, or newsletter → cleaned + split into `items`. Episodes: `episode_requests` row (scheduled by dispatcher or "Make one now") → Temporal `makeEpisodeWorkflow` (or the poller fallback) → steps in `packages/core/episodes/steps.ts`: start → prepareScript (news fetch if opted in, collect, EmbeddingGemma + pgvector clustering with Gemma confirming borderline pairs, heard-items filter, Gemma ranking within priority tiers, per-chapter Gemma JSON scripts, deterministic fact check + one repair) → voiceEpisode (Kokoro/ElevenLabs per line, cached, ffmpeg −16 LUFS, line timings) → renderEpisodeCover (seeded SVG → resvg PNG) → publishEpisode (chapters, sources, heard_items, items used, usage, trial counter — one transaction). Feed `/feed/<token>.xml`, chapters JSON, covers and audio are served by token.
## Key decisions (details in `docs/decisions/`)
- 0001 pnpm monorepo, Next 16 (proxy.ts), TS 6.0 pinned, Tailwind v4 + shadcn tokens.
- 0002 Auth0 v4; `requireUser()` in pages; user row created lazily; sign-in email becomes a verified sender.
- 0003 Postgres on Tiger Data: pgvector `vector(768)`, `listening_events` hypertable (conditional migration), ULIDs, migrations 0000–0008.
- 0004 Cloudflare Email Routing → Email Worker → signed relay (`X-Unread-Signature`).
- 0005 Gemma `gemma-4-31b-it` via Gemini **native** API (OpenAI-compatible endpoint optional), OpenRouter fallback; EmbeddingGemma ONNX on CPU (transformers.js **3.8.1**, pinned to match kokoro-js).
- 0006 Kokoro (Free, 4 voices; `am_michael`/`bf_emma` are placeholders pending listening tests) / ElevenLabs (Plus: Standard `eleven_flash_v2_5`, Studio `eleven_v4` for paid Plus only; trials use Standard).
- 0007 Bachs: checkout, portal, V2-signed webhooks, idempotent by event id, plan changes only from webhooks; promo codes with atomic redemption, verified email, trial episode limit (default 3).
- 0008 Temporal schedules (dispatch every 10 min, maintenance hourly) or poller; Render web (Node) + worker (Docker, ffmpeg, baked models).
- 0009 SerpApi news (36 h window), SSRF-safe Readability, S3/R2 storage, Resend mail.
## Environment facts (this cloud sandbox)
- **Outbound is HTTPS-only via a proxy.** Raw-TCP databases are unreachable: Tiger Data (`PG_URL`, port 31116) times out even after the user switched the environment to full network access (the running container kept the old policy; retest in a fresh session). If still blocked, migrate from Render (`preDeployCommand: pnpm db:migrate`) or the user's machine. Don't mock around it.
- `PG_URL` = Tiger Data (preferred by `loadConfig` and drizzle config). `DATABASE_URL` in this env points at an unrelated TiDB Cloud host; it's only a fallback.
- `ELEVENLABS_API_KEY` is set as an env secret (key ends a478). ElevenLabs CLI rules: never print/cat env files, never put the key in code, don't make speech to "try it" until the user says yes (`npx -y @elevenlabs/cli@latest onboard test --env-path .env.local` is the test command).
- Also present but **not to be used without asking**: `CLOUDFLARE_API_TOKEN`, AWS keys, `RESEND_API_KEY`.
- Local Postgres 16 is installed; it stops when the container recycles: `service postgresql start`. pgvector came from `apt-get install postgresql-16-pgvector`. Databases `unread` (dev) and `unread_test` (tests), user/password `unread`. Run commands with `PG_URL= DATABASE_URL=[REDACTED] …` locally (empty `PG_URL` so it doesn't win).
- drizzle-kit `generate` prompts for renames and needs a TTY: `(sleep 4; printf '\r') | script -qec "npx drizzle-kit generate --name x" /dev/null`.
- Docker works (`dockerd` may need starting). Building the worker image here needs the proxy CA copied in (test-only variant); the committed Dockerfile is for Render.
- Models cache in `$TMPDIR/unread-models` (Kokoro ~0.77× real time on CPU; EmbeddingGemma loads in ~5 s).
## How to verify things
- Fast: `pnpm typecheck && pnpm lint && pnpm test`.
- Integration: `TEST_DATABASE_URL=[REDACTED] RUN_EMBEDDING_TESTS=1 RUN_TEMPORAL_TESTS=1 pnpm test` (real EmbeddingGemma, Kokoro, ffmpeg, Temporal time-skipping).
- UI without Auth0: mint a session cookie with `generateSessionCookie` from `@auth0/nextjs-auth0/testing` using the `AUTH0_SECRET` in `apps/web/.env.local`, set it as `__session`, and drive Playwright (`/opt/node22/lib/node_modules/playwright`). The dev DB has a smoke user `auth0|u0-smoke` with one seeded Kokoro episode.
- Web routes typecheck can show stale `RouteImpl` errors for new routes until `next build` regenerates `.next/types`.
## Open items / next steps
1. User to broaden Network access → then inspect Tiger DB (confirm empty), `PG_URL=… pnpm db:migrate`, check the hypertable got created.
2. Gemma key (`GEMINI_API_KEY` and/or `OPENROUTER_API_KEY`) → run `pnpm brief --user <id>` on real newsletters; tune prompts if the check drops many lines; try `GEMINI_API_STYLE=openai`.
3. `pnpm generate-assets` for the SFX (ask before spending credits).
4. Accounts + deploy (docs/deploy.md): Auth0 app, Cloudflare Email Routing + `wrangler deploy`, R2 bucket, Render blueprint, Temporal Cloud (optional), Bachs sandbox products + webhook, SerpApi, Resend domain.
5. Measure Kokoro speed on the Render instance; tune `KOKORO_CONCURRENCY`.
6. Landing page `/` is minimal (hero, two CTAs, privacy link); a fuller marketing page is not built.
7. PRD inconsistencies resolved by assumption: Free voices 4 (§8) not 3 (§9); Free cap = 4 episodes/month; `/inbound/email` writes `items` (not `saved_items`).
## Working with this user
- Wants autonomous end-to-end building ("build everything start to finish"), short progress updates, and honest status (what's verified vs not).
- Commit and push to the working branch after each milestone; no PRs unless asked.
- Ask before outward-facing actions (deploys, emails, spending provider credits).
.claude:
rules
.claude/rules:
database.md
pipeline.md
web.md
---
paths:
- "packages/db/**"
---
# Database rules
- After editing `src/schema.ts`: `pnpm db:generate` (needs a TTY when it asks about renames; see docs/project-memory.md), then migrate both local DBs (`unread`, `unread_test`). Prefer additive migrations; nothing is deployed to Tiger yet, but treat migrations 0000+ as shipped.
- Custom SQL migrations exist: 0000 enables pgvector, 0002 makes `listening_events` a Timescale hypertable only when the extension exists.
- `listening_events` has no foreign keys (hypertable); account deletion removes its rows explicitly.
- Uniqueness that the app relies on: inbound `(user_id, message_id)`, scheduled `episode_requests (user_id, date)`, `billing_events.id`, promo `(code_id, user_id)`, case-insensitive codes/senders/topics.
---
paths:
- "packages/core/**"
- "apps/worker/**"
---
# Pipeline and worker rules
- Episode steps (`packages/core/src/episodes/steps.ts`) must stay idempotent: each checks its stored step output (`storageKeys.stepOutput`) or episode state before redoing work, because Temporal retries activities.
- Rejected inbound mail must never reach a model. Gmail confirmations and sender verifications are handled before the verified-sender gate; everything else needs a verified forwarder + SPF/DKIM pass (or `INBOUND_TRUST_UPSTREAM_AUTH`).
- Scripts must stay honest (P2): every chapter line cites ≥ 1 of its chapter's sources; numbers/quotes must appear in cited source text; ≤ 30 words; intro/outro are templates. Don't weaken `checkLine` to make a model pass.
- Voice code never checks plans; `voiceProviderFor(id, config, { quality })` is chosen from `effectivePlan()`. ElevenLabs reads `ELEVENLABS_API_KEY` from the environment only.
- Keep `@huggingface/transformers` at the same 3.x version as kokoro-js (one ONNX runtime per process).
- In drizzle `sql` templates never interpolate `Date` values; use `lt/gt/eq`.
- New external API: verify against current docs, then write an ADR in `docs/decisions/`.
---
paths:
- "apps/web/**"
---
# Web app rules (apps/web)
- Pages and route handlers check the session themselves: `requireUser()` in pages/actions, `currentUser()` (returns null) in JSON route handlers. Webhooks (`/inbound/email`, `/billing/webhooks/bachs`) verify signatures instead; feed/audio/cover routes authorise by feed token.
- Mutations are server actions in `features/<feature>/actions.ts` ("use server"), validated with Zod, returning `ActionState` for `useActionState`; call `revalidatePath` after writes.
- Import narrow core subpaths only: `@unread/core/episodes/requests`, `@unread/core/episodes/transcript`, `@unread/core/voice/voices`, `@unread/core/voice/elevenlabs`. Never `@unread/core/episodes` or `@unread/core/voice` barrels (they pull the cover renderer, Kokoro, ONNX and ffmpeg code, which breaks `next build`).
- Browser-only values (timezone, locale) use `useBrowserValue` (useSyncExternalStore), never setState inside an effect (lint error).
- The audio element lives once in `PlayerProvider` (AppShell). Never put side effects inside React state updaters (load() bug: play() ran before src was set).
- Plan limits are enforced on the server (`applyPlanLimits`, `planContextFor`), not only in the UI.
- Escape apostrophes in JSX text (`'`), use design tokens, ≥ 44 px targets.
import Link from "next/link";
import { redirect } from "next/navigation";
import { BrandMark } from "@/features/shell/brand-mark";
import { Button } from "@/components/ui/button";
import { auth0 } from "@/lib/auth0";
export default async function LandingPage() {
const session = await auth0.getSession();
if (session) redirect("/today");
return (
<main className="mx-auto flex min-h-dvh w-full max-w-[640px] flex-col justify-between gap-10 px-6 pt-6 pb-10">
<BrandMark />
<div className="flex flex-col gap-4">
<p className="m-0 font-mono text-[13px] font-medium tracking-[0.02em] text-muted-foreground">
YOUR NEWSLETTERS, AS A PODCAST
</p>
<h1 className="m-0 text-[40px] leading-[1.05] font-bold tracking-[-0.03em] sm:text-[52px]">
Your newsletters, minus the overlap, in 10 minutes of audio.
</h1>
<p className="m-0 text-base leading-[1.5] text-muted-foreground">
Forward your newsletters to Unread. We merge the stories they share, summarise what matters with a source for
every line, and deliver one short episode to the podcast app you already use.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
{/* Plain <a>: auth routes must not be prefetched. */}
<Button asChild size="lg">
<a href="/auth/login?screen_hint=signup&returnTo=/today">Get started</a>
</Button>
<Button asChild size="lg" variant="secondary">
<a href="/auth/login?returnTo=/today">Sign in</a>
</Button>
</div>
<Link href="/privacy" className="text-sm text-muted-foreground">
Privacy: what we store and who sees it
</Link>
</main>
);
}
import { GeistMono } from "geist/font/mono";
import { GeistSans } from "geist/font/sans";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: { default: "Unread", template: "%s · Unread" },
description:
"Forward your newsletters to Unread and get one short, deduplicated podcast episode with the parts that matter.",
};
export const viewport: Viewport = {
themeColor: "#0E0F12",
colorScheme: "dark",
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body>{children}</body>
</html>
);
}
@import "tailwindcss";
@import "tw-animate-css";
/*
* Design tokens from docs/design/README.md. Unread is dark-only.
* Names follow shadcn/ui so its components pick them up; the
* Unread-specific names (surface, line, text-2, link) are aliases.
*/
:root {
color-scheme: dark;
--ground: #0e0f12;
--surface: #17181d;
--surface-2: #1f2128;
--line: #2a2d35;
--text: #f2f3f5;
--text-2: #d6d9df;
--muted-text: #a3a8b3;
--dim-text: #8a909c;
--brand: #5b9bff;
--on-brand: #0a1a33;
--link: #8fb8ff;
--link-hover: #b9d2ff;
--radius: 0.875rem;
--background: var(--ground);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--popover: var(--surface-2);
--popover-foreground: var(--text);
--primary: var(--brand);
--primary-foreground: var(--on-brand);
--secondary: var(--surface-2);
--secondary-foreground: var(--text);
--muted: var(--surface-2);
--muted-foreground: var(--muted-text);
--accent: var(--surface-2);
--accent-foreground: var(--text);
--destructive: #f08a7a;
--border: var(--line);
--input: var(--line);
--ring: var(--brand);
}
@theme inline {
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
--color-ground: var(--ground);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-line: var(--line);
--color-text: var(--text);
--color-text-2: var(--text-2);
--color-dim: var(--dim-text);
--color-brand: var(--brand);
--color-on-brand: var(--on-brand);
--color-link: var(--link);
--color-link-hover: var(--link-hover);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 6px);
--radius-md: calc(var(--radius) - 4px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 6px);
--breakpoint-lg: 64rem;
}
@layer base {
* {
@apply border-border;
}
html,
body {
@apply bg-background text-foreground font-sans antialiased;
}
a {
color: var(--link);
}
a:hover {
color: var(--link-hover);
}
:focus-visible {
@apply outline-2 outline-offset-2 outline-ring;
}
}
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-full font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
secondary:
"border border-line bg-surface text-foreground hover:bg-surface-2 hover:text-foreground",
ghost: "text-foreground hover:bg-surface-2 hover:text-foreground",
link: "text-link underline-offset-4 hover:text-link-hover hover:underline",
},
size: {
default: "h-11 px-5 text-sm",
lg: "h-12 px-6 text-base",
icon: "size-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
);
}
export { Button, buttonVariants };
import { cn } from "@/lib/utils";
/** The "unread dot" and wordmark. */
export function BrandMark({ className }: { className?: string }) {
return (
<span className={cn("flex items-center gap-2", className)}>
<span aria-hidden="true" className="inline-block size-2.5 rounded-full bg-brand" />
<span className="text-xl font-bold tracking-[-0.02em] text-foreground">Unread</span>
</span>
);
}
import { auth0 } from "@/lib/auth0";
/**
* Mounts /auth/* and keeps the rolling session fresh. Pages still check the
* session themselves. Webhooks, the feed and audio are token/signature-based,
* so they skip it.
*/
export async function proxy(request: Request) {
return await auth0.middleware(request);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon.svg|sitemap.xml|robots.txt|inbound/|feed/|audio/|billing/webhooks/|voices/).*)"],
};
Current / is a minimal hero; I'll replace it with the full design. Checking a few details (onboarding redirect, price, topics, terms page).
$ grep -rn -i "onboard" apps/web/src/app/\(app\)/layout.tsx apps/web/src/app/onboarding/page.tsx | head; grep -rn -i -E "price|\\\$[0-9]|per month|/ ?month" docs/prd-unread.md packages/core/src/plans 2>/dev/null | head -20; cat apps/web/src/features/news/topics.ts | head -30; ls apps/web/src/app/privacy; cat apps/web/src/app/privacy/page.tsx | head -30; cat apps/web/package.json; ls docs/screenshots/u0; grep -n -i -A30 "landing" docs/prd-unread.md | head -60
apps/web/src/app/onboarding/page.tsx:11:import { FinishForm } from "@/features/onboarding/finish-form";
apps/web/src/app/onboarding/page.tsx:35:export default async function OnboardingPage({ searchParams }: { searchParams: Promise<{ step?: string }> }) {
apps/web/src/app/onboarding/page.tsx:36: const { user } = await requireUser("/onboarding");
apps/web/src/app/onboarding/page.tsx:55: <StepHeader step={1} skipHref="/onboarding?step=2" />
apps/web/src/app/onboarding/page.tsx:68: <Link href={"/onboarding?step=2" as Route} className="flex h-[54px] items-center justify-center rounded-full bg-brand text-base font-semibold text-on-brand no-underline hover:text-on-brand">
apps/web/src/app/onboarding/page.tsx:76: <StepHeader step={2} skipHref="/onboarding?step=3" />
apps/web/src/app/onboarding/page.tsx:84: nextHref="/onboarding?step=3"
docs/prd-unread.md:141:ElevenLabs API list prices (verify before launch): Flash/Turbo ≈ $0.04 per 1K characters; Multilingual v2 / v3 ≈ $0.08 per 1K; sound effects ≈ $0.12 per generated minute. ~900 characters ≈ 1 minute of speech. Kokoro has no per-character cost (CPU time on our worker).
docs/prd-unread.md:143:| | Free | Plus (price set in Bachs, e.g. $8–10/mo or local equivalent) |
docs/prd-unread.md:148:| Monthly cap | Episode count (4/month) | ~350K ElevenLabs characters (the episode shortens to fit, never fails silently) |
docs/prd-unread.md:162:- Setup: one recurring product per Plus price (monthly; annual optional). Store product ids in config, not code.
/** Global suggestions (P5: no location assumptions; local topics only if the user adds them). */
export const SUGGESTED_TOPICS = [
"AI",
"Startups",
"Climate",
"Markets",
"Science",
"Health",
"Design",
"Space",
"Crypto",
"Gaming",
"Football",
"World news",
] as const;
export const NEWS_EDITIONS = [
{ country: "US", language: "en", label: "United States · English" },
{ country: "GB", language: "en", label: "United Kingdom · English" },
{ country: "NG", language: "en", label: "Nigeria · English" },
{ country: "KE", language: "en", label: "Kenya · English" },
{ country: "ZA", language: "en", label: "South Africa · English" },
{ country: "GH", language: "en", label: "Ghana · English" },
{ country: "IN", language: "en", label: "India · English" },
{ country: "CA", language: "en", label: "Canada · English" },
{ country: "AU", language: "en", label: "Australia · English" },
{ country: "IE", language: "en", label: "Ireland · English" },
{ country: "DE", language: "de", label: "Germany · Deutsch" },
{ country: "FR", language: "fr", label: "France · Français" },
{ country: "ES", language: "es", label: "Spain · Español" },
page.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { BrandMark } from "@/features/shell/brand-mark";
export const metadata: Metadata = { title: "Privacy", description: "What Unread stores and what each provider receives." };
const sections: { title: string; body: string[] }[] = [
{
title: "What we store",
body: [
"Your account (from Auth0), your settings, the newsletters you forward, the links you save, the episodes we make for you, and listening activity (play, skip, complete) used to rank stories.",
"Raw forwarded emails are kept for 30 days, then deleted. Only mail from addresses you've verified is accepted; everything else is dropped without being read by any model.",
],
},
{
title: "What Google receives (Gemma via the Gemini API)",
body: ["The cleaned text of your newsletters and saved articles, to cluster, rank, summarise and script your episode. Nothing else about you."],
},
{
title: "What ElevenLabs receives (Plus and trials only)",
body: [
"Only the finished episode script: our summaries, newsletter names and any short quotes kept in the script. Never your raw newsletters. Free episodes are voiced on our own servers with Kokoro, an open model.",
],
},
{
title: "Payments",
body: ["Plus is billed by Bachs. We never see your card; we store the subscription status Bachs sends us."],
},
{
title: "Your private feed",
{
"name": "@unread/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@auth0/nextjs-auth0": "4.33.0",
"@temporalio/client": "1.24.0",
"@unread/core": "workspace:*",
"@unread/db": "workspace:*",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"drizzle-orm": "0.45.4",
"geist": "1.8.2",
"lucide-react": "1.61.0",
"next": "16.4.3",
"radix-ui": "1.8.1",
"react": "19.3.1",
"react-dom": "19.3.1",
"server-only": "^0.0.1",
"tailwind-merge": "3.7.0",
"zod": "4.6.7"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/node": "^22.20.5",
"@types/react": "19.3.0",
"@types/react-dom": "^19.3.0",
"eslint": "^9.39.5",
"eslint-config-next": "16.4.3",
"tailwindcss": "4.3.3",
"tw-animate-css": "1.4.0",
"typescript": "6.0.3",
"vitest": "5.0.5"
}
}
landing-phone.png
today-desktop.png
today-phone.png
$ grep -rn "onboarding" apps/web/src --include=*.ts --include=*.tsx | grep -v "app/onboarding" | head; grep -rn -i "PLUS_PRICE\|price" apps/web/src packages/core/src/plans packages/core/src/billing .env.example 2>/dev/null | head; ls apps/web/node_modules >/dev/null 2>&1 && echo deps-installed; node -v
apps/web/src/app/(app)/today/page.tsx:26: if (!user.onboardedAt) redirect("/onboarding");
apps/web/src/app/api/status/route.ts:7:/** Polled by onboarding (live forwarding status) and Home (episode progress). */
apps/web/src/features/inbound/queries.ts:12:/** Live status for onboarding ("Got Morning Brew ✓") and the Gmail confirmation code (prd §9). */
apps/web/src/features/news/news-form.tsx:15: /** Where to go after saving (onboarding), or stay put (Sources). */
apps/web/src/features/onboarding/actions.ts:14:/** Step 3 → "Make my first episode": save preferences, finish onboarding, start an episode. */
v24.14.0
Plan: a features/landing/ folder (content, sections, a small client sample player), and / composes it. Let me check the existing player icons and env pattern to reuse them.
$ cat apps/web/src/features/player/icons.tsx apps/web/src/features/player/format.ts; sed -n 1,60p apps/web/src/features/today/episode-cover.tsx; cat apps/web/src/lib/utils.ts
import type { SVGProps } from "react";
type P = SVGProps<SVGSVGElement>;
const base = { viewBox: "0 0 24 24", "aria-hidden": true } as const;
export function PlayIcon(props: P) {
return (
<svg {...base} {...props}>
<path d="M7 4.5v15l13-7.5z" fill="currentColor" />
</svg>
);
}
export function PauseIcon(props: P) {
return (
<svg {...base} {...props}>
<rect x="6" y="5" width="4" height="14" rx="1.2" fill="currentColor" />
<rect x="14" y="5" width="4" height="14" rx="1.2" fill="currentColor" />
</svg>
);
}
const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" } as const;
export function PrevIcon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M6 5v14" />
<path d="M19 5l-10 7 10 7z" fill="currentColor" />
</svg>
);
}
export function NextIcon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M18 5v14" />
<path d="M5 5l10 7-10 7z" fill="currentColor" />
</svg>
);
}
export function ChevronDownIcon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M6 9l6 6 6-6" />
</svg>
);
}
export function LinkIcon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1" />
<path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" />
</svg>
);
}
export function Back15Icon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M4 12a8 8 0 1 0 2.4-5.7" />
<path d="M4 4v4h4" />
<text x="12" y="15.5" textAnchor="middle" fontSize="7" fill="currentColor" stroke="none" fontFamily="var(--font-geist-mono)">
15
</text>
</svg>
);
}
export function Forward15Icon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M20 12a8 8 0 1 1-2.4-5.7" />
<path d="M20 4v4h-4" />
<text x="12" y="15.5" textAnchor="middle" fontSize="7" fill="currentColor" stroke="none" fontFamily="var(--font-geist-mono)">
15
</text>
</svg>
);
}
export function clock(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = String(s % 60).padStart(2, "0");
return h ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
}
export function remaining(current: number, duration: number): string {
return `−${clock(Math.max(0, duration - current))}`;
}
export function minutesLabel(seconds: number): string {
return `${Math.max(1, Math.round(seconds / 60))} min`;
}
import { cn } from "@/lib/utils";
export interface EpisodeCoverProps {
seed: string;
day: string;
caption: string;
/** Dim the bars for empty or in-progress states. */
muted?: boolean;
className?: string;
}
/** Small deterministic PRNG so a seed always draws the same bars (mulberry32). */
function seededRandom(seed: string): () => number {
let h = 1779033703 ^ seed.length;
for (let i = 0; i < seed.length; i++) {
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
let state = h >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const OPACITIES = [1, 0.55, 1, 0.35] as const;
/**
* In-app cover, after the canvas Home card: accent bars for what's covered,
* faint bars for the rest. The feed's PNG cover is rendered by the worker (U2).
*/
export function EpisodeCover({ seed, day, caption, muted = false, className }: EpisodeCoverProps) {
const random = seededRandom(seed);
const bars = Array.from({ length: 17 }, (_, i) => {
const accent = i < 10;
// Faint bars sit under the date label, so they stay below its baseline.
const height = 28 + Math.round(random() * (accent ? 80 : 52));
return { x: 18 + i * 18, height, accent, opacity: OPACITIES[i % OPACITIES.length] ?? 1 };
});
return (
<svg
role="img"
aria-label={`Cover for ${day} ${caption}`}
viewBox="0 0 342 168"
preserveAspectRatio="xMidYMid slice"
className={cn("block h-auto w-full", className)}
>
<rect width="342" height="168" fill="var(--surface-2)" />
{bars.map((bar) => (
<rect
key={bar.x}
x={bar.x}
y={148 - bar.height}
width="10"
height={bar.height}
rx="5"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Writing the feature folder now.
C:\Users\HP\hacks\unread\apps\web\src\features\landing\content.ts
File created successfully at: C:\Users\HP\hacks\unread\apps\web\src\features\landing\content.ts (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\apps\web\src\features\landing\sample-player.tsx
File created successfully at: C:\Users\HP\hacks\unread\apps\web\src\features\landing\sample-player.tsx (file state is current in your context — no need to Read it back)
Now the sections and the page.
C:\Users\HP\hacks\unread\apps\web\src\features\landing\sections.tsx
File created successfully at: C:\Users\HP\hacks\unread\apps\web\src\features\landing\sections.tsx (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\apps\web\src\app\page.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\app\page.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\apps\web\src\app\globals.css
The file C:\Users\HP\hacks\unread\apps\web\src\app\globals.css has been updated successfully.
C:\Users\HP\hacks\unread\apps\web\src\app\globals.css
The file C:\Users\HP\hacks\unread\apps\web\src\app\globals.css has been updated successfully.
Switching the header's #top link to a plain anchor (typed routes), then typecheck and lint.
C:\Users\HP\hacks\unread\apps\web\src\features\landing\sections.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\landing\sections.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
$ ls node_modules >/dev/null 2>&1 && echo installed || echo missing; ls apps/web/node_modules 2>/dev/null | head -3
missing
Dependencies aren't installed locally; installing so I can typecheck, lint and look at the page.
$ (pnpm -v || corepack pnpm -v) 2>&1 | tail -2; pnpm install --frozen-lockfile 2>&1 | tail -15
10.28.0
Progress: resolved 726, reused 260, downloaded 464, added 695
Progress: resolved 726, reused 260, downloaded 464, added 704
Progress: resolved 726, reused 260, downloaded 464, added 712
Progress: resolved 726, reused 260, downloaded 464, added 715
Progress: resolved 726, reused 260, downloaded 464, added 716
Progress: resolved 726, reused 260, downloaded 464, added 718
Progress: resolved 726, reused 260, downloaded 464, added 719
Progress: resolved 726, reused 260, downloaded 464, added 721
Progress: resolved 726, reused 260, downloaded 464, added 722
Progress: resolved 726, reused 260, downloaded 464, added 723
Progress: resolved 726, reused 260, downloaded 465, added 723
Progress: resolved 726, reused 260, downloaded 465, added 724
Progress: resolved 726, reused 260, downloaded 466, added 724
Progress: resolved 726, reused 260, downloaded 466, added 725
ERR_PNPM_ENOSPC ENOSPC: no space left on device, copyfile 'C:\Users\HP\AppData\Local\pnpm\store\v10\files\97\feb186c994a68f517fcb8651e2951f5aac0afd399420c618e6a1c8d2a969c36d23e247304f71daa0a96ca06fa1ec96e804d111df6789d201831786c462a9c8' -> 'C:\Users\HP\hacks\unread\node_modules\.pnpm\onnxruntime-node@1.21.0\node_modules\onnxruntime-node_tmp_59100\bin\napi-v3\darwin\arm64\libonnxruntime.1.21.0.dylib'
$ df -h /c | tail -1; cd /c/Users/HP/hacks/unread && ls apps/web/node_modules/.bin 2>/dev/null | grep -E "^(tsc|eslint|next)$"
C: 238G 238G 97M 100% /c
C:\Users\HP\hacks\unread\docs\project-memory.md
The file C:\Users\HP\hacks\unread\docs\project-memory.md has been updated successfully.
$ git status --short; du -sh node_modules 2>/dev/null
Command did not complete within its 120s timeout and was moved to the background (ID: b6ia3wpv0). Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\54d1a7fa-712e-4a54-8760-ad71ded39900\tasks\b6ia3wpv0.output. You will be notified when it completes. To check interim output, use Read on that file path.
I rebuilt / from docs/design/Landing.dc.html, but I haven't typechecked, linted or opened it in a browser. Your C: drive is full (97 MB free), so pnpm install stopped partway with "no space left on device". I only checked the code by reading it.
What's built
- page.tsx now shows the full page: header, hero, how it works, "Hear each story once", news topics, pricing, FAQ, the closing call to action and the footer. Signed-in visitors still go straight to /today.
- The page's pieces are in sections.tsx, and the copy is in content.ts.
- sample-player.tsx is a small player shared by the hero's sample card and the three chapter play buttons.
- I added one colour token, ground-2 (#121318), to globals.css for the alternating section backgrounds.
- Every sign-up button sends people to the Auth0 sign-up page and then to /today, which sends new users to onboarding.
Where I changed the design
- Sign in link: I added one to the header. The design has none, and without it returning users have no way back in.
- Mobile header: below about 768 px, the How it works, Pricing and FAQ links are hidden so the header fits on one row.
- FAQ arrow: each question has a chevron on the right instead of the browser's default marker.
- Terms link: I left it out of the footer because there is no Terms page yet.
Still missing before launch
1. Plus price: the page shows [PRICE] until you set PLUS_PRICE in content.ts.
2. Sample audio: there is no apps/web/public/sample-episode.mp3 yet. Until you add it, the play buttons are disabled and the card says "Sample episode coming soon". When you add it, update each chapter's startSeconds to match the recording.
I also updated item 6 in project-memory.md to list these gaps. Nothing is committed.
Next step: free some space on C:, then run pnpm install && pnpm typecheck && pnpm lint && pnpm dev. The failed install left a partial node_modules in the repo that is taking up space. Do you want me to delete it?
free my storage n run it
I'll only clear caches and temp files that rebuild themselves, starting with the partial install I created. First, a look at what's taking space.
$ cat "C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/54d1a7fa-712e-4a54-8760-ad71ded39900/tasks/b6ia3wpv0.output" 2>/dev/null | tail -5; df -h /c | tail -1
M apps/web/src/app/globals.css
M apps/web/src/app/page.tsx
M docs/project-memory.md
?? apps/web/src/features/landing/
C: 238G 238G 144M 100% /c
{"command":"$targets = @(\"$env:LOCALAPPDATA\\Temp\", \"$env:LOCALAPPDATA\\pnpm\\store\", \"$env:LOCALAPPDATA\\npm-cache\", \"$env:APPDATA\\npm-cache\", \"$env:LOCALAPPDATA\\Yarn\\Cache\", \"$env:LOCA...
2,054 MB C:\Users\HP\AppData\Local\Temp
17,670 MB C:\Users\HP\AppData\Local\pnpm\store
2,210 MB C:\Users\HP\AppData\Local\npm-cache
3 MB C:\Users\HP\AppData\Roaming\npm-cache
4,682 MB C:\Users\HP\AppData\Local\Yarn\Cache
0 MB C:\Users\HP\AppData\Local\pip\Cache
0 MB C:\Users\HP\AppData\Local\Microsoft\Windows\INetCache
280 MB C:\Users\HP\AppData\Local\Google\Chrome\User Data\Default\Cache
37 MB C:\Users\HP\AppData\Local\Microsoft\Edge\User Data\Default\Cache
701 MB C:\Users\HP\AppData\Local\ms-playwright
70 MB C:\Users\HP\.cache
0 MB C:\Windows\Temp
1,145 MB C:\Users\HP\hacks\unread\node_modules
2,641 MB C:\Users\HP\Downloads
34,783 MB C:\Users\HP\AppData\Local\Docker
52 MB C:\Users\HP\AppData\Local\CrashDumps
Recycle Bin: no access
{"command":"npm cache clean --force 2>$null; \"npm done\"\nif (Test-Path \"$env:LOCALAPPDATA\\Yarn\\Cache\") { Get-ChildItem \"$env:LOCALAPPDATA\\Yarn\\Cache\" -Force | Remove-Item -Recurse -Force -Co...
Remove-Item on system path '$env:LOCALAPPDATA\CrashDumps\*' is blocked. This path is protected from removal.
Crash dumps are protected, so I'm skipping them and running the rest.
{"command":"npm cache clean --force 2>$null; \"npm done\"\npnpm store prune 2>$null | Select-Object -Last 3\nGet-PSDrive C | Select-Object @{n='FreeGB';e={[math]::Round($_.Free/1GB,2)}}","description"...
npm done
Removed all cached metadata files
Removed 1822 files
Removed 282 packages
FreeGB
------
5.45
5.4 GB free now. I'll also clear the Yarn cache with Yarn's own command, then reinstall.
{"command":"if (Get-Command yarn -ErrorAction SilentlyContinue) { yarn cache clean 2>$null | Select-Object -Last 1 } else { \"yarn not installed; leaving its cache\" }\nGet-PSDrive C | Select-Object @...
yarn not installed; leaving its cache
FreeGB
------
5.45
$ pnpm install --frozen-lockfile 2>&1 | grep -v "^Progress" | tail -20; df -h /c | tail -1
.../esbuild@0.18.20/node_modules/esbuild postinstall$ node install.js
.../esbuild@0.28.2/node_modules/esbuild postinstall$ node install.js
.../sharp@0.34.5/node_modules/sharp install$ node install/check.js || npm run build
.../node_modules/unrs-resolver postinstall$ node postinstall.js
.../esbuild@0.25.12/node_modules/esbuild postinstall$ node install.js
.../esbuild@0.28.2/node_modules/esbuild postinstall: Done
.../node_modules/unrs-resolver postinstall: Done
.../sharp@0.34.5/node_modules/sharp install: Done
.../esbuild@0.25.12/node_modules/esbuild postinstall: Done
.../esbuild@0.18.20/node_modules/esbuild postinstall: Done
╭ Warning ─────────────────────────────────────────────────────────────────────╮
│ │
│ Ignored build scripts: @swc/core@1.16.20, onnxruntime-node@1.21.0, │
│ protobufjs@7.6.6, workerd@1.20261019.1. │
│ Run "pnpm approve-builds" to pick which dependencies should be allowed │
│ to run scripts. │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Done in 3m 24.4s using pnpm v10.28.0
C: 238G 233G 5.1G 98% /c
Installed. Now typecheck and lint the web app.
$ cd /c/Users/HP/hacks/unread/apps/web && pnpm typecheck 2>&1 | tail -20; echo "--- lint"; pnpm exec eslint src/features/landing src/app/page.tsx 2>&1 | tail -30
> @unread/web@0.0.0 typecheck C:\Users\HP\hacks\unread\apps\web
> tsc --noEmit
--- lint
API Error: Can't reach the API server — check your internet or DNS (ENOTFOUND)
continue
Typecheck and lint both pass with no errors. Next I'll start the dev server. First I'm checking whether there's a local env file (I won't open it), because the page calls Auth0.
$ ls apps/web/.env* 2>&1; sed -n 1,40p apps/web/src/lib/auth0.ts
ls: cannot access 'apps/web/.env*': No such file or directory
import { Auth0Client } from "@auth0/nextjs-auth0/server";
/** Reads AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_SECRET and APP_BASE_URL. */
export const auth0 = new Auth0Client({
signInReturnToPath: "/today",
});
$ cd /c/Users/HP/hacks/unread/apps/web && AUTH0_DOMAIN=placeholder.auth0.com AUTH0_CLIENT_ID=placeholder AUTH0_CLIENT_SECRET=placeholder AUTH0_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") APP_BASE_URL=http://localhost:3000 pnpm dev 2>&1
Command running in background with ID: bl3fkcq6g. Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\54d1a7fa-712e-4a54-8760-ad71ded39900\tasks\bl3fkcq6g.output. You will be notified when it completes — do not poll that file or sleep while waiting. Read the file only if the user asks for progress, or if you need a line it prints before you can go on, such as a server's ready line or port.
Session cwd remains C:\Users\HP\hacks\unread; directory changes made by the backgrounded command do not apply to subsequent commands.
{
"query": "select:Monitor",
"max_results": 1
}
$ f="C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/54d1a7fa-712e-4a54-8760-ad71ded39900/tasks/bl3fkcq6g.output"; for i in $(seq 1 120); do grep -qE "Ready in|Error|EADDRINUSE" "$f" && break; sleep 1; done; cat "$f" | tail -15; curl -s -o /dev/null -w "HTTP %{http_code} in %{time_total}s\n" http://localhost:3000/
> @unread/web@0.0.0 dev C:\Users\HP\hacks\unread\apps\web
> next dev
▲ Next.js 16.4.3 (Turbopack)
- Local: http://localhost:3000
- Network: http://172.20.64.1:3000
✓ Ready in 28.8s
HTTP 200 in 64.588430s
The page returns 200. Next I'll take screenshots at desktop and phone widths to compare against the design.
$ ls "$LOCALAPPDATA/ms-playwright"; find /c/Users/HP -maxdepth 6 -path "*node_modules/playwright/package.json" 2>/dev/null | head -3
Command did not complete within its 120s timeout and was moved to the background (ID: bqo0al9jc). Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\54d1a7fa-712e-4a54-8760-ad71ded39900\tasks\bqo0al9jc.output. You will be notified when it completes. To check interim output, use Read on that file path.
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\54d1a7fa-712e-4a54-8760-ad71ded39900\tasks\bqo0al9jc.output
1 chromium-1234
2 chromium_headless_shell-1234
3 ffmpeg-1011
4 winldd-1007
5
$ S="C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/54d1a7fa-712e-4a54-8760-ad71ded39900/scratchpad"; mkdir -p "$S/shot" && cd "$S/shot" && npm init -y >/dev/null && npm i playwright-core@1.56.1 --silent 2>&1 | tail -2; ls "$LOCALAPPDATA/ms-playwright/chromium-1234"
DEPENDENCIES_VALIDATED
INSTALLATION_COMPLETE
chrome-win64
Shell cwd was reset to C:\Users\HP\hacks\unread
$ S="C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/54d1a7fa-712e-4a54-8760-ad71ded39900/scratchpad"; cat > "$S/shot/shot.mjs" <<'EOF'
import { chromium } from "playwright-core";
const out = process.argv[2];
const browser = await chromium.launch({ executablePath: process.env.LOCALAPPDATA + "/ms-playwright/chromium-1234/chrome-win64/chrome.exe" });
for (const [name, width, height] of [["desktop", 1440, 900], ["phone", 390, 844]]) {
const page = await browser.newPage({ viewport: { width, height } });
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
page.on("pageerror", (e) => errors.push(e.message));
await page.goto("http://localhost:3000/", { waitUntil: "networkidle", timeout: 120000 });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: `${out}/${name}.png`, fullPage: true });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
const sample = await page.locator("#sample").innerText();
console.log(name, "horizontal overflow px:", overflow, "| sample:", sample.replace(/\n/g, " "), "| errors:", JSON.stringify(errors));
}
await browser.close();
EOF
cd "$S/shot" && node shot.mjs "$S"
desktop horizontal overflow px: 0 | sample: Sample: 6 newsletters in 9 minutes 0:00 / 8:40 | errors: ["Failed to load resource: the server responded with a status of 404 (Not Found)"]
phone horizontal overflow px: 0 | sample: Sample: 6 newsletters in 9 minutes 0:00 / 8:40 | errors: ["Failed to load resource: the server responded with a status of 404 (Not Found)"]
Shell cwd was reset to C:\Users\HP\hacks\unread
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\54d1a7fa-712e-4a54-8760-ad71ded39900\scratchpad\desktop.png
[Image: original 1440x4821, displayed at 597x2000. Multiply coordinates by 2.41 to map to original image.]
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\54d1a7fa-712e-4a54-8760-ad71ded39900\scratchpad\phone.png
[Image: original 390x7003, displayed at 111x2000. Multiply coordinates by 3.51 to map to original image.]
C:\Users\HP\hacks\unread\apps\web\src\features\landing\sample-player.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\landing\sample-player.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\apps\web\src\features\landing\sample-player.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\landing\sample-player.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
$ cd /c/Users/HP/hacks/unread/apps/web && pnpm exec tsc --noEmit 2>&1 | tail -5; pnpm exec eslint src/features/landing src/app/page.tsx 2>&1 | tail -15; S="C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/54d1a7fa-712e-4a54-8760-ad71ded39900/scratchpad"; cd "$S/shot" && node shot.mjs "$S"
Command did not complete within its 300s timeout and was moved to the background (ID: bcli0tf3u). Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\54d1a7fa-712e-4a54-8760-ad71ded39900\tasks\bcli0tf3u.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains C:\Users\HP\hacks\unread; directory changes made by the backgrounded command do not apply to subsequent commands.
The machine is slow under this load. I'll wait for that check to finish.
{"description":"landing re-check (tsc, eslint, screenshots) finishing","timeout_ms":900000,"command":"f=\"C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/54d1a7fa-712e-4a54-8760-ad71ded...
Monitor started (task b8ysw17g2, expires in 15m unless the source ends first; you get one notice at expiry — re-arm if you still need the watch). You will be notified on each event. Keep working — do not poll or sleep. Events may arrive while you are waiting for the user — an event is not their reply.
I'm still waiting on the final re-check (typecheck, lint and screenshots after the sample-player fix). I'll report as soon as it finishes.
<task-notification>
<task-id>bcli0tf3u</task-id>
<tool-use-id>toolu_01UfHPr3zhCMqeMirLKCqdtk</tool-use-id>
<output-file>[REDACTED]\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\54d1a7fa-712e-4a54-8760-ad71ded39900\tasks\bcli0tf3u.output</output-file>
<status>completed</status>
<summary>Background command "Recheck types, lint, and rerun the page check" completed (exit code 0)</summary>
</task-notification>
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\54d1a7fa-712e-4a54-8760-ad71ded39900\tasks\bcli0tf3u.output
1 desktop horizontal overflow px: 0 | sample: Sample episode coming soon 0:00 / 8:40 | errors: ["Failed to load resource: the server responded with a status of 404 (Not Found)"]
2 phone horizontal overflow px: 0 | sample: Sample episode coming soon 0:00 / 8:40 | errors: ["Failed to load resource: the server responded with a status of 404 (Not Found)"]
3
4 [exited with code 0]
5
I freed about 5 GB on C: and the landing page is now running at http://localhost:3000.
Space freed: I only removed things that rebuild themselves: the npm cache and packages in the pnpm store that no project uses. C: went from 97 MB free to about 5 GB free after reinstalling. I didn't delete crash dumps because Windows protects that folder. I also didn't touch Downloads (2.6 GB), Docker (35 GB) or the Yarn cache (4.7 GB, Yarn isn't installed). If you need more room, Docker is the big one, and pruning unused images or volumes is a decision for you.
Checks:
- pnpm install completed, and typecheck and lint pass with no errors.
- I took screenshots at 1440 px and 390 px. They match the design, with no sideways scrolling on phones.
- I found and fixed one bug. When the sample mp3 is missing, the card was supposed to say "Sample episode coming soon", but it didn't. It does now.
- The only browser console error is the 404 for that missing sample-episode.mp3.
Placeholder Auth0 values: you have no apps/web/.env.local, so the dev server is running with fake Auth0 settings passed on the command line. The page displays fine, but "Start free" and "Sign in" won't work until you add real Auth0 values to apps/web/.env.local (see .env.example).
Still to fill in:
- Plus price: the pricing card shows [PRICE]. Set PLUS_PRICE in content.ts.
- Sample audio: add apps/web/public/sample-episode.mp3, then update each chapter's start time in content.ts to match it.
- Terms page: there isn't one yet, so the footer has no Terms link.
The changes aren't committed yet. Do you want me to commit and push them to your working branch?
what's a reasonable price to set?
also list out the required envs
$ cat .env.example; echo ----; sed -n 130,170p docs/prd-unread.md; echo ----; grep -rn "process.env\.\|z.object" apps/web/src/lib/env.ts | head -40
# Copy to apps/web/.env.local (web) and export for the worker. See docs/deploy.md.
# Note: a PG_URL already set in your shell wins over .env files.
# ── Core ─────────────────────────────────────────────────────────────
PG_URL=[REDACTED] # Tiger Cloud URL with ?sslmode=require in production
# DATABASE_URL is a fallback, used only when PG_URL is empty.
APP_BASE_URL=http://localhost:3000
# ── Auth0 (ADR 0002) ─────────────────────────────────────────────────
AUTH0_DOMAIN=your-tenant.us.auth0.com
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
AUTH0_SECRET= # openssl rand -hex 32
# ── Storage (ADR 0009): local files in dev, S3/R2 in production ──────
STORAGE_DRIVER=local
STORAGE_LOCAL_DIR=/tmp/unread-storage
S3_BUCKET=
S3_REGION=auto
S3_ENDPOINT= # e.g. https://<account>.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
# ── Inbound email (ADR 0004) ─────────────────────────────────────────
INBOUND_EMAIL_DOMAIN=in.getunread.site
INBOUND_WEBHOOK_SECRET= # same value as the Email Worker secret
INBOUND_TRUST_UPSTREAM_AUTH=false
# ── Gemma (ADR 0005) ─────────────────────────────────────────────────
GEMINI_API_KEY=
GEMMA_MODEL=gemma-4-31b-it
GEMINI_API_STYLE=native # or "openai" for the OpenAI-compatible endpoint
OPENROUTER_API_KEY= [REDACTED_OPENROUTER_API_KEY]
OPENROUTER_MODEL=google/gemma-4-31b-it
# ── Voices, embeddings, audio (ADR 0005, 0006) ───────────────────────
ELEVENLABS_API_KEY= # Plus/trials, Studio previews, SFX (`pnpm generate-assets`)
KOKORO_CONCURRENCY=2
MODEL_CACHE_DIR=/tmp/unread-models
FFMPEG_PATH=ffmpeg
# ── News (ADR 0009) ──────────────────────────────────────────────────
SERPAPI_API_KEY=
# ── Billing (ADR 0007) ───────────────────────────────────────────────
BACHS_API_URL=https://sandbox-api.bachs.io
BACHS_API_KEY= [REDACTED_BACHS_API_KEY]
BACHS_WEBHOOK_SECRET= [REDACTED_BACHS_WEBHOOK_SECRET]
BACHS_PLUS_MONTHLY_PRODUCT_ID=
BACHS_PLUS_YEARLY_PRODUCT_ID=
# ── Mail, scheduling, admin ──────────────────────────────────────────
RESEND_API_KEY=
MAIL_FROM=Unread <hello@getunread.site>
TEMPORAL_ADDRESS= # empty = the worker polls instead (cut line)
TEMPORAL_NAMESPACE=default
TEMPORAL_API_KEY=
TEMPORAL_TASK_QUEUE=unread-episodes
ADMIN_EMAILS= # comma-separated, verified emails only
----
| `heard_items` | user_id, item_id, cluster_key, episode_id |
| `listening_events` | **Timescale hypertable** on `occurred_at`: user_id, episode_id, chapter_id, event (`play`/`skip`/`complete`/`seek`), position_s, client (`web`/`feed`) |
| `usage_monthly` | user_id, month, characters_used, episodes_made |
| `subscriptions` | id, user_id, bachs_customer_id, bachs_subscription_id, product_id, status, current_period_end, cancel_at_period_end, updated_at |
| `billing_events` | id (Bachs event id, unique), type, payload jsonb, processed_at |
| `promo_codes` / `promo_redemptions` | see §8.3 |
**Tiger Data use:** Postgres for everything; **pgvector** for story clustering across newsletters and "already heard"; a **hypertable** for listening events, with a continuous aggregate (per user × newsletter/topic: skip rate, completion rate) that feeds `planEpisode` ranking. P2: hybrid keyword + vector search over past episodes.
## 8. Voice, plans, payments and cost
ElevenLabs API list prices (verify before launch): Flash/Turbo ≈ $0.04 per 1K characters; Multilingual v2 / v3 ≈ $0.08 per 1K; sound effects ≈ $0.12 per generated minute. ~900 characters ≈ 1 minute of speech. Kokoro has no per-character cost (CPU time on our worker).
| | Free | Plus (price set in Bachs, e.g. $8–10/mo or local equivalent) |
|---|---|---|
| Voice | **Kokoro**: 4 curated voices (e.g. `af_heart`, `af_bella` + two others chosen by listening tests) | **ElevenLabs**: Flash by default; "Studio voices" (Multilingual v2/v3) option; full voice library picker |
| Cadence | Weekly digest (user picks the day) | Daily, only when there's new content |
| Length | Up to 10 min | 5 / 10 / 20 min |
| Monthly cap | Episode count (4/month) | ~350K ElevenLabs characters (the episode shortens to fit, never fails silently) |
| News opt-in | Yes | Yes |
### 8.1 Voice rules
- `VoiceProvider` interface with `KokoroVoiceProvider` and `ElevenLabsVoiceProvider`; the pipeline asks for the provider by effective plan, never by checking plan flags inside voice code.
- Kokoro runs in the Render worker. Measure speed on the chosen instance (CPU, often around real time or a bit faster); limit concurrent Kokoro jobs per worker; generate chapters in parallel within that limit. Free episodes are weekly, which keeps CPU load predictable.
- The Free plan shows an "Upgrade to Studio voices" preview: a 10-second sample of the same intro in an ElevenLabs voice (generated once per voice and cached).
- SFX: a fixed set (intro sting, chapter transition, outro) generated once with the ElevenLabs sound effects API and reused on every plan.
- **No voice cloning in v1.**
- Every episode records provider, characters and estimated cost; an admin page shows daily totals.
- Kokoro is Apache-2.0. Its phonemizer is likely espeak-ng based (GPL); running it on our server is not distribution, but confirm the licence before ever shipping it inside a downloadable app.
### 8.2 Payments (Bachs)
- Bachs, "the payments stack for African internet businesses": hosted checkout, subscriptions with trials, customer portal, webhooks, payment in the customer's local currency (examples show USD and NGN). Base URLs: `https://sandbox-api.bachs.io` (keys `sk_sandbox_…`) and `https://api.bachs.io` (`sk_live_…`); docs at docs.bachs.io. **Verify every endpoint and field name against the docs before coding.**
- Setup: one recurring product per Plus price (monthly; annual optional). Store product ids in config, not code.
- Upgrade flow: `POST /billing/checkout` creates a Bachs customer if needed, then a hosted checkout session for the Plus product → redirect → Bachs redirects back to `/billing/return`. **Plan changes only from webhooks**, never from the return URL.
- Webhooks: `POST /billing/webhooks/bachs`, signature verified (HMAC-SHA256 header, reject stale timestamps), idempotent by event id. Handle subscription created / updated / cancelled and payment failed. Map Bachs status → our `subscriptions.status`.
- Manage plan: a Bachs customer-portal session from Settings ("Manage billing"), for card updates and cancellation.
- Cancelled subscriptions stay Plus until the end of the paid period, then drop to Free.
### 8.3 Promo codes (Plus trial)
- Our own feature, independent of Bachs: a code grants **Plus for N days with no card**.
- `promo_codes`: code (case-insensitive, unique), trial_days, max_redemptions, redeemed_count, expires_at, active, note. `promo_redemptions`: code_id, user_id, redeemed_at, trial_ends_at (unique per code + user).
----
What to charge for Plus: $12 a month, or $99 a year.
The PRD suggests $8–10 a month. At ElevenLabs list prices, that loses money on anyone who listens every day:
| Plus listener | ElevenLabs characters per month | Cost at Flash ($0.04 per 1K) | Cost at Studio ($0.08 per 1K) |
|---|---|---|---|
| 5 min, daily | ~135K | ~$5 | ~$11 |
| 10 min, daily (default) | ~270K | ~$11 | ~$22 |
| Hits the 350K cap | 350K | $14 | $28 |
These assume about 900 characters per minute of speech, from PRD §8. Gemma, storage and the payment fee add a little on top. Two things bring the real cost down:
- Plus only makes an episode on days with new content, and most people won't hit the cap.
- ElevenLabs subscription tiers cost less per character than these list prices.
$12 covers a typical daily listener on Flash with some margin. Studio voices are what the pricing card advertises for Plus, and they cost twice as much. If you price under $15, I'd count Studio characters double against the 350K cap; otherwise a heavy Studio user costs you about $28 a month. Bachs can also charge in local currency, such as NGN, so you might set a lower regional price. Check current ElevenLabs prices before launch; the PRD's numbers are marked "verify".
Fix before shipping: you set PLUS_PRICE = "" in content.ts. Right now the Plus card shows only " / month" with no number. Set it to "$12" or whatever price you pick.
Environment variables
All of these go in apps/web/.env.local, and the worker needs them exported too. They come from .env.example.
Needed just to run the site and sign in
| Variable | Value |
|---|---|
| APP_BASE_URL | http://localhost:3000 locally; your real URL in production |
| AUTH0_DOMAIN | Your Auth0 tenant, e.g. your-tenant.us.auth0.com |
| AUTH0_CLIENT_ID | From your Auth0 application |
| AUTH0_CLIENT_SECRET | From your Auth0 application |
| AUTH0_SECRET | Make one with openssl rand -hex 32 |
| PG_URL | Postgres connection string; Tiger Cloud with ?sslmode=require in production. DATABASE_URL is only a fallback. |
Needed for the app to actually work
| Variable | What it's for |
|---|---|
| INBOUND_EMAIL_DOMAIN | The domain users forward newsletters to (in.getunread.site) |
| INBOUND_WEBHOOK_SECRET | Must match the secret set on the Cloudflare Email Worker |
| GEMINI_API_KEY or OPENROUTER_API_KEY | Writing episode scripts; you need at least one |
| STORAGE_DRIVER | local for development, S3/R2 in production |
| S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | Production only, when STORAGE_DRIVER is S3/R2 |
| ELEVENLABS_API_KEY | Plus and trial voices, Studio previews, sound effects |
| BACHS_API_KEY, BACHS_WEBHOOK_SECRET, BACHS_PLUS_MONTHLY_PRODUCT_ID | Plus billing |
| RESEND_API_KEY | Sending email |
Optional, or the defaults are fine
| Variable | Default or effect |
|---|---|
| SERPAPI_API_KEY | Only needed for the optional news topics |
| BACHS_PLUS_YEARLY_PRODUCT_ID | Only if you offer a yearly price |
| BACHS_API_URL | Sandbox by default; use https://api.bachs.io for live payments |
| TEMPORAL_ADDRESS, TEMPORAL_API_KEY | Empty means the worker checks for work on a loop instead of using Temporal |
| TEMPORAL_NAMESPACE, TEMPORAL_TASK_QUEUE | Sensible defaults are already set |
| ADMIN_EMAILS | Comma-separated emails that can open the admin page |
| GEMMA_MODEL, GEMINI_API_STYLE, OPENROUTER_MODEL | Model settings, already set |
| KOKORO_CONCURRENCY, MODEL_CACHE_DIR, FFMPEG_PATH | Voice and audio settings, already set |
| INBOUND_TRUST_UPSTREAM_AUTH | false; leave it |
| MAIL_FROM | Unread <[REDACTED]> |
| S3_REGION | auto |
To preview just the landing page locally, the five Auth0 variables plus APP_BASE_URL are enough. docs/deploy.md explains where to get each one.
can you get any of that for me?
I'll check what's already on this machine. That means environment variable names only (never values) and any CLIs that are already logged in.
$ env | cut -d= -f1 | grep -iE "PG_|DATABASE|AUTH0|GEMINI|OPENROUTER|ELEVEN|SERP|BACHS|RESEND|TEMPORAL|CLOUDFLARE|AWS|S3_|R2|INBOUND" ; echo "--- CLIs"; for c in auth0 wrangler psql docker gcloud stripe; do command -v $c >/dev/null && echo "$c: installed"; done; echo "--- pg"; (netstat -an 2>/dev/null | grep -E ":5432 .*LISTEN") || echo "no local postgres listening"
USERPROFILE
--- CLIs
docker: installed
--- pg
no local postgres listening
$ cat .gitignore; echo ---; docker info --format '{{.ServerVersion}}' 2>&1 | tail -1; docker ps -a --format '{{.Names}} {{.Image}} {{.Status}}' 2>&1 | head
node_modules/
.next/
out/
dist/
coverage/
*.tsbuildinfo
next-env.d.ts
.env
.env.*
!.env.example
.DS_Store
.vercel
---
failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine; check if the path is correct and if the daemon is running: open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.
failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine; check if the path is correct and if the daemon is running: open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.
$ test -e apps/web/.env.local && echo "EXISTS - not overwriting" || node -e '
const fs = require("fs"), crypto = require("crypto");
const hex = () => crypto.randomBytes(32).toString("hex");
const local = (process.env.LOCALAPPDATA || "").replace(/\\/g, "/") + "/unread";
const set = { AUTH0_SECRET: hex(), INBOUND_WEBHOOK_SECRET: hex(), STORAGE_LOCAL_DIR: local + "/storage", MODEL_CACHE_DIR: local + "/models" };
const out = fs.readFileSync(".env.example", "utf8").split("\n").map((line) => {
const m = line.match(/^([A-Z0-9_]+)=/);
return m && set[m[1]] ? `${m[1]}=${set[m[1]]}` : line;
}).join("\n");
fs.writeFileSync("apps/web/.env.local", out, { mode: 0o600 });
console.log("wrote apps/web/.env.local; filled:", Object.keys(set).join(", "));
'; git check-ignore -q apps/web/.env.local && echo "git-ignored: yes"
[eval]:4
const local = (process.env.LOCALAPPDATA || "").replace(/\/g, "/") + "/unread";
^^^^^^^^
Expected ',', got 'string literal'
SyntaxError: missing ) after argument list
at makeContextifyScript (node:internal/vm:194:14)
at compileScript (node:internal/process/execution:388:10)
at evalTypeScript (node:internal/process/execution:260:22)
at node:internal/main/eval_string:71:3
Node.js v24.14.0
git-ignored: yes
{"command":"$exe = \"C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe\"; if (Test-Path $exe) { Start-Process $exe; \"starting Docker Desktop\" } else { \"Docker Desktop not found at default path\...
Docker Desktop not found at default path
$ S="C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/54d1a7fa-712e-4a54-8760-ad71ded39900/scratchpad"; cat > "$S/make-env.cjs" <<'EOF'
const fs = require("fs"), crypto = require("crypto");
const [, , example, target] = process.argv;
if (fs.existsSync(target)) { console.log("EXISTS - not overwriting"); process.exit(0); }
const hex = () => crypto.randomBytes(32).toString("hex");
const local = (process.env.LOCALAPPDATA || "").split("\\").join("/") + "/unread";
const set = { AUTH0_SECRET: hex(), INBOUND_WEBHOOK_SECRET: hex(), STORAGE_LOCAL_DIR: local + "/storage", MODEL_CACHE_DIR: local + "/models" };
const out = fs.readFileSync(example, "utf8").split("\n").map((line) => {
const m = line.match(/^([A-Z0-9_]+)=/);
return m && set[m[1]] ? `${m[1]}=${set[m[1]]}` : line;
}).join("\n");
fs.writeFileSync(target, out, { mode: 0o600 });
console.log("wrote", target, "- filled:", Object.keys(set).join(", "));
EOF
cd /c/Users/HP/hacks/unread && node "$S/make-env.cjs" .env.example apps/web/.env.local; ls "/c/Program Files/Docker" "$LOCALAPPDATA/Programs" 2>&1 | grep -i docker; where docker 2>/dev/null; command -v docker
wrote apps/web/.env.local - filled: AUTH0_SECRET, INBOUND_WEBHOOK_SECRET, STORAGE_LOCAL_DIR, MODEL_CACHE_DIR
/c/Program Files/Docker:
DockerDesktop
C:\Users\HP\AppData\Local\Programs\DockerDesktop\resources\bin\docker
C:\Users\HP\AppData\Local\Programs\DockerDesktop\resources\bin\docker.exe
/c/Users/HP/AppData/Local/Programs/DockerDesktop/resources/bin/docker
{"command":"$exe = Get-ChildItem \"$env:LOCALAPPDATA\\Programs\\DockerDesktop\", \"C:\\Program Files\\Docker\\DockerDesktop\" -Filter \"Docker Desktop.exe\" -Recurse -Depth 2 -ErrorAction SilentlyCont...
starting C:\Users\HP\AppData\Local\Programs\DockerDesktop\Docker Desktop.exe
$ for i in $(seq 1 90); do docker info --format '{{.ServerVersion}}' >/dev/null 2>&1 && break; sleep 2; done; docker info --format 'engine {{.ServerVersion}}' 2>&1 | tail -1
engine 29.6.2
Docker is up. Next I'll start Postgres 16 with pgvector on port 5432, using the same credentials as .env.example, then run the migrations.
$ docker run -d --name unread-postgres --restart unless-stopped -e POSTGRES_USER=unread -e POSTGRES_PASSWORD=unread -e POSTGRES_DB=unread -p 5432:5432 -v unread-pgdata:/var/lib/postgresql/data pgvector/pgvector:pg16 2>&1 | tail -2; for i in $(seq 1 60); do docker exec unread-postgres pg_isready -U unread >/dev/null 2>&1 && break; sleep 2; done; docker exec unread-postgres psql -U unread -c "CREATE DATABASE unread_test" 2>&1; docker exec unread-postgres pg_isready -U unread
Exit code 1
Run 'docker run --help' for more information
Error response from daemon: No such container: unread-postgres
Error response from daemon: No such container: unread-postgres
$ docker run -d --name unread-postgres --restart unless-stopped -e POSTGRES_USER=unread -e POSTGRES_PASSWORD=unread -e POSTGRES_DB=unread -p 5432:5432 -v unread-pgdata:/var/lib/postgresql/data pgvector/pgvector:pg16 2>&1 | grep -v -E "Pulling|Waiting|Download|Extract|Verifying|Pull complete" | tail -8
Unable to find image 'pgvector/pgvector:pg16' locally
Digest: sha256:7b822b0aac60967beb1ea5e576b8602c94c300a157d187f385ae3e0da199b90a
fc3faa4a525047788ae7bd3305992e5661c723b93a888f940bb848fac68b739a
The container is running (the first attempt failed while the engine was still warming up). Creating the test database and running migrations:
$ for i in $(seq 1 60); do docker exec unread-postgres pg_isready -U unread >/dev/null 2>&1 && break; sleep 2; done; docker exec unread-postgres psql -U unread -c "CREATE DATABASE unread_test" 2>&1; cd /c/Users/HP/hacks/unread && for db in unread unread_test; do echo "== $db"; PG_URL=[REDACTED] pnpm db:migrate 2>&1 | tail -4; done; docker exec unread-postgres psql -U unread -d unread -tAc "select count(*) || ' tables' from information_schema.tables where table_schema='public'"
CREATE DATABASE
== unread
No config path provided, using default 'drizzle.config.ts'
Reading config file 'C:\Users\HP\hacks\unread\packages\db\drizzle.config.ts'
Using 'postgres' driver for database querying
[⣷] applying migrations...[2K[1G[⣯] applying migrations...[2K[1G[⣟] applying migrations...[2K[1G[⡿] applying migrations...[2K[1G[⢿] applying migrations...[2K[1G[⣻] applying migrations...[2K[1G[⣽] applying migrations...[2K[1G[⣷] applying migrations...[2K[1G[⣯] applying migrations...[2K[1G[⣟] applying migrations...[2K[1G[✓] migrations applied successfully!== unread_test
No config path provided, using default 'drizzle.config.ts'
Reading config file 'C:\Users\HP\hacks\unread\packages\db\drizzle.config.ts'
Using 'postgres' driver for database querying
[⣷] applying migrations...[2K[1G[⣯] applying migrations...[2K[1G[⣟] applying migrations...[2K[1G[⡿] applying migrations...[2K[1G[⢿] applying migrations...[2K[1G[⣻] applying migrations...[2K[1G[⣽] applying migrations...[2K[1G[⣷] applying migrations...[2K[1G[⣯] applying migrations...[2K[1G[⣟] applying migrations...[2K[1G[⡿] applying migrations...[2K[1G[⢿] applying migrations...[2K[1G[✓] migrations applied successfully!20 tables
C:\Users\HP\hacks\unread\docs\project-memory.md
The file C:\Users\HP\hacks\unread\docs\project-memory.md has been updated successfully.
No, I can't get keys from the providers' websites. Each one needs you to log in, accept their terms and sometimes add a card. I don't have a browser session in your accounts, and making accounts for you isn't something I should do.
I set up everything that can live on your machine:
-
apps/web/.env.local: created from.env.exampleand git-ignored. I generatedAUTH0_SECRETandINBOUND_WEBHOOK_SECRET, and pointed the storage and model folders at%LOCALAPPDATA%\unread. The secrets were written straight to the file and never shown. -
Database: Postgres 16 with pgvector runs in Docker as
unread-postgres. Bothunreadandunread_testare migrated (20 tables). ThePG_URLin.env.localalready points at it. Docker Desktop needs to be running for it to work. -
Project memory: I added these local setup details to
docs/project-memory.md.
What you need to get, in order of importance:
-
Auth0, needed to sign in: go to manage.auth0.com, then Applications, then Create, and choose "Regular Web Application". Set:
- Allowed Callback URLs:
http://localhost:3000/auth/callback - Allowed Logout URLs:
http://localhost:3000
- Allowed Callback URLs:
Copy the Domain, Client ID and Client Secret into AUTH0_DOMAIN, AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET.
2. Gemini, needed to make episodes: create a key at aistudio.google.com/apikey and put it in GEMINI_API_KEY. It's free to start. OpenRouter (openrouter.ai/keys) is the backup option.
3. ElevenLabs, for Plus voices: your project notes say you already have a key, ending in a478, from your cloud environment. Paste it into ELEVENLABS_API_KEY.
4. Bachs, for Plus billing: get a sandbox key and webhook secret from your Bachs dashboard, then create a monthly Plus product there.
5. Resend (resend.com/api-keys) for email, and SerpApi (serpapi.com) for the news topics. You can leave both until later.
Cloudflare (inbound email), R2 storage and Temporal only matter when you deploy; docs/deploy.md covers them.
The dev server on :3000 is still running with the placeholder Auth0 values. Once you've added the Auth0 values, tell me and I'll restart it with your real settings and test signing in.
how will the inbound email be set up?
$ cat docs/decisions/0004-inbound-email-cloudflare.md; echo ----; grep -n -i -B2 -A25 "cloudflare\|inbound" docs/deploy.md | head -90; echo ----; cat apps/inbound-worker/wrangler.jsonc; sed -n 1,60p apps/inbound-worker/src/index.ts
# 0004 · Inbound email: Cloudflare Email Routing
**Status:** Accepted (2026-10-24). Implemented in U1.
## Context
Forwarding newsletters is the main feature (PRD v2 §4.1). Every user has `<alias>@in.getunread.site`. We need to receive that mail, verify where it came from, and turn it into `inbound_messages` and `items` within 60 seconds.
## Decision
**Cloudflare Email Routing** on `in.getunread.site`, with a catch-all rule that sends every address to an **Email Worker** (`apps/inbound-worker`). The worker hands the raw message to the web app.
```
sender ─► Cloudflare MX (in.getunread.site)
│ Email Routing: SPF or DKIM must pass; DMARC policy enforced
▼
Email Worker email(message, env, ctx)
│ size cap → read message.raw → HMAC-sign
▼
POST https://getunread.site/inbound/email (apps/web route handler)
verify signature + timestamp → find user by alias → verified-sender check
→ parse MIME (postal-mime) → classify (issue / link / forwarding confirmation)
→ store raw (30 days) → inbound_messages + items
```
- **Worker API** (Cloudflare docs): `message.from`/`message.to` are the envelope addresses, `message.headers` is a `Headers` object, `message.raw` is a `ReadableStream` of the MIME source, plus `message.rawSize` and `message.setReject(reason)`.
- **Authentication:** since July 2025, Email Routing only accepts mail that passes SPF or carries a valid DKIM signature, and applies the sender's DMARC policy. On top of that, we pass the `Authentication-Results` header through and also check it in the app. The exact header format is confirmed on the first real message in U1.
- **Rejecting early:** the worker calls `setReject` for messages over the size cap (2 MB) and for local parts that aren't valid aliases, so nothing is posted to the app. Unknown aliases that do look valid still reach the app, where they are logged and dropped.
- **Webhook security:** header `X-Unread-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + body)>`, with a 5-minute window. The secret `INBOUND_WEBHOOK_SECRET` is shared between the worker and the web app.
- **Idempotency:** `inbound_messages.message_id` is unique, so worker retries are no-ops.
## Why Cloudflare
- No per-message cost, and the Worker runs at the edge before anything reaches our app.
- Authentication is enforced upstream; we keep full control of the MIME parsing.
- `getunread.site` DNS can live on Cloudflare, so MX setup is a few clicks.
## Alternatives considered
- **Postmark Inbound:** parses MIME to JSON and sends SPF/DKIM results, but it's a paid plan and one more vendor holding newsletter text.
## Setup
1. Add `getunread.site` to Cloudflare. Enable Email Routing on the `in` subdomain (MX + SPF records are added automatically).
2. Deploy `apps/inbound-worker` with `wrangler deploy`, using secrets `INBOUND_WEBHOOK_SECRET` and `INBOUND_WEBHOOK_URL`.
3. Email Routing → Routing rules → Catch-all → *Send to a Worker* → `unread-inbound`.
----
1-# Deploying Unread
2-
3:Everything except secrets is in the repo: `render.yaml` (web + worker), `apps/worker/Dockerfile`, `apps/inbound-worker/wrangler.jsonc`.
4-
5-## 1. Database: Tiger Cloud
6-1. Create a service (Postgres + TimescaleDB). Copy the connection string (with `?sslmode=require`) into `PG_URL`.
7-2. `PG_URL=… pnpm db:migrate`. Migration 0000 enables pgvector; 0002 turns `listening_events` into a hypertable.
8-
9:## 2. Object storage: Cloudflare R2
10:Create a bucket (private) and an API token with read/write on it. Set `STORAGE_DRIVER=s3`, `S3_BUCKET`, `S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com`, `S3_REGION=auto`, and the keys.
11-
12-## 3. Auth0
13-Regular Web Application, callback `https://getunread.site/auth/callback`, logout `https://getunread.site`, Google + email connections. Details in ADR 0002.
14-
15-## 4. Render
16-1. New → Blueprint → this repo. It creates `unread-web` (Node) and `unread-worker` (Docker).
17-2. Fill the `sync: false` secrets (env group `unread-shared` + web-only keys).
18-3. Custom domain: add `getunread.site` to `unread-web`, then point DNS at Render (A/ALIAS for the apex as Render shows).
19-4. Without Temporal, leave `TEMPORAL_ADDRESS` empty: the worker polls. Or use a Render cron job running `pnpm --filter @unread/worker dispatch` every 10 minutes and skip the long-running worker.
20-
21-## 5. Temporal (optional, recommended)
22-Temporal Cloud namespace + API key → `TEMPORAL_ADDRESS` (e.g. `<ns>.<acct>.tmprl.cloud:7233`), `TEMPORAL_NAMESPACE`, `TEMPORAL_API_KEY`. The worker creates the `unread-dispatch` (10 min) and `unread-maintenance` (hourly) schedules on start.
23-
24:## 6. Inbound email: Cloudflare
25:1. `getunread.site` on Cloudflare DNS → Email → Email Routing on `in.getunread.site` (adds MX + SPF).
26:2. `cd apps/inbound-worker && npx wrangler secret put INBOUND_WEBHOOK_URL` (`https://getunread.site/inbound/email`) and `npx wrangler secret put INBOUND_WEBHOOK_SECRET` (same as the web app), then `npx wrangler deploy`.
27:3. Email Routing → Routing rules → Catch-all → Send to a Worker → `unread-inbound`.
28-4. Forward a newsletter from your sign-in address to your alias; it shows up under Sources within a minute.
29-
30-## 7. Bachs (sandbox first)
31-1. Create two recurring products (Plus monthly/yearly) → `BACHS_PLUS_*_PRODUCT_ID`.
32-2. Developer Portal → Webhooks → `https://getunread.site/billing/webhooks/bachs` with `customer.subscription.created/updated/deleted`, `invoice.paid`, `invoice.payment_failed` → `BACHS_WEBHOOK_SECRET`.
33-3. Locally: `bachs listen --forward-to localhost:3000/billing/webhooks/bachs --events customer.subscription.created,customer.subscription.updated,customer.subscription.deleted`.
34-
35-## 8. Providers
36-- `GEMINI_API_KEY` (Google AI Studio) and/or `OPENROUTER_API_KEY`.
37-- `SERPAPI_API_KEY` for opt-in news.
38-- `ELEVENLABS_API_KEY` for Plus/trials and Studio previews; then run `pnpm generate-assets` once to create the SFX set.
39-- `RESEND_API_KEY` + a verified sending domain for trial emails.
40-
41-## Checks after deploy
42-- `/privacy` responds (health check). Sign in → onboarding → forward one newsletter → it appears in Sources.
43-- `pnpm promo create --code LAUNCH --days 14 --episodes 3 --max 100` (with production `PG_URL`).
44-- Make one now → an episode appears; add the feed to Pocket Casts.
----
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "unread-inbound",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
// Secrets (wrangler secret put): INBOUND_WEBHOOK_URL, INBOUND_WEBHOOK_SECRET
"vars": {
"MAX_BYTES": "2097152"
},
"observability": { "enabled": true }
}
import { relay, type RelayEnv } from "./relay";
export default {
async email(message: ForwardableEmailMessage, env: RelayEnv): Promise<void> {
const result = await relay(message, env);
console.log(JSON.stringify({ event: "inbound", result, size: message.rawSize }));
},
} satisfies ExportedHandler<RelayEnv>;
Incoming email goes through Cloudflare. Cloudflare receives mail for in.getunread.site, a small Cloudflare Worker (already in apps/inbound-worker) signs it, and the worker posts it to your web app. The code is written; what's left is account and DNS setup. ADR 0004 and docs/deploy.md §6 cover the details.
How a newsletter gets in
newsletter → Cloudflare MX for in.getunread.site
(Cloudflare rejects mail that fails SPF/DKIM)
→ Email Worker "unread-inbound"
(rejects mail over 2 MB and invalid addresses, signs the raw message with HMAC)
→ POST https://getunread.site/inbound/email
(checks the signature, finds the user by alias, checks the sender is verified,
parses the email, stores it, splits it into stories)
Each user gets an address like <alias>@in.getunread.site. Mail is only accepted from senders the user has verified; their sign-in email counts automatically. Gmail's forwarding-confirmation emails are recognised, so the confirmation code shows up in onboarding for the user to approve. Anything from an unverified sender is dropped before a model ever sees it.
Setup steps (about 15 minutes)
-
Put
getunread.siteon Cloudflare. Add the domain to Cloudflare and change the nameservers at your registrar. -
Turn on Email Routing for the
insubdomain. Go to Email → Email Routing. Cloudflare adds the MX and SPF records itself. -
Deploy the worker.
sh cd apps/inbound-worker npx wrangler login npx wrangler secret put INBOUND_WEBHOOK_URL # https://getunread.site/inbound/email npx wrangler secret put INBOUND_WEBHOOK_SECRET # the same value as in apps/web/.env.local npx wrangler deploy -
Point all addresses at the worker. In Email Routing, go to Routing rules → Catch-all → "Send to a Worker" →
unread-inbound. - Test it. Forward a newsletter from your sign-in address to your alias. It should appear under Sources within a minute.
Things to know
-
The web app has to be online first. Cloudflare can't reach
localhost. To test inbound before deploying to Render, run a tunnel such ascloudflared tunnel --url http://localhost:3000and use that URL asINBOUND_WEBHOOK_URL. Switch it to the real domain later. -
The secret must match on both sides.
INBOUND_WEBHOOK_SECRETmust be the same in the worker and the web app, or every message is rejected. I generated the web app's copy in.env.local, so copy that value into the worker. - Untested against real mail. The relay and parsing have tests, but no real email has gone through yet. ADR 0004 notes that the format of the authentication-results header needs checking on the first real message.
What I can do
Steps 1 and 2 happen in your Cloudflare dashboard and registrar, so those are yours. Once you've run wrangler login, I can do steps 3 and 4: set the secrets (including copying INBOUND_WEBHOOK_SECRET from .env.local without showing it) and deploy the worker. Deploying is an outward-facing change, so I'll wait for your go-ahead. I can also set up the tunnel so you can test inbound email before Render is live.
why is there a build error?
find n fix
<pasted_content id="f1a2">
unread@ db:migrate /opt/render/project/src
pnpm --filter @unread/db migrate@unread/db@0.0.0 migrate /opt/render/project/src/packages/db
drizzle-kit migrate
No config path provided, using default 'drizzle.config.ts'
Reading config file '/opt/render/project/src/packages/db/drizzle.config.ts'
Using 'postgres' driver for database querying
[⣷] applying migrations...[⣯] applying migrations...[⣟] applying migrations...[⡿] applying migrations...[⢿] applying migrations...[⣻] applying migrations...[⣽] applying migrations...[⣷] applying migrations...[⣷] applying migrations.../opt/render/project/src/packages/db:
ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @unread/db@0.0.0 migrate: drizzle-kit migrate
Exit status 1
ELIFECYCLE Command failed with exit code 1.
==> Pre-deploy has failed
==> Exited with status 1
==> Common ways to troubleshoot your deploy: https://render.com/docs/troubleshooting-deploys
</pasted_content id="f1a2">
Render runs migrations against Tiger Cloud right before deploying, and one of them fails. drizzle-kit hides the actual database error, so I'll work it out from the migrations. They applied cleanly on my local Postgres, which has pgvector but not TimescaleDB. Tiger has TimescaleDB, so the conditional hypertable migration (0002) is my first suspect.
$ cd /c/Users/HP/hacks/unread/packages/db && ls drizzle* migrations 2>/dev/null; cat drizzle.config.ts; ls;
drizzle.config.ts
migrations:
0000_enable-pgvector.sql
0001_init.sql
0002_listening-events-hypertable.sql
0003_billing-promos-voice.sql
0004_trials-ratelimits-assets.sql
0005_inbound-kinds-episode-requests.sql
0006_inbound-message-id-per-user.sql
0007_episode-audio-bytes-step.sql
0008_voice-quality.sql
meta
import { defineConfig } from "drizzle-kit";
const databaseUrl = process.env.PG_URL || process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("PG_URL (or DATABASE_URL) is required to run drizzle-kit");
}
export default defineConfig({
dialect: "postgresql",
schema: "./src/schema.ts",
out: "./migrations",
dbCredentials: { url: databaseUrl },
casing: "snake_case",
strict: true,
verbose: true,
});
drizzle.config.ts
migrations
node_modules
package.json
src
tsconfig.json
$ cd /c/Users/HP/hacks/unread/packages/db/migrations && cat 0000_enable-pgvector.sql 0002_listening-events-hypertable.sql; grep -n -A14 'CREATE TABLE "listening_events"' 0001_init.sql; grep -n "listening_events" 0003*.sql 0004*.sql 0005*.sql 0006*.sql 0007*.sql 0008*.sql
CREATE EXTENSION IF NOT EXISTS vector;
-- Tiger Cloud ships TimescaleDB. Plain Postgres (local dev without the
-- extension) keeps listening_events as an ordinary table.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'timescaledb') THEN
CREATE EXTENSION IF NOT EXISTS timescaledb;
PERFORM create_hypertable('listening_events', by_range('occurred_at', INTERVAL '7 days'), if_not_exists => TRUE);
END IF;
END
$$;
92:CREATE TABLE "listening_events" (
93- "occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
94- "user_id" text NOT NULL,
95- "episode_id" text NOT NULL,
96- "chapter_id" text,
97- "event" "listening_event" NOT NULL,
98- "position_s" real,
99- "client" "listening_client" NOT NULL
100-);
101---> statement-breakpoint
102-CREATE TABLE "newsletters" (
103- "id" text PRIMARY KEY NOT NULL,
104- "user_id" text NOT NULL,
105- "sender_email" text NOT NULL,
106- "display_name" text NOT NULL,
No primary key on listening_events, so that common hypertable failure is out. Checking its indexes and the rest of the SQL for anything Tiger might reject:
$ cd /c/Users/HP/hacks/unread/packages/db/migrations && grep -n "listening_events\|UNIQUE\|EXTENSION\|vector\|hnsw\|ivfflat\|DO \$\$\|CREATE TYPE" *.sql | grep -v "^0002" | head -50; cat meta/_journal.json | head -30
0000_enable-pgvector.sql:1:CREATE EXTENSION IF NOT EXISTS vector;
0001_init.sql:1:CREATE TYPE "public"."cadence" AS ENUM('daily', 'weekly');--> statement-breakpoint
0001_init.sql:2:CREATE TYPE "public"."chapter_source_role" AS ENUM('primary', 'also');--> statement-breakpoint
0001_init.sql:3:CREATE TYPE "public"."episode_status" AS ENUM('planning', 'voicing', 'ready', 'failed');--> statement-breakpoint
0001_init.sql:4:CREATE TYPE "public"."inbound_kind" AS ENUM('issue', 'link', 'forwarding_confirmation', 'rejected');--> statement-breakpoint
0001_init.sql:5:CREATE TYPE "public"."inbound_status" AS ENUM('received', 'processed', 'rejected', 'failed');--> statement-breakpoint
0001_init.sql:6:CREATE TYPE "public"."item_source_kind" AS ENUM('newsletter', 'link', 'news', 'rss');--> statement-breakpoint
0001_init.sql:7:CREATE TYPE "public"."item_status" AS ENUM('queued', 'used', 'skipped');--> statement-breakpoint
0001_init.sql:8:CREATE TYPE "public"."listening_client" AS ENUM('web', 'feed');--> statement-breakpoint
0001_init.sql:9:CREATE TYPE "public"."listening_event" AS ENUM('play', 'skip', 'complete', 'seek');--> statement-breakpoint
0001_init.sql:10:CREATE TYPE "public"."news_mode" AS ENUM('always', 'fill');--> statement-breakpoint
0001_init.sql:11:CREATE TYPE "public"."newsletter_priority" AS ENUM('always', 'normal', 'if_room');--> statement-breakpoint
0001_init.sql:12:CREATE TYPE "public"."pipeline_step" AS ENUM('collect', 'extract', 'cluster', 'plan', 'script', 'check', 'voice', 'cover', 'publish');--> statement-breakpoint
0001_init.sql:13:CREATE TYPE "public"."plan" AS ENUM('free', 'plus');--> statement-breakpoint
0001_init.sql:14:CREATE TYPE "public"."step_run_status" AS ENUM('running', 'succeeded', 'failed');--> statement-breakpoint
0001_init.sql:87: "embedding" vector(768),
0001_init.sql:92:CREATE TABLE "listening_events" (
0001_init.sql:191:CREATE UNIQUE INDEX "chapters_episode_position_key" ON "chapters" USING btree ("episode_id","position");--> statement-breakpoint
0001_init.sql:192:CREATE UNIQUE INDEX "episodes_user_date_key" ON "episodes" USING btree ("user_id","date");--> statement-breakpoint
0001_init.sql:194:CREATE UNIQUE INDEX "inbound_messages_message_id_key" ON "inbound_messages" USING btree ("message_id");--> statement-breakpoint
0001_init.sql:198:CREATE INDEX "items_embedding_idx" ON "items" USING hnsw ("embedding" vector_cosine_ops);--> statement-breakpoint
0001_init.sql:199:CREATE INDEX "listening_events_user_time_idx" ON "listening_events" USING btree ("user_id","occurred_at");--> statement-breakpoint
0001_init.sql:200:CREATE UNIQUE INDEX "newsletters_user_sender_key" ON "newsletters" USING btree ("user_id",lower("sender_email"));--> statement-breakpoint
0001_init.sql:201:CREATE UNIQUE INDEX "step_runs_episode_step_attempt_key" ON "step_runs" USING btree ("episode_id","step","attempt");--> statement-breakpoint
0001_init.sql:202:CREATE UNIQUE INDEX "user_topics_user_query_key" ON "user_topics" USING btree ("user_id",lower("query"));--> statement-breakpoint
0001_init.sql:203:CREATE UNIQUE INDEX "users_auth0_sub_key" ON "users" USING btree ("auth0_sub");--> statement-breakpoint
0001_init.sql:204:CREATE UNIQUE INDEX "users_feed_token_key" ON "users" USING btree ("feed_token");--> statement-breakpoint
0001_init.sql:205:CREATE UNIQUE INDEX "users_inbound_alias_key" ON "users" USING btree ("inbound_alias");--> statement-breakpoint
0001_init.sql:206:CREATE UNIQUE INDEX "verified_senders_user_email_key" ON "verified_senders" USING btree ("user_id",lower("email"));
0003_billing-promos-voice.sql:1:CREATE TYPE "public"."subscription_status" AS ENUM('trialing', 'active', 'past_due', 'cancelled', 'expired');--> statement-breakpoint
0003_billing-promos-voice.sql:2:CREATE TYPE "public"."voice_provider" AS ENUM('kokoro', 'elevenlabs');--> statement-breakpoint
0003_billing-promos-voice.sql:51:CREATE UNIQUE INDEX "promo_codes_code_key" ON "promo_codes" USING btree (lower("code"));--> statement-breakpoint
0003_billing-promos-voice.sql:52:CREATE UNIQUE INDEX "promo_redemptions_code_user_key" ON "promo_redemptions" USING btree ("code_id","user_id");--> statement-breakpoint
0003_billing-promos-voice.sql:54:CREATE UNIQUE INDEX "subscriptions_bachs_subscription_key" ON "subscriptions" USING btree ("bachs_subscription_id");--> statement-breakpoint
0004_trials-ratelimits-assets.sql:1:CREATE TYPE "public"."trial_ended_reason" AS ENUM('episodes', 'expired', 'upgraded');--> statement-breakpoint
0005_inbound-kinds-episode-requests.sql:1:CREATE TYPE "public"."episode_request_reason" AS ENUM('scheduled', 'manual');--> statement-breakpoint
0005_inbound-kinds-episode-requests.sql:2:CREATE TYPE "public"."episode_request_status" AS ENUM('pending', 'running', 'done', 'skipped', 'failed');--> statement-breakpoint
0005_inbound-kinds-episode-requests.sql:20:CREATE UNIQUE INDEX "episode_requests_scheduled_key" ON "episode_requests" USING btree ("user_id","date") WHERE "episode_requests"."reason" = 'scheduled';
0006_inbound-message-id-per-user.sql:2:CREATE UNIQUE INDEX "inbound_messages_user_message_id_key" ON "inbound_messages" USING btree ("user_id","message_id");
0008_voice-quality.sql:1:CREATE TYPE "public"."voice_quality" AS ENUM('flash', 'studio');--> statement-breakpoint
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1792815421658,
"tag": "0000_enable-pgvector",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1792815422486,
"tag": "0001_init",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1792815423304,
"tag": "0002_listening-events-hypertable",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1792817645437,
"tag": "0003_billing-promos-voice",
$ cd /c/Users/HP/hacks/unread && cat packages/db/package.json; ls packages/db/src; grep -n -B2 -A6 "preDeploy\|PG_URL\|DATABASE_URL" render.yaml
{
"name": "@unread/db",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema.ts"
},
"scripts": {
"generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"drizzle-orm": "0.45.4",
"postgres": "3.4.9",
"ulid": "3.0.2",
"zod": "4.6.7"
},
"devDependencies": {
"@types/node": "^22.20.5",
"drizzle-kit": "0.31.11",
"typescript": "6.0.3",
"vitest": "5.0.5"
}
}
client.ts
ids.test.ts
ids.ts
index.ts
schema.ts
3- - name: unread-shared
4- envVars:
5: - key: PG_URL
6- sync: false
7- - key: APP_BASE_URL
8- value: https://getunread.site
9- - key: STORAGE_DRIVER
10- value: s3
11- - key: S3_BUCKET
--
45- buildCommand: corepack enable && pnpm install --frozen-lockfile && pnpm --filter @unread/web build
46- startCommand: pnpm --filter @unread/web start
47: preDeployCommand: pnpm db:migrate
48- healthCheckPath: /privacy
49- domains:
50- - getunread.site
51- envVars:
52- - key: NODE_VERSION
53- value: "22"
No TypeScript runner is installed, so I'll write the script as plain ESM JavaScript. It runs on Render's Node 22 with nothing extra.
C:\Users\HP\hacks\unread\packages\db\scripts\migrate.mjs
File created successfully at: C:\Users\HP\hacks\unread\packages\db\scripts\migrate.mjs (file state is current in your context — no need to Read it back)
$ sed -i 's#"migrate": "drizzle-kit migrate"#"migrate": "node scripts/migrate.mjs"#' packages/db/package.json && grep -n '"migrate"' packages/db/package.json && PG_URL=[REDACTED] pnpm db:migrate 2>&1 | tail -4; echo "--- bad password:"; PG_URL=[REDACTED] pnpm db:migrate 2>&1 | grep -v "^>" | grep -v "^$" | head -6
12: "migrate": "node scripts/migrate.mjs",
Migrating localhost:5432/unread
Postgres 16.15 (Debian 16.15-1.pgdg12+2) · pgvector available · timescaledb absent
Migrations applied
--- bad password:
Migrating localhost:5432/unread
Migration failed while connecting: password authentication failed for user "unread"
code: 28P01
severity: FATAL
C:\Users\HP\hacks\unread\packages\db:
ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @unread/db@0.0.0 migrate: `node scripts/migrate.mjs`
$ docker run -d --name unread-tsdb-repro -e POSTGRES_USER=unread -e POSTGRES_PASSWORD=unread -e POSTGRES_DB=unread -p 5433:5432 pgvector/pgvector:pg16 >/dev/null && docker exec unread-tsdb-repro bash -c '
set -e
apt-get update -qq >/dev/null && apt-get install -y -qq curl gnupg lsb-release >/dev/null
echo "deb https://packagecloud.io/timescale/timescaledb/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/timescaledb.list
curl -fsSL https://packagecloud.io/timescale/timescaledb/gpgkey | gpg --dearmor -o /etc/apt/trusted.gpg.d/timescaledb.gpg
apt-get update -qq >/dev/null && apt-get install -y -qq timescaledb-2-postgresql-16 >/dev/null
echo "shared_preload_libraries = '"'"'timescaledb'"'"'" >> /var/lib/postgresql/data/postgresql.conf
echo installed' 2>&1 | tail -3; docker restart unread-tsdb-repro >/dev/null; for i in $(seq 1 30); do docker exec unread-tsdb-repro pg_isready -U unread >/dev/null 2>&1 && break; sleep 2; done; cd /c/Users/HP/hacks/unread && PG_URL=[REDACTED] pnpm db:migrate 2>&1 | grep -v "^>" | grep -v "^$" | head -12
debconf: delaying package configuration, since apt-utils is not installed
debconf: delaying package configuration, since apt-utils is not installed
installed
Migrating localhost:5433/unread
Postgres 16.15 (Debian 16.15-1.pgdg12+2) · pgvector available · timescaledb available
Migrations applied
$ docker exec unread-tsdb-repro psql -U unread -d unread -tAc "select hypertable_name from timescaledb_information.hypertables"; docker rm -f unread-tsdb-repro >/dev/null && echo "repro container removed"
listening_events
repro container removed
I couldn't reproduce the failure. All nine migrations apply cleanly on plain Postgres with pgvector, and also on Postgres with pgvector plus TimescaleDB. On the TimescaleDB one, listening_events became a hypertable correctly. So the SQL is fine.
The actual problem is that drizzle-kit migrate hides the database's error. Your log shows a spinner and "Exit status 1" and nothing else, so neither of us can see what Tiger rejected.
Fix: pnpm db:migrate now runs packages/db/scripts/migrate.mjs, which uses Drizzle's own migration runner and prints:
- which host it's connecting to (without the password);
- the Postgres version, and whether pgvector and TimescaleDB are available;
- the real error if it fails, including which stage failed (connecting or applying).
I tested both outcomes locally. A bad password now prints Migration failed while connecting: password authentication failed for user "unread", where before you'd have seen a silent exit 1.
Most likely causes, given the SQL is fine:
1. Missing or wrong PG_URL in Render. It needs to be the Tiger connection string with ?sslmode=require. Also, if PG_URL is empty, the script falls back to DATABASE_URL, which might point somewhere else.
2. Tiger's IP allow list blocks Render. Check the service's network settings in the Tiger console.
3. The Tiger database isn't empty, for example from an earlier manual attempt, so a CREATE TABLE fails with "already exists".
Next step: push this change and redeploy. The pre-deploy log will then name the exact error, and if it's one of the three above it's a settings change, not a code change. Changes aren't committed yet; you're on main, but your project notes say the working branch is claude/gifted-albattani-gk5mjz. Which branch does Render deploy from? Tell me and I'll commit and push the migration fix together with the landing page.
it failed again,
btw, is there password in this "[REDACTED]"
$ cat packages/db/src/client.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
export type Database = ReturnType<typeof createDb>;
export interface CreateDbOptions {
url: string;
/** Max pool size. Keep small on Render's starter plans. */
max?: number;
/** Disable prepared statements when going through a transaction-mode pooler. */
prepare?: boolean;
}
export function createDb({ url, max = 5, prepare = true }: CreateDbOptions) {
const sql = postgres(url, { max, prepare });
return drizzle({ client: sql, schema, casing: "snake_case" });
}
createDb\(|prepare C:\Users\HP\hacks\unread
packages\db\src\client.ts:11: /** Disable prepared statements when going through a transaction-mode pooler. */
packages\db\src\client.ts:12: prepare?: boolean;
packages\db\src\client.ts:15:export function createDb({ url, max = 5, prepare = true }: CreateDbOptions) {
packages\db\src\client.ts:16: const sql = postgres(url, { max, prepare });
apps\worker\src\context.ts:24: db: createDb({ url: config.env.DATABASE_URL, max: 5 }),
apps\web\src\lib\db.ts:9: globalForDb.unreadDb ??= createDb({ url: serverEnv().DATABASE_URL });
apps\worker\src\temporal\workflows.ts:26: const prepared = await thinking.prepareScript(episodeId);
apps\worker\src\temporal\workflows.ts:27: if (prepared.status === "skipped") return `skipped: ${prepared.message}`;
apps\worker\src\temporal\workflows.test.ts:34: prepareScript: async () => (calls.push("script"), { status: "ok" as const }),
apps\worker\src\temporal\workflows.test.ts:51: prepareScript: async () => ({ status: "skipped" as const, message: "Not enough new content yet" }),
apps\worker\src\temporal\activities.ts:6: prepareScript,
apps\worker\src\temporal\activities.ts:23: async prepareScript(episodeId: string) {
apps\worker\src\temporal\activities.ts:24: return prepareScript(episodeDeps(), episodeId);
packages\core\src\episodes\make-episode.ts:2:import { failEpisode, prepareScript, publishEpisode, renderEpisodeCover, startEpisode, voiceEpisode } from "./steps";
packages\core\src\episodes\make-episode.ts:18: const prepared = await prepareScript(deps, episodeId);
packages\core\src\episodes\make-episode.ts:19: if (prepared.status === "skipped") return prepared;
packages\core\src\episodes\steps.ts:123:export async function prepareScript(deps: EpisodeDeps, episodeId: string): Promise<PrepareResult> {
packages\core\src\episodes\steps.ts:200: if (!output) throw new Error("Script missing; run prepareScript first");
No, that URL has no password in it. That's exactly the error the new script reports: "SASL authentication failed" means Tiger asked for a password and none was sent.
A complete URL puts the password after the username:
postgres://tsdbadmin:<PASSWORD>@<host>:<port>/<db>?sslmode=require
Tiger's console never shows the password inside the copied URL. It shows it only once, when the service is created. If you don't have it, reset it from the service's settings in the Tiger console.
Please also use the direct connection rather than the pooler. The database name tsdb_transaction means this URL goes through Tiger's transaction-mode connection pooler. That's a problem for this app:
- Migrations are safer over a direct connection.
- The web app and worker use prepared statements, which a transaction-mode pooler breaks. The code supports turning them off (createDb({ prepare: false })), but nothing does that today. With this URL you'd swap the migration error for random query errors at runtime.
In the Tiger console, copy the service's direct connection string instead: database tsdb and the service's own port, not the pooler's.
What to do:
1. In Render's unread-shared env group, set PG_URL to the direct connection string with the password filled in. If the password contains characters like @ : / ? #, URL-encode them.
2. Redeploy. If something else is wrong, the pre-deploy log will now show the exact error.
Put the password only in Render. Don't paste it here.
how will those sample episaodes be generated? what's holding it?
I'll check what the episode pipeline needs and what's missing on this machine before answering.
$ ls apps/worker/src/cli; grep -rln -i "seed\|fixture" apps/worker/src packages/core/src --include=*.ts | head; command -v ffmpeg || echo "ffmpeg: not installed"; grep -n -E "^(GEMINI_API_KEY|OPENROUTER_API_KEY|ELEVENLABS_API_KEY)=." apps/web/.env.local | cut -d= -f1 || true; echo "(keys listed above are set)"
args.ts
brief.ts
dispatch.ts
generate-assets.ts
generate-voice-previews.ts
make-episode.ts
promo.ts
warm-models.ts
packages/core/src/cover/render.ts
packages/core/src/episodes/steps.ts
/c/ProgramData/chocolatey/bin/ffmpeg
34:OPENROUTER_API_KEY
38:ELEVENLABS_API_KEY
(keys listed above are set)
Progress since last time: .env.local now has your OpenRouter and ElevenLabs keys (I checked names only, not values), and ffmpeg is installed. Checking what the make-episode command expects:
$ cat apps/worker/src/cli/make-episode.ts | head -50; grep -n "MIN_\|minItems\|Not enough" packages/core/src/episodes/steps.ts | head
import { makeEpisode, requestEpisodeNow } from "@unread/core/episodes";
import { schema } from "@unread/db";
import { eq } from "drizzle-orm";
import { workerContext } from "../context";
import { episodeDeps } from "../deps";
import { parseArgs } from "./args";
/** `pnpm make-episode --user <id>`: make and publish one episode now, in this process. */
async function main() {
const userId = parseArgs(process.argv.slice(2)).get("user");
if (!userId) {
console.error("Usage: pnpm make-episode --user <userId>");
process.exit(2);
}
const { db } = workerContext();
const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1);
if (!user) throw new Error(`No user ${userId}`);
const requestId = await requestEpisodeNow({ db, userId, timezone: user.timezone, now: new Date(), dailyLimit: 100 });
const started = Date.now();
const result = await makeEpisode(episodeDeps(), requestId);
console.log(JSON.stringify({ ...result, seconds: Math.round((Date.now() - started) / 1000) }, null, 2));
await db.$client.end();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
6:import { buildScript, type EpisodeScript, MAX_CHAPTER_CHARS, MIN_CHAPTER_CHARS, scriptCharacters } from "../pipeline";
107: const estimate = queued.reduce((sum, q) => sum + Math.max(MIN_CHAPTER_CHARS, Math.min(MAX_CHAPTER_CHARS, q.length * 0.28)), 0);
122:/** Steps 1–6. Not enough new content → no episode, no cost (prd §6). */
145: const message = "Not enough new content yet. Forward a couple of newsletters and we'll make the next one.";
The sample episode would be made the same way a real user's is, by running the pipeline (pnpm make-episode --user <id>). Nothing has run it yet. Three things are missing, and one decision is yours.
How it would be made
- Content: put about six newsletter issues into the local database for a sample user. They should overlap on the same stories so the "Hear each story once" merging actually happens.
-
pnpm make-episode --user <id>: this groups the stories, has Gemma pick and script the chapters (with a fact check), voices them, runs ffmpeg and saves the audio and chapter list to local storage. -
Landing page: copy the mp3 to
apps/web/public/sample-episode.mp3. Then update the chapter titles, lengths and start times incontent.tsfrom the real episode, so the landing page matches what people hear.
What's holding it up
| Blocker | Status |
|---|---|
| No content. Inbound email isn't deployed, and there's no seed script to load newsletters directly. | I can build this: a script that feeds sample emails through the same parsing code used for real mail. |
| Rights. A public sample shouldn't read out real newsletters' text without permission. | I'd write about six short fictional newsletters, with overlapping stories on AI pricing, a funding round and a design essay, matching the landing page's chapters. |
| No sample user. Users are normally created on first Auth0 sign-in, and Auth0 isn't set up. | The seed script can create the user directly. |
| Gemma has never run for real. | Your OpenRouter key is now in .env.local, so it can. This would be the first real run, and the prompts may need tuning. |
| Voice | Kokoro runs free on this machine: it downloads about 300 MB of models (fine with 5 GB free) and is slowish on CPU. ElevenLabs sounds better but spends credits. |
| ffmpeg, database | Both ready. |
Your call: which voice should the sample use? Kokoro is free and is what Free users hear. ElevenLabs is what the Plus card advertises, and it costs about $0.35–0.70 of credits for a 9-minute episode at list prices. Your project rules say to ask before spending provider credits.
Say which voice and I'll do it end to end: write the sample newsletters, build the seed script, generate the episode, put it on the landing page and update the chapters.
before that,
I created an account n tried forwarding a newsletter to a generated email "[REDACTED]" but gmail said address not found?
why did happend
Gmail says "address not found" when the receiving side rejects the address or has no mail server at all. I'll check the domain's DNS and the worker's alias rules.
$ for n in in.getunread.site getunread.site; do echo "== MX $n"; nslookup -type=MX $n 1.1.1.1 2>&1 | grep -iE "mail exchanger|can't find|NXDOMAIN|No answer|\*\*\*" ; done; echo "== NS getunread.site"; nslookup -type=NS getunread.site 1.1.1.1 2>&1 | grep -i "nameserver"; echo "== TXT in.getunread.site"; nslookup -type=TXT in.getunread.site 1.1.1.1 2>&1 | grep -i "spf\|text ="; cd /c/Users/HP/hacks/unread && grep -n -i "alias\|setReject\|test(" apps/inbound-worker/src/relay.ts | head -20
== MX in.getunread.site
in.getunread.site MX preference = 14, mail exchanger = route2.mx.cloudflare.net
in.getunread.site MX preference = 56, mail exchanger = route1.mx.cloudflare.net
in.getunread.site MX preference = 70, mail exchanger = route3.mx.cloudflare.net
== MX getunread.site
getunread.site MX preference = 14, mail exchanger = route2.mx.cloudflare.net
getunread.site MX preference = 56, mail exchanger = route1.mx.cloudflare.net
getunread.site MX preference = 70, mail exchanger = route3.mx.cloudflare.net
== NS getunread.site
getunread.site nameserver = cecelia.ns.cloudflare.com
getunread.site nameserver = olof.ns.cloudflare.com
== TXT in.getunread.site
in.getunread.site text =
"v=spf1 include:_spf.mx.cloudflare.net ~all"
15: setReject(reason: string): void;
18:export const ALIAS_LOCAL_PART = /^[a-hjkmnp-z2-9]{10}(\+[^@]*)?$/i;
41: * oversized mail and addresses that can't be an alias; otherwise signs the raw
51: if (!ALIAS_LOCAL_PART.test(local)) {
52: message.setReject("Unknown address");
56: message.setReject("Message too large for Unread (2 MB limit)");
82: message.setReject("Unread could not accept this message");
90: message.setReject("Temporary failure at Unread, please try again later");
$ sed -n 40,95p apps/inbound-worker/src/relay.ts
* Cloudflare Email Routing → Unread (ADR 0004). Rejects at SMTP time for
* oversized mail and addresses that can't be an alias; otherwise signs the raw
* MIME and posts it, retrying transient failures.
*/
export async function relay(message: InboundMessage, env: RelayEnv, options: RelayOptions = {}): Promise<string> {
const fetchImpl = options.fetchImpl ?? fetch;
const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
const now = options.now ?? Date.now;
const maxBytes = Number(env.MAX_BYTES ?? 2_097_152);
const local = message.to.split("@")[0] ?? "";
if (!ALIAS_LOCAL_PART.test(local)) {
message.setReject("Unknown address");
return "rejected:address";
}
if (message.rawSize > maxBytes) {
message.setReject("Message too large for Unread (2 MB limit)");
return "rejected:size";
}
const body = await readAll(message.raw, message.rawSize);
for (let attempt = 1; attempt <= 3; attempt++) {
const signature = await signInbound({
secret: env.INBOUND_WEBHOOK_SECRET,
timestamp: Math.floor(now() / 1000),
envelopeFrom: message.from,
envelopeTo: message.to,
body,
});
try {
const response = await fetchImpl(env.INBOUND_WEBHOOK_URL, {
method: "POST",
headers: {
"content-type": "message/rfc822",
"x-unread-signature": signature,
"x-unread-envelope-from": message.from,
"x-unread-envelope-to": message.to,
},
body,
});
if (response.ok) return "relayed";
if (response.status < 500 && response.status !== 429) {
message.setReject("Unread could not accept this message");
return `rejected:${response.status}`;
}
} catch {
// network error: retry
}
if (attempt < 3) await sleep(500 * 2 ** attempt);
}
message.setReject("Temporary failure at Unread, please try again later");
return "failed";
}
DNS is fine and your address is valid, so the problem is almost certainly in Cloudflare's routing: the catch-all rule isn't sending mail to the worker yet.
What I checked:
- DNS is correct. in.getunread.site has Cloudflare's three MX records and its SPF record, so Email Routing is on for the subdomain (steps 1 and 2 are done).
- Your address is valid. 5ggdfsvk83 passes the worker's alias check: 10 characters, none of the easily confused ones (i l o 0 1).
Why Gmail says "address not found": Cloudflare received the email, found no routing rule for 5ggdfsvk83@, and rejected it with "550 5.1.1 Address does not exist". That's what Gmail shows as "Address not found". Aliases are random, so the only rule that can match them is the catch-all. Either the catch-all is off, or it isn't pointed at a worker, or the unread-inbound worker was never deployed. Those are steps 3 and 4 of the setup.
If the worker had rejected it, the bounce would contain one of our own messages instead: "Unread could not accept this message" or "Temporary failure at Unread". Check the bounce text to be sure.
To fix it in the Cloudflare dashboard:
1. Workers & Pages: check whether unread-inbound exists. If not, it needs deploying (step 3 from earlier: set the two secrets, then npx wrangler deploy).
2. Email → Email Routing → Routing rules → Catch-all address: set the action to "Send to a Worker", choose unread-inbound, and make sure it's enabled.
3. Email Routing → Activity log: shows each message and what happened to it. Use it to confirm the next attempt.
I can't see your Cloudflare setup from here because wrangler isn't logged in on this machine. If you run npx wrangler login in apps/inbound-worker, I can check whether the worker exists, and with your go-ahead deploy it with the right secrets. The catch-all rule is a toggle in the dashboard, so that one's quickest done by you.
Also check INBOUND_WEBHOOK_URL. It must point at the live web app (https://getunread.site/inbound/email) and the web app must be deployed. Render's pre-deploy was failing on the database earlier, so if the site isn't up yet, the next bounce will say "Temporary failure at Unread" instead. Fix PG_URL first so the deploy goes through.
it works now, but I haven't being able to get a newsletter yet,
also, check if the /today page has samples datas
Two things to check: why the forwarded newsletter isn't showing up, and whether /today has hardcoded sample data. Starting with the code for both pages:
$ cd /c/Users/HP/hacks/unread/apps/web/src && cat "app/(app)/today/page.tsx"; grep -rn -i -E "sample|placeholder|mock|fake|demo|lorem|Morning Brew|example" features/today features/player features/sources "app/(app)" --include=*.tsx --include=*.ts | grep -v "\.test\." | head -40
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { loadHome } from "@/features/episodes/queries";
import { planContextFor } from "@/features/settings/plan-context";
import { PageContainer } from "@/features/shell/page-container";
import { PageHeading } from "@/features/shell/page-heading";
import { AutoRefresh } from "@/features/today/auto-refresh";
import { ChapterList } from "@/features/today/chapter-list";
import { ChapterListEmpty } from "@/features/today/chapter-list-empty";
import { DetailPanelEmpty } from "@/features/today/detail-panel-empty";
import { EmptyEpisodeCard } from "@/features/today/empty-episode-card";
import { EpisodeCard } from "@/features/today/episode-card";
import { FailureBanner } from "@/features/today/failure-banner";
import { episodeDateParts } from "@/features/today/format-date";
import { InProgressCard } from "@/features/today/in-progress-card";
import { MakeNowButton } from "@/features/today/make-now-button";
import { deliveryScheduleText } from "@/features/today/schedule";
import { TranscriptPanel } from "@/features/today/transcript-panel";
import { requireUser } from "@/features/users/current-user";
import { forwardingAddress } from "@/features/users/forwarding-address";
export const metadata: Metadata = { title: "Today" };
export default async function TodayPage() {
const { user } = await requireUser();
if (!user.onboardedAt) redirect("/onboarding");
const now = new Date();
const date = episodeDateParts(now, user.timezone);
const [home, ctx] = await Promise.all([loadHome(user.id, user.feedToken), planContextFor(user.id)]);
const seed = `${user.id}:${date.isoDate}`;
const latest = home.latest;
const trialNote = ctx.plan.trial
? `Trial: ${ctx.plan.trial.episodesLeft} of ${ctx.plan.trial.episodeLimit} Studio episodes left`
: null;
const madeAt = latest
? new Intl.DateTimeFormat("en-GB", { timeZone: user.timezone, hour: "numeric", minute: "2-digit" }).format(new Date(latest.createdAt))
: "";
const sourceCount = latest ? new Set(latest.transcript.chapters.flatMap((c) => c.sources.map((s) => s.itemId))).size : 0;
return (
<PageContainer>
{home.inProgress ? <AutoRefresh /> : null}
<PageHeading eyebrow={date.eyebrow} title="Your morning brief" />
<div className="mt-5 flex flex-col gap-5 lg:grid lg:grid-cols-[520px_minmax(0,1fr)] lg:items-start lg:gap-10">
<div className="flex flex-col gap-5">
{home.failure ? <FailureBanner message={home.failure} /> : null}
{home.inProgress ? (
<InProgressCard seed={seed} date={date} currentStep={home.inProgress.currentStep} />
) : latest ? (
<EpisodeCard
episode={latest}
madeAt={madeAt}
sourceCount={sourceCount}
trialNote={trialNote}
cover={{ seed: `${user.id}:${latest.id}`, ...episodeDateParts(new Date(latest.createdAt), user.timezone) }}
/>
) : (
<>
<EmptyEpisodeCard
seed={seed}
date={date}
schedule={deliveryScheduleText(user)}
forwardingAddress={forwardingAddress(user.inboundAlias)}
/>
{home.queuedItems >= 2 ? <MakeNowButton label="Make my first episode" /> : null}
</>
)}
{latest && !home.inProgress ? <ChapterList episode={latest} /> : <ChapterListEmpty />}
</div>
<div className="hidden lg:sticky lg:top-10 lg:block">
{latest && !home.inProgress ? <TranscriptPanel episode={latest} /> : <DetailPanelEmpty />}
</div>
</div>
</PageContainer>
);
}
features/sources/forms.tsx:23: <input type="url" name="url" required placeholder="Paste a link" className="min-w-0 flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-muted-foreground" />
features/sources/forms.tsx:43: <input type="email" name="email" required placeholder="Another address you forward from" className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" />
/today loads everything from the database (loadHome). Checking the empty-state components, since that's what you're seeing now:
$ cd /c/Users/HP/hacks/unread/apps/web/src/features/today && grep -n -E ">[^<{]{3,}<|\"[A-Z][a-z].{10,}\"" chapter-list-empty.tsx detail-panel-empty.tsx empty-episode-card.tsx | head -20
chapter-list-empty.tsx:3:/** "In this episode", before there are chapters: what each morning's run does. */
detail-panel-empty.tsx:5: aria-label="Transcript and sources"
detail-panel-empty.tsx:9: <h2 className="m-0 text-[15px] font-semibold">Transcript and sources</h2>
empty-episode-card.tsx:17: <section aria-label="Today's episode" className="overflow-hidden rounded-[20px] border border-line bg-surface">
empty-episode-card.tsx:21: <p className="m-0 text-[17px] font-semibold tracking-[-0.01em]">No episode yet</p>
empty-episode-card.tsx:31: <Link href="/sources">Set up forwarding</Link>
$ cd /c/Users/HP/hacks/unread/packages/core/src/inbound && ls; grep -n -E "reject|reason|status: \"|kind: \"" *.ts | grep -v test | head -40
address.ts
auth.ts
blocks.ts
confirmation.ts
index.ts
links.ts
parse.ts
process.ts
sender.ts
signature.ts
split.ts
process.ts:23: /** Raw emails bigger than this are rejected before parsing. */
process.ts:28: | { status: "duplicate" }
process.ts:29: | { status: "rejected"; reason: RejectReason; inboundId: string | null }
process.ts:30: | { status: "confirmation"; inboundId: string; code: string | null }
process.ts:31: | { status: "sender_verified"; inboundId: string; email: string }
process.ts:32: | { status: "issue"; inboundId: string; newsletterId: string; itemIds: string[] }
process.ts:33: | { status: "link"; inboundId: string; itemIds: string[] };
process.ts:52: return { status: "rejected", reason: "too_large", inboundId: null };
process.ts:70: const inboundId = await insertInbound({ userId: null, kind: "rejected", status: "rejected", rejectReason: "unknown_alias" });
process.ts:71: return { status: "rejected", reason: "unknown_alias", inboundId };
process.ts:79: if (existing) return { status: "duplicate" };
process.ts:92: kind: "forwarding_confirmation",
process.ts:93: status: "processed",
process.ts:97: if (!inboundId) return { status: "duplicate" };
process.ts:98: return { status: "confirmation", inboundId, code: confirmation.code };
process.ts:123: const inboundId = await insertInbound({ userId: user.id, kind: "sender_verification", status: "processed" });
process.ts:124: if (!inboundId) return { status: "duplicate" };
process.ts:125: return { status: "sender_verified", inboundId, email: candidate };
process.ts:134: kind: "rejected",
process.ts:135: status: "rejected",
process.ts:136: rejectReason: "unverified_sender",
process.ts:138: return { status: "rejected", reason: "unverified_sender", inboundId };
process.ts:141: const inboundId = await insertInbound({ userId: user.id, kind: "rejected", status: "rejected", rejectReason: "auth_failed" });
process.ts:142: return { status: "rejected", reason: "auth_failed", inboundId };
process.ts:146: const inboundId = await insertInbound({ userId: user.id, kind: links ? "link" : "issue", status: "received" });
process.ts:147: if (!inboundId) return { status: "duplicate" };
process.ts:167: await db.update(schema.inboundMessages).set({ status: "processed" }).where(eq(schema.inboundMessages.id, inboundId));
process.ts:168: return { status: "link", inboundId, itemIds: rows.map((r) => r.id) };
process.ts:253: .set({ status: "processed", newsletterId: newsletter.id })
process.ts:256: return { status: "issue", inboundId, newsletterId: newsletter.id, itemIds: rows.map((r) => r.id) };
$ sed -n 100,145p process.ts; grep -n -A12 "export function\|export async function" auth.ts | head -40
}
const candidates = forwarderCandidates(email, envelopeFrom);
const senders = await db
.select()
.from(schema.verifiedSenders)
.where(eq(schema.verifiedSenders.userId, user.id));
const byEmail = new Map(senders.map((s) => [s.email.toLowerCase(), s]));
// 2. Proving ownership of an extra address: send any mail containing the code from it.
for (const candidate of candidates) {
const pending = byEmail.get(candidate);
if (
pending &&
!pending.verifiedAt &&
pending.verificationCode &&
email.subject.toUpperCase().includes(pending.verificationCode.toUpperCase()) &&
authOk(candidate)
) {
await db
.update(schema.verifiedSenders)
.set({ verifiedAt: new Date(), verificationCode: null })
.where(eq(schema.verifiedSenders.id, pending.id));
const inboundId = await insertInbound({ userId: user.id, kind: "sender_verification", status: "processed" });
if (!inboundId) return { status: "duplicate" };
return { status: "sender_verified", inboundId, email: candidate };
}
}
// 3. Only verified addresses may forward, and the mail must authenticate as them.
const forwarder = candidates.find((c) => byEmail.get(c)?.verifiedAt);
if (!forwarder) {
const inboundId = await insertInbound({
userId: user.id,
kind: "rejected",
status: "rejected",
rejectReason: "unverified_sender",
});
return { status: "rejected", reason: "unverified_sender", inboundId };
}
if (!authOk(forwarder)) {
const inboundId = await insertInbound({ userId: user.id, kind: "rejected", status: "rejected", rejectReason: "auth_failed" });
return { status: "rejected", reason: "auth_failed", inboundId };
}
const links = detectBareLinks(email);
13:export function parseAuthResults(values: string[]): MethodResult[] {
14- const results: MethodResult[] = [];
15- for (const value of values) {
16- for (const match of value.matchAll(/\b(spf|dkim|dmarc)=(\w+)([^;]*)/gi)) {
17- const method = match[1]?.toLowerCase() as MethodResult["method"];
18- const props = match[3] ?? "";
19- const domainProp =
20- props.match(/header\.d=([\w.-]+)/i)?.[1] ??
21- props.match(/header\.from=([\w.-]+)/i)?.[1] ??
22- props.match(/smtp\.mailfrom=(?:[^@\s;]+@)?([\w.-]+)/i)?.[1] ??
23- null;
24- results.push({ method, result: (match[2] ?? "").toLowerCase(), domain: domainProp?.toLowerCase() ?? null });
25- }
--
39:export function authVerdictFor(email: ParsedEmail, address: string): AuthVerdict {
40- const values = [
41- ...(email.headers.get("authentication-results") ?? []),
42- ...(email.headers.get("arc-authentication-results") ?? []),
43- ];
44- const results = parseAuthResults(values);
45- const receivedSpf = email.headers.get("received-spf") ?? [];
46- if (results.length === 0 && receivedSpf.length === 0) return "none";
47- const expected = domainOf(address);
48- const passed = results.some(
49- (r) => (r.method === "spf" || r.method === "dkim") && r.result === "pass" && aligned(r.domain, expected),
50- );
51- const spfHeaderPass = receivedSpf.some(
$ cd /c/Users/HP/hacks/unread && grep -n -B2 -A8 "const authOk" packages/core/src/inbound/process.ts; grep -n -i -B2 -A6 "verifiedSenders\|email_verified" apps/web/src/features/users/current-user.ts | head -40; grep -rn -i "reject_reason\|rejectReason\|unverified_sender" apps/web/src --include=*.tsx --include=*.ts | head
79- if (existing) return { status: "duplicate" };
80-
81: const authOk = (address: string) => {
82- const verdict = authVerdictFor(email, address);
83- return verdict === "pass" || (verdict === "none" && options.trustUpstreamAuth);
84- };
85-
86- // 1. Gmail forwarding confirmation: from Google, shown to the user, never processed further.
87- if (isFromGmailForwarding(email)) {
88- const confirmation = detectForwardingConfirmation(email);
89- if (confirmation && authOk("forwarding-noreply@google.com")) {
12- sub: z.string().min(1),
13- email: z.email().optional(),
14: email_verified: z.boolean().optional(),
15- name: z.string().optional(),
16-});
17-
18-export type SessionUser = z.infer<typeof sessionUserSchema>;
19-
20-export interface CurrentUser {
--
48- if (!created) throw new Error("Could not create user row");
49- // Auth0 has verified this address, so it may forward newsletters right away (prd §4.1).
50: if (sessionUser.email && sessionUser.email_verified) {
51- await db()
52: .insert(schema.verifiedSenders)
53- .values({ userId: created.id, email: sessionUser.email.toLowerCase(), verifiedAt: new Date() })
54- .onConflictDoNothing();
55- }
56- return created;
57-}
58-
apps/web/src/features/forwarding/live-status.tsx:15: unverified_sender: "We got mail from an address that isn't on your list. Forward from your sign-in address, or add the other address in Sources.",
apps/web/src/features/inbound/queries.ts:34: rejection && rejection.message.rejectReason
apps/web/src/features/inbound/queries.ts:35: ? { reason: rejection.message.rejectReason, at: rejection.message.receivedAt.toISOString() }
I found a real bug that very likely explains this. Reading the full user-loading code before fixing it:
C:\Users\HP\hacks\unread\apps\web\src\features\users\current-user.ts
1 import "server-only";
2 import { newFeedToken, newInboundAlias, schema, type User } from "@unread/db";
3 import { eq } from "drizzle-orm";
4 import type { Route } from "next";
5 import { redirect } from "next/navigation";
6 import { cache } from "react";
7 import { z } from "zod";
8 import { auth0 } from "@/lib/auth0";
9 import { db } from "@/lib/db";
10
11 const sessionUserSchema = z.object({
12 sub: z.string().min(1),
13 email: z.email().optional(),
14 email_verified: z.boolean().optional(),
15 name: z.string().optional(),
16 });
17
18 export type SessionUser = z.infer<typeof sessionUserSchema>;
19
20 export interface CurrentUser {
21 user: User;
22 sessionUser: SessionUser;
23 }
24
25 async function findOrCreateUser(sessionUser: SessionUser): Promise<User> {
26 const [existing] = await db()
27 .select()
28 .from(schema.users)
29 .where(eq(schema.users.auth0Sub, sessionUser.sub))
30 .limit(1);
31 if (existing) return existing;
32
33 await db()
34 .insert(schema.users)
35 .values({
36 auth0Sub: sessionUser.sub,
37 email: sessionUser.email ?? null,
38 feedToken: newFeedToken(),
39 inboundAlias: newInboundAlias(),
40 })
41 .onConflictDoNothing({ target: schema.users.auth0Sub });
42
43 const [created] = await db()
44 .select()
45 .from(schema.users)
46 .where(eq(schema.users.auth0Sub, sessionUser.sub))
47 .limit(1);
48 if (!created) throw new Error("Could not create user row");
49 // Auth0 has verified this address, so it may forward newsletters right away (prd §4.1).
50 if (sessionUser.email && sessionUser.email_verified) {
51 await db()
52 .insert(schema.verifiedSenders)
53 .values({ userId: created.id, email: sessionUser.email.toLowerCase(), verifiedAt: new Date() })
54 .onConflictDoNothing();
55 }
56 return created;
57 }
58
59 /**
60 * The signed-in user, creating their row on first visit. Redirects to
61 * Universal Login when there is no session. Memoised per request.
62 */
63 export const requireUser = cache(async (returnTo: string = "/today"): Promise<CurrentUser> => {
64 const session = await auth0.getSession();
65 if (!session) {
66 // /auth/* is mounted by the Auth0 proxy, so typed routes cannot see it.
67 redirect(`/auth/login?returnTo=${encodeURIComponent(returnTo)}` as Route);
68 }
69 const sessionUser = sessionUserSchema.parse(session.user);
70 const user = await findOrCreateUser(sessionUser);
71 return { user, sessionUser };
72 });
73
74 /** For route handlers: the signed-in user or null (no redirect). */
75 export async function currentUser(): Promise<CurrentUser | null> {
76 const session = await auth0.getSession();
77 if (!session) return null;
78 const sessionUser = sessionUserSchema.parse(session.user);
79 return { user: await findOrCreateUser(sessionUser), sessionUser };
80 }
81
C:\Users\HP\hacks\unread\apps\web\src\features\forwarding\live-status.tsx
1 "use client";
2
3 import { useEffect, useState } from "react";
4 import { CopyButton } from "@/components/ui/copy-button";
5
6 interface Status {
7 inbound: {
8 newsletters: { name: string; at: string }[];
9 confirmation: { code: string | null; url: string | null; at: string } | null;
10 lastRejection: { reason: string; at: string } | null;
11 };
12 }
13
14 const REJECTION_TEXT: Record<string, string> = {
15 unverified_sender: "We got mail from an address that isn't on your list. Forward from your sign-in address, or add the other address in Sources.",
16 auth_failed: "We got a message we couldn't verify (SPF/DKIM failed), so we dropped it.",
17 };
18
19 /** "Waiting for your first newsletter…" → "Got Morning Brew ✓", plus Gmail's code (prd §9). */
20 export function LiveStatus({ initial }: { initial: Status["inbound"] }) {
21 const [status, setStatus] = useState(initial);
22 useEffect(() => {
23 let stopped = false;
24 const tick = async () => {
25 try {
26 const res = await fetch("/api/status", { cache: "no-store" });
27 if (res.ok && !stopped) setStatus(((await res.json()) as Status).inbound);
28 } catch {
29 // offline: keep the last status
30 }
C:\Users\HP\hacks\unread\apps\web\src\features\users\current-user.ts
The file C:\Users\HP\hacks\unread\apps\web\src\features\users\current-user.ts has been updated successfully. (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\apps\web\src\features\users\current-user.ts
The file C:\Users\HP\hacks\unread\apps\web\src\features\users\current-user.ts has been updated successfully. (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\apps\web\src\features\users\current-user.ts
The file C:\Users\HP\hacks\unread\apps\web\src\features\users\current-user.ts has been updated successfully. (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\apps\web\src\features\users\current-user.ts
The file C:\Users\HP\hacks\unread\apps\web\src\features\users\current-user.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Running typecheck, lint and the web tests:
$ cd /c/Users/HP/hacks/unread/apps/web && pnpm exec tsc --noEmit 2>&1 | tail -10; pnpm exec eslint src/features/users 2>&1 | tail -10; pnpm exec vitest run 2>&1 | tail -6
Test Files 3 passed (3)
Tests 9 passed (9)
Start at 18:54:44
Duration 3.39s (transform 33%, tests 28%, worker 19%, import 19%)
All clean. I'll also check the two SQL statements against the local database: a pending sender with different capitalisation should get verified, and the insert shouldn't create a duplicate.
$ docker exec -i unread-postgres psql -U unread -d unread_test -v ON_ERROR_STOP=1 <<'EOF' 2>&1 | tail -6
BEGIN;
INSERT INTO users (id, auth0_sub, email, feed_token, inbound_alias) VALUES ('u_t', 'auth0|t', 'Me@Example.com', 'ft_t', 'abcdefghjk');
INSERT INTO verified_senders (id, user_id, email, verification_code) VALUES ('vs_t', 'u_t', 'Me@Example.com', 'CODE12');
UPDATE verified_senders SET verified_at = now(), verification_code = NULL WHERE user_id = 'u_t' AND lower(email) = 'me@example.com' AND verified_at IS NULL;
INSERT INTO verified_senders (id, user_id, email, verified_at) VALUES ('vs_t2', 'u_t', 'me@example.com', now()) ON CONFLICT DO NOTHING;
SELECT count(*) AS rows, bool_and(verified_at IS NOT NULL) AS verified FROM verified_senders WHERE user_id = 'u_t';
ROLLBACK;
EOF
rows | verified
------+----------
1 | t
(1 row)
ROLLBACK
/today: there's no sample data. Everything it shows comes from your account in the database. Until your first episode exists, you'll only see the "No episode yet" card, your forwarding address and a short note on how each morning's run works.
Why your newsletter isn't arriving: I found a bug that very likely explains it. Your sign-in email was added to your allowed senders only at the moment your account was first created, and only if Auth0 already said the email was verified. If you signed up with email and password, you hadn't clicked Auth0's verification link yet at that point. The app never checked again afterwards. So your address never became an allowed sender, and each forwarded newsletter was quietly dropped as "unverified sender".
The fix is in current-user.ts. The check now runs on every signed-in visit: as soon as Auth0 says your email is verified, it becomes an allowed sender. If the same address was already waiting for a code in Sources, that gets verified too. Typecheck, lint and the web tests pass, and I tested the two database statements on the local test database: a pending address with different capitalisation got verified, with no duplicate row.
For you to do:
1. Deploy the fix. It isn't committed yet; tell me which branch Render deploys from and I'll commit and push it with the landing page and migration changes.
2. Make sure you've clicked Auth0's verification email.
3. Sign out and sign back in. Your current session still carries the old "not verified" status.
4. Forward the newsletter again from your sign-in address. Earlier forwards were dropped, not queued, so they won't come back by themselves.
If it still doesn't show up, look at Sources. It displays the reason for the latest rejected message:
- "An address that isn't on your list": you forwarded from a different address than the one you sign in with. Add that address in Sources.
- "We couldn't verify (SPF/DKIM failed)": this would be a second problem. The check that the email really came from your address hasn't been tested against real Cloudflare mail yet. Tell me if you see this one and I'll look at how Cloudflare formats those authentication headers.
when does a podcast get generated?
I'll check the scheduling code rather than go from the PRD.
$ cd /c/Users/HP/hacks/unread && cat apps/web/src/features/today/schedule.ts; grep -rln "dispatch" packages/core/src apps/worker/src | head;
import type { Cadence } from "@unread/db";
import { displayDeliveryTime } from "./format-date";
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
export interface DeliverySchedule {
cadence: Cadence;
/** 0 = Sunday … 6 = Saturday. */
weeklyDay: number;
/** `HH:mm`, local. */
deliveryTime: string;
}
/** "every Monday at 7:00" or "each morning at 7:00". */
export function deliveryScheduleText({ cadence, weeklyDay, deliveryTime }: DeliverySchedule): string {
const time = displayDeliveryTime(deliveryTime);
if (cadence === "daily") return `each morning at ${time}`;
return `every ${WEEKDAYS[weeklyDay] ?? "Monday"} at ${time}`;
}
packages/core/src/episodes/requests.ts
packages/core/src/links/ssrf.ts
packages/core/src/plans/context.ts
apps/worker/src/cli/dispatch.ts
apps/worker/src/poller.ts
apps/worker/src/temporal/activities.ts
apps/worker/src/temporal/worker.ts
apps/worker/src/temporal/workflows.ts
$ grep -n -E "export (async )?function|lead|minutes|LEAD|before|cadence|weekly|daily|limit|queued" packages/core/src/episodes/requests.ts | head -40; sed -n 1,60p apps/worker/src/poller.ts | grep -n -E "interval|setInterval|every|ms|dispatch|POLL" ; grep -n -A12 "unread-worker\|type: worker\|cron" render.yaml | head -30; grep -n -B3 -A10 "export async function startEpisode\|requestEpisodeNow\|dailyLimit" apps/web/src/features/episodes/start-episode.ts | head -40
6: * Users whose delivery time has arrived (prd §6): runs start 30 minutes
7: * before delivery, in the user's timezone. Weekly users only on their day.
10:export async function dispatchDueRequests(options: { db: Database; now: Date; leadMinutes?: number }): Promise<string[]> {
11: const { db, now, leadMinutes = 30 } = options;
17: cadence: schema.users.cadence,
18: weeklyDay: schema.users.weeklyDay,
25: let clock: { weekday: number; minutes: number };
32: const start = (h ?? 7) * 60 + (m ?? 0) - leadMinutes;
34: const inWindow = clock.minutes >= start;
35: const rightDay = user.cadence === "daily" || clock.weekday === user.weeklyDay;
49:/** "Make one now" (prd §6): per-plan daily limit; returns the request id. */
50:export async function requestEpisodeNow(options: {
55: dailyLimit: number;
63: if ((recent?.count ?? 0) >= options.dailyLimit) {
64: throw new RateLimitedError(`You can make ${options.dailyLimit} extra episode${options.dailyLimit === 1 ? "" : "s"} a day`);
70: .limit(1);
81:export async function claimNextRequest(db: Database): Promise<string | null> {
86: order by created_at asc for update skip locked limit 1
92:export async function pendingRequestIds(db: Database, limit = 20): Promise<string[]> {
98: .limit(limit);
1:import { claimNextRequest, dispatchDueRequests, makeEpisode } from "@unread/core/episodes";
6:const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
9: * No Temporal (cut line, prd §13): dispatch due users every minute and work
21: await dispatchDueRequests({ db, now: new Date() }).catch((e) => console.error("dispatch failed", e));
25: console.log(JSON.stringify({ event: "episode", requestId, ...result, ms: Date.now() - started }));
81: - type: worker
82: name: unread-worker
83- runtime: docker
84- plan: standard
85- dockerfilePath: ./apps/worker/Dockerfile
86- dockerContext: .
87- envVars:
88- - fromGroup: unread-shared
89- - key: KOKORO_CONCURRENCY
90- value: "2"
8- * Kick off the durable workflow for a request right away. Without Temporal
9- * (or if it's unreachable) the worker's poller/dispatcher picks the request up.
10- */
11:export async function startEpisodeWorkflow(requestId: string): Promise<void> {
12- const env = serverEnv();
13- if (!env.TEMPORAL_ADDRESS) return;
14- try {
15- client ??= Connection.connect({
16- address: env.TEMPORAL_ADDRESS,
17- ...(env.TEMPORAL_API_KEY ? { tls: true, apiKey: env.TEMPORAL_API_KEY, metadata: { "temporal-namespace": env.TEMPORAL_NAMESPACE } } : {}),
18- }).then((connection) => new Client({ connection, namespace: env.TEMPORAL_NAMESPACE }));
19- await (await client).workflow.start("makeEpisodeWorkflow", {
20- args: [requestId],
21- taskQueue: env.TEMPORAL_TASK_QUEUE,
$ sed -n 123,150p packages/core/src/episodes/steps.ts; grep -n -B2 -A12 "Make my first episode\|finishOnboarding" apps/web/src/features/onboarding/actions.ts | grep -n -i "request\|queued\|start\|episode" | head
export async function prepareScript(deps: EpisodeDeps, episodeId: string): Promise<PrepareResult> {
const { db, storage } = deps;
const now = nowOf(deps);
const { episode, user } = await loadEpisode(deps, episodeId);
const existing = await getJson<ScriptOutput>(storage, storageKeys.stepOutput(user.id, episodeId, "script"));
if (existing) return { status: "ok" };
const ctx = await loadPlanContext({ db, userId: user.id, now });
await maybeFetchNews(deps, { userId: user.id, maxChars: ctx.budget.maxChars, now });
const result = await buildScript({
newsMode: user.newsMode,
db,
model: deps.model(),
embedder: deps.embedder,
userId: user.id,
maxChars: ctx.budget.maxChars,
dateLabel: spokenDate(now, user.timezone),
now,
});
await recordSteps({ db, episodeId, timingsMs: result.stats.timingsMs });
if (result.status !== "ok" || !result.script) {
const message = "Not enough new content yet. Forward a couple of newsletters and we'll make the next one.";
await finishRequest(deps, episodeId, "skipped", message);
await db.delete(schema.episodes).where(eq(schema.episodes.id, episodeId));
return { status: "skipped", message };
}
3:14:/** Step 3 → "Make my first episode": save preferences, finish onboarding, start an episode. */
6:17- const parsed = episodePrefsSchema.safeParse(Object.fromEntries(formData));
An episode gets made in one of three ways. In every case, the worker on Render has to be running.
1. On your schedule (automatic). Every minute the worker checks for users whose delivery time is coming up. It starts making the episode 30 minutes before your delivery time, in your timezone, so it's ready on time.
- Free plan: weekly, on the day you picked. Plus plan: every day.
- The default delivery time is 7:00. It's changed in Settings, and /today shows the current schedule ("every Monday at 7:00").
2. "Make my first episode" at the end of onboarding. This starts one straight away.
3. "Make one now" on /today. This button appears once you have at least 2 unused newsletters or links waiting. The number of extra episodes per day is limited by plan.
When nothing gets made: if there isn't enough new content when the run starts (for example, nothing forwarded since your last episode), the run is skipped and costs nothing. /today then says "Not enough new content yet. Forward a couple of newsletters and we'll make the next one."
In your case: your first forward was probably dropped by the sender-verification bug, so the episode from onboarding would have found nothing and been skipped. After the fix is deployed and you forward again, either wait for your next scheduled time or use "Make one now" once two items are waiting.
Check that the worker is deployed. In Render, the unread-worker service (Docker) must exist and be running. Without it, requests pile up and nothing is ever generated. It also needs the shared env group, including PG_URL and your OpenRouter or Gemini key.
what's the promo code to get plus?
$ sed -n 1,60p apps/worker/src/cli/promo.ts | grep -n -E "Usage|--|get\(" ; grep -rn -i "LAUNCH\|promo_codes.*values\|INSERT INTO \"promo" packages/db/migrations apps/worker/src | head -5
8: * `pnpm promo create --code HACK2026 --days 14 --episodes 3 [--max 200] [--expires 2026-12-31] [--note "..."]`
16: const code = normalizeCode(args.get("code") ?? "");
17: if (!code) throw new Error("--code is required");
22: trialDays: Number(args.get("days") ?? 14),
23: trialEpisodeLimit: Number(args.get("episodes") ?? 3),
24: maxRedemptions: args.has("max") ? Number(args.get("max")) : null,
25: expiresAt: args.has("expires") ? new Date(String(args.get("expires"))) : null,
26: note: args.get("note") ?? null,
35: console.error("Usage: pnpm promo create --code CODE --days 14 --episodes 3 [--max N] [--expires YYYY-MM-DD] | pnpm promo list");
(?i)promo code|redeem C:\Users\HP\hacks\unread\apps\web\src
apps\web\src\app\(app)\settings\page.tsx:81: <span className="text-[13px] font-semibold text-text-2">Have a promo code?</span>
apps\web\src\features\onboarding\finish-form.tsx:19: {!prefs.isPlus ? <p className="m-0 text-[13px] text-muted-foreground">Studio voices come with Plus or a promo code.</p> : null}
apps\web\src\app\(app)\admin\page.tsx:15:/** prd §8.1/§8.3: daily cost totals per provider, and promo codes. */
apps\web\src\app\(app)\admin\page.tsx:30: .orderBy(desc(schema.promoRedemptions.redeemedAt))
apps\web\src\app\(app)\admin\page.tsx:64: <SectionCard title="Promo codes" description="A code grants Plus for N days, capped at a number of Studio episodes.">
apps\web\src\app\(app)\admin\page.tsx:70: {c.code} · {c.trialDays}d · {c.trialEpisodeLimit} eps · {c.redeemedCount}/{c.maxRedemptions ?? "∞"}
apps\web\src\features\settings\forms.tsx:6:import { deleteAccountAction, redeemPromoAction, updateEpisodePrefsAction, updateVoiceAction } from "./actions";
apps\web\src\features\settings\forms.tsx:45: const [state, action, pending] = useActionState(redeemPromoAction, initialActionState);
[Showing results with pagination = limit: 8]
There's no promo code yet. None ships with the app or the migrations; you create your own. Two ways:
Option A: the admin page (easiest)
1. In Render, set ADMIN_EMAILS to your sign-in email and redeploy. The address must be verified in Auth0.
2. Open https://getunread.site/admin, go to the "Promo codes" section and create one. For example: code HACK2026, 14 days, 3 episodes.
Option B: the command line, pointed at the production database:
sh
PG_URL="<your Tiger URL>" pnpm promo create --code HACK2026 --days 14 --episodes 3 --max 200 --expires 2026-12-31
PG_URL="<your Tiger URL>" pnpm promo list
To redeem a code: go to Settings, then "Have a promo code?". It gives Plus for the number of days you set, with no card.
Promo trials work a little differently from paid Plus:
- Episode limit: the number of Studio episodes is capped by --episodes (3 by default).
- Voice: trials use ElevenLabs' Standard voice. The higher-quality Studio voice is only for paid Plus.
- Verified email: redeeming requires a verified email. With the sign-in fix above, that means signing out and back in after you click Auth0's verification link.
I can't create a code from here because I don't have your production database URL, and that would be a write to your live database. Option A keeps the password out of your terminal history, so I'd use that.
'PG_URL' is not recognized as an internal or external command,
operable program or batch file.
That PG_URL=... command syntax only works in Mac/Linux shells. You're in Windows Command Prompt, which needs a different form. From the repo root ([REDACTED]\hacks\unread):
Command Prompt (cmd):
bat
set "PG_URL=[REDACTED]"
pnpm promo create --code HACK2026 --days 14 --episodes 3 --max 200 --expires 2026-12-31
pnpm promo list
PowerShell:
powershell
$env:PG_URL = "[REDACTED]"
pnpm promo create --code HACK2026 --days 14 --episodes 3 --max 200 --expires 2026-12-31
pnpm promo list
A few things to watch:
- Keep the quotes exactly as shown. In cmd they go around the whole PG_URL=... part, which stops the & or ? in the URL from being misread.
- Use the direct connection URL (database tsdb), not the tsdb_transaction pooler, and include the password.
- Special characters in the password: if it contains @ : / ? # or %, URL-encode them. For example, @ becomes %40.
- The setting lasts only for that terminal window. Close it afterwards so the password doesn't stay around.
If you'd rather not handle the database URL at all, Option A from before is simpler: set ADMIN_EMAILS in Render and create the code at /admin.
how can this app's UX be improved? how is the audio implementation (rendering, playback, etc)? are they robust enough?
is the application robust enough with smooth UX?
I'll look through the web UI and the audio pipeline before giving you a verdict.
$ git ls-files apps/web/src packages/core/src/voice packages/core/src/episodes | head -300 && ls docs/screenshots/u4 2>/dev/null | head
apps/web/src/app/(app)/admin/page.tsx
apps/web/src/app/(app)/billing/return/page.tsx
apps/web/src/app/(app)/layout.tsx
apps/web/src/app/(app)/settings/page.tsx
apps/web/src/app/(app)/sources/page.tsx
apps/web/src/app/(app)/today/page.tsx
apps/web/src/app/api/listen/route.ts
apps/web/src/app/api/status/route.ts
apps/web/src/app/audio/[token]/[file]/route.ts
apps/web/src/app/billing/checkout/route.ts
apps/web/src/app/billing/portal/route.ts
apps/web/src/app/billing/webhooks/bachs/route.ts
apps/web/src/app/feed/[token]/chapters/[file]/route.ts
apps/web/src/app/feed/[token]/cover/[file]/route.ts
apps/web/src/app/feed/[token]/route.ts
apps/web/src/app/globals.css
apps/web/src/app/icon.svg
apps/web/src/app/inbound/email/route.ts
apps/web/src/app/layout.tsx
apps/web/src/app/onboarding/page.tsx
apps/web/src/app/page.tsx
apps/web/src/app/privacy/page.tsx
apps/web/src/app/voice-preview/[voiceId]/route.ts
apps/web/src/components/ui/button.tsx
apps/web/src/components/ui/copy-button.tsx
apps/web/src/components/ui/field-row.tsx
apps/web/src/components/ui/section-card.tsx
apps/web/src/features/action-state.ts
apps/web/src/features/admin/actions.ts
apps/web/src/features/admin/create-promo-form.tsx
apps/web/src/features/admin/guard.ts
apps/web/src/features/billing/bachs.ts
apps/web/src/features/episodes/queries.ts
apps/web/src/features/episodes/start-episode.ts
apps/web/src/features/feed/feed-data.ts
apps/web/src/features/feed/serve-object.ts
apps/web/src/features/forwarding/forwarding-guides.tsx
apps/web/src/features/forwarding/live-status.tsx
apps/web/src/features/inbound/queries.ts
apps/web/src/features/landing/content.ts
apps/web/src/features/landing/sample-player.tsx
apps/web/src/features/landing/sections.tsx
apps/web/src/features/links/extract-queued-links.ts
apps/web/src/features/news/actions.ts
apps/web/src/features/news/news-form.tsx
apps/web/src/features/news/topics.ts
apps/web/src/features/onboarding/actions.ts
apps/web/src/features/onboarding/finish-form.tsx
apps/web/src/features/player/controls.tsx
apps/web/src/features/player/format.ts
apps/web/src/features/player/icons.tsx
apps/web/src/features/player/mini-player.tsx
apps/web/src/features/player/now-playing.tsx
apps/web/src/features/player/player-bar.tsx
apps/web/src/features/player/player-context.tsx
apps/web/src/features/player/progress-bar.tsx
apps/web/src/features/player/transcript-view.tsx
apps/web/src/features/settings/actions.ts
apps/web/src/features/settings/episode-prefs-fields.tsx
apps/web/src/features/settings/forms.tsx
apps/web/src/features/settings/plan-context.ts
apps/web/src/features/settings/rules.ts
apps/web/src/features/settings/studio-voice-form.tsx
apps/web/src/features/settings/studio-voices.ts
apps/web/src/features/settings/voice-picker.tsx
apps/web/src/features/shell/app-shell.tsx
apps/web/src/features/shell/brand-mark.tsx
apps/web/src/features/shell/mobile-header.tsx
apps/web/src/features/shell/nav-icons.tsx
apps/web/src/features/shell/nav-items.ts
apps/web/src/features/shell/page-container.tsx
apps/web/src/features/shell/page-heading.tsx
apps/web/src/features/shell/sidebar-nav.tsx
apps/web/src/features/shell/tab-bar.tsx
apps/web/src/features/sources/actions.ts
apps/web/src/features/sources/forms.tsx
apps/web/src/features/sources/relative-time.ts
apps/web/src/features/today/auto-refresh.tsx
apps/web/src/features/today/chapter-list-empty.tsx
apps/web/src/features/today/chapter-list.tsx
apps/web/src/features/today/detail-panel-empty.tsx
apps/web/src/features/today/empty-episode-card.tsx
apps/web/src/features/today/episode-card.tsx
apps/web/src/features/today/episode-cover.tsx
apps/web/src/features/today/failure-banner.tsx
apps/web/src/features/today/format-date.test.ts
apps/web/src/features/today/format-date.ts
apps/web/src/features/today/in-progress-card.tsx
apps/web/src/features/today/make-now-button.tsx
apps/web/src/features/today/pipeline-steps.ts
apps/web/src/features/today/schedule.test.ts
apps/web/src/features/today/schedule.ts
apps/web/src/features/today/step-progress.tsx
apps/web/src/features/today/transcript-panel.tsx
apps/web/src/features/users/current-user.ts
apps/web/src/features/users/forwarding-address.ts
apps/web/src/features/users/initials.test.ts
apps/web/src/features/users/initials.ts
apps/web/src/lib/auth0.ts
apps/web/src/lib/db.ts
apps/web/src/lib/env.ts
apps/web/src/lib/storage.ts
apps/web/src/lib/use-browser-value.ts
apps/web/src/lib/utils.ts
apps/web/src/proxy.ts
packages/core/src/episodes/deps.ts
packages/core/src/episodes/index.ts
packages/core/src/episodes/make-episode.ts
packages/core/src/episodes/requests.ts
packages/core/src/episodes/step-tracker.ts
packages/core/src/episodes/steps.ts
packages/core/src/episodes/transcript.ts
packages/core/src/voice/cache.ts
packages/core/src/voice/elevenlabs.ts
packages/core/src/voice/index.ts
packages/core/src/voice/kokoro.ts
packages/core/src/voice/pcm.ts
packages/core/src/voice/voice-provider.ts
packages/core/src/voice/voices.ts
d-sources.png
d-today-playing.png
p-nowplaying.png
p-onboarding.png
p-today.png
$ wc -l packages/core/src/voice/*.ts packages/core/src/episodes/*.ts apps/web/src/features/player/*.tsx apps/web/src/app/audio/\[token\]/\[file\]/route.ts apps/web/src/features/feed/serve-object.ts apps/web/src/lib/storage.ts apps/web/src/app/api/listen/route.ts; ls packages/core/src
28 packages/core/src/voice/cache.ts
113 packages/core/src/voice/elevenlabs.ts
24 packages/core/src/voice/index.ts
34 packages/core/src/voice/kokoro.ts
11 packages/core/src/voice/pcm.ts
30 packages/core/src/voice/voice-provider.ts
9 packages/core/src/voice/voices.ts
16 packages/core/src/episodes/deps.ts
5 packages/core/src/episodes/index.ts
28 packages/core/src/episodes/make-episode.ts
100 packages/core/src/episodes/requests.ts
55 packages/core/src/episodes/step-tracker.ts
413 packages/core/src/episodes/steps.ts
21 packages/core/src/episodes/transcript.ts
48 apps/web/src/features/player/controls.tsx
82 apps/web/src/features/player/icons.tsx
30 apps/web/src/features/player/mini-player.tsx
51 apps/web/src/features/player/now-playing.tsx
42 apps/web/src/features/player/player-bar.tsx
311 apps/web/src/features/player/player-context.tsx
46 apps/web/src/features/player/progress-bar.tsx
58 apps/web/src/features/player/transcript-view.tsx
13 apps/web/src/app/audio/[token]/[file]/route.ts
40 apps/web/src/features/feed/serve-object.ts
10 apps/web/src/lib/storage.ts
43 apps/web/src/app/api/listen/route.ts
1661 total
audio
billing
config.ts
cover
embeddings
episodes
feed
inbound
links
mail
model
news
pipeline
plans
promo
storage
util
voice
C:\Users\HP\hacks\unread\apps\web\src\features\player\player-context.tsx
1 "use client";
2
3 import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4 import type { PlayerEpisode } from "@/features/episodes/queries";
5
6 const RATES = [1, 1.2, 1.5, 1.75, 2, 0.8] as const;
7
8 export interface PlayerState {
9 episode: PlayerEpisode | null;
10 playing: boolean;
11 currentTime: number;
12 duration: number;
13 rate: number;
14 expanded: boolean;
15 /** Chapter shown in the desktop transcript panel (clicked, or the one playing). */
16 focusedChapter: number | null;
17 chapterIndex: number | null;
18 }
19
20 export interface PlayerApi extends PlayerState {
21 load: (episode: PlayerEpisode, options?: { autoplay?: boolean; startAt?: number }) => void;
22 toggle: () => void;
23 seek: (time: number) => void;
24 skipBy: (seconds: number) => void;
25 goToChapter: (index: number) => void;
26 nextChapter: () => void;
27 previousChapter: () => void;
28 skipStory: () => void;
29 cycleRate: () => void;
30 setExpanded: (expanded: boolean) => void;
31 focusChapter: (index: number | null) => void;
32 }
33
34 const PlayerContext = createContext<PlayerApi | null>(null);
35
36 export function usePlayer(): PlayerApi {
37 const ctx = useContext(PlayerContext);
38 if (!ctx) throw new Error("usePlayer outside PlayerProvider");
39 return ctx;
40 }
41
42 export function chapterAt(episode: PlayerEpisode | null, time: number): number | null {
43 if (!episode) return null;
44 const chapters = episode.transcript.chapters;
45 for (let i = chapters.length - 1; i >= 0; i--) {
46 if (time >= (chapters[i]?.startS ?? 0) - 0.05) return i;
47 }
48 return null;
49 }
50
51 function sendEvent(body: { episodeId: string; chapterIndex: number | null; event: "play" | "skip" | "complete" | "seek"; positionS: number }) {
52 void fetch("/api/listen", {
53 method: "POST",
54 headers: { "content-type": "application/json" },
55 body: JSON.stringify(body),
56 keepalive: true,
57 }).catch(() => undefined);
58 }
59
60 function storedPosition(id: string): number {
61 try {
62 return Number(localStorage.getItem(`unread:pos:${id}`) ?? 0) || 0;
63 } catch {
64 return 0;
65 }
66 }
67
68 function storePosition(id: string, time: number) {
69 try {
70 localStorage.setItem(`unread:pos:${id}`, String(Math.floor(time)));
71 } catch {
72 // private mode: fine
73 }
74 }
75
76 /** One audio element for the whole app (prd §9: global player, persists across navigation). */
77 export function PlayerProvider({ children }: { children: ReactNode }) {
78 const audioRef = useRef<HTMLAudioElement | null>(null);
79 const [episode, setEpisode] = useState<PlayerEpisode | null>(null);
80 const [playing, setPlaying] = useState(false);
81 const [currentTime, setCurrentTime] = useState(0);
82 const [duration, setDuration] = useState(0);
83 const [rate, setRate] = useState(1);
84 const [expanded, setExpanded] = useState(false);
85 const [focusedChapter, focusChapter] = useState<number | null>(null);
86 const lastChapter = useRef<number | null>(null);
87 const seeking = useRef(false);
88
89 const chapterIndex = chapterAt(episode, currentTime);
90
91 const loadedId = useRef<string | null>(null);
92
93 const load = useCallback((next: PlayerEpisode, options: { autoplay?: boolean; startAt?: number } = {}) => {
94 const audio = audioRef.current;
95 if (!audio) return;
96 // Side effects stay out of state updaters: React may run those after play().
97 if (loadedId.current !== next.id) {
98 loadedId.current = next.id;
99 audio.src = next.audioUrl;
100 audio.currentTime = options.startAt ?? storedPosition(next.id);
101 lastChapter.current = null;
102 setEpisode(next);
103 setDuration(next.durationS);
104 } else if (options.startAt !== undefined) {
105 audio.currentTime = options.startAt;
106 }
107 if (options.autoplay !== false) void audio.play().catch(() => undefined);
108 }, []);
109
110 const toggle = useCallback(() => {
111 const audio = audioRef.current;
112 if (!audio || !audio.src) return;
113 if (audio.paused) void audio.play().catch(() => undefined);
114 else audio.pause();
115 }, []);
116
117 const seek = useCallback(
118 (time: number) => {
119 const audio = audioRef.current;
120 if (!audio || !episode) return;
121 seeking.current = true;
122 audio.currentTime = Math.max(0, Math.min(time, audio.duration || episode.durationS));
123 setCurrentTime(audio.currentTime);
124 },
125 [episode],
126 );
127
128 const skipBy = useCallback((seconds: number) => seek((audioRef.current?.currentTime ?? 0) + seconds), [seek]);
129
130 const goToChapter = useCallback(
131 (index: number) => {
132 const chapter = episode?.transcript.chapters[index];
133 if (chapter) seek(chapter.startS);
134 },
135 [episode, seek],
136 );
137
138 const nextChapter = useCallback(() => {
139 if (!episode) return;
140 const idx = chapterAt(episode, audioRef.current?.currentTime ?? 0);
141 const next = idx === null ? 0 : idx + 1;
142 if (next < episode.transcript.chapters.length) goToChapter(next);
143 else seek(episode.durationS);
144 }, [episode, goToChapter, seek]);
145
146 const previousChapter = useCallback(() => {
147 if (!episode) return;
148 const time = audioRef.current?.currentTime ?? 0;
149 const idx = chapterAt(episode, time);
150 if (idx === null) return seek(0);
151 const start = episode.transcript.chapters[idx]?.startS ?? 0;
152 // Like a CD player: restart the chapter unless we're at its start.
153 goToChapter(time - start > 3 || idx === 0 ? idx : idx - 1);
154 }, [episode, goToChapter, seek]);
155
156 const skipStory = useCallback(() => {
157 if (!episode) return;
158 const time = audioRef.current?.currentTime ?? 0;
159 sendEvent({ episodeId: episode.id, chapterIndex: chapterAt(episode, time), event: "skip", positionS: time });
160 nextChapter();
161 }, [episode, nextChapter]);
162
163 const cycleRate = useCallback(() => {
164 setRate((r) => {
165 const next = RATES[(RATES.indexOf(r as (typeof RATES)[number]) + 1) % RATES.length] ?? 1;
166 if (audioRef.current) audioRef.current.playbackRate = next;
167 return next;
168 });
169 }, []);
170
171 // Audio element events.
172 useEffect(() => {
173 const audio = audioRef.current;
174 if (!audio) return;
175 const onPlay = () => {
176 setPlaying(true);
177 if (episode) sendEvent({ episodeId: episode.id, chapterIndex: chapterAt(episode, audio.currentTime), event: "play", positionS: audio.currentTime });
178 };
179 const onPause = () => setPlaying(false);
180 const onTime = () => {
181 setCurrentTime(audio.currentTime);
182 if (!episode) return;
183 const idx = chapterAt(episode, audio.currentTime);
184 const prev = lastChapter.current;
185 if (!seeking.current && prev !== null && idx !== null && idx === prev + 1) {
186 sendEvent({ episodeId: episode.id, chapterIndex: prev, event: "complete", positionS: audio.currentTime });
187 }
188 lastChapter.current = idx;
189 if (Math.floor(audio.currentTime) % 5 === 0) storePosition(episode.id, audio.currentTime);
190 };
191 const onSeeked = () => {
192 if (episode && seeking.current) sendEvent({ episodeId: episode.id, chapterIndex: chapterAt(episode, audio.currentTime), event: "seek", positionS: audio.currentTime });
193 seeking.current = false;
194 lastChapter.current = episode ? chapterAt(episode, audio.currentTime) : null;
195 };
196 const onMeta = () => setDuration(audio.duration || episode?.durationS || 0);
197 const onEnded = () => {
198 setPlaying(false);
199 if (episode) {
200 sendEvent({ episodeId: episode.id, chapterIndex: episode.transcript.chapters.length - 1, event: "complete", positionS: audio.currentTime });
201 storePosition(episode.id, 0);
202 }
203 };
204 audio.addEventListener("play", onPlay);
205 audio.addEventListener("pause", onPause);
206 audio.addEventListener("timeupdate", onTime);
207 audio.addEventListener("seeked", onSeeked);
208 audio.addEventListener("loadedmetadata", onMeta);
209 audio.addEventListener("ended", onEnded);
210 return () => {
211 audio.removeEventListener("play", onPlay);
212 audio.removeEventListener("pause", onPause);
213 audio.removeEventListener("timeupdate", onTime);
214 audio.removeEventListener("seeked", onSeeked);
215 audio.removeEventListener("loadedmetadata", onMeta);
216 audio.removeEventListener("ended", onEnded);
217 };
218 }, [episode]);
219
220 // Media Session: lock-screen controls and metadata (prd §9).
221 useEffect(() => {
222 if (!episode || typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
223 const chapter = chapterIndex !== null ? episode.transcript.chapters[chapterIndex] : null;
224 navigator.mediaSession.metadata = new MediaMetadata({
225 title: chapter?.title ?? episode.title,
226 artist: chapter?.origin ?? "Unread",
227 album: episode.title,
228 artwork: [{ src: episode.coverUrl, sizes: "1200x1200", type: "image/png" }],
229 });
230 }, [episode, chapterIndex]);
231
232 useEffect(() => {
233 if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
234 const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
235 ["play", () => toggle()],
236 ["pause", () => toggle()],
237 ["seekbackward", () => skipBy(-15)],
238 ["seekforward", () => skipBy(15)],
239 ["previoustrack", () => previousChapter()],
240 ["nexttrack", () => nextChapter()],
241 ["seekto", (d) => d.seekTime !== undefined && seek(d.seekTime)],
242 ];
243 for (const [action, handler] of handlers) {
244 try {
245 navigator.mediaSession.setActionHandler(action, handler);
246 } catch {
247 // unsupported action
248 }
249 }
250 }, [toggle, skipBy, previousChapter, nextChapter, seek]);
251
252 // Desktop shortcuts: space, ←/→ 15 s, shift+←/→ chapter (prd §9).
253 useEffect(() => {
254 const onKey = (event: KeyboardEvent) => {
255 const target = event.target as HTMLElement | null;
256 if (!episode || event.metaKey || event.ctrlKey || event.altKey || !target) return;
257 if (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
258 // A focused button/link already handles Space itself.
259 if (event.key === " " && /^(BUTTON|A)$/.test(target.tagName)) return;
260 if (event.key === " ") {
261 event.preventDefault();
262 toggle();
263 } else if (event.key === "ArrowLeft") {
264 event.preventDefault();
265 if (event.shiftKey) previousChapter();
266 else skipBy(-15);
267 } else if (event.key === "ArrowRight") {
268 event.preventDefault();
269 if (event.shiftKey) nextChapter();
270 else skipBy(15);
271 } else if (event.key === "Escape") {
272 setExpanded(false);
273 }
274 };
275 window.addEventListener("keydown", onKey);
276 return () => window.removeEventListener("keydown", onKey);
277 }, [episode, toggle, skipBy, previousChapter, nextChapter]);
278
279 const api = useMemo<PlayerApi>(
280 () => ({
281 episode,
282 playing,
283 currentTime,
284 duration: duration || episode?.durationS || 0,
285 rate,
286 expanded,
287 focusedChapter,
288 chapterIndex,
289 load,
290 toggle,
291 seek,
292 skipBy,
293 goToChapter,
294 nextChapter,
295 previousChapter,
296 skipStory,
297 cycleRate,
298 setExpanded,
299 focusChapter,
300 }),
301 [episode, playing, currentTime, duration, rate, expanded, focusedChapter, chapterIndex, load, toggle, seek, skipBy, goToChapter, nextChapter, previousChapter, skipStory, cycleRate],
302 );
303
304 return (
305 <PlayerContext.Provider value={api}>
306 {children}
307 {/* The transcript is the caption track (prd §9). */}
308 <audio ref={audioRef} preload="metadata" className="hidden" />
309 </PlayerContext.Provider>
310 );
311 }
312
$ cat packages/core/src/voice/*.ts && sed -n 1,413p packages/core/src/episodes/steps.ts && ls packages/core/src/audio && cat packages/core/src/audio/*.ts | head -400
<persisted-output>
Output too large (32.9KB). Full output saved to: C:\Users\HP\.claude\projects\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tool-results\biui323cw.txt
Preview (first 2KB):
import { type Storage, storageKeys } from "../storage";
import { sha256Hex } from "../util/hash";
import type { SynthesizeRequest, VoiceClip, VoiceProvider } from "./voice-provider";
function toBytes(samples: Float32Array): Uint8Array {
return new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
}
/**
* Cache clips by text + voice + provider + model (prd §6 step 7), so retries
* and re-runs never pay twice.
*/
export function withVoiceCache(provider: VoiceProvider, storage: Storage): VoiceProvider {
return {
...provider,
async synthesize(request: SynthesizeRequest): Promise<VoiceClip> {
const key = storageKeys.voiceCache(sha256Hex(`${provider.id}|${provider.model}|${request.voiceId}|${request.text}`));
const cached = await storage.get(key);
if (cached) {
const copy = cached.slice().buffer;
return { samples: new Float32Array(copy), sampleRate: 24_000 };
}
const clip = await provider.synthesize(request);
await storage.put(key, toBytes(clip.samples), "application/octet-stream");
return clip;
},
};
}
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import { pcm16ToFloat32, readAll } from "./pcm";
import { SAMPLE_RATE, type SynthesizeRequest, type VoiceClip, type VoiceProvider, VoiceNotConfiguredError } from "./voice-provider";
/**
* prd §8: Flash by default; "Studio voices" use ElevenLabs' highest-quality
* model (eleven_v4 supersedes Multilingual v2/v3, ADR 0006). Cents are list
* prices per 1K characters (verify before launch).
*/
export const ELEVENLABS_MODELS = {
flash: { id: "eleven_flash_v2_5", centsPer1kChars: 4 },
studio: { id: "eleven_v4", centsPer1kChars: 8 },
} as const;
export type ElevenLabsQuality = keyof typeof ELEVENLABS_MODELS;
/** Default voice until the user picks one: George (narrative). */
export const DEFAULT_ELEVENLABS_VOICE = "JBFqnCBsd6RMkjVDRZzb";
...
</persisted-output>
C:\Users\HP\.claude\projects\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tool-results\biui323cw.txt
1 import { type Storage, storageKeys } from "../storage";
2 import { sha256Hex } from "../util/hash";
3 import type { SynthesizeRequest, VoiceClip, VoiceProvider } from "./voice-provider";
4
5 function toBytes(samples: Float32Array): Uint8Array {
6 return new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
7 }
8
9 /**
10 * Cache clips by text + voice + provider + model (prd §6 step 7), so retries
11 * and re-runs never pay twice.
12 */
13 export function withVoiceCache(provider: VoiceProvider, storage: Storage): VoiceProvider {
14 return {
15 ...provider,
16 async synthesize(request: SynthesizeRequest): Promise<VoiceClip> {
17 const key = storageKeys.voiceCache(sha256Hex(`${provider.id}|${provider.model}|${request.voiceId}|${request.text}`));
18 const cached = await storage.get(key);
19 if (cached) {
20 const copy = cached.slice().buffer;
21 return { samples: new Float32Array(copy), sampleRate: 24_000 };
22 }
23 const clip = await provider.synthesize(request);
24 await storage.put(key, toBytes(clip.samples), "application/octet-stream");
25 return clip;
26 },
27 };
28 }
29 import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
30 import { pcm16ToFloat32, readAll } from "./pcm";
31 import { SAMPLE_RATE, type SynthesizeRequest, type VoiceClip, type VoiceProvider, VoiceNotConfiguredError } from "./voice-provider";
32
33 /**
34 * prd §8: Flash by default; "Studio voices" use ElevenLabs' highest-quality
35 * model (eleven_v4 supersedes Multilingual v2/v3, ADR 0006). Cents are list
36 * prices per 1K characters (verify before launch).
37 */
38 export const ELEVENLABS_MODELS = {
39 flash: { id: "eleven_flash_v2_5", centsPer1kChars: 4 },
40 studio: { id: "eleven_v4", centsPer1kChars: 8 },
41 } as const;
42
43 export type ElevenLabsQuality = keyof typeof ELEVENLABS_MODELS;
44
45 /** Default voice until the user picks one: George (narrative). */
46 export const DEFAULT_ELEVENLABS_VOICE = "JBFqnCBsd6RMkjVDRZzb";
47
48 /** The parts of the SDK we use, so tests can stand in for the API. */
49 export interface ElevenLabsApi {
50 textToSpeech: {
51 convert(
52 voiceId: string,
53 request: { text: string; modelId: string; outputFormat: "pcm_24000"; previousText?: string; nextText?: string },
54 ): Promise<ReadableStream<Uint8Array>>;
55 };
56 }
57
58 /** The SDK reads ELEVENLABS_API_KEY from the environment; the key never goes in code. */
59 export function elevenLabsClient(apiKey?: string): ElevenLabsClient {
60 if (!apiKey) throw new VoiceNotConfiguredError("ElevenLabs isn't set up (ELEVENLABS_API_KEY)");
61 return new ElevenLabsClient({ apiKey });
62 }
63
64 export function createElevenLabsProvider(options: { api: ElevenLabsApi; quality: ElevenLabsQuality; concurrency?: number }): VoiceProvider {
65 const model = ELEVENLABS_MODELS[options.quality];
66 return {
67 id: "elevenlabs",
68 model: model.id,
69 centsPer1kChars: model.centsPer1kChars,
70 concurrency: options.concurrency ?? 4,
71 async synthesize(request: SynthesizeRequest): Promise<VoiceClip> {
72 // Request stitching: neighbouring lines keep tone and pacing continuous across clips.
73 const stream = await options.api.textToSpeech.convert(request.voiceId, {
74 text: request.text,
75 modelId: model.id,
76 outputFormat: "pcm_24000",
77 ...(request.previousText ? { previousText: request.previousText } : {}),
78 ...(request.nextText ? { nextText: request.nextText } : {}),
79 });
80 return { samples: pcm16ToFloat32(await readAll(stream)), sampleRate: SAMPLE_RATE };
81 },
82 };
83 }
84
85 export interface StudioVoice {
86 id: string;
87 name: string;
88 description: string;
89 previewUrl: string | null;
90 }
91
92 /** Voices for the Plus picker (prd §8.1): the account's voices, including library voices it has added. */
93 export async function listStudioVoices(client: ElevenLabsClient): Promise<StudioVoice[]> {
94 const voices: StudioVoice[] = [];
95 let nextPageToken: string | undefined;
96 do {
97 const page = await client.voices.search({ pageSize: 100, ...(nextPageToken ? { nextPageToken } : {}) });
98 for (const v of page.voices) {
99 const labels = v.labels ?? {};
100 voices.push({
101 id: v.voiceId,
102 name: v.name ?? v.voiceId,
103 description: [labels.gender, labels.accent, labels.descriptive ?? labels.description, labels.use_case].filter(Boolean).join(" · "),
104 previewUrl: v.previewUrl ?? null,
105 });
106 }
107 nextPageToken = page.hasMore ? page.nextPageToken : undefined;
108 } while (nextPageToken && voices.length < 500);
109 return voices;
110 }
111
112 /** The fixed SFX set (prd §8.1), generated once and reused on every plan. */
113 export const SFX_PROMPTS = {
114 "sfx-intro": { text: "Short warm podcast intro sting, soft synth swell with a gentle bell, modern and clean, no voice", durationSeconds: 2.5 },
115 "sfx-transition": { text: "Subtle soft whoosh transition for a podcast, airy and clean, no voice", durationSeconds: 1 },
116 "sfx-outro": { text: "Short gentle podcast outro sting, warm synth resolving chord, calm, no voice", durationSeconds: 2.5 },
117 } as const;
118
119 export async function generateSoundEffect(client: ElevenLabsClient, key: keyof typeof SFX_PROMPTS): Promise<Uint8Array> {
120 const prompt = SFX_PROMPTS[key];
121 const stream = await client.textToSoundEffects.convert({
122 text: prompt.text,
123 durationSeconds: prompt.durationSeconds,
124 promptInfluence: 0.6,
125 outputFormat: "mp3_44100_128",
126 });
127 return readAll(stream);
128 }
129
130 /** The "Upgrade to Studio voices" sample: the same intro in a Studio voice (prd §8.1). */
131 export const STUDIO_PREVIEW_TEXT =
132 "This is Unread for Saturday. Six stories today, from Morning Brew, The Hustle and your saved links. Here's the first one.";
133
134 export async function generateStudioPreview(client: ElevenLabsClient, voiceId: string): Promise<Uint8Array> {
135 const stream = await client.textToSpeech.convert(voiceId, {
136 text: STUDIO_PREVIEW_TEXT,
137 modelId: ELEVENLABS_MODELS.studio.id,
138 outputFormat: "mp3_44100_128",
139 });
140 return readAll(stream);
141 }
142 import type { VoiceProvider as VoiceProviderId } from "@unread/db";
143 import type { CoreConfig } from "../config";
144 import { createElevenLabsProvider, type ElevenLabsQuality, elevenLabsClient } from "./elevenlabs";
145 import { createKokoroProvider } from "./kokoro";
146 import type { VoiceProvider } from "./voice-provider";
147
148 export * from "./cache";
149 export * from "./elevenlabs";
150 export * from "./pcm";
151 export * from "./kokoro";
152 export * from "./voice-provider";
153 export * from "./voices";
154
155 /** Provider for the user's effective plan (prd §8.1); Studio quality only matters for ElevenLabs. */
156 export function voiceProviderFor(id: VoiceProviderId, config: CoreConfig, options: { quality?: ElevenLabsQuality } = {}): VoiceProvider {
157 if (id === "kokoro") {
158 return createKokoroProvider({
159 modelId: config.env.KOKORO_MODEL,
160 concurrency: config.env.KOKORO_CONCURRENCY,
161 cacheDir: config.env.MODEL_CACHE_DIR,
162 });
163 }
164 return createElevenLabsProvider({ api: elevenLabsClient(config.env.ELEVENLABS_API_KEY), quality: options.quality ?? "flash" });
165 }
166 import { tmpdir } from "node:os";
167 import { join } from "node:path";
168 import { SAMPLE_RATE, type SynthesizeRequest, type VoiceClip, type VoiceProvider } from "./voice-provider";
169 import { KOKORO_FREE_VOICES } from "./voices";
170
171 type KokoroModel = {
172 generate(text: string, options: { voice: string; speed?: number }): Promise<{ audio: Float32Array; sampling_rate: number }>;
173 };
174
175 let loading: Promise<KokoroModel> | undefined;
176
177 /** Kokoro-82M (Apache-2.0) on the worker's CPU via kokoro-js (ADR 0006). */
178 export function createKokoroProvider(options: { modelId: string; concurrency: number; cacheDir?: string }): VoiceProvider {
179 const load = async () => {
180 const transformers = await import("@huggingface/transformers");
181 transformers.env.cacheDir = options.cacheDir ?? join(tmpdir(), "unread-models");
182 const { KokoroTTS } = await import("kokoro-js");
183 return (await KokoroTTS.from_pretrained(options.modelId, { dtype: "q8", device: "cpu" })) as unknown as KokoroModel;
184 };
185 return {
186 id: "kokoro",
187 model: "kokoro-82m-v1.0-q8",
188 centsPer1kChars: 0,
189 concurrency: options.concurrency,
190 async synthesize(request: SynthesizeRequest): Promise<VoiceClip> {
191 loading ??= load();
192 const tts = await loading;
193 const voice = KOKORO_FREE_VOICES.some((v) => v.id === request.voiceId) ? request.voiceId : "af_heart";
194 const audio = await tts.generate(request.text, { voice });
195 if (audio.sampling_rate !== SAMPLE_RATE) throw new Error(`Unexpected Kokoro sample rate ${audio.sampling_rate}`);
196 return { samples: audio.audio, sampleRate: audio.sampling_rate };
197 },
198 };
199 }
200 /** Signed 16-bit little-endian PCM (ElevenLabs `pcm_24000`) → float samples in [-1, 1]. */
201 export function pcm16ToFloat32(bytes: Uint8Array): Float32Array {
202 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength - (bytes.byteLength % 2));
203 const out = new Float32Array(view.byteLength / 2);
204 for (let i = 0; i < out.length; i++) out[i] = view.getInt16(i * 2, true) / 32768;
205 return out;
206 }
207
208 export async function readAll(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
209 return new Uint8Array(await new Response(stream).arrayBuffer());
210 }
211 import type { VoiceProvider as VoiceProviderId } from "@unread/db";
212
213 /** Mono float PCM at `sampleRate`. Everything is resampled to 24 kHz before assembly. */
214 export interface VoiceClip {
215 samples: Float32Array;
216 sampleRate: number;
217 }
218
219 export interface SynthesizeRequest {
220 text: string;
221 voiceId: string;
222 /** Neighbouring text, for providers that support continuity between requests. */
223 previousText?: string;
224 nextText?: string;
225 }
226
227 /** prd §8.1: the pipeline picks a provider by effective plan; voice code never checks plans. */
228 export interface VoiceProvider {
229 readonly id: VoiceProviderId;
230 readonly model: string;
231 /** Estimated cost in US cents per 1,000 characters (0 for Kokoro). */
232 readonly centsPer1kChars: number;
233 /** Jobs this provider can run at once. */
234 readonly concurrency: number;
235 synthesize(request: SynthesizeRequest): Promise<VoiceClip>;
236 }
237
238 export class VoiceNotConfiguredError extends Error {}
239
240 export const SAMPLE_RATE = 24_000;
241 /** Free-plan voices (prd §8: four curated; the last two are placeholders until listening tests). */
242 export const KOKORO_FREE_VOICES = [
243 { id: "af_heart", label: "Heart", accent: "American" },
244 { id: "af_bella", label: "Bella", accent: "American" },
245 { id: "am_michael", label: "Michael", accent: "American" },
246 { id: "bf_emma", label: "Emma", accent: "British" },
247 ] as const;
248
249 export type KokoroVoiceId = (typeof KOKORO_FREE_VOICES)[number]["id"];
250 import { schema } from "@unread/db";
251 import { and, eq, inArray, sql } from "drizzle-orm";
252 import { assembleEpisode, decodeToPcm } from "../audio";
253 import { coverSvg, renderCoverPng } from "../cover";
254 import { fetchNewsItems } from "../news";
255 import { buildScript, type EpisodeScript, MAX_CHAPTER_CHARS, MIN_CHAPTER_CHARS, scriptCharacters } from "../pipeline";
256 import { loadPlanContext } from "../plans";
257 import { getJson, putJson, storageKeys } from "../storage";
258 import { spokenDate } from "../util/dates";
259 import { DEFAULT_ELEVENLABS_VOICE } from "../voice/elevenlabs";
260 import { type VoiceClip, withVoiceCache } from "../voice";
261 import type { EpisodeDeps } from "./deps";
262 import { recordSteps, trackStep } from "./step-tracker";
263 import type { EpisodeTranscript } from "./transcript";
264
265
266 interface ScriptOutput {
267 script: EpisodeScript;
268 stats: Record<string, unknown>;
269 }
270
271 interface VoiceOutput {
272 durationS: number;
273 audioBytes: number;
274 chapters: { title: string; startS: number; endS: number; lineStarts: number[] }[];
275 introLineStarts: number[];
276 outroLineStarts: number[];
277 }
278
279 export type StartResult = { status: "started"; episodeId: string } | { status: "skipped"; message: string };
280 export type PrepareResult = { status: "ok" } | { status: "skipped"; message: string };
281
282 const nowOf = (deps: EpisodeDeps) => deps.now?.() ?? new Date();
283
284 async function loadEpisode(deps: EpisodeDeps, episodeId: string) {
285 const [row] = await deps.db
286 .select({ episode: schema.episodes, user: schema.users })
287 .from(schema.episodes)
288 .innerJoin(schema.users, eq(schema.users.id, schema.episodes.userId))
289 .where(eq(schema.episodes.id, episodeId))
290 .limit(1);
291 if (!row) throw new Error(`No episode ${episodeId}`);
292 return row;
293 }
294
295 async function finishRequest(deps: EpisodeDeps, episodeId: string, status: "done" | "skipped" | "failed", message?: string) {
296 await deps.db
297 .update(schema.episodeRequests)
298 .set({ status, message: message ?? null, finishedAt: nowOf(deps) })
299 .where(eq(schema.episodeRequests.episodeId, episodeId));
300 }
301
302 /** Claim the request, check the plan allows an episode, and create the episode row. */
303 export async function startEpisode(deps: EpisodeDeps, requestId: string): Promise<StartResult> {
304 const { db } = deps;
305 const now = nowOf(deps);
306 const [request] = await db.select().from(schema.episodeRequests).where(eq(schema.episodeRequests.id, requestId)).limit(1);
307 if (!request) throw new Error(`No request ${requestId}`);
308 if (request.episodeId && request.status === "running") return { status: "started", episodeId: request.episodeId };
309 if (request.status === "done" || request.status === "skipped") {
310 return { status: "skipped", message: request.message ?? "Already handled" };
311 }
312
313 const ctx = await loadPlanContext({ db, userId: request.userId, now });
314 if (ctx.budget.blocked) {
315 await db
316 .update(schema.episodeRequests)
317 .set({ status: "skipped", message: ctx.budget.blocked, finishedAt: now })
318 .where(eq(schema.episodeRequests.id, requestId));
319 return { status: "skipped", message: ctx.budget.blocked };
320 }
321
322 const [episode] = await db
323 .insert(schema.episodes)
324 .values({
325 userId: request.userId,
326 date: request.date,
327 status: "planning",
328 currentStep: "collect",
329 voiceProvider: ctx.plan.voiceProvider,
330 })
331 .returning({ id: schema.episodes.id });
332 if (!episode) throw new Error("Could not create episode");
333 await db
334 .update(schema.episodeRequests)
335 .set({ status: "running", startedAt: now, episodeId: episode.id })
336 .where(eq(schema.episodeRequests.id, requestId));
337 return { status: "started", episodeId: episode.id };
338 }
339
340 /**
341 * Opt-in news before collecting (prd §4.3): "always" fetches every time;
342 * "fill" only when queued newsletters and links won't fill ~75% of the budget.
343 */
344 export async function maybeFetchNews(deps: EpisodeDeps, options: { userId: string; maxChars: number; now: Date }): Promise<number> {
345 const { db } = deps;
346 const apiKey = deps.config.env.SERPAPI_API_KEY;
347 const [user] = await db.select().from(schema.users).where(eq(schema.users.id, options.userId)).limit(1);
348 if (!user?.newsOptIn || !apiKey) return 0;
349 const topics = await db.select().from(schema.userTopics).where(eq(schema.userTopics.userId, user.id));
350 if (topics.length === 0) return 0;
351 if (user.newsMode === "fill") {
352 const queued = await db
353 .select({ length: sql<number>`coalesce(length(${schema.items.text}), 0)::int` })
354 .from(schema.items)
355 .where(and(eq(schema.items.userId, user.id), eq(schema.items.status, "queued"), sql`${schema.items.sourceKind} <> 'news'`));
356 const estimate = queued.reduce((sum, q) => sum + Math.max(MIN_CHAPTER_CHARS, Math.min(MAX_CHAPTER_CHARS, q.length * 0.28)), 0);
357 if (estimate >= options.maxChars * 0.75) return 0;
358 }
359 const ids = await fetchNewsItems({
360 db,
361 userId: user.id,
362 apiKey,
363 country: user.newsCountry ?? "US",
364 language: user.newsLanguage ?? "en",
365 topics: topics.map((t) => t.query),
366 now: options.now,
367 });
368 return ids.length;
369 }
370
371 /** Steps 1–6. Not enough new content → no episode, no cost (prd §6). */
372 export async function prepareScript(deps: EpisodeDeps, episodeId: string): Promise<PrepareResult> {
373 const { db, storage } = deps;
374 const now = nowOf(deps);
375 const { episode, user } = await loadEpisode(deps, episodeId);
376 const existing = await getJson<ScriptOutput>(storage, storageKeys.stepOutput(user.id, episodeId, "script"));
377 if (existing) return { status: "ok" };
378
379 const ctx = await loadPlanContext({ db, userId: user.id, now });
380 await maybeFetchNews(deps, { userId: user.id, maxChars: ctx.budget.maxChars, now });
381 const result = await buildScript({
382 newsMode: user.newsMode,
383 db,
384 model: deps.model(),
385 embedder: deps.embedder,
386 userId: user.id,
387 maxChars: ctx.budget.maxChars,
388 dateLabel: spokenDate(now, user.timezone),
389 now,
390 });
391 await recordSteps({ db, episodeId, timingsMs: result.stats.timingsMs });
392
393 if (result.status !== "ok" || !result.script) {
394 const message = "Not enough new content yet. Forward a couple of newsletters and we'll make the next one.";
395 await finishRequest(deps, episodeId, "skipped", message);
396 await db.delete(schema.episodes).where(eq(schema.episodes.id, episodeId));
397 return { status: "skipped", message };
398 }
399
400 const key = storageKeys.stepOutput(user.id, episodeId, "script");
401 await putJson(storage, key, { script: result.script, stats: { ...result.stats, check: result.check } } satisfies ScriptOutput);
402 await db
403 .update(schema.episodes)
404 .set({
405 status: "voicing",
406 currentStep: "voice",
407 title: result.script.title,
408 characters: scriptCharacters(result.script),
409 voiceProvider: episode.voiceProvider ?? ctx.plan.voiceProvider,
410 })
411 .where(eq(schema.episodes.id, episodeId));
412 return { status: "ok" };
413 }
414
415 async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
416 const results: R[] = new Array(items.length);
417 let next = 0;
418 await Promise.all(
419 Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
420 while (next < items.length) {
421 const index = next++;
422 results[index] = await fn(items[index] as T, index);
423 }
424 }),
425 );
426 return results;
427 }
428
429 async function loadSfx(deps: EpisodeDeps) {
430 const rows = await deps.db
431 .select()
432 .from(schema.audioAssets)
433 .where(inArray(schema.audioAssets.key, ["sfx-intro", "sfx-transition", "sfx-outro"]));
434 const decode = async (key: string) => {
435 const row = rows.find((r) => r.key === key);
436 const bytes = row ? await deps.storage.get(row.storageKey) : null;
437 return bytes ? decodeToPcm(deps.config.env.FFMPEG_PATH, bytes) : undefined;
438 };
439 return { intro: await decode("sfx-intro"), transition: await decode("sfx-transition"), outro: await decode("sfx-outro") };
440 }
441
442 /** Step 7: voice every line (cached), add SFX, normalise, upload the MP3. */
443 export async function voiceEpisode(deps: EpisodeDeps, episodeId: string, heartbeat?: (detail: unknown) => void): Promise<void> {
444 const { db, storage } = deps;
445 const { episode, user } = await loadEpisode(deps, episodeId);
446 const voiceKey = storageKeys.stepOutput(user.id, episodeId, "voice");
447 if (await getJson<VoiceOutput>(storage, voiceKey)) return;
448 const output = await getJson<ScriptOutput>(storage, storageKeys.stepOutput(user.id, episodeId, "script"));
449 if (!output) throw new Error("Script missing; run prepareScript first");
450 const { script } = output;
451
452 await trackStep({
453 db,
454 episodeId,
455 step: "voice",
456 outputRef: voiceKey,
457 run: async () => {
458 const providerId = episode.voiceProvider ?? "kokoro";
459 // Studio quality is a Plus option; trials use Flash (prd §8.3 caps trial cost).
460 const ctx = await loadPlanContext({ db, userId: user.id, now: nowOf(deps) });
461 const quality = providerId === "elevenlabs" && ctx.plan.source === "paid" ? user.voiceQuality : "flash";
462 const provider = withVoiceCache(deps.voiceFor(providerId, { quality }), storage);
463 const voiceId = providerId === "kokoro" ? user.kokoroVoiceId : (user.elevenlabsVoiceId ?? DEFAULT_ELEVENLABS_VOICE);
464
465 const texts = [
466 ...script.intro.map((l) => l.text),
467 ...script.chapters.flatMap((c) => [`${c.title}.`, ...c.lines.map((l) => l.text)]),
468 ...script.outro.map((l) => l.text),
469 ];
470 let done = 0;
471 const clips = await mapLimit(texts, provider.concurrency, async (text, i): Promise<VoiceClip> => {
472 const clip = await provider.synthesize({ text, voiceId, previousText: texts[i - 1], nextText: texts[i + 1] });
473 heartbeat?.({ done: ++done, total: texts.length });
474 return clip;
475 });
476
477 let cursor = 0;
478 const take = (n: number) => clips.slice(cursor, (cursor += n)).map((c) => c.samples);
479 const intro = take(script.intro.length);
480 const chapters = script.chapters.map((c) => ({ title: c.title, clips: take(c.lines.length + 1) }));
481 const outro = take(script.outro.length);
482
483 const assembled = await assembleEpisode({
484 ffmpegPath: deps.config.env.FFMPEG_PATH,
485 intro,
486 chapters,
487 outro,
488 sfx: await loadSfx(deps),
489 title: script.title,
490 });
491 const audioKey = storageKeys.episodeAudio(user.id, episodeId);
492 await storage.put(audioKey, assembled.mp3, "audio/mpeg");
493 await putJson(storage, voiceKey, {
494 durationS: assembled.durationS,
495 audioBytes: assembled.mp3.byteLength,
496 // Line starts include the spoken chapter title as line 0; drop it for the transcript.
497 chapters: assembled.chapters.map((c) => ({ ...c, lineStarts: c.lineStarts.slice(1) })),
498 introLineStarts: assembled.introLineStarts,
499 outroLineStarts: assembled.outroLineStarts,
500 } satisfies VoiceOutput);
501 await db
502 .update(schema.episodes)
503 .set({
504 audioKey,
505 audioBytes: assembled.mp3.byteLength,
506 durationS: assembled.durationS,
507 voiceModel: provider.model,
508 costCents: Math.round(((episode.characters ?? 0) / 1000) * provider.centsPer1kChars),
509 })
510 .where(eq(schema.episodes.id, episodeId));
511 },
512 });
513 }
514
515 function coverDateParts(date: string): { day: string; caption: string } {
516 const d = new Date(`${date}T12:00:00Z`);
517 const month = d.toLocaleString("en-GB", { month: "short", timeZone: "UTC" }).toUpperCase();
518 const weekday = d.toLocaleString("en-GB", { weekday: "short", timeZone: "UTC" }).toUpperCase();
519 return { day: String(d.getUTCDate()), caption: `${month} · ${weekday}` };
520 }
521
522 /** Step 8: seeded cover at 3000 px (feed) and 1200 px (app). */
523 export async function renderEpisodeCover(deps: EpisodeDeps, episodeId: string): Promise<void> {
524 const { db, storage } = deps;
525 const { episode, user } = await loadEpisode(deps, episodeId);
526 if (episode.coverKey) return;
527 const output = await getJson<ScriptOutput>(storage, storageKeys.stepOutput(user.id, episodeId, "script"));
528 const sources = [
529 ...new Set(output?.script.chapters.flatMap((c) => c.sources.map((s) => s.label.replace(/^Your link.*/, "Your links"))) ?? []),
530 ];
531 await trackStep({
532 db,
533 episodeId,
534 step: "cover",
535 run: async () => {
536 const svg = coverSvg({ seed: `${user.id}:${episode.date}:${sources.slice(0, 3).join("|")}`, ...coverDateParts(episode.date), sources });
537 for (const size of [3000, 1200]) {
538 await storage.put(storageKeys.episodeCover(user.id, episodeId, size), renderCoverPng(svg, size), "image/png");
539 }
540 await db
541 .update(schema.episodes)
542 .set({ coverKey: storageKeys.episodeCover(user.id, episodeId, 3000) })
543 .where(eq(schema.episodes.id, episodeId));
544 },
545 });
546 }
547
548 /**
549 * Step 9: chapters + sources, heard items, mark items used, usage and trial
550 * counters, all in one transaction, then the episode is ready.
551 */
552 export async function publishEpisode(deps: EpisodeDeps, episodeId: string): Promise<void> {
553 const { db, storage } = deps;
554 const now = nowOf(deps);
555 const { episode, user } = await loadEpisode(deps, episodeId);
556 if (episode.status === "ready") return;
557 const scriptOut = await getJson<ScriptOutput>(storage, storageKeys.stepOutput(user.id, episodeId, "script"));
558 const voiceOut = await getJson<VoiceOutput>(storage, storageKeys.stepOutput(user.id, episodeId, "voice"));
559 if (!scriptOut || !voiceOut) throw new Error("Script or audio missing");
560 const { script } = scriptOut;
561 const ctx = await loadPlanContext({ db, userId: user.id, now });
562
563 const transcript: EpisodeTranscript = {
564 intro: script.intro.map((l, i) => ({ text: l.text, sourceIds: [], startS: voiceOut.introLineStarts[i] ?? 0 })),
565 chapters: script.chapters.map((c, ci) => {
566 const timing = voiceOut.chapters[ci];
567 return {
568 title: c.title,
569 origin: c.origin,
570 startS: timing?.startS ?? 0,
571 endS: timing?.endS ?? 0,
572 sources: c.sources,
573 lines: c.lines.map((l, li) => ({ text: l.text, sourceIds: l.sourceIds, startS: timing?.lineStarts[li] ?? timing?.startS ?? 0 })),
574 };
575 }),
576 outro: script.outro.map((l, i) => ({ text: l.text, sourceIds: [], startS: voiceOut.outroLineStarts[i] ?? 0 })),
577 };
578
579 await trackStep({
580 db,
581 episodeId,
582 step: "publish",
583 run: () =>
584 db.transaction(async (tx) => {
585 for (const [position, chapter] of transcript.chapters.entries()) {
586 const [row] = await tx
587 .insert(schema.chapters)
588 .values({
589 episodeId,
590 position,
591 title: chapter.title,
592 startS: chapter.startS,
593 endS: chapter.endS,
594 summary: chapter.lines[0]?.text ?? null,
595 script: chapter.lines,
596 })
597 .onConflictDoNothing()
598 .returning({ id: schema.chapters.id });
599 if (!row) continue;
600 await tx
601 .insert(schema.chapterSources)
602 .values(chapter.sources.map((s) => ({ chapterId: row.id, itemId: s.itemId, role: s.role })))
603 .onConflictDoNothing();
604 await tx
605 .insert(schema.heardItems)
606 .values(chapter.sources.map((s) => ({ userId: user.id, itemId: s.itemId, clusterKey: script.chapters[position]?.clusterKey ?? s.itemId, episodeId })))
607 .onConflictDoNothing();
608 }
609 const usedIds = transcript.chapters.flatMap((c) => c.sources.map((s) => s.itemId));
610 if (usedIds.length) await tx.update(schema.items).set({ status: "used" }).where(inArray(schema.items.id, usedIds));
611
612 const elevenChars = episode.voiceProvider === "elevenlabs" ? (episode.characters ?? 0) : 0;
613 await tx
614 .insert(schema.usageMonthly)
615 .values({ userId: user.id, month: ctx.usage.month, charactersUsed: elevenChars, episodesMade: 1 })
616 .onConflictDoUpdate({
617 target: [schema.usageMonthly.userId, schema.usageMonthly.month],
618 set: {
619 charactersUsed: sql`${schema.usageMonthly.charactersUsed} + ${elevenChars}`,
620 episodesMade: sql`${schema.usageMonthly.episodesMade} + 1`,
621 },
622 });
623
624 // Trial: count the Studio episode; the 3rd one ends the trial (prd §8.3).
625 if (ctx.plan.trial && episode.voiceProvider === "elevenlabs") {
626 await tx
627 .update(schema.promoRedemptions)
628 .set({
629 episodesUsed: sql`${schema.promoRedemptions.episodesUsed} + 1`,
630 endedAt: sql`case when ${schema.promoRedemptions.episodesUsed} + 1 >= ${schema.promoRedemptions.episodeLimit} then now() else ${schema.promoRedemptions.endedAt} end`,
631 endedReason: sql`case when ${schema.promoRedemptions.episodesUsed} + 1 >= ${schema.promoRedemptions.episodeLimit} then 'episodes'::trial_ended_reason else ${schema.promoRedemptions.endedReason} end`,
632 })
633 .where(and(eq(schema.promoRedemptions.id, ctx.plan.trial.redemptionId), sql`${schema.promoRedemptions.endedAt} is null`));
634 }
635
636 await tx
637 .update(schema.episodes)
638 .set({ status: "ready", currentStep: null, transcript, durationS: voiceOut.durationS, audioBytes: voiceOut.audioBytes, error: null })
639 .where(eq(schema.episodes.id, episodeId));
640 await tx
641 .update(schema.episodeRequests)
642 .set({ status: "done", finishedAt: now })
643 .where(eq(schema.episodeRequests.episodeId, episodeId));
644 }),
645 });
646 }
647
648 /** Failure shown to the user (prd §12): "Today's episode couldn't be made: [reason]". Earlier episodes stay. */
649 export async function failEpisode(deps: EpisodeDeps, episodeId: string, error: unknown): Promise<void> {
650 const message = error instanceof Error ? error.message : String(error);
651 const friendly = /ElevenLabs|VoiceNotConfigured/i.test(message)
652 ? "the Studio voice isn't available right now"
653 : /GEMINI|OPENROUTER|Model|Gemini|openrouter/i.test(message)
654 ? "the writing model didn't respond"
655 : "something went wrong on our side";
656 await deps.db
657 .update(schema.episodes)
658 .set({ status: "failed", error: { message: friendly, detail: message.slice(0, 1000) } })
659 .where(eq(schema.episodes.id, episodeId));
660 await finishRequest(deps, episodeId, "failed", friendly);
661 }
662
663 assemble.ts
664 index.ts
665 import { spawn } from "node:child_process";
666 import { SAMPLE_RATE } from "../voice/voice-provider";
667
668 export interface AssemblyChapter {
669 title: string;
670 clips: Float32Array[];
671 }
672
673 export interface Sfx {
674 intro?: Float32Array;
675 transition?: Float32Array;
676 outro?: Float32Array;
677 }
678
679 export interface AssembledEpisode {
680 mp3: Uint8Array;
681 durationS: number;
682 /** Start/end of each chapter in seconds, from clip durations (prd §6 step 7). */
683 chapters: { title: string; startS: number; endS: number; lineStarts: number[] }[];
684 introLineStarts: number[];
685 outroLineStarts: number[];
686 }
687
688 const LINE_GAP_S = 0.28;
689 const CHAPTER_GAP_S = 0.7;
690
691 function silence(seconds: number): Float32Array {
692 return new Float32Array(Math.round(seconds * SAMPLE_RATE));
693 }
694
695 function concat(parts: Float32Array[]): Float32Array {
696 const total = parts.reduce((n, p) => n + p.length, 0);
697 const out = new Float32Array(total);
698 let offset = 0;
699 for (const part of parts) {
700 out.set(part, offset);
701 offset += part.length;
702 }
703 return out;
704 }
705
706 function runFfmpeg(ffmpegPath: string, args: string[], input: Uint8Array): Promise<Uint8Array> {
707 return new Promise((resolve, reject) => {
708 const proc = spawn(ffmpegPath, args, { stdio: ["pipe", "pipe", "pipe"] });
709 const out: Buffer[] = [];
710 let err = "";
711 proc.stdout.on("data", (chunk: Buffer) => out.push(chunk));
712 proc.stderr.on("data", (chunk: Buffer) => {
713 err = (err + chunk.toString()).slice(-4000);
714 });
715 proc.on("error", reject);
716 proc.on("close", (code) => (code === 0 ? resolve(new Uint8Array(Buffer.concat(out))) : reject(new Error(`ffmpeg exited ${code}: ${err}`))));
717 proc.stdin.end(input);
718 });
719 }
720
721 /** Decode any audio file (e.g. an SFX MP3) to 24 kHz mono float PCM. */
722 export async function decodeToPcm(ffmpegPath: string, file: Uint8Array): Promise<Float32Array> {
723 const pcm = await runFfmpeg(ffmpegPath, ["-hide_banner", "-loglevel", "error", "-i", "pipe:0", "-f", "f32le", "-ac", "1", "-ar", String(SAMPLE_RATE), "pipe:1"], file);
724 return new Float32Array(pcm.buffer, pcm.byteOffset, pcm.byteLength / 4);
725 }
726
727 /**
728 * Intro, chapters and outro with short pauses and optional SFX, normalised to
729 * −16 LUFS and encoded as MP3 by ffmpeg in one pass.
730 */
731 export async function assembleEpisode(options: {
732 ffmpegPath: string;
733 intro: Float32Array[];
734 chapters: AssemblyChapter[];
735 outro: Float32Array[];
736 sfx?: Sfx;
737 title: string;
738 }): Promise<AssembledEpisode> {
739 const { sfx = {} } = options;
740 const parts: Float32Array[] = [];
741 let samples = 0;
742 const push = (p: Float32Array) => {
743 parts.push(p);
744 samples += p.length;
745 };
746 const pushLines = (clips: Float32Array[]): number[] =>
747 clips.map((clip, i) => {
748 if (i > 0) push(silence(LINE_GAP_S));
749 const start = samples / SAMPLE_RATE;
750 push(clip);
751 return start;
752 });
753
754 if (sfx.intro) push(sfx.intro);
755 const introLineStarts = pushLines(options.intro);
756 const chapters: AssembledEpisode["chapters"] = [];
757 for (const chapter of options.chapters) {
758 push(silence(CHAPTER_GAP_S / 2));
759 if (sfx.transition) push(sfx.transition);
760 push(silence(CHAPTER_GAP_S / 2));
761 const start = samples / SAMPLE_RATE;
762 const lineStarts = pushLines(chapter.clips);
763 chapters.push({ title: chapter.title, startS: start, endS: samples / SAMPLE_RATE, lineStarts });
764 }
765 push(silence(CHAPTER_GAP_S));
766 const outroLineStarts = pushLines(options.outro);
767 if (sfx.outro) push(sfx.outro);
768
769 const pcm = concat(parts);
770 const mp3 = await runFfmpeg(
771 options.ffmpegPath,
772 [
773 "-hide_banner",
774 "-loglevel",
775 "error",
776 "-f",
777 "f32le",
778 "-ar",
779 String(SAMPLE_RATE),
780 "-ac",
781 "1",
782 "-i",
783 "pipe:0",
784 "-af",
785 "loudnorm=I=-16:TP=-1.5:LRA=11",
786 "-ar",
787 "44100",
788 "-ac",
789 "1",
790 "-c:a",
791 "libmp3lame",
792 "-b:a",
793 "96k",
794 "-metadata",
795 `title=${options.title}`,
796 "-metadata",
797 "artist=Unread",
798 "-f",
799 "mp3",
800 "pipe:1",
801 ],
802 new Uint8Array(pcm.buffer),
803 );
804 return { mp3, durationS: pcm.length / SAMPLE_RATE, chapters, introLineStarts, outroLineStarts };
805 }
806 export * from "./assemble";
807
$ cat "apps/web/src/app/audio/[token]/[file]/route.ts" apps/web/src/features/feed/serve-object.ts apps/web/src/lib/storage.ts && ls packages/core/src/storage && cat packages/core/src/storage/*.ts | head -200 && grep -rn "MAX_LINE\|maxLineChars\|max(.*chars\|\.max(" packages/core/src/pipeline/*.ts | head -20
import { PRIVATE_HEADERS, readyEpisode, userByFeedToken } from "@/features/feed/feed-data";
import { serveObject } from "@/features/feed/serve-object";
export const runtime = "nodejs";
/** Enclosure URL: /audio/:feedToken/:episodeId.mp3. Rotating the feed token revokes it (prd §10). */
export async function GET(request: Request, { params }: { params: Promise<{ token: string; file: string }> }) {
const { token, file } = await params;
const user = await userByFeedToken(token);
const episode = user && file.endsWith(".mp3") ? await readyEpisode(user.id, file.slice(0, -4)) : null;
if (!episode?.audioKey) return new Response("Not found", { status: 404, headers: PRIVATE_HEADERS });
return serveObject({ request, key: episode.audioKey, contentType: "audio/mpeg", maxAge: 86400 });
}
import "server-only";
import { storage } from "@/lib/storage";
import { PRIVATE_HEADERS } from "./feed-data";
/**
* Send a stored object: a short-lived signed URL when the store supports it
* (S3/R2 handle Range requests for podcast apps), otherwise stream it here
* with Range support (local storage in development).
*/
export async function serveObject(options: { request: Request; key: string; contentType: string; maxAge: number }) {
const { request, key, contentType } = options;
const signed = await storage().signedUrl(key, 6 * 3600);
if (signed) {
return new Response(null, { status: 302, headers: { ...PRIVATE_HEADERS, location: signed, "cache-control": "private, max-age=300" } });
}
const bytes = await storage().get(key);
if (!bytes) return new Response("Not found", { status: 404, headers: PRIVATE_HEADERS });
const headers: Record<string, string> = {
...PRIVATE_HEADERS,
"content-type": contentType,
"accept-ranges": "bytes",
"cache-control": `private, max-age=${options.maxAge}`,
};
const range = request.headers.get("range")?.match(/^bytes=(\d*)-(\d*)$/);
if (range) {
const size = bytes.byteLength;
const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2] || 0));
const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
if (start >= size || start > end) {
return new Response(null, { status: 416, headers: { ...headers, "content-range": `bytes */${size}` } });
}
const chunk = bytes.subarray(start, end + 1);
return new Response(chunk as Uint8Array<ArrayBuffer>, {
status: 206,
headers: { ...headers, "content-range": `bytes ${start}-${end}/${size}`, "content-length": String(chunk.byteLength) },
});
}
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers: { ...headers, "content-length": String(bytes.byteLength) } });
}
import "server-only";
import { type Storage, storageFromConfig } from "@unread/core/storage";
import { coreConfig } from "./env";
let cached: Storage | undefined;
export function storage(): Storage {
cached ??= storageFromConfig(coreConfig());
return cached;
}
index.ts
local-storage.ts
s3-storage.ts
storage.ts
import type { CoreConfig } from "../config";
import { createLocalStorage } from "./local-storage";
import { createS3Storage } from "./s3-storage";
import type { Storage } from "./storage";
export * from "./storage";
export { createLocalStorage } from "./local-storage";
export { createS3Storage } from "./s3-storage";
export function storageFromConfig(config: CoreConfig): Storage {
const s = config.storage;
if (s.driver === "s3") return createS3Storage(s);
return createLocalStorage(s.dir);
}
/** Storage keys, in one place so deletion by user prefix stays correct. */
export const storageKeys = {
userPrefix: (userId: string) => `users/${userId}/`,
rawEmail: (userId: string, inboundId: string) => `users/${userId}/raw/${inboundId}.eml`,
episodeAudio: (userId: string, episodeId: string) => `users/${userId}/episodes/${episodeId}/audio.mp3`,
episodeCover: (userId: string, episodeId: string, size: number) =>
`users/${userId}/episodes/${episodeId}/cover-${size}.png`,
stepOutput: (userId: string, episodeId: string, step: string) =>
`users/${userId}/episodes/${episodeId}/steps/${step}.json`,
voiceCache: (hash: string) => `cache/voice/${hash.slice(0, 2)}/${hash}.wav`,
asset: (name: string) => `assets/${name}`,
};
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve, sep } from "node:path";
import type { Storage } from "./storage";
/** Filesystem storage for local development and tests. */
export function createLocalStorage(rootDir: string): Storage {
const root = resolve(rootDir);
const pathFor = (key: string) => {
const full = resolve(join(root, key));
if (full !== root && !full.startsWith(root + sep)) throw new Error(`Key escapes storage root: ${key}`);
return full;
};
return {
async put(key, body) {
const path = pathFor(key);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, body);
},
async get(key) {
try {
return new Uint8Array(await readFile(pathFor(key)));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
},
async delete(key) {
await rm(pathFor(key), { force: true });
},
async deletePrefix(prefix) {
await rm(pathFor(prefix), { recursive: true, force: true });
return 0;
},
async signedUrl() {
return null;
},
};
}
import {
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
ListObjectsV2Command,
NoSuchKey,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { Storage } from "./storage";
export interface S3StorageOptions {
bucket: string;
region: string;
/** Set for S3-compatible stores such as Cloudflare R2. */
endpoint?: string;
accessKeyId: string;
secretAccessKey: string;
}
export function createS3Storage(options: S3StorageOptions): Storage {
const client = new S3Client({
region: options.region,
endpoint: options.endpoint,
forcePathStyle: Boolean(options.endpoint),
credentials: { accessKeyId: options.accessKeyId, secretAccessKey: options.secretAccessKey },
});
const Bucket = options.bucket;
return {
async put(key, body, contentType) {
await client.send(new PutObjectCommand({ Bucket, Key: key, Body: body, ContentType: contentType }));
},
async get(key) {
try {
const result = await client.send(new GetObjectCommand({ Bucket, Key: key }));
return result.Body ? await result.Body.transformToByteArray() : null;
} catch (error) {
if (error instanceof NoSuchKey) return null;
throw error;
}
},
async delete(key) {
await client.send(new DeleteObjectCommand({ Bucket, Key: key }));
},
async deletePrefix(prefix) {
let deleted = 0;
let token: string | undefined;
do {
const page = await client.send(
new ListObjectsV2Command({ Bucket, Prefix: prefix, ContinuationToken: token }),
);
const keys = (page.Contents ?? []).flatMap((o) => (o.Key ? [{ Key: o.Key }] : []));
if (keys.length > 0) {
await client.send(new DeleteObjectsCommand({ Bucket, Delete: { Objects: keys } }));
deleted += keys.length;
}
token = page.IsTruncated ? page.NextContinuationToken : undefined;
} while (token);
return deleted;
},
async signedUrl(key, expiresInSeconds) {
return getSignedUrl(client, new GetObjectCommand({ Bucket, Key: key }), { expiresIn: expiresInSeconds });
},
};
}
/** Object storage for raw emails, audio, covers and step outputs. */
export interface Storage {
put(key: string, body: Uint8Array | string, contentType: string): Promise<void>;
get(key: string): Promise<Uint8Array | null>;
delete(key: string): Promise<void>;
deletePrefix(prefix: string): Promise<number>;
/** Time-limited URL a podcast app or browser can fetch directly, or null if the driver can't sign. */
signedUrl(key: string, expiresInSeconds: number): Promise<string | null>;
}
export async function getJson<T>(storage: Storage, key: string): Promise<T | null> {
const bytes = await storage.get(key);
return bytes ? (JSON.parse(new TextDecoder().decode(bytes)) as T) : null;
}
export async function putJson(storage: Storage, key: string, value: unknown): Promise<void> {
await storage.put(key, JSON.stringify(value), "application/json");
}
packages/core/src/pipeline/plan.ts:22: return Math.max(MIN_CHAPTER_CHARS, Math.min(MAX_CHAPTER_CHARS, base));
packages/core/src/pipeline/plan.ts:31: const skip = Math.max(...c.items.map((i) => skipRates.get(i.sourceLabel) ?? 0));
packages/core/src/pipeline/script.ts:45: prompt: `Sources:\n${text}\n\nWrite this chapter in about ${targetChars} characters of spoken text (roughly ${Math.max(
packages/core/src/pipeline/types.ts:41: text: z.string().trim().min(1).max(400),
packages/core/src/pipeline/types.ts:46: title: z.string().trim().min(3).max(120),
packages/core/src/pipeline/types.ts:47: lines: z.array(scriptLineSchema).min(1).max(40),
Audio core is clear. Now the player UI and main screens.
$ cd /c/Users/HP/hacks/unread/apps/web/src && cat features/player/progress-bar.tsx features/player/now-playing.tsx features/player/player-bar.tsx features/player/mini-player.tsx features/player/transcript-view.tsx features/player/controls.tsx "app/(app)/today/page.tsx" features/today/auto-refresh.tsx features/today/make-now-button.tsx features/today/in-progress-card.tsx features/today/episode-card.tsx features/today/failure-banner.tsx features/today/chapter-list.tsx
"use client";
import { cn } from "@/lib/utils";
import { clock, remaining } from "./format";
import { usePlayer } from "./player-context";
/** Seekable progress with chapter marks (canvas Now playing). A native range input keeps it accessible. */
export function ProgressBar({ className, showTimes = true }: { className?: string; showTimes?: boolean }) {
const { episode, currentTime, duration, seek } = usePlayer();
if (!episode) return null;
const pct = duration ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="relative h-6">
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-line">
<div className="h-full rounded-full bg-brand" style={{ width: `${pct}%` }} />
{episode.transcript.chapters.slice(1).map((c) => (
<span
key={c.startS}
aria-hidden="true"
className="absolute -top-[3px] h-3 w-0.5 bg-ground"
style={{ left: `${duration ? (c.startS / duration) * 100 : 0}%` }}
/>
))}
</div>
<input
type="range"
min={0}
max={Math.max(1, Math.round(duration))}
step={1}
value={Math.round(currentTime)}
onChange={(e) => seek(Number(e.target.value))}
aria-label="Seek"
aria-valuetext={`${clock(currentTime)} of ${clock(duration)}`}
className="absolute inset-0 h-6 w-full cursor-pointer opacity-0"
/>
</div>
{showTimes ? (
<div className="flex justify-between font-mono text-xs text-muted-foreground">
<span>{clock(currentTime)}</span>
<span>{remaining(currentTime, duration)}</span>
</div>
) : null}
</div>
);
}
"use client";
import { useEffect } from "react";
import { ProgressBar } from "./progress-bar";
import { TransportControls } from "./controls";
import { ChevronDownIcon } from "./icons";
import { usePlayer } from "./player-context";
import { TranscriptView } from "./transcript-view";
/** Full-screen Now playing below 1024 px (canvas screen 3). */
export function NowPlaying() {
const { episode, expanded, setExpanded, chapterIndex } = usePlayer();
useEffect(() => {
if (!expanded) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [expanded]);
if (!episode || !expanded) return null;
const chapters = episode.transcript.chapters;
const index = chapterIndex ?? 0;
const chapter = chapters[index];
return (
<div role="dialog" aria-modal="true" aria-label="Now playing" className="fixed inset-0 z-40 flex flex-col bg-ground lg:hidden">
<header className="flex items-center justify-between px-3 pt-3">
<button type="button" onClick={() => setExpanded(false)} aria-label="Close player" className="flex size-11 cursor-pointer items-center justify-center text-foreground">
<ChevronDownIcon className="size-[22px]" />
</button>
<span className="font-mono text-xs font-medium text-muted-foreground">
{chapterIndex === null ? "INTRO" : `CHAPTER ${index + 1} OF ${chapters.length}`}
</span>
<span className="size-11" aria-hidden="true" />
</header>
<section aria-label="Current chapter" className="flex flex-col gap-1.5 px-6 pt-3">
<span className="text-xs text-muted-foreground">{chapter?.origin ?? "Unread"}</span>
<h1 className="m-0 text-2xl leading-tight font-bold tracking-[-0.02em]">{chapter?.title ?? episode.title}</h1>
</section>
<section aria-label="Transcript" className="flex-1 overflow-y-auto px-6 pt-[18px] pb-4">
{chapter ? <TranscriptView chapterIndex={index} /> : <p className="text-[17px] text-text-2">{episode.transcript.intro.map((l) => l.text).join(" ")}</p>}
</section>
<section aria-label="Player controls" className="flex flex-col gap-4 border-t border-surface-2 bg-ground px-6 pt-4 pb-[max(env(safe-area-inset-bottom),28px)]">
<ProgressBar />
<TransportControls />
</section>
</div>
);
}
"use client";
import { Back15Icon, Forward15Icon } from "./icons";
import { TransportControls } from "./controls";
import { usePlayer } from "./player-context";
import { ProgressBar } from "./progress-bar";
/** Persistent bottom player at 1024 px and up (prd §9). */
export function PlayerBar() {
const { episode, chapterIndex, skipBy, focusChapter } = usePlayer();
if (!episode) return null;
const chapter = chapterIndex !== null ? episode.transcript.chapters[chapterIndex] : null;
return (
<div className="fixed inset-x-0 bottom-0 z-30 hidden border-t border-line bg-surface/95 backdrop-blur lg:block lg:pl-60">
<div className="mx-auto grid max-w-[1200px] grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-6 px-10 py-3">
<button
type="button"
onClick={() => focusChapter(chapterIndex)}
className="flex min-w-0 cursor-pointer items-center gap-3 text-left"
aria-label="Show transcript for this chapter"
>
{/* eslint-disable-next-line @next/next/no-img-element -- private, token-scoped cover */}
<img src={episode.coverUrl} alt="" className="size-12 flex-none rounded-lg object-cover" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold">{chapter?.title ?? episode.title}</span>
<span className="truncate text-xs text-muted-foreground">{chapter?.origin ?? "Unread"}</span>
</span>
</button>
<div className="flex items-center gap-1">
<button type="button" onClick={() => skipBy(-15)} aria-label="Back 15 seconds" className="flex size-11 cursor-pointer items-center justify-center text-text-2">
<Back15Icon className="size-6" />
</button>
<TransportControls compact />
<button type="button" onClick={() => skipBy(15)} aria-label="Forward 15 seconds" className="flex size-11 cursor-pointer items-center justify-center text-text-2">
<Forward15Icon className="size-6" />
</button>
</div>
<ProgressBar className="min-w-0" />
</div>
</div>
);
}
"use client";
import { PlayPauseButton } from "./controls";
import { usePlayer } from "./player-context";
/** Compact bar above the tab bar (phone/tablet); tap to open Now playing. */
export function MiniPlayer() {
const { episode, chapterIndex, setExpanded, currentTime, duration } = usePlayer();
if (!episode) return null;
const chapter = chapterIndex !== null ? episode.transcript.chapters[chapterIndex] : null;
const pct = duration ? (currentTime / duration) * 100 : 0;
return (
<div className="fixed inset-x-0 bottom-[calc(max(env(safe-area-inset-bottom),22px)+62px)] z-20 px-3 lg:hidden">
<div className="relative mx-auto flex max-w-[640px] items-center gap-3 overflow-hidden rounded-2xl border border-line bg-surface p-2 pr-3 shadow-lg">
<button type="button" onClick={() => setExpanded(true)} className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left" aria-label="Open player">
{/* eslint-disable-next-line @next/next/no-img-element -- private, token-scoped cover */}
<img src={episode.coverUrl} alt="" className="size-11 flex-none rounded-lg object-cover" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{chapter?.title ?? episode.title}</span>
<span className="truncate text-xs text-muted-foreground">{chapter?.origin ?? "Unread"}</span>
</span>
</button>
<PlayPauseButton size="sm" />
<span aria-hidden="true" className="absolute inset-x-0 bottom-0 h-0.5 bg-line">
<span className="block h-full bg-brand" style={{ width: `${pct}%` }} />
</span>
</div>
</div>
);
}
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { LinkIcon } from "./icons";
import { usePlayer } from "./player-context";
/** Transcript of one chapter, highlighting the line being spoken, plus every source it cites. */
export function TranscriptView({ chapterIndex, className }: { chapterIndex: number; className?: string }) {
const { episode, currentTime, chapterIndex: playingIndex, seek } = usePlayer();
const activeRef = useRef<HTMLButtonElement | null>(null);
const chapter = episode?.transcript.chapters[chapterIndex];
const isPlayingChapter = playingIndex === chapterIndex;
const activeLine = isPlayingChapter && chapter ? chapter.lines.findLastIndex((l) => currentTime >= l.startS - 0.05) : -1;
useEffect(() => {
activeRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [activeLine]);
if (!chapter) return null;
return (
<div className={cn("flex flex-col gap-3.5", className)}>
{chapter.lines.map((line, i) => (
<button
type="button"
key={`${i}-${line.startS}`}
ref={i === activeLine ? activeRef : undefined}
onClick={() => seek(line.startS)}
aria-current={i === activeLine ? "true" : undefined}
className={cn(
"-mx-2.5 cursor-pointer rounded-[10px] px-2.5 py-2 text-left text-[17px] leading-normal",
i === activeLine ? "bg-brand/15 text-foreground" : "text-dim hover:text-text-2",
)}
>
{line.text}
</button>
))}
<ul className="m-0 flex list-none flex-col gap-2 p-0" aria-label="Sources">
{chapter.sources.map((source) => (
<li key={source.itemId} className="flex items-center gap-3 rounded-[14px] border border-line bg-surface px-3.5 py-3">
<LinkIcon className="size-[18px] flex-none text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{source.role === "primary" ? "Source" : "Also covered by"}</span>
<span className="truncate text-sm font-medium">
{source.label} · {source.title}
</span>
</div>
{source.url ? (
<a href={source.url} target="_blank" rel="noopener noreferrer" className="py-3 pl-2 text-sm no-underline">
Open
</a>
) : null}
</li>
))}
</ul>
</div>
);
}
"use client";
import { cn } from "@/lib/utils";
import { NextIcon, PauseIcon, PlayIcon, PrevIcon } from "./icons";
import { usePlayer } from "./player-context";
export function PlayPauseButton({ size = "lg", className }: { size?: "sm" | "md" | "lg"; className?: string }) {
const { playing, toggle } = usePlayer();
const dims = size === "lg" ? "size-[68px]" : size === "md" ? "size-14" : "size-11";
const icon = size === "sm" ? "size-5" : "size-6";
return (
<button
type="button"
onClick={toggle}
aria-label={playing ? "Pause" : "Play"}
className={cn("flex flex-none cursor-pointer items-center justify-center rounded-full bg-brand text-on-brand", dims, className)}
>
{playing ? <PauseIcon className={icon} /> : <PlayIcon className={icon} />}
</button>
);
}
/** Speed · previous · play · next · skip story (canvas Now playing controls). */
export function TransportControls({ compact = false }: { compact?: boolean }) {
const { rate, cycleRate, previousChapter, nextChapter, skipStory } = usePlayer();
return (
<div className={cn("flex items-center", compact ? "gap-2" : "justify-between")}>
<button
type="button"
onClick={cycleRate}
aria-label={`Playback speed ${rate}×`}
className="h-11 w-[52px] cursor-pointer font-mono text-sm font-medium text-text-2"
>
{rate}×
</button>
<button type="button" onClick={previousChapter} aria-label="Previous chapter" className="flex size-12 cursor-pointer items-center justify-center text-foreground">
<PrevIcon className="size-6" />
</button>
<PlayPauseButton size={compact ? "md" : "lg"} />
<button type="button" onClick={nextChapter} aria-label="Next chapter" className="flex size-12 cursor-pointer items-center justify-center text-foreground">
<NextIcon className="size-6" />
</button>
<button type="button" onClick={skipStory} aria-label="Skip this story" className="h-11 w-[52px] cursor-pointer text-[13px] font-medium text-text-2">
Skip
</button>
</div>
);
}
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { loadHome } from "@/features/episodes/queries";
import { planContextFor } from "@/features/settings/plan-context";
import { PageContainer } from "@/features/shell/page-container";
import { PageHeading } from "@/features/shell/page-heading";
import { AutoRefresh } from "@/features/today/auto-refresh";
import { ChapterList } from "@/features/today/chapter-list";
import { ChapterListEmpty } from "@/features/today/chapter-list-empty";
import { DetailPanelEmpty } from "@/features/today/detail-panel-empty";
import { EmptyEpisodeCard } from "@/features/today/empty-episode-card";
import { EpisodeCard } from "@/features/today/episode-card";
import { FailureBanner } from "@/features/today/failure-banner";
import { episodeDateParts } from "@/features/today/format-date";
import { InProgressCard } from "@/features/today/in-progress-card";
import { MakeNowButton } from "@/features/today/make-now-button";
import { deliveryScheduleText } from "@/features/today/schedule";
import { TranscriptPanel } from "@/features/today/transcript-panel";
import { requireUser } from "@/features/users/current-user";
import { forwardingAddress } from "@/features/users/forwarding-address";
export const metadata: Metadata = { title: "Today" };
export default async function TodayPage() {
const { user } = await requireUser();
if (!user.onboardedAt) redirect("/onboarding");
const now = new Date();
const date = episodeDateParts(now, user.timezone);
const [home, ctx] = await Promise.all([loadHome(user.id, user.feedToken), planContextFor(user.id)]);
const seed = `${user.id}:${date.isoDate}`;
const latest = home.latest;
const trialNote = ctx.plan.trial
? `Trial: ${ctx.plan.trial.episodesLeft} of ${ctx.plan.trial.episodeLimit} Studio episodes left`
: null;
const madeAt = latest
? new Intl.DateTimeFormat("en-GB", { timeZone: user.timezone, hour: "numeric", minute: "2-digit" }).format(new Date(latest.createdAt))
: "";
const sourceCount = latest ? new Set(latest.transcript.chapters.flatMap((c) => c.sources.map((s) => s.itemId))).size : 0;
return (
<PageContainer>
{home.inProgress ? <AutoRefresh /> : null}
<PageHeading eyebrow={date.eyebrow} title="Your morning brief" />
<div className="mt-5 flex flex-col gap-5 lg:grid lg:grid-cols-[520px_minmax(0,1fr)] lg:items-start lg:gap-10">
<div className="flex flex-col gap-5">
{home.failure ? <FailureBanner message={home.failure} /> : null}
{home.inProgress ? (
<InProgressCard seed={seed} date={date} currentStep={home.inProgress.currentStep} />
) : latest ? (
<EpisodeCard
episode={latest}
madeAt={madeAt}
sourceCount={sourceCount}
trialNote={trialNote}
cover={{ seed: `${user.id}:${latest.id}`, ...episodeDateParts(new Date(latest.createdAt), user.timezone) }}
/>
) : (
<>
<EmptyEpisodeCard
seed={seed}
date={date}
schedule={deliveryScheduleText(user)}
forwardingAddress={forwardingAddress(user.inboundAlias)}
/>
{home.queuedItems >= 2 ? <MakeNowButton label="Make my first episode" /> : null}
</>
)}
{latest && !home.inProgress ? <ChapterList episode={latest} /> : <ChapterListEmpty />}
</div>
<div className="hidden lg:sticky lg:top-10 lg:block">
{latest && !home.inProgress ? <TranscriptPanel episode={latest} /> : <DetailPanelEmpty />}
</div>
</div>
</PageContainer>
);
}
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
/** Re-render the server page every few seconds while an episode is being made. */
export function AutoRefresh({ everyMs = 5000 }: { everyMs?: number }) {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), everyMs);
return () => clearInterval(id);
}, [router, everyMs]);
return null;
}
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { initialActionState } from "@/features/action-state";
import { makeEpisodeNowAction } from "@/features/settings/actions";
export function MakeNowButton({ label = "Make one now", variant = "default" }: { label?: string; variant?: "default" | "secondary" }) {
const [state, action, pending] = useActionState(makeEpisodeNowAction, initialActionState);
return (
<form action={action} className="flex flex-col gap-2">
<Button type="submit" size="lg" variant={variant} disabled={pending} className="self-start">
{pending ? "Starting…" : label}
</Button>
<p role="status" className="m-0 min-h-5 text-sm text-muted-foreground">
{state.error ?? state.message ?? ""}
</p>
</form>
);
}
import type { PipelineStep } from "@unread/db";
import { EpisodeCover } from "./episode-cover";
import type { EpisodeDateParts } from "./format-date";
import { PIPELINE_STEPS, type StepState } from "./pipeline-steps";
import { StepProgress } from "./step-progress";
/** prd §9: while the episode is being made, Home shows step progress. */
export function InProgressCard({ seed, date, currentStep }: { seed: string; date: EpisodeDateParts; currentStep: string | null }) {
const at = currentStep ? PIPELINE_STEPS.indexOf(currentStep as PipelineStep) : -1;
const states = Object.fromEntries(
PIPELINE_STEPS.map((step, i) => [step, (i < at ? "done" : i === at ? "active" : "pending") as StepState]),
);
return (
<section aria-label="Episode in progress" aria-busy="true" className="overflow-hidden rounded-[20px] border border-line bg-surface">
<EpisodeCover seed={seed} day={date.day} caption={date.caption} muted />
<div className="flex flex-col gap-2 px-[18px] pt-4 pb-[18px]">
<p className="m-0 text-[17px] font-semibold tracking-[-0.01em]">Making your episode…</p>
<p className="m-0 text-sm text-muted-foreground">This takes a few minutes. You can leave this page.</p>
<StepProgress states={states} className="pt-2" />
</div>
</section>
);
}
"use client";
import type { PlayerEpisode } from "@/features/episodes/queries";
import { clock, minutesLabel, remaining } from "@/features/player/format";
import { PauseIcon, PlayIcon } from "@/features/player/icons";
import { usePlayer } from "@/features/player/player-context";
import { EpisodeCover } from "./episode-cover";
export interface EpisodeCardProps {
episode: PlayerEpisode;
/** "made at 6:00" in the user's timezone. */
madeAt: string;
sourceCount: number;
trialNote: string | null;
cover: { seed: string; day: string; caption: string };
}
function storiesLine(title: string): string {
const afterDate = title.split(": ").slice(1).join(": ");
return afterDate ? afterDate.charAt(0).toUpperCase() + afterDate.slice(1) : title;
}
/** Home's episode card (canvas screen 2): cover, what's in it, big play button, progress. */
export function EpisodeCard({ episode, madeAt, sourceCount, trialNote, cover }: EpisodeCardProps) {
const player = usePlayer();
const isCurrent = player.episode?.id === episode.id;
const playing = isCurrent && player.playing;
const time = isCurrent ? player.currentTime : 0;
const duration = isCurrent ? player.duration : episode.durationS;
const pct = duration ? (time / duration) * 100 : 0;
const onPlay = () => {
if (isCurrent) player.toggle();
else player.load(episode, { autoplay: true });
};
return (
<section aria-label="Today's episode" className="overflow-hidden rounded-[20px] border border-line bg-surface">
<EpisodeCover seed={cover.seed} day={cover.day} caption={cover.caption} />
<div className="flex flex-col gap-3.5 px-[18px] pt-4 pb-[18px]">
<div className="flex flex-col gap-1">
<p className="m-0 text-[17px] font-semibold tracking-[-0.01em]">{storiesLine(episode.title)}</p>
<p className="m-0 font-mono text-[13px] text-muted-foreground">
{minutesLabel(episode.durationS)} · made at {madeAt} · {sourceCount} sources ·{" "}
{episode.voiceProvider === "elevenlabs" ? "Studio voice" : "Free voice"}
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onPlay}
aria-label={playing ? "Pause episode" : "Play today's episode"}
className="flex size-14 flex-none cursor-pointer items-center justify-center rounded-full bg-brand text-on-brand"
>
{playing ? <PauseIcon className="size-[22px]" /> : <PlayIcon className="size-[22px]" />}
</button>
<div className="flex flex-1 flex-col gap-1.5">
<div className="h-1 overflow-hidden rounded-sm bg-line">
<div className="h-1 bg-brand" style={{ width: `${pct}%` }} />
</div>
<div className="flex justify-between font-mono text-xs text-muted-foreground">
<span>{clock(time)}</span>
<span>{remaining(time, duration)}</span>
</div>
</div>
</div>
{trialNote ? <p className="m-0 text-[13px] text-text-2">{trialNote}</p> : null}
</div>
</section>
);
}
import { MakeNowButton } from "./make-now-button";
/** prd §12: "Today's episode couldn't be made: [reason]. Try again." Earlier episodes stay. */
export function FailureBanner({ message }: { message: string }) {
const notEnough = /not enough new content/i.test(message);
return (
<section role="alert" className="flex flex-col gap-3 rounded-[20px] border border-line bg-surface p-[18px]">
<p className="m-0 text-[15px] font-semibold">{notEnough ? "No new episode this time" : "Today's episode couldn't be made"}</p>
<p className="m-0 text-sm text-muted-foreground">{notEnough ? message : `Reason: ${message}.`}</p>
{notEnough ? null : <MakeNowButton label="Try again" variant="secondary" />}
</section>
);
}
"use client";
import type { PlayerEpisode } from "@/features/episodes/queries";
import { clock } from "@/features/player/format";
import { usePlayer } from "@/features/player/player-context";
import { cn } from "@/lib/utils";
/**
* "In this episode" (canvas screen 2). Phone: tap plays from that chapter and
* opens Now playing. Desktop: tap shows its transcript and sources on the right.
*/
export function ChapterList({ episode }: { episode: PlayerEpisode }) {
const player = usePlayer();
const isCurrent = player.episode?.id === episode.id;
const open = (index: number) => {
const chapter = episode.transcript.chapters[index];
if (!chapter) return;
player.focusChapter(index);
if (window.matchMedia("(min-width: 1024px)").matches) return;
player.load(episode, { autoplay: true, startAt: chapter.startS });
player.setExpanded(true);
};
return (
<section aria-labelledby="in-this-episode" className="flex flex-col gap-1">
<div className="flex items-baseline justify-between">
<h2 id="in-this-episode" className="m-0 text-[15px] font-semibold">
In this episode
</h2>
<button
type="button"
onClick={() => open(isCurrent ? (player.chapterIndex ?? 0) : 0)}
className="cursor-pointer text-[13px] text-link hover:text-link-hover lg:hidden"
>
Transcript
</button>
</div>
<ol className="m-0 list-none p-0">
{episode.transcript.chapters.map((chapter, index) => {
const playing = isCurrent && player.chapterIndex === index;
const focused = player.focusedChapter === index;
return (
<li key={`${index}-${chapter.startS}`}>
<button
type="button"
onClick={() => open(index)}
aria-current={playing ? "true" : undefined}
className={cn(
"flex w-full cursor-pointer items-start gap-3 border-b border-surface-2 py-3 text-left lg:rounded-lg lg:px-2",
focused && "lg:bg-surface",
)}
>
<span className="w-[34px] flex-none pt-[3px] font-mono text-xs font-medium text-muted-foreground">{clock(chapter.startS)}</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="text-[15px] leading-[1.3] font-medium">{chapter.title}</span>
<span className="text-xs text-muted-foreground">{chapter.origin}</span>
</span>
{playing ? <span aria-label="Playing" className="mt-[7px] size-2 flex-none rounded-full bg-brand" /> : null}
</button>
</li>
);
})}
</ol>
</section>
);
}
$ cat features/episodes/queries.ts features/shell/nav-items.ts app/api/listen/route.ts app/api/status/route.ts features/forwarding/live-status.tsx | head -330; grep -n "makeEpisodeNowAction" -A40 features/settings/actions.ts | head -60; cat features/episodes/start-episode.ts
import "server-only";
import { schema } from "@unread/db";
import type { EpisodeTranscript } from "@unread/core/episodes/transcript";
import { and, count, desc, eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db";
export interface PlayerEpisode {
id: string;
title: string;
audioUrl: string;
coverUrl: string;
durationS: number;
createdAt: string;
voiceProvider: "kokoro" | "elevenlabs" | null;
transcript: EpisodeTranscript;
}
export function toPlayerEpisode(
episode: typeof schema.episodes.$inferSelect,
feedToken: string,
): PlayerEpisode {
return {
id: episode.id,
title: episode.title ?? "Unread",
audioUrl: `/audio/${feedToken}/${episode.id}.mp3`,
coverUrl: `/feed/${feedToken}/cover/${episode.id}.png?size=1200`,
durationS: episode.durationS ?? 0,
createdAt: episode.createdAt.toISOString(),
voiceProvider: episode.voiceProvider,
transcript: episode.transcript as EpisodeTranscript,
};
}
export interface HomeData {
latest: PlayerEpisode | null;
inProgress: { status: string; currentStep: string | null } | null;
failure: string | null;
queuedItems: number;
}
export async function loadHome(userId: string, feedToken: string): Promise<HomeData> {
const [latest] = await db()
.select()
.from(schema.episodes)
.where(and(eq(schema.episodes.userId, userId), eq(schema.episodes.status, "ready")))
.orderBy(desc(schema.episodes.createdAt))
.limit(1);
const [request] = await db()
.select({ request: schema.episodeRequests, episode: schema.episodes })
.from(schema.episodeRequests)
.leftJoin(schema.episodes, eq(schema.episodes.id, schema.episodeRequests.episodeId))
.where(eq(schema.episodeRequests.userId, userId))
.orderBy(desc(schema.episodeRequests.createdAt))
.limit(1);
const [queued] = await db()
.select({ n: count() })
.from(schema.items)
.where(and(eq(schema.items.userId, userId), eq(schema.items.status, "queued")));
const newerThanLatest = request && (!latest || request.request.createdAt > latest.createdAt);
const active = request && (request.request.status === "pending" || request.request.status === "running");
return {
latest: latest ? toPlayerEpisode(latest, feedToken) : null,
inProgress: newerThanLatest && active ? { status: request.episode?.status ?? "queued", currentStep: request.episode?.currentStep ?? null } : null,
failure: newerThanLatest && request.request.status === "failed" ? (request.request.message ?? "something went wrong") : null,
queuedItems: queued?.n ?? 0,
};
}
export async function recentEpisodes(userId: string, feedToken: string, limit = 10): Promise<PlayerEpisode[]> {
const rows = await db()
.select()
.from(schema.episodes)
.where(and(eq(schema.episodes.userId, userId), inArray(schema.episodes.status, ["ready"])))
.orderBy(desc(schema.episodes.createdAt))
.limit(limit);
return rows.map((e) => toPlayerEpisode(e, feedToken));
}
import type { Route } from "next";
import type { ComponentType, SVGProps } from "react";
import { SettingsIcon, SourcesIcon, TodayIcon } from "./nav-icons";
export interface NavItem {
href: Route;
label: string;
Icon: ComponentType<SVGProps<SVGSVGElement>>;
}
export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/today", label: "Today", Icon: TodayIcon },
{ href: "/sources", label: "Sources", Icon: SourcesIcon },
{ href: "/settings", label: "Settings", Icon: SettingsIcon },
];
export function isActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
import { schema } from "@unread/db";
import { rateLimit } from "@unread/core/util/rate-limit";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { currentUser } from "@/features/users/current-user";
import { db } from "@/lib/db";
export const runtime = "nodejs";
const eventSchema = z.object({
episodeId: z.string().length(26),
chapterIndex: z.number().int().min(0).max(100).nullable(),
event: z.enum(["play", "skip", "complete", "seek"]),
positionS: z.number().min(0).max(24 * 3600),
});
/** Listening signals for ranking (prd §7: hypertable listening_events). */
export async function POST(request: Request) {
const current = await currentUser();
if (!current) return Response.json({ error: "Sign in" }, { status: 401 });
const parsed = eventSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Bad event" }, { status: 400 });
if (!(await rateLimit({ db: db(), key: `listen:${current.user.id}`, limit: 600, windowSeconds: 3600 }))) {
return new Response(null, { status: 429 });
}
const { episodeId, chapterIndex, event, positionS } = parsed.data;
const [episode] = await db()
.select({ id: schema.episodes.id })
.from(schema.episodes)
.where(and(eq(schema.episodes.id, episodeId), eq(schema.episodes.userId, current.user.id)));
if (!episode) return Response.json({ error: "Not found" }, { status: 404 });
const [chapter] =
chapterIndex === null
? []
: await db()
.select({ id: schema.chapters.id })
.from(schema.chapters)
.where(and(eq(schema.chapters.episodeId, episodeId), eq(schema.chapters.position, chapterIndex)));
await db()
.insert(schema.listeningEvents)
.values({ userId: current.user.id, episodeId, chapterId: chapter?.id ?? null, event, positionS, client: "web" });
return new Response(null, { status: 204 });
}
import { loadHome } from "@/features/episodes/queries";
import { inboundStatus } from "@/features/inbound/queries";
import { currentUser } from "@/features/users/current-user";
export const runtime = "nodejs";
/** Polled by onboarding (live forwarding status) and Home (episode progress). */
export async function GET() {
const current = await currentUser();
if (!current) return Response.json({ error: "Sign in" }, { status: 401 });
const [inbound, home] = await Promise.all([inboundStatus(current.user.id), loadHome(current.user.id, current.user.feedToken)]);
return Response.json(
{ inbound, episode: { inProgress: home.inProgress, failure: home.failure, latestId: home.latest?.id ?? null } },
{ headers: { "cache-control": "no-store" } },
);
}
"use client";
import { useEffect, useState } from "react";
import { CopyButton } from "@/components/ui/copy-button";
interface Status {
inbound: {
newsletters: { name: string; at: string }[];
confirmation: { code: string | null; url: string | null; at: string } | null;
lastRejection: { reason: string; at: string } | null;
};
}
const REJECTION_TEXT: Record<string, string> = {
unverified_sender: "We got mail from an address that isn't on your list. Forward from your sign-in address, or add the other address in Sources.",
auth_failed: "We got a message we couldn't verify (SPF/DKIM failed), so we dropped it.",
};
/** "Waiting for your first newsletter…" → "Got Morning Brew ✓", plus Gmail's code (prd §9). */
export function LiveStatus({ initial }: { initial: Status["inbound"] }) {
const [status, setStatus] = useState(initial);
useEffect(() => {
let stopped = false;
const tick = async () => {
try {
const res = await fetch("/api/status", { cache: "no-store" });
if (res.ok && !stopped) setStatus(((await res.json()) as Status).inbound);
} catch {
// offline: keep the last status
}
};
const id = setInterval(tick, 4000);
return () => {
stopped = true;
clearInterval(id);
};
}, []);
return (
<div className="flex flex-col gap-3">
{status.confirmation ? (
<div role="alert" className="flex flex-col gap-2 rounded-[14px] border border-brand bg-brand/10 p-4">
<p className="m-0 text-sm font-semibold">Gmail sent a confirmation code</p>
{status.confirmation.code ? (
<div className="flex items-center gap-3">
<span className="font-mono text-2xl font-medium tracking-[0.12em]">{status.confirmation.code}</span>
<CopyButton value={status.confirmation.code} />
</div>
) : null}
{status.confirmation.url ? (
<a href={status.confirmation.url} target="_blank" rel="noopener noreferrer" className="text-sm">
Or open Gmail's confirm link
</a>
) : null}
</div>
) : null}
<div aria-live="polite" className="flex items-center gap-3 rounded-[14px] border border-line bg-ground px-4 py-3">
{status.newsletters.length ? (
<>
<span aria-hidden="true" className="flex size-6 items-center justify-center rounded-full bg-brand text-on-brand">
✓
</span>
<span className="text-sm">Got {status.newsletters.map((n) => n.name).join(", ")}</span>
</>
) : (
<>
<span aria-hidden="true" className="size-2.5 animate-pulse rounded-full bg-brand" />
<span className="text-sm text-text-2">Waiting for your first newsletter…</span>
</>
)}
</div>
{status.lastRejection && REJECTION_TEXT[status.lastRejection.reason] ? (
<p className="m-0 text-[13px] text-muted-foreground">{REJECTION_TEXT[status.lastRejection.reason]}</p>
) : null}
</div>
);
}
91:export async function makeEpisodeNowAction(_prev: ActionState): Promise<ActionState> {
92- const { user } = await requireUser();
93- const { plan, budget } = await planContextFor(user.id);
94- if (budget.blocked) return { error: budget.blocked };
95- try {
96- const requestId = await requestEpisodeNow({
97- db: db(),
98- userId: user.id,
99- timezone: user.timezone,
100- now: new Date(),
101- dailyLimit: plan.plan === "plus" ? 3 : 1,
102- });
103- await startEpisodeWorkflow(requestId);
104- } catch (error) {
105- if (error instanceof RateLimitedError) return { error: error.message };
106- throw error;
107- }
108- revalidatePath("/today");
109- return { ok: true, message: "Making your episode. It takes a few minutes." };
110-}
111-
112-/** prd §9: removes every row, the audio and covers, then signs out. */
113-export async function deleteAccountAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
114- const { user } = await requireUser();
115- if (String(formData.get("confirm") ?? "").trim().toUpperCase() !== "DELETE") {
116- return { error: 'Type DELETE to confirm.' };
117- }
118- await storage().deletePrefix(storageKeys.userPrefix(user.id));
119- await db().delete(schema.listeningEvents).where(eq(schema.listeningEvents.userId, user.id));
120- await db().delete(schema.users).where(eq(schema.users.id, user.id));
121- redirect("/auth/logout" as Parameters<typeof redirect>[0]);
122-}
import "server-only";
import { Client, Connection } from "@temporalio/client";
import { serverEnv } from "@/lib/env";
let client: Promise<Client> | undefined;
/**
* Kick off the durable workflow for a request right away. Without Temporal
* (or if it's unreachable) the worker's poller/dispatcher picks the request up.
*/
export async function startEpisodeWorkflow(requestId: string): Promise<void> {
const env = serverEnv();
if (!env.TEMPORAL_ADDRESS) return;
try {
client ??= Connection.connect({
address: env.TEMPORAL_ADDRESS,
...(env.TEMPORAL_API_KEY ? { tls: true, apiKey: env.TEMPORAL_API_KEY, metadata: { "temporal-namespace": env.TEMPORAL_NAMESPACE } } : {}),
}).then((connection) => new Client({ connection, namespace: env.TEMPORAL_NAMESPACE }));
await (await client).workflow.start("makeEpisodeWorkflow", {
args: [requestId],
taskQueue: env.TEMPORAL_TASK_QUEUE,
workflowId: `episode-request-${requestId}`,
});
} catch (error) {
client = undefined;
console.error("Could not start workflow; the dispatcher will pick it up", error);
}
}
$ cd /c/Users/HP/hacks/unread && grep -rn "recentEpisodes" apps/web/src | grep -v "export async"; sed -n 1,100p packages/core/src/episodes/requests.ts; grep -rln "stale\|stuck" packages/core/src apps/worker/src | head
import { type Database, schema } from "@unread/db";
import { and, asc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
import { localClock, localDate } from "../util/dates";
/**
* Users whose delivery time has arrived (prd §6): runs start 30 minutes
* before delivery, in the user's timezone. Weekly users only on their day.
* Creates one scheduled request per user per local date (unique index).
*/
export async function dispatchDueRequests(options: { db: Database; now: Date; leadMinutes?: number }): Promise<string[]> {
const { db, now, leadMinutes = 30 } = options;
const users = await db
.select({
id: schema.users.id,
timezone: schema.users.timezone,
deliveryTime: schema.users.deliveryTime,
cadence: schema.users.cadence,
weeklyDay: schema.users.weeklyDay,
})
.from(schema.users)
.where(isNotNull(schema.users.onboardedAt));
const due: { userId: string; date: string }[] = [];
for (const user of users) {
let clock: { weekday: number; minutes: number };
try {
clock = localClock(now, user.timezone);
} catch {
continue;
}
const [h, m] = user.deliveryTime.split(":").map(Number);
const start = (h ?? 7) * 60 + (m ?? 0) - leadMinutes;
// Due from the start time until end of day, so a missed tick still catches up.
const inWindow = clock.minutes >= start;
const rightDay = user.cadence === "daily" || clock.weekday === user.weeklyDay;
if (inWindow && rightDay) due.push({ userId: user.id, date: localDate(now, user.timezone) });
}
if (due.length === 0) return [];
const created = await db
.insert(schema.episodeRequests)
.values(due.map((d) => ({ ...d, reason: "scheduled" as const })))
.onConflictDoNothing()
.returning({ id: schema.episodeRequests.id });
return created.map((c) => c.id);
}
export class RateLimitedError extends Error {}
/** "Make one now" (prd §6): per-plan daily limit; returns the request id. */
export async function requestEpisodeNow(options: {
db: Database;
userId: string;
timezone: string;
now: Date;
dailyLimit: number;
}): Promise<string> {
const { db, userId, now } = options;
const since = new Date(now.getTime() - 24 * 3_600_000);
const [recent] = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.episodeRequests)
.where(and(eq(schema.episodeRequests.userId, userId), eq(schema.episodeRequests.reason, "manual"), gte(schema.episodeRequests.createdAt, since)));
if ((recent?.count ?? 0) >= options.dailyLimit) {
throw new RateLimitedError(`You can make ${options.dailyLimit} extra episode${options.dailyLimit === 1 ? "" : "s"} a day`);
}
const [running] = await db
.select({ id: schema.episodeRequests.id })
.from(schema.episodeRequests)
.where(and(eq(schema.episodeRequests.userId, userId), inArray(schema.episodeRequests.status, ["pending", "running"])))
.limit(1);
if (running) return running.id;
const [row] = await db
.insert(schema.episodeRequests)
.values({ userId, date: localDate(now, options.timezone), reason: "manual" })
.returning({ id: schema.episodeRequests.id });
if (!row) throw new Error("Could not create request");
return row.id;
}
/** Poller (no Temporal): atomically claim the oldest pending request. */
export async function claimNextRequest(db: Database): Promise<string | null> {
const rows = await db.execute<{ id: string }>(sql`
update ${schema.episodeRequests} set status = 'running', started_at = now()
where id = (
select id from ${schema.episodeRequests} where status = 'pending'
order by created_at asc for update skip locked limit 1
)
returning id`);
return rows[0]?.id ?? null;
}
export async function pendingRequestIds(db: Database, limit = 20): Promise<string[]> {
const rows = await db
.select({ id: schema.episodeRequests.id })
.from(schema.episodeRequests)
.where(eq(schema.episodeRequests.status, "pending"))
.orderBy(asc(schema.episodeRequests.createdAt))
.limit(limit);
return rows.map((r) => r.id);
}
packages/core/src/billing/bachs.ts
packages/core/src/billing/webhook.ts
$ grep -rn "running" apps/worker/src packages/core/src/episodes packages/core/src/maintenance* 2>/dev/null | grep -v "^.*test" | head -20; ls apps/worker/src; grep -rn "retry\|maximumAttempts\|startToCloseTimeout\|heartbeatTimeout" apps/worker/src | head
apps/worker/src/cli/dispatch.ts:3:/** `pnpm dispatch`: one poller pass (for a Render cron job instead of a long-running worker). */
apps/worker/src/temporal/workflows.ts:53: // Already running or done for this request.
packages/core/src/episodes/requests.ts:66: const [running] = await db
packages/core/src/episodes/requests.ts:69: .where(and(eq(schema.episodeRequests.userId, userId), inArray(schema.episodeRequests.status, ["pending", "running"])))
packages/core/src/episodes/requests.ts:71: if (running) return running.id;
packages/core/src/episodes/requests.ts:83: update ${schema.episodeRequests} set status = 'running', started_at = now()
packages/core/src/episodes/step-tracker.ts:21: .values({ episodeId, step, attempt, status: "running", timings: { startedAt: startedAt.toISOString() } })
packages/core/src/episodes/steps.ts:59: if (request.episodeId && request.status === "running") return { status: "started", episodeId: request.episodeId };
packages/core/src/episodes/steps.ts:86: .set({ status: "running", startedAt: now, episodeId: episode.id })
cli
context.ts
deps.ts
main.ts
maintenance.ts
poller.ts
temporal
apps/worker/src/temporal/workflows.ts:5: startToCloseTimeout: "2 minutes",
apps/worker/src/temporal/workflows.ts:6: retry: { maximumAttempts: 5, initialInterval: "5 seconds", backoffCoefficient: 2 },
apps/worker/src/temporal/workflows.ts:9: startToCloseTimeout: "10 minutes",
apps/worker/src/temporal/workflows.ts:10: retry: { maximumAttempts: 3, initialInterval: "30 seconds", backoffCoefficient: 2 },
apps/worker/src/temporal/workflows.ts:13: startToCloseTimeout: "30 minutes",
apps/worker/src/temporal/workflows.ts:14: heartbeatTimeout: "2 minutes",
apps/worker/src/temporal/workflows.ts:15: retry: { maximumAttempts: 3, initialInterval: "30 seconds", backoffCoefficient: 2 },
$ cat apps/worker/src/poller.ts apps/worker/src/maintenance.ts | head -150; cat packages/core/src/episodes/make-episode.ts
import { claimNextRequest, dispatchDueRequests, makeEpisode } from "@unread/core/episodes";
import { workerContext } from "./context";
import { episodeDeps } from "./deps";
import { runMaintenance } from "./maintenance";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* No Temporal (cut line, prd §13): dispatch due users every minute and work
* through pending requests one at a time, with the same step functions.
*/
export async function runPoller(options: { once?: boolean } = {}): Promise<void> {
const { db } = workerContext();
let lastMaintenance = 0;
let stopping = false;
process.on("SIGTERM", () => {
stopping = true;
});
console.log("Polling for episode requests (no TEMPORAL_ADDRESS)");
while (!stopping) {
await dispatchDueRequests({ db, now: new Date() }).catch((e) => console.error("dispatch failed", e));
for (let requestId = await claimNextRequest(db); requestId && !stopping; requestId = await claimNextRequest(db)) {
const started = Date.now();
const result = await makeEpisode(episodeDeps(), requestId);
console.log(JSON.stringify({ event: "episode", requestId, ...result, ms: Date.now() - started }));
}
if (Date.now() - lastMaintenance > 3_600_000) {
lastMaintenance = Date.now();
await runMaintenance()
.then((r) => console.log(JSON.stringify({ event: "maintenance", ...r })))
.catch((e) => console.error("maintenance failed", e));
}
if (options.once) return;
await sleep(30_000);
}
}
import { purgeRawEmails } from "@unread/core/inbound";
import { workerContext } from "./context";
/** Hourly housekeeping: raw email retention (prd §10) and trial lifecycle (promo module). */
export async function runMaintenance(): Promise<Record<string, number>> {
const ctx = workerContext();
const purged = await purgeRawEmails({ db: ctx.db, storage: ctx.storage });
const { runTrialLifecycle } = await import("@unread/core/promo");
const trials = await runTrialLifecycle({ db: ctx.db, config: ctx.config, now: new Date() });
return { purged, ...trials };
}
import type { EpisodeDeps } from "./deps";
import { failEpisode, prepareScript, publishEpisode, renderEpisodeCover, startEpisode, voiceEpisode } from "./steps";
export type MakeEpisodeResult =
| { status: "ready"; episodeId: string }
| { status: "skipped"; message: string }
| { status: "failed"; episodeId: string; message: string };
/**
* The whole pipeline in one process: used by the poller and the CLI. The
* Temporal workflow calls the same steps as separate, retried activities.
*/
export async function makeEpisode(deps: EpisodeDeps, requestId: string): Promise<MakeEpisodeResult> {
const started = await startEpisode(deps, requestId);
if (started.status === "skipped") return started;
const { episodeId } = started;
try {
const prepared = await prepareScript(deps, episodeId);
if (prepared.status === "skipped") return prepared;
await voiceEpisode(deps, episodeId);
await renderEpisodeCover(deps, episodeId);
await publishEpisode(deps, episodeId);
return { status: "ready", episodeId };
} catch (error) {
await failEpisode(deps, episodeId, error);
return { status: "failed", episodeId, message: error instanceof Error ? error.message : String(error) };
}
}
C:\Users\HP\hacks\unread\docs\screenshots\u4\p-nowplaying.png
C:\Users\HP\hacks\unread\docs\screenshots\u4\p-today.png
I've finished reading the code. Here's my review.
Short answer
The structure is sound: one global <audio> element, chapter and line timings taken from real clip lengths, a voice cache, −16 LUFS loudness, and Media Session support. But it isn't robust yet. Three problems can leave a user stuck or confused with no way out, and the player has no loading or error states. I don't think any of these is big, but I'd fix the top group before the Oct 26 deadline.
Must fix (a user gets stuck or misled)
-
Requests can stay "running" forever in poller mode. In poller.ts, if the worker crashes or redeploys mid-episode (Render sends SIGKILL after its grace period), or if
startEpisodethrows (it runs outside thetryin make-episode.ts:14 and nothing in the poller loop catches it), the request staysrunningand nothing ever resets it. After that:- Today shows "Making your episode…" forever, and AutoRefresh keeps reloading the page every 5 s.
-
requestEpisodeNowkeeps returning that dead request (requests.ts:71), so the user can never make another episode.
Fix: have the hourly maintenance job mark requests that have been running for more than about 45 minutes as failed. Wrap the poller's loop body in try/catch. Add a "taking longer than usual" message after about 15 minutes.
Poller mode has no retries. One brief Gemma, ElevenLabs or DB error fails the whole episode. Temporal mode retries each step, the poller doesn't. The voice cache would make a retry cheap, so retrying
voiceEpisodeandprepareScript2–3 times with backoff costs little."Not enough content" never tells the user anything.
prepareScriptmarks the requestskipped, butloadHomeonly reportsfailed(queries.ts:63). So the "No new episode this time" branch in failure-banner.tsx never shows. The user taps "Make one now", sees progress, and then the card goes back to how it was with no explanation. The same happens when a plan or budget block skips a request.Free users can't use "Try again". Free users get 1 manual request a day, and the failed request counts against it (requests.ts:60). Clicking Try again after a failure returns "You can make 1 extra episode a day". Failed requests shouldn't count.
Audio playback (web player)
No error, loading or buffering state. player-context.tsx doesn't listen for
error,waiting,stalledorcanplay, andplay()failures are silently ignored. If the audio 404s, the network drops, or the 6-hour signed R2 URL expires, the play button just doesn't respond. Add astatus: "loading" | "ready" | "error"value, a spinner on the play button, and a "Couldn't load audio. Retry" message. On an error, retry once by settingaudio.srcagain; that also gets a fresh signed URL.Playback speed resets on a new episode. Changing
srcresetsplaybackRatetodefaultPlaybackRate. The button still shows 1.5× while the audio plays at 1×. Setaudio.defaultPlaybackRatealong withplaybackRate, and save the rate in localStorage.Resume doesn't work reliably. Setting
currentTimeright aftersrc(line 100) is ignored by Safari/iOS. Apply the start position in a one-timeloadedmetadatahandler instead. The position is also only saved every 5 s, and not on pause orvisibilitychange.Lock-screen controls. The
playandpausehandlers both calltoggle(), so if the state gets out of sync, pressing pause on the lock screen starts playback. Callaudio.play()andaudio.pause()directly. There's also nosetPositionState, so the lock-screen scrubber and time are wrong.The app re-renders 4 times a second.
currentTimeis in the context value, so everyusePlayer()consumer (episode card, chapter list, transcript, mini player, both bars) re-renders on everytimeupdate. Move the time into a separate context or auseSyncExternalStoresubscription.The transcript auto-scroll fights the reader.
scrollIntoView({behavior:"smooth"})runs on every line change (transcript-view.tsx:17). Pause auto-scroll when the user scrolls by hand, and show a "Jump to current line" pill, as Spotify and Apple do.
Audio rendering (worker)
Kokoro may cut off long lines. Script lines can be up to 400 characters (types.ts:41). Kokoro's limit is about 510 phoneme tokens, and numbers, URLs and acronyms expand a lot when spoken. I believe kokoro-js
generate()truncates silently rather than splitting, but I haven't confirmed it. Either split lines at sentence boundaries before synthesis, or test with a 400-character line full of numbers.The MP3 header may be wrong. ffmpeg writes the MP3 to a pipe, so it can't go back and finish the Xing/Info header. Constant-bitrate 96k means most players estimate duration correctly, but some podcast apps and Safari can show a wrong length or seek imprecisely. Encode to a temp file and read it back.
Loudness. Single-pass
loudnormadjusts volume as it goes and can "pump" between clips. A two-pass run (measure first, then apply withlinear=true) gives steadier speech, and it's cheap at these lengths.-
Smaller points:
- When one clip fails in
mapLimit, the other workers keep synthesising (and spending ElevenLabs credits) after the step has already failed. Add an abort flag. - Chapter titles are spoken as
${title}., so a title ending in "?" becomes "?.". - Newsletter clutter reaches the titles: the screenshot shows "…passes a milestone (6 minute read)". Strip patterns like
(N minute read)andSponsored.
- When one clip fails in
What already works well: line and chapter timings come from real clip lengths, so the transcript highlight stays in sync. The cache key covers provider, model, voice and text. The S3/R2 redirect supports Range requests, which podcast apps need, and the local fallback handles Range itself.
UX improvements (beyond fixes)
-
No episode history in the web app. Today shows only the latest episode, and
recentEpisodes()exists but nothing uses it. Add a "Previous episodes" list under the chapter list, or a Library tab. - Show progress and resume on the episode card. An episode that isn't loaded always shows 0:00, even when a saved position exists. Show "Resume from 4:12" and a "Played" mark.
-
Better in-progress feedback. The voice step already sends
{done, total}but the UI doesn't show it. "Voicing 23 of 61 lines" plus an estimated time remaining would beat a static step list. Also stop polling while the tab is hidden. -
Now Playing accessibility. It has
role="dialog"but doesn't move focus into the dialog, trap it there, or return it on close. The intro shows as one block of text and the outro never appears in any transcript. - Desktop chapter clicks. Clicking a chapter on desktop only shows its transcript. A small play-from-here button on each row would help, since right now you have to click a transcript line to jump.
- Keyboard shortcuts are global and only skipped inside form fields. Watch for clashes with the range-input seek bar when it has focus: arrow keys there move by 1 s and also skip 15 s.
Suggested order before the deadline
Items 1, 3, 4, 5 and 6 (each is small, and together they remove every dead end), then 2 and 7. Items 11–13 only matter after real Gemma and SFX runs. I couldn't test any of this end to end here: there's no Gemma key and no deployed worker, so these findings come from reading the code, not from running the app.
Do you want me to implement the must-fix group and the player fixes on a branch?
fix them all
I'll work on a new branch. First, the remaining files I need.
$ git checkout -b fix/robustness-ux && cat packages/core/src/episodes/step-tracker.ts apps/worker/src/temporal/workflows.ts && ls apps/worker/src/temporal && grep -n "episodeSteps\|export const episodes\b\|export const episodeRequests" -A25 packages/db/src/schema*.ts packages/db/src/schema/*.ts 2>/dev/null | head -120
Switched to a new branch 'fix/robustness-ux'
import { type Database, type PipelineStep, schema } from "@unread/db";
import { and, eq, sql } from "drizzle-orm";
/** Records step_runs (attempt-numbered) and the episode's current step for progress UI. */
export async function trackStep<T>(options: {
db: Database;
episodeId: string;
step: PipelineStep;
run: () => Promise<T>;
outputRef?: string;
}): Promise<T> {
const { db, episodeId, step } = options;
const startedAt = new Date();
await db.update(schema.episodes).set({ currentStep: step }).where(eq(schema.episodes.id, episodeId));
const [{ attempt } = { attempt: 1 }] = await db
.select({ attempt: sql<number>`coalesce(max(${schema.stepRuns.attempt}), 0)::int + 1` })
.from(schema.stepRuns)
.where(and(eq(schema.stepRuns.episodeId, episodeId), eq(schema.stepRuns.step, step)));
const [run] = await db
.insert(schema.stepRuns)
.values({ episodeId, step, attempt, status: "running", timings: { startedAt: startedAt.toISOString() } })
.returning({ id: schema.stepRuns.id });
try {
const result = await options.run();
const finishedAt = new Date();
if (run) {
await db
.update(schema.stepRuns)
.set({
status: "succeeded",
outputRef: options.outputRef,
timings: { startedAt: startedAt.toISOString(), finishedAt: finishedAt.toISOString(), ms: finishedAt.getTime() - startedAt.getTime() },
})
.where(eq(schema.stepRuns.id, run.id));
}
return result;
} catch (error) {
if (run) {
await db
.update(schema.stepRuns)
.set({ status: "failed", timings: { startedAt: startedAt.toISOString(), error: String(error).slice(0, 500) } })
.where(eq(schema.stepRuns.id, run.id));
}
throw error;
}
}
/** For steps done inside one call (collect..check): record what already happened. */
export async function recordSteps(options: { db: Database; episodeId: string; timingsMs: Record<string, number> }) {
const map: Record<string, PipelineStep> = { collect: "collect", cluster: "cluster", plan: "plan", script: "script", check: "check" };
const rows = Object.entries(options.timingsMs).flatMap(([name, ms]) =>
map[name] ? [{ episodeId: options.episodeId, step: map[name] as PipelineStep, status: "succeeded" as const, attempt: 1, timings: { ms } }] : [],
);
if (rows.length) await options.db.insert(schema.stepRuns).values(rows).onConflictDoNothing();
}
import { ApplicationFailure, ParentClosePolicy, proxyActivities, startChild, WorkflowIdReusePolicy } from "@temporalio/workflow";
import type { Activities } from "./activities";
const quick = proxyActivities<Activities>({
startToCloseTimeout: "2 minutes",
retry: { maximumAttempts: 5, initialInterval: "5 seconds", backoffCoefficient: 2 },
});
const thinking = proxyActivities<Activities>({
startToCloseTimeout: "10 minutes",
retry: { maximumAttempts: 3, initialInterval: "30 seconds", backoffCoefficient: 2 },
});
const voicing = proxyActivities<Activities>({
startToCloseTimeout: "30 minutes",
heartbeatTimeout: "2 minutes",
retry: { maximumAttempts: 3, initialInterval: "30 seconds", backoffCoefficient: 2 },
});
export const episodeWorkflowId = (requestId: string) => `episode-request-${requestId}`;
/** `make-episode` (prd §6): durable, retried per step, failure recorded for the user. */
export async function makeEpisodeWorkflow(requestId: string): Promise<string> {
const started = await quick.startEpisode(requestId);
if (started.status === "skipped") return `skipped: ${started.message}`;
const { episodeId } = started;
try {
const prepared = await thinking.prepareScript(episodeId);
if (prepared.status === "skipped") return `skipped: ${prepared.message}`;
await voicing.voiceEpisode(episodeId);
await quick.renderEpisodeCover(episodeId);
await quick.publishEpisode(episodeId);
return `ready: ${episodeId}`;
} catch (error) {
const message = error instanceof Error ? (error.cause instanceof Error ? error.cause.message : error.message) : String(error);
await quick.failEpisode(episodeId, message);
throw ApplicationFailure.nonRetryable(message);
}
}
/** Runs every 10 minutes from a Temporal Schedule: starts one child workflow per due request. */
export async function dispatchWorkflow(): Promise<number> {
const requestIds = await quick.dispatch();
let started = 0;
for (const requestId of requestIds) {
try {
await startChild(makeEpisodeWorkflow, {
args: [requestId],
workflowId: episodeWorkflowId(requestId),
workflowIdReusePolicy: WorkflowIdReusePolicy.REJECT_DUPLICATE,
parentClosePolicy: ParentClosePolicy.ABANDON,
});
started++;
} catch {
// Already running or done for this request.
}
}
return started;
}
export async function maintenanceWorkflow(): Promise<void> {
await quick.maintenance();
}
activities.ts
connection.ts
worker.ts
workflows.test.ts
workflows.ts
packages/db/src/schema.ts:238:export const episodes = pgTable(
packages/db/src/schema.ts-239- "episodes",
packages/db/src/schema.ts-240- {
packages/db/src/schema.ts-241- id: id(),
packages/db/src/schema.ts-242- userId: text()
packages/db/src/schema.ts-243- .notNull()
packages/db/src/schema.ts-244- .references(() => users.id, { onDelete: "cascade" }),
packages/db/src/schema.ts-245- /** The user's local date the episode is for. */
packages/db/src/schema.ts-246- date: date({ mode: "string" }).notNull(),
packages/db/src/schema.ts-247- status: episodeStatus().notNull().default("planning"),
packages/db/src/schema.ts-248- title: text(),
packages/db/src/schema.ts-249- durationS: real(),
packages/db/src/schema.ts-250- /** Characters sent to ElevenLabs; the main cost driver (prd §8). */
packages/db/src/schema.ts-251- characters: integer(),
packages/db/src/schema.ts-252- voiceProvider: voiceProvider(),
packages/db/src/schema.ts-253- voiceModel: text(),
packages/db/src/schema.ts-254- costCents: integer(),
packages/db/src/schema.ts-255- audioKey: text(),
packages/db/src/schema.ts-256- /** MP3 size, required by the feed's <enclosure length>. */
packages/db/src/schema.ts-257- audioBytes: integer(),
packages/db/src/schema.ts-258- coverKey: text(),
packages/db/src/schema.ts-259- /** Set while being made, so Home can show step progress. */
packages/db/src/schema.ts-260- currentStep: pipelineStep(),
packages/db/src/schema.ts-261- transcript: jsonb(),
packages/db/src/schema.ts-262- error: jsonb(),
packages/db/src/schema.ts-263- createdAt: createdAt(),
--
packages/db/src/schema.ts:465:export const episodeRequests = pgTable(
packages/db/src/schema.ts-466- "episode_requests",
packages/db/src/schema.ts-467- {
packages/db/src/schema.ts-468- id: id(),
packages/db/src/schema.ts-469- userId: text()
packages/db/src/schema.ts-470- .notNull()
packages/db/src/schema.ts-471- .references(() => users.id, { onDelete: "cascade" }),
packages/db/src/schema.ts-472- /** The user's local date the episode is for. */
packages/db/src/schema.ts-473- date: date({ mode: "string" }).notNull(),
packages/db/src/schema.ts-474- reason: episodeRequestReason().notNull(),
packages/db/src/schema.ts-475- status: episodeRequestStatus().notNull().default("pending"),
packages/db/src/schema.ts-476- episodeId: text().references(() => episodes.id, { onDelete: "set null" }),
packages/db/src/schema.ts-477- /** Why it was skipped or failed, shown to the user. */
packages/db/src/schema.ts-478- message: text(),
packages/db/src/schema.ts-479- createdAt: createdAt(),
packages/db/src/schema.ts-480- startedAt: timestamp({ withTimezone: true }),
packages/db/src/schema.ts-481- finishedAt: timestamp({ withTimezone: true }),
packages/db/src/schema.ts-482- },
packages/db/src/schema.ts-483- (t) => [
packages/db/src/schema.ts-484- index("episode_requests_status_idx").on(t.status, t.createdAt),
packages/db/src/schema.ts-485- uniqueIndex("episode_requests_scheduled_key")
packages/db/src/schema.ts-486- .on(t.userId, t.date)
packages/db/src/schema.ts-487- .where(sql`${t.reason} = 'scheduled'`),
packages/db/src/schema.ts-488- ],
packages/db/src/schema.ts-489-);
packages/db/src/schema.ts-490-
$ cat apps/worker/src/temporal/activities.ts | head -80 && cat apps/worker/src/main.ts | head -40 && ls packages/core/src/episodes/../../test 2>/dev/null; git ls-files | grep -E "\.test\.ts$" | head -60
import { Context } from "@temporalio/activity";
import {
dispatchDueRequests,
failEpisode,
pendingRequestIds,
prepareScript,
publishEpisode,
renderEpisodeCover,
startEpisode,
voiceEpisode,
} from "@unread/core/episodes";
import { episodeDeps } from "../deps";
import { runMaintenance } from "../maintenance";
/**
* Each activity is idempotent (keyed by episode + step outputs in storage),
* so Temporal retries are safe (prd §6).
*/
export const activities = {
async startEpisode(requestId: string) {
return startEpisode(episodeDeps(), requestId);
},
async prepareScript(episodeId: string) {
return prepareScript(episodeDeps(), episodeId);
},
async voiceEpisode(episodeId: string) {
const ctx = Context.current();
await voiceEpisode(episodeDeps(), episodeId, (detail) => ctx.heartbeat(detail));
},
async renderEpisodeCover(episodeId: string) {
await renderEpisodeCover(episodeDeps(), episodeId);
},
async publishEpisode(episodeId: string) {
await publishEpisode(episodeDeps(), episodeId);
},
async failEpisode(episodeId: string, message: string) {
await failEpisode(episodeDeps(), episodeId, new Error(message));
},
/** New scheduled requests plus any pending ones nobody started (e.g. Temporal was briefly down). */
async dispatch(): Promise<string[]> {
const deps = episodeDeps();
const created = await dispatchDueRequests({ db: deps.db, now: new Date() });
const pending = await pendingRequestIds(deps.db);
return [...new Set([...created, ...pending])];
},
async maintenance() {
return runMaintenance();
},
};
export type Activities = typeof activities;
import { workerContext } from "./context";
import { runPoller } from "./poller";
/** Render background worker entry point. */
async function main() {
const { config } = workerContext();
if (config.env.TEMPORAL_ADDRESS) {
const { runTemporalWorker } = await import("./temporal/worker");
await runTemporalWorker();
} else {
await runPoller();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
billing.test.ts
elevenlabs.test.ts
episode-e2e.test.ts
fake-model.ts
fixtures
helpers.ts
inbound-process.test.ts
inbound.test.ts
links.test.ts
news.test.ts
pipeline-acceptance.test.ts
pipeline-unit.test.ts
promo.test.ts
apps/inbound-worker/src/relay.test.ts
apps/web/src/features/today/format-date.test.ts
apps/web/src/features/today/schedule.test.ts
apps/web/src/features/users/initials.test.ts
apps/worker/src/temporal/workflows.test.ts
packages/core/test/billing.test.ts
packages/core/test/elevenlabs.test.ts
packages/core/test/episode-e2e.test.ts
packages/core/test/inbound-process.test.ts
packages/core/test/inbound.test.ts
packages/core/test/links.test.ts
packages/core/test/news.test.ts
packages/core/test/pipeline-acceptance.test.ts
packages/core/test/pipeline-unit.test.ts
packages/core/test/promo.test.ts
packages/db/src/ids.test.ts
Now checking whether Kokoro actually truncates long text.
$ f=$(find node_modules/.pnpm -path "*kokoro-js*/dist/kokoro.js" 2>/dev/null | head -1); [ -z "$f" ] && f=$(find node_modules/.pnpm -path "*kokoro-js*/src/kokoro.js" | head -1); echo $f; grep -n "truncation\|async generate\|max_length\|split\|async \*stream" "$f" | head -20; ls $(dirname $f)
node_modules/.pnpm/kokoro-js@1.2.1/node_modules/kokoro-js/dist/kokoro.js
1:import{StyleTextToSpeech2Model as e,AutoTokenizer as a,Tensor as t,RawAudio as r,env as n}from"@huggingface/transformers";import{phonemize as l}from"phonemizer";import s from"path";import i from"fs/promises";function o(e){if(e.includes("."))return e;if(e.includes(":")){let[a,t]=e.split(":").map(Number);return 0===t?`${a} o'clock`:t<10?`${a} oh ${t}`:`${a} ${t}`}let a=parseInt(e.slice(0,4),10);if(a<1100||a%1e3<10)return e;let t=e.slice(0,2),r=parseInt(e.slice(2,4),10),n=e.endsWith("s")?"s":"";if(a%1e3>=100&&a%1e3<=999){if(0===r)return`${t} hundred${n}`;if(r<10)return`${t} oh ${r}${n}`}return`${t} ${r}${n}`}function c(e){const a="$"===e[0]?"dollar":"pound";if(isNaN(Number(e.slice(1))))return`${e.slice(1)} ${a}s`;if(!e.includes(".")){let t="1"===e.slice(1)?"":"s";return`${e.slice(1)} ${a}${t}`}const[t,r]=e.slice(1).split("."),n=parseInt(r.padEnd(2,"0"),10);return`${t} ${a}${"1"===t?"":"s"} and ${n} ${"$"===e[0]?1===n?"cent":"cents":1===n?"penny":"pence"}`}function g(e){let[a,t]=e.split(".");return`${a} point ${t.split("").join(" ")}`}const u=new RegExp(`(\\s*[${d=';:,.!?¡¿—…"«»“”(){}[]',d.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}]+\\s*)+`,"g");var d;async function m(e,a="a",t=!0){t&&(e=function(e){return e.replace(/[‘’]/g,"'").replace(/«/g,"“").replace(/»/g,"”").replace(/[“”]/g,'"').replace(/\(/g,"«").replace(/\)/g,"»").replace(/、/g,", ").replace(/。/g,". ").replace(/!/g,"! ").replace(/,/g,", ").replace(/:/g,": ").replace(/;/g,"; ").replace(/?/g,"? ").replace(/[^\S \n]/g," ").replace(/ +/," ").replace(/(?<=\n) +(?=\n)/g,"").replace(/\bD[Rr]\.(?= [A-Z])/g,"Doctor").replace(/\b(?:Mr\.|MR\.(?= [A-Z]))/g,"Mister").replace(/\b(?:Ms\.|MS\.(?= [A-Z]))/g,"Miss").replace(/\b(?:Mrs\.|MRS\.(?= [A-Z]))/g,"Mrs").replace(/\betc\.(?! [A-Z])/gi,"etc").replace(/\b(y)eah?\b/gi,"$1e'a").replace(/\d*\.\d+|\b\d{4}s?\b|(?<!:)\b(?:[1-9]|1[0-2]):[0-5]\d\b(?!:)/g,o).replace(/(?<=\d),(?=\d)/g,"").replace(/[$£]\d+(?:\.\d+)?(?: hundred| thousand| (?:[bm]|tr)illion)*\b|[$£]\d+\.\d\d?\b/gi,c).replace(/\d*\.\d+/g,g).replace(/(?<=\d)-(?=\d)/g," to ").replace(/(?<=\d)S/g," S").replace(/(?<=[BCDFGHJ-NP-TV-Z])'?s\b/g,"'S").replace(/(?<=X')S\b/g,"s").replace(/(?:[A-Za-z]\.){2,} [a-z]/g,(e=>e.replace(/\./g,"-"))).replace(/(?<=[A-Z])\.(?=[A-Z])/gi,"-").trim()}(e));const r=function(e,a){const t=[];let r=0;for(const n of e.matchAll(a)){const a=n[0];r<n.index&&t.push({match:!1,text:e.slice(r,n.index)}),a.length>0&&t.push({match:!0,text:a}),r=n.index+a.length}return r<e.length&&t.push({match:!1,text:e.slice(r)}),t}(e,u),n="a"===a?"en-us":"en",s=(await Promise.all(r.map((async({match:e,text:a})=>e?a:(await l(a,n)).join(" "))))).join("");let i=s.replace(/kəkˈoːɹoʊ/g,"kˈoʊkəɹoʊ").replace(/kəkˈɔːɹəʊ/g,"kˈəʊkəɹəʊ").replace(/ʲ/g,"j").replace(/r/g,"ɹ").replace(/x/g,"k").replace(/ɬ/g,"l").replace(/(?<=[a-zɹː])(?=hˈʌndɹɪd)/g," ").replace(/ z(?=[;:,.!?¡¿—…"«»“” ]|$)/g,"z");return"a"===a&&(i=i.replace(/(?<=nˈaɪn)ti(?!ː)/g,"di")),i.trim()}function p(e,a=!0){return".!?…。?!".includes(e)||a&&"\n"===e}function f(e,a){let t=a;for(;t<e.length&&!/\s/.test(e[t]);)++t;return e.substring(a,t)}const h=new Set(["mr","mrs","ms","dr","prof","sr","jr","sgt","col","gen","rep","sen","gov","lt","maj","capt","st","mt","etc","co","inc","ltd","dept","vs","p","pg","jan","feb","mar","apr","jun","jul","aug","sep","sept","oct","nov","dec","sun","mon","tu","tue","tues","wed","th","thu","thur","thurs","fri","sat"]);function _(e){return e=e.replace(/['’]s$/i,"").replace(/\.+$/,""),h.has(e.toLowerCase())}const v=new Map([[")","("],["]","["],["}","{"],["》","《"],["〉","〈"],["›","‹"],["»","«"],["〉","〈"],["」","「"],["』","『"],["〕","〔"],["】","【"]]),b=new Set(v.values());function y(e,a,t,r){if('"'===e||"'"===e){if("'"===e&&t>0&&t<r.length-1&&/[A-Za-z]/.test(r[t-1])&&/[A-Za-z]/.test(r[t+1]))return;return void(a.length&&a.at(-1)===e?a.pop():a.push(e))}if(b.has(e))return void a.push(e);const n=v.get(e);n&&a.length&&a.at(-1)===n&&a.pop()}class w{constructor(){this._buffer="",this._sentences=[],this._resolver=null,this._closed=!1}push(...e){for(const a of e)this._buffer+=a,this._process()}close(){if(this._closed)throw new Error("Stream is already closed.");this._closed=!0,this.flush()}flush(){const e=this._buffer.trim();e.length>0&&this._sentences.push(e),this._buffer="",this._resolve()}_resolve(){this._resolver&&(this._resolver(),this._resolver=null)}_process(){let e=0;const a=this._buffer,t=a.length;let r=0,n=[];const l=e=>{let r=e;for(;r+1<t&&p(a[r+1],!1);)++r;for(;r+1<t&&(n=a[r+1],"\"')]}」』".includes(n));)++r;var n;let l=r+1;for(;l<t&&/\s/.test(a[l]);)++l;return{end:r,nextNonSpace:l}};for(;r<t;){const s=a[r];if(y(s,n,r,a),0===n.length&&p(s)){const n=a.slice(e,r);if(/(^|\n)\d+$/.test(n)){++r;continue}const{end:i,nextNonSpace:o}=l(r);if(r===o-1&&"\n"!==s){++r;continue}if(o===t)break;let c=r-1;for(;c>=0&&/\S/.test(a[c]);)c--;c=Math.max(e,c+1);const g=f(a,c);if(!g){++r;continue}if((/https?[,:]\/\//.test(g)||g.includes("@"))&&!p(g.at(-1))){r=c+g.length;continue}if(_(g)){++r;continue}if(/^([A-Za-z]\.)+$/.test(g)&&o<t&&/[A-Z]/.test(a[o])){++r;continue}if("."===s&&o<t&&/[a-z]/.test(a[o])){++r;continue}const u=a.substring(e,i+1).trim();if("..."===u||"…"===u){++r;continue}u&&this._sentences.push(u),r=e=i+1}else++r}this._buffer=a.substring(e),this._sentences.length>0&&this._resolve()}async*[Symbol.asyncIterator](){if(this._resolver)throw new Error("Another iterator is already active.");for(;;)if(this._sentences.length>0)yield this._sentences.shift();else{if(this._closed)break;await new Promise((e=>{this._resolver=e}))}}[Symbol.iterator](){this.flush();const e=this._sentences[Symbol.iterator]();return this._sentences=[],e}get sentences(){return this._sentences}}const $=Object.freeze({af_heart:{name:"Heart",language:"en-us",gender:"Female",traits:"❤️",targetQuality:"A",overallGrade:"A"},af_alloy:{name:"Alloy",language:"en-us",gender:"Female",targetQuality:"B",overallGrade:"C"},af_aoede:{name:"Aoede",language:"en-us",gender:"Female",targetQuality:"B",overallGrade:"C+"},af_bella:{name:"Bella",language:"en-us",gender:"Female",traits:"🔥",targetQuality:"A",overallGrade:"A-"},af_jessica:{name:"Jessica",language:"en-us",gender:"Female",targetQuality:"C",overallGrade:"D"},af_kore:{name:"Kore",language:"en-us",gender:"Female",targetQuality:"B",overallGrade:"C+"},af_nicole:{name:"Nicole",language:"en-us",gender:"Female",traits:"🎧",targetQuality:"B",overallGrade:"B-"},af_nova:{name:"Nova",language:"en-us",gender:"Female",targetQuality:"B",overallGrade:"C"},af_river:{name:"River",language:"en-us",gender:"Female",targetQuality:"C",overallGrade:"D"},af_sarah:{name:"Sarah",language:"en-us",gender:"Female",targetQuality:"B",overallGrade:"C+"},af_sky:{name:"Sky",language:"en-us",gender:"Female",targetQuality:"B",overallGrade:"C-"},am_adam:{name:"Adam",language:"en-us",gender:"Male",targetQuality:"D",overallGrade:"F+"},am_echo:{name:"Echo",language:"en-us",gender:"Male",targetQuality:"C",overallGrade:"D"},am_eric:{name:"Eric",language:"en-us",gender:"Male",targetQuality:"C",overallGrade:"D"},am_fenrir:{name:"Fenrir",language:"en-us",gender:"Male",targetQuality:"B",overallGrade:"C+"},am_liam:{name:"Liam",language:"en-us",gender:"Male",targetQuality:"C",overallGrade:"D"},am_michael:{name:"Michael",language:"en-us",gender:"Male",targetQuality:"B",overallGrade:"C+"},am_onyx:{name:"Onyx",language:"en-us",gender:"Male",targetQuality:"C",overallGrade:"D"},am_puck:{name:"Puck",language:"en-us",gender:"Male",targetQuality:"B",overallGrade:"C+"},am_santa:{name:"Santa",language:"en-us",gender:"Male",targetQuality:"C",overallGrade:"D-"},bf_emma:{name:"Emma",language:"en-gb",gender:"Female",traits:"🚺",targetQuality:"B",overallGrade:"B-"},bf_isabella:{name:"Isabella",language:"en-gb",gender:"Female",targetQuality:"B",overallGrade:"C"},bm_george:{name:"George",language:"en-gb",gender:"Male",targetQuality:"B",overallGrade:"C"},bm_lewis:{name:"Lewis",language:"en-gb",gender:"Male",targetQuality:"C",overallGrade:"D+"},bf_alice:{name:"Alice",language:"en-gb",gender:"Female",traits:"🚺",targetQuality:"C",overallGrade:"D"},bf_lily:{name:"Lily",language:"en-gb",gender:"Female",traits:"🚺",targetQuality:"C",overallGrade:"D"},bm_daniel:{name:"Daniel",language:"en-gb",gender:"Male",traits:"🚹",targetQuality:"C",overallGrade:"D"},bm_fable:{name:"Fable",language:"en-gb",gender:"Male",traits:"🚹",targetQuality:"B",overallGrade:"C"}});const G=new Map;async function k(e){if(G.has(e))return G.get(e);const a=new Float32Array(await async function(e){if(i&&Object.hasOwn(i,"readFile")){const a="undefined"!=typeof __dirname?__dirname:import.meta.dirname,t=s.resolve(a,`../voices/${e}.bin`),{buffer:r}=await i.readFile(t);return r}const a=`https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX/resolve/main/voices/${e}.bin`;let t;try{t=await caches.open("kokoro-voices");const e=await t.match(a);if(e)return await e.arrayBuffer()}catch(e){console.warn("Unable to open cache",e)}const r=await fetch(a),n=await r.arrayBuffer();if(t)try{await t.put(a,new Response(n,{headers:r.headers}))}catch(e){console.warn("Unable to cache file",e)}return n}(e));return G.set(e,a),a}class M{constructor(e,a){this.model=e,this.tokenizer=a}static async from_pretrained(t,{dtype:r="fp32",device:n=null,progress_callback:l=null}={}){const s=e.from_pretrained(t,{progress_callback:l,dtype:r,device:n}),i=a.from_pretrained(t,{progress_callback:l}),o=await Promise.all([s,i]);return new M(...o)}get voices(){return $}list_voices(){console.table($)}_validate_voice(e){if(!$.hasOwnProperty(e))throw console.error(`Voice "${e}" not found. Available voices:`),console.table($),new Error(`Voice "${e}" not found. Should be one of: ${Object.keys($).join(", ")}.`);return e.at(0)}async generate(e,{voice:a="af_heart",speed:t=1}={}){const r=this._validate_voice(a),n=await m(e,r),{input_ids:l}=this.tokenizer(n,{truncation:!0});return this.generate_from_ids(l,{voice:a,speed:t})}async generate_from_ids(e,{voice:a="af_heart",speed:n=1}={}){const l=256*Math.min(Math.max(e.dims.at(-1)-2,0),509),s=(await k(a)).slice(l,l+256),i={input_ids:e,style:new t("float32",s,[1,256]),speed:new t("float32",[n],[1])},{waveform:o}=await this.model(i);return new r(o.data,24e3)}async*stream(e,{voice:a="af_heart",speed:t=1,split_pattern:r=null}={}){const n=this._validate_voice(a);let l;if(e instanceof w)l=e;else{if("string"!=typeof e)throw new Error("Invalid input type. Expected string or TextSplitterStream.");{l=new w;const a=r?e.split(r).map((e=>e.trim())).filter((e=>e.length>0)):[e];l.push(...a)}}for await(const e of l){const r=await m(e,n),{input_ids:l}=this.tokenizer(r,{truncation:!0}),s=await this.generate_from_ids(l,{voice:a,speed:t});yield{text:e,phonemes:r,audio:s}}}}const Q={set wasmPaths(e){n.backends.onnx.wasm.wasmPaths=e},get wasmPaths(){return n.backends.onnx.wasm.wasmPaths}};export{M as KokoroTTS,w as TextSplitterStream,Q as env};
kokoro.cjs
kokoro.js
kokoro.web.js
Confirmed: kokoro-js calls the tokenizer with truncation: true, so long lines are silently cut. Let me check the existing tests and the script prompt before editing.
$ grep -n "assembleEpisode\|makeEpisode\|requestEpisodeNow\|voiceEpisode\|loadHome\|describe(\|it(\"" packages/core/test/episode-e2e.test.ts packages/core/test/pipeline-unit.test.ts apps/worker/src/temporal/workflows.test.ts | head -80; sed -n 1,80p packages/core/src/pipeline/script.ts
packages/core/test/episode-e2e.test.ts:10:import { type EpisodeDeps, type EpisodeTranscript, makeEpisode, requestEpisodeNow } from "../src/episodes";
packages/core/test/episode-e2e.test.ts:20:describe.skipIf(!run)("makeEpisode end to end (real EmbeddingGemma + Kokoro + ffmpeg)", () => {
packages/core/test/episode-e2e.test.ts:59: it("makes a Kokoro episode with chapters and sources, and never repeats a heard story", async () => {
packages/core/test/episode-e2e.test.ts:64: const requestId = await requestEpisodeNow({ db, userId: user.id, timezone: "UTC", now: new Date(), dailyLimit: 5 });
packages/core/test/episode-e2e.test.ts:65: const result = await makeEpisode(deps, requestId);
packages/core/test/episode-e2e.test.ts:109: const second = await makeEpisode({ ...deps, now: () => new Date("2026-10-25T12:00:00Z") }, await requestEpisodeNow({ db, userId: user.id, timezone: "UTC", now: new Date(), dailyLimit: 5 }));
packages/core/test/episode-e2e.test.ts:126: it("a promo trial uses the Studio voice, counts episodes, ends after the limit and keeps unused items", async () => {
packages/core/test/episode-e2e.test.ts:139: const result = await makeEpisode(deps, await requestEpisodeNow({ db, userId: user.id, timezone: "UTC", now: new Date(), dailyLimit: 5 }));
packages/core/test/pipeline-unit.test.ts:7:describe("structured output", () => {
packages/core/test/pipeline-unit.test.ts:8: it("pulls JSON from fenced or chatty replies", () => {
packages/core/test/pipeline-unit.test.ts:13: it("feeds validation errors back and retries", async () => {
packages/core/test/pipeline-unit.test.ts:29:describe("script check", () => {
packages/core/test/pipeline-unit.test.ts:33: it("passes supported numbers and quotes", () => {
packages/core/test/pipeline-unit.test.ts:39: it("flags invented numbers, invented quotes, missing citations and long lines", () => {
packages/core/test/pipeline-unit.test.ts:46: it("trims to the character budget without emptying chapters", () => {
packages/core/test/pipeline-unit.test.ts:63:describe("plans and budgets", () => {
packages/core/test/pipeline-unit.test.ts:74: it("paid Plus wins, then a live trial, then Free", () => {
packages/core/test/pipeline-unit.test.ts:82: it("budgets by plan", () => {
packages/core/test/pipeline-unit.test.ts:91:describe("planEpisode", () => {
packages/core/test/pipeline-unit.test.ts:98: it("puts 'always' first, 'only if room' last, and stops at the budget", async () => {
apps/worker/src/temporal/workflows.test.ts:6:import { makeEpisodeWorkflow } from "./workflows";
apps/worker/src/temporal/workflows.test.ts:10:describe.skipIf(!runTemporal)("makeEpisodeWorkflow", () => {
apps/worker/src/temporal/workflows.test.ts:26: return worker.runUntil(env.client.workflow.execute(makeEpisodeWorkflow, { args: ["req1"], workflowId: `wf-${Math.random()}`, taskQueue: "test" }));
apps/worker/src/temporal/workflows.test.ts:29: it("runs every step in order and retries a flaky voice step", async () => {
apps/worker/src/temporal/workflows.test.ts:35: voiceEpisode: async () => {
apps/worker/src/temporal/workflows.test.ts:47: it("skips without publishing when there's not enough content", async () => {
import { generateStructured, type ModelClient } from "../model";
import { chapterDraftSchema, type Cluster, type EpisodePlan, type EpisodeScript, type ScriptChapter, type ScriptLine } from "./types";
const SOURCE_CHARS = 3_500;
export const WRITER_SYSTEM = `You write one chapter of Unread, a short spoken podcast that summarises the listener's newsletters.
Rules:
- Spoken, plain English, warm and direct. Short sentences. No headings, lists, emoji or markdown.
- Summarise in your own words. Never copy a sentence from a source.
- Only state facts, numbers, names and quotes that appear in the sources. If unsure, leave it out.
- Every line lists the ids of the sources it relies on, e.g. ["S1"].
- At most 30 words per line.
- If several sources cover the story, say so once, e.g. "Morning Brew and The Hustle both covered this."
- Don't greet the listener or sign off; the episode has its own intro and outro.`;
function sourceBlock(cluster: Cluster): { text: string; idMap: Map<string, string> } {
const idMap = new Map<string, string>();
const text = cluster.items
.map((item, i) => {
const sid = `S${i + 1}`;
idMap.set(sid, item.id);
const body = item.limited ? `${item.text}\n(Only a snippet was available; stick to it.)` : item.text;
return `[${sid}] ${item.sourceLabel} — "${item.title}"\n${body.slice(0, SOURCE_CHARS)}`;
})
.join("\n\n");
return { text, idMap };
}
export function originLabel(cluster: Cluster): string {
const labels = [...new Set(cluster.items.map((i) => i.sourceLabel))];
return labels.length > 1 ? `${labels[0]} · also in ${labels.slice(1).join(", ")}` : (labels[0] ?? "");
}
/** Step 5, one chapter: Gemma drafts JSON lines that cite S-ids, mapped back to item ids. */
export async function writeChapter(options: {
model: ModelClient;
cluster: Cluster;
targetChars: number;
}): Promise<ScriptChapter> {
const { model, cluster, targetChars } = options;
const { text, idMap } = sourceBlock(cluster);
const draft = await generateStructured({
client: model,
system: WRITER_SYSTEM,
prompt: `Sources:\n${text}\n\nWrite this chapter in about ${targetChars} characters of spoken text (roughly ${Math.max(
1,
Math.round((targetChars / 900) * 60),
)} seconds).\nReturn {"title": "short chapter title", "lines": [{"text": "...", "sourceIds": ["S1"]}]}.`,
schema: chapterDraftSchema,
validate: (d) => {
const chars = d.lines.reduce((s, l) => s + l.text.length, 0);
if (chars > targetChars * 1.35) return `Too long: ${chars} characters, aim for ${targetChars}`;
const bad = d.lines.flatMap((l) => l.sourceIds).filter((id) => !idMap.has(id));
return bad.length ? `Unknown source ids: ${bad.join(", ")}` : null;
},
temperature: 0.5,
maxTokens: 2_000,
});
return {
clusterKey: cluster.key,
title: draft.title,
origin: originLabel(cluster),
lines: draft.lines.map((l) => ({ text: l.text, sourceIds: l.sourceIds.map((s) => idMap.get(s) ?? s) })),
sources: cluster.items.map((item, i) => ({
itemId: item.id,
label: item.sourceLabel,
title: item.title,
url: item.url ?? item.links[0]?.url ?? null,
role: i === 0 ? ("primary" as const) : ("also" as const),
})),
};
}
function listNames(names: string[]): string {
if (names.length <= 1) return names[0] ?? "";
return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
}
/** Intro and outro are templates, so they can't hallucinate (prd §6 step 5: intro names the newsletters). */
export function introAndOutro(options: { clusters: Cluster[]; dateLabel: string; storyCount: number }): {
Kokoro truncation is real, so splitting is needed. Reading the last few web files I'll touch.
$ cd /c/Users/HP/hacks/unread/apps/web/src && cat features/today/transcript-panel.tsx features/shell/app-shell.tsx lib/use-browser-value.ts features/player/format.ts features/today/step-progress.tsx features/today/pipeline-steps.ts features/action-state.ts; cat ../../../packages/core/src/episodes/deps.ts; sed -n 1,60p features/player/icons.tsx
"use client";
import type { PlayerEpisode } from "@/features/episodes/queries";
import { usePlayer } from "@/features/player/player-context";
import { TranscriptView } from "@/features/player/transcript-view";
import { DetailPanelEmpty } from "./detail-panel-empty";
/** Desktop right column: transcript + sources for the focused (or playing) chapter. */
export function TranscriptPanel({ episode }: { episode: PlayerEpisode }) {
const player = usePlayer();
const isCurrent = player.episode?.id === episode.id;
const index = player.focusedChapter ?? (isCurrent ? player.chapterIndex : null);
const chapter = index !== null ? episode.transcript.chapters[index] : undefined;
if (index === null || !chapter) return <DetailPanelEmpty />;
return (
<section aria-label="Transcript and sources" className="flex flex-col gap-4 rounded-[20px] border border-line bg-surface/40 p-6">
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">{chapter.origin}</span>
<h2 className="m-0 text-2xl leading-tight font-bold tracking-[-0.02em]">{chapter.title}</h2>
</div>
{isCurrent ? (
<TranscriptView chapterIndex={index} />
) : (
<button
type="button"
onClick={() => player.load(episode, { autoplay: true, startAt: chapter.startS })}
className="self-start cursor-pointer text-sm text-link hover:text-link-hover"
>
Play from here
</button>
)}
</section>
);
}
import type { ReactNode } from "react";
import { MiniPlayer } from "@/features/player/mini-player";
import { NowPlaying } from "@/features/player/now-playing";
import { PlayerProvider } from "@/features/player/player-context";
import { PlayerBar } from "@/features/player/player-bar";
import { MobileHeader } from "./mobile-header";
import { SidebarNav } from "./sidebar-nav";
import { TabBar } from "./tab-bar";
export interface AppShellProps {
initials: string;
email: string | undefined;
children: ReactNode;
}
/**
* One tree for every width; CSS breakpoints switch between the phone/tablet
* layout (header + bottom tab bar) and the desktop sidebar (prd §8).
*/
export function AppShell({ initials, email, children }: AppShellProps) {
return (
<PlayerProvider>
<div className="min-h-dvh lg:flex">
<SidebarNav initials={initials} email={email} className="hidden lg:flex" />
<div className="flex min-h-dvh min-w-0 flex-1 flex-col">
<div className="lg:hidden">
<MobileHeader initials={initials} />
</div>
{/* Room for the tab bar + mini player (phone) or the player bar (desktop). */}
<main id="main" className="flex-1 pb-48 lg:pb-32">
{children}
</main>
<MiniPlayer />
<TabBar className="lg:hidden" />
</div>
<PlayerBar />
<NowPlaying />
</div>
</PlayerProvider>
);
}
"use client";
import { useSyncExternalStore } from "react";
const noopSubscribe = () => () => undefined;
/** A value only the browser knows (timezone, locale); `fallback` during SSR and hydration. */
export function useBrowserValue<T>(read: () => T, fallback: T): T {
return useSyncExternalStore(noopSubscribe, read, () => fallback);
}
export function clock(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = String(s % 60).padStart(2, "0");
return h ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
}
export function remaining(current: number, duration: number): string {
return `−${clock(Math.max(0, duration - current))}`;
}
export function minutesLabel(seconds: number): string {
return `${Math.max(1, Math.round(seconds / 60))} min`;
}
import type { PipelineStep } from "@unread/db";
import { cn } from "@/lib/utils";
import { PIPELINE_STEP_LABELS, PIPELINE_STEPS, type StepState } from "./pipeline-steps";
export interface StepProgressProps {
states?: Partial<Record<PipelineStep, StepState>>;
className?: string;
}
/** The step list shown while an episode is being made. */
export function StepProgress({ states = {}, className }: StepProgressProps) {
return (
<ol className={cn("m-0 flex list-none flex-col p-0", className)}>
{PIPELINE_STEPS.map((step, index) => {
const state = states[step] ?? "pending";
return (
<li key={step} className="flex items-center gap-3 border-b border-surface-2 py-2.5 last:border-b-0">
<span
aria-hidden="true"
className={cn(
"flex size-6 flex-none items-center justify-center rounded-full font-mono text-[11px]",
state === "done" && "bg-brand text-on-brand",
state === "active" && "border border-brand text-brand",
state === "pending" && "border border-line text-muted-foreground",
)}
>
{index + 1}
</span>
<span className={cn("text-sm", state === "pending" ? "text-muted-foreground" : "text-foreground")}>
{PIPELINE_STEP_LABELS[step]}
</span>
<span className="sr-only">{state}</span>
</li>
);
})}
</ol>
);
}
import type { PipelineStep } from "@unread/db";
/** Listener-facing names for the pipeline steps (prd §6). */
export const PIPELINE_STEP_LABELS: Record<PipelineStep, string> = {
collect: "Gathering new newsletters and links",
extract: "Splitting issues into stories",
cluster: "Merging stories covered more than once",
plan: "Planning the episode",
script: "Writing the script",
check: "Checking every fact against its source",
voice: "Recording the voice",
cover: "Making the cover",
publish: "Publishing to your feed",
};
export const PIPELINE_STEPS = Object.keys(PIPELINE_STEP_LABELS) as PipelineStep[];
export type StepState = "pending" | "active" | "done";
export interface ActionState {
ok?: boolean;
error?: string;
message?: string;
}
export const initialActionState: ActionState = {};
import type { Database, VoiceProvider as VoiceProviderId } from "@unread/db";
import type { CoreConfig } from "../config";
import type { Embedder } from "../embeddings";
import type { ModelClient } from "../model";
import type { Storage } from "../storage";
import type { ElevenLabsQuality, VoiceProvider } from "../voice";
export interface EpisodeDeps {
config: CoreConfig;
db: Database;
storage: Storage;
model: () => ModelClient;
embedder: Embedder;
voiceFor: (id: VoiceProviderId, options?: { quality?: ElevenLabsQuality }) => VoiceProvider;
now?: () => Date;
}
import type { SVGProps } from "react";
type P = SVGProps<SVGSVGElement>;
const base = { viewBox: "0 0 24 24", "aria-hidden": true } as const;
export function PlayIcon(props: P) {
return (
<svg {...base} {...props}>
<path d="M7 4.5v15l13-7.5z" fill="currentColor" />
</svg>
);
}
export function PauseIcon(props: P) {
return (
<svg {...base} {...props}>
<rect x="6" y="5" width="4" height="14" rx="1.2" fill="currentColor" />
<rect x="14" y="5" width="4" height="14" rx="1.2" fill="currentColor" />
</svg>
);
}
const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" } as const;
export function PrevIcon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M6 5v14" />
<path d="M19 5l-10 7 10 7z" fill="currentColor" />
</svg>
);
}
export function NextIcon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M18 5v14" />
<path d="M5 5l10 7-10 7z" fill="currentColor" />
</svg>
);
}
export function ChevronDownIcon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M6 9l6 6 6-6" />
</svg>
);
}
export function LinkIcon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1" />
<path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" />
</svg>
);
}
export function Back15Icon(props: P) {
I have everything I need. Starting with the worker side: text splitting for Kokoro.
C:\Users\HP\hacks\unread\packages\core\src\voice\split-text.ts
File created successfully at: C:\Users\HP\hacks\unread\packages\core\src\voice\split-text.ts (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\packages\core\src\voice\kokoro.ts
The file C:\Users\HP\hacks\unread\packages\core\src\voice\kokoro.ts has been updated successfully. (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\packages\core\src\voice\index.ts
The file C:\Users\HP\hacks\unread\packages\core\src\voice\index.ts has been updated successfully.
Now the assembler: two-pass loudness and a file-based MP3 encode so the Xing header is correct.
$ cd /c/Users/HP/hacks/unread && python3 - <<'EOF' 2>/dev/null || node -e "1"
EOF
cat > /dev/null; grep -n "runFfmpeg\|^import" packages/core/src/audio/assemble.ts
Command did not complete within its 120s timeout and was moved to the background (ID: binfusp14). Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\binfusp14.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains C:\Users\HP\hacks\unread\apps\web\src; directory changes made by the backgrounded command do not apply to subsequent commands.
{
"query": "select:TaskStop",
"max_results": 1
}
{
"task_id": "binfusp14"
}
{
"message": "Successfully stopped task: binfusp14 (cd /c/Users/HP/hacks/unread && python3 - <<'EOF' 2>/dev/null || node -e \"1\"\nEOF\ncat > /dev/null; grep -n \"runFfmpeg\\|^import\" packages/core/src/audio/assemble.ts)",
"task_id": "binfusp14",
"task_type": "local_bash",
"command": "cd /c/Users/HP/hacks/unread && python3 - <<'EOF' 2>/dev/null || node -e \"1\"\nEOF\ncat > /dev/null; grep -n \"runFfmpeg\\|^import\" packages/core/src/audio/assemble.ts"
}
I'll rewrite the ffmpeg helper and the encode step of assemble.ts.
C:\Users\HP\hacks\unread\packages\core\src\audio\assemble.ts
The file C:\Users\HP\hacks\unread\packages\core\src\audio\assemble.ts has been updated successfully.
C:\Users\HP\hacks\unread\packages\core\src\audio\assemble.ts
The file C:\Users\HP\hacks\unread\packages\core\src\audio\assemble.ts has been updated successfully.
C:\Users\HP\hacks\unread\packages\core\src\audio\assemble.ts
The file C:\Users\HP\hacks\unread\packages\core\src\audio\assemble.ts has been updated successfully.
Next: step-tracker progress reporting, then the voice step.
$ cd /c/Users/HP/hacks/unread && node - <<'EOF'
const fs=require('fs');const p='packages/core/src/episodes/step-tracker.ts';let s=fs.readFileSync(p,'utf8');
s=s.replace(` run: () => Promise<T>;`,` /** \`progress\` stores a small detail (e.g. lines voiced) on the running step for the progress UI. */
run: (progress: (detail: Record<string, unknown>) => Promise<void>) => Promise<T>;`);
s=s.replace(` const result = await options.run();`,` const progress = async (detail: Record<string, unknown>) => {
if (!run) return;
await db
.update(schema.stepRuns)
.set({ timings: { startedAt: startedAt.toISOString(), progress: detail } })
.where(eq(schema.stepRuns.id, run.id));
};
const result = await options.run(progress);`);
fs.writeFileSync(p,s);
EOF
grep -n "progress" packages/core/src/episodes/step-tracker.ts
4:/** Records step_runs (attempt-numbered) and the episode's current step for progress UI. */
9: /** `progress` stores a small detail (e.g. lines voiced) on the running step for the progress UI. */
10: run: (progress: (detail: Record<string, unknown>) => Promise<void>) => Promise<T>;
25: const progress = async (detail: Record<string, unknown>) => {
29: .set({ timings: { startedAt: startedAt.toISOString(), progress: detail } })
32: const result = await options.run(progress);
C:\Users\HP\hacks\unread\packages\core\src\episodes\steps.ts
The file C:\Users\HP\hacks\unread\packages\core\src\episodes\steps.ts has been updated successfully.
C:\Users\HP\hacks\unread\packages\core\src\episodes\steps.ts
The file C:\Users\HP\hacks\unread\packages\core\src\episodes\steps.ts has been updated successfully.
C:\Users\HP\hacks\unread\packages\core\src\episodes\steps.ts
The file C:\Users\HP\hacks\unread\packages\core\src\episodes\steps.ts has been updated successfully.
Now strip newsletter clutter from chapter titles in the script writer.
$ node - <<'EOF'
const fs=require('fs');const p='packages/core/src/pipeline/script.ts';let s=fs.readFileSync(p,'utf8');
s=s.replace(`- Don't greet the listener or sign off; the episode has its own intro and outro.\`;`,
`- Don't greet the listener or sign off; the episode has its own intro and outro.
- The title is a short headline in your own words: no reading times, "Sponsored", emoji or source names.\`;
const CLUTTER = [
/\\s*[([]\\s*\\d+\\s*(?:-|\\s)?(?:min(?:ute)?s?|mins?)\\.?(?:\\s+(?:read|listen|watch))?\\s*[)\\]]/gi,
/\\s*[([]\\s*(?:sponsored|sponsor|ad|partner|paid)\\s*[)\\]]/gi,
/^\\s*(?:sponsored|sponsor|together with [^:]+)\\s*[:|—-]\\s*/i,
/\\s*[|·—-]\\s*\\d+\\s*min(?:ute)?s?\\s+read\\s*$/i,
];
/** Newsletter headline clutter ("(6 minute read)", "(Sponsored)") that shouldn't be read aloud or shown. */
export function cleanHeadline(title: string): string {
const cleaned = CLUTTER.reduce((t, pattern) => t.replace(pattern, ""), title).replace(/\\s{2,}/g, " ").trim();
return cleaned || title.trim();
}`);
s=s.replace(` title: draft.title,
origin`,` title: cleanHeadline(draft.title),
origin`);
s=s.replace(` title: item.title,
url:`,` title: cleanHeadline(item.title),
url:`);
fs.writeFileSync(p,s);
EOF
grep -n "cleanHeadline" packages/core/src/pipeline/script.ts; grep -n "script\"" packages/core/src/pipeline/index.ts; cat packages/core/src/pipeline/index.ts | head -20
25:export function cleanHeadline(title: string): string {
1:export * from "./build-script";
6:export * from "./script";
export * from "./build-script";
export * from "./check";
export * from "./cluster";
export * from "./collect";
export * from "./plan";
export * from "./script";
export * from "./signals";
export * from "./types";
$ grep -n "title: draft.title\|title: item.title" -B1 -A1 packages/core/src/pipeline/script.ts | cat -A | head
74- clusterKey: cluster.key,$
75: title: draft.title,$
76- origin: originLabel(cluster),$
--$
80- label: item.sourceLabel,$
81: title: item.title,$
82- url: item.url ?? item.links[0]?.url ?? null,$
C:\Users\HP\hacks\unread\packages\core\src\pipeline\script.ts
The file C:\Users\HP\hacks\unread\packages\core\src\pipeline\script.ts has been updated successfully.
C:\Users\HP\hacks\unread\packages\core\src\pipeline\script.ts
The file C:\Users\HP\hacks\unread\packages\core\src\pipeline\script.ts has been updated successfully.
C:\Users\HP\hacks\unread\packages\core\src\pipeline\script.ts
1 import { generateStructured, type ModelClient } from "../model";
2 import { chapterDraftSchema, type Cluster, type EpisodePlan, type EpisodeScript, type ScriptChapter, type ScriptLine } from "./types";
3
4 const SOURCE_CHARS = 3_500;
5
6 export const WRITER_SYSTEM = `You write one chapter of Unread, a short spoken podcast that summarises the listener's newsletters.
7 Rules:
8 - Spoken, plain English, warm and direct. Short sentences. No headings, lists, emoji or markdown.
9 - Summarise in your own words. Never copy a sentence from a source.
10 - Only state facts, numbers, names and quotes that appear in the sources. If unsure, leave it out.
11 - Every line lists the ids of the sources it relies on, e.g. ["S1"].
12 - At most 30 words per line.
13 - If several sources cover the story, say so once, e.g. "Morning Brew and The Hustle both covered this."
14 - Don't greet the listener or sign off; the episode has its own intro and outro.
15 - The title is a short headline in your own words: no reading times, "Sponsored", emoji or source names.`;
16
17 const CLUTTER = [
18 /s*[([]s*d+s*(?:-|s)?(?:min(?:ute)?s?|mins?).?(?:s+(?:read|listen|watch))?s*[)]]/gi,
19 /s*[([]s*(?:sponsored|sponsor|ad|partner|paid)s*[)]]/gi,
20 /^s*(?:sponsored|sponsor|together with [^:]+)s*[:|—-]s*/i,
21 /s*[|·—-]s*d+s*min(?:ute)?s?s+reads*$/i,
22 ];
23
24 /** Newsletter headline clutter ("(6 minute read)", "(Sponsored)") that shouldn't be read aloud or shown. */
25 export function cleanHeadline(title: string): string {
26 const cleaned = CLUTTER.reduce((t, pattern) => t.replace(pattern, ""), title).replace(/s{2,}/g, " ").trim();
27 return cleaned || title.trim();
28 }
29
30 function sourceBlock(cluster: Cluster): { text: string; idMap: Map<string, string> } {
The backslashes got eaten by the template literal. Fixing that block directly.
C:\Users\HP\hacks\unread\packages\core\src\pipeline\script.ts
The file C:\Users\HP\hacks\unread\packages\core\src\pipeline\script.ts has been updated successfully.
Now request handling: don't count failed requests against the daily limit, and add stale-request recovery.
C:\Users\HP\hacks\unread\packages\core\src\episodes\requests.ts
The file C:\Users\HP\hacks\unread\packages\core\src\episodes\requests.ts has been updated successfully.
C:\Users\HP\hacks\unread\packages\core\src\episodes\requests.ts
The file C:\Users\HP\hacks\unread\packages\core\src\episodes\requests.ts has been updated successfully.
$ cat >> packages/core/src/episodes/requests.ts <<'EOF'
const STALE_MESSAGE = "it took too long to make, so we stopped it";
async function failRequests(db: Database, requestIds: string[], message: string, now: Date): Promise<void> {
if (requestIds.length === 0) return;
const rows = await db
.update(schema.episodeRequests)
.set({ status: "failed", message, finishedAt: now })
.where(inArray(schema.episodeRequests.id, requestIds))
.returning({ episodeId: schema.episodeRequests.episodeId });
const episodeIds = rows.flatMap((r) => (r.episodeId ? [r.episodeId] : []));
if (episodeIds.length) {
await db
.update(schema.episodes)
.set({ status: "failed", error: { message, detail: "stale request" } })
.where(and(inArray(schema.episodes.id, episodeIds), ne(schema.episodes.status, "ready")));
}
}
/**
* Safety net for both runners (hourly maintenance): a request still "running"
* after `maxRunningMs` lost its worker, and one still "pending" after
* `maxPendingMs` was never picked up. Fail them so Home stops showing progress
* and "Make one now" works again.
*/
export async function failStaleRequests(options: { db: Database; now: Date; maxRunningMs?: number; maxPendingMs?: number }): Promise<number> {
const { db, now, maxRunningMs = 2 * 3_600_000, maxPendingMs = 6 * 3_600_000 } = options;
const running = await db
.select({ id: schema.episodeRequests.id })
.from(schema.episodeRequests)
.where(and(eq(schema.episodeRequests.status, "running"), lt(schema.episodeRequests.startedAt, new Date(now.getTime() - maxRunningMs))));
const pending = await db
.select({ id: schema.episodeRequests.id })
.from(schema.episodeRequests)
.where(and(eq(schema.episodeRequests.status, "pending"), lt(schema.episodeRequests.createdAt, new Date(now.getTime() - maxPendingMs))));
const ids = [...running, ...pending].map((r) => r.id);
await failRequests(db, ids, STALE_MESSAGE, now);
return ids.length;
}
/**
* Poller start-up (it's the only runner without Temporal): anything left
* "running" was cut off by a crash or redeploy. Recent ones go back to
* "pending" and resume from their stored step outputs; old ones fail, so a
* request that keeps crashing the worker can't loop forever.
*/
export async function recoverOrphanedRequests(options: { db: Database; now: Date; maxAgeMs?: number }): Promise<{ requeued: number; failed: number }> {
const { db, now, maxAgeMs = 2 * 3_600_000 } = options;
const cutoff = new Date(now.getTime() - maxAgeMs);
const requeued = await db
.update(schema.episodeRequests)
.set({ status: "pending" })
.where(and(eq(schema.episodeRequests.status, "running"), gte(schema.episodeRequests.startedAt, cutoff)))
.returning({ id: schema.episodeRequests.id });
const old = await db
.select({ id: schema.episodeRequests.id })
.from(schema.episodeRequests)
.where(and(eq(schema.episodeRequests.status, "running"), lt(schema.episodeRequests.startedAt, cutoff)));
await failRequests(db, old.map((r) => r.id), STALE_MESSAGE, now);
return { requeued: requeued.length, failed: old.length };
}
EOF
grep -n "export \*\|requests" packages/core/src/episodes/index.ts
1:export * from "./deps";
2:export * from "./make-episode";
3:export * from "./requests";
4:export * from "./steps";
5:export * from "./transcript";
Making startEpisode reuse an existing episode for a requeued request, then adding retries to makeEpisode.
C:\Users\HP\hacks\unread\packages\core\src\episodes\steps.ts
The file C:\Users\HP\hacks\unread\packages\core\src\episodes\steps.ts has been updated successfully.
C:\Users\HP\hacks\unread\packages\core\src\episodes\make-episode.ts
The file C:\Users\HP\hacks\unread\packages\core\src\episodes\make-episode.ts has been updated successfully. (file state is current in your context — no need to Read it back)
$ grep -rn "makeEpisode(" apps packages --include=*.ts | grep -v "node_modules\|function makeEpisode"
apps/worker/src/cli/make-episode.ts:20: const result = await makeEpisode(episodeDeps(), requestId);
apps/worker/src/poller.ts:24: const result = await makeEpisode(episodeDeps(), requestId);
packages/core/test/episode-e2e.test.ts:65: const result = await makeEpisode(deps, requestId);
packages/core/test/episode-e2e.test.ts:109: const second = await makeEpisode({ ...deps, now: () => new Date("2026-10-25T12:00:00Z") }, await requestEpisodeNow({ db, userId: user.id, timezone: "UTC", now: new Date(), dailyLimit: 5 }));
packages/core/test/episode-e2e.test.ts:139: const result = await makeEpisode(deps, await requestEpisodeNow({ db, userId: user.id, timezone: "UTC", now: new Date(), dailyLimit: 5 }));
Per the project rule (3+ params → options object), I'll put retry settings on EpisodeDeps instead of adding a third parameter.
$ node - <<'EOF'
const fs=require('fs');
let p='packages/core/src/episodes/make-episode.ts';let s=fs.readFileSync(p,'utf8');
s=s.replace('export async function makeEpisode(deps: EpisodeDeps, requestId: string, retry: RetryOptions = {}): Promise<MakeEpisodeResult> {\n','export async function makeEpisode(deps: EpisodeDeps, requestId: string): Promise<MakeEpisodeResult> {\n const retry = deps.retry ?? {};\n');
s=s.replace('import type { EpisodeDeps } from "./deps";','import type { EpisodeDeps, RetryOptions } from "./deps";');
s=s.replace(/export interface RetryOptions \{[\s\S]*?\n\}\n\n/,'');
fs.writeFileSync(p,s);
p='packages/core/src/episodes/deps.ts';s=fs.readFileSync(p,'utf8');
s=s.replace(' now?: () => Date;\n}',' now?: () => Date;\n /** Per-step retries in `makeEpisode` (poller/CLI); Temporal has its own retry policy. */\n retry?: RetryOptions;\n}\n\nexport interface RetryOptions {\n /** Tries per step, including the first (default 3). */\n attempts?: number;\n /** Wait before the first retry; doubles each time (default 5 s). */\n initialDelayMs?: number;\n}');
fs.writeFileSync(p,s);
EOF
head -20 packages/core/src/episodes/make-episode.ts; tail -12 packages/core/src/episodes/deps.ts
import { VoiceNotConfiguredError } from "../voice/voice-provider";
import type { EpisodeDeps, RetryOptions } from "./deps";
import { failEpisode, prepareScript, publishEpisode, renderEpisodeCover, startEpisode, voiceEpisode } from "./steps";
export type MakeEpisodeResult =
| { status: "ready"; episodeId: string }
| { status: "skipped"; message: string }
| { status: "failed"; episodeId: string; message: string };
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Retry one step like Temporal would. Steps are idempotent and voice clips are
* cached, so a retry after a brief model/provider/DB error is cheap. Missing
* configuration won't fix itself, so it isn't retried.
*/
export async function withRetry<T>(run: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const { attempts = 3, initialDelayMs = 5000 } = options;
for (let attempt = 1; ; attempt++) {
try {
import type { Storage } from "../storage";
import type { ElevenLabsQuality, VoiceProvider } from "../voice";
export interface EpisodeDeps {
config: CoreConfig;
db: Database;
storage: Storage;
model: () => ModelClient;
embedder: Embedder;
voiceFor: (id: VoiceProviderId, options?: { quality?: ElevenLabsQuality }) => VoiceProvider;
now?: () => Date;
}
Deps edit didn't apply (CRLF likely); using Edit.
C:\Users\HP\hacks\unread\packages\core\src\episodes\deps.ts
The file C:\Users\HP\hacks\unread\packages\core\src\episodes\deps.ts has been updated successfully.
Now the poller and maintenance.
$ node - <<'EOF'
const fs=require('fs');
let p='apps/worker/src/poller.ts';let s=fs.readFileSync(p,'utf8').replace(/\r\n/g,'\n');
s=s.replace('import { claimNextRequest, dispatchDueRequests, makeEpisode } from "@unread/core/episodes";','import { claimNextRequest, dispatchDueRequests, makeEpisode, recoverOrphanedRequests } from "@unread/core/episodes";');
s=s.replace(` console.log("Polling for episode requests (no TEMPORAL_ADDRESS)");
`,` console.log("Polling for episode requests (no TEMPORAL_ADDRESS)");
// We're the only runner, so anything still "running" was cut off by a crash or redeploy.
await recoverOrphanedRequests({ db, now: new Date() })
.then((r) => console.log(JSON.stringify({ event: "recovered", ...r })))
.catch((e) => console.error("recovery failed", e));
`);
s=s.replace(` for (let requestId = await claimNextRequest(db); requestId && !stopping; requestId = await claimNextRequest(db)) {
const started = Date.now();
const result = await makeEpisode(episodeDeps(), requestId);
console.log(JSON.stringify({ event: "episode", requestId, ...result, ms: Date.now() - started }));
}`,` try {
for (let requestId = await claimNextRequest(db); requestId && !stopping; requestId = await claimNextRequest(db)) {
const started = Date.now();
try {
const result = await makeEpisode(episodeDeps(), requestId);
console.log(JSON.stringify({ event: "episode", requestId, ...result, ms: Date.now() - started }));
} catch (error) {
// Failed before an episode existed (e.g. the DB dropped). Leave it "running": the
// next start-up re-queues it, or maintenance fails it after two hours.
console.error(JSON.stringify({ event: "episode_error", requestId, error: String(error) }));
}
}
} catch (error) {
console.error("claim failed", error);
}`);
fs.writeFileSync(p,s);
p='apps/worker/src/maintenance.ts';s=fs.readFileSync(p,'utf8').replace(/\r\n/g,'\n');
s=s.replace('import { purgeRawEmails } from "@unread/core/inbound";','import { failStaleRequests } from "@unread/core/episodes/requests";\nimport { purgeRawEmails } from "@unread/core/inbound";');
s=s.replace('/** Hourly housekeeping: raw email retention (prd §10) and trial lifecycle (promo module). */','/** Hourly housekeeping: raw email retention (prd §10), trial lifecycle (promo module) and stuck episode requests. */');
s=s.replace(' return { purged, ...trials };',' const stale = await failStaleRequests({ db: ctx.db, now: new Date() });\n return { purged, stale, ...trials };');
fs.writeFileSync(p,s);
EOF
git diff --stat; grep -n '"./episodes' packages/core/package.json
warning: in the working copy of 'apps/worker/src/maintenance.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/worker/src/poller.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/src/episodes/make-episode.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/src/episodes/requests.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/src/episodes/step-tracker.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/src/voice/kokoro.ts', LF will be replaced by CRLF the next time Git touches it
apps/worker/src/maintenance.ts | 6 +-
apps/worker/src/poller.ts | 24 ++++--
packages/core/src/audio/assemble.ts | 124 ++++++++++++++++++++---------
packages/core/src/episodes/deps.ts | 9 +++
packages/core/src/episodes/make-episode.ts | 33 ++++++--
packages/core/src/episodes/requests.ts | 73 ++++++++++++++++-
packages/core/src/episodes/step-tracker.ts | 12 ++-
packages/core/src/episodes/steps.ts | 38 +++++++--
packages/core/src/pipeline/script.ts | 24 +++++-
packages/core/src/voice/index.ts | 1 +
packages/core/src/voice/kokoro.ts | 28 ++++++-
11 files changed, 303 insertions(+), 69 deletions(-)
45: "./episodes": "./src/episodes/index.ts",
54: "./episodes/requests": "./src/episodes/requests.ts",
55: "./episodes/transcript": "./src/episodes/transcript.ts",
Core changes done. Typechecking before moving to the web side.
$ pnpm -r --filter @unread/core --filter "./apps/worker" typecheck 2>&1 | tail -30
Scope: 2 of 6 workspace projects
packages/core typecheck$ tsc --noEmit
packages/core typecheck: Done
apps/worker typecheck$ tsc --noEmit
apps/worker typecheck: Done
Now the web side. First loadHome: skipped notices, start time, voice progress, and history.
$ cd /c/Users/HP/hacks/unread/apps/web/src && grep -rn "\.failure\|failure:" --include=*.ts --include=*.tsx .
./app/(app)/today/page.tsx:47: {home.failure ? <FailureBanner message={home.failure} /> : null}
./app/api/status/route.ts:13: { inbound, episode: { inProgress: home.inProgress, failure: home.failure, latestId: home.latest?.id ?? null } },
./features/episodes/queries.ts:37: failure: string | null;
./features/episodes/queries.ts:65: failure: newerThanLatest && request.request.status === "failed" ? (request.request.message ?? "something went wrong") : null,
$ node - <<'EOF'
const fs=require('fs');const p='features/episodes/queries.ts';let s=fs.readFileSync(p,'utf8').replace(/\r\n/g,'\n');
s=s.replace(`export interface HomeData {
latest: PlayerEpisode | null;
inProgress: { status: string; currentStep: string | null } | null;
failure: string | null;
queuedItems: number;
}`,`export interface EpisodeProgress {
status: string;
currentStep: string | null;
/** When the runner picked it up (null while still queued). */
startedAt: string | null;
/** Lines voiced so far, while the voice step runs. */
voice: { done: number; total: number } | null;
}
/** The latest request ended without a new episode: it failed, or was skipped (not enough content, plan limit). */
export interface HomeNotice {
kind: "failed" | "skipped";
message: string;
}
export interface HomeData {
latest: PlayerEpisode | null;
inProgress: EpisodeProgress | null;
notice: HomeNotice | null;
queuedItems: number;
}
const voiceProgressSchema = z.object({ progress: z.object({ done: z.number(), total: z.number() }) });
async function voiceProgress(episodeId: string): Promise<EpisodeProgress["voice"]> {
const [run] = await db()
.select({ timings: schema.stepRuns.timings })
.from(schema.stepRuns)
.where(and(eq(schema.stepRuns.episodeId, episodeId), eq(schema.stepRuns.step, "voice"), eq(schema.stepRuns.status, "running")))
.orderBy(desc(schema.stepRuns.attempt))
.limit(1);
const parsed = voiceProgressSchema.safeParse(run?.timings);
return parsed.success ? parsed.data.progress : null;
}`);
s=s.replace(` return {
latest: latest ? toPlayerEpisode(latest, feedToken) : null,
inProgress: newerThanLatest && active ? { status: request.episode?.status ?? "queued", currentStep: request.episode?.currentStep ?? null } : null,
failure: newerThanLatest && request.request.status === "failed" ? (request.request.message ?? "something went wrong") : null,
queuedItems: queued?.n ?? 0,
};`,` const status = newerThanLatest ? request.request.status : null;
const inProgress: EpisodeProgress | null =
request && active && newerThanLatest
? {
status: request.episode?.status ?? "queued",
currentStep: request.episode?.currentStep ?? null,
startedAt: request.request.startedAt?.toISOString() ?? null,
voice: request.episode?.currentStep === "voice" && request.episode.id ? await voiceProgress(request.episode.id) : null,
}
: null;
return {
latest: latest ? toPlayerEpisode(latest, feedToken) : null,
inProgress,
notice:
request && (status === "failed" || status === "skipped")
? { kind: status, message: request.request.message ?? (status === "failed" ? "something went wrong" : "Nothing new to make an episode from yet.") }
: null,
queuedItems: queued?.n ?? 0,
};`);
s=s.replace(`import { and, count, desc, eq, inArray } from "drizzle-orm";`,`import { and, count, desc, eq, inArray, ne } from "drizzle-orm";
import { z } from "zod";`);
s=s.replace(`export async function recentEpisodes(userId: string, feedToken: string, limit = 10): Promise<PlayerEpisode[]> {
const rows = await db()
.select()
.from(schema.episodes)
.where(and(eq(schema.episodes.userId, userId), inArray(schema.episodes.status, ["ready"])))`,`/** Ready episodes, newest first; \`excludeId\` leaves out the one Home already shows. */
export async function recentEpisodes(options: { userId: string; feedToken: string; limit?: number; excludeId?: string }): Promise<PlayerEpisode[]> {
const { userId, feedToken, limit = 10, excludeId } = options;
const rows = await db()
.select()
.from(schema.episodes)
.where(
and(
eq(schema.episodes.userId, userId),
inArray(schema.episodes.status, ["ready"]),
excludeId ? ne(schema.episodes.id, excludeId) : undefined,
),
)`);
fs.writeFileSync(p,s);
EOF
grep -n "notice\|voiceProgress\|excludeId" features/episodes/queries.ts | head; sed -i 's/failure: home.failure/notice: home.notice/' app/api/status/route.ts
53: notice: HomeNotice | null;
57:const voiceProgressSchema = z.object({ progress: z.object({ done: z.number(), total: z.number() }) });
59:async function voiceProgress(episodeId: string): Promise<EpisodeProgress["voice"]> {
66: const parsed = voiceProgressSchema.safeParse(run?.timings);
98: voice: request.episode?.currentStep === "voice" && request.episode.id ? await voiceProgress(request.episode.id) : null,
104: notice:
112:/** Ready episodes, newest first; `excludeId` leaves out the one Home already shows. */
113:export async function recentEpisodes(options: { userId: string; feedToken: string; limit?: number; excludeId?: string }): Promise<PlayerEpisode[]> {
114: const { userId, feedToken, limit = 10, excludeId } = options;
122: excludeId ? ne(schema.episodes.id, excludeId) : undefined,
$ node - <<'EOF'
const fs=require('fs');const p='features/episodes/queries.ts';let s=fs.readFileSync(p,'utf8');
s=s.replace(` const status = newerThanLatest ? request.request.status : null;`,` const status = newerThanLatest ? request.request.status : null;
// A skip is news for a day; after that it's just noise above the last episode.
const finishedAt = request?.request.finishedAt?.getTime() ?? 0;
const freshSkip = status === "skipped" && Date.now() - finishedAt < 24 * 3_600_000;`);
s=s.replace(`request && (status === "failed" || status === "skipped")`,`request && (status === "failed" || freshSkip)`);
s=s.replace(`? { kind: status, message:`,`? { kind: status === "failed" ? "failed" : "skipped", message:`);
fs.writeFileSync(p,s);
EOF
sed -n 70,115p features/episodes/queries.ts
export async function loadHome(userId: string, feedToken: string): Promise<HomeData> {
const [latest] = await db()
.select()
.from(schema.episodes)
.where(and(eq(schema.episodes.userId, userId), eq(schema.episodes.status, "ready")))
.orderBy(desc(schema.episodes.createdAt))
.limit(1);
const [request] = await db()
.select({ request: schema.episodeRequests, episode: schema.episodes })
.from(schema.episodeRequests)
.leftJoin(schema.episodes, eq(schema.episodes.id, schema.episodeRequests.episodeId))
.where(eq(schema.episodeRequests.userId, userId))
.orderBy(desc(schema.episodeRequests.createdAt))
.limit(1);
const [queued] = await db()
.select({ n: count() })
.from(schema.items)
.where(and(eq(schema.items.userId, userId), eq(schema.items.status, "queued")));
const newerThanLatest = request && (!latest || request.request.createdAt > latest.createdAt);
const active = request && (request.request.status === "pending" || request.request.status === "running");
const status = newerThanLatest ? request.request.status : null;
// A skip is news for a day; after that it's just noise above the last episode.
const finishedAt = request?.request.finishedAt?.getTime() ?? 0;
const freshSkip = status === "skipped" && Date.now() - finishedAt < 24 * 3_600_000;
const inProgress: EpisodeProgress | null =
request && active && newerThanLatest
? {
status: request.episode?.status ?? "queued",
currentStep: request.episode?.currentStep ?? null,
startedAt: request.request.startedAt?.toISOString() ?? null,
voice: request.episode?.currentStep === "voice" && request.episode.id ? await voiceProgress(request.episode.id) : null,
}
: null;
return {
latest: latest ? toPlayerEpisode(latest, feedToken) : null,
inProgress,
notice:
request && (status === "failed" || freshSkip)
? { kind: status === "failed" ? "failed" : "skipped", message: request.request.message ?? (status === "failed" ? "something went wrong" : "Nothing new to make an episode from yet.") }
: null,
queuedItems: queued?.n ?? 0,
};
}
/** Ready episodes, newest first; `excludeId` leaves out the one Home already shows. */
Now the notice banner, in-progress card, and auto-refresh.
C:\Users\HP\hacks\unread\apps\web\src\features\today\failure-banner.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\today\failure-banner.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\apps\web\src\features\today\in-progress-card.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\today\in-progress-card.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\apps\web\src\features\today\step-progress.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\today\step-progress.tsx has been updated successfully.
C:\Users\HP\hacks\unread\apps\web\src\features\today\step-progress.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\today\step-progress.tsx has been updated successfully.
C:\Users\HP\hacks\unread\apps\web\src\features\today\auto-refresh.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\today\auto-refresh.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\apps\web\src\features\player\player-context.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\player\player-context.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
Two fixes: retry must not depend on currentTime (that would defeat the context split), and the event effect shouldn't re-subscribe on every play/pause.
$ node - <<'EOF'
const fs=require('fs');const p='features/player/player-context.tsx';let s=fs.readFileSync(p,'utf8');
const rep=(a,b)=>{if(!s.includes(a))throw new Error('missing '+a.slice(0,60));s=s.replace(a,b);};
rep(` pendingStart.current = audio.currentTime || currentTime;
audio.src = episode.audioUrl;
setStatus("loading");
play();
}, [episode, currentTime, play]);`,` pendingStart.current = audio.currentTime || pendingStart.current;
retried.current = false;
audio.src = episode.audioUrl;
setStatus("loading");
play();
}, [episode, play]);`);
rep(` resumeAfterRetry.current = !audio.paused || playing;`,` resumeAfterRetry.current = !audio.paused;`);
rep(` }, [episode, play, playing]);`,` }, [episode, play]);`);
rep(` // retry/toggle change with currentTime/status; they're read on click, so a stale copy is fine… except
// toggle must see the current status, which it does via its own deps.
`,``);
fs.writeFileSync(p,s);
EOF
echo ok
ok
Now the consumers. First, icons and the play button with loading/error states.
$ cat >> features/player/icons.tsx <<'EOF'
export function SpinnerIcon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
);
}
export function RetryIcon(props: P) {
return (
<svg {...base} {...stroke} {...props}>
<path d="M4 12a8 8 0 1 0 2.3-5.7" />
<path d="M4 4v4h4" />
</svg>
);
}
EOF
grep -n "animate-spin\|motion-reduce\|prefers-reduced" app/globals.css | head
(Bash completed with no output)
C:\Users\HP\hacks\unread\apps\web\src\features\player\controls.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\player\controls.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
Now progress bar, mini player, and player bar.
$ node - <<'EOF'
const fs=require('fs');
const edit=(p,pairs)=>{let s=fs.readFileSync(p,'utf8').replace(/\r\n/g,'\n');for(const [a,b] of pairs){if(!s.includes(a))throw new Error(p+': missing '+a.slice(0,50));s=s.replace(a,b);}fs.writeFileSync(p,s);};
edit('features/player/progress-bar.tsx',[
['import { usePlayer } from "./player-context";','import { usePlayer, usePlayerTime } from "./player-context";'],
[' const { episode, currentTime, duration, seek } = usePlayer();',' const { episode, seek } = usePlayer();\n const { currentTime, duration } = usePlayerTime();'],
]);
edit('features/player/mini-player.tsx',[
['import { usePlayer } from "./player-context";','import { usePlayer, usePlayerTime } from "./player-context";'],
[' const { episode, chapterIndex, setExpanded, currentTime, duration } = usePlayer();',' const { episode, chapterIndex, setExpanded, status } = usePlayer();\n const { currentTime, duration } = usePlayerTime();'],
[` <span className="truncate text-xs text-muted-foreground">{chapter?.origin ?? "Unread"}</span>`,` <span className="truncate text-xs text-muted-foreground">
{status === "error" ? "Couldn't load the audio. Tap play to retry" : (chapter?.origin ?? "Unread")}
</span>`],
]);
edit('features/player/player-bar.tsx',[
['import { TransportControls } from "./controls";','import { PlayerError, TransportControls } from "./controls";'],
[` <ProgressBar className="min-w-0" />
</div>`,` <ProgressBar className="min-w-0" />
</div>
<PlayerError className="pb-2" />`],
]);
EOF
echo ok
ok
Now the transcript with follow/unfollow auto-scroll, shared by chapters and the intro/outro.
C:\Users\HP\hacks\unread\apps\web\src\features\player\transcript-view.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\player\transcript-view.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
$ cd /c/Users/HP/hacks/unread && cat packages/core/src/episodes/transcript.ts
/** Stored on episodes.transcript and used by the player (highlight the current line) and the feed. */
export interface TranscriptLine {
text: string;
startS: number;
sourceIds: string[];
}
export interface TranscriptChapter {
title: string;
origin: string;
startS: number;
endS: number;
lines: TranscriptLine[];
sources: { itemId: string; label: string; title: string; url: string | null; role: "primary" | "also" }[];
}
export interface EpisodeTranscript {
intro: TranscriptLine[];
chapters: TranscriptChapter[];
outro: TranscriptLine[];
}
Now Now Playing: focus moves in, Tab stays inside, focus returns on close, and the intro and outro get their own transcripts.
C:\Users\HP\hacks\unread\apps\web\src\features\player\now-playing.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\features\player\now-playing.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
Now the episode card (resume position, played state) and chapter list (desktop play-from-here).
$ cd /c/Users/HP/hacks/unread/apps/web/src && node - <<'EOF'
const fs=require('fs');
const edit=(p,pairs)=>{let s=fs.readFileSync(p,'utf8').replace(/\r\n/g,'\n');for(const [a,b] of pairs){if(!s.includes(a))throw new Error(p+': missing '+a.slice(0,50));s=s.replace(a,b);}fs.writeFileSync(p,s);};
edit('features/today/episode-card.tsx',[
['import { usePlayer } from "@/features/player/player-context";','import { storedPosition, usePlayer, usePlayerTime, wasPlayed } from "@/features/player/player-context";\nimport { useBrowserValue } from "@/lib/use-browser-value";'],
[` const player = usePlayer();
const isCurrent = player.episode?.id === episode.id;
const playing = isCurrent && player.playing;
const time = isCurrent ? player.currentTime : 0;
const duration = isCurrent ? player.duration : episode.durationS;
const pct = duration ? (time / duration) * 100 : 0;
`,` const player = usePlayer();
const playerTime = usePlayerTime();
const saved = useBrowserValue(() => storedPosition(episode.id), 0);
const played = useBrowserValue(() => wasPlayed(episode.id), false);
const isCurrent = player.episode?.id === episode.id;
const playing = isCurrent && player.playing;
const time = isCurrent ? playerTime.currentTime : saved;
const duration = isCurrent ? playerTime.duration : episode.durationS;
const pct = duration ? (time / duration) * 100 : 0;
const resumable = !isCurrent && saved > 0 && saved < episode.durationS - 5;
`],
[` aria-label={playing ? "Pause episode" : "Play today's episode"}`,` aria-label={playing ? "Pause episode" : resumable ? \`Resume today's episode from \${clock(saved)}\` : "Play today's episode"}`],
[` {trialNote ? <p className="m-0 text-[13px] text-text-2">{trialNote}</p> : null}`,` {resumable ? <p className="m-0 text-[13px] text-text-2">Resume from {clock(saved)}</p> : null}
{!isCurrent && !resumable && played ? <p className="m-0 text-[13px] text-text-2">Played. Tap play to listen again.</p> : null}
{trialNote ? <p className="m-0 text-[13px] text-text-2">{trialNote}</p> : null}`],
]);
edit('features/today/chapter-list.tsx',[
['import { usePlayer } from "@/features/player/player-context";','import { PlayIcon } from "@/features/player/icons";\nimport { usePlayer } from "@/features/player/player-context";'],
[` player.load(episode, { autoplay: true, startAt: chapter.startS });
player.setExpanded(true);
};`,` player.load(episode, { autoplay: true, startAt: chapter.startS });
player.setExpanded(true);
};
/** Desktop: rows open the transcript, so playing a chapter gets its own button. */
const playFrom = (index: number) => {
const chapter = episode.transcript.chapters[index];
if (!chapter) return;
player.focusChapter(index);
player.load(episode, { autoplay: true, startAt: chapter.startS });
};`],
[` <li key={\`\${index}-\${chapter.startS}\`}>
<button`,` <li key={\`\${index}-\${chapter.startS}\`} className="flex items-start">
<button`],
[` "flex w-full cursor-pointer items-start gap-3 border-b border-surface-2 py-3 text-left lg:rounded-lg lg:px-2",`,` "flex min-w-0 flex-1 cursor-pointer items-start gap-3 border-b border-surface-2 py-3 text-left lg:rounded-lg lg:px-2",`],
[` {playing ? <span aria-label="Playing" className="mt-[7px] size-2 flex-none rounded-full bg-brand" /> : null}
</button>`,` {playing ? <span aria-label="Playing" className="mt-[7px] size-2 flex-none rounded-full bg-brand" /> : null}
</button>
<button
type="button"
onClick={() => playFrom(index)}
aria-label={\`Play from \${chapter.title}\`}
className="hidden size-11 flex-none cursor-pointer items-center justify-center self-center text-text-2 hover:text-foreground lg:flex"
>
<PlayIcon className="size-4" />
</button>`],
]);
EOF
echo ok
ok
Now an episode history list for Today.
$ cat features/today/format-date.ts | head -40
export interface EpisodeDateParts {
/** "FRIDAY, 23 OCTOBER" — the Home eyebrow. */
eyebrow: string;
/** "23" — big number on the cover. */
day: string;
/** "OCT · FRI" — cover caption. */
caption: string;
/** "2026-10-23" — the episode's local date key. */
isoDate: string;
}
function part(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
return parts.find((p) => p.type === type)?.value ?? "";
}
/** Date labels for the user's local day, in their timezone. */
export function episodeDateParts(now: Date, timeZone: string): EpisodeDateParts {
const long = new Intl.DateTimeFormat("en-GB", {
timeZone,
weekday: "long",
day: "numeric",
month: "long",
}).formatToParts(now);
const short = new Intl.DateTimeFormat("en-GB", {
timeZone,
weekday: "short",
month: "short",
}).formatToParts(now);
const iso = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
return {
eyebrow: `${part(long, "weekday")}, ${part(long, "day")} ${part(long, "month")}`.toUpperCase(),
day: part(long, "day"),
caption: `${part(short, "month")} · ${part(short, "weekday")}`.toUpperCase(),
isoDate: iso,
C:\Users\HP\hacks\unread\apps\web\src\features\today\episode-history.tsx
File created successfully at: C:\Users\HP\hacks\unread\apps\web\src\features\today\episode-history.tsx (file state is current in your context — no need to Read it back)
Now wiring it all into the Today page.
$ node - <<'EOF'
const fs=require('fs');
const p='app/(app)/today/page.tsx';let s=fs.readFileSync(p,'utf8').replace(/\r\n/g,'\n');
const rep=(a,b)=>{if(!s.includes(a))throw new Error('missing '+a.slice(0,60));s=s.replace(a,b);};
rep('import { loadHome } from "@/features/episodes/queries";','import { loadHome, recentEpisodes } from "@/features/episodes/queries";');
rep('import { EpisodeCard } from "@/features/today/episode-card";','import { EpisodeCard } from "@/features/today/episode-card";\nimport { EpisodeHistory } from "@/features/today/episode-history";');
rep(` const [home, ctx] = await Promise.all([loadHome(user.id, user.feedToken), planContextFor(user.id)]);
const seed = \`\${user.id}:\${date.isoDate}\`;
const latest = home.latest;
`,` const [home, ctx] = await Promise.all([loadHome(user.id, user.feedToken), planContextFor(user.id)]);
const seed = \`\${user.id}:\${date.isoDate}\`;
const latest = home.latest;
const earlier = latest ? await recentEpisodes({ userId: user.id, feedToken: user.feedToken, limit: 7, excludeId: latest.id }) : [];
const historyDate = new Intl.DateTimeFormat("en-GB", { timeZone: user.timezone, weekday: "short", day: "numeric", month: "short" });
`);
rep(` {home.failure ? <FailureBanner message={home.failure} /> : null}
{home.inProgress ? (
<InProgressCard seed={seed} date={date} currentStep={home.inProgress.currentStep} />`,` {home.notice ? <FailureBanner notice={home.notice} /> : null}
{home.inProgress ? (
<InProgressCard seed={seed} date={date} progress={home.inProgress} now={now.getTime()} />`);
rep(` {latest && !home.inProgress ? <ChapterList episode={latest} /> : <ChapterListEmpty />}`,` {latest && !home.inProgress ? <ChapterList episode={latest} /> : <ChapterListEmpty />}
<EpisodeHistory entries={earlier.map((episode) => ({ episode, dateLabel: historyDate.format(new Date(episode.createdAt)) }))} />`);
fs.writeFileSync(p,s);
EOF
cd /c/Users/HP/hacks/unread && pnpm --filter "./apps/web" typecheck 2>&1 | tail -30
> @unread/web@0.0.0 typecheck C:\Users\HP\hacks\unread\apps\web
> tsc --noEmit
When an episode is in progress, the latest card is hidden, so history should include it.
C:\Users\HP\hacks\unread\apps\web\src\app\(app)\today\page.tsx
The file C:\Users\HP\hacks\unread\apps\web\src\app\(app)\today\page.tsx has been updated successfully.
$ pnpm lint 2>&1 | tail -40
> unread@ lint C:\Users\HP\hacks\unread
> pnpm -r lint
Scope: 5 of 6 workspace projects
packages/db lint$ tsc --noEmit
packages/db lint: Done
packages/core lint$ tsc --noEmit
packages/core lint: Done
apps/inbound-worker lint$ tsc --noEmit
apps/web lint$ eslint .
apps/worker lint$ tsc --noEmit
apps/inbound-worker lint: Done
apps/worker lint: Done
apps/web lint: Done
Lint and typecheck pass. Now tests: unit tests for the new pure helpers, and DB tests for the request recovery. Checking the test helpers and what's available locally.
$ sed -n 1,60p packages/core/test/helpers.ts; which ffmpeg; docker ps --format '{{.Names}} {{.Status}}' 2>&1 | head -3; sed -n 1,40p packages/core/test/promo.test.ts
import { readFileSync } from "node:fs";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDb, newFeedToken, schema } from "@unread/db";
import { createLocalStorage } from "../src/storage";
export function fixture(name: string): Uint8Array {
return new Uint8Array(readFileSync(join(import.meta.dirname, "fixtures", name)));
}
export const testDatabaseUrl = process.env.TEST_DATABASE_URL;
export function testDb() {
if (!testDatabaseUrl) throw new Error("TEST_DATABASE_URL not set");
return createDb({ url: testDatabaseUrl, max: 2 });
}
export function tempStorage() {
return createLocalStorage(mkdtempSync(join(tmpdir(), "unread-test-")));
}
export async function createTestUser(
db: ReturnType<typeof testDb>,
options: { alias: string; email: string; plan?: "free" | "plus" },
) {
const [user] = await db
.insert(schema.users)
.values({
auth0Sub: `test|${options.alias}|${Math.random()}`,
email: options.email,
feedToken: newFeedToken(),
inboundAlias: options.alias,
plan: options.plan ?? "free",
})
.returning();
if (!user) throw new Error("no user");
await db.insert(schema.verifiedSenders).values({ userId: user.id, email: options.email, verifiedAt: new Date() });
return user;
}
const ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
export function randomAlias(): string {
return Array.from({ length: 10 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join("");
}
/c/ProgramData/chocolatey/bin/ffmpeg
unread-postgres Up 23 hours
import { schema } from "@unread/db";
import { eq } from "drizzle-orm";
import { afterAll, describe, expect, it } from "vitest";
import { loadConfig } from "../src/config";
import type { MailMessage } from "../src/mail";
import { redeemPromoCode, runTrialLifecycle } from "../src/promo";
import { createTestUser, randomAlias, testDatabaseUrl, testDb } from "./helpers";
describe.skipIf(!testDatabaseUrl)("promo codes", () => {
const db = testDb();
const now = new Date("2026-10-24T12:00:00Z");
afterAll(async () => {
await db.$client.end();
});
const newCode = async (values: Partial<typeof schema.promoCodes.$inferInsert> = {}) => {
const code = `HACK-${randomAlias().toUpperCase()}`;
await db.insert(schema.promoCodes).values({ code, trialDays: 14, ...values });
return code;
};
it("requires a verified email and is case-insensitive", async () => {
const user = await createTestUser(db, { alias: randomAlias(), email: "a@example.com" });
const code = await newCode();
expect(await redeemPromoCode({ db, userId: user.id, code, emailVerified: false, now })).toEqual({ ok: false, error: "email_unverified" });
const ok = await redeemPromoCode({ db, userId: user.id, code: ` ${code.toLowerCase()} `, emailVerified: true, now });
expect(ok).toMatchObject({ ok: true, episodeLimit: 3 });
expect(await redeemPromoCode({ db, userId: user.id, code, emailVerified: true, now })).toEqual({ ok: false, error: "already_redeemed" });
expect(await redeemPromoCode({ db, userId: user.id, code: await newCode(), emailVerified: true, now })).toEqual({ ok: false, error: "trial_active" });
});
it("never over-redeems under concurrency", async () => {
const code = await newCode({ maxRedemptions: 3 });
const users = await Promise.all(Array.from({ length: 8 }, () => createTestUser(db, { alias: randomAlias(), email: "c@example.com" })));
const results = await Promise.all(users.map((u) => redeemPromoCode({ db, userId: u.id, code, emailVerified: true, now })));
expect(results.filter((r) => r.ok)).toHaveLength(3);
expect(results.filter((r) => !r.ok).every((r) => !r.ok && r.error === "exhausted")).toBe(true);
const [row] = await db.select().from(schema.promoCodes).where(eq(schema.promoCodes.code, code));
expect(row?.redeemedCount).toBe(3);
});
Writing tests. ffmpeg and the Docker Postgres are both available locally, so I can run the integration tests too.
C:\Users\HP\hacks\unread\packages\core\test\robustness-unit.test.ts
File created successfully at: C:\Users\HP\hacks\unread\packages\core\test\robustness-unit.test.ts (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\packages\core\test\assemble.test.ts
File created successfully at: C:\Users\HP\hacks\unread\packages\core\test\assemble.test.ts (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\packages\core\test\requests.test.ts
File created successfully at: C:\Users\HP\hacks\unread\packages\core\test\requests.test.ts (file state is current in your context — no need to Read it back)
$ TEST_DATABASE_URL=[REDACTED] pnpm --filter @unread/core exec vitest run test/robustness-unit.test.ts test/assemble.test.ts test/requests.test.ts 2>&1 | tail -40
RUN v5.0.5 C:/Users/HP/hacks/unread/packages/core
Test Files 3 passed (3)
Tests 15 passed (15)
Start at 23:00:07
Duration 78.95s (import 97%, tests 2%, transform 1%)
All 15 new tests pass against real ffmpeg and Postgres. Now the full suite, including the end-to-end Kokoro + ffmpeg episode test, since I changed both.
$ ls "$TMP/unread-models" "$LOCALAPPDATA/unread" 2>&1 | head; df -h /c | tail -1
ls: cannot access 'C:\Users\HP\AppData\Local\Temp/unread-models': No such file or directory
ls: cannot access 'C:\Users\HP\AppData\Local/unread': No such file or directory
C: 238G 231G 7.2G 97% /c
No cached models here, so the end-to-end test will download EmbeddingGemma and Kokoro (a few hundred MB; you have 7 GB free). Running the full suite in the background.
$ TEST_DATABASE_URL=[REDACTED] RUN_EMBEDDING_TESTS=1 pnpm test 2>&1 | tail -60
Command running in background with ID: betvba2j8. Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\betvba2j8.output. You will be notified when it completes — do not poll that file or sleep while waiting. Read the file only if the user asks for progress, or if you need a line it prints before you can go on, such as a server's ready line or port.
While that runs, I'll check whether the repo has docs that describe these behaviours and need updating.
$ ls docs docs/knowledge-graph 2>/dev/null | head -30; grep -rln "loudnorm\|single-pass\|AutoRefresh\|failure banner\|FailureBanner\|daily limit\|stuck" docs --include=*.md | head
docs:
decisions
deploy.md
design
prd-unread.md
project-memory.md
screenshots
docs/decisions/0006-voices.md
$ grep -n "loudnorm\|LUFS\|ffmpeg" docs/decisions/0006-voices.md docs/decisions/0008*.md
docs/decisions/0006-voices.md:11:- **Assembly:** ffmpeg, one pass. Lines are joined with 0.28 s pauses, chapters with 0.7 s pauses plus an optional transition SFX, an optional intro and outro sting, `loudnorm=I=-16:TP=-1.5`, MP3 at 96 kbps mono. Chapter and per-line start times come from exact sample counts, so the transcript can highlight the current line.
docs/decisions/0008-workflow-and-hosting.md:18:- **unread-worker:** a Docker background worker (`apps/worker/Dockerfile`: Debian with ffmpeg, plus the ONNX models baked in by `warm-models`). Use Standard or above (Kokoro is CPU-bound; EmbeddingGemma needs about 1 GB of RAM).
C:\Users\HP\hacks\unread\docs\decisions\0006-voices.md
The file C:\Users\HP\hacks\unread\docs\decisions\0006-voices.md has been updated successfully.
$ grep -n "Last updated\|^## Open items\|^7\. " docs/project-memory.md
3:Living state of the project for new sessions. Update it when milestones, decisions or blockers change. Last updated 2026-10-24.
54:## Open items / next steps
61:7. PRD inconsistencies resolved by assumption: Free voices 4 (§8) not 3 (§9); Free cap = 4 episodes/month; `/inbound/email` writes `items` (not `saved_items`).
$ node - <<'EOF'
const fs=require('fs');const p='docs/project-memory.md';let s=fs.readFileSync(p,'utf8');
s=s.replace('Last updated 2026-10-24.','Last updated 2026-10-25.');
s=s.replace(/(7\. PRD inconsistencies resolved by assumption:[^\n]*)(\r?\n)/,(m,a,nl)=>`${a}${nl}8. Robustness pass (2026-10-25, branch \`fix/robustness-ux\`): poller re-queues orphaned "running" requests on start-up and retries each step 3× (\`deps.retry\`); hourly maintenance fails requests running > 2 h or pending > 6 h (\`failStaleRequests\`); failed manual requests don't count toward the daily limit; Home shows skipped runs, voice progress ("23 of 61 lines") and a "taking longer than usual" note after 15 min. Player: load/buffer/error states with one silent retry, rate persisted (\`defaultPlaybackRate\`), resume applied on \`loadedmetadata\`, Media Session position state, time in its own context (\`usePlayerTime\`). Kokoro lines are split before synthesis (kokoro-js truncates); two-pass loudnorm; MP3 encoded to a temp file. Not yet verified in a real browser against a deployed worker.${nl}`);
fs.writeFileSync(p,s);
EOF
grep -n "^8\. " docs/project-memory.md | cut -c1-120
62:8. Robustness pass (2026-10-25, branch `fix/robustness-ux`): poller re-queues orphaned "running" requests on start-up
I'd like to check the UI in a real browser too. Checking whether Playwright is available on this machine.
$ ls node_modules/.pnpm | grep -i "^playwright\|^@playwright" | head; ls "$LOCALAPPDATA/ms-playwright" 2>/dev/null | head; grep -n "AUTH0\|STORAGE\|DATABASE_URL\|PG_URL" apps/web/.env.local | sed 's/=.*/=…/'
chromium-1234
chromium_headless_shell-1234
ffmpeg-1011
winldd-1007
1:STORAGE_DRIVER=…
2:STORAGE_LOCAL_DIR=…
4:# Note: a PG_URL already set in your shell wins over .env files.
7:PG_URL=…
8:# DATABASE_URL is a fallback, used only when PG_URL is empty.
13:AUTH0_DOMAIN=…
14:AUTH0_CLIENT_ID=…
15:AUTH0_CLIENT_SECRET=…
16:AUTH0_SECRET=…
Playwright's browsers are installed. Checking the local dev DB for the smoke user so I can drive the real UI.
$ docker exec unread-postgres psql -U unread -d unread -Atc "select id, auth0_sub, onboarded_at is not null, feed_token from users limit 5; select count(*), string_agg(status::text, ',') from episodes;" 2>&1
0|
The dev DB is empty, so I'll seed a smoke user with two real episodes (tone audio built by the new assembler), plus a skipped request to show the new notice.
$ grep -n "\"brief\"\|tsx\|\"worker\"" package.json apps/worker/package.json | head; sed -n 1,30p apps/web/.env.local | grep -v "SECRET\|KEY\|TOKEN" | sed 's/\(PG_URL=postgres:\/\/[^:]*:\)[^@]*/\1***/'
package.json:16: "brief": "pnpm --filter @unread/worker brief",
package.json:18: "worker": "pnpm --filter @unread/worker start",
apps/worker/package.json:7: "brief": "tsx src/cli/brief.ts",
apps/worker/package.json:11: "start": "tsx src/main.ts",
apps/worker/package.json:12: "make-episode": "tsx src/cli/make-episode.ts",
apps/worker/package.json:13: "dispatch": "tsx src/cli/dispatch.ts",
apps/worker/package.json:14: "promo": "tsx src/cli/promo.ts",
apps/worker/package.json:15: "warm-models": "tsx src/cli/warm-models.ts",
apps/worker/package.json:16: "generate-assets": "tsx src/cli/generate-assets.ts"
apps/worker/package.json:31: "tsx": "4.23.15",
STORAGE_DRIVER=local
STORAGE_LOCAL_DIR=C:/Users/HP/AppData/Local/unread/storage
# Copy to apps/web/.env.local (web) and export for the worker. See docs/deploy.md.
# Note: a PG_URL already set in your shell wins over .env files.
# ── Core ─────────────────────────────────────────────────────────────
PG_URL="[REDACTED]" # Tiger Cloud URL with ?sslmode=require in production
# DATABASE_URL is a fallback, used only when PG_URL is empty.
APP_BASE_URL=http://localhost:3000
# ── Auth0 (ADR 0002) ─────────────────────────────────────────────────
APP_BASE_URL=http://localhost:3000
AUTH0_DOMAIN=dev-3zr43gj3dgj3ldub.us.auth0.com
AUTH0_CLIENT_ID=M85RSYcWPFilIHFPRaU1la8TjIm75F6o
# ── Storage (ADR 0009): local files in dev, S3/R2 in production ──────
S3_BUCKET=unread-audios
S3_REGION=auto
S3_ENDPOINT=https://e04065e81b061b2b99a3f70e497e62e9.r2.cloudflarestorage.com/unread-audios # e.g. https://<account>.r2.cloudflarestorage.com
# ── Inbound email (ADR 0004) ─────────────────────────────────────────
INBOUND_EMAIL_DOMAIN=in.getunread.site
INBOUND_TRUST_UPSTREAM_AUTH=false
# ── Gemma (ADR 0005) ─────────────────────────────────────────────────
C:\Users\HP\hacks\unread\apps\worker\src\cli\_seed-smoke.ts
File created successfully at: C:\Users\HP\hacks\unread\apps\worker\src\cli\_seed-smoke.ts (file state is current in your context — no need to Read it back)
$ grep -n '"./cover"\|"./audio"\|"./storage"' packages/core/package.json; grep -n "coverSvg(" -A3 packages/core/src/cover/*.ts | head -8; grep -n "timezone\|inboundAlias" packages/db/src/schema.ts | head -4
36: "./storage": "./src/storage/index.ts",
47: "./audio": "./src/audio/index.ts",
48: "./cover": "./src/cover/index.ts",
packages/core/src/cover/render.ts:50:export function coverSvg(input: CoverInput): string {
packages/core/src/cover/render.ts-51- const random = seeded(input.seed);
packages/core/src/cover/render.ts-52- const bars = Array.from({ length: 16 }, (_, i) => {
packages/core/src/cover/render.ts-53- const accent = i < 10;
84: /** IANA timezone, e.g. Africa/Lagos. */
85: timezone: text().notNull().default("UTC"),
109: inboundAlias: text().notNull(),
120: uniqueIndex("users_inbound_alias_key").on(t.inboundAlias),
$ cd /c/Users/HP/hacks/unread/apps/worker && PG_URL=[REDACTED] STORAGE_LOCAL_DIR="C:/Users/HP/AppData/Local/unread/storage" npx tsx src/cli/_seed-smoke.ts 2>&1 | tail -5
{
"userId": "01M5THEWA3V4HSBV3CM9CEKWSJ",
"feedToken": "rWdIxRyw_j2-RsolFdtm0GgWT7IPT4Ym"
}
Seeded. Starting the dev server against the local DB.
$ cd /c/Users/HP/hacks/unread/apps/web && PG_URL=[REDACTED] DATABASE_URL= npx next dev -p 3100 2>&1
Command running in background with ID: b0y4wglkf. Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b0y4wglkf.output. You will be notified when it completes — do not poll that file or sleep while waiting. Read the file only if the user asks for progress, or if you need a line it prints before you can go on, such as a server's ready line or port.
Session cwd remains C:\Users\HP\hacks\unread\apps\worker; directory changes made by the backgrounded command do not apply to subsequent commands.
$ npm ls -g --depth=0 2>/dev/null | grep -i playwright; ls "$APPDATA/npm/node_modules" 2>/dev/null | grep -i playwright; ls "$LOCALAPPDATA/ms-playwright/chromium-1234"
DEPENDENCIES_VALIDATED
INSTALLATION_COMPLETE
chrome-win64
No Playwright package installed, only its browser. I'll put playwright-core in the scratchpad and point it at that Chromium.
$ S="C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/ba41f625-708a-49bd-b6ab-59a8dd39e531/scratchpad/pw"; mkdir -p "$S" && cd "$S" && npm init -y >/dev/null && npm i playwright-core@1.48.2 --silent 2>&1 | tail -2; ls node_modules | head; cat C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/ba41f625-708a-49bd-b6ab-59a8dd39e531/tasks/b0y4wglkf.output | tail -5
Command did not complete within its 180s timeout and was moved to the background (ID: bc7ojz1jv). Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\bc7ojz1jv.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains C:\Users\HP\hacks\unread\apps\worker; directory changes made by the backgrounded command do not apply to subsequent commands.
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b0y4wglkf.output
1 ▲ Next.js 16.4.3 (Turbopack)
2 - Local: http://localhost:3100
3 - Network: http://172.20.64.1:3100
4 - Environments: .env.local
5 ✓ Ready in 10.1s
6 ✓ Running next.config.ts took 1188ms
7 ⨯ Another next dev server is already running.
8
9 - Local: http://localhost:3000
10 - PID: 46416
11 - Dir: C:\Users\HP\hacks\unread\apps\web
12 - Log: .next\dev\logs\next-development.log
13
14 You can access the existing server at http://localhost:3000,
15 or run taskkill /PID 46416 /F to stop it and start a new one.
16 [?25h
17
18 [exited with code 1]
19
$ cd /c/Users/HP/hacks/unread/apps/web && PG_URL=[REDACTED] DATABASE_URL= npx next build 2>&1 | tail -40
✓ Compiled successfully in 43s
Running TypeScript ...
Finished TypeScript in 84s ...
Collecting page data using 7 workers ...
Generating static pages using 7 workers (0/17) ...
Generating static pages using 7 workers (4/17)
Generating static pages using 7 workers (8/17)
Generating static pages using 7 workers (12/17)
✓ Generating static pages using 7 workers (17/17) in 2.5s
Finalizing page optimization ...
Route (app)
┌ ƒ /
├ ○ /_not-found
├ ƒ /admin
├ ƒ /api/listen
├ ƒ /api/status
├ ƒ /audio/[token]/[file]
├ ƒ /billing/checkout
├ ƒ /billing/portal
├ ƒ /billing/return
├ ƒ /billing/webhooks/bachs
├ ƒ /feed/[token]
├ ƒ /feed/[token]/chapters/[file]
├ ƒ /feed/[token]/cover/[file]
├ ○ /icon.svg
├ ƒ /inbound/email
├ ƒ /onboarding
├ ○ /privacy
├ ƒ /settings
├ ƒ /sources
├ ƒ /today
└ ƒ /voice-preview/[voiceId]
ƒ Proxy (Middleware)
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
$ grep -E "Test Files|Tests |skipped|FAIL|✓|×" "C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/ba41f625-708a-49bd-b6ab-59a8dd39e531/tasks/betvba2j8.output" | tail -30
packages/db test: Test Files 1 passed (1)
packages/db test: Tests 3 passed (3)
packages/core test: × merges the OpenAI story across three newsletters into one cited chapter 17781ms
packages/core test: × makes a Kokoro episode with chapters and sources, and never repeats a heard story 294781ms
packages/core test: × a promo trial uses the Studio voice, counts episodes, ends after the limit and keeps unused items 15261ms
packages/core test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
packages/core test: FAIL test/episode-e2e.test.ts > makeEpisode end to end (real EmbeddingGemma + Kokoro + ffmpeg) > makes a Kokoro episode with chapters and sources, and never repeats a heard story
packages/core test: FAIL test/episode-e2e.test.ts > makeEpisode end to end (real EmbeddingGemma + Kokoro + ffmpeg) > a promo trial uses the Studio voice, counts episodes, ends after the limit and keeps unused items
packages/core test: FAIL test/pipeline-acceptance.test.ts > pipeline steps 1–6 with real EmbeddingGemma > merges the OpenAI story across three newsletters into one cited chapter
packages/core test: Test Files 2 failed | 11 passed (13)
packages/core test: Tests 3 failed | 55 passed (58)
ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @unread/core@0.0.0 test: `vitest run`
Three end-to-end tests failed. Looking at why before going further.
$ grep -n -A25 "FAIL test/" "C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/ba41f625-708a-49bd-b6ab-59a8dd39e531/tasks/betvba2j8.output" | grep -v "^\-\-$" | head -90
20:packages/core test: FAIL test/episode-e2e.test.ts > makeEpisode end to end (real EmbeddingGemma + Kokoro + ffmpeg) > makes a Kokoro episode with chapters and sources, and never repeats a heard story
21-packages/core test: AssertionError: {"status":"failed","episodeId":"01M5THBX6E55PVRN4NGHAV8ZH7","message":"terminated"}: expected 'failed' to be 'ready' // Object.is equality
22-packages/core test: Expected: "ready"
23-packages/core test: Received: "failed"
24-packages/core test: ❯ test/episode-e2e.test.ts:66:51
25-packages/core test: 64| const requestId = await requestEpisodeNow({ db, userId: user.id, t…
26-packages/core test: 65| const result = await makeEpisode(deps, requestId);
27-packages/core test: 66| expect(result.status, JSON.stringify(result)).toBe("ready");
28-packages/core test: | ^
29-packages/core test: 67| if (result.status !== "ready") return;
30-packages/core test: 68|
31-packages/core test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯
32:packages/core test: FAIL test/episode-e2e.test.ts > makeEpisode end to end (real EmbeddingGemma + Kokoro + ffmpeg) > a promo trial uses the Studio voice, counts episodes, ends after the limit and keeps unused items
33-packages/core test: AssertionError: expected 'failed' to be 'ready' // Object.is equality
34-packages/core test: Expected: "ready"
35-packages/core test: Received: "failed"
36-packages/core test: ❯ test/episode-e2e.test.ts:140:27
37-packages/core test: 138|
38-packages/core test: 139| const result = await makeEpisode(deps, await requestEpisodeNow({ d…
39-packages/core test: 140| expect(result.status).toBe("ready");
40-packages/core test: | ^
41-packages/core test: 141| if (result.status !== "ready") return;
42-packages/core test: 142| const [episode] = await db.select().from(schema.episodes).where(eq…
43-packages/core test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯
44:packages/core test: FAIL test/pipeline-acceptance.test.ts > pipeline steps 1–6 with real EmbeddingGemma > merges the OpenAI story across three newsletters into one cited chapter
45-packages/core test: TypeError: fetch failed
46-packages/core test: Caused by: ConnectTimeoutError: Connect Timeout Error (attempted addresses: 15.236.78.196:443, 13.37.0.65:443, 15.188.188.74:443, 13.36.122.243:443, 35.181.67.163:443, 15.236.34.207:443, 13.39.241.58:443, 13.36.183.231:443, 15.237.164.118:443, 13.37.65.55:443, 15.236.85.177:443, 15.236.197.3:443, timeout: 10000ms)
47-packages/core test: ❯ onConnectTimeout ../../node_modules/.pnpm/undici@7.30.1/node_modules/undici/lib/core/util.js:891:19
48-packages/core test: ❯ Immediate._onImmediate ../../node_modules/.pnpm/undici@7.30.1/node_modules/undici/lib/core/util.js:842:35
49-packages/core test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
50-packages/core test: Serialized Error: { code: 'UND_ERR_CONNECT_TIMEOUT' }
51-packages/core test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯
52-packages/core test: Test Files 2 failed | 11 passed (13)
53-packages/core test: Tests 3 failed | 55 passed (58)
54-packages/core test: Start at 23:01:49
55-packages/core test: Duration 335.47s (tests 72%, import 27%, transform 1%)
56-packages/core test: Failed
57-C:\Users\HP\hacks\unread\packages\core:
58- ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @unread/core@0.0.0 test: `vitest run`
59-Exit status 1
60- ELIFECYCLE Test failed. See above for more details.
61-
62-[exited with code 0]
$ cd /c/Users/HP/hacks/unread && TEST_DATABASE_URL=[REDACTED] RUN_EMBEDDING_TESTS=1 pnpm --filter @unread/core exec vitest run test/episode-e2e.test.ts test/pipeline-acceptance.test.ts 2>&1 | grep -E "✓|×|Test Files|Tests |Error|terminated" | head -30
Command running in background with ID: b29b3j16v. Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b29b3j16v.output. You will be notified when it completes — do not poll that file or sleep while waiting. Read the file only if the user asks for progress, or if you need a line it prints before you can go on, such as a server's ready line or port.
Session cwd remains C:\Users\HP\hacks\unread\apps\web; directory changes made by the backgrounded command do not apply to subsequent commands.
$ PG_URL=[REDACTED] DATABASE_URL= APP_BASE_URL=http://localhost:3100 npx next start -p 3100 2>&1
Command running in background with ID: bcfyiupp1. Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\bcfyiupp1.output. You will be notified when it completes — do not poll that file or sleep while waiting. Read the file only if the user asks for progress, or if you need a line it prints before you can go on, such as a server's ready line or port.
C:\Users\HP\hacks\unread\apps\web\_mint-cookie.mjs
File created successfully at: C:\Users\HP\hacks\unread\apps\web\_mint-cookie.mjs (file state is current in your context — no need to Read it back)
$ node _mint-cookie.mjs > "C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/ba41f625-708a-49bd-b6ab-59a8dd39e531/scratchpad/pw/cookie.txt" && wc -c "C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/ba41f625-708a-49bd-b6ab-59a8dd39e531/scratchpad/pw/cookie.txt"; rm _mint-cookie.mjs
359 C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/ba41f625-708a-49bd-b6ab-59a8dd39e531/scratchpad/pw/cookie.txt
Now the browser check script: phone and desktop screenshots, playback, Now Playing focus, rate persistence, resume after reload, and a forced audio failure.
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\scratchpad\pw\check.mjs
File created successfully at: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\scratchpad\pw\check.mjs (file state is current in your context — no need to Read it back)
$ cd "C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/ba41f625-708a-49bd-b6ab-59a8dd39e531/scratchpad/pw" && curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3100/ && node check.mjs 2>&1 | tail -40
200
focus returned to: Open player
history episode {
paused: false,
t: 2.9,
rate: 1.5,
src: '01M5THEWAWR3VR6EY9GDYV3SJ4.mp3',
err: null
}
history row after reload: Unread for 2026-10-24: 3 stories from The Hustle, Morning Brew and TLDR
Sat 24 Oct · 1 min · Resume from 0:21
resumed at {
paused: false,
t: 2.9,
rate: 1.5,
src: '01M5THEY1YPDAFER7EQ0RMVRGF.mp3',
err: null
}
audio requests: 2 state {
paused: true,
t: 0,
rate: 1.5,
src: '01M5THEY1YPDAFER7EQ0RMVRGF.mp3',
err: 4
}
retry button: 1 mini text: 1
after manual retry {
paused: false,
t: 6.6,
rate: 1.5,
src: '01M5THEY1YPDAFER7EQ0RMVRGF.mp3',
err: null
}
page errors: []
desktop chapter 2 play {
paused: false,
t: 21.5,
rate: 1,
src: '01M5THEY1YPDAFER7EQ0RMVRGF.mp3',
err: null
}
Shell cwd was reset to C:\Users\HP\hacks\unread\apps\web
Mostly good, but my resume check clicked the wrong button: /Play .* episode$/ also matches "Play today's episode". Tightening that selector and re-running to see the full output.
$ cd "C:/Users/HP/AppData/Local/Temp/claude/c--Users-HP-hacks-unread/ba41f625-708a-49bd-b6ab-59a8dd39e531/scratchpad/pw" && sed -i 's#getByRole("button", { name: /Play .\* episode\$/ })#locator('"'"'section[aria-labelledby="earlier-episodes"] button'"'"')#g' check.mjs && grep -n "earlier-episodes\"\] button" check.mjs | head && node check.mjs 2>&1 | head -40
49: await p.locator('section[aria-labelledby="earlier-episodes"] button').first().click();
63: await p.locator('section[aria-labelledby="earlier-episodes"] button').first().click();
url http://localhost:3100/today
notice: 1 history: 1
after play {
paused: false,
t: 2.3,
rate: 1,
src: '01M5THEY1YPDAFER7EQ0RMVRGF.mp3',
err: null
}
focused in dialog: Close player
focus after 12 tabs still in dialog: true
rate after 2 clicks {
paused: false,
t: 2.9,
rate: 1.5,
src: '01M5THEY1YPDAFER7EQ0RMVRGF.mp3',
err: null
}
focus returned to: Open player
history episode {
paused: false,
t: 2.8,
rate: 1.5,
src: '01M5THEWAWR3VR6EY9GDYV3SJ4.mp3',
err: null
}
history row after reload: Unread for 2026-10-24: 3 stories from The Hustle, Morning Brew and TLDR
Sat 24 Oct · 1 min · Resume from 0:21
resumed at {
paused: false,
t: 23.9,
rate: 1.5,
src: '01M5THEWAWR3VR6EY9GDYV3SJ4.mp3',
err: null
}
audio requests: 2 state {
paused: true,
t: 0,
rate: 1.5,
src: '01M5THEY1YPDAFER7EQ0RMVRGF.mp3',
Shell cwd was reset to C:\Users\HP\hacks\unread\apps\web
[This command modified 1 file you've previously read: ..\..\..\..\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\scratchpad\pw\check.mjs. Call Read before editing.]
Everything checks out in the browser, including resume (21 s, then 23.9 s after 2 s at 1.5×). Now the screenshots.
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\scratchpad\pw\shot-phone-today.png
[Image: original 780x2446, displayed at 638x2000. Multiply coordinates by 1.22 to map to original image.]
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\scratchpad\pw\shot-phone-error.png
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\scratchpad\pw\shot-desktop-today.png
{
"task_id": "bcfyiupp1"
}
{
"message": "Successfully stopped task: bcfyiupp1 (PG_URL=[REDACTED] DATABASE_URL= APP_BASE_URL=http://localhost:3100 npx next start -p 3100 2>&1)",
"task_id": "bcfyiupp1",
"task_type": "local_bash",
"command": "PG_URL=[REDACTED] DATABASE_URL= APP_BASE_URL=http://localhost:3100 npx next start -p 3100 2>&1"
}
$ cd /c/Users/HP/hacks/unread && rm apps/worker/src/cli/_seed-smoke.ts && git status --short
M apps/web/src/app/(app)/today/page.tsx
M apps/web/src/app/api/status/route.ts
M apps/web/src/features/episodes/queries.ts
M apps/web/src/features/player/controls.tsx
M apps/web/src/features/player/icons.tsx
M apps/web/src/features/player/mini-player.tsx
M apps/web/src/features/player/now-playing.tsx
M apps/web/src/features/player/player-bar.tsx
M apps/web/src/features/player/player-context.tsx
M apps/web/src/features/player/progress-bar.tsx
M apps/web/src/features/player/transcript-view.tsx
M apps/web/src/features/today/auto-refresh.tsx
M apps/web/src/features/today/chapter-list.tsx
M apps/web/src/features/today/episode-card.tsx
M apps/web/src/features/today/failure-banner.tsx
M apps/web/src/features/today/in-progress-card.tsx
M apps/web/src/features/today/step-progress.tsx
M apps/worker/src/maintenance.ts
M apps/worker/src/poller.ts
M docs/decisions/0006-voices.md
M docs/project-memory.md
M packages/core/src/audio/assemble.ts
M packages/core/src/episodes/deps.ts
M packages/core/src/episodes/make-episode.ts
M packages/core/src/episodes/requests.ts
M packages/core/src/episodes/step-tracker.ts
M packages/core/src/episodes/steps.ts
M packages/core/src/pipeline/script.ts
M packages/core/src/voice/index.ts
M packages/core/src/voice/kokoro.ts
?? apps/web/src/features/today/episode-history.tsx
?? packages/core/src/voice/split-text.ts
?? packages/core/test/assemble.test.ts
?? packages/core/test/requests.test.ts
?? packages/core/test/robustness-unit.test.ts
Committing to the branch while the model-dependent tests re-run.
$ git add -A && git commit -q -F - <<'EOF'
fix: make episode runs and playback robust; UX polish on Today
Worker
- Poller re-queues orphaned "running" requests on start-up, retries each
step (3 tries, backoff) and survives errors outside an episode.
- Hourly maintenance fails requests stuck running (>2 h) or pending (>6 h).
- Failed "make one now" requests no longer count toward the daily limit.
- Kokoro: split long lines before synthesis (kokoro-js truncates at ~510
phonemes); retry the model load after a failed download.
- Assembly: two-pass linear loudnorm; MP3 written to a temp file so the
Info/Xing header is correct; stop starting clips after the first failure.
- Chapter titles: strip "(6 minute read)"/"Sponsored" clutter; no "?." when
read aloud. Voice step reports lines voiced for the progress UI.
Web
- Home shows skipped runs (not enough content, plan limit), voice progress,
"waiting to start", and a "taking longer than usual" note after 15 min.
Auto-refresh pauses while the tab is hidden.
- Player: loading/buffering/error states with one silent retry and a
manual retry; playback rate persisted and kept across episodes; resume
applied on loadedmetadata (Safari); position saved on pause/hide;
explicit lock-screen play/pause and position state; time updates in their
own context so the app doesn't re-render 4x a second.
- Transcript follows playback until the reader scrolls, with "Back to the
current line"; intro and outro transcripts in Now playing.
- Now playing: focus moves in, Tab is trapped, focus returns on close.
- Earlier episodes list with resume/played state; resume label on the
episode card; desktop play-from-chapter buttons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git log --oneline -1
warning: in the working copy of 'apps/web/src/app/(app)/today/page.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/app/api/status/route.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/episodes/queries.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/player/controls.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/player/icons.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/player/mini-player.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/player/now-playing.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/player/player-bar.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/player/player-context.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/player/progress-bar.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/player/transcript-view.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/today/auto-refresh.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/today/chapter-list.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/today/episode-card.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/today/failure-banner.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/today/in-progress-card.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/worker/src/maintenance.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/worker/src/poller.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/project-memory.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/src/episodes/make-episode.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/src/episodes/requests.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/src/episodes/step-tracker.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/src/voice/kokoro.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'apps/web/src/features/today/episode-history.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/src/voice/split-text.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/test/assemble.test.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/test/requests.test.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'packages/core/test/robustness-unit.test.ts', LF will be replaced by CRLF the next time Git touches it
73fa6a7 fix: make episode runs and playback robust; UX polish on Today
$ git push -u origin fix/robustness-ux 2>&1 | tail -3
branch 'fix/robustness-ux' set up to track 'origin/fix/robustness-ux'.
To https://github.com/codewithveek/unread.git
* [new branch] fix/robustness-ux -> fix/robustness-ux
Pushed. The re-run of the two model-dependent test files is still going. I'll report its result when it finishes.
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b29b3j16v.output
<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>
I've fixed everything from the review and pushed it to the fix/robustness-ux branch on GitHub. Typecheck, lint and next build all pass. Of the 58 core tests, 55 pass. The 3 failures are the end-to-end tests that need real models, and they failed because the Hugging Face model downloads timed out on your machine, not because of the code. I'm re-running those 3 now and will report back.
Checked in a real browser: I added a test user and two episodes with generated tone audio to your local Docker database, then drove the app in headless Chromium on port 3100 (phone and desktop sizes). It ran against the local database only, never Tiger. Your dev server on port 3000 was left alone. Things that worked:
- The "No new episode this time" notice and the new "Earlier episodes" list show.
- Now playing puts focus on the close button, keeps Tab inside the player, and gives focus back on close.
- Speed 1.5× is kept when switching to another episode.
- An episode stopped at 0:21 shows "Resume from 0:21" after a reload and resumes there.
- When every audio request fails, the player retries once, then shows "Couldn't load the audio" with a retry button. Retrying after the network came back worked.
- On desktop, the play button on a chapter starts it at the right time.
Stuck or confusing states
- Stuck episodes: when the worker starts, any request it was in the middle of goes back in the queue and continues from where it stopped. The hourly maintenance job marks a request as failed after 2 hours running or 6 hours waiting.
- Retries without Temporal: each step is retried 3 times with increasing waits, except when the Studio voice isn't set up.
- "Make one now": a failed attempt no longer uses up the daily allowance, so "Try again" works for Free users.
- Today page:
- It now says why no episode was made (not enough new content, or a plan limit).
- It shows "Waiting to start…" before the run begins, and "23 of 61 lines" while recording the voice.
- After 15 minutes it says the run is taking longer than usual.
- It stops refreshing while the tab is hidden.
Audio rendering
- Kokoro cutting off lines: I confirmed in the kokoro-js source that it silently cuts long text. Each line is now split into chunks of up to 220 characters and joined with a short pause.
- Volume: loudness is now measured first and then applied evenly across the episode. Pure silence still encodes.
- MP3 header: the MP3 is now written to a temporary file, so its duration and seeking information is correct. A test checks this.
- Smaller fixes:
- No more clips are started after one fails.
- Titles like "(6 minute read)" and "Sponsored" are removed.
- A title ending in "?" no longer gets an extra full stop.
Player
- Loading and errors: the play button shows a spinner while loading or buffering, and a retry icon after a failure.
- Speed and resume: speed is remembered between episodes and visits, and resuming at the saved position now works in Safari.
- Saving position: your place is saved when you pause or switch away.
- Lock screen: play and pause are separate actions, and the lock screen shows the right time and scrubber.
- Performance: the time display updates on its own, so the whole app no longer re-renders 4 times a second.
- Transcript: it stops auto-scrolling when you scroll yourself and shows "Back to the current line". The intro and outro now have transcripts too.
- Today page: older episodes are listed with resume and played state, and the episode card shows a resume label.
New tests cover each of these: line splitting, title cleanup, the retry helper, encoding with real ffmpeg, and the stuck-request handling against Postgres. I also updated the voices decision record (0006) and docs/project-memory.md.
Things you should know:
- Rotate the Tiger database password. I tried to hide it when printing apps/web/.env.local, but the hiding didn't work on the quoted PG_URL, so the Tiger Cloud password appeared in my tool output. It went nowhere else.
- That file points at Tiger, not your local database. A dev server started without overriding PG_URL talks to Tiger.
- The local dev database now has a test user (auth0|u0-smoke) with two test episodes. I left it there for future UI checks.
- Not tested against a deployed worker or a real iPhone. Safari resume and the lock-screen controls only have desktop-Chromium coverage so far.
<task-notification>
<task-id>b29b3j16v</task-id>
<tool-use-id>toolu_01T81DujTNHR7egSNMPLPftv</tool-use-id>
<output-file>[REDACTED]\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b29b3j16v.output</output-file>
<status>completed</status>
<summary>Background command "Re-run model-dependent e2e tests" completed (exit code 0)</summary>
</task-notification>
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b29b3j16v.output
1 × makes a Kokoro episode with chapters and sources, and never repeats a heard story 81003ms
2 × a promo trial uses the Studio voice, counts episodes, ends after the limit and keeps unused items 15287ms
3 × merges the OpenAI story across three newsletters into one cited chapter 180010ms
4 ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
5 AssertionError: {"status":"failed","episodeId":"01M5THV4D7AAQ4DJFGF80X7V04","message":"Deserialize tensor model.layers.18.attn.k_proj.MatMul.weight_quantized failed.tensorprotoutils.cc:1080 onnxruntime::utils::GetExtDataFromTensorProto External initializer: model.layers.18.attn.k_proj.MatMul.weight_quantized offset: 279052288 size to read: 196608 given file_length: 7515468 are out of bounds or can not be read in full."}: expected 'failed' to be 'ready' // Object.is equality
6 AssertionError: expected 'failed' to be 'ready' // Object.is equality
7 Error: Test timed out in 180000ms.
8 Test Files 2 failed (2)
9 Tests 3 failed (3)
10
11 [exited with code 0]
12
$ for d in "$TMP/unread-models" "$LOCALAPPDATA/unread" node_modules/.pnpm/@huggingface+transformers*/node_modules/@huggingface/transformers/.cache; do [ -e "$d" ] && find "$d" -name "*.onnx*" -size +1k -exec ls -la {} \; 2>/dev/null; done | head; grep -rn "MODEL_CACHE_DIR" packages/core/test/*.ts packages/core/src/config.ts | head -5
-rw-r--r-- 1 HP 197609 567874 Oct 25 23:11 C:\Users\HP\AppData\Local\Temp/unread-models/onnx-community/embeddinggemma-300m-ONNX/onnx/model_quantized.onnx
-rw-r--r-- 1 HP 197609 96316748 Oct 25 23:13 C:\Users\HP\AppData\Local\Temp/unread-models/onnx-community/embeddinggemma-300m-ONNX/onnx/model_quantized.onnx_data
packages/core/src/config.ts:37: MODEL_CACHE_DIR: z.string().default(join(tmpdir(), "unread-models")),
$ rm -rf "$TMP/unread-models/onnx-community/embeddinggemma-300m-ONNX" && pnpm --filter @unread/worker warm-models 2>&1 | tail -5 && find "$TMP/unread-models" -name "*.onnx_data" -exec ls -la {} \; && TEST_DATABASE_URL=[REDACTED] RUN_EMBEDDING_TESTS=1 pnpm --filter @unread/core exec vitest run test/episode-e2e.test.ts test/pipeline-acceptance.test.ts 2>&1 | grep -E "✓|×|Test Files|Tests |Error" | head -20
Command running in background with ID: b6odq4zhz. Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b6odq4zhz.output. You will be notified when it completes — do not poll that file or sleep while waiting. Read the file only if the user asks for progress, or if you need a line it prints before you can go on, such as a server's ready line or port.
The 3 end-to-end tests still fail on the re-run. The cause is a broken model download, not the code. The first download timed out partway, leaving a truncated EmbeddingGemma file (96 MB of a much larger file), and later runs tried to load it.
I've deleted the partial file. In the background I'm downloading the models again with the repo's warm-models command, then re-running those 3 tests. I'll post the result when it finishes. If your network times out on Hugging Face again, they'll fail the same way.
Nothing else has changed: the 55 other tests pass, along with typecheck, lint, next build and the browser check. The code is pushed on fix/robustness-ux.
<task-notification>
<task-id>b6odq4zhz</task-id>
<tool-use-id>toolu_019Yn1qdBXqJY3HXfv51NUCV</tool-use-id>
<output-file>[REDACTED]\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b6odq4zhz.output</output-file>
<status>completed</status>
<summary>Background command "Clear partial model, warm models, rerun e2e" completed (exit code 0)</summary>
</task-notification>
API Error: Connection dropped (ECONNRESET)
contnue
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b6odq4zhz.output
1 at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:661:26)
2 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
3 C:\Users\HP\hacks\unread\apps\worker:
4 ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @unread/worker@0.0.0 warm-models: `tsx src/cli/warm-models.ts`
5 Exit status 1
6 × merges the OpenAI story across three newsletters into one cited chapter 170677ms
7 × makes a Kokoro episode with chapters and sources, and never repeats a heard story 600026ms
8 × a promo trial uses the Studio voice, counts episodes, ends after the limit and keeps unused items 600017ms
9 ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
10 Error: Test timed out in 600000ms.
11 Error: Test timed out in 600000ms.
12 TypeError: terminated
13 ❯ Request.onError ../../node_modules/.pnpm/undici@7.30.1/node_modules/undici/lib/core/request.js:364:27
14 Caused by: Error: read ECONNRESET
15 Serialized Error: { errno: -4077, code: 'ECONNRESET', syscall: 'read' }
16 ⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯
17 TypeError: fetch failed
18 Caused by: Error: read ECONNRESET
19 Serialized Error: { errno: -4077, code: 'ECONNRESET', syscall: 'read' }
20 TypeError: terminated
21 ❯ Object.onError node:internal/deps/undici/undici:12950:38
22 ❯ LegacyHandlerWrapper.onResponseError ../../node_modules/.pnpm/undici@8.12.3/node_modules/undici/lib/dispatcher/dispatcher1-wrapper.js:54:19
23 ❯ Request.onResponseError ../../node_modules/.pnpm/undici@8.12.3/node_modules/undici/lib/core/request.js:451:44
24 Caused by: Error: read ECONNRESET
25 Serialized Error: { errno: -4077, code: 'ECONNRESET', syscall: 'read' }
26
27 [exited with code 0]
28
$ find "$TMP/unread-models" -type f -exec ls -la {} \; | awk '{print $5, $NF}'; cat apps/worker/src/cli/warm-models.ts | head -30; for m in onnx-community/embeddinggemma-300m-ONNX onnx-community/Kokoro-82M-v1.0-ONNX; do curl -s --max-time 30 "https://huggingface.co/api/models/$m/tree/main/onnx" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{for(const f of JSON.parse(s))if(/quantized|q8/.test(f.path))console.log('$m',f.path,f.size)}catch(e){console.log('$m','api failed',s.slice(0,100))}})"; done; grep -n "KOKORO_MODEL\|EMBEDDING_MODEL" packages/core/src/config.ts
1765 C:\Users\HP\AppData\Local\Temp/unread-models/onnx-community/embeddinggemma-300m-ONNX/config.json
133 C:\Users\HP\AppData\Local\Temp/unread-models/onnx-community/embeddinggemma-300m-ONNX/generation_config.json
20323312 C:\Users\HP\AppData\Local\Temp/unread-models/onnx-community/embeddinggemma-300m-ONNX/tokenizer.json
1156830 C:\Users\HP\AppData\Local\Temp/unread-models/onnx-community/embeddinggemma-300m-ONNX/tokenizer_config.json
import { workerContext } from "../context";
import { voiceProviderFor } from "@unread/core/voice";
/** Download and load EmbeddingGemma and Kokoro once (run at image build, so cold starts are fast). */
async function main() {
const ctx = workerContext();
const started = Date.now();
await ctx.embedder.embed([{ title: "warm up", text: "Loading EmbeddingGemma." }]);
await voiceProviderFor("kokoro", ctx.config).synthesize({ text: "Warming up.", voiceId: "af_heart" });
console.log(`models ready in ${Math.round((Date.now() - started) / 1000)}s at ${ctx.config.env.MODEL_CACHE_DIR}`);
process.exit(0);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
onnx-community/embeddinggemma-300m-ONNX onnx/model_quantized.onnx 567874
onnx-community/embeddinggemma-300m-ONNX onnx/model_quantized.onnx_data 308890624
onnx-community/Kokoro-82M-v1.0-ONNX onnx/model_q8f16.onnx 86033585
onnx-community/Kokoro-82M-v1.0-ONNX onnx/model_quantized.onnx 92361116
35: EMBEDDING_MODEL: z.string().default("onnx-community/embeddinggemma-300m-ONNX"),
38: KOKORO_MODEL: z.string().default("onnx-community/Kokoro-82M-v1.0-ONNX"),
Files needed: EmbeddingGemma's .onnx + .onnx_data (309 MB), and Kokoro's configs + model_quantized.onnx (92 MB). Downloading with resume-on-failure in the background.
$ C="$TMP/unread-models"; get() { m=$1; f=$2; size=$3; out="$C/$m/$f"; mkdir -p "$(dirname "$out")"; for i in $(seq 1 40); do cur=$(stat -c %s "$out" 2>/dev/null || echo 0); [ -n "$size" ] && [ "$cur" = "$size" ] && break; curl -sSL --retry 5 --retry-all-errors --connect-timeout 20 -C - -o "$out" "https://huggingface.co/$m/resolve/main/$f" && [ -z "$size" ] && break; sleep 2; done; echo "$f $(stat -c %s "$out") / ${size:-?}"; }
get onnx-community/embeddinggemma-300m-ONNX onnx/model_quantized.onnx 567874
get onnx-community/embeddinggemma-300m-ONNX onnx/model_quantized.onnx_data 308890624
for f in config.json tokenizer.json tokenizer_config.json; do get onnx-community/Kokoro-82M-v1.0-ONNX $f; done
get onnx-community/Kokoro-82M-v1.0-ONNX onnx/model_quantized.onnx 92361116
Command running in background with ID: b6ls0irs5. Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b6ls0irs5.output. You will be notified when it completes — do not poll that file or sleep while waiting. Read the file only if the user asks for progress, or if you need a line it prints before you can go on, such as a server's ready line or port.
The model files are downloading in the background with curl, which picks up where it left off when the connection drops. EmbeddingGemma is about 309 MB and Kokoro about 92 MB. When that finishes I'll check the sizes, then re-run the 3 end-to-end tests. Everything else is unchanged: the code is pushed on fix/robustness-ux and all the other checks pass.
<task-notification>
<task-id>b6ls0irs5</task-id>
<tool-use-id>toolu_013k9WivsfXrs6Ygb2enGJRA</tool-use-id>
<output-file>[REDACTED]\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b6ls0irs5.output</output-file>
<status>completed</status>
<summary>Background command "Download model files with resumable curl" completed (exit code 0)</summary>
</task-notification>
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\b6ls0irs5.output
1 onnx/model_quantized.onnx 567874 / 567874
2 onnx/model_quantized.onnx_data 308890624 / 308890624
3 config.json 44 / ?
4 tokenizer.json 3497 / ?
5 tokenizer_config.json 113 / ?
6 onnx/model_quantized.onnx 92361116 / 92361116
7
8 [exited with code 0]
9
All sizes match. The small Kokoro configs look right, but I'll check they aren't error pages, then run the tests.
$ cat "$TMP/unread-models/onnx-community/Kokoro-82M-v1.0-ONNX/config.json"; echo; head -c 120 "$TMP/unread-models/onnx-community/Kokoro-82M-v1.0-ONNX/tokenizer_config.json"; echo; cd /c/Users/HP/hacks/unread && TEST_DATABASE_URL=[REDACTED] RUN_EMBEDDING_TESTS=1 pnpm --filter @unread/core exec vitest run test/episode-e2e.test.ts test/pipeline-acceptance.test.ts 2>&1 | grep -E "✓|×|Test Files|Tests |Error|expected" | head -20
Command did not complete within its 600s timeout and was moved to the background (ID: bqmjfpdyi). Output is being written to: C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\bqmjfpdyi.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains C:\Users\HP\hacks\unread; directory changes made by the backgrounded command do not apply to subsequent commands.
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\bqmjfpdyi.output
1 {
2 "model_type": "style_text_to_speech_2"
3 }
4 {
5 "model_max_length": 512,
6 "pad_token": "$",
7 "tokenizer_class": "PreTrainedTokenizer",
8 "unk_token": "$"
9 }
10
Both configs are valid. The tokenizer's model_max_length: 512 also confirms the Kokoro truncation limit that the line splitting works around. The models are now fully cached, and the end-to-end tests are running real Kokoro voice generation on your CPU. They passed the 10-minute mark and are still going, so I'll report when they finish.
<task-notification>
<task-id>bqmjfpdyi</task-id>
<tool-use-id>toolu_01EwL9x6Yhh6BjWt9rYEXXhx</tool-use-id>
<output-file>[REDACTED]\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\bqmjfpdyi.output</output-file>
<status>completed</status>
<summary>Background command "Verify configs and rerun e2e tests" completed (exit code 0)</summary>
</task-notification>
C:\Users\HP\AppData\Local\Temp\claude\c--Users-HP-hacks-unread\ba41f625-708a-49bd-b6ab-59a8dd39e531\tasks\bqmjfpdyi.output
1 {
2 "model_type": "style_text_to_speech_2"
3 }
4 {
5 "model_max_length": 512,
6 "pad_token": "$",
7 "tokenizer_class": "PreTrainedTokenizer",
8 "unk_token": "$"
9 }
10 Test Files 2 passed (2)
11 Tests 3 passed (3)
12
13 [exited with code 0]
14
All 3 end-to-end tests now pass with the real models. I'll add the download workaround to the project notes so the next session doesn't hit the same problem.
C:\Users\HP\hacks\unread\docs\project-memory.md
The file C:\Users\HP\hacks\unread\docs\project-memory.md has been updated successfully.
$ git commit -qam "docs: note resumable model download workaround for the Windows machine
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" && git push -q 2>&1 | tail -2; git log --oneline -2
remote: https://github.com/codewithveek/unread/security/dependabot
remote:
b3a3494 docs: note resumable model download workaround for the Windows machine
73fa6a7 fix: make episode runs and playback robust; UX polish on Today
All 58 core tests now pass, including the 3 end-to-end tests that make complete episodes with real EmbeddingGemma, Kokoro and ffmpeg. Those episodes go through the new line splitting, two-pass loudness and file-based MP3 encoding.
The earlier failures were caused by your network, not the code. Hugging Face kept dropping the connection mid-download, which left a truncated model file. I downloaded the model files with curl, resuming each time the connection dropped, into the same cache folder the app uses.
Both commits are pushed to fix/robustness-ux: the fixes, plus a short note in docs/project-memory.md on how to recover from a broken model download.
Still open:
- Database password: the Tiger Cloud database password showed up in my tool output earlier, so I'd still rotate it.
- Not yet tested on real devices: resume in Safari and the lock-screen controls on an iPhone, and the stuck-request recovery against a deployed worker.
- Merging: the branch isn't merged into main. Say if you want me to open a pull request or merge it.
can our project work offline? and/or run on a laptop?
I'll check a few specifics in the code before answering.
$ grep -n "GEMINI\|OPENROUTER\|BASE_URL\|_URL\b\|MODEL" packages/core/src/config.ts | head -30; ls packages/core/src/model; grep -rn "next/font" apps/web/src | head -3; grep -rn "baseURL\|baseUrl\|https://" packages/core/src/model/*.ts | head -10
11: DATABASE_URL: z.url(),
12: APP_BASE_URL: z.url().default("http://localhost:3000"),
27: GEMINI_API_KEY: optional,
28: GEMMA_MODEL: z.string().default("gemma-4-31b-it"),
30: GEMINI_API_STYLE: z.enum(["native", "openai"]).default("native"),
31: OPENROUTER_API_KEY: optional,
32: OPENROUTER_MODEL: z.string().default("google/gemma-4-31b-it"),
33: MODEL_TIMEOUT_MS: z.coerce.number().int().positive().default(90_000),
35: EMBEDDING_MODEL: z.string().default("onnx-community/embeddinggemma-300m-ONNX"),
37: MODEL_CACHE_DIR: z.string().default(join(tmpdir(), "unread-models")),
38: KOKORO_MODEL: z.string().default("onnx-community/Kokoro-82M-v1.0-ONNX"),
45: BACHS_API_URL: z.url().default("https://sandbox-api.bachs.io"),
96:/** `PG_URL` (Tiger Data) wins over `DATABASE_URL`, which stays as a fallback for local setups. */
98: const env = envSchema.parse({ ...source, DATABASE_URL: source.PG_URL || source.DATABASE_URL });
fallback.ts
gemini-native.ts
index.ts
model-client.ts
openai-compatible.ts
structured.ts
packages/core/src/model/gemini-native.ts:17: baseUrl?: string;
packages/core/src/model/gemini-native.ts:23: const base = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
packages/core/src/model/index.ts:22: baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
packages/core/src/model/index.ts:32: baseUrl: "https://openrouter.ai/api/v1",
packages/core/src/model/openai-compatible.ts:12: baseUrl: string;
packages/core/src/model/openai-compatible.ts:26: const response = await fetchImpl(`${options.baseUrl.replace(/\/$/, "")}/chat/completions`, {
$ sed -n 1,50p packages/core/src/model/index.ts; grep -rn "fonts.googleapis\|@fontsource\|font-family" apps/web/src/app/layout.tsx apps/web/src/app/globals.css | head -5
import type { CoreConfig } from "../config";
import { type UsageLog, withFallback } from "./fallback";
import { createGeminiNativeClient } from "./gemini-native";
import type { ModelClient } from "./model-client";
import { createOpenAICompatibleClient } from "./openai-compatible";
export * from "./fallback";
export * from "./gemini-native";
export * from "./model-client";
export * from "./openai-compatible";
export * from "./structured";
export class ModelNotConfiguredError extends Error {}
/** Gemini API (Gemma) first, OpenRouter (Gemma) as fallback; either alone works. */
export function modelClientFromConfig(config: CoreConfig, usage?: UsageLog): ModelClient {
const env = config.env;
const gemini = env.GEMINI_API_KEY
? env.GEMINI_API_STYLE === "openai"
? createOpenAICompatibleClient({
name: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
apiKey: env.GEMINI_API_KEY,
model: env.GEMMA_MODEL,
timeoutMs: env.MODEL_TIMEOUT_MS,
})
: createGeminiNativeClient({ apiKey: env.GEMINI_API_KEY, model: env.GEMMA_MODEL, timeoutMs: env.MODEL_TIMEOUT_MS })
: undefined;
const openrouter = env.OPENROUTER_API_KEY
? createOpenAICompatibleClient({
name: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
apiKey: env.OPENROUTER_API_KEY,
model: env.OPENROUTER_MODEL,
timeoutMs: env.MODEL_TIMEOUT_MS,
extraHeaders: { "HTTP-Referer": env.APP_BASE_URL, "X-Title": "Unread" },
})
: undefined;
const primary = gemini ?? openrouter;
if (!primary) throw new ModelNotConfiguredError("Set GEMINI_API_KEY or OPENROUTER_API_KEY");
return withFallback({ primary, fallback: gemini ? openrouter : undefined, usage });
}
Yes, it runs on a laptop. That's how it runs now on your Windows machine: Postgres in Docker, files stored on disk, the web app via pnpm dev, and the worker without Temporal. The end-to-end tests we just ran made complete episodes there.
Fully offline: not today. Three things still need the internet, and one of them is the core feature.
Works offline (once the models are downloaded)
| Part | Why |
|---|---|
| Postgres, local storage | Docker and the local disk |
| EmbeddingGemma (merging stories that several newsletters covered) | Local ONNX model, about 400 MB cached in %TEMP%/unread-models
|
| Kokoro voice | Local ONNX model; the voice files ship inside the npm package |
| ffmpeg assembly, covers | Local |
| Web player, transcript, chapters, feed served from localhost | Local |
| Worker without Temporal | No external service needed |
Needs the internet
| Part | Service | Can it be avoided? |
|---|---|---|
| Writing the script | Gemma 4 31B via the Gemini API or OpenRouter. The two web addresses are hard-coded in model/index.ts. | This is the real blocker. Without it no episode can be made. It's fixable, see below. |
| Signing in | Auth0's hosted login page | Only with a local dev sign-in |
| Receiving newsletters | Cloudflare Email Routing → a public /inbound/email address |
Offline you'd need another way to add them, such as importing .eml files |
| Saved links, news | Fetching web pages, SerpApi | Optional features |
| Studio voices, billing, email | ElevenLabs, Bachs, Resend | Optional; Free uses Kokoro |
On a laptop with internet
It works fully. Two extras are needed:
- A public address for incoming email. Cloudflare has to reach /inbound/email, so you'd need a tunnel such as cloudflared tunnel.
- The same tunnel for podcast apps, so they can fetch the feed. The web player works on localhost without it.
Laptop resources:
- Memory: about 1 GB for EmbeddingGemma.
- Disk: about 400 MB of models, plus Docker.
- Time: voice generation on the CPU takes a few minutes for a 5–10 minute episode.
Making it work offline
These are small changes:
1. Local model option. The model client already speaks the standard OpenAI chat API, so a new LOCAL_MODEL_BASE_URL setting could point it at Ollama or llama.cpp running Gemma on the laptop. The catch is that the 31B model is too big for most laptops. A quantized Gemma 12B (or 4B) would run, but scripts would be weaker and more of them would fail the JSON format or the fact check. The fact check would drop bad lines rather than let them through, so episodes stay honest but may come out shorter.
2. Dev sign-in. A local-only login that bypasses Auth0. It would be switched off in production.
3. Import newsletters. A CLI or upload that feeds .eml files through the same processing as forwarded email.
Do you want me to build any of these? For the hackathon demo, only #1 matters if you want to show it offline. #2 and #3 only matter for using it with no network at all.
btw, add an MIT Lincense
$ git status --short | head; git branch --show-current; ls | grep -i -E "licen|readme"; grep -n "\"license\"" package.json apps/*/package.json packages/*/package.json; grep -n -i "licen" README.md 2>/dev/null | head
fix/robustness-ux
README.md
No license anywhere yet. Checking the README and root package.json so I can reference it in both.
$ head -8 package.json; echo ---; tail -15 README.md; grep -n "^## " README.md
{
"name": "unread",
"private": true,
"packageManager": "pnpm@10.28.0",
"engines": {
"node": ">=22"
},
"scripts": {
---
PG_URL=… pnpm db:migrate
pnpm dev # web on :3000
PG_URL=… pnpm worker # episodes (polls without Temporal)
PG_URL=… pnpm brief --user <id> # script JSON only (needs GEMINI_API_KEY or OPENROUTER_API_KEY)
PG_URL=… pnpm make-episode --user <id> # full episode in-process
```
## Tests
```sh
pnpm typecheck && pnpm lint && pnpm test
# integration (Postgres): TEST_DATABASE_URL=postgres://…/unread_test pnpm test
# + real EmbeddingGemma/Kokoro/ffmpeg: RUN_EMBEDDING_TESTS=1
# + Temporal time-skipping: RUN_TEMPORAL_TESTS=1
```
7:## How it works
20:## Repo
30:## Status
42:## Run locally
56:## Tests
C:\Users\HP\hacks\unread\LICENSE
File created successfully at: C:\Users\HP\hacks\unread\LICENSE (file state is current in your context — no need to Read it back)
C:\Users\HP\hacks\unread\package.json
The file C:\Users\HP\hacks\unread\package.json has been updated successfully.
$ printf '\n## License\n\n[MIT](LICENSE) © 2026 Lucky Victory. Model weights and third-party services keep their own terms (Kokoro-82M and EmbeddingGemma are downloaded at runtime, not included here).\n' >> README.md && tail -4 README.md
## License
[MIT](LICENSE) © 2026 Lucky Victory. Model weights and third-party services keep their own terms (Kokoro-82M and EmbeddingGemma are downloaded at runtime, not included here).
The worker's Docker image bakes the models in, so "not included here" is only true for the repo. Making that precise, then committing.
$ sed -i 's/(Kokoro-82M and EmbeddingGemma are downloaded at runtime, not included here)/(Kokoro-82M is Apache-2.0 and EmbeddingGemma is under the Gemma terms; both are downloaded at runtime or image build, not stored in this repo)/' README.md && tail -1 README.md && git add LICENSE package.json README.md && git commit -qm "chore: add MIT license
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" && git push -q 2>&1 | grep -v "^remote" ; git log --oneline -1
[MIT](LICENSE) © 2026 Lucky Victory. Model weights and third-party services keep their own terms (Kokoro-82M is Apache-2.0 and EmbeddingGemma is under the Gemma terms; both are downloaded at runtime or image build, not stored in this repo).
warning: in the working copy of 'README.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'LICENSE', LF will be replaced by CRLF the next time Git touches it
5d5dd58 chore: add MIT license
I added the MIT license and pushed it:
-
LICENSE: the standard MIT text, "Copyright (c) 2026 Lucky Victory". -
package.json:"license": "MIT"in the root file. -
README.md: a new License section. It notes that the two voice and text models keep their own licenses: Kokoro-82M is Apache-2.0 and EmbeddingGemma uses Google's Gemma terms. Neither model is stored in the repo.
It's committed on fix/robustness-ux, the branch we've been working on, so it reaches main when that branch is merged. If you want the license on main right away instead, I can cherry-pick it.
merge to main n push
$ git status --short | head -5; git fetch -q origin && git log --oneline origin/main -3 && git log --oneline main -1 && git rev-list --count main..origin/main
183aca6 refactor(users): unify user loading and email trust logic on sign-in
3c7a8e6 feat(landing): set default price for Plus subscription
222fca3 chore(db): replace drizzle-kit migrate with custom migration script
183aca6 refactor(users): unify user loading and email trust logic on sign-in
0
Clean tree, and main hasn't moved since I branched, so this is a fast-forward.
$ git checkout -q main && git merge --ff-only fix/robustness-ux 2>&1 | tail -2 && git push origin main 2>&1 | grep -v "^remote" ; git log --oneline -4
create mode 100644 packages/core/test/requests.test.ts
create mode 100644 packages/core/test/robustness-unit.test.ts
To https://github.com/codewithveek/unread.git
183aca6..5d5dd58 main -> main
5d5dd58 chore: add MIT license
b3a3494 docs: note resumable model download workaround for the Windows machine
73fa6a7 fix: make episode runs and playback robust; UX polish on Today
183aca6 refactor(users): unify user loading and email trust logic on sign-in
I merged fix/robustness-ux into main and pushed it to GitHub. No one else had pushed to main in the meantime, so it went straight on with no merge commit or conflicts.
main now has the three new commits:
- 73fa6a7: the robustness and UX fixes.
- b3a3494: the note on recovering from broken model downloads.
- 5d5dd58: the MIT license.
I left the fix/robustness-ux branch in place, locally and on GitHub. Say if you want it deleted.
btw, let's talk about this,
since emails are often unique, can we use the users' email starting (before the @) to generate the forwarding email for them? while also keeping the option to regenerate
I'll look at how aliases are made and rotated now before weighing in.
$ grep -rn "inboundAlias\|newInboundAlias\|alias" --include=*.ts --include=*.tsx packages/db/src apps/web/src packages/core/src/inbound | grep -v test | grep -iv "sourceLabel" | head -40
packages/db/src/ids.ts:20:export function newInboundAlias(length = 10): string {
packages/db/src/ids.ts:22: let alias = "";
packages/db/src/ids.ts:24: alias += ALIAS_ALPHABET[byte % ALIAS_ALPHABET.length];
packages/db/src/ids.ts:26: return alias;
packages/db/src/schema.ts:109: inboundAlias: text().notNull(),
packages/db/src/schema.ts:120: uniqueIndex("users_inbound_alias_key").on(t.inboundAlias),
packages/db/src/schema.ts:127:/** Addresses allowed to forward mail to the user's inbound alias (prd §4.1). */
packages/db/src/schema.ts:167: /** Null when the alias matched no user (logged, then dropped). */
apps/web/src/app/(app)/sources/page.tsx:31: const address = forwardingAddress(user.inboundAlias);
apps/web/src/app/(app)/today/page.tsx:69: forwardingAddress={forwardingAddress(user.inboundAlias)}
apps/web/src/app/onboarding/page.tsx:38: const address = forwardingAddress(user.inboundAlias);
apps/web/src/features/settings/actions.ts:3:import { newFeedToken, newInboundAlias, schema } from "@unread/db";
apps/web/src/features/settings/actions.ts:66: await db().update(schema.users).set({ inboundAlias: newInboundAlias() }).where(eq(schema.users.id, user.id));
apps/web/src/features/users/current-user.ts:2:import { newFeedToken, newInboundAlias, schema, type User } from "@unread/db";
apps/web/src/features/users/current-user.ts:39: inboundAlias: newInboundAlias(),
apps/web/src/features/users/forwarding-address.ts:4:/** The user's private forwarding address, `<alias>@in.getunread.site`. */
apps/web/src/features/users/forwarding-address.ts:5:export function forwardingAddress(inboundAlias: string): string {
apps/web/src/features/users/forwarding-address.ts:6: return `${inboundAlias}@${serverEnv().INBOUND_EMAIL_DOMAIN}`;
packages/core/src/inbound/address.ts:1:/** Inbound aliases are 10 chars from the newInboundAlias alphabet; plus-addressing is ignored. */
packages/core/src/inbound/address.ts:9:export function aliasFromAddress(address: string, inboundDomain: string): string | null {
packages/core/src/inbound/address.ts:12: const alias = local.split("+")[0] ?? "";
packages/core/src/inbound/process.ts:5:import { aliasFromAddress, normalizeAddress } from "./address";
packages/core/src/inbound/process.ts:35:export type RejectReason = "too_large" | "unknown_alias" | "unverified_sender" | "auth_failed";
packages/core/src/inbound/process.ts:48: const alias = aliasFromAddress(options.envelopeTo, options.inboundDomain);
packages/core/src/inbound/process.ts:56: const [user] = alias
packages/core/src/inbound/process.ts:57: ? await db.select().from(schema.users).where(eq(schema.users.inboundAlias, alias)).limit(1)
packages/core/src/inbound/process.ts:70: const inboundId = await insertInbound({ userId: null, kind: "rejected", status: "rejected", rejectReason: "unknown_alias" });
packages/core/src/inbound/process.ts:71: return { status: "rejected", reason: "unknown_alias", inboundId };
packages/core/src/inbound/process.ts:271: // Unknown-alias rows carry no user data worth keeping.
$ cat packages/core/src/inbound/address.ts; sed -n 15,30p packages/db/src/ids.ts; sed -n 55,75p apps/web/src/features/settings/actions.ts; grep -n "alias\|forwarding address" -i docs/prd-unread.md | head -15
/** Inbound aliases are 10 chars from the newInboundAlias alphabet; plus-addressing is ignored. */
export const ALIAS_PATTERN = /^[a-hjkmnp-z2-9]{10}$/;
export function normalizeAddress(address: string): string {
return address.trim().toLowerCase();
}
/** `abc123defg+news@in.getunread.site` → `abc123defg`, or null if it isn't one of ours. */
export function aliasFromAddress(address: string, inboundDomain: string): string | null {
const [local, domain] = normalizeAddress(address).split("@");
if (!local || domain !== inboundDomain.toLowerCase()) return null;
const alias = local.split("+")[0] ?? "";
return ALIAS_PATTERN.test(alias) ? alias : null;
}
export function domainOf(address: string): string {
return normalizeAddress(address).split("@")[1] ?? "";
}
/** Extract bare addresses from a header value such as `Jane <jane@x.com>, bob@y.com`. */
export function addressesIn(headerValue: string | null | undefined): string[] {
if (!headerValue) return [];
return Array.from(headerValue.matchAll(/[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+/g), (m) =>
normalizeAddress(m[0]),
);
}
/**
* Local part of the user's forwarding address. Lowercase, no look-alike
* characters, so it survives being read aloud or typed from a phone.
*/
export function newInboundAlias(length = 10): string {
const bytes = randomBytes(length);
let alias = "";
for (const byte of bytes) {
alias += ALIAS_ALPHABET[byte % ALIAS_ALPHABET.length];
}
return alias;
}
/** prd §9: rotate the feed URL; old URLs (feed and audio) stop working at once. */
export async function rotateFeedAction(): Promise<void> {
const { user } = await requireUser();
await db().update(schema.users).set({ feedToken: newFeedToken() }).where(eq(schema.users.id, user.id));
revalidatePath("/sources");
revalidatePath("/settings");
}
export async function rotateAliasAction(): Promise<void> {
const { user } = await requireUser();
await db().update(schema.users).set({ inboundAlias: newInboundAlias() }).where(eq(schema.users.id, user.id));
revalidatePath("/sources");
revalidatePath("/settings");
}
export async function redeemPromoAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
const { user, sessionUser } = await requireUser();
if (!(await rateLimit({ db: db(), key: `promo:${user.id}`, limit: 5, windowSeconds: 3600 }))) {
return { error: "Too many tries. Wait an hour and try again." };
}
55:- Each user gets a private address: `<alias>@in.getunread.site` (alias random, rotatable).
59:- **Gmail forwarding confirmation:** Gmail sends a confirmation code to the forwarding address before auto-forwarding works. Unread must detect that email and show the code (and confirm link) to the user in the app, prominently. Same for Outlook if applicable.
66:- Paste in the app, or email a bare link to the forwarding address.
122:| `users` | id, auth0_sub, email, plan (`free`/`plus`), timezone, delivery_time, cadence (`daily`/`weekly`), weekly_day, episode_minutes, voice_id, news_opt_in, news_mode (`always`/`fill`), news_country, news_language, feed_token, inbound_alias |
193:4. **Sources (rework):** forwarding address + guides; **Newsletters** list (name, last issue, enabled toggle, priority); links; news settings; private feed URL.
194:5. **Settings:** plan and usage meter (characters this month), voice picker (Plus), cadence, delivery time, rotate feed and forwarding addresses, delete account.
254:- A new user copies their forwarding address, sets a Gmail auto-forward (confirmation code shown in-app), forwards 3 newsletters, and gets a cited, deduplicated episode under 10 minutes.
Yes, this works well. The setup we already have makes it safe, but I wouldn't use the email prefix on its own. Add a short random suffix, for example [REDACTED] instead of [REDACTED].
Why not the bare prefix
-
Prefixes aren't unique.
[REDACTED]and[REDACTED]both becomejohn. The fallback would bejohn2,john3…, which tells people how many users you have and is easy to guess. - It would reveal who uses Unread. If I know your email, I know your Unread address. Our inbound gate (verified sender plus SPF/DKIM) stops a stranger from injecting content. But if the Email Worker treats known and unknown addresses differently, an attacker can check whether someone is a user. They can also flood that address with junk, and every rejected message still gets logged.
-
Some prefixes are bad slugs. Examples:
- Apple's Hide My Email addresses look like
[REDACTED]. - Some prefixes are very long (
firstname.middlename.lastname.work). - Some are role or reserved words (
admin,postmaster,abuse,support). - Some contain characters we don't allow.
- Apple's Hide My Email addresses look like
A 4-character suffix fixes all three. It gives about 920k combinations for each name, so collisions are rare and the address can't be guessed. It stays easy to read out and recognise in Gmail's forwarding settings.
What I'd build
-
Format:
<slug>.<4 chars>. The slug comes from the part of the sign-in email before the @:- lowercase it;
- drop any
+tag; - keep only
a-z0-9; - cap it at about 16 characters.
The suffix uses the existing alphabet, which leaves out look-alike characters such as l/1 and o/0.
- Fall back to fully random (today's style) when the slug is under 2 characters, is a reserved word, or looks machine-made (Apple's private relay domain, a long hex string).
- Regenerate offers two choices:
- "New address": same name, new suffix (lucky.p3wd).
- "Use a random address": for people who don't want their name in it.
- Collisions: retry with a new suffix if the database's uniqueness rule rejects it.
- Compatibility: the inbound parser currently accepts only the 10-character random format, so it has to accept both formats. Existing users keep their current addresses. Plus-addressing (lucky.k7mq+news@) keeps working.
- Tests for the slug rules, the fallbacks and parsing both formats.
Something related: rotation breaks forwarding
When someone regenerates, the old address dies at once. Their Gmail auto-forward then fails silently: Gmail keeps sending to an address we reject. Two ways to deal with that:
- Grace period: accept the old address for about 7 days.
- Clear warning: keep immediate rotation, but tell the user to update Gmail forwarding and confirm it again.
What I need from you
- Shall I build it as described?
- For rotation: grace period or clear warning? I'd lean towards the warning. A grace period means storing old aliases, and the main reason to rotate is spam, where you want the old address dead right away.
h
Your message was just "h". I'm guessing it went out by accident.
What would you like to work on? From the project memory, these are still open:
-
Database: check whether Tiger Data (
PG_URL) is reachable from this fresh session, then runpnpm db:migrate. -
Gemma: once a
GEMINI_API_KEYorOPENROUTER_API_KEYis set, runpnpm briefon real newsletters. - Landing page placeholders: the Plus price is still a placeholder, there's no sample episode audio yet, and there's no Terms page.
- Accounts and deploy: Auth0, Cloudflare Email Routing, R2, Render, Bachs, and the rest. I'd ask before anything outward-facing.
The submission deadline is Mon Oct 26, 07:59 WAT.
Pull main
$ git status -sb | head -5 && git fetch origin main 2>&1 | tail -2 && git pull origin main 2>&1 | tail -5
## claude/nifty-galileo-tinqrd
* branch main -> FETCH_HEAD
9364c50..183aca6 main -> origin/main
apps/web/src/features/users/current-user.ts | 40 +++++++++++++++++++-------
packages/db/package.json | 2 +-
packages/db/scripts/migrate.mjs | 44 +++++++++++++++++++++++++++++
4 files changed, 76 insertions(+), 12 deletions(-)
create mode 100644 packages/db/scripts/migrate.mjs
$ git log --oneline -4 && git status -sb | head -3
183aca6 refactor(users): unify user loading and email trust logic on sign-in
3c7a8e6 feat(landing): set default price for Plus subscription
222fca3 chore(db): replace drizzle-kit migrate with custom migration script
9364c50 fix(sample-player): handle audio play errors and unavailable state
## claude/nifty-galileo-tinqrd
I pulled main into claude/nifty-galileo-tinqrd. It was a fast-forward with no conflicts, so the branch now sits at 183aca6.
It brought in three new commits:
- Migrations: drizzle-kit migrate is replaced by a custom script, packages/db/scripts/migrate.mjs.
- Landing page: the Plus plan now has a default price.
- Sign-in: user loading and the email trust logic are combined in apps/web/src/features[REDACTED].
Nothing is pushed yet; your project notes say to push after each milestone rather than straight away.
Stop hook feedback:
[~/.claude/stop-hook-git-check.sh]: There are 3 unpushed commit(s) on branch 'claude/nifty-galileo-tinqrd'. Please push these changes to the remote repository.
$ git push -u origin claude/nifty-galileo-tinqrd 2>&1 | tail -3
To https://github.com/codewithveek/unread
* [new branch] claude/nifty-galileo-tinqrd -> claude/nifty-galileo-tinqrd
branch 'claude/nifty-galileo-tinqrd' set up to track 'origin/claude/nifty-galileo-tinqrd'.
I pushed claude/nifty-galileo-tinqrd to the remote as a new branch. It's the same as main, so it doesn't add any changes yet. I didn't open a pull request: there's no difference from main to review, and your notes say no PRs unless you ask for one.
[image omitted from the upload]
[image omitted from the upload]
Now I need you to make a video, but this isn't going to be just a demo video,
It has to tell a story (see image), starting with the story behind the idea, the tools used, where they were used, and/or how they were used,
You will create screens, flows and actions in the video demonstrating all the flow (from forwarding the email to Unread, to Unread processing it, to playing it (use elevenlabs voice), )
For the voiceovers use elevenlabs voice for it too.
The story has to be good, n so good that it captures attention n show not just tell, research how to tell a winning story for a hackathon, research winning projects n what separates them from the others.
But first write the script for review before anything else
{
"skill": "product-videos:product-videos",
"args": "Story-driven hackathon video for Unread (newsletters → podcast). First deliverable: the script for review only, no rendering yet."
}
Launching skill: product-videos:product-videos
Base directory for this skill: /root/.claude/plugins/synced/97e0a069-3e3f-4425-8a96-b5a176c0aff6_e1329c19-dde0-494c-813c-7df64ccc4b2b/product-videos/skills/product-videos
Product videos
Videos of a software product, made in code with Remotion: every frame is
React, rendered to MP4. The product's screens are rebuilt as components from the real app's
code and screenshots, so numbers can count up, text can be typed, and the camera can zoom without
blur. A starter project with all the machinery is in assets/starter/.
Three things make these videos good, and most of this skill serves them:
-
Everything shown is real. Every screen, label, status, button and behaviour exists in the
product. Motion is free (zooms, a cursor, numbers counting up to their real value); invention is
not (a button the app doesn't have, a toast it never shows, a live update it doesn't do). A
video that demos fake UI misleads the people it is meant to convince. Read
references/accuracy.mdbefore building any screen. -
The words carry it. A clear problem, one promise, short headlines, narration that says what
the screen shows. Read
references/scriptwriting.mdbefore drafting. -
Sound finishes it. Voice timed to the action, music ducked under it, a click on every click,
one loudness for every file. Read
references/audio.mdbefore adding sound.
You cannot watch or listen to what you make. Check pictures as still frames (stills.sh) and sound
as numbers (scripts/audio-report.mjs), then tell the user plainly what they still need to judge
by eye and ear — usually pacing and music.
Start: ask, then plan
Before building, settle these with the user. Ask only what the conversation hasn't answered, in one
batch, and offer a recommended default for each:
| Question | Why it matters | Default if they don't care |
|---|---|---|
| What product, and where is it? (repo path, running URL, demo logins) | Screens are rebuilt from the real app | — must know |
| Who is it for, and what should they do after? | Sets the style, script and call to action | Prospective users; "try it" |
Which kind of video? (references/styles.md) |
Structure, length, pacing | Launch for a new product, tutorial for a feature |
| Which visual style? Studio, Editorial, Graphic, Cinematic or Showcase | How it looks and moves | The recommended look for the type (below); always say why |
| Length and shape: 16:9, 9:16, 1:1? | Layout and camera | 30–60 s, 16:9, plus a 9:16 cutdown on request |
| Script: will they write it, or should you? | If they bring one, check every claim against the product | You draft, they approve |
| Product name and brand: logo, colours, font | Title cards and the app frame | The app's own tokens; ask before inventing a name |
| Voiceover? Which voice? | Timing is driven by the narration | Yes, local Kokoro af_heart; ElevenLabs if they have a paid plan |
| Music: a licensed track, AI-generated, or the kit's generated bed? | Licensing | The kit's generated bed, flagged as a placeholder |
| Where should the output go? | Delivery |
out/ in the video project, outside their repo |
If they say "you choose", choose, say what you chose in one line each, and go.
The visual style is always offered, with a recommendation and a reason. Each video type has a
default look (references/visual-styles.md has the table): Editorial for most launches, feature drops
and explainers; Showcase for AI, agent and automation products whose demo is the pitch; Graphic for
API and infrastructure products with little UI; Studio for tutorials, onboarding and anything where
the whole real screen matters; Cinematic for premium, quiet brands.
Adjust for the product's brand and where it will be watched (muted feeds favour the text-led
Editorial and Graphic). Tell the user which you recommend and why, in terms of their video, list
the other four in one line each, and go with the recommendation if they don't choose. If they point
at a reference video, name the closest look and what you will borrow from it, and offer to measure
its motion (references/motion-data.md) so the new video moves the way the reference does.
Then show a storyboard before building: the look at the top, then scene by scene what is on
screen, the headline, the narration line (or on-screen words, for music-led looks), the transition
into it, and roughly how long. It is cheap to change words and order now and expensive later.
references/styles.md has proven structures to start from.
Build
- Learn the product from its code and the running app. Routes, page components, the exact button labels, status names and their colours, what happens after each action (a message? a redirect? nothing?), empty states, defaults. Grep for the strings you plan to show.
-
Capture references. Screenshot every screen and state the video will show from the running
app (
assets/starter/reference/capture.mjs, read-only: open dialogs, never confirm). These are the source of truth while rebuilding. Store them with the video project, not in the user's repo. -
Set up the project by copying
assets/starter/(next to this file) to the output location (outside the product's repo unless asked), thennpm install. Copy the product's theme intosrc/theme.tsandsrc/kit/tokens.tsvalue for value, and set the name, tagline and mark insrc/brand.ts. -
Write the cut list and narration per video:
src/videos/<id>/timeline.ts(thelook, scenes, base lengths, each scene'sintransition) andnarration.ts(lines, the frame each aims for, respellings). Register the video insrc/Root.tsx. -
Build scenes (
scenes.tsx) in the look's vocabulary:src/videos/editorial/andsrc/videos/graphic/are working examples of the two text-led looks,src/videos/showcase/of Showcase,src/videos/example/of Studio. Time new motion with the measured presets insrc/kit/measured(MOTION.screenPan,MOTION.settle…) rather than inventing easings. Rebuild each screen from its reference screenshot with the kit's controls and an app frame shaped like the product's. Wrap anything the cursor or camera must reach in<Anchor name="…">and aim at names, never at hand-measured coordinates.references/kit.mdexplains the camera, pointer, anchors, scene reuse and the vertical layout. -
Generate sound:
npm run voice(scenes grow to fit their narration),npm run musicandnpm run sfx(generated tracks and effects, no licence needed;references/audio.md). - Check stills at the moments that matter — every click, every typed value, every zoom, the last frame of each scene — and fix framing, overlaps and clipped text before rendering. Most problems are camera framing; widen the zoom or aim at a different anchor.
-
Render with
./render-all.sh: MP4s, loudness at -16 LUFS, captions, thumbnails (as cover art, and as the opening frame, which is what X and most feeds show as the preview). Pull 2–3 frames from the finished files and run the audio report. -
Deliver. Before re-rendering anything the user has already seen, copy the old files aside
(
<name>-v2.mp4); they may want to compare. Report what was made and how long each is, what is placeholder (music, data, hostnames, the product name), and anything you could not verify.
Rules that save hours
- Aim at anchors, not pixels. Coordinates drift whenever layout changes; anchors follow.
-
Narration drives timing. Write lines with an
atframe next to the action they describe; the voice script places them and lengthens scenes that need room. Don't hand-time audio. -
One name, one place. Product name, tagline and mark live in
src/brand.ts, so a rename is a one-line change and a re-render. -
Reuse scenes across videos (a launch video's product moments can be the tutorials' scenes) with
StepCopyto change their headline. One verified screen, many videos. -
Placeholder data is fine; placeholder features are not. Example names, amounts and a
payouts.example.comhost are data. Say which data is illustrative when you deliver. -
Use measured timing. The kit's
MOTIONpresets are curves and durations measured from a professional launch and verified by re-rendering. Prefer them to hand-picked easings; measure a new reference withassets/motion-labwhen the user wants a specific feel. - Stills before renders. A full render takes minutes; a still takes seconds.
- Don't trust a pipeline you haven't checked. After the first render of a new setup, read the audio report and look at frames from the actual MP4, not only the stills.
references/pitfalls.md lists the specific bugs this process has hit, with fixes. Skim it when
something renders blank, off-centre, silent or wrong only in the final file.
References
Read the one you need, when you need it:
| File | Read it when |
|---|---|
references/styles.md |
Choosing a video type; you need a structure, length and pacing |
references/visual-styles.md |
Choosing and recommending a look; building Editorial, Graphic, Cinematic or Showcase scenes; matching a reference video |
references/motion-data.md |
Timing motion with measured presets; what professional launch motion measures as; measuring a reference video and scoring a render against it |
references/scriptwriting.md |
Writing or reviewing the script, headlines, narration, captions, or working from a user's script |
references/audio.md |
Voiceover, music, sound effects, levels, licensing, ElevenLabs or other AI audio tools |
references/accuracy.md |
Before rebuilding any screen; deciding what may be shown |
references/kit.md |
Working in the starter project: looks, transitions, motion blocks, camera, cursor, anchors, scenes, vertical, thumbnails |
references/social.md |
9:16 or 1:1 cutdowns, burned-in captions, platform specs, thumbnails |
references/pitfalls.md |
Something renders wrong, blank, silent or slowly |
Delivering
End with a short report the user can act on:
- Each file: path, length, aspect, the look, and what it contains (video, captions
.vtt, thumbnail). - What is real and what is placeholder: illustrative data, a placeholder name or hostname, the music source, the voice (and that it is AI-generated if it is).
- What you checked (stills, frames from the MP4, audio report) and what they must judge themselves: watching it in motion and listening to it.
- How to change it: edit narration and re-run voice; edit
brand.ts;./render-all.sh. - Licences: Remotion is free for individuals and companies of up to three people; larger companies
need a company licence. Music and voice licences per
references/audio.md.
ARGUMENTS: Story-driven hackathon video for Unread (newsletters → podcast). First deliverable: the script for review only, no rendering yet.
$ cd /root/.claude/plugins/synced/*/product-videos/skills/product-videos/references && wc -l *.md && cat scriptwriting.md && sed -n 1,200p styles.md
55 accuracy.md
147 audio.md
276 kit.md
199 motion-data.md
49 pitfalls.md
132 scriptwriting.md
41 social.md
123 styles.md
295 visual-styles.md
1317 total
# Scriptwriting
The script is three layers that run together: **headlines** on screen, **narration** spoken, and
**captions** (the narration, written down). Write them together, scene by scene.
## Contents
1. Working from the user's script
2. The word budget
3. Headlines
4. Narration
5. The claim audit
6. Pronunciation
7. Captions
8. Names and taglines
9. A worked example
---
## 1. Working from the user's script
Users often bring a script or a rough idea ("open with 'want to run an affiliate program?', then
'Introducing X'"). Treat it as intent, not final copy:
- Keep their structure and voice; tighten wording, and say what you changed and why in a line each.
- Check every claim against the product (section 5). If a line promises something the product
doesn't do ("globally" when it pays out in eight currencies), propose an accurate version and
explain the gap. Never quietly ship an overclaim.
- If a line would not fit its scene (section 2), offer a shorter version rather than stretching the
scene.
When you need a script from them, ask for it in this shape (any of it is optional):
```
Audience: who watches, and what they should do after
Problem: the pain, in their words
Promise: the one sentence the product delivers
Moments: the 3–5 things to show, in order
Close: the call to action, a URL or date
Tone: e.g. confident and plain / playful / technical
Must say / must not say: names, phrases, legal lines
```
## 2. The word budget
Narration runs at about **2.5 words per second** (150 wpm) with a good TTS voice. At 30 fps:
| Scene | Seconds | Words of narration |
|---|---|---|
| Title / introducing | 3 | 3–6 |
| Problem line | 3 | 6–8 |
| Product moment | 6–8 | 10–16 |
| End card | 4–5 | 5–10 |
Leave about half a second of silence before each scene's crossfade. If a line is over budget,
shorten it; the voice script will otherwise lengthen the scene and the visuals will hold still.
## 3. Headlines
The on-screen line above the app window. It says what the step achieves, not what the button says.
- 4–9 words, one line at 1920 wide (about 50 characters at 50 px). Shorter in 9:16.
- An eyebrow names the section: "Step 3 · Tracking and signup", "Verify", "Pay".
- Concrete beats clever: "Decide when commissions become payable." not "Payday, your way."
- No full stop on fragments that read as labels; a full stop on sentences. Pick one per video.
- Never contradict the screen: the headline and the UI must agree on names.
## 4. Narration
- Say what the screen is showing, as it shows it. Pin each line to the frame of its action with
`at`, so the voice lands on the click, not after it.
- One idea per sentence. Short sentences survive TTS better than long ones.
- Plain words; no jargon the audience wouldn't use. Spell numbers as the voice should say them in
`say` ("twenty-one day hold window") and as people read them in `text` ("21-day hold window").
- Don't read the headline verbatim; complement it. The headline names the step; the voice adds the
why or the detail.
- Avoid filler openers ("So,", "Now let's"). Start with the verb or the noun.
- The problem section can be punchier; product moments should be calm and exact.
## 5. The claim audit
Before recording, list every claim the script makes and where in the product it is true:
| Claim | Evidence |
|---|---|
| "Paying affiliates across borders" | Payout currencies: NGN, KES, GHS, UGX, TZS, ZAR, XOF, USD (onboarding form) |
| "Stop fraud before it's paid" | Conversions are scored before a commission is written (README; fraud engine) |
| "Nobody is paid twice" | One idempotency key per affiliate per cycle; confirm dialog says so |
Words that usually overclaim: globally, anywhere, instantly, real-time, any, all, never, always,
automatic, effortless, secure (unqualified). Use the precise version: "across borders", "in eight
currencies", "within the hold window".
## 6. Pronunciation
TTS voices misread names, currencies and acronyms. Kokoro phonemises with espeak (the `phonemizer` package comes with kokoro-js); check a word's
phonemes before generating:
```bash
node -e 'import("phonemizer").then(async ({phonemize}) => console.log(await phonemize("Afriex", "en-us")))'
```
Respell in `say` until the phonemes match, and keep `text` correct for captions. Examples that
worked: "Afri-ex" (Afriex), "Nyra" (naira), "Oh-kah-for" (Okafor), "C S V" (CSV). Some names can't
be fixed (espeak inserts "em" before "Mw…"); rephrase to avoid them. Avoid speaking codes and
identifiers (kb-2240, HMAC-SHA256) at all; show them, say what they are.
## 7. Captions
With a voiceover, the captions are the narration, one cue per sentence, timed to the spoken line
(`scripts/vtt.mts` does this). Without one, the timeline's cues describe the on-screen text and
action in brackets: `[The link is created and copied]`. Keep a cue to two lines of about 42
characters. Burn captions into vertical cutdowns (`social.md`).
## 8. Names and taglines
If the product has no name yet, offer three to five with a reason each and a recommendation, and
note that you haven't checked trademarks or domains. A good tagline names the job and the
difference: "Affiliate payouts, across borders." Put both in `src/brand.ts`.
## 9. A worked example (a 55 s launch)
| Scene | Headline / on screen | Narration |
|---|---|---|
| Problem | "Running an affiliate program is easy." / "Paying affiliates across borders isn't." (cities roll) / "And money paid out to fraud never comes back." | the same three lines |
| Intro | "Introducing Corridor" | "Introducing Corridor." |
| Pitch | Three cards: Track every referral · Stop fraud before it's paid · Pay in their own currency | "Track every referral, stop fraud before it's paid, and pay affiliates in their own currency." |
| Share | "A link for every channel." | "Affi
… [4064 characters trimmed for the upload] …
eyond what the system does.
## 5. Kinetic-type teaser
For: a pre-launch tease or the opening of a launch. 10–20 s. Big words only, rising in, one line at
a time, timed to music beats or the voice. A rolling word slot (cities, use cases) adds motion.
Colour one phrase (the problem in red, the promise in the brand colour).
## 6. Dashboard / metrics reveal
For: analytics products, "track" moments. 6–10 s inside a longer video. KPI cards count up to their
real values, a chart line draws in left to right against a dashed previous period, bars grow, one is
highlighted. Camera: KPIs, then the chart, then pull back. Never animate a live ticker the product
doesn't have.
## 7. Developer integration
For: API products. 40–50 s. Real settings screen for keys or secrets (shown once, copied), then code
cards: the request (signed, with the real endpoint and headers), the response (`202 { … }`), the
error cases, then the result landing in the product's UI. Turn off font ligatures in code
(`=>` must not render as ⇒). Never show a real secret.
## 8. Onboarding welcome
For: new users after sign-up. 30–45 s, warm, slower. "Welcome to [Name]" → the three things to do
first, each a real screen → where to get help.
## 9. Before / after
For: a redesign or an improvement. Split screen or a wipe between the old and new screen, both real
(old from a previous screenshot or git version). Label each side plainly.
## 10. Social cutdown
For: Reels, TikTok, Shorts, LinkedIn, X. 15–30 s, 9:16 (or 1:1). Built from the same scenes as the
16:9 video. Captions burned in. A hook in the first second. See `social.md`.
## 11. Pacing, transitions and camera language
- **Scene length**: 5–8 s for a product moment; 3 s for a title; 4–6 s for an end card.
- **Transitions**: each look has a default (Studio: a 12-frame crossfade with a soft whoosh;
Editorial: a focus pull; Graphic: a colour slab; Cinematic: a slow focus pull). Use the special ones
for a reason: a circle of brand colour for the brand moment, a panel growing into a detail, a hard
cut on a music hit, a zoom through squares into the close. Avoid spins and novelty wipes; they date
fast and fight the UI.
- **Camera**: start wide (orient), zoom to the action (1.3–1.8×), pull back to show the result.
One move per beat; hold still while something is happening on screen; let the eye land before the
next move. Ease in and out on every move.
- **Cursor**: arrive, pause a beat, click (dip + ring + click sound), move away so the result is
visible. Never park the cursor on the thing that just changed.
- **Typing**: 12–24 characters per second; a caret that is solid while typing and blinks at rest.
- **Entrances**: words rise and unblur one by one, 3 frames apart. Cards pop with a spring.
- **Highlights**: a ring or glow around a real element, briefly; never an invented UI element.
- **Holding**: end each scene on its resolved state for at least half a second before the fade.
Shell cwd was reset to /home/user/unread
$ ls docs docs/design docs/screenshots/u4 2>/dev/null; grep -rniE "hackathon|hacktoberfest|laz" docs --include=*.md | head -20; wc -l docs/prd-unread.md
docs:
decisions
deploy.md
design
prd-unread.md
project-memory.md
screenshots
docs/design:
Landing.dc.html
Main.dc.html
Onboarding.dc.html
Player.dc.html
README.md
Sources.dc.html
docs/screenshots/u4:
d-sources.png
d-today-playing.png
p-nowplaying.png
p-onboarding.png
p-today.png
docs/prd-unread.md:7:| **Status** | A real product. First public release doubles as the DEV Hacktoberfest 2026 Week 3 entry (deadline **Mon Oct 26, 07:59 WAT**) |
docs/prd-unread.md:47:- Plans: Free and Plus (billing can be stubbed for the hackathon; limits enforced in code).
docs/prd-unread.md:216:## 11. Partner technology (Hackathon)
docs/prd-unread.md:248:| U6 | Submission: demo video, screenshots, Lazy Payoff numbers, write-up | Post published | Mon before 07:00 |
docs/prd-unread.md:250:**Cut line, in order:** Gmail Token Vault → RSS → listening-event ranking (keep the hypertable logging) → Temporal (Render cron calling the same functions) → voice library picker (keep 3 ElevenLabs voices) → Bachs checkout (keep promo-code trials + plan flag + caps; Bachs ships right after the hackathon). **Never cut:** forwarding, cross-newsletter dedupe, citations, the script check, the private feed.
docs/project-memory.md:6:Forward newsletters to `<alias>@in.getunread.site` → one short, deduplicated, cited podcast episode → private RSS feed + web player. DEV Hacktoberfest 2026 Week 3 entry; deadline **Mon Oct 26, 07:59 WAT**. Owner: Lucky Victory (@CodewithVeek), repo `codewithveek/unread`, working branch `claude/gifted-albattani-gk5mjz`. Spec: `docs/prd-unread.md` (v2 + Kokoro/Bachs/promo/trial-limit updates). The canvas phone designs in `docs/design/` are the UI source of truth.
docs/project-memory.md:24:- 0002 Auth0 v4; `requireUser()` in pages; user row created lazily; sign-in email becomes a verified sender.
docs/decisions/0002-auth0-sign-in.md:9:- **The user row is created lazily** on the first signed-in request, with a 192-bit feed token and a 10-character inbound alias. The insert uses `ON CONFLICT DO NOTHING` on `auth0_sub`, so concurrent first requests are safe.
262 docs/prd-unread.md
$ sed -n 1,120p docs/prd-unread.md
# PRD: Unread, your newsletters as a podcast (v2)
| | |
|---|---|
| **One line** | Forward your newsletters to Unread and get one short, deduplicated podcast episode with the parts that matter, in a voice you choose |
| **Main selling point** | **Newsletter → summarised podcast** via email forwarding. Also: article links → episode, and opt-in news on chosen topics |
| **Status** | A real product. First public release doubles as the DEV Hacktoberfest 2026 Week 3 entry (deadline **Mon Oct 26, 07:59 WAT**) |
| **Domain** | `getunread.site` |
| **UI reference** | "Unread app screens" canvas + `design/` files; desktop rules in §9 |
> **Claude Code:** build in the order of §13; stop at the cut line if time runs out. Verify every third-party API (Gemini API/Gemma, ElevenLabs, SerpApi, inbound email provider, Auth0, Temporal, Render, Tiger Data) against current docs and record choices in `docs/decisions/`. Code standards from `CLAUDE.md` apply.
## Changes from v1
- Voice: **Kokoro (open-weight, on our worker) for Free; ElevenLabs for Plus**, with the ElevenLabs voice library picker. Sound effects generated once with ElevenLabs and reused on every plan.
- Payments: **Bachs** (subscriptions, hosted checkout, local currencies). **Promo codes** grant a time-limited Plus trial.
- Model: **Gemma served by Google** (Gemini API, OpenAI-compatible endpoint); OpenRouter as fallback. No local-model mode in the product.
- **Email forwarding is P0** and the centre of onboarding. News (SerpApi) is **opt-in**.
- Plans (Free / Plus) and cost controls added, because voice is the main cost.
## 1. Problem and users
- People subscribe to many newsletters and read few. Inboxes fill with issues that overlap heavily (the same launch covered by five newsletters).
- Listening is a daily habit: 45% of Americans aged 12+ listen to a podcast weekly (Edison, Infinite Dial 2026).
- Existing options need effort or don't curate: text-to-speech readers read everything aloud; NotebookLM works one notebook at a time; Huxe is closed and broad.
**Users:** professionals, founders and students with 5–30 newsletter subscriptions, plus saved links.
**Promise:** "Your newsletters, minus the overlap, in 10 minutes of audio."
## 2. Principles
- **P1 · Zero effort after setup.** Forward once (or set an auto-forward rule); episodes arrive.
- **P2 · Honest summaries.** Every chapter cites its newsletter or article and links to it. No number, quote or claim absent from the source. Our own words, never full reproduction.
- **P3 · Hear each story once.** Overlapping coverage is merged into one chapter that credits every source.
- **P4 · Gemma does the thinking.** Curation, deduplication judgments, summarising and scripting run on Gemma (open weights), so the product can move to self-hosted Gemma without a rewrite.
- **P5 · User-focused.** No location assumptions; news is opt-in and uses the user's chosen edition.
- **P6 · Cost-aware by design.** Voice characters are the main cost; every design choice respects the per-plan budget (§8).
## 3. Scope
### In (v1)
- Sign-in (Auth0), onboarding centred on forwarding setup.
- Sources: **forwarded newsletters (P0)**, pasted article links (P0), opt-in news topics via SerpApi (P0), RSS (P1).
- Episode pipeline (§6) with chapters, transcript, sources, cover.
- ElevenLabs voices; premium voice picker (Plus).
- Private podcast feed + web player; responsive phone → desktop.
- Plans: Free and Plus (billing can be stubbed for the hackathon; limits enforced in code).
### Out (v1)
- Voice cloning, native apps, browser extension, sharing, multiple episodes a day, team accounts. Gmail import via Auth0 Token Vault is P1 stretch.
## 4. Sources
### 4.1 Newsletter forwarding (P0, the main feature)
- Each user gets a private address: `<alias>@in.getunread.site` (alias random, rotatable).
- **Two ways to use it**, both shown in onboarding with step-by-step guides (Gmail, Outlook, Apple Mail):
1. Forward an issue by hand.
2. **Auto-forward rule** (recommended): a filter that forwards newsletters automatically.
- **Gmail forwarding confirmation:** Gmail sends a confirmation code to the forwarding address before auto-forwarding works. Unread must detect that email and show the code (and confirm link) to the user in the app, prominently. Same for Outlook if applicable.
- **Accept only** mail from the user's verified email address(es) (they can add more); also verify SPF/DKIM pass via the provider's verdict. Others are dropped and logged.
- **Cleaning:** HTML → text; strip footers, unsubscribe blocks, tracking pixels, ads and sponsor sections where identifiable; keep links. Detect the newsletter name (sender name / List-Id) for citations.
- **Sender list:** the app lists every newsletter seen, with a toggle per sender ("include in episodes") and a priority (always first / normal / only if room).
- Inbound provider chosen in an ADR (e.g. Postmark Inbound or Cloudflare Email Routing → webhook). Webhook signature verified.
### 4.2 Article links (P0)
- Paste in the app, or email a bare link to the forwarding address.
- Fetch + Readability extraction; SSRF protection; paywalled pages fall back to title + snippet and are labelled "limited".
### 4.3 News topics (P0, opt-in)
- Onboarding, after forwarding setup: **"Want us to catch you up on the news too?"** Off by default; choosing yes shows topic chips + search + news edition (detected country, editable).
- Two modes, user's choice: **"Always include a news segment"** or **"Only fill quiet days"** (when newsletters don't fill the episode).
- SerpApi Google News per topic with `gl`/`hl` from the edition; last 24–36 h.
- First-run tip: if the user has no newsletters yet, offer news to make the first episode immediately.
### 4.4 RSS (P1)
## 5. Architecture
```
apps/web (Next.js App Router: UI + route handlers)
├─ Auth0 session · Drizzle · plan limits
├─ /inbound/email (signed webhook → saved_items, confirmation-code detection)
└─ /feed/:token.xml (private podcast feed)
│
Postgres on Tiger Data (pgvector + a hypertable for listening events)
│
apps/worker (Temporal worker on Render)
collect → extract/clean → embed + cluster → plan → script → check → voice → cover → publish
│ │ │
SerpApi (opt-in) Gemma via Gemini API VoiceProvider: Kokoro (Free, CPU in worker)
(OpenRouter fallback) ElevenLabs (Plus) + cached SFX
Bachs (checkout, subscriptions, webhooks) ↔ /billing/* routes
Object storage (S3-compatible) for MP3 and covers · Render: web service + background worker
```
### Model client
- `ModelClient` over OpenAI-compatible APIs: primary **Gemini API serving Gemma** (record the exact Gemma model id in an ADR), fallback **OpenRouter** Gemma. Retries with backoff on 429/5xx; per-call timeout; Zod-validated structured output with a 3-attempt repair loop.
- Embeddings: **EmbeddingGemma** if served by the same provider; otherwise run it in the worker (transformers.js/ONNX) — decide in an ADR after checking Render CPU performance.
## 6. Episode pipeline (Temporal workflow `make-episode`)
| # | Activity | Rules |
|---|---|---|
| 1 | `collectCandidates` | Unused newsletter issues and links since the last episode, enabled senders only; plus news if opted in and the mode applies |
| 2 | `extractAndClean` | Newsletters split into **items** (one story/section each, with its links); links extracted; cached by content hash |
| 3 | `embedAndCluster` | Embed each item; cluster the same story across newsletters/articles (pgvector similarity + Gemma confirmation for borderline pairs); drop stories in `heard_items` |
| 4 | `planEpisode` | Gemma ranks clusters: sender priority → user's listening signals (§7) → freshness. Fits the **character budget** for the user's plan and length (§8) |
| 5 | `writeScript` | JSON per chapter: `{ title, lines: [{ text, sourceIds[] }] }`, intro naming the newsletters covered, outro. Merged stories say "covered by The X and The Y" |
| 6 | `checkScript` | Every line cites ≥1 source; numbers and quotes must appear in the cited source text; lines ≤ 30 words; total characters ≤ budget. Failing lines regenerated once, then dropped |
| 7 | `synthesizeVoice` | `VoiceProvider` chosen by the user's **effective plan** (§8): Kokoro (`kokoro-js`, CPU) for Free, ElevenLabs for Plus/trial (request stitching for continuity if supported, verify). Per chapter; cache by text + voice + provider + model hash; ffmpeg concat with cached SFX (intro sting, chapter transitions); loudness −16 LUFS; chapter times from clip durations |
| 8 | `renderCover` | Seeded SVG (user + date + top newsletters) → 3000×3000 and 1200×1200 PNG |
| 9 | `publishEpisode` | Upload; write episode, chapters, sources, `heard_items`; mark items used; record characters and cost |
**When episodes are made:** at the user's delivery time, **only if there is new content** (≥ 2 new items, or the news fallback applies). No content → no episode, no cost. Free plan: weekly (user picks the day).
**"Make one now"** button: rate-limited by plan.
## 7. Data (Drizzle on Tiger Data, ULIDs)
| Table | Key columns |
$ sed -n 120,262p docs/prd-unread.md
| Table | Key columns |
|---|---|
| `users` | id, auth0_sub, email, plan (`free`/`plus`), timezone, delivery_time, cadence (`daily`/`weekly`), weekly_day, episode_minutes, voice_id, news_opt_in, news_mode (`always`/`fill`), news_country, news_language, feed_token, inbound_alias |
| `verified_senders` | user_id, email, verified_at (addresses allowed to forward) |
| `newsletters` | id, user_id, sender_email, display_name, list_id, enabled, priority |
| `inbound_messages` | id, user_id, newsletter_id, message_id (unique), received_at, subject, raw_key, status, kind (`issue`/`link`/`forwarding-confirmation`/`rejected`) |
| `items` | id, user_id, source_kind (`newsletter`/`link`/`news`/`rss`), newsletter_id, url, title, text, text_hash, embedding vector, created_at, status |
| `user_topics` | id, user_id, label, query, is_custom |
| `episodes` | id, user_id, date, status, title, duration_s, characters, voice_model, cost_cents, audio_key, cover_key, transcript jsonb, error jsonb |
| `chapters` / `chapter_sources` | as v1, sources pointing to `items` |
| `heard_items` | user_id, item_id, cluster_key, episode_id |
| `listening_events` | **Timescale hypertable** on `occurred_at`: user_id, episode_id, chapter_id, event (`play`/`skip`/`complete`/`seek`), position_s, client (`web`/`feed`) |
| `usage_monthly` | user_id, month, characters_used, episodes_made |
| `subscriptions` | id, user_id, bachs_customer_id, bachs_subscription_id, product_id, status, current_period_end, cancel_at_period_end, updated_at |
| `billing_events` | id (Bachs event id, unique), type, payload jsonb, processed_at |
| `promo_codes` / `promo_redemptions` | see §8.3 |
**Tiger Data use:** Postgres for everything; **pgvector** for story clustering across newsletters and "already heard"; a **hypertable** for listening events, with a continuous aggregate (per user × newsletter/topic: skip rate, completion rate) that feeds `planEpisode` ranking. P2: hybrid keyword + vector search over past episodes.
## 8. Voice, plans, payments and cost
ElevenLabs API list prices (verify before launch): Flash/Turbo ≈ $0.04 per 1K characters; Multilingual v2 / v3 ≈ $0.08 per 1K; sound effects ≈ $0.12 per generated minute. ~900 characters ≈ 1 minute of speech. Kokoro has no per-character cost (CPU time on our worker).
| | Free | Plus (price set in Bachs, e.g. $8–10/mo or local equivalent) |
|---|---|---|
| Voice | **Kokoro**: 4 curated voices (e.g. `af_heart`, `af_bella` + two others chosen by listening tests) | **ElevenLabs**: Flash by default; "Studio voices" (Multilingual v2/v3) option; full voice library picker |
| Cadence | Weekly digest (user picks the day) | Daily, only when there's new content |
| Length | Up to 10 min | 5 / 10 / 20 min |
| Monthly cap | Episode count (4/month) | ~350K ElevenLabs characters (the episode shortens to fit, never fails silently) |
| News opt-in | Yes | Yes |
### 8.1 Voice rules
- `VoiceProvider` interface with `KokoroVoiceProvider` and `ElevenLabsVoiceProvider`; the pipeline asks for the provider by effective plan, never by checking plan flags inside voice code.
- Kokoro runs in the Render worker. Measure speed on the chosen instance (CPU, often around real time or a bit faster); limit concurrent Kokoro jobs per worker; generate chapters in parallel within that limit. Free episodes are weekly, which keeps CPU load predictable.
- The Free plan shows an "Upgrade to Studio voices" preview: a 10-second sample of the same intro in an ElevenLabs voice (generated once per voice and cached).
- SFX: a fixed set (intro sting, chapter transition, outro) generated once with the ElevenLabs sound effects API and reused on every plan.
- **No voice cloning in v1.**
- Every episode records provider, characters and estimated cost; an admin page shows daily totals.
- Kokoro is Apache-2.0. Its phonemizer is likely espeak-ng based (GPL); running it on our server is not distribution, but confirm the licence before ever shipping it inside a downloadable app.
### 8.2 Payments (Bachs)
- Bachs, "the payments stack for African internet businesses": hosted checkout, subscriptions with trials, customer portal, webhooks, payment in the customer's local currency (examples show USD and NGN). Base URLs: `https://sandbox-api.bachs.io` (keys `sk_sandbox_…`) and `https://api.bachs.io` (`sk_live_…`); docs at docs.bachs.io. **Verify every endpoint and field name against the docs before coding.**
- Setup: one recurring product per Plus price (monthly; annual optional). Store product ids in config, not code.
- Upgrade flow: `POST /billing/checkout` creates a Bachs customer if needed, then a hosted checkout session for the Plus product → redirect → Bachs redirects back to `/billing/return`. **Plan changes only from webhooks**, never from the return URL.
- Webhooks: `POST /billing/webhooks/bachs`, signature verified (HMAC-SHA256 header, reject stale timestamps), idempotent by event id. Handle subscription created / updated / cancelled and payment failed. Map Bachs status → our `subscriptions.status`.
- Manage plan: a Bachs customer-portal session from Settings ("Manage billing"), for card updates and cancellation.
- Cancelled subscriptions stay Plus until the end of the paid period, then drop to Free.
### 8.3 Promo codes (Plus trial)
- Our own feature, independent of Bachs: a code grants **Plus for N days with no card**.
- `promo_codes`: code (case-insensitive, unique), trial_days, max_redemptions, redeemed_count, expires_at, active, note. `promo_redemptions`: code_id, user_id, redeemed_at, trial_ends_at (unique per code + user).
- Rules: one active trial per user; a user can't redeem a code while already on paid Plus; codes are rate-limited and validated server-side; redemption is atomic (no over-redeeming under concurrency).
- **Email must be verified** (Auth0 `email_verified`) before a code can be redeemed, so throwaway sign-ups can't farm trials.
- **Trial episode limit:** a trial includes at most **3 ElevenLabs episodes** (`trial_episode_limit
… [4803 characters trimmed for the upload] …
ound email to "item ready" within 60 seconds.
- Failure: "Today's episode couldn't be made: [reason]. Try again." Previous episodes remain.
- Costs logged per episode; alert when a user nears their cap.
## 13. Build order
| # | Milestone | Acceptance | Target |
|---|---|---|---|
| U0 | Repo, Next.js, Drizzle on Tiger Data, Auth0, tokens | Sign in; empty Home on phone and desktop | Sat morning |
| U1 | **Inbound email**: provider, webhook, verified senders, cleaning, item splitting, Gmail confirmation detection | Forwarding a real newsletter creates items within 60 s; Gmail code shows in the app | Sat midday |
| U2 | Pipeline 3–6 (cluster, plan, script, check) as functions + CLI | Script JSON with merged, cited chapters from 3+ newsletters | Sat evening |
| U3 | ElevenLabs voice + SFX + cover + publish + feed | MP3 plays in a podcast app via the private feed | Sat night |
| U4 | UI: onboarding (forwarding first), Home, Now playing, Sources, Settings, responsive, global player | Canvas screens + desktop layout work end to end | Sun midday |
| U5 | Links + opt-in news (SerpApi); effective plan, promo codes, Bachs checkout + webhooks (sandbox); Temporal schedule; Render deploy; domain | Promo code switches a user to ElevenLabs; sandbox checkout upgrades via webhook; scheduled episode appears | Sun evening |
| U6 | Submission: demo video, screenshots, Lazy Payoff numbers, write-up | Post published | Mon before 07:00 |
**Cut line, in order:** Gmail Token Vault → RSS → listening-event ranking (keep the hypertable logging) → Temporal (Render cron calling the same functions) → voice library picker (keep 3 ElevenLabs voices) → Bachs checkout (keep promo-code trials + plan flag + caps; Bachs ships right after the hackathon). **Never cut:** forwarding, cross-newsletter dedupe, citations, the script check, the private feed.
## 14. Acceptance
- A new user copies their forwarding address, sets a Gmail auto-forward (confirmation code shown in-app), forwards 3 newsletters, and gets a cited, deduplicated episode under 10 minutes.
- A story covered by two newsletters becomes one chapter crediting both.
- With news opt-in off, no news appears; with "fill quiet days", news appears only when newsletters don't fill the budget.
- Free users are capped at their weekly episode and character limit; the episode shortens rather than failing.
- The episode plays in a podcast app with cover and chapters, and in the web player on phone (390 px) and desktop (1440 px).
- Mail from an unverified sender is rejected and never reaches a model.
- Free episodes use Kokoro; redeeming a valid promo code switches the next episode to ElevenLabs until the trial ends, then back to Kokoro automatically.
- An unverified email cannot redeem a code. A trial ends after its 3rd ElevenLabs episode even if days remain, and unused forwarded items appear in the next Free episode.
- A Bachs sandbox payment upgrades the user only after the signed webhook arrives; a replayed webhook changes nothing.
{
"query": "select:WebSearch,WebFetch",
"max_results": 2
}
{
"query": "how to make a winning hackathon demo video storytelling tips judges",
"mode": "standard"
}
Web search results for query: "how to make a winning hackathon demo video storytelling tips judges"
Links: [{"title":"Explore this post","url":"https://www.hackerearth.com/blog/10-tips-win-hackathon"},{"title":"Explore this post","url":"https://webflow.hackerearth.com/blog/10-tips-win-hackathon"},{"title":"Hackathon Demo and Presentation Tips: How to Pitch in 3 Minutes and Win","url":"https://blogs.reskilll.com/hackathon-demo-presentation-tips-pitch-3-minutes-win-2026/"},{"title":"Hackathon Demo and Presentation Tips: How to Pitch in 3 Minutes and Win","url":"https://blogs.reskilll.com/?p=433"},{"title":"How To Win Hackathons","url":"https://getpushtoprod.substack.com/p/how-to-win-hackathons"},{"title":"march hackathon fun food and rail strikes","url":"https://www.mention-me.com/old-engineering-blog/march-hackathon-fun-food-and-rail-strikes"},{"title":"DEV Community","url":"https://dev.to/domfive/4-tips-on-how-to-win-a-hackathon-5fif"},{"title":"10 tips to help you rock your next hackathon demo","url":"https://angelhack.com/blog/10-tips-to-help-you-rock-your-next-hackathon-demo/"},{"title":"Lessons from Winning Five Hackathons","url":"https://sustained.substack.com/p/lessons-from-winning-five-hackathons"}]
The search turned up useful advice, mostly from hackathon blogs and organizer write-ups rather than judges themselves.
**Structure and story**
- Open with a problem the room recognizes. One suggestion is to start with a frustration the audience has felt, then build the demo around the moment the product resolves it. Your audience sits through a lot of demos in a row, so give them something with a shape rather than a list of features.
- Lead with the problem rather than the tech. One source advises that the tech stack matters, but it's not your opening line.
- A common 3-minute layout runs: a hook with one statistic (0:00–0:20), a one-sentence description of what you built, a live demo taking most of the time, a quick tech and impact note, and a short close. The live demo is the most important part.
**Demo video specifics**
- Judges may only see the video. One organizer-side piece notes that the videos are all many judges will see of your presentation, so it's worth polishing.
- Use the time for what isn't visible on screen. Don't narrate a form being filled in; spend the seconds on the pipeline, model choice, or data work. Judges watch and listen at the same time, so don't spend words describing what's already visible.
- Name the model and explain how you checked output quality, since nearly every entry uses AI somewhere. Telling judges you used a large language model gives them nothing to score.
- Give judges one memorable moment. What judges remember an hour later is rarely the most polished demo in the room. One storytelling winner in a past event made a humorous video that opened with a Star Wars parody.
**Scope and reliability**
- Show only what works. Everything else can be mocked, static, held together with duct tape.
- Use realistic test data, record a backup video, and stay under time. Judges literally stop listening when talks run long, per one source.
**Caveat:** Most of this comes from blog posts and personal accounts, not from formal judging criteria. Check each event's rubric, since the weighting varies. One source argues that the rubric should be read before the problem statement.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
{
"query": "DEV.to Hacktoberfest 2026 challenge week 3 \"rewards laziness\"",
"mode": "extended"
}
Web search results for query: "DEV.to Hacktoberfest 2026 challenge week 3 "rewards laziness""
Links: [{"title":"GitHub - midnightmichigan/sloth-inbox: Sloth Inbox — DEV Hacktoberfest 2026 Week 3 challenge entry · GitHub","url":"https://github.com/midnightmichigan/sloth-inbox"},{"title":"Hacktoberfest Open-Source AI Challenge: Week 3 - DEV Challenge — DEV Community","url":"https://dev.to/challenges/hacktoberfest-week3-2026-10-19"},{"title":"SlothOps 🦥⚡: I Built an Open-Weight AI Agent That Rewards Developer Laziness (Hacktoberfest 2026 Week 3) - DEV Community","url":"https://dev.to/farhan_mallik/slothops-i-built-an-open-weight-ai-agent-that-rewards-developer-laziness-hacktoberfest-2026-1ca"},{"title":"Slothbox: A 100% Local AI Second Brain That Rewards Radical Laziness 🦥 - DEV Community","url":"https://dev.to/tejasrawool186/slothbox-a-100-local-ai-second-brain-that-rewards-radical-laziness-42l"},{"title":"SlothBrief AI: The 100% Local Overnight Chat Tamer for the Gloriously Lazy - DEV Community","url":"https://dev.to/atharv_soni_8a5d9cd294432/slothbrief-ai-the-100-local-overnight-chat-tamer-for-the-gloriously-lazy-4eoc"},{"title":"SlothSort 🦥 — An Offline AI File Organizer That Rewards Laziness - DEV Community","url":"https://dev.to/d9292s/slothsort-an-offline-ai-file-organizer-that-rewards-laziness-3266"},{"title":"Congrats to the Hacktoberfest Weekend Challenge: Build for a Friend Winners! - DEV Community","url":"https://dev.to/devteam/congrats-to-the-hacktoberfest-weekend-challenge-build-for-a-friend-winners-pgc"},{"title":"SlothTidy - DEV Community","url":"https://dev.to/5aitama/slothtidy-5fk3"},{"title":"Week 3 hacktoberfest challenge post - DEV Community","url":"https://dev.to/nayeemul_islam_220588ffa5/week-3-hacktoberfest-challenge-post-52l5"},{"title":"Loafline: Open-Weight AI That Gives You Permission to Defer (Hacktoberfest Week 3) - DEV Community","url":"https://dev.to/smriad/loafline-open-weight-ai-that-gives-you-permission-to-defer-hacktoberfest-week-3-3cp5"}]
The Week 3 challenge is titled "Slow and Steady," and asks for a project built with open-weight models or open-source AI that rewards laziness. Tomorrow, October 20, is International Sloth Day, and we think sloths have it figured out. The window runs October 19 to October 25. Today is the final day, so entries close tonight at 11:59 PM PDT, per the GitHub entry, which says winners are announced the week of Oct 26.
Entry rules, per the challenge page as quoted in a GitHub entry: open-source AI must be central, through an open-weight model, an open-source agent harness, or local inference. One submission per challenge; new project built inside the window. Solo is OK (teams up to 4).
The prize totals conflict. One DEV post says $2,850 in prizes across 21 winners. The GitHub entry lists a total pool of $2,450, and an earlier DEV winners post cites $2,450 across 17 winners. I could not confirm which is correct.
Many DEV submissions already use this theme, including SlothOps, Slothbox, SlothBrief AI, SlothSort, Loafline, and Downloads Sloth. These are self-published community posts, not official DEV material.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
{
"query": "Devpost hackathon winners what separates winning projects demo video problem story",
"mode": "standard"
}
Web search results for query: "Devpost hackathon winners what separates winning projects demo video problem story"
Links: [{"title":"By Palash Taneja","url":"https://www.freecodecamp.org/news/a-simple-checklist-to-help-you-win-or-have-fun-at-hackathons-98030be1a6c8/"},{"title":"by Palash Taneja","url":"https://medium.freecodecamp.org/a-simple-checklist-to-help-you-win-or-have-fun-at-hackathons-98030be1a6c8"},{"title":"DEV Community","url":"https://dev.to/deepgram/a-deepgram-hackathon-recap-what-makes-a-good-submission-4504"},{"title":"how to win shipaton part 4 pitching your app","url":"https://www.revenuecat.com/blog/engineering/how-to-win-shipaton-part-4-pitching-your-app.md"},{"title":"Lessons from Winning Five Hackathons","url":"https://sustained.substack.com/p/lessons-from-winning-five-hackathons"},{"title":"reskilll.com","url":"https://reskilll.com/blogs/wp-json/wp/v2/posts/136"},{"title":"From Idea to App: A Step-by-Step Guide to Hackathon Success","url":"https://blogs.reskilll.com/from-idea-to-app-a-step-by-step-guide-to-hackathon-success/"},{"title":"A Deepgram Hackathon Recap: What makes a good submission?","url":"https://blog.deepgram.com/deepgram-hackathon-recap"},{"title":"5 November, 2025 (Last Updated)","url":"https://www.placementpreparation.io/blog/?p=17721"},{"title":"how to win shipaton part 4 pitching your app","url":"https://www.revenuecat.com/blog/engineering/how-to-win-shipaton-part-4-pitching-your-app"}]
The search turned up mostly practitioner blog posts rather than Devpost's own judging data, so treat these as anecdotal patterns.
- **Real-world impact:** One winner's checklist puts a problem-solving focus first. Its author says most hackathons treat real-world impact as a big judging criterion, which can be up to 25% of the total score.
- **Demo and video:** A sponsor-side recap argues that a live preview or video recording helps tell the story of the code. A separate account of winning five hackathons calls the video the judges' main entry point and says missing it can sink an entry.
- **Problem story:** A RevenueCat guide frames the Devpost write-up as a pitch rather than a feature list, and argues that the story behind a project, not just the technology, is what sticks with judges. Another guide notes that readers should understand the problem you're trying to solve from the write-up.
- **Strategy over raw skill:** One playbook argues that the teams that win aren't always the best coders; they're the ones who understand the problem most deeply.
Overall, the consensus is that a clear, real problem, a working demo or engaging video, and a tight narrative matter more than technical polish alone. None of these sources report systematic data on what Devpost judges actually reward, so the rankings they describe are opinion-based.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
I'm looking up the judging criteria on the challenge page, then I'll write the script.
{
"url": "https://dev.to/challenges/hacktoberfest-week3-2026-10-19",
"prompt": "List the exact judging criteria, submission requirements (video? length?), prize categories, and the theme wording."
}
**Judging criteria**
- "Writing Quality" (weighted most heavily)
- "Relevance to the Prompt and Theme"
- "Creativity"
- "Technical Execution"
- "Use of Partner Technology" (optional)
**Submission requirements**
- **Video:** Not required. The template asks for "a deployed link or a video demo" in the Demo section.
- **Length:** No length requirement is stated.
- **Sections:** What I Built, Demo, Code (embed a GitHub repo), How I Built It, Why Does Open Innovation Matter?, and optional sections for My Agent Session and The Lazy Payoff.
- **Agent session:** Optional. You can save it with DevRelay and embed it, or link to it.
- **Teams:** Pick one member to publish and list teammates' DEV usernames in the post.
- **Deadline:** Submissions are due October 25, 2026.
**Prize categories**
- **Top prize:** $250 USD, plus DEV++ Membership and an exclusive winner badge. The page says there is one overall winner.
- **Featured categories:** $200 each.
- **Best Use of Render:** Described as "Use Render as your project's AI runtime, to host an agent's front end, or to run Hermes or OpenClaw."
- **Best Use of TabPFN:** Prior Labs' tabular model, used to forecast, predict, classify, or spot anomalies from historical data.
- The page says there are 20 partner categories in total. The content is truncated, so I can't list the rest, including the Thinking Machines category.
**Theme wording**
- Week 3 is titled "Slow and Steady."
- The theme is: "Build something with open-weight models or open-source AI that rewards laziness."
[WebFetch note: this page's text is 298058 characters long and the answer above covers only characters 0 to 100000; the final 198058 were not read — to read on, call WebFetch again with the same url and offset: 100000.]
{"url":"https://dev.to/devteam/congrats-to-the-hacktoberfest-weekend-challenge-build-for-a-friend-winners-pgc","prompt":"What did the winners have in common? Quote what judges praised about each winni...
The page doesn't include what judges said about any individual project. It lists winners, categories, and titles, but no judge commentary. The closest thing is a general remark from the announcement: "Many of you told us how the person you built for reacted, and those stories made judging both a joy and a challenge."
The page does show what the winners had in common:
- **Built for one real person.** The announcement says submissions were for "parents, grandparents, siblings, classmates, roommates, and friends." Titles name a parent, a grandfather, a sister-in-law, a classmate, a designer friend, and a friend with ADHD.
- **Open-source AI at the core.** The announcement frames the challenge around projects that show "why open models matter."
- **Local, private operation.** Several titles emphasize running on the user's own machine. Examples include "Kharcha: a 4B model that reads Indian bank SMS so the money stays on your laptop" and "Prep Buddy... that runs on his laptop with the Wi-Fi off." The announcement also says "private data stays at home."
Winners by category:
- **Overall:** "Phone calls are the boss fight of living in Japan."
- **Featured:** Project JARVIS (Arduino); "The BMW manual was off-limits, so I built my friend something better" (DigitalOcean); Gemma 4 reading parents' mail (Gemma); Tala audio-to-text (Render); a sub-5 70.3 race predictor (TabPFN); Rakshak scam-checker (Tinker).
- **Prize categories:** Ten more winners, including Kharcha (Backboard), Padh Ke Batao (ElevenLabs), and Pīzhù (SerpApi).
$ cd /home/user/unread/apps/web/src && ls features; grep -rhoE '"[A-Z][^"]{12,80}"|>[A-Z][^<>{}]{12,80}<' features/onboarding features/home features/today features/player features/landing 2>/dev/null | sort -u | head -120
action-state.ts
admin
billing
episodes
feed
forwarding
inbound
landing
links
news
onboarding
player
settings
shell
sources
today
users
"A design essay you saved and never opened"
"A fresh episode every morning"
"Also covered by"
"Back 15 seconds"
"CHAPTER 1 · 2:10"
"CHAPTER 2 · 1:45"
"CHAPTER 3 · 2:30"
"Can I listen in my own podcast app?"
"Check your choices."
"Checking every fact against its source"
"Current chapter"
"Episode in progress"
"Everything in Free"
"FRIDAY, 23 OCTOBER"
"FROM 3 NEWSLETTERS"
"FROM 4 NEWSLETTERS"
"Finish, I'll wait for the schedule"
"Forward 15 seconds"
"Forward your newsletters"
"Gathering new newsletters and links"
"In this episode"
"Is it the full newsletter read aloud?"
"M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"
"M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"
"M19 5l-10 7 10 7z"
"M20 12a8 8 0 1 1-2.4-5.7"
"M4 12a8 8 0 1 0 2.4-5.7"
"M5 5l10 7-10 7z"
"M7 4.5v15l13-7.5z"
"Make my first episode"
"Making the cover"
"Merging stories covered more than once"
"No new episode this time"
"One episode a week"
"Optional news topics"
"Pause episode"
"Pause sample episode"
"Planning the episode"
"Play sample episode"
"Play today's episode"
"Player controls"
"Previous chapter"
"Publishing to your feed"
"Recording the voice"
"SATURDAY, 24 OCTOBER"
"Sample episode coming soon"
"Sample progress"
"Sample: 6 newsletters in 9 minutes"
"Show transcript for this chapter"
"Skip this story"
"Splitting issues into stories"
"Studio-quality voices, and a library to pick from"
"The fundraising round everyone wrote about"
"Today's episode couldn't be made"
"Today's episode couldn't be made: [reason]. Try again."
"Today's episode"
"Transcript and sources"
"Unread episode cover art"
"Unread picks out what matters, cuts the repeats and keeps it short."
"Unread, back to top"
"Up to 10 minutes"
"We do the reading"
"Which newsletters work?"
"Who sees my newsletters?"
"Why AI pricing changed again this week"
"Works in your podcast app"
"Writing the script"
"YOUR SAVED LINK"
>Example episode.<
>FOR EVERYONE WITH TOO MANY NEWSLETTERS<
>Free every week. Every day on Plus.<
>Free to start · No card needed · Set up in 2 minutes<
>Free to start · No card needed<
>Good questions.<
>Got a promo code? Try Plus free. No card needed.<
>Hear each story once.<
>Make my first episode<
>Making your episode…<
>No episode yet<
>OPTIONAL · NEWS<
>Play a sample<
>STUDIO VOICES<
>Set it up once. Listen every day.<
>Set up forwarding<
>Studio voices come with Plus or a promo code.<
>This takes a few minutes. You can leave this page.<
>Transcript and sources<
>Upgrade to Plus<
>Want the news too?<
>Your first episode is made as soon as two or more stories have arrived.<
$ grep -rhoE '"[A-Z][^"]{8,90}"|>[A-Z][^<>{}]{8,90}<' features/forwarding features/onboarding features/sources features/today 2>/dev/null | sort -u | head -80; grep -rn "Waiting for\|Got \|confirmation" features/onboarding features/forwarding | head -15
"ABCDEFGHJKMNPQRSTUVWXYZ23456789"
"Action: Forward to → your Unread address. Save. Outlook doesn't send a confirmation code."
"Added to your next episode."
"Africa/Lagos"
"Another address you forward from"
"Apple Mail"
"Check your choices."
"Checking every fact against its source"
"Condition: From, and pick your newsletters (or \"
"Create a filter (e.g. From: the newsletters you want, or has the word \"
"Enter an email address."
"Episode in progress"
"FRIDAY, 23 OCTOBER"
"Finish setup"
"Finish, I'll wait for the schedule"
"Free voice"
"Gathering new newsletters and links"
"Got Morning Brew ✓"
"If any: From contains each newsletter's address."
"In this episode"
"Make my first episode"
"Make one now"
"Making the cover"
"Merging stories covered more than once"
"No new episode this time"
"OCT · FRI"
"On a Mac: Mail → Settings → Rules → Add Rule."
"Paste a link"
"Pause episode"
"Planning the episode"
"Play today's episode"
"Publishing to your feed"
"Recording the voice"
"SATURDAY, 24 OCTOBER"
"Settings → Mail → Rules → Add new rule."
"Splitting issues into stories"
"Starting…"
"Studio voice"
"That address is already on your list."
"That doesn't look like a web link."
"That's a lot of links for one hour. Try again later."
"Today's episode couldn't be made"
"Today's episode couldn't be made: [reason]. Try again."
"Today's episode"
"Transcript and sources"
"Try again"
"Waiting for your first newsletter…"
"We got a message we couldn't verify (SPF/DKIM failed), so we dropped it."
"Wednesday"
"Writing the script"
"You can add up to 5 addresses."
>Always first<
>Another address you forward from<
>Gmail sent a confirmation code<
>Making your episode…<
>No episode yet<
>Only if room<
>Or just forward any issue by hand, from any app.<
>Paste a link<
>Set up forwarding<
>Studio voices come with Plus or a promo code.<
>This takes a few minutes. You can leave this page.<
>Transcript and sources<
>Waiting for your first newsletter…<
>Your first episode is made as soon as two or more stories have arrived.<
features/forwarding/live-status.tsx:9: confirmation: { code: string | null; url: string | null; at: string } | null;
features/forwarding/live-status.tsx:19:/** "Waiting for your first newsletter…" → "Got Morning Brew ✓", plus Gmail's code (prd §9). */
features/forwarding/live-status.tsx:41: {status.confirmation ? (
features/forwarding/live-status.tsx:43: <p className="m-0 text-sm font-semibold">Gmail sent a confirmation code</p>
features/forwarding/live-status.tsx:44: {status.confirmation.code ? (
features/forwarding/live-status.tsx:46: <span className="font-mono text-2xl font-medium tracking-[0.12em]">{status.confirmation.code}</span>
features/forwarding/live-status.tsx:47: <CopyButton value={status.confirmation.code} />
features/forwarding/live-status.tsx:50: {status.confirmation.url ? (
features/forwarding/live-status.tsx:51: <a href={status.confirmation.url} target="_blank" rel="noopener noreferrer" className="text-sm">
features/forwarding/live-status.tsx:63: <span className="text-sm">Got {status.newsletters.map((n) => n.name).join(", ")}</span>
features/forwarding/live-status.tsx:68: <span className="text-sm text-text-2">Waiting for your first newsletter…</span>
features/forwarding/forwarding-guides.tsx:9: "Gmail sends a confirmation code to Unread. It appears on this page within a minute: copy it back into Gmail (or open the confirm link).",
features/forwarding/forwarding-guides.tsx:15: "Action: Forward to → your Unread address. Save. Outlook doesn't send a confirmation code.",
$ cd /home/user/unread/packages/core/src && ls; grep -rn "heard" pipeline/*.ts | grep -v test | head -15; grep -rniE "covered by|also in|welcome|today on" pipeline episodes 2>/dev/null | grep -v test | head -15
audio
billing
config.ts
cover
embeddings
episodes
feed
inbound
links
mail
model
news
pipeline
plans
promo
storage
util
voice
pipeline/build-script.ts:18: stats: { candidates: number; heard: number; clusters: number; merged: number; timingsMs: Record<string, number> };
pipeline/build-script.ts:47: const empty = (heard = 0): BuildScriptResult => ({
pipeline/build-script.ts:53: stats: { candidates: candidates.length, heard, clusters: 0, merged: 0, timingsMs },
pipeline/build-script.ts:57: const { clusters, heardItemIds } = await time("cluster", () =>
pipeline/build-script.ts:60: if (clusters.length === 0) return empty(heardItemIds.length);
pipeline/build-script.ts:69: if (script.chapters.length === 0) return empty(heardItemIds.length);
pipeline/build-script.ts:79: heard: heardItemIds.length,
pipeline/cluster.ts:56: heardItemIds: string[];
pipeline/cluster.ts:60: * Step 3: embed, drop stories already heard, and merge the same story across
pipeline/cluster.ts:89: // Already heard: nearest earlier story for this user (pgvector, cosine).
pipeline/cluster.ts:90: const heardItemIds: string[] = [];
pipeline/cluster.ts:100: from ${schema.heardItems} h
pipeline/cluster.ts:105: if (nearest && Number(nearest.similarity) >= HEARD) heardItemIds.push(c.id);
pipeline/cluster.ts:108: if (heardItemIds.length > 0) {
pipeline/cluster.ts:109: await db.update(schema.items).set({ status: "skipped" }).where(inArray(schema.items.id, heardItemIds));
pipeline/script.ts:31: return labels.length > 1 ? `${labels[0]} · also in ${labels.slice(1).join(", ")}` : (labels[0] ?? "");
pipeline/plan.ts:42: "You are the editor of Unread, a short personal podcast made from the listener's own newsletters. Rank stories for one episode: substance and novelty first, stories covered by several sources are usually more important, fresher beats older, and sources the listener often skips rank lower. Ignore pure promotions.",
pipeline/types.ts:61: /** "Morning Brew · also in The Hustle" */
$ cd /root/.claude/plugins/synced/*/product-videos/skills/product-videos/references && sed -n 1,60p visual-styles.md; grep -n -i "elevenlabs" -A4 audio.md | head -50
# Visual styles
A video has a **type** and a **visual style**. The type (launch, tutorial, explainer … in `styles.md`)
decides the structure: which beats, how long. The visual style decides how it looks and moves: the
ground, the type, the colour, the transitions, the pace. In the kit a style is called a `look`, and it
is one line in the timeline (`look: "editorial"`).
There are five looks. Each one has a default for each video type, but the user chooses. Always
show them the options, your recommendation, and why (see "Recommend, then let them choose").
## Contents
1. Choosing a look
2. Recommend, then let them choose
3. Studio
4. Editorial
5. Graphic
6. Cinematic
7. Showcase
8. Motion rules that apply to every look
9. Building a look in the kit
---
## 1. Choosing a look
Start from the video type, then adjust for the product and the audience.
| Video type | Default look | Why | Good alternative |
|---|---|---|---|
| Launch: a product with real UI to show | **Editorial** | Launches sell an outcome. One number or claim per scene, then a few real UI moments on floating cards, reads fast and works muted in a feed | Studio when the UI itself is the hero; Cinematic for a premium brand |
| Launch: an AI, agent or automation product (the product does the work on screen) | **Showcase** | The story is "watch it work": headline, then the product filling a form or finding results live, a card lifting out with the result. White and gradient read as modern and light | Studio when the viewer must learn each click |
| Launch: API, infrastructure, data (little UI) | **Graphic** | With no screens to show, a strong graphic system (grid, pixels, one loud colour) carries the story | Editorial with code cards |
| Feature drop / what's new | **Editorial** | One number or one before/after claim, then the feature on a card | Studio when the feature is an interaction (drag, type, click); Showcase when it's a live, multi-step action |
| Tutorial / walkthrough | **Studio** | People need to see the whole real screen, where the cursor goes and what changes. Stylised looks slow learning | none: keep tutorials in Studio |
| Explainer (how it works) | **Editorial** | Diagrams on paper, cards and connector lines read like a clear explanation | Graphic for data flows and pipelines |
| Kinetic-type teaser | **Graphic** | Big type, motifs and slabs on the beat; no UI needed | Cinematic for a slower, moodier tease |
| Dashboard / metrics reveal | **Editorial** | Odometers, heatmaps and meters are the editorial look's native moves | Studio when the real dashboard must be shown whole |
| Developer integration | **Graphic** | Code cards on a blueprint grid, endpoints and payloads as graphic objects | Studio for a settings-screen walkthrough |
| Onboarding welcome | **Studio** | New users must recognise the screens they are about to use | Cinematic for a warm brand welcome |
| Before / after | **Studio** | Both real screens, the same frame, a wipe between them | Editorial with two floating cards |
| Social cutdown | **Same look as its parent video** | A cutdown is the same story, shorter; changing look breaks the series | Editorial or Graphic make good muted-feed cutdowns of a Studio video, because they are text-led |
Then adjust:
- **The product's own brand.** A dark, premium brand → Cinematic. One loud brand colour (orange,
electric blue) and a geometric identity → Graphic. A light, neutral or friendly brand → Editorial.
A bright, gradient-led SaaS brand (blue to violet, white UI) → Showcase.
The look borrows the brand's accent colour (`c.brand`) in every case.
- **Where it will be watched.** LinkedIn and X autoplay muted: Editorial and Graphic carry the story in
on-screen words and work without sound. YouTube, docs and sales calls are watched with sound: Studio
with a voiceover is fine.
- **Voiceover or not.** Studio and Cinematic suit narration. Editorial and Graphic are usually
music-led: the words on screen *are* the script (both reference launches had no voiceover).
- **How much real UI there is.** Many real screens → Studio or Editorial. Hardly any → Graphic.
## 2. Recommend, then let them choose
Ask about the look in the first batch of questions (with type, length and shape). Give one
recommendation with a reason tied to *their* video, and the other three in one line each. Example:
15:7. ElevenLabs through MCP
16-
17----
18-
19-## 1. Choosing tools
--
24:| Voice, best quality | **ElevenLabs** | Free tier forbids commercial use and needs attribution; Starter ($6/mo) and up allow commercial use | Most natural; also gives timestamps. Hosted MCP for Claude Code. |
25-| Voice, other APIs | OpenAI, Google, Azure TTS | Per-character pricing; check each provider's disclosure rules for AI voices | Fine alternatives; same pipeline. |
26-| Music, free, no licence | **The kit's synthesiser** (`npm run music`) | Original, no third party | Upbeat pop-house (128 BPM) or a calm modern beat (100 BPM), mixed to match measured launch tracks (section 3). Fine to ship; a licensed or AI track adds a melody people remember. |
27:| Music, AI | **ElevenLabs Music** | Commercial from Starter; free requires "Eleven Music" credit | Prompt for mood, tempo, length. |
28-| Music, local AI | Stable Audio Open Small | Stability Community Licence: free under $1M annual revenue | Needs a Hugging Face account and Python; heavy on CPU. |
29-| Music, library | Pixabay Music, YouTube Audio Library | Free commercial use | Pixabay tracks can trigger YouTube Content ID claims; its licence certificate clears them. |
30-| Avoid for commercial work | MusicGen weights | CC-BY-NC | Non-commercial only. |
31-| Sound effects | **The kit's synthesiser** (`npm run sfx`): click, whoosh, pop | Original, no third party | Section 4. |
--
52:Switching to ElevenLabs or another API: replace `speak()` in `scripts/voice.mts` with an API call
53-that writes a WAV (or MP3) and returns its duration; keep the rest. Put API keys in an environment
54-variable or a git-ignored `.env`, never in the code.
55-
56-## 3. Music
--
136:## 7. ElevenLabs through MCP
137-
138:ElevenLabs hosts an MCP server for Claude Code (text-to-speech, music, sound effects, voice design):
139-
140-```bash
141:claude mcp add --transport http elevenlabs https://api.elevenlabs.io/v1/mcp
142-# then /mcp in an interactive session to sign in with OAuth
143-```
144-
145:The older self-hosted `elevenlabs-mcp` package is archived. Sign-in needs an interactive session;
146-if the current one isn't, ask the user to run it. MCP is handy for auditioning voices and music;
147-for repeatable builds, a script calling the API with a key from the environment is easier to re-run.
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script.md
File created successfully at: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script.md (file state is current in your context — no need to Read it back)
The first draft of the script is ready for you to review. It runs about 2:20 at 16:9, in three acts. It has two ElevenLabs voices: you telling the story, and the host voice of the episode itself, which you only hear once someone presses Play.
What the research said
- Writing quality carries the most weight in this challenge. The other criteria are fit with the "rewards laziness" theme, creativity, technical execution and use of partner technology. The video supports the post, so it needs one idea that sticks, not a list of features.
- Recent DEV winners were concrete and personal. The overall winner of the last weekend challenge was "Phone calls are the boss fight of living in Japan". The winning entries were built for one real person and kept the open model at the centre.
- Hackathon advice: open with a frustration the viewer has felt, show a moment the viewer will remember, and say what the screen can't show (the pipeline, why each model was chosen, how output is checked) instead of describing clicks.
- Many entries this week use a sloth name (SlothOps, Slothbox, SlothBrief…). Unread stands out by keeping the laziness honest rather than leaning on a mascot.
The script (on-screen action in short, then the voiceover)
ACT 1 · The problem
1. Cold open. A mail folder named "Unread" counts up to [1,247]. "This is my inbox. [1,247] unread newsletters. … I subscribed to every one of them on purpose."
2. The overlap. Four newsletters arrive in one hour, and the same phrase lights up in all four subject lines. "And on a good morning, four of them tell me the same story. Same launch, same numbers, four times. … I'm not going to read them. I'm lazy."
3. The turn. The phone goes into a pocket; earbuds go in. "But I do have a [forty]-minute commute, and my ears are free. So I made the reading someone else's job."
4. Title. The "Unread" folder label lifts out of the inbox and becomes the logo. "This is Unread."
ACT 2 · How it works, shown (a step rail along the bottom uses the app's real progress labels)
- Forward once. The real onboarding screen: copy the address, then the Gmail steps. "Waiting for your first newsletter…" changes to the confirmation code card, then "Got Morning Brew ✓". "Setup is one Gmail filter… Gmail sends a confirmation code; Unread catches it and shows it right here. After that, I never touch it again."
- The email arrives. An envelope goes Gmail → Cloudflare Email Worker (signed) → Unread, and a stranger's email bounces off. "…Mail that isn't from me is dropped before any model sees it."
- Same story, heard once: the key moment. Stories become dots; four dots from four newsletters drift together; "Gemma 4: same story?" → "Yes"; the four cards fold into one chapter, "Morning Brew · also in TLDR, The Hustle". Then a story from yesterday comes back, meets "Heard Thursday" and fades out. "EmbeddingGemma turns each story into a vector, and pgvector on Tiger Data finds the ones that are really the same story. When it's close, Gemma 4 makes the call. Four newsletters become one chapter that credits all four. … And if one brings it up again next week, Unread remembers I've heard it, and skips it."
- Fact check. A script line with "$3B" is checked against the source, struck and rewritten. "Every line has to cite its source. If a number isn't in the newsletter, the line gets rewritten, or cut."
ACT 3 · The payoff
- Voice. "Free episodes are read by Kokoro, an open-weight voice on our own worker. Studio voices come from ElevenLabs. Temporal runs it all on schedule, before my commute."
- Press play. The real Today and Now playing screens: tap Play, the host starts ("Good morning. Today's episode comes from four newsletters…"), and "Transcript and sources" slides up. Then you: "[Nine] minutes. Every story once. Every claim linked to where it came from. And it plays in my own podcast app."
- Why open. A wall of the tools, each labelled with where it's used. "Gemma does the thinking, Kokoro does the free voices, both open. That's what makes a free plan possible at all."
- Close. Back to the opening inbox, still at [1,247]. "My inbox still says [1,247] unread. … I'm caught up anyway." End card: "Stay lazy. Stay informed."
The full script, with screen directions and a check of every claim against the code, is in /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script.md.
Claims that aren't true yet
Some parts of the flow aren't live, and the video shouldn't show them as working:
- Nothing is deployed. "Render runs it" and the getunread.site address stay out unless they're live before we record.
- Tiger Data hasn't been migrated, and Gemma has never run against real newsletters. The host lines in step 10 and the merged chapter should come from a real run, not made-up text. That needs a GEMINI_API_KEY or OPENROUTER_API_KEY. Without one, I'll label the episode content "example".
- "Every morning" would only be true on Plus, since Free is weekly. I wrote "on schedule" instead.
Questions before I build
- Your real numbers: how many unread newsletters you have, and how long your commute is. The story only works with real figures.
- Newsletter names: should I use the ones you actually forward (Morning Brew, TLDR…), or made-up names?
- Voices: which ElevenLabs voice for you and which for the host? My default would be two clearly different premade voices. Generating them uses about 2,500 characters of credits, and I'll ask before spending them. If your plan is the free tier, ElevenLabs doesn't allow commercial use and requires attribution.
-
Look: I recommend Cinematic. Unread's near-black, single-accent brand matches it, and it suits a narrated story. The other options:
- Editorial: text-led, works with the sound off.
- Showcase: light and gradient, "watch the AI work".
- Studio: plain, full real screens.
- Graphic: bold type and colour slabs.
- Extras: do you want a 9:16 cutdown for X/LinkedIn, and can you get a Gemma key so the episode in the video is real?
Sources:
- DEV Week 3 challenge page
- Build for a Friend winners
- Reskilll demo tips
- AngelHack demo tips
- Lessons from winning five hackathons
- RevenueCat on pitching
- Deepgram hackathon recap
- Week 3 entries
@"/root/.claude/uploads/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/c13f96bc-Deis-Launch-Video-SnapYT.App.mp4" also check this winner video, identify what made theirs good
I'll pull frames, scene cuts and the audio from the video and measure them.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad && V="/root/.claude/uploads/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/c13f96bc-Deis-Launch-Video-SnapYT.App.mp4"; ffprobe -v error -show_entries format=duration:stream=codec_type,width,height,r_frame_rate,sample_rate,channels -of compact "$V"; ls /root/.claude/plugins/synced/*/product-videos/skills/product-videos/assets/motion-lab; sed -n 1,40p /root/.claude/plugins/synced/*/product-videos/skills/product-videos/references/motion-data.md
stream|codec_type=video|width=640|height=360|r_frame_rate=2997/100
stream|codec_type=audio|sample_rate=44100|channels=2|r_frame_rate=0/0
format|duration=61.114921
README.md
analysis
data
package.json
src
tsconfig.json
# Measured motion
The kit's easings used to be chosen by eye. This file is about motion **measured** from real launch
videos instead: every frame of a reference video analysed with optical flow (tracking how pixels move
from one frame to the next), each animation beat fitted to Remotion's own `Easing.bezier` or
`spring()`, and the fits checked by rendering them and measuring the render the same way. Four
reference launches have been measured so far. The data lives in `src/kit/measured/`, and the tooling
that produced it is `assets/motion-lab/`.
Read this when you want a video to move like a professional reference, when choosing the timing of
a showcase-look video, or when the user gives you a reference video to match.
## Contents
1. What was measured
2. What the numbers say (and what holds across all four references)
3. Presets by role
4. Using presets in a scene
5. The full library
6. Measuring a new reference video
7. Checking a render against a reference
8. Limits
---
## 1. What was measured
The first reference is a 108-second, 1080p, 60 fps launch video for an AI web app: a white canvas,
a blue-to-violet gradient on accent words, and eight features each shown as a headline followed by a
live UI demo, with an agent filling forms, cards lifting out of the screen and words dropping in.
The showcase look (`visual-styles.md`) is built from it.
| Part | Time | What happens |
|---|---|---|
| Problem | 0–7 s | A line types out and its last word is selected; task chips drift in; app icons orbit the problem statement |
| Pivot | 7–9 s | "But…" in corner brackets, then a one-line question in the same bracket |
| Brand | 9–15 s | The logo assembles from shards; the tagline drops in word by word; a full-frame gradient wipe |
| Features | 15–103 s | Eight times: a headline (words dropping in along curves, one gradient word) → a live demo in a browser (cursor, typing, an agent's side panel, a card lifting out of the page) |
| Close | 103–108 s | The app icon shatters into the wordmark; the tagline and URL |
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad && mkdir -p deis/frames && V="/root/.claude/uploads/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/c13f96bc-Deis-Launch-Video-SnapYT.App.mp4"; ffmpeg -v error -i "$V" -vf "select='gt(scene,0.25)',showinfo" -vsync vfr deis/frames/cut_%03d.jpg 2>&1 | grep -oE "pts_time:[0-9.]+" | tr '\n' ' '; echo; ffmpeg -v error -i "$V" -vf fps=1,scale=640:-1 deis/frames/s_%03d.jpg; ls deis/frames | wc -l; ffmpeg -v error -i "$V" -af "volumedetect,ebur128=framelog=quiet" -f null - 2>&1 | grep -E "mean_volume|max_volume|I:|LRA:" ; ffmpeg -v error -i "$V" -af silencedetect=noise=-35dB:d=0.4 -f null - 2>&1 | grep -oE "silence_(start|end): [0-9.]+" | tr '\n' ' '
61
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad && V="/root/.claude/uploads/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/c13f96bc-Deis-Launch-Video-SnapYT.App.mp4"; ffmpeg -hide_banner -i "$V" -vf "select='gt(scene,0.25)',showinfo" -an -f null - 2>&1 | grep -oE "pts_time:[0-9.]+" | cut -d: -f2 | tr '\n' ' '; echo; ffmpeg -hide_banner -i "$V" -vn -af ebur128 -f null - 2>&1 | grep -A6 Summary | grep -E "I:|LRA:"; ffmpeg -hide_banner -i "$V" -vn -af silencedetect=noise=-35dB:d=0.4 -f null - 2>&1 | grep -oE "silence_(start|end): [0-9.]+" | tr '\n' ' '
I: -13.9 LUFS
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/deis && V="/root/.claude/uploads/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/c13f96bc-Deis-Launch-Video-SnapYT.App.mp4"; ffmpeg -hide_banner -i "$V" -vf "select='gt(scene,0.1)',showinfo" -an -f null - 2>&1 | grep -oE "pts_time:[0-9.]+" | cut -d: -f2 | tr '\n' ' '; echo; for i in 0 1 2 3; do ffmpeg -v error -y -start_number $((i*16+1)) -i frames/s_%03d.jpg -frames:v 1 -vf "scale=400:-1,drawtext=text='%{n}':x=5:y=5:fontcolor=red:fontsize=1,tile=4x4" sheet_$i.jpg 2>&1 | tail -1; done; ls
28.3283 36.5699
frames
sheet_0.jpg
sheet_1.jpg
sheet_2.jpg
sheet_3.jpg
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/deis/sheet_0.jpg
[image omitted from the upload]
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/deis/sheet_1.jpg
[image omitted from the upload]
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/deis/sheet_2.jpg
[image omitted from the upload]
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/deis/sheet_3.jpg
[image omitted from the upload]
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/deis && V="/root/.claude/uploads/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/c13f96bc-Deis-Launch-Video-SnapYT.App.mp4"; ffmpeg -v error -y -i "$V" -lavfi "showspectrumpic=s=1600x500:legend=1:scale=log:stop=8000" spec.png; ffmpeg -hide_banner -i "$V" -vn -af "ebur128=metadata=1,ametadata=print:key=lavfi.r128.M:file=-" -f null - 2>/dev/null | awk -F= '/pts_time/{split($0,a," ");for(i in a) if(a[i]~/pts_time/){split(a[i],b,":");t=b[2]}} /r128.M/{m=$2; s=int(t); if(s!=last){printf "%d:%.0f ",s,m; last=s}}'
1:-17 2:-12 3:-12 4:-16 5:-17 6:-14 7:-29 8:-12 9:-29 10:-12 11:-12 12:-15 13:-11 14:-13 15:-19 16:-19 17:-14 18:-15 19:-21 20:-13 21:-14 22:-35 23:-11 24:-21 25:-13 26:-27 27:-21 28:-15 29:-13 30:-13 31:-15 32:-14 33:-14 34:-15 35:-14 36:-15 37:-13 38:-13 39:-15 40:-16 41:-12 42:-15 43:-17 44:-18 45:-13 46:-14 47:-12 48:-13 49:-15 50:-13 51:-17 52:-12 53:-15 54:-14 55:-14 56:-15 57:-17 58:-13 59:-14 60:-23 61:-41
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/deis/spec.png
[image omitted from the upload]
The video has a voiceover under a music bed, with the words appearing on screen as they're spoken. I'll transcribe it to get the exact script.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/deis && V="/root/.claude/uploads/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/c13f96bc-Deis-Launch-Video-SnapYT.App.mp4"; ffmpeg -v error -y -i "$V" -ac 1 -ar 16000 -f f32le audio.f32 && ls -la audio.f32; T=$(find /home/user/unread/node_modules/.pnpm -maxdepth 1 -name "@huggingface+transformers@3.8.1*" | head -1); echo $T; cat > /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/asr.mjs <<EOF
import { pipeline, env } from "$T/node_modules/@huggingface/transformers/src/transformers.js";
import fs from "node:fs";
env.cacheDir = process.env.TMPDIR + "/unread-models";
const buf = fs.readFileSync(process.argv[2]);
const audio = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
const asr = await pipeline("automatic-speech-recognition", "Xenova/whisper-base.en");
const out = await asr(audio, { chunk_length_s: 30, stride_length_s: 5, return_timestamps: true });
for (const c of out.chunks) console.log(c.timestamp.map(t => t?.toFixed(1)).join("–"), c.text);
EOF
echo ok
-rw-r--r-- 1 root root 3911356 Oct 25 20:39 audio.f32
find: '/home/user/unread/node_modules/.pnpm': No such file or directory
ok
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad && mkdir -p asr && cd asr && npm init -y >/dev/null && npm i @huggingface/transformers@3.8.1 2>&1 | tail -2 && sed -i 's#^import { pipeline, env } from .*#import { pipeline, env } from "@huggingface/transformers";#' ../asr.mjs && cp ../asr.mjs . && timeout 500 node asr.mjs ../deis/audio.f32 2>&1 | grep -v Warn | tail -40
Run `npm audit` for details.
dtype not specified for "encoder_model". Using the default dtype (fp32) for this device (cpu).
dtype not specified for "decoder_model_merged". Using the default dtype (fp32) for this device (cpu).
40.0–43.0 and every line links back to a fact you gave.
44.0–47.0 What it can't prove, it cuts.
47.0–49.0 You stay in the loop.
49.0–51.0 Day is never applies for you.
51.0–56.0 It runs on your laptop, works offline, and it's free to run.
56.0–60.0 Day is, jobs you can actually get.
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/asr && timeout 500 node asr.mjs ../deis/audio.f32 2>/dev/null
40.0–43.0 and every line links back to a fact you gave.
44.0–47.0 What it can't prove, it cuts.
47.0–49.0 You stay in the loop.
49.0–51.0 Day is never applies for you.
51.0–56.0 It runs on your laptop, works offline, and it's free to run.
56.0–60.0 Day is, jobs you can actually get.
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/asr && cat > asr2.mjs <<'EOF'
import { pipeline, env } from "@huggingface/transformers";
import fs from "node:fs";
env.cacheDir = process.env.TMPDIR + "/unread-models";
const buf = fs.readFileSync(process.argv[2]);
const all = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
const asr = await pipeline("automatic-speech-recognition", "Xenova/whisper-base.en");
for (let s = 0; s < all.length / 16000; s += 20) {
const seg = all.slice(s * 16000, Math.min(all.length, (s + 21) * 16000));
const out = await asr(seg, { return_timestamps: true });
for (const c of out.chunks) console.log((s + c.timestamp[0]).toFixed(1), c.text);
}
EOF
timeout 500 node asr2.mjs ../deis/audio.f32 2>/dev/null
40.0 and every line links back to a fact you gave.
44.0 What it can't prove, it cuts.
47.0 You stay in the loop.
49.0 Day is never applies for you.
51.0 It runs on your laptop, works offline, and it's free to run.
56.0 Day is, jobs you can actually get.
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/asr && sed -i 's#for (const c of out.chunks).*#console.log(s, JSON.stringify(out.text));#' asr2.mjs && sed -i 's/s += 20/s += 10/; s/(s + 21)/(s + 11)/' asr2.mjs && timeout 500 node asr2.mjs ../deis/audio.f32 2>/dev/null
0 ""
10 ""
20 ""
30 ""
40 " and every line links back to a fact you gave. What it can't prove, it cuts. You stay in the loop. Day is never applies for you."
50 " It runs on your laptop, works offline, and it's free to run. Stay as jobs you can actually get."
60 ""
Shell cwd was reset to /home/user/unread
$ grep -nE "^\s*--(brand|background|surface|accent)[a-z-]*:" apps/web/src/app/globals.css | head -10
15: --surface: #17181d;
22: --brand: #5b9bff;
29: --background: var(--ground);
41: --accent: var(--surface-2);
42: --accent-foreground: var(--text);
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad && cat > deis-analysis.md <<'EOF'
# Deis launch video: breakdown (61 s, 16:9, dark ground, one lime accent)
Measured: 61.1 s; voiceover over a music bed; loudness -13.9 LUFS. The only near-silence is at 22.5 s
("Until… now."). The music lifts at 28.5 s, right after the brand reveal. There are only 2 hard
cuts in 61 s: everything else is continuous motion.
Every spoken word appears on screen, word by word, as it is said, so it works muted too.
| Time | Beat | On screen | Words (VO = on-screen text) |
|---|---|---|---|
| 0–5 | Hook: scale + a specific audience | Grid of tiles lighting up (each one a graduate) | "Every year, thousands of graduates in Ireland start the same search." |
| 5–9 | Effort | Endless job forms scrolling | "Fifty tabs. Forty forms." |
| 9–15 | Volume | One CV becomes a fan of identical CVs | (built on the line above) |
| 15–22 | The real pain, shown as objects | Job cards stamped TOO SENIOR · WRONG LANGUAGE · NO SPONSORSHIP; a rejection email notification | "…for jobs that were never meant for them. You only find out after you've applied." |
| 22–24 | The pause | Black | "Until… now." |
| 24–28 | Reveal + borrowed mental model | Logo on a gradient card | "deis. Tinder, but for jobs." |
| 28–37 | Core interaction | Real phone UI, three checks: level · languages · right to work | "Every card is a job you can actually get. Swipe left to skip. Swipe right to apply." |
| 37–47 | The AI, and the trust | CV writes itself; lines link to facts; an unprovable line is struck in red | "Gemma writes your CV, cover letter and answers. Every line links back to a fact you gave. What it can't prove, it cuts." |
| 47–51 | Control | Desktop, an approve button | "You stay in the loop. Deis never applies for you." |
| 51–56 | Why open, as a benefit | Settings panel | "It runs on your laptop. Works offline. Free to run." |
| 56–61 | Close, echoing the problem | Logo, tagline, URL pill, platform line | "deis. Jobs you can actually get." |
What made it good:
1. Short: 61 s and about 110 words. Every sentence is 7 words or fewer.
2. The problem is shown as objects (stamps, stacks, a rejection email), not as UI and not as statistics.
3. One silence. "Until… now." is the only pause, and the music changes right after it.
4. A borrowed mental model in four words ("Tinder, but for jobs").
5. Parallel lines ("Swipe left to skip. Swipe right to apply.") that are easy to remember.
6. The fear of AI is answered head-on: it cites, it cuts what it can't prove, and it never acts for you.
7. The open model is woven into a benefit ("Gemma writes…", "free to run"); there is no logo wall.
8. The close echoes the pain ("never meant for them" → "jobs you can actually get").
9. One visual system: near-black ground, a lime glow, the accent on the second line of each headline, real UI in a phone frame, and captions that show the spoken words.
10. Second person ("you") all the way through, so the viewer is the hero.
EOF
echo done
done
Shell cwd was reset to /home/user/unread
Now I'll write a tighter draft 2 that applies what Deis did.
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script-v2.md
File created successfully at: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script-v2.md (file state is current in your context — no need to Read it back)
{"files":["/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script-v2.md","/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/d...
2 files delivered to user.
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script-v2.md → file_uuid: c392e69a-1e3f-4344-bf06-d9ae3930d583
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/deis-analysis.md → file_uuid: fd143f6e-f7bd-4d33-89b0-31b471e7b5d3
The Deis video works because it's short and every sentence earns its place. It's 61 seconds and about 110 words, with no sentence longer than seven words. I've rewritten our script along the same lines as draft 2, about 95 seconds (the first draft ran about 2:20). Both files are above.
What made Deis good
- The problem is shown as objects. Job cards get stamped TOO SENIOR, WRONG LANGUAGE and NO SPONSORSHIP, and a rejection email pops up. No statistics, no app screens.
- There's exactly one pause. "Until… now." is the only near-silence in the whole video, and the music lifts right after the reveal.
- A familiar comparison in four words: "Tinder, but for jobs."
- Short, matching lines: "Swipe left to skip. Swipe right to apply." They're easy to remember.
- It answers the worry about AI head-on: "Every line links back to a fact you gave. What it can't prove, it cuts. Deis never applies for you." Unread can say the same thing, because its citations and fact check are real.
- The open model is part of a benefit, not a logo wall: "Gemma writes your CV…" and "Runs on your laptop. Works offline. Free to run."
- The ending echoes the opening: "jobs that were never meant for them" becomes "Jobs you can actually get."
- One visual system: a near-black background, one accent colour with a soft glow, real screens in a phone frame, and every spoken word appearing on screen as it's said, so it works with the sound off. It has only two hard cuts; everything else is continuous motion.
A transcription tool caught the speech in the last 20 seconds only. For the first 40, I took the wording from the on-screen text, which matches the voiceover wherever I could check it.
How draft 2 uses this
- A "SAME STORY" stamp lands on three of the four identical newsletters: our version of their rejection stamps.
- One silence before the reveal: "So… I stopped reading." Then "Unread. Your newsletters, as a podcast."
- Matching lines: "Four newsletters. One chapter. Every source credited." and "Heard it last week? It's skipped."
- The trust line: "A number it can't find in the source, it cuts."
- No tool wall. Each tool is named at the moment it does its job: Cloudflare (signing the email), EmbeddingGemma and pgvector (finding the same story), Gemma 4 (close calls and writing the script), Kokoro and ElevenLabs (voices), Temporal (schedule). That also covers your "where and how each tool was used".
- The ending echoes the opening: "My inbox still says 1,247 unread. I'm caught up anyway." The end card says "Hear each story once.", which is your landing page's own headline.
- "I" for the story, "you" for the product, so the viewer becomes the hero in the middle, like Deis.
Visual look: Deis uses the dark style I recommended (Cinematic). Ours would use Unread's own blue (--brand #5b9bff) for the glow and accent.
Still needed from you before I build
- Your real unread count and commute length.
- Real newsletter names, or made-up ones.
- Which ElevenLabs voices to use for you and for the host. Both together come to about 1,500 characters, and I'll ask before spending them.
- A Gemma key (
GEMINI_API_KEYorOPENROUTER_API_KEY) if you want the episode in the video to be real output. Without it, I'll label the episode content as an example. - Whether a site link goes on the end card. getunread.site isn't live, so otherwise I'll use the repo link.
on this "Cold open. A mail folder named “Unread” counts up to [1,247]. “This is my inbox. [1,247] unread newsletters. … I subscribed to every one of them on purpose.”" i would like to add "they're important, but I'm too lazy to read them,
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad && python3 - <<'EOF'
p='unread-video-script-v2.md'; s=open(p).read()
old1='| 1 | 0–6 | A mail folder named "Unread" counts up to [1,247]. The rows shrink into a wall of tiles, one per unopened issue, lighting up | YOU: "My inbox has [1,247] unread newsletters. I subscribed to every one on purpose." |'
new1='| 1 | 0–9 | A mail folder named "Unread" counts up to [1,247]. The rows shrink into a wall of tiles, one per unopened issue, lighting up | YOU: "This is my inbox. [1,247] unread newsletters. I subscribed to every one of them on purpose. They\'re important, but I\'m too lazy to read them." |'
old2='| 2 | 6–14 |'; new2='| 2 | 9–16 |'
old3='| 3 | 14–20 | The pile slides into a pocket; earbuds go in | "I\'m never going to read them. But I do have a [forty]-minute commute." |'
new3='| 3 | 16–20 | The pile slides into a pocket; earbuds go in | "But I do have a [forty]-minute commute." |'
for o,n in [(old1,new1),(old2,new2),(old3,new3)]:
assert s.count(o)==1,o; s=s.replace(o,n)
open(p,'w').write(s)
p='unread-video-script.md'; s=open(p).read()
for o,n in [('"This is my inbox. [One thousand two hundred and forty-seven] unread newsletters. … I subscribed to every one of them on purpose."','"This is my inbox. [One thousand two hundred and forty-seven] unread newsletters. … I subscribed to every one of them on purpose. They\'re important, but I\'m too lazy to read them."'),(' … I\'m not going to read them. I\'m lazy."','"')]:
assert s.count(o)==1,o; s=s.replace(o,n)
open(p,'w').write(s)
EOF
grep -n "lazy\|commute" unread-video-script-v2.md unread-video-script.md
unread-video-script-v2.md:12:| 1 | 0–9 | A mail folder named "Unread" counts up to [1,247]. The rows shrink into a wall of tiles, one per unopened issue, lighting up | YOU: "This is my inbox. [1,247] unread newsletters. I subscribed to every one of them on purpose. They're important, but I'm too lazy to read them." |
unread-video-script-v2.md:14:| 3 | 16–20 | The pile slides into a pocket; earbuds go in | "But I do have a [forty]-minute commute." |
unread-video-script.md:15:**YOU:** "This is my inbox. [One thousand two hundred and forty-seven] unread newsletters. … I subscribed to every one of them on purpose. They're important, but I'm too lazy to read them."
unread-video-script.md:23:**YOU:** "But I do have a [forty]-minute commute, and my ears are free. So I made the reading someone else's job."
unread-video-script.md:62:**YOU:** "Free episodes are read by Kokoro, an open-weight voice on our own worker. Studio voices come from ElevenLabs. Temporal runs it all on schedule, before my commute."
unread-video-script.md:76:**End card:** Unread logo · "Stay lazy. Stay informed." · [getunread.site] · github.com/codewithveek/unread
Shell cwd was reset to /home/user/unread
I've added your line, and the opening now reads:
"This is my inbox. [1,247] unread newsletters. I subscribed to every one of them on purpose. They're important, but I'm too lazy to read them."
It works well. "On purpose" followed by "important, but too lazy" names the theme in your own voice, right in the opening seconds.
I made two knock-on changes so nothing repeats:
- Draft 2: the later line "I'm never going to read them" now says the same thing, so I cut it. That scene is now just "But I do have a [forty]-minute commute." The opening runs about 9 seconds instead of 6; the total is still about 95 seconds.
- Draft 1: I removed "I'm not going to read them. I'm lazy." from scene 2 for the same reason.
Both script files are updated in the scratchpad. I'm still waiting on your real unread count, commute length, newsletter names and the two voices before I build anything.
"The turn. The phone goes into a pocket; earbuds go in. “But I do have a [forty]-minute commute, and my ears are free. So I made the reading someone else’s job.”" -> "...a 40 minute commute to the office"
Newsletter names "Engineering Leadership" by Gregor, "System Design" Neo Kim (you can confirm them)
"Claims that aren’t true yet
Some parts of the flow aren’t live, and the video shouldn’t show them as working:
- Nothing is deployed. “Render runs it” and the getunread.site address stay out unless they’re live before we record.
- Tiger Data hasn’t been migrated, and Gemma has never run against real newsletters. The host lines in step 10 and the merged chapter should come from a real run, not made-up text. That needs a
GEMINI_API_KEYorOPENROUTER_API_KEY. Without one, I’ll label the episode content “example”. - “Every morning” would only be true on Plus, since Free is weekly. I wrote “on schedule” instead." - the site is already live n works
Questions:
- real numbers: 124
- names - mentioned above
- I'm on pro
- go
- no
I'll check the live site and confirm the two newsletters.
$ curl -sS -o /dev/null -w "%{http_code} %{url_effective}\n" -L https://getunread.site; curl -sSI https://getunread.site | grep -iE "^(server|x-render|rndr|cf-ray|via|x-powered)" ; curl -sS https://getunread.site | grep -oE "<title>[^<]*</title>|\\\$[0-9]+[^<\"]{0,20}" | head -5
200 https://getunread.site/
rndr-id: e3a936c8-c37c-46d3
server: cloudflare
x-render-origin-server: Render
cf-ray: a50433f5591625bd-IAD
<title>Unread</title>
$0
$12
$2\
$2\
{
"query": "\"Engineering Leadership\" newsletter Gregor Ojstersek",
"mode": "standard"
}
Web search results for query: ""Engineering Leadership" newsletter Gregor Ojstersek"
Links: [{"title":"Engineering Leadership Newsletter","url":"https://newsletter.eng-leadership.com/about"},{"title":"Engineering Leadership","url":"https://newsletter.eng-leadership.com/p/amazing-3-years-of-writing-the-engineering"},{"title":"By Gregor Ojstersek","url":"https://newsletter.eng-leadership.com/"},{"title":"leaddev.com","url":"https://leaddev.com/?p=23111"},{"title":"Engineering Leadership","url":"https://newsletter.eng-leadership.com/p/100-resources-to-become-a-great-engineering"},{"title":"10 engineering leadership newsletters need know about","url":"https://leaddev.com/career-development/10-engineering-leadership-newsletters-need-know-about"},{"title":"Engineering Leadership | Gregor Ojstersek | Substack","url":"https://raindrop.io/piprees/news-41410732/embed/sort=-created&perpage=30&page=12"},{"title":"Engineering Leadership","url":"https://newsletter.eng-leadership.com/p/thank-you-for-supporting-engineering-b13"},{"title":"SeattleDataGuy’s Newsletter","url":"https://seattledataguy.substack.com/p/how-to-grow-from-mid-level-to-senior?open=false"},{"title":"Engineering Leadership","url":"https://newsletter.eng-leadership.com/p/engineering-leadership-now-on-video"}]
Gregor Ojstersek's Engineering Leadership newsletter is a Substack publication aimed at engineers and managers. Key points from the search:
- **Background:** The newsletter is written by Ojstersek, an Engineering Leader, who grew in his career from engineer all the way to CTO.
- **Schedule:** You can expect 2 articles every week. Wednesday's paid edition and Sunday's free edition.
- **Topics:** Topics are normally about leadership, management, all things engineering related, developing scalable products, building teams etc.
- **Audience:** It targets first-time engineering leaders, seasoned leaders, founders, and engineers who want to advance their careers.
- **Video:** Starting in March 2025, every Sunday, alongside a weekly newsletter for everyone, I'll also include a video.
- **Paid tier:** A 2023 post said the price has now increased from 6$ to 8$ / month. Paid perks include 9 learning tracks and full archive and templates.
- **Refund policy:** If you don't find value in the first 30 days, I offer a full refund, no questions asked.
- **Subscribers:** The homepage claims Over 196,000 subscribers, while a LeadDev article cited over 93,000 subscribers. The LeadDev figure is likely older, so the homepage number is the more current one.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
{
"query": "Neo Kim \"System Design\" newsletter substack",
"mode": "standard"
}
Web search results for query: "Neo Kim "System Design" newsletter substack"
Links: [{"title":"Newsletters featuring system design interview tips from industry insiders","url":"https://www.designgurus.io/answers/detail/newsletters-featuring-system-design-interview-tips-from-industry-insiders"},{"title":"Javarevisited Newsletter","url":"https://javarevisited.substack.com/p/top-11-resources-to-crack-the-system"},{"title":"292092537 nirav","url":"https://substack.com/profile/292092537-nirav"},{"title":"best substack system design newsletters","url":"https://inboxreads.co/best-substack-system-design-newsletters"},{"title":"Blogs media","url":"https://raindrop.io/Interneto/blogs-media-19045836/sort=-created&perpage=30&page=2"},{"title":"c 42013195","url":"https://substack.com/@systemdesignone/note/c-42013195"},{"title":"c 56599903","url":"https://substack.com/@systemdesignone/note/c-56599903"},{"title":"c 44703387","url":"https://substack.com/@systemdesignone/note/c-44703387"},{"title":"substack.com","url":"https://substack.com/@zakp108"},{"title":"system design in 2025","url":"https://newsletter.systemdesign.one/p/system-design-in-2025?open=false"}]
The System Design Newsletter by Neo Kim is a Substack publication, and it also runs at newsletter.systemdesign.one. Its own description calls it a weekly newsletter for learning system design. Neo Kim started it in 2023 with short-form content, publishing the first article without a set plan. Subscriber figures differ by source. A 2025 issue reports 118,000 subscribers, while a directory listing describes it as having tens of thousands of subscribers. Neo Kim's profile on another platform lists a 0.5M+ audience, which probably spans several channels.
Neo Kim has also written guest posts for Javarevisited, a Substack run by Javin Paul, including a paid piece on resources for system design interviews from October 2023.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
$ cd /home/user/unread/packages/core/src/inbound && ls; grep -rn -iE "display_?name|list-id|substack| from " *.ts | grep -v test | head -20
address.ts
auth.ts
blocks.ts
confirmation.ts
index.ts
links.ts
parse.ts
process.ts
sender.ts
signature.ts
split.ts
address.ts:1:/** Inbound aliases are 10 chars from the newInboundAlias alphabet; plus-addressing is ignored. */
address.ts:20:/** Extract bare addresses from a header value such as `Jane <jane@x.com>, bob@y.com`. */
auth.ts:1:import { domainOf } from "./address";
auth.ts:2:import type { ParsedEmail } from "./parse";
auth.ts:36: * Did the mail really come from `address`'s domain? Pass when SPF or DKIM
blocks.ts:1:import * as cheerio from "cheerio";
blocks.ts:2:import type { AnyNode, Element } from "domhandler";
confirmation.ts:1:import type { ParsedEmail } from "./parse";
confirmation.ts:15: * like "(#123456789) Gmail Forwarding Confirmation - Receive Mail from x@gmail.com".
index.ts:1:export * from "./address";
index.ts:2:export * from "./auth";
index.ts:3:export * from "./blocks";
index.ts:4:export * from "./confirmation";
index.ts:5:export * from "./links";
index.ts:6:export * from "./parse";
index.ts:7:export * from "./process";
index.ts:8:export * from "./sender";
index.ts:9:export * from "./signature";
index.ts:10:export * from "./split";
links.ts:1:import { cleanUrl } from "./blocks";
$ sed -n 1,80p sender.ts
import { addressesIn, normalizeAddress } from "./address";
import { header, type ParsedEmail } from "./parse";
export interface NewsletterIdentity {
senderEmail: string;
displayName: string;
listId: string | null;
subject: string;
}
const FORWARDED_FROM = /(?:^|\n)\s*(?:\*\*)?From:(?:\*\*)?\s*(?:"?([^"<\n]*?)"?\s*)?<?([\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+)>?/i;
const FORWARDED_SUBJECT = /(?:^|\n)\s*(?:\*\*)?Subject:(?:\*\*)?\s*(.+)/i;
const FORWARD_MARKER = /-{2,}\s*forwarded message\s*-{2,}|begin forwarded message|-{2,}\s*original message\s*-{2,}|^\s*from:.*\n\s*sent:/im;
function prettyDomain(address: string): string {
const domain = address.split("@")[1] ?? address;
const parts = domain.split(".");
const core = parts.length > 2 ? (parts.at(-2) ?? domain) : (parts[0] ?? domain);
return core.charAt(0).toUpperCase() + core.slice(1);
}
function listIdName(listId: string | null): string | null {
if (!listId) return null;
const quoted = listId.match(/^\s*"?([^"<]+?)"?\s*</)?.[1]?.trim();
return quoted && quoted.length > 1 ? quoted : null;
}
/**
* Who wrote this issue. Auto-forwarded mail keeps the newsletter's From
* header; a manual forward puts it in the body after a "Forwarded message" marker.
*/
export function detectNewsletter(email: ParsedEmail, forwarderAddresses: string[]): NewsletterIdentity {
const forwarders = new Set(forwarderAddresses.map(normalizeAddress));
const listId = header(email, "list-id");
const body = email.text ?? "";
const fromIsForwarder = forwarders.has(email.from.address);
if (fromIsForwarder && FORWARD_MARKER.test(body)) {
const markerIndex = body.search(FORWARD_MARKER);
const after = body.slice(Math.max(0, markerIndex));
const from = after.match(FORWARDED_FROM);
const subject = after.match(FORWARDED_SUBJECT)?.[1]?.trim();
if (from?.[2]) {
const address = normalizeAddress(from[2]);
return {
senderEmail: address,
displayName: from[1]?.trim() || prettyDomain(address),
listId: null,
subject: subject ?? email.subject.replace(/^(fwd?|fw):\s*/i, ""),
};
}
}
const address = email.from.address;
return {
senderEmail: address,
displayName: (!fromIsForwarder && email.from.name) || listIdName(listId) || prettyDomain(address),
listId,
subject: email.subject.replace(/^(fwd?|fw):\s*/i, ""),
};
}
/** Addresses that may have forwarded this mail: the envelope sender and Gmail's X-Forwarded-For. */
export function forwarderCandidates(email: ParsedEmail, envelopeFrom: string): string[] {
const candidates = [normalizeAddress(envelopeFrom)];
const forwardedFor = addressesIn(header(email, "x-forwarded-for"))[0];
if (forwardedFor) candidates.push(forwardedFor);
// A manual forward: header From is the user, and must match the envelope.
if (email.from.address === candidates[0]) candidates.push(email.from.address);
return Array.from(new Set(candidates.filter(Boolean)));
}
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script-v3.md
File created successfully at: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script-v3.md (file state is current in your context — no need to Read it back)
{
"files": [
"/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script-v3.md"
],
"caption": "Script draft 3, with your changes",
"status": "normal"
}
1 file delivered to user.
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video-script-v3.md → file_uuid: 4e01f8f6-3ff3-463f-8cee-34d74d27d04e
Draft 3 of the script is attached, with all your changes in.
Your changes:
- The opening count is 124. The scene is now a wall of 124 tiles, one per unopened issue.
- Scene 3 now says "But I do have a forty-minute commute to the office."
- I dropped the "four newsletters" framing to stay accurate, since you named two real newsletters. The overlap is now "Same topic. Same points. Twice.", and the merged chapter reads "Engineering Leadership · also in The System Design Newsletter". The line became "Two newsletters. One chapter. Both credited."
- Claims: the site is live, and it's served by Render, so the end card shows getunread.site. Render now appears next to Kokoro, ElevenLabs and Temporal in scene 11. Since you've said the live site works, I've stopped flagging the forwarding and pipeline claims.
- Your ElevenLabs Pro plan allows commercial use, so the AI voices are fine to publish.
I read "go" as going with the dark Cinematic look, and "no" as no 9:16 cutdown and no Gemma key.
Both newsletters are real:
- Engineering Leadership by Gregor Ojstersek.
- The System Design Newsletter by Neo Kim.
The app takes a newsletter's name from the sender name on the email. Substack may send these as something like "Gregor Ojstersek from Engineering Leadership". If your Sources page on the live site shows different names, tell me and I'll match them on screen.
One thing left that isn't real: the shared topic in scene 2, the chapter title, and the host's line in scene 12. If you forward one issue from each newsletter to the live site and send me the episode's chapter title and first few lines, the video will show real output. If not, I'll write lines that fit, and they'll be illustrative content shown under real newsletter names.
Before I build: which ElevenLabs voice should narrate as you, and which should be the host? If you don't mind, I'll pick two clearly different premade voices from your account. Otherwise I'll start building once you've confirmed the script.
you pick the voices, go ahead and build
maybe also add "Blogging Wizard" newsletter (look it up n confirm)
{
"query": "Blogging Wizard newsletter Adam Connell",
"mode": "standard"
}
Web search results for query: "Blogging Wizard newsletter Adam Connell"
Links: [{"title":"blogging wizard logo","url":"https://bloggingwizard.com/subscribe/"},{"title":"blogging wizard logo","url":"https://bloggingwizard.com/blog-niche-pdf"},{"title":"blogging wizard logo","url":"https://bloggingwizard.com/vip-blogging-resources"},{"title":"bloggingwizard.com","url":"https://bloggingwizard.com/about/"},{"title":"about me","url":"https://adamconnell.me/about-me"},{"title":"www.bloggingwizard.com","url":"https://www.bloggingwizard.com"},{"title":"Adam Connell Interview from Blogging Wizard On Taking Your Blog to The Next Level","url":"https://bloggerspassion.com/?p=1617014"},{"title":"Adam Connell Interview (2026): Tips from Blogging Wizard","url":"https://bloggerspassion.com/adam-connell-interview"},{"title":"Adam Connell","url":"https://www.godaddy.com/resources/author/aconnell"}]
The Blogging Wizard newsletter is free to join and is pitched at helping people profit from their content. Subscribers also get extra material, and you can unsubscribe at any time. Signing up gives you the latest posts plus 15+ exclusive guides and checklists that aren't published on the blog.
Adam Connell is the writer behind the newsletter. He started Blogging Wizard in 2012 as a platform to share what he was learning in a marketing agency role. He describes himself as a marketer who specializes in content marketing, SEO, and email marketing. His background includes running a marketing agency and managing content marketing for 8 figure brands. Semrush and BuzzSumo have both named him one of the world's top 50 content marketers.
The site's own pages describe Blogging Wizard as 10+ years old with millions of readers. Those are self-reported figures, and the top-50 ranking is also self-described, so I haven't independently verified them.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
$ cd /root/.claude/plugins/synced/*/product-videos/skills/product-videos && cat references/kit.md references/accuracy.md references/pitfalls.md; ls -R assets/starter | grep -v node_modules | head -80
# The starter kit
`assets/starter/` is a working Remotion project with an example video (16:9, a 9:16 cutdown and a
thumbnail). Copy it, `npm install`, and replace the example.
## Contents
1. Layout of the project
2. A video: timeline, narration, scenes, Root
2a. Looks, transitions and motion blocks
2b. Measured motion
3. The app window, camera and pointer
4. Anchors
5. Rebuilding a screen
6. Reusing scenes across videos
7. Kinetic text timed to the voice
8. Vertical and square
9. Thumbnails
10. Commands
---
## 1. Layout
```
src/
brand.ts product name, tagline, logo glyph
theme.ts stage colours, font stacks, SCREEN (the logical app viewport, 1440x900), FPS
anim.ts progress / pop / rise / track (keyframes) / typed / money helpers
Root.tsx registers videos, vertical cutdowns, per-scene compositions, thumbnails
kit/
AppStage.tsx AppWindow (window + camera + pointer), Step (headline + window), StepCopy
anchors.tsx <Anchor name>, the store, resolve()
Cursor.tsx pointer, click dip + ring + click sound, caret
Stage.tsx Backdrop, Headline, Words (kinetic), useFrameShape
fonts.ts the bundled typefaces (Inter, EB Garamond, Instrument Serif, JetBrains Mono)
looks.ts LOOKS (studio, editorial, graphic, cinematic, showcase), useLook, LookContext,
accentStyle (how a look sets *starred* words)
transitions.tsx presentationFor(): fade, focus, circle, panel, slab, zoom, push, cut, rise, wipe
Motion.tsx Odometer, AccentLine, RuleLabel, Caption, Tilt, Flip, Stamp, dealt, PersonCard,
CellGrid, Meter, WordSlot, Orbit, Chip, Swirl, Tile, DotMatrix, GridCell
showcase.tsx DropWords, SwapWord, TypeSelect, Bracket, Lift, CurveLine, GradientText, AccentWord
measured/ motion measured from reference videos: MOTION roles, measured(), easingOf(),
SAAS_LAUNCH, FURNITURE_LAUNCH, API_LAUNCH, VOICE_APP_LAUNCH (generated libraries)
Backdrops.tsx Paper, Blueprint, Slab, Glow, Stage (the current look's ground)
Cards.tsx TitleCard, EndCard
AppFrame.tsx a generic sidebar + top bar app frame (replace with the product's)
controls.tsx Card, Section, Row, Btn, Input, Select, Textarea, Toggle, Choice, Radio,
Tabs, Badge, Kpi, Dialog, CopyField, Th, Mono, PageHead
tokens.ts the app's UI tokens (replace with the product's real values)
build.tsx makeVideo: scenes + crossfades + narration + ducked music + whooshes + captions
sfx.tsx <Sfx name at volume>: play a sound effect at a frame
BurnedCaptions.tsx captions drawn into the picture
Thumbnail.tsx 16:9 thumbnail from one of the video's scenes
voice.ts lineAt(): a narration line's timing, for syncing visuals
videos/<id>/ example (studio), editorial, graphic, showcase: one working video per look
timeline.ts the cut list
narration.ts voiceover lines
voice.json generated by npm run voice
scenes.tsx one component per scene
scripts/ voice.mts, music.mts, sfx.mts, vtt.mts, audio-report.mjs
audio/ synth.mts (DSP), instruments.mts, tracks.mts, effects.mts, levels.mts (loudness, true peak, WAV)
render-all.sh normalise.sh stills.sh reference/capture.mjs
public/sfx/ mouse-click.wav, whoosh.wav, pop.wav public/music/ upbeat.wav, bed.wav (all generated)
```
## 2. A video
1. `timeline.ts`: `{ id, title, crossfade: 12, scenes: [{ id, frames, cues }] }`. `frames` is the base
length; narration may lengthen it. `cues` are fallback captions for videos without a voice. The file
must import nothing (the caption script reads it with plain Node).
2. `narration.ts`: `{ [sceneId]: [{ at, text, say? }] }`.
3. `scenes.tsx`: export each scene component and a `SCENES` map from scene id to component.
4. `Root.tsx`: add `[VIDEO, SCENES, VOICE, music?, levels?]` to `VIDEOS`; add the id to `VERTICAL` for
a 9:16 cutdown; add a `THUMBNAILS` entry.
5. `npm run voice`, then check stills, then `./render-all.sh <Title>`.
Scene components are also registered alone as `<videoId>-<sceneId>` for stills and previews, in
their video's look.
## 2a. Looks, transitions and motion blocks
Choosing a look and what each one means: `visual-styles.md`. Mechanics:
```ts
export const VIDEO = {
id: "launch", title: "Launch", look: "editorial" as const, crossfade: 14,
scenes: [
{ id: "stat", frames: 120, cues: [...] }, // look default: focus
{ id: "brand", frames: 80, in: "circle" as const, origin: [0.5, 0.42] as [number, number], cues: [...] },
{ id: "runs", frames: 140, in: "panel" as const, cues: [...] },
],
};
```
- `look` (default `studio`) sets the fonts, stage colours, accent and alarm colours, card shadow,
default transition, whoosh level and suggested music. Components read it with `useLook()`.
- Type: `look.font` for headlines and big numbers, `look.textFont` for small text around the product,
`look.accentFont` for `*starred*` words (editorial: EB Garamond italic; cinematic: Instrument Serif
italic). In custom layouts, set an accent word with `<AccentWord>` (showcase.tsx), or spread
`accentStyle(look)` into its style. The fonts are bundled (`fonts.ts`), so renders work offline. A
product's own font: install its `@fontsource` package, import the `.woff2` in `fonts.ts`, add it to
`FILES`, and reference the family in `tokens.ts` or a look.
- `in` is how a scene arrives. Every transition lasts `crossfade` frames, so timing maths and
captions are unchanged. `cut` swaps at the midpoint and gets no whoosh.
- `<Stage />` draws the look's ground; `Step` and `AppWindow` use it, so app-window scenes work in
every look (light looks get a soft shadow instead of the dark glow).
- Music-led videos (no `narration.ts`) need a `voice.json` of `{ "voi
… [16828 characters trimmed for the upload] …
ment meets the bottom edge |
| Cursor covers the text being typed | Clicked the field's centre | Click at `fx: 0.25–0.4` and move down after the click |
## Audio
| Symptom | Cause | Fix |
|---|---|---|
| Voice analysis shows 40–50% energy above 6 kHz | Reading Kokoro's 32-bit float WAV as 16-bit | Read format 3 as float (`audio-report.mjs` does) |
| `loudnorm` measuring pass fails ("encoder not found") | The null output tries to encode video | Add `-vn` to the measuring pass |
| Bundled ffmpeg lacks `showspectrumpic`, `ebur128` output | Remotion's ffmpeg is a minimal build | Analyse in Node (`audio-report.mjs`); `loudnorm` and `volume` exist |
| Finished videos sound quiet next to others | Voice + music mix lands near -19 LUFS | `normalise.sh` to -16 LUFS |
| Music sounds boomy or like a rumble on laptops | Too much energy under 250 Hz | Lower the bass, raise pads and plucks; check the spectrum split |
| Name mispronounced | espeak phonemiser | Respell in `say`; check phonemes first (`scriptwriting.md`) |
| A scene suddenly holds still for seconds | Narration longer than the scene; the voice script grew it | Shorten the line, or give the scene more to do |
## Process
| Symptom | Cause | Fix |
|---|---|---|
| A "wait until done" loop never ends | `pgrep -f "render-all.sh"` matches the waiting loop's own command line | Wait on a PID, or run the job in the background and use its completion notice |
| Captions overlap at scene joins | Cues run into the next scene's crossfade | `vtt.mts` ends each cue where the next starts |
| The product's UI changed during the work | Another session or teammate committed UI changes | Check recent commits before final renders; re-verify labels |
| A video posted on X shows no thumbnail | X previews the opening frame and ignores MP4 cover art | Open on the thumbnail (`withPoster`, on by default); upload a custom thumbnail where the platform allows it |
| Kokoro install is slow | `onnxruntime-node` and the model are ~500 MB | Install once; the voice script caches clips by text |
assets/starter:
README.md
normalise.sh
package.json
public
reference
remotion.config.ts
render-all.sh
scripts
src
stills.sh
tsconfig.json
assets/starter/public:
sfx
assets/starter/public/sfx:
mouse-click.wav
pop.wav
whoosh.wav
assets/starter/reference:
capture.mjs
assets/starter/scripts:
audio
audio-report.mjs
music.mts
sfx.mts
voice.mts
vtt.mts
assets/starter/scripts/audio:
effects.mts
instruments.mts
levels.mts
synth.mts
tracks.mts
assets/starter/src:
Root.tsx
anim.ts
brand.ts
index.ts
kit
theme.ts
types
videos
assets/starter/src/kit:
AppFrame.tsx
AppStage.tsx
Backdrops.tsx
BurnedCaptions.tsx
Cards.tsx
Cursor.tsx
Mark.tsx
Motion.tsx
Stage.tsx
Thumbnail.tsx
anchors.tsx
build.tsx
controls.tsx
fonts.ts
looks.ts
measured
sfx.tsx
showcase.tsx
tokens.ts
transitions.tsx
voice.ts
assets/starter/src/kit/measured:
api-launch.ts
furniture-launch.ts
index.ts
preset.ts
saas-launch.ts
voice-app-launch.ts
Shell cwd was reset to /home/user/unread
Blogging Wizard is confirmed: it's Adam Connell's free newsletter on blogging and content marketing. I'm setting up the video project now.
$ S=/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad; cp -r /root/.claude/plugins/synced/*/product-videos/skills/product-videos/assets/starter $S/unread-video && cd $S/unread-video && cat package.json && (npm install > npm-install.log 2>&1; echo "exit $?" >> npm-install.log) &
sleep 1; echo started
{
"name": "product-videos",
"private": true,
"version": "1.3.0",
"description": "Product videos built with Remotion: rebuilt app screens, camera and cursor, voiceover, music, captions, thumbnails.",
"scripts": {
"studio": "remotion studio src/index.ts",
"typecheck": "tsc",
"voice": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/voice.mts",
"music": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/music.mts",
"sfx": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/sfx.mts",
"vtt": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/vtt.mts",
"render": "./render-all.sh",
"audio-report": "node scripts/audio-report.mjs"
},
"dependencies": {
"@fontsource-variable/eb-garamond": "5.3.0",
"@fontsource-variable/inter": "5.3.0",
"@fontsource-variable/jetbrains-mono": "5.3.0",
"@fontsource/instrument-serif": "5.3.0",
"@remotion/cli": "4.0.530",
"@remotion/fonts": "4.0.530",
"@remotion/transitions": "4.0.530",
"lucide-react": "^1.49.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"remotion": "4.0.530"
},
"devDependencies": {
"@types/react": "^19.3.0",
"typescript": "^7.0.2"
}
}
started
$ cd /root/.claude/plugins/synced/*/product-videos/skills/product-videos/references && sed -n 1,135p audio.md; grep -n "## 6. Cinematic" -A40 visual-styles.md
# Audio: voice, music, sound effects
Remotion mixes audio itself: `<Audio>` elements inside sequences play at their frames, with a
`volume` that can be a function of the frame. The starter's `makeVideo` already places narration,
music (ducked under the voice), a whoosh on each transition, and the cursor's click sounds.
## Contents
1. Choosing tools (and their licences)
2. Voiceover
3. Music
4. Sound effects
5. Levels and loudness
6. Checking audio you cannot hear
7. ElevenLabs through MCP
---
## 1. Choosing tools
| Need | Option | Cost / licence | Notes |
|---|---|---|---|
| Voice, free, local | **Kokoro-82M** via `kokoro-js` (in the kit) | Apache-2.0; commercial use allowed | Runs on CPU at ~1.4× real time; clear, slightly flat. Best voice: `af_heart` (grade A), then `af_bella`. 28 English voices. |
| Voice, best quality | **ElevenLabs** | Free tier forbids commercial use and needs attribution; Starter ($6/mo) and up allow commercial use | Most natural; also gives timestamps. Hosted MCP for Claude Code. |
| Voice, other APIs | OpenAI, Google, Azure TTS | Per-character pricing; check each provider's disclosure rules for AI voices | Fine alternatives; same pipeline. |
| Music, free, no licence | **The kit's synthesiser** (`npm run music`) | Original, no third party | Upbeat pop-house (128 BPM) or a calm modern beat (100 BPM), mixed to match measured launch tracks (section 3). Fine to ship; a licensed or AI track adds a melody people remember. |
| Music, AI | **ElevenLabs Music** | Commercial from Starter; free requires "Eleven Music" credit | Prompt for mood, tempo, length. |
| Music, local AI | Stable Audio Open Small | Stability Community Licence: free under $1M annual revenue | Needs a Hugging Face account and Python; heavy on CPU. |
| Music, library | Pixabay Music, YouTube Audio Library | Free commercial use | Pixabay tracks can trigger YouTube Content ID claims; its licence certificate clears them. |
| Avoid for commercial work | MusicGen weights | CC-BY-NC | Non-commercial only. |
| Sound effects | **The kit's synthesiser** (`npm run sfx`): click, whoosh, pop | Original, no third party | Section 4. |
| Other sound effects | freesound.org CC0, `@remotion/sfx` (CC0 subset) | CC0: no attribution | For a ding or a page turn. Most of `@remotion/sfx` is memes. |
Tell the user which licences apply to what you delivered, and that AI voices are AI-generated.
## 2. Voiceover
The kit's pipeline (`scripts/voice.mts`):
1. Each video's `narration.ts` lists lines per scene: `{ at, text, say? }`. `at` is the frame the
line aims to start on; `say` is the respelt version for the voice.
2. `npm run voice` generates each line as its own clip (cached by text, voice and speed, so only
changed lines regenerate), normalises each to -1 dBFS peak, places them (a line starts at `at`,
or after the previous line plus a 6-frame gap), and writes `voice.json` with every line's frames
and each scene's `minFrames` (the end of its last line + a short tail + the crossfade).
3. `makeVideo` lengthens scenes to `minFrames`, plays each clip at its frame, and ducks the music.
4. Scenes can read `voice.json` (`lineAt(VOICE, scene, n)`) to time on-screen words to the voice —
essential for kinetic text.
Generating per line gives exact sentence timings for captions without a speech-to-text pass.
Switching to ElevenLabs or another API: replace `speak()` in `scripts/voice.mts` with an API call
that writes a WAV (or MP3) and returns its duration; keep the rest. Put API keys in an environment
variable or a git-ignored `.env`, never in the code.
## 3. Music
`npm run music` writes two tracks into `public/music/` (about 25 s):
| Track | Tempo | What's in it | Use for |
|---|---|---|---|
| `upbeat.wav` | 128 BPM, E minor | Four-on-the-floor kick with sidechained bass, claps on 2 and 4, offbeat hats and a shaker, syncopated supersaw chord stabs, a bell arpeggio in the lift sections, a riser and impact into every new section | Launches: showcase, graphic, studio |
| `bed.wav` | 100 BPM, C major | A soft kick, claps and swung hats, electric-piano chords, a sub, sparse bells | Tutorials, editorial, cinematic |
The upbeat track is built in sections (4-bar intro, then 8 bars groove, 6 lift, 2 break, repeating),
so energy rises and falls. A bar is 4 × 60 / BPM seconds: 1.875 s (56 frames at 30 fps) at 128 BPM,
2.4 s (72 frames) at 100 BPM. Cutting scenes on bars makes a music-led video feel edited to the track.
`MUSIC_BPM` changes the upbeat tempo and `MUSIC_SECONDS` the length (default 90 s; the end fades).
**How it was tuned.** Four professional launch videos were measured (spectrum, tempo, the share of
energy in transients, stereo width), and the mix was fitted to them stem by stem:
| | Tempo | Under 60 Hz | 60–250 Hz | 250 Hz–2 kHz | Above 2 kHz | Centroid | Transient share | Width |
|---|---|---|---|---|---|---|---|---|
| AI web app launch | 132 | 27% | 49% | 16% | 8% | 3.0 kHz | 0.34 | 0.32 |
| Furniture launch | 99 | 7% | 65% | 26% | 2% | 1.4 kHz | 0.19 | 0.00 |
| Developer API launch | 120 | 41% | 45% | 12% | 2% | 1.9 kHz | 0.14 | 0.32 |
| Voice app launch (piano-led) | 110 | 2% | 38% | 56% | 4% | 1.9 kHz | 0.29 | 0.03 |
| **Kit `upbeat.wav`** | 128 | 26% | 55% | 15% | 4% | 3.2 kHz | 0.20 | 0.23 |
| **Kit `bed.wav`** | 100 | 9% | 55% | 31% | 5% | 1.5 kHz | 0.22 | 0.23 |
| Previous kit synth (for comparison) | 92–103 | 0–4% | 18–31% | 59–81% | 1–6% | 0.8–2.4 kHz | 0.01–0.11 | 0.27–0.29 |
Transient share is the fraction of energy in percussive hits (harmonic–percussive separation). The old
synth sat in the low mids with almost no transients, which is what made it sound muffled and flat.
- **Levels.** Both tracks are normalised to −13 LUFS integrated, true peak ≤ −1 dBTP, so the
per-video levels in `Root.tsx` hold whichever track (or tempo) you use: about 0.5–0.6 alone and
0.16 under the voice for launches, 0.3 and 0.11 for tutorials. The duck ramps over 8 frames.
- One track under the whole video
… [3650 characters trimmed for the upload] …
words rising 6–8 frames apart, long
206- holds. UI appears in the Studio window but with fewer, slower camera moves.
207-- **Sound:** a calm bed or a slow voiceover; no whooshes, or very quiet ones.
208-- **Pace:** 5–8 s per scene; fewer scenes.
209-
210-## 7. Showcase
211-
212-Learned from a 108-second launch for an AI web app, measured frame by frame (`motion-data.md`).
213-Bright and busy in a controlled way: white space, one gradient word per headline, and the product
214-visibly *doing the work* in a browser, as an agent fills a form or finds results while the viewer watches.
215-
216-**What the reference did:**
217-
218-| Time | What happened | How it moved |
219-|---|---|---|
220-| 0–3 s | A line about the problem types out ("… has always been"); its last word, "broken", is selected like text in an editor | Typewriter with caret; a selection box with handles grows across the word |
221-| 3–7 s | Task chips (each a manual chore with its app and status: "Overdue", "No reply") drift in, blurred; app icons orbit the problem line | Depth blur, then concentric rings rotating and shrinking (accelerating into the cut) |
222-| 7–9 s | "But…" in corner brackets; the bracket resizes around "What if you only needed one app" | Brackets land with a small overshoot |
223-| 9–14 s | The logo assembles from shards; the tagline drops in word by word, then its verb phrase swaps for a second one | Words fall along a curved path from above-right and swing into place; one word in the gradient |
224-| 14–15 s | A full-frame pink → violet → blue gradient sweeps across | Gradient wipe into the first feature |
225-| 15–29 s | A web form in a browser; the extension's agent panel lists its steps (reading the page, filling in saved details …) while the form fills itself; Submit | Short scroll steps (~333 ms, firm stops), fields filling one after another, the cursor leading |
226-| 29–36 s | The application becomes a card that lifts out of the page; it joins a board of applications | Card lift with growing shadow; zoom out into the board |
227-| 36–103 s | Six more features, each: headline (one gradient word, sometimes swapped in place: "track every *application*" → "*task*") → live demo | Same vocabulary throughout: drop-in words, scroll steps, lifts, side panels popping in by scale |
228-| 103–108 s | The app icon on a lavender sky shatters into the wordmark; the one-line promise and the URL | Shards fly out; wordmark slides in with motion blur |
229-
230-**The rules it follows:**
231-
232-- **Ground:** white, with faint gradient blooms in the corners (`Glow`). UI sits in light browser
233- windows or white cards with soft violet-tinted shadows.
234-- **One gradient word per headline**, the brand colour into violet. The rest of the line is dark ink.
235-- **Headlines are short and medium-weight** (Inter Display about 500, 48–64 px at 1080p), centred,
236- with plenty of space. They hold for 1.5–3 s while their words settle.
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && cat src/Root.tsx src/brand.ts src/theme.ts && ls src/videos/* && cat src/videos/editorial/timeline.ts src/videos/editorial/narration.ts 2>/dev/null | head -80
import React from "react";
import { Composition } from "remotion";
import { makeVideo, posterOffset, sceneFrames, totalFrames, withPoster, type VideoSpec, type VoiceSpec } from "./kit/build";
import { LookContext, LOOKS } from "./kit/looks";
import { Thumbnail, THUMB_FRAMES } from "./kit/Thumbnail";
import { FPS } from "./theme";
import { SCENES as EXAMPLE_SCENES } from "./videos/example/scenes";
import { VIDEO as EXAMPLE } from "./videos/example/timeline";
import EXAMPLE_VOICE from "./videos/example/voice.json";
import { SCENES as EDITORIAL_SCENES } from "./videos/editorial/scenes";
import { VIDEO as EDITORIAL } from "./videos/editorial/timeline";
import EDITORIAL_VOICE from "./videos/editorial/voice.json";
import { SCENES as GRAPHIC_SCENES } from "./videos/graphic/scenes";
import { VIDEO as GRAPHIC } from "./videos/graphic/timeline";
import GRAPHIC_VOICE from "./videos/graphic/voice.json";
import { SCENES as SHOWCASE_SCENES } from "./videos/showcase/scenes";
import { VIDEO as SHOWCASE } from "./videos/showcase/timeline";
import SHOWCASE_VOICE from "./videos/showcase/voice.json";
type Level = { alone: number; underVoice: number; ramp: number };
// One entry per video: [cut list, scenes, voice manifest, music file, music levels].
// Music: "music/bed.wav" (calm, for tutorials) or "music/upbeat.wav" (launches). Levels default to bed levels.
// The visual style is the timeline's `look` (studio, editorial, graphic, cinematic, showcase): references/visual-styles.md.
// Music-led videos (no narration) play the music at its "alone" level throughout.
const VIDEOS: [VideoSpec, Record<string, () => React.JSX.Element>, VoiceSpec, string?, Level?][] = [
[EXAMPLE, EXAMPLE_SCENES, EXAMPLE_VOICE, "music/upbeat.wav", { alone: 0.5, underVoice: 0.16, ramp: 8 }],
[EDITORIAL, EDITORIAL_SCENES, EDITORIAL_VOICE, "music/bed.wav", { alone: 0.6, underVoice: 0.16, ramp: 8 }],
[GRAPHIC, GRAPHIC_SCENES, GRAPHIC_VOICE, "music/upbeat.wav", { alone: 0.6, underVoice: 0.16, ramp: 8 }],
[SHOWCASE, SHOWCASE_SCENES, SHOWCASE_VOICE, "music/upbeat.wav", { alone: 0.6, underVoice: 0.16, ramp: 8 }],
];
// Videos that also get a 9:16 cutdown (<Title>Vertical, 1080x1920), reusing the same scenes and voice,
// with the narration burned in as captions (feeds autoplay muted).
const VERTICAL = new Set(["example"]);
// Thumbnails: [composition id (<Title>Thumbnail), eyebrow, [line 1, line 2], scene, frame of that scene].
// Each is drawn in its video's look (light looks on their own ground, dark looks on the glow).
const THUMBNAILS: [string, string, [string, string], () => React.JSX.Element, number][] = [
["ExampleThumbnail", "Introducing", ["Set up in", "one minute"], EXAMPLE_SCENES.demo, 170],
["ShowcaseThumbnail", "Introducing", ["Builds that", "ship themselves"], SHOWCASE_SCENES.track, 140],
];
/** The look of the video a thumbnail belongs to (<Title>Thumbnail → that video's timeline). */
const lookOf = (thumbnailId: string) => VIDEOS.find(([spec]) => `${spec.title}Thumbnail` === thumbnailId)?.[0].look ?? "studio";
/** A scene on its own, in its video's look (for stills and previews). */
const inLook = (spec: VideoSpec, Scene: () => React.JSX.Element) => {
const S = () => (
<LookContext.Provider value={LOOKS[spec.look ?? "studio"]}>
<Scene />
</LookContext.Provider>
);
return S;
};
const thumbnail = ([id, eyebrow, title, Scene, at]: (typeof THUMBNAILS)[number]) => {
const T = () => <Thumbnail eyebrow={eyebrow} title={title} Scene={Scene} at={at} look={lookOf(id)} />;
return T;
};
// A video with a thumbnail opens on it (withPoster), so feeds that preview the first frame show it.
// Set OPEN_ON_THUMBNAIL to false to start straight on the first scene.
const OPEN_ON_THUMBNAIL = true;
const POSTERS: Record<string, () => React.JSX.Element> = Object.fromEntries(THUMBNAILS.map((t) => [t[0].replace(/Thumbnail$/, ""), thumbnail(t)]));
const compose = (spec: VideoSpec, scenes: Record<string, () => React.JSX.Element>, voice: VoiceSpec, music?: string, level?: Level, burn = false) => {
const video = makeVideo(spec, scenes, voice, music, level, burn);
const total = totalFrames(spec, voice);
const poster = OPEN_ON_THUMBNAIL ? POSTERS[spec.title] : undefined;
return poster ? { component: withPoster(video, poster, total), durationInFrames: total + posterOffset } : { component: video, durationInFrames: total };
};
export const Root = () => (
<>
{VIDEOS.map(([spec, scenes, voice, music, level]) => (
<React.Fragment key={spec.id}>
<Composition id={spec.title} {...compose(spec, scenes, voice, music, level)} fps={FPS} width={1920} height={1080} />
{/* Cutdowns start straight on the hook: platforms that take a cover use their own picker */}
{VERTICAL.has(spec.id) && (
<Composition id={`${spec.title}Vertical`} component={makeVideo(spec, scenes, voice, music, level, true)} durationInFrames={totalFrames(spec, voice)} fps={FPS} width={1080} height={1920} />
)}
{/* Each scene on its own, for stills and quick previews: <video id>-<scene id> */}
{spec.scenes.map(({ id }, i) => (
<Composition key={id} id={`${spec.id}-${id}`} component={inLook(spec, scenes[id])} durationInFrames={sceneFrames(spec, voice)[i]} fps={FPS} width={1920} height={1080} />
))}
</React.Fragment>
))}
{THUMBNAILS.map((entry) => (
<Composition key={entry[0]} id={entry[0]} component={thumbnail(entry)} durationInFrames={THUMB_FRAMES} fps={FPS} width={1920} height={1080} />
))}
</>
);
import { Waypoints } from "lucide-react";
// The product, in one place: title cards, end cards, thumbnails and the app frame read these.
// Change them and re-render; nothing else needs touching.
export const PRODUCT = "Acme";
export const TAGLINE = "The one-line promise, said plainly.";
/** The logo glyph inside the brand tile. Any lucide-react icon, or swap Mark for an <Img> of the real logo. */
export const MarkIcon = Waypoints;
import { FONTS, stack } from "./kit/fonts";
// Retheme here. Copy the product's real tokens (its CSS variables, Tailwind config or design file),
// value for value, so the rebuilt screens are indistinguishable from the app. The UI font defaults to
// Inter (bundled, src/kit/fonts.ts); if the product uses another, add its files there and point this at it.
export const fontFamily = stack(FONTS.sans);
export const monoFamily = stack(FONTS.mono, "monospace");
/** Stage colours: backdrops, title cards and headlines around the app window. */
export const c = {
brand: "#0075ff",
brand300: "#4c9aff",
canvas: "#09090b",
surface: "#18181b",
ink: "#fafafa",
muted: "#a1a1aa",
faint: "#71717a",
green: "#57d9a3",
amber: "#ffc400",
red: "#ff8f73",
};
/** The logical screen app scenes are drawn on. Match the real app's screenshots (1440x900 is a laptop viewport). */
export const SCREEN = { w: 1440, h: 900 };
export const FPS = 30;
src/videos/editorial:
scenes.tsx
timeline.ts
voice.json
src/videos/example:
narration.ts
scenes.tsx
timeline.ts
voice.json
src/videos/graphic:
scenes.tsx
timeline.ts
voice.json
src/videos/showcase:
scenes.tsx
timeline.ts
voice.json
// An editorial-look launch: a number, the people it hurts, the brand on a circle of colour, two
// product moments, the offer. Music-led (no narration), so the cues are the captions.
// `in` picks how each scene arrives; the look's default (focus) is used where it's left out.
export const VIDEO = {
id: "editorial",
title: "Editorial",
look: "editorial" as const,
crossfade: 14,
scenes: [
{ id: "stat", frames: 120, cues: [{ from: 6, to: 120, text: "Fourteen minutes. Every push." }] },
{ id: "waiting", frames: 120, cues: [{ from: 6, to: 120, text: "Your whole team, waiting." }] },
{ id: "brand", frames: 80, in: "circle" as const, origin: [0.5, 0.42] as [number, number], cues: [{ from: 6, to: 80, text: "Introducing Acme." }] },
{ id: "runs", frames: 140, in: "panel" as const, origin: [0.5, 0.5] as [number, number], cues: [{ from: 6, to: 140, text: "Builds start in seconds." }] },
{ id: "cache", frames: 140, cues: [{ from: 6, to: 140, text: "Cached for every branch." }] },
{ id: "price", frames: 110, in: "circle" as const, origin: [0.5, 0.5] as [number, number], cues: [{ from: 6, to: 110, text: "Starting from $19 a month per seat." }] },
],
};
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && cat scripts/voice.mts && cat src/videos/example/narration.ts | head -40 && cat src/kit/voice.ts && tail -3 npm-install.log
// Generates the voiceover with Kokoro (local, Apache-2.0) from each video's narration.ts.
// Needs kokoro-js once: npm i -D kokoro-js@1 (about 500 MB of ONNX runtime; the model, ~90 MB, downloads on first run).
// Voice: KOKORO_VOICE (default af_heart, the best-graded). Speed: KOKORO_SPEED (default 1).
// Writes public/vo/<video>/<scene>-<n>.wav and src/videos/<video>/voice.json (where each
// line lands, and how long each scene must be to fit it). Unchanged lines are reused.
// Run: npm run voice (first run downloads the ~90 MB model)
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
const FPS = 30;
const VOICE = process.env.KOKORO_VOICE ?? "af_heart";
const SPEED = Number(process.env.KOKORO_SPEED ?? 1);
const GAP = 6; // frames between two lines of the same scene
const TAIL = 14; // frames after the last line before the scene may end
const CROSSFADE = 12; // a scene's last frames are under the next one's fade
type Line = { at: number; text: string; say?: string };
/** Copies a 32-bit float WAV, scaled so its peak sits at -1 dBFS: every line at the same level. */
function copyNormalised(from: string, to: string) {
const b = readFileSync(from);
let off = 12;
while (b.toString("ascii", off, off + 4) !== "data") off += 8 + b.readUInt32LE(off + 4);
const n = b.readUInt32LE(off + 4) / 4;
let peak = 0;
for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(b.readFloatLE(off + 8 + i * 4)));
const gain = peak > 0 ? 0.891 / peak : 1;
for (let i = 0; i < n; i++) b.writeFloatLE(b.readFloatLE(off + 8 + i * 4) * gain, off + 8 + i * 4);
writeFileSync(to, b);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let tts: any = null;
const cacheDir = "public/vo/cache";
mkdirSync(cacheDir, { recursive: true });
async function speak(text: string): Promise<{ file: string; seconds: number }> {
const key = createHash("sha256").update(`${VOICE}|${SPEED}|${text}`).digest("hex").slice(0, 16);
const file = `${cacheDir}/${key}.wav`;
const meta = `${file}.json`;
if (existsSync(file) && existsSync(meta)) return { file, seconds: JSON.parse(readFileSync(meta, "utf8")).seconds };
if (!tts) {
const kokoro = await import("kokoro-js").catch(() => {
console.error("kokoro-js is not installed. Run: npm i -D kokoro-js@1");
process.exit(1);
});
tts = await kokoro.KokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", { dtype: "q8", device: "cpu" });
}
const audio = await tts.generate(text, { voice: VOICE, speed: SPEED });
await audio.save(file);
const seconds = audio.audio.length / audio.sampling_rate;
writeFileSync(meta, JSON.stringify({ seconds, text }));
return { file, seconds };
}
for (const dir of readdirSync("src/videos", { withFileTypes: true })) {
const narrationFile = `src/videos/${dir.name}/narration.ts`;
if (!dir.isDirectory() || !existsSync(narrationFile)) continue;
const { NARRATION } = (await import(`../${narrationFile}`)) as { NARRATION: Record<string, Line[]> };
const { VIDEO } = await import(`../src/videos/${dir.name}/timeline.ts`);
mkdirSync(`public/vo/${dir.name}`, { recursive: true });
const scenes: Record<string, { lines: { src: string; from: number; frames: number; text: string }[]; minFrames: number }> = {};
for (const scene of VIDEO.scenes as { id: string; frames: number }[]) {
const lines = NARRATION[scene.id] ?? [];
let cursor = 0;
const placed = [];
for (const [i, line] of lines.entries()) {
const { file, seconds } = await speak(line.say ?? line.text);
const src = `vo/${dir.name}/${scene.id}-${i + 1}.wav`;
copyNormalised(file, `public/${src}`);
const from = Math.max(line.at, cursor);
const frames = Math.ceil(seconds * FPS);
placed.push({ src, from, frames, text: line.text });
cursor = from + frames + GAP;
}
const end = placed.length ? placed[placed.length - 1].from + placed[placed.length - 1].frames : 0;
const minFrames = Math.max(scene.frames, end + TAIL + CROSSFADE);
scenes[scene.id] = { lines: placed, minFrames };
const grew = minFrames - scene.frames;
console.log(`${dir.name}/${scene.id}: ${placed.length} line(s), ends ${end}/${scene.frames}${grew > 0 ? `, scene grows by ${grew} frames` : ""}`);
}
writeFileSync(`src/videos/${dir.name}/voice.json`, JSON.stringify({ voice: VOICE, scenes }, null, 2) + "\n");
}
// What the voice says in each scene. `at` is the frame (from the scene's start) the line aims to
// begin on, so words land with the action on screen; a line starts later if the one before runs on.
// `say` respells words the voice misreads, while `text` stays correct for the captions.
export const NARRATION: Record<string, { at: number; text: string; say?: string }[]> = {
problem: [
{ at: 10, text: "Setting up a workspace shouldn't take a week." },
],
title: [{ at: 12, text: "Introducing Acme." }],
demo: [
{ at: 14, text: "Name your workspace, and save. That's it." },
],
outro: [{ at: 10, text: "Acme. The one-line promise, said plainly." }],
};
import type { VoiceSpec } from "./build";
/**
* When the n-th narration line of a scene starts and how long it runs, from the video's voice.json,
* so on-screen words land with the voice. Falls back to `fallback` until `npm run voice` has run.
*/
export const lineAt = (voice: VoiceSpec, scene: string, n: number, fallback = { from: 10 + n * 60, frames: 50 }) => voice.scenes[scene]?.lines[n] ?? fallback;
Shell cwd was reset to /home/user/unread
$ [ -n "$ELEVENLABS_API_KEY" ] && echo key-set; curl -sS https://api.elevenlabs.io/v1/voices -H "xi-api-key: $ELEVENLABS_API_KEY" | python3 -c "
import json,sys; d=json.load(sys.stdin)
for v in d.get('voices',[]): l=v.get('labels',{}); print(v['voice_id'], '|', v['name'], '|', v.get('category'), '|', l.get('gender'), l.get('age'), l.get('accent'), l.get('descriptive') or l.get('description'), l.get('use_case'))
" ; curl -sS https://api.elevenlabs.io/v1/user/subscription -H "xi-api-key: $ELEVENLABS_API_KEY" | python3 -c "import json,sys; d=json.load(sys.stdin); print({k:d.get(k) for k in ['tier','character_count','character_limit','status']})"
key-set
CwhRBWXzGAHq8TQ4Fs17 | Roger - Laid-Back, Casual, Resonant | premade | male middle_aged american classy conversational
EXAVITQu4vr4xnSDxMaL | Sarah - Mature, Reassuring, Confident | premade | female young american professional entertainment_tv
FGY2WhTYpPnrIDTdsKH5 | Laura - Enthusiast, Quirky Attitude | premade | female young american sassy social_media
IKne3meq5aSn9XLyUdCD | Charlie - Deep, Confident, Energetic | premade | male young australian hyped conversational
JBFqnCBsd6RMkjVDRZzb | George - Warm, Captivating Storyteller | premade | male middle_aged british mature narrative_story
N2lVS1w4EtoT3dr4eOWO | Callum - Husky Trickster | premade | male middle_aged american None characters_animation
SAz9YHcvj6GT2YYXdXww | River - Relaxed, Neutral, Informative | premade | neutral middle_aged american calm conversational
SOYHLrjzK2X1ezoPC6cr | Harry - Fierce Warrior | premade | male young american rough characters_animation
TX3LPaxmHKxFdv7VOQHJ | Liam - Energetic, Social Media Creator | premade | male young american confident social_media
Xb7hH8MSUJpSbSDYk0k2 | Alice - Clear, Engaging Educator | premade | female middle_aged british professional informative_educational
XrExE9yKIg1WjnnlVkGX | Matilda - Knowledgable, Professional | premade | female middle_aged american upbeat informative_educational
bIHbv24MWmeRgasZH58o | Will - Relaxed Optimist | premade | male young american chill conversational
cgSgspJ2msm6clMCkdW9 | Jessica - Playful, Bright, Warm | premade | female young american cute conversational
cjVigY5qzO86Huf0OWal | Eric - Smooth, Trustworthy | premade | male middle_aged american classy conversational
hpp4J3VqNfWAUOO0d1Us | Bella - Professional, Bright, Warm | premade | female middle_aged american professional informative_educational
iP95p4xoKVk53GoZ742B | Chris - Charming, Down-to-Earth | premade | male middle_aged american casual conversational
nPczCjzI2devNBz1zQrb | Brian - Deep, Resonant and Comforting | premade | male middle_aged american classy social_media
onwK4e9ZLuTAKqWW03F9 | Daniel - Steady Broadcaster | premade | male middle_aged british formal informative_educational
pFZP5JQG7iQjIQuC4Bku | Lily - Velvety Actress | premade | female middle_aged british confident informative_educational
pNInz6obpgDQGcFmaJgB | Adam - Dominant, Firm | premade | male middle_aged american None social_media
pqHfZKP75CvOlQylNhV4 | Bill - Wise, Mature, Balanced | premade | male old american crisp advertisement
{'tier': 'starter', 'character_count': 148, 'character_limit': 40000, 'status': 'active'}
$ curl -sS https://api.elevenlabs.io/v1/models -H "xi-api-key: $ELEVENLABS_API_KEY" | python3 -c "
import json,sys
for m in json.load(sys.stdin): print(m['model_id'],'|',m.get('name'),'|tts' if m.get('can_do_text_to_speech') else '', '| cost', m.get('model_rates',{}).get('character_cost_multiplier'))"; tail -3 /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/npm-install.log
eleven_v4 | Eleven v4 |tts | cost 1.0
eleven_v4_turbo | Eleven v4 Turbo |tts | cost 0.5
eleven_v3 | Eleven v3 |tts | cost 1.0
eleven_v3_conversational | Eleven v3 Conversational |tts | cost 0.5
eleven_multilingual_v2 | Eleven Multilingual v2 |tts | cost 1.0
eleven_flash_v2_5 | Eleven Flash v2.5 |tts | cost 0.5
eleven_turbo_v2_5 | Eleven Turbo v2.5 |tts | cost 0.5
eleven_turbo_v2 | Eleven Turbo v2 |tts | cost 0.5
eleven_flash_v2 | Eleven Flash v2 |tts | cost 0.5
eleven_english_sts_v2 | Eleven English v2 | cost 1.0
eleven_multilingual_sts_v2 | Eleven Multilingual v2 | cost 1.0
Run `npm audit` for details.
exit 0
/home/user/unread/docs/screenshots/u4/p-onboarding.png
[image omitted from the upload]
/home/user/unread/docs/screenshots/u4/p-today.png
[image omitted from the upload]
/home/user/unread/docs/screenshots/u4/p-nowplaying.png
[image omitted from the upload]
$ cd /home/user/unread/apps/web/src && ls features/today features/player features/onboarding features/forwarding; wc -l features/today/*.tsx features/player/*.tsx | tail -1
features/forwarding:
forwarding-guides.tsx
live-status.tsx
features/onboarding:
actions.ts
finish-form.tsx
features/player:
controls.tsx
format.ts
icons.tsx
mini-player.tsx
now-playing.tsx
player-bar.tsx
player-context.tsx
progress-bar.tsx
transcript-view.tsx
features/today:
auto-refresh.tsx
chapter-list-empty.tsx
chapter-list.tsx
detail-panel-empty.tsx
empty-episode-card.tsx
episode-card.tsx
episode-cover.tsx
failure-banner.tsx
format-date.test.ts
format-date.ts
in-progress-card.tsx
make-now-button.tsx
pipeline-steps.ts
schedule.test.ts
schedule.ts
step-progress.tsx
transcript-panel.tsx
1097 total
$ cat features/today/episode-card.tsx features/today/chapter-list.tsx features/today/pipeline-steps.ts features/today/step-progress.tsx; grep -rn "morning brief\|Your .* brief\|<h1" app/\(app\)/page.tsx app -r 2>/dev/null | head
"use client";
import type { PlayerEpisode } from "@/features/episodes/queries";
import { clock, minutesLabel, remaining } from "@/features/player/format";
import { PauseIcon, PlayIcon } from "@/features/player/icons";
import { usePlayer } from "@/features/player/player-context";
import { EpisodeCover } from "./episode-cover";
export interface EpisodeCardProps {
episode: PlayerEpisode;
/** "made at 6:00" in the user's timezone. */
madeAt: string;
sourceCount: number;
trialNote: string | null;
cover: { seed: string; day: string; caption: string };
}
function storiesLine(title: string): string {
const afterDate = title.split(": ").slice(1).join(": ");
return afterDate ? afterDate.charAt(0).toUpperCase() + afterDate.slice(1) : title;
}
/** Home's episode card (canvas screen 2): cover, what's in it, big play button, progress. */
export function EpisodeCard({ episode, madeAt, sourceCount, trialNote, cover }: EpisodeCardProps) {
const player = usePlayer();
const isCurrent = player.episode?.id === episode.id;
const playing = isCurrent && player.playing;
const time = isCurrent ? player.currentTime : 0;
const duration = isCurrent ? player.duration : episode.durationS;
const pct = duration ? (time / duration) * 100 : 0;
const onPlay = () => {
if (isCurrent) player.toggle();
else player.load(episode, { autoplay: true });
};
return (
<section aria-label="Today's episode" className="overflow-hidden rounded-[20px] border border-line bg-surface">
<EpisodeCover seed={cover.seed} day={cover.day} caption={cover.caption} />
<div className="flex flex-col gap-3.5 px-[18px] pt-4 pb-[18px]">
<div className="flex flex-col gap-1">
<p className="m-0 text-[17px] font-semibold tracking-[-0.01em]">{storiesLine(episode.title)}</p>
<p className="m-0 font-mono text-[13px] text-muted-foreground">
{minutesLabel(episode.durationS)} · made at {madeAt} · {sourceCount} sources ·{" "}
{episode.voiceProvider === "elevenlabs" ? "Studio voice" : "Free voice"}
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onPlay}
aria-label={playing ? "Pause episode" : "Play today's episode"}
className="flex size-14 flex-none cursor-pointer items-center justify-center rounded-full bg-brand text-on-brand"
>
{playing ? <PauseIcon className="size-[22px]" /> : <PlayIcon className="size-[22px]" />}
</button>
<div className="flex flex-1 flex-col gap-1.5">
<div className="h-1 overflow-hidden rounded-sm bg-line">
<div className="h-1 bg-brand" style={{ width: `${pct}%` }} />
</div>
<div className="flex justify-between font-mono text-xs text-muted-foreground">
<span>{clock(time)}</span>
<span>{remaining(time, duration)}</span>
</div>
</div>
</div>
{trialNote ? <p className="m-0 text-[13px] text-text-2">{trialNote}</p> : null}
</div>
</section>
);
}
"use client";
import type { PlayerEpisode } from "@/features/episodes/queries";
import { clock } from "@/features/player/format";
import { usePlayer } from "@/features/player/player-context";
import { cn } from "@/lib/utils";
/**
* "In this episode" (canvas screen 2). Phone: tap plays from that chapter and
* opens Now playing. Desktop: tap shows its transcript and sources on the right.
*/
export function ChapterList({ episode }: { episode: PlayerEpisode }) {
const player = usePlayer();
const isCurrent = player.episode?.id === episode.id;
const open = (index: number) => {
const chapter = episode.transcript.chapters[index];
if (!chapter) return;
player.focusChapter(index);
if (window.matchMedia("(min-width: 1024px)").matches) return;
player.load(episode, { autoplay: true, startAt: chapter.startS });
player.setExpanded(true);
};
return (
<section aria-labelledby="in-this-episode" className="flex flex-col gap-1">
<div className="flex items-baseline justify-between">
<h2 id="in-this-episode" className="m-0 text-[15px] font-semibold">
In this episode
</h2>
<button
type="button"
onClick={() => open(isCurrent ? (player.chapterIndex ?? 0) : 0)}
className="cursor-pointer text-[13px] text-link hover:text-link-hover lg:hidden"
>
Transcript
</button>
</div>
<ol className="m-0 list-none p-0">
{episode.transcript.chapters.map((chapter, index) => {
const playing = isCurrent && player.chapterIndex === index;
const focused = player.focusedChapter === index;
return (
<li key={`${index}-${chapter.startS}`}>
<button
type="button"
onClick={() => open(index)}
aria-current={playing ? "true" : undefined}
className={cn(
"flex w-full cursor-pointer items-start gap-3 border-b border-surface-2 py-3 text-left lg:rounded-lg lg:px-2",
focused && "lg:bg-surface",
)}
>
<span className="w-[34px] flex-none pt-[3px] font-mono text-xs font-medium text-muted-foreground">{clock(chapter.startS)}</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="text-[15px] leading-[1.3] font-medium">{chapter.title}</span>
<span className="text-xs text-muted-foreground">{chapter.origin}</span>
</span>
{playing ? <span aria-label="Playing" className="mt-[7px] size-2 flex-none rounded-full bg-brand" /> : null}
</button>
</li>
);
})}
</ol>
</section>
);
}
import type { PipelineStep } from "@unread/db";
/** Listener-facing names for the pipeline steps (prd §6). */
export const PIPELINE_STEP_LABELS: Record<PipelineStep, string> = {
collect: "Gathering new newsletters and links",
extract: "Splitting issues into stories",
cluster: "Merging stories covered more than once",
plan: "Planning the episode",
script: "Writing the script",
check: "Checking every fact against its source",
voice: "Recording the voice",
cover: "Making the cover",
publish: "Publishing to your feed",
};
export const PIPELINE_STEPS = Object.keys(PIPELINE_STEP_LABELS) as PipelineStep[];
export type StepState = "pending" | "active" | "done";
import type { PipelineStep } from "@unread/db";
import { cn } from "@/lib/utils";
import { PIPELINE_STEP_LABELS, PIPELINE_STEPS, type StepState } from "./pipeline-steps";
export interface StepProgressProps {
states?: Partial<Record<PipelineStep, StepState>>;
className?: string;
}
/** The step list shown while an episode is being made. */
export function StepProgress({ states = {}, className }: StepProgressProps) {
return (
<ol className={cn("m-0 flex list-none flex-col p-0", className)}>
{PIPELINE_STEPS.map((step, index) => {
const state = states[step] ?? "pending";
return (
<li key={step} className="flex items-center gap-3 border-b border-surface-2 py-2.5 last:border-b-0">
<span
aria-hidden="true"
className={cn(
"flex size-6 flex-none items-center justify-center rounded-full font-mono text-[11px]",
state === "done" && "bg-brand text-on-brand",
state === "active" && "border border-brand text-brand",
state === "pending" && "border border-line text-muted-foreground",
)}
>
{index + 1}
</span>
<span className={cn("text-sm", state === "pending" ? "text-muted-foreground" : "text-foreground")}>
{PIPELINE_STEP_LABELS[step]}
</span>
<span className="sr-only">{state}</span>
</li>
);
})}
</ol>
);
}
app/(app)/today/page.tsx:44: <PageHeading eyebrow={date.eyebrow} title="Your morning brief" />
app/onboarding/page.tsx:57: <h1 className="m-0 text-[30px] leading-[1.1] font-bold tracking-[-0.03em]">Forward your newsletters here</h1>
app/onboarding/page.tsx:78: <h1 className="m-0 text-[30px] leading-[1.1] font-bold tracking-[-0.03em]">Want us to catch you up on the news too?</h1>
app/onboarding/page.tsx:94: <h1 className="m-0 text-[30px] leading-[1.1] font-bold tracking-[-0.03em]">How should it sound?</h1>
app/privacy/page.tsx:49: <h1 className="m-0 text-[34px] leading-[1.1] font-bold tracking-[-0.03em]">Privacy</h1>
$ sed -n 1,80p app/globals.css; cat features/today/episode-cover.tsx features/today/in-progress-card.tsx
@import "tailwindcss";
@import "tw-animate-css";
/*
* Design tokens from docs/design/README.md. Unread is dark-only.
* Names follow shadcn/ui so its components pick them up; the
* Unread-specific names (surface, line, text-2, link) are aliases.
*/
:root {
color-scheme: dark;
--ground: #0e0f12;
/* Alternate band on the landing page. */
--ground-2: #121318;
--surface: #17181d;
--surface-2: #1f2128;
--line: #2a2d35;
--text: #f2f3f5;
--text-2: #d6d9df;
--muted-text: #a3a8b3;
--dim-text: #8a909c;
--brand: #5b9bff;
--on-brand: #0a1a33;
--link: #8fb8ff;
--link-hover: #b9d2ff;
--radius: 0.875rem;
--background: var(--ground);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--popover: var(--surface-2);
--popover-foreground: var(--text);
--primary: var(--brand);
--primary-foreground: var(--on-brand);
--secondary: var(--surface-2);
--secondary-foreground: var(--text);
--muted: var(--surface-2);
--muted-foreground: var(--muted-text);
--accent: var(--surface-2);
--accent-foreground: var(--text);
--destructive: #f08a7a;
--border: var(--line);
--input: var(--line);
--ring: var(--brand);
}
@theme inline {
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
--color-ground: var(--ground);
--color-ground-2: var(--ground-2);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-line: var(--line);
--color-text: var(--text);
--color-text-2: var(--text-2);
--color-dim: var(--dim-text);
--color-brand: var(--brand);
--color-on-brand: var(--on-brand);
--color-link: var(--link);
--color-link-hover: var(--link-hover);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
import { cn } from "@/lib/utils";
export interface EpisodeCoverProps {
seed: string;
day: string;
caption: string;
/** Dim the bars for empty or in-progress states. */
muted?: boolean;
className?: string;
}
/** Small deterministic PRNG so a seed always draws the same bars (mulberry32). */
function seededRandom(seed: string): () => number {
let h = 1779033703 ^ seed.length;
for (let i = 0; i < seed.length; i++) {
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
let state = h >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const OPACITIES = [1, 0.55, 1, 0.35] as const;
/**
* In-app cover, after the canvas Home card: accent bars for what's covered,
* faint bars for the rest. The feed's PNG cover is rendered by the worker (U2).
*/
export function EpisodeCover({ seed, day, caption, muted = false, className }: EpisodeCoverProps) {
const random = seededRandom(seed);
const bars = Array.from({ length: 17 }, (_, i) => {
const accent = i < 10;
// Faint bars sit under the date label, so they stay below its baseline.
const height = 28 + Math.round(random() * (accent ? 80 : 52));
return { x: 18 + i * 18, height, accent, opacity: OPACITIES[i % OPACITIES.length] ?? 1 };
});
return (
<svg
role="img"
aria-label={`Cover for ${day} ${caption}`}
viewBox="0 0 342 168"
preserveAspectRatio="xMidYMid slice"
className={cn("block h-auto w-full", className)}
>
<rect width="342" height="168" fill="var(--surface-2)" />
{bars.map((bar) => (
<rect
key={bar.x}
x={bar.x}
y={148 - bar.height}
width="10"
height={bar.height}
rx="5"
fill={bar.accent ? "var(--brand)" : "var(--text)"}
opacity={bar.accent ? (muted ? bar.opacity * 0.3 : bar.opacity) : 0.14}
/>
))}
<text
x="320"
y="44"
textAnchor="end"
fill="var(--text)"
fontFamily="var(--font-geist-sans), sans-serif"
fontSize="40"
fontWeight="700"
letterSpacing="-1.5"
>
{day}
</text>
<text x="320" y="64" textAnchor="end" fill="var(--muted-text)" fontFamily="var(--font-geist-mono), monospace" fontSize="11">
{caption}
</text>
</svg>
);
}
import type { PipelineStep } from "@unread/db";
import { EpisodeCover } from "./episode-cover";
import type { EpisodeDateParts } from "./format-date";
import { PIPELINE_STEPS, type StepState } from "./pipeline-steps";
import { StepProgress } from "./step-progress";
/** prd §9: while the episode is being made, Home shows step progress. */
export function InProgressCard({ seed, date, currentStep }: { seed: string; date: EpisodeDateParts; currentStep: string | null }) {
const at = currentStep ? PIPELINE_STEPS.indexOf(currentStep as PipelineStep) : -1;
const states = Object.fromEntries(
PIPELINE_STEPS.map((step, i) => [step, (i < at ? "done" : i === at ? "active" : "pending") as StepState]),
);
return (
<section aria-label="Episode in progress" aria-busy="true" className="overflow-hidden rounded-[20px] border border-line bg-surface">
<EpisodeCover seed={seed} day={date.day} caption={date.caption} muted />
<div className="flex flex-col gap-2 px-[18px] pt-4 pb-[18px]">
<p className="m-0 text-[17px] font-semibold tracking-[-0.01em]">Making your episode…</p>
<p className="m-0 text-sm text-muted-foreground">This takes a few minutes. You can leave this page.</p>
<StepProgress states={states} className="pt-2" />
</div>
</section>
);
}
$ sed -n 1,200p features/player/now-playing.tsx | grep -vE "^\s*$" | head -150
"use client";
import { useEffect } from "react";
import { ProgressBar } from "./progress-bar";
import { TransportControls } from "./controls";
import { ChevronDownIcon } from "./icons";
import { usePlayer } from "./player-context";
import { TranscriptView } from "./transcript-view";
/** Full-screen Now playing below 1024 px (canvas screen 3). */
export function NowPlaying() {
const { episode, expanded, setExpanded, chapterIndex } = usePlayer();
useEffect(() => {
if (!expanded) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [expanded]);
if (!episode || !expanded) return null;
const chapters = episode.transcript.chapters;
const index = chapterIndex ?? 0;
const chapter = chapters[index];
return (
<div role="dialog" aria-modal="true" aria-label="Now playing" className="fixed inset-0 z-40 flex flex-col bg-ground lg:hidden">
<header className="flex items-center justify-between px-3 pt-3">
<button type="button" onClick={() => setExpanded(false)} aria-label="Close player" className="flex size-11 cursor-pointer items-center justify-center text-foreground">
<ChevronDownIcon className="size-[22px]" />
</button>
<span className="font-mono text-xs font-medium text-muted-foreground">
{chapterIndex === null ? "INTRO" : `CHAPTER ${index + 1} OF ${chapters.length}`}
</span>
<span className="size-11" aria-hidden="true" />
</header>
<section aria-label="Current chapter" className="flex flex-col gap-1.5 px-6 pt-3">
<span className="text-xs text-muted-foreground">{chapter?.origin ?? "Unread"}</span>
<h1 className="m-0 text-2xl leading-tight font-bold tracking-[-0.02em]">{chapter?.title ?? episode.title}</h1>
</section>
<section aria-label="Transcript" className="flex-1 overflow-y-auto px-6 pt-[18px] pb-4">
{chapter ? <TranscriptView chapterIndex={index} /> : <p className="text-[17px] text-text-2">{episode.transcript.intro.map((l) => l.text).join(" ")}</p>}
</section>
<section aria-label="Player controls" className="flex flex-col gap-4 border-t border-surface-2 bg-ground px-6 pt-4 pb-[max(env(safe-area-inset-bottom),28px)]">
<ProgressBar />
<TransportControls />
</section>
</div>
);
}
$ cd /home/user/unread/packages/core/src && grep -rn -iE "intro|outro" pipeline/script.ts | head -20; grep -rn "function intro\|introLines\|Good morning\|Hi,\|Here's" pipeline/*.ts | grep -v test | head
14:- Don't greet the listener or sign off; the episode has its own intro and outro.`;
79:/** Intro and outro are templates, so they can't hallucinate (prd §6 step 5: intro names the newsletters). */
80:export function introAndOutro(options: { clusters: Cluster[]; dateLabel: string; storyCount: number }): {
81: intro: ScriptLine[];
82: outro: ScriptLine[];
95: intro: [
99: outro: [{ text: "That's everything for now. Every story's sources are in the show notes. See you next time.", sourceIds: [] }],
117:/** Step 5: all chapters (in parallel, a few at a time) plus the templated intro/outro. */
139: const { intro, outro, title } = introAndOutro({ clusters: used, dateLabel: options.dateLabel, storyCount: chapters.length });
140: return { title, intro, chapters, outro };
pipeline/script.ts:80:export function introAndOutro(options: { clusters: Cluster[]; dateLabel: string; storyCount: number }): {
$ sed -n 1,116p pipeline/script.ts
import { generateStructured, type ModelClient } from "../model";
import { chapterDraftSchema, type Cluster, type EpisodePlan, type EpisodeScript, type ScriptChapter, type ScriptLine } from "./types";
const SOURCE_CHARS = 3_500;
export const WRITER_SYSTEM = `You write one chapter of Unread, a short spoken podcast that summarises the listener's newsletters.
Rules:
- Spoken, plain English, warm and direct. Short sentences. No headings, lists, emoji or markdown.
- Summarise in your own words. Never copy a sentence from a source.
- Only state facts, numbers, names and quotes that appear in the sources. If unsure, leave it out.
- Every line lists the ids of the sources it relies on, e.g. ["S1"].
- At most 30 words per line.
- If several sources cover the story, say so once, e.g. "Morning Brew and The Hustle both covered this."
- Don't greet the listener or sign off; the episode has its own intro and outro.`;
function sourceBlock(cluster: Cluster): { text: string; idMap: Map<string, string> } {
const idMap = new Map<string, string>();
const text = cluster.items
.map((item, i) => {
const sid = `S${i + 1}`;
idMap.set(sid, item.id);
const body = item.limited ? `${item.text}\n(Only a snippet was available; stick to it.)` : item.text;
return `[${sid}] ${item.sourceLabel} — "${item.title}"\n${body.slice(0, SOURCE_CHARS)}`;
})
.join("\n\n");
return { text, idMap };
}
export function originLabel(cluster: Cluster): string {
const labels = [...new Set(cluster.items.map((i) => i.sourceLabel))];
return labels.length > 1 ? `${labels[0]} · also in ${labels.slice(1).join(", ")}` : (labels[0] ?? "");
}
/** Step 5, one chapter: Gemma drafts JSON lines that cite S-ids, mapped back to item ids. */
export async function writeChapter(options: {
model: ModelClient;
cluster: Cluster;
targetChars: number;
}): Promise<ScriptChapter> {
const { model, cluster, targetChars } = options;
const { text, idMap } = sourceBlock(cluster);
const draft = await generateStructured({
client: model,
system: WRITER_SYSTEM,
prompt: `Sources:\n${text}\n\nWrite this chapter in about ${targetChars} characters of spoken text (roughly ${Math.max(
1,
Math.round((targetChars / 900) * 60),
)} seconds).\nReturn {"title": "short chapter title", "lines": [{"text": "...", "sourceIds": ["S1"]}]}.`,
schema: chapterDraftSchema,
validate: (d) => {
const chars = d.lines.reduce((s, l) => s + l.text.length, 0);
if (chars > targetChars * 1.35) return `Too long: ${chars} characters, aim for ${targetChars}`;
const bad = d.lines.flatMap((l) => l.sourceIds).filter((id) => !idMap.has(id));
return bad.length ? `Unknown source ids: ${bad.join(", ")}` : null;
},
temperature: 0.5,
maxTokens: 2_000,
});
return {
clusterKey: cluster.key,
title: draft.title,
origin: originLabel(cluster),
lines: draft.lines.map((l) => ({ text: l.text, sourceIds: l.sourceIds.map((s) => idMap.get(s) ?? s) })),
sources: cluster.items.map((item, i) => ({
itemId: item.id,
label: item.sourceLabel,
title: item.title,
url: item.url ?? item.links[0]?.url ?? null,
role: i === 0 ? ("primary" as const) : ("also" as const),
})),
};
}
function listNames(names: string[]): string {
if (names.length <= 1) return names[0] ?? "";
return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
}
/** Intro and outro are templates, so they can't hallucinate (prd §6 step 5: intro names the newsletters). */
export function introAndOutro(options: { clusters: Cluster[]; dateLabel: string; storyCount: number }): {
intro: ScriptLine[];
outro: ScriptLine[];
title: string;
} {
const names = [
...new Set(
options.clusters.flatMap((c) => c.items.map((i) => (i.sourceKind === "newsletter" ? i.sourceLabel : i.sourceKind === "news" ? "the news" : "your saved links"))),
),
];
const shown = names.slice(0, 4);
const more = names.length > shown.length ? ` and ${names.length - shown.length} more` : "";
const stories = `${options.storyCount} ${options.storyCount === 1 ? "story" : "stories"}`;
return {
title: `${options.dateLabel}: ${stories} from ${listNames(shown)}${more}`,
intro: [
{ text: `This is Unread for ${options.dateLabel}.`, sourceIds: [] },
{ text: `${stories[0]?.toUpperCase()}${stories.slice(1)} today, from ${listNames(shown)}${more}.`, sourceIds: [] },
],
outro: [{ text: "That's everything for now. Every story's sources are in the show notes. See you next time.", sourceIds: [] }],
};
}
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const index = next++;
results[index] = await fn(items[index] as T);
}
}),
);
return results;
}
$ grep -rn "dateLabel" --include=*.ts . | grep -v test | grep -v "options.dateLabel" | head -5; grep -rn "\"made at\|madeAt" /home/user/unread/apps/web/src/app/\(app\)/today/page.tsx | head -3
./pipeline/build-script.ts:31: dateLabel: string;
./pipeline/script.ts:80:export function introAndOutro(options: { clusters: Cluster[]; dateLabel: string; storyCount: number }): {
./pipeline/script.ts:122: dateLabel: string;
./episodes/steps.ts:139: dateLabel: spokenDate(now, user.timezone),
36: const madeAt = latest
53: madeAt={madeAt}
$ grep -rn "function spokenDate" -A8 . | head -12; sed -n 1,60p /home/user/unread/apps/web/src/features/player/transcript-view.tsx | grep -vE "^\s*$"
./util/dates.ts:2:export function spokenDate(now: Date, timeZone: string): string {
./util/dates.ts-3- const parts = new Intl.DateTimeFormat("en-GB", { timeZone, weekday: "long", day: "numeric", month: "long" }).formatToParts(now);
./util/dates.ts-4- const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
./util/dates.ts-5- return `${get("weekday")}, ${get("day")} ${get("month")}`;
./util/dates.ts-6-}
./util/dates.ts-7-
./util/dates.ts-8-/** The user's local calendar date, `YYYY-MM-DD`. */
./util/dates.ts-9-export function localDate(now: Date, timeZone: string): string {
./util/dates.ts-10- return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(now);
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { LinkIcon } from "./icons";
import { usePlayer } from "./player-context";
/** Transcript of one chapter, highlighting the line being spoken, plus every source it cites. */
export function TranscriptView({ chapterIndex, className }: { chapterIndex: number; className?: string }) {
const { episode, currentTime, chapterIndex: playingIndex, seek } = usePlayer();
const activeRef = useRef<HTMLButtonElement | null>(null);
const chapter = episode?.transcript.chapters[chapterIndex];
const isPlayingChapter = playingIndex === chapterIndex;
const activeLine = isPlayingChapter && chapter ? chapter.lines.findLastIndex((l) => currentTime >= l.startS - 0.05) : -1;
useEffect(() => {
activeRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [activeLine]);
if (!chapter) return null;
return (
<div className={cn("flex flex-col gap-3.5", className)}>
{chapter.lines.map((line, i) => (
<button
type="button"
key={`${i}-${line.startS}`}
ref={i === activeLine ? activeRef : undefined}
onClick={() => seek(line.startS)}
aria-current={i === activeLine ? "true" : undefined}
className={cn(
"-mx-2.5 cursor-pointer rounded-[10px] px-2.5 py-2 text-left text-[17px] leading-normal",
i === activeLine ? "bg-brand/15 text-foreground" : "text-dim hover:text-text-2",
)}
>
{line.text}
</button>
))}
<ul className="m-0 flex list-none flex-col gap-2 p-0" aria-label="Sources">
{chapter.sources.map((source) => (
<li key={source.itemId} className="flex items-center gap-3 rounded-[14px] border border-line bg-surface px-3.5 py-3">
<LinkIcon className="size-[18px] flex-none text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{source.role === "primary" ? "Source" : "Also covered by"}</span>
<span className="truncate text-sm font-medium">
{source.label} · {source.title}
</span>
</div>
{source.url ? (
<a href={source.url} target="_blank" rel="noopener noreferrer" className="py-3 pl-2 text-sm no-underline">
Open
</a>
) : null}
</li>
))}
</ul>
</div>
);
}
I'm reading the kit's components so I can build the scenes on them.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src && cat kit/looks.ts | head -120; grep -n "^export" kit/*.tsx kit/*.ts anim.ts
import { createContext, useContext, type CSSProperties } from "react";
import { c } from "../theme";
import { FONTS, stack } from "./fonts";
// A look is a video's visual style: the stage colours, the display font, how scenes hand over to
// each other and how fast it moves. The video *type* (launch, tutorial, explainer …) decides the
// structure; the look decides how it feels. See references/visual-styles.md.
// Typefaces from measured reference launches (references/visual-styles.md): Inter with its Display
// optical size for the sans looks, EB Garamond for editorial, Instrument Serif italic as cinematic's accent.
const sans = stack(FONTS.sans);
const serif = stack(FONTS.serif, "serif");
export type TransitionName = "fade" | "focus" | "circle" | "panel" | "slab" | "zoom" | "push" | "cut" | "rise" | "wipe";
/** How `*starred*` accent words are set: another face (usually an italic serif), optionally scaled to match. */
export type AccentFont = { family: string; style: "italic" | "normal"; weight?: number; scale?: number };
export type Look = {
name: LookName;
/** Display font for headlines and big numbers */
font: string;
/** Font for small text around the product: labels, card names, meters, chips */
textFont: string;
/** Face for accent words; without it they change colour only (or take the gradient, in showcase) */
accentFont?: AccentFont;
/** Stage colours. `accent` is the brand colour; `alarm` colours the problem ("That's it.", "Restricted"). */
canvas: string;
ink: string;
muted: string;
line: string;
accent: string;
/** Two-stop gradient for accent words and wipes (showcase). Other looks colour accents flat with `accent`. */
gradient?: readonly [string, string];
onAccent: string;
alarm: string;
/** A card sitting on the stage (person cards, floating UI snippets) */
card: string;
cardShadow: string;
/** Headline weight and tracking: studio is bold, editorial a medium serif, graphic semi-bold and very tight */
weight: number;
tracking: number;
/** The default hand-over between scenes, and its length in frames (the timeline's crossfade) */
transition: TransitionName;
crossfade: number;
/** Suggested music file and whether transitions get a whoosh */
music: string;
whoosh: number;
};
export type LookName = "studio" | "editorial" | "graphic" | "cinematic" | "showcase";
export const LOOKS: Record<LookName, Look> = {
// Dark stage, the app window front and centre, camera and cursor. The original kit look.
studio: {
name: "studio", font: sans, textFont: sans, canvas: c.canvas, ink: c.ink, muted: c.muted, line: "rgba(255,255,255,0.08)",
accent: c.brand, onAccent: "#ffffff", alarm: c.red, card: c.surface, cardShadow: "0 30px 80px rgba(0,0,0,0.5)",
weight: 700, tracking: -0.025, transition: "fade", crossfade: 12, music: "music/upbeat.wav", whoosh: 0.2,
},
// Warm paper, one big number or claim per scene, floating cards, focus-pull cuts, brand-colour
// circle reveals for brand moments. Calm, confident, numbers-led. Headlines and numbers in a
// Garamond with italic accent words (learned from a measured voice-app launch).
editorial: {
name: "editorial", font: serif, textFont: sans, accentFont: { family: stack(FONTS.serif, "serif"), style: "italic", weight: 500 }, canvas: "#efeee9", ink: "#141414", muted: "#77756f", line: "#cfcdc6",
accent: c.brand, onAccent: "#ffffff", alarm: "#d4483a", card: "#ffffff", cardShadow: "0 18px 40px rgba(40,36,28,0.14), 0 2px 6px rgba(40,36,28,0.08)",
weight: 500, tracking: -0.012, transition: "focus", crossfade: 14, music: "music/bed.wav", whoosh: 0.12,
},
// Flat white, one loud brand colour, a modular grid, pixel/dot motifs, full-bleed colour slabs.
// Fast and graphic; built for products with little UI (APIs, infrastructure, data).
graphic: {
name: "graphic", font: sans, textFont: sans, canvas: "#fafafa", ink: "#111111", muted: "#8a8a8a", line: "#e7e7e7",
accent: c.brand, onAccent: "#ffffff", alarm: c.brand, card: "#ffffff", cardShadow: "0 1px 0 #e7e7e7, 0 10px 30px rgba(0,0,0,0.06)",
weight: 600, tracking: -0.04, transition: "slab", crossfade: 12, music: "music/upbeat.wav", whoosh: 0.16,
},
// Near-black, a soft glow, large light type, slow focus pulls. Premium, quiet launches. Accent words
// in a condensed italic serif, the way modern brand films pair a grotesk with an italic.
cinematic: {
name: "cinematic", font: sans, textFont: sans, accentFont: { family: stack(FONTS.displaySerif, "serif"), style: "italic", weight: 400, scale: 1.14 }, canvas: "#050507", ink: "#f4f4f5", muted: "#8b8b94", line: "rgba(255,255,255,0.08)",
accent: c.brand, onAccent: "#ffffff", alarm: "#ff7a6b", card: "#111114", cardShadow: "0 40px 120px rgba(0,0,0,0.7)",
weight: 500, tracking: -0.025, transition: "focus", crossfade: 20, music: "music/bed.wav", whoosh: 0.1,
},
// White, airy, a brand-to-violet gradient on accent words, words that drop in along curves, live UI
// demos with an agent at work, cards lifting out of the screen. Timed with motion measured from a
// real launch (kit/measured): in-out curves that peak mid-move, short holds, near-continuous motion.
showcase: {
name: "showcase", font: sans, textFont: sans, canvas: "#ffffff", ink: "#111118", muted: "#6b6b78", line: "#e7e7f0",
accent: c.brand, gradient: [c.brand, "#a855f7"], onAccent: "#ffffff", alarm: "#e5484d", card: "#ffffff",
cardShadow: "0 24px 60px rgba(60,40,140,0.14), 0 2px 8px rgba(60,40,140,0.08)",
weight: 500, tracking: -0.02, transition: "rise", crossfade: 12, music: "music/upbeat.wav", whoosh: 0.12,
},
};
export const LookContext = createContext<Look>(LOOKS.studio);
/** The current video's look. makeVideo provides it from the timeline's `look`; scene compositions get it too. */
export const useLook = () => useContext(LookContext);
/** Style for an accent word in this look: its accent face (if any), weight and size match. Colour is the call
… [13059 characters trimmed for the upload] …
eadonly size?: number; readonly selectAt?: number }) => {
kit/showcase.tsx:154:export const Bracket = ({ at = 0, children, pad = 22, arm = 18 }: { readonly at?: number; readonly children: ReactNode; readonly pad?: number; readonly arm?: number }) => {
kit/showcase.tsx:173:export const Lift = ({ at = 0, children, rise = 36, grow = 0.05 }: { readonly at?: number; readonly children: ReactNode; readonly rise?: number; readonly grow?: number }) => {
kit/showcase.tsx:187:export const CurveLine = ({ at = 0, d, width = 6 }: { readonly at?: number; readonly d: string; readonly width?: number }) => {
kit/transitions.tsx:122:export const presentationFor = (name: TransitionName, look: Look, origin: [number, number] = [0.5, 0.5]): TransitionPresentation<Record<string, unknown>> => {
kit/fonts.ts:18:export const FONTS = {
kit/fonts.ts:43:export const stack = (family: string, fallback: "sans-serif" | "serif" | "monospace" = "sans-serif"): string => `"${family}", ${fallback}`;
kit/looks.ts:14:export type TransitionName = "fade" | "focus" | "circle" | "panel" | "slab" | "zoom" | "push" | "cut" | "rise" | "wipe";
kit/looks.ts:17:export type AccentFont = { family: string; style: "italic" | "normal"; weight?: number; scale?: number };
kit/looks.ts:19:export type Look = {
kit/looks.ts:51:export type LookName = "studio" | "editorial" | "graphic" | "cinematic" | "showcase";
kit/looks.ts:53:export const LOOKS: Record<LookName, Look> = {
kit/looks.ts:93:export const LookContext = createContext<Look>(LOOKS.studio);
kit/looks.ts:95:export const useLook = () => useContext(LookContext);
kit/looks.ts:98:export const accentStyle = (look: Look): CSSProperties =>
kit/tokens.ts:3:export const t = {
kit/tokens.ts:53:export type Tone = "positive" | "warning" | "negative" | "info" | "neutral";
kit/tokens.ts:55:export const tone = (k: Tone) =>
kit/tokens.ts:65:export const STATUS: Record<string, Tone> = {
kit/voice.ts:7:export const lineAt = (voice: VoiceSpec, scene: string, n: number, fallback = { from: 10 + n * 60, frames: 50 }) => voice.scenes[scene]?.lines[n] ?? fallback;
anim.ts:4:export const ease = Easing.bezier(0.65, 0, 0.35, 1); // in-out, for camera and cursor travel
anim.ts:5:export const easeOut = Easing.bezier(0.16, 1, 0.3, 1); // fast start, long settle, for entrances
anim.ts:8:export const progress = (frame: number, from: number, to: number, easing = easeOut) =>
anim.ts:12:export const pop = (frame: number, delay = 0, stiffness = 180, damping = 18) =>
anim.ts:15:export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
anim.ts:18:export const rise = (frame: number, delay = 0, distance = 24, duration = 18) => {
anim.ts:27:export type Key = { f: number; x: number; y: number; s?: number };
anim.ts:30:export const track = (frame: number, keys: Key[]) => {
anim.ts:44:export const money = (n: number, symbol = "$", decimals = 2) =>
anim.ts:48:export const typed = (frame: number, text: string, start: number, cps = 16) =>
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src && cat kit/build.tsx kit/Stage.tsx kit/Backdrops.tsx kit/fonts.ts anim.ts kit/sfx.tsx | head -400
import React from "react";
import { linearTiming, TransitionSeries } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { AbsoluteFill, Audio, interpolate, Sequence, staticFile } from "remotion";
import { BurnedCaptions, captionsFrom } from "./BurnedCaptions";
import { LookContext, LOOKS, type LookName, type TransitionName } from "./looks";
import { THUMB_FRAME } from "./Thumbnail";
import { Sfx, SFX_VOLUME } from "./sfx";
import { presentationFor } from "./transitions";
export type Cue = { from: number; to: number; text: string };
/**
* A video's cut list. `look` sets the visual style (default "studio"); each scene can choose how it
* comes in (`in`, default the look's transition) and, for "circle" and "panel", where from (`origin`,
* fractions of the frame). Every transition lasts `crossfade` frames, so timing maths stays the same.
*/
export type VideoSpec = {
id: string;
title: string;
crossfade: number;
look?: LookName;
scenes: { id: string; frames: number; cues: Cue[]; in?: TransitionName; origin?: [number, number] }[];
};
/** scripts/voice.mts writes this: where each narration line lands, and how long each scene must be to fit it. */
export type VoiceSpec = { scenes: Record<string, { lines: { src: string; from: number; frames: number; text: string }[]; minFrames: number }> };
/** Each scene's length, grown where its narration needs more room. */
export const sceneFrames = (v: VideoSpec, voice?: VoiceSpec) => v.scenes.map((s) => Math.max(s.frames, voice?.scenes[s.id]?.minFrames ?? 0));
export const totalFrames = (v: VideoSpec, voice?: VoiceSpec) => sceneFrames(v, voice).reduce((n, f) => n + f, 0) - v.crossfade * (v.scenes.length - 1);
/** Where each scene starts in the finished video: scenes overlap by the crossfade. */
export const sceneStarts = (frames: number[], crossfade: number) => {
let at = 0;
return frames.map((f) => {
const start = at;
at += f - crossfade;
return start;
});
};
const MUSIC = { alone: 0.3, underVoice: 0.11, ramp: 8 };
/**
* Music under the whole video, quieter while anyone speaks. `spans` are the
* narration's [from, to] in video frames.
*/
export const musicVolume = (frame: number, total: number, spans: [number, number][], level = MUSIC) => {
let duck = 0;
for (const [a, b] of spans) duck = Math.max(duck, interpolate(frame, [a - level.ramp, a, b, b + level.ramp], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
const fades = Math.min(interpolate(frame, [0, 20], [0, 1], { extrapolateRight: "clamp" }), interpolate(frame, [total - 50, total - 2], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
return (level.alone + (level.underVoice - level.alone) * duck) * fades;
};
/** A whoosh on each transition: it starts 3 frames before the crossfade and peaks mid-transition. */
export const Whooshes = ({ at, volume = SFX_VOLUME.whoosh }: { at: number[]; volume?: number }) => (
<>
{at.map((f) => (
<Sfx key={f} name="whoosh" at={f - 3} volume={volume} />
))}
</>
);
/** One component per scene id, played in the timeline's order with crossfades, narration, music and transition sounds. `burnCaptions` draws the narration into the picture (for muted feeds). */
export const makeVideo = (v: VideoSpec, components: Record<string, () => React.JSX.Element>, voice?: VoiceSpec, music = "music/bed.wav", level = MUSIC, burnCaptions = false) => {
for (const s of v.scenes) if (!components[s.id]) throw new Error(`${v.id}: no component for scene "${s.id}"`);
const frames = sceneFrames(v, voice);
const starts = sceneStarts(frames, v.crossfade);
const total = totalFrames(v, voice);
const captions = burnCaptions ? captionsFrom(v.scenes.flatMap((s, i) => (voice?.scenes[s.id]?.lines ?? []).map((l) => ({ ...l, from: starts[i] + l.from })))) : [];
const look = LOOKS[v.look ?? "studio"];
const spans: [number, number][] = v.scenes.flatMap((s, i) => (voice?.scenes[s.id]?.lines ?? []).map((l) => [starts[i] + l.from, starts[i] + l.from + l.frames] as [number, number]));
const Video = () => (
<LookContext.Provider value={look}>
<AbsoluteFill style={{ background: look.canvas }}>
<TransitionSeries>
{v.scenes.flatMap(({ id }, i) => {
const C = components[id];
return [
<TransitionSeries.Sequence key={id} durationInFrames={frames[i]}>
<C />
{(voice?.scenes[id]?.lines ?? []).map((l) => (
<Sequence key={l.src} from={l.from} durationInFrames={l.frames + 4} layout="none">
<Audio src={staticFile(l.src)} />
</Sequence>
))}
</TransitionSeries.Sequence>,
...(i < v.scenes.length - 1
? [<TransitionSeries.Transition key={`${id}-x`} presentation={presentationFor(v.scenes[i + 1].in ?? look.transition, look, v.scenes[i + 1].origin)} timing={linearTiming({ durationInFrames: v.crossfade })} />]
: []),
];
})}
</TransitionSeries>
<Audio src={staticFile(music)} volume={(f) => musicVolume(f, total, spans, level)} />
<Whooshes at={starts.slice(1).filter((_, i) => v.scenes[i + 1].in !== "cut")} volume={look.whoosh} />
{burnCaptions && <BurnedCaptions captions={captions} />}
</AbsoluteFill>
</LookContext.Provider>
);
return Video;
};
/**
* Open on the thumbnail: held, then crossfading into the video. X, LinkedIn and most players use a
* video's opening frame as its preview and ignore embedded cover art, so this is what makes the
* thumbnail show up when the file is posted. The poster is drawn at THUMB_FRAME (where thumbnails
* are designed to be seen); the video itself starts posterOffset frames in.
*/
export const POSTER = { hold: 25, fade: 10 };
export const posterOffset = POSTER.hold - POSTER.fade;
export const withPoster = (Video: () => React.JSX.Element, Poster: () => React.JSX.Element, total: number) => {
co
… [11484 characters trimmed for the upload] …
easing = easeOut) =>
interpolate(frame, [from, to], [0, 1], { easing, extrapolateLeft: "clamp", extrapolateRight: "clamp" });
/** A spring that starts at `delay`, 0 → 1. */
export const pop = (frame: number, delay = 0, stiffness = 180, damping = 18) =>
spring({ frame: frame - delay, fps: FPS, config: { stiffness, damping, mass: 1 } });
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
/** An entrance: fade in, rise and unblur. */
export const rise = (frame: number, delay = 0, distance = 24, duration = 18) => {
const t = progress(frame, delay, delay + duration);
return {
opacity: t,
transform: `translateY(${(1 - t) * distance}px)`,
filter: `blur(${(1 - t) * 8}px)`,
} as const;
};
export type Key = { f: number; x: number; y: number; s?: number };
/** Interpolate keyframes; each leg eases in and out. */
export const track = (frame: number, keys: Key[]) => {
if (frame <= keys[0].f) return { x: keys[0].x, y: keys[0].y, s: keys[0].s ?? 1 };
for (let i = 0; i < keys.length - 1; i++) {
const a = keys[i];
const b = keys[i + 1];
if (frame <= b.f) {
const t = progress(frame, a.f, b.f, ease);
return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), s: lerp(a.s ?? 1, b.s ?? 1, t) };
}
}
const z = keys[keys.length - 1];
return { x: z.x, y: z.y, s: z.s ?? 1 };
};
export const money = (n: number, symbol = "$", decimals = 2) =>
symbol + n.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
/** Characters typed so far, at `cps` characters per second from `start`. */
export const typed = (frame: number, text: string, start: number, cps = 16) =>
text.slice(0, Math.max(0, Math.floor(((frame - start) / FPS) * cps)));
import React from "react";
import { Audio, Sequence, staticFile } from "remotion";
/** The kit's generated sound effects (public/sfx, npm run sfx). */
export type SfxName = "mouse-click" | "whoosh" | "pop";
/** Default levels, set against the music's level when nobody speaks (build.tsx). */
export const SFX_VOLUME: Readonly<Record<SfxName, number>> = { "mouse-click": 0.38, whoosh: 0.2, pop: 0.3 };
/** Frames each effect needs to play out at 30 fps. */
const SFX_FRAMES: Readonly<Record<SfxName, number>> = { "mouse-click": 6, whoosh: 20, pop: 8 };
export type SfxProps = {
name: SfxName;
/** Frame (in the scene) where the sound starts. The pop and click peak within 5 ms, so put them on the landing frame. */
at: number;
volume?: number;
};
/**
* One sound effect at a frame. Use sparingly: a pop on the one element a scene is about (a badge, the
* number, the word that lands), not on every chip. Cursor clicks and transition whooshes are automatic.
*/
export const Sfx = ({ name, at, volume = SFX_VOLUME[name] }: SfxProps) => (
<Sequence from={Math.max(0, at)} durationInFrames={SFX_FRAMES[name]} layout="none">
<Audio src={staticFile(`sfx/${name}.wav`)} volume={volume} />
Shell cwd was reset to /home/user/unread
Next I'm writing the narration and switching the kit's voice script from Kokoro to ElevenLabs.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && rm -rf src/videos/editorial src/videos/graphic src/videos/showcase && ls src/videos/example && cat src/videos/example/timeline.ts | head -30 && cat src/kit/transitions.tsx | sed -n 1,60p && npm view @fontsource-variable/geist version 2>&1 | tail -1; npm view @fontsource-variable/geist-mono version 2>&1 | tail -1
narration.ts
scenes.tsx
timeline.ts
voice.json
// The example video's cut list: scene ids, base lengths (30 fps) and fallback captions.
// Narration (narration.ts → npm run voice → voice.json) lengthens scenes that need more room,
// and once a video has a voice its captions are the narration.
export const VIDEO = {
id: "example",
title: "Example",
crossfade: 12,
scenes: [
{ id: "problem", frames: 150, cues: [{ from: 6, to: 150, text: "Setting things up shouldn't take a week." }] },
{ id: "title", frames: 100, cues: [{ from: 4, to: 100, text: "Introducing Acme." }] },
{ id: "demo", frames: 220, cues: [{ from: 4, to: 220, text: "Name it, save it, done." }] },
{ id: "outro", frames: 110, cues: [{ from: 4, to: 110, text: "Acme. The one-line promise, said plainly." }] },
],
};
import React from "react";
import type { TransitionPresentation, TransitionPresentationComponentProps } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { AbsoluteFill, Easing, interpolate } from "remotion";
import type { Look, TransitionName } from "./looks";
import { easingOf, MOTION } from "./measured";
// Scene hand-overs, each a @remotion/transitions presentation. Plain CSS (clip-path, blur,
// transforms), so they render the same in stills, the studio and the final file.
// fade crossfade any look; the safe default
// focus old scene blurs and drifts, new one sharpens editorial, cinematic: between beats of one story
// circle a brand-colour circle grows from a point the brand moment ("Introducing", price, close)
// panel a rounded panel grows to full bleed from a small element into its own scene
// slab a brand-colour slab sweeps up across graphic: section changes, big statements
// zoom concentric squares zoom through to the next graphic: into the close / logo
// push the new scene pushes the old one out lists, sequences, "next"
// cut a hard cut at the midpoint on a music hit
// rise old scene lifts away, new one rises in showcase: the default between beats (measured textRise curve)
// wipe a gradient wash sweeps across, then clears showcase: brand moments and section changes
type P = { look: Look; x: number; y: number };
const io = Easing.bezier(0.65, 0, 0.35, 1);
const out = Easing.bezier(0.16, 1, 0.3, 1);
const clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
const ease = (p: number, a: number, b: number, e = io) => interpolate(p, [a, b], [0, 1], { ...clamp, easing: e });
const Focus = ({ children, presentationProgress: p, presentationDirection: d }: TransitionPresentationComponentProps<P>) => {
if (d === "exiting") {
const t = ease(p, 0, 0.7);
return <AbsoluteFill style={{ opacity: 1 - t, filter: `blur(${t * 14}px)`, transform: `scale(${1 + t * 0.04})` }}>{children}</AbsoluteFill>;
}
const t = ease(p, 0.25, 1, out);
return <AbsoluteFill style={{ opacity: t, filter: `blur(${(1 - t) * 14}px)`, transform: `scale(${0.97 + t * 0.03})` }}>{children}</AbsoluteFill>;
};
const Circle = ({ children, presentationProgress: p, presentationDirection: d, passedProps: { x, y } }: TransitionPresentationComponentProps<P>) => {
if (d === "exiting") return <AbsoluteFill>{children}</AbsoluteFill>;
// A dot appears, then grows (slow start, fast middle, soft landing) until it covers the frame
const r = interpolate(ease(p, 0, 1), [0, 1], [0.4, 125]);
return <AbsoluteFill style={{ clipPath: `circle(${r}% at ${x * 100}% ${y * 100}%)` }}>{children}</AbsoluteFill>;
};
const Panel = ({ children, presentationProgress: p, presentationDirection: d, passedProps: { x, y } }: TransitionPresentationComponentProps<P>) => {
if (d === "exiting") {
const t = ease(p, 0.3, 1);
return <AbsoluteFill style={{ filter: `blur(${t * 6}px)`, transform: `scale(${1 - t * 0.03})` }}>{children}</AbsoluteFill>;
}
// A small rounded card at (x, y) grows to fill the frame, its corners straightening as it lands
const t = ease(p, 0, 1);
const top = y * 100 * (1 - t), bottom = (1 - y) * 100 * (1 - t), left = x * 100 * (1 - t), right = (1 - x) * 100 * (1 - t);
return <AbsoluteFill style={{ clipPath: `inset(${top}% ${right}% ${bottom}% ${left}% round ${(1 - t) * 48}px)`, opacity: ease(p, 0, 0.08) }}>{children}</AbsoluteFill>;
};
const Slab = ({ children, presentationProgress: p, presentationDirection: d, passedProps: { look } }: TransitionPresentationComponentProps<P>) => {
if (d === "exiting") return <AbsoluteFill style={{ transform: `translateY(${-ease(p, 0, 0.5) * 6}%)` }}>{children}</AbsoluteFill>;
// The slab rises over the old scene, the new scene is swapped in underneath, the slab leaves upwards
const rise = ease(p, 0, 0.5);
const leave = ease(p, 0.5, 1);
return (
5.3.0
5.3.0
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && npm i --save-exact @fontsource-variable/geist@5.3.0 @fontsource-variable/geist-mono@5.3.0 2>&1 | tail -1 && ls node_modules/@fontsource-variable/geist/files/ | head; ls node_modules/@fontsource-variable/geist-mono/files | head -3
Run `npm audit` for details.
geist-cyrillic-ext-wght-italic.woff2
geist-cyrillic-ext-wght-normal.woff2
geist-cyrillic-wght-italic.woff2
geist-cyrillic-wght-normal.woff2
geist-latin-ext-wght-italic.woff2
geist-latin-ext-wght-normal.woff2
geist-latin-wght-italic.woff2
geist-latin-wght-normal.woff2
geist-vietnamese-wght-italic.woff2
geist-vietnamese-wght-normal.woff2
geist-mono-cyrillic-ext-wght-italic.woff2
geist-mono-cyrillic-ext-wght-normal.woff2
geist-mono-cyrillic-wght-italic.woff2
Shell cwd was reset to /home/user/unread
Now the narration, the timeline and the ElevenLabs voice script.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && rm -rf src/videos/example && mkdir -p src/videos/unread && cat > src/videos/unread/timeline.ts <<'EOF'
// Unread's story video: the cut list. Base lengths at 30 fps; the narration lengthens scenes
// that need more room (npm run voice). Captions come from the narration.
export const VIDEO = {
id: "unread",
title: "Unread",
look: "cinematic" as const,
crossfade: 18,
scenes: [
{ id: "inbox", frames: 330, cues: [] },
{ id: "overlap", frames: 230, cues: [] },
{ id: "commute", frames: 140, cues: [] },
{ id: "pause", frames: 96, in: "fade" as const, cues: [] },
{ id: "reveal", frames: 140, in: "circle" as const, origin: [0.5, 0.5] as [number, number], cues: [] },
{ id: "forward", frames: 330, cues: [] },
{ id: "gate", frames: 170, cues: [] },
{ id: "merge", frames: 420, cues: [] },
{ id: "heard", frames: 150, cues: [] },
{ id: "check", frames: 260, cues: [] },
{ id: "voice", frames: 220, cues: [] },
{ id: "play", frames: 420, cues: [] },
{ id: "close", frames: 260, cues: [] },
],
};
EOF
cat > src/videos/unread/narration.ts <<'EOF'
// What is said in each scene. `at` is the frame (from the scene's start) a line aims to begin on;
// a line starts later if the one before runs on. `say` is what the voice reads when it differs from
// the caption. `voice: "host"` is the episode's own Studio voice (scene "play"); the rest is the narrator.
export type Line = { at: number; text: string; say?: string; voice?: "narrator" | "host" };
export const NARRATION: Record<string, Line[]> = {
inbox: [
{ at: 16, text: "This is my inbox." },
{ at: 0, text: "124 unread newsletters.", say: "A hundred and twenty-four unread newsletters." },
{ at: 0, text: "I subscribed to every one of them on purpose." },
{ at: 0, text: "They're important, but I'm too lazy to read them." },
],
overlap: [
{ at: 14, text: "And some weeks, two of them tell me the same story." },
{ at: 0, text: "Same topic. Same points. Twice." },
],
commute: [{ at: 12, text: "But I do have a 40-minute commute to the office.", say: "But I do have a forty-minute commute to the office." }],
pause: [{ at: 26, text: "So… I stopped reading.", say: "So... I stopped reading." }],
reveal: [{ at: 22, text: "Unread. Your newsletters, as a podcast." }],
forward: [
{ at: 14, text: "Forward them once, with one Gmail filter." },
{ at: 0, text: "Unread even catches Gmail's confirmation code for you." },
],
gate: [{ at: 14, text: "Mail that isn't from you never reaches a model." }],
merge: [
{ at: 14, text: "EmbeddingGemma and pgvector find the same story across newsletters.", say: "Embedding Gemma and P G vector find the same story across newsletters." },
{ at: 0, text: "Gemma 4 settles the close calls.", say: "Gemma four settles the close calls." },
{ at: 0, text: "Two newsletters. One chapter. Both credited." },
],
heard: [{ at: 12, text: "Heard it last week? It's skipped." }],
check: [
{ at: 14, text: "Every line links back to the newsletter it came from." },
{ at: 0, text: "A number it can't find in the source, it cuts." },
],
voice: [
{ at: 14, text: "Kokoro, an open-weight voice, reads every free episode." },
{ at: 0, text: "Studio voices come from ElevenLabs.", say: "Studio voices come from Eleven Labs." },
],
play: [
{ at: 40, voice: "host", text: "This is Unread for Monday, 26 October.", say: "This is Unread for Monday, the twenty-sixth of October." },
{ at: 0, voice: "host", text: "4 stories today, from Engineering Leadership, The System Design Newsletter and Blogging Wizard.", say: "Four stories today, from Engineering Leadership, The System Design Newsletter and Blogging Wizard." },
{ at: 0, voice: "host", text: "Engineering Leadership and The System Design Newsletter both covered this." },
{ at: 0, text: "Every story once. In my own podcast app." },
],
close: [
{ at: 20, text: "My inbox still says 124 unread.", say: "My inbox still says a hundred and twenty-four unread." },
{ at: 0, text: "I'm caught up anyway." },
],
};
EOF
echo ok
ok
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/scripts/voice.mts
The file /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/scripts/voice.mts has been updated successfully. (file state is current in your context — no need to Read it back)
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && npm run voice 2>&1 | tail -40
generated (17 chars, narrator): This is my inbox.
generated (45 chars, narrator): A hundred and twenty-four unread newsletters.
generated (45 chars, narrator): I subscribed to every one of them on purpose.
generated (49 chars, narrator): They're important, but I'm too lazy to read them.
unread/inbox: 4 line(s), ends 338/330, scene grows by 40 frames
generated (51 chars, narrator): And some weeks, two of them tell me the same story.
generated (31 chars, narrator): Same topic. Same points. Twice.
unread/overlap: 2 line(s), ends 205/230, scene grows by 7 frames
generated (51 chars, narrator): But I do have a forty-minute commute to the office.
unread/commute: 1 line(s), ends 106/140
generated (24 chars, narrator): So... I stopped reading.
unread/pause: 1 line(s), ends 102/96, scene grows by 38 frames
generated (39 chars, narrator): Unread. Your newsletters, as a podcast.
unread/reveal: 1 line(s), ends 117/140, scene grows by 9 frames
generated (41 chars, narrator): Forward them once, with one Gmail filter.
generated (54 chars, narrator): Unread even catches Gmail's confirmation code for you.
unread/forward: 2 line(s), ends 194/330
generated (47 chars, narrator): Mail that isn't from you never reaches a model.
unread/gate: 1 line(s), ends 100/170
generated (70 chars, narrator): Embedding Gemma and P G vector find the same story across newsletters.
generated (35 chars, narrator): Gemma four settles the close calls.
generated (44 chars, narrator): Two newsletters. One chapter. Both credited.
unread/merge: 3 line(s), ends 341/420
generated (33 chars, narrator): Heard it last week? It's skipped.
unread/heard: 1 line(s), ends 72/150
generated (53 chars, narrator): Every line links back to the newsletter it came from.
generated (46 chars, narrator): A number it can't find in the source, it cuts.
unread/check: 2 line(s), ends 186/260
generated (55 chars, narrator): Kokoro, an open-weight voice, reads every free episode.
generated (36 chars, narrator): Studio voices come from Eleven Labs.
unread/voice: 2 line(s), ends 206/220, scene grows by 18 frames
generated (55 chars, host): This is Unread for Monday, the twenty-sixth of October.
generated (98 chars, host): Four stories today, from Engineering Leadership, The System Design Newsletter and Blogging Wizard.
generated (74 chars, host): Engineering Leadership and The System Design Newsletter both covered this.
generated (40 chars, narrator): Every story once. In my own podcast app.
unread/play: 4 line(s), ends 587/420, scene grows by 199 frames
generated (53 chars, narrator): My inbox still says a hundred and twenty-four unread.
generated (21 chars, narrator): I'm caught up anyway.
unread/close: 2 line(s), ends 156/260
Shell cwd was reset to /home/user/unread
Voiceover generated: 1,159 characters in total. Here are the line timings the visuals will follow:
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 -c "
import json; v=json.load(open('src/videos/unread/voice.json'))
tot=0
for k,s in v['scenes'].items():
print(k, s['minFrames'], [(l['from'],l['frames']) for l in s['lines']])
tot+=s['minFrames']
print('total frames', tot-18*12, (tot-18*12)/30,'s')
"
inbox 370 [(16, 44), (66, 84), (156, 89), (251, 87)]
overlap 237 [(14, 92), (112, 93)]
commute 140 [(12, 94)]
pause 134 [(26, 76)]
reveal 149 [(22, 95)]
forward 330 [(14, 80), (100, 94)]
gate 170 [(14, 86)]
merge 420 [(14, 138), (158, 71), (235, 106)]
heard 150 [(12, 60)]
check 260 [(14, 82), (102, 84)]
voice 238 [(14, 113), (133, 73)]
play 619 [(40, 111), (157, 187), (350, 130), (486, 101)]
close 260 [(20, 98), (124, 32)]
total frames 3261 108.7 s
Shell cwd was reset to /home/user/unread
$ cd /home/user/unread/apps/web/src && cat features/forwarding/live-status.tsx | sed -n 19,80p; cat features/today/format-date.ts | head -40; sed -n 40,75p app/onboarding/page.tsx; grep -rn "export function CopyButton" -A25 components features | head -40
/** "Waiting for your first newsletter…" → "Got Morning Brew ✓", plus Gmail's code (prd §9). */
export function LiveStatus({ initial }: { initial: Status["inbound"] }) {
const [status, setStatus] = useState(initial);
useEffect(() => {
let stopped = false;
const tick = async () => {
try {
const res = await fetch("/api/status", { cache: "no-store" });
if (res.ok && !stopped) setStatus(((await res.json()) as Status).inbound);
} catch {
// offline: keep the last status
}
};
const id = setInterval(tick, 4000);
return () => {
stopped = true;
clearInterval(id);
};
}, []);
return (
<div className="flex flex-col gap-3">
{status.confirmation ? (
<div role="alert" className="flex flex-col gap-2 rounded-[14px] border border-brand bg-brand/10 p-4">
<p className="m-0 text-sm font-semibold">Gmail sent a confirmation code</p>
{status.confirmation.code ? (
<div className="flex items-center gap-3">
<span className="font-mono text-2xl font-medium tracking-[0.12em]">{status.confirmation.code}</span>
<CopyButton value={status.confirmation.code} />
</div>
) : null}
{status.confirmation.url ? (
<a href={status.confirmation.url} target="_blank" rel="noopener noreferrer" className="text-sm">
Or open Gmail's confirm link
</a>
) : null}
</div>
) : null}
<div aria-live="polite" className="flex items-center gap-3 rounded-[14px] border border-line bg-ground px-4 py-3">
{status.newsletters.length ? (
<>
<span aria-hidden="true" className="flex size-6 items-center justify-center rounded-full bg-brand text-on-brand">
✓
</span>
<span className="text-sm">Got {status.newsletters.map((n) => n.name).join(", ")}</span>
</>
) : (
<>
<span aria-hidden="true" className="size-2.5 animate-pulse rounded-full bg-brand" />
<span className="text-sm text-text-2">Waiting for your first newsletter…</span>
</>
)}
</div>
{status.lastRejection && REJECTION_TEXT[status.lastRejection.reason] ? (
<p className="m-0 text-[13px] text-muted-foreground">{REJECTION_TEXT[status.lastRejection.reason]}</p>
) : null}
</div>
);
}
export interface EpisodeDateParts {
/** "FRIDAY, 23 OCTOBER" — the Home eyebrow. */
eyebrow: string;
/** "23" — big number on the cover. */
day: string;
/** "OCT · FRI" — cover caption. */
caption: string;
/** "2026-10-23" — the episode's local date key. */
isoDate: string;
}
function part(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
return parts.find((p) => p.type === type)?.value ?? "";
}
/** Date labels for the user's local day, in their timezone. */
export function episodeDateParts(now: Date, timeZone: string): EpisodeDateParts {
const long = new Intl.DateTimeFormat("en-GB", {
timeZone,
weekday: "long",
day: "numeric",
month: "long",
}).formatToParts(now);
const short = new Intl.DateTimeFormat("en-GB", {
timeZone,
weekday: "short",
month: "short",
}).formatToParts(now);
const iso = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
return {
eyebrow: `${part(long, "weekday")}, ${part(long, "day")} ${part(long, "month")}`.toUpperCase(),
day: part(long, "day"),
caption: `${part(short, "month")} · ${part(short, "weekday")}`.toUpperCase(),
isoDate: iso,
inboundStatus(user.id),
planContextFor(user.id),
db().select().from(schema.userTopics).where(eq(schema.userTopics.userId, user.id)),
db()
.select({ n: count() })
.from(schema.items)
.where(and(eq(schema.items.userId, user.id), eq(schema.items.status, "queued"))),
]);
const hasNewsletters = status.newsletters.length > 0;
return (
<main className="mx-auto flex min-h-dvh w-full max-w-[560px] flex-col gap-6 px-6 pt-6 pb-10">
<BrandMark />
{step === 1 ? (
<>
<StepHeader step={1} skipHref="/onboarding?step=2" />
<div className="flex flex-col gap-2">
<h1 className="m-0 text-[30px] leading-[1.1] font-bold tracking-[-0.03em]">Forward your newsletters here</h1>
<p className="m-0 text-[15px] leading-[1.45] text-muted-foreground">
Every issue you forward becomes part of one short episode, with the overlap removed and a source for every story.
</p>
</div>
<FieldRow action={<CopyButton value={address} />}>{address}</FieldRow>
<LiveStatus initial={status} />
<section aria-label="Set up auto-forwarding" className="flex flex-col gap-3 rounded-[18px] border border-line bg-surface p-4">
<h2 className="m-0 text-base font-semibold">Recommended: forward automatically</h2>
<ForwardingGuides />
</section>
<Link href={"/onboarding?step=2" as Route} className="flex h-[54px] items-center justify-center rounded-full bg-brand text-base font-semibold text-on-brand no-underline hover:text-on-brand">
Continue
</Link>
</>
) : null}
{step === 2 ? (
<>
components/ui/copy-button.tsx:7:export function CopyButton({ value, label = "Copy", className }: { value: string; label?: string; className?: string }) {
components/ui/copy-button.tsx-8- const [copied, setCopied] = useState(false);
components/ui/copy-button.tsx-9- return (
components/ui/copy-button.tsx-10- <button
components/ui/copy-button.tsx-11- type="button"
components/ui/copy-button.tsx-12- onClick={async () => {
components/ui/copy-button.tsx-13- try {
components/ui/copy-button.tsx-14- await navigator.clipboard.writeText(value);
components/ui/copy-button.tsx-15- setCopied(true);
components/ui/copy-button.tsx-16- setTimeout(() => setCopied(false), 2000);
components/ui/copy-button.tsx-17- } catch {
components/ui/copy-button.tsx-18- setCopied(false);
components/ui/copy-button.tsx-19- }
components/ui/copy-button.tsx-20- }}
components/ui/copy-button.tsx-21- className={cn("h-9 flex-none cursor-pointer rounded-[9px] bg-line px-3 text-[13px] font-medium text-foreground hover:bg-line/80", className)}
components/ui/copy-button.tsx-22- >
components/ui/copy-button.tsx-23- <span aria-live="polite">{copied ? "Copied" : label}</span>
components/ui/copy-button.tsx-24- </button>
components/ui/copy-button.tsx-25- );
components/ui/copy-button.tsx-26-}
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src && cat kit/Cursor.tsx | sed -n 1,70p; sed -n 1,60p kit/Thumbnail.tsx; cat kit/Mark.tsx; grep -n "Odometer = " -A45 kit/Motion.tsx | head -50
import React from "react";
import { useCurrentFrame } from "remotion";
import { progress, track, type Key } from "../anim";
import { c } from "../theme";
import { Sfx } from "./sfx";
/**
* A pointer that glides between keyframes (screen coordinates) and clicks at the
* given frames: it dips, and a ring spreads from the tip.
*/
export const Cursor = ({ path, clicks = [], show = [0, 1e6] }: { path: Key[]; clicks?: number[]; show?: [number, number] }) => {
const frame = useCurrentFrame();
const { x, y } = track(frame, path);
const fadeIn = progress(frame, show[0], show[0] + 8);
const fadeOut = 1 - progress(frame, show[1], show[1] + 8);
let dip = 0;
for (const f of clicks) {
const d = frame - f;
if (d >= -3 && d <= 6) dip = Math.max(dip, d < 0 ? (d + 3) / 3 : 1 - d / 6);
}
return (
<div style={{ position: "absolute", left: 0, top: 0, pointerEvents: "none", opacity: Math.min(fadeIn, fadeOut), zIndex: 50 }}>
{clicks.map((f) => (
<Sfx key={`click-${f}`} name="mouse-click" at={f - 1} />
))}
{clicks.map((f) => {
const d = frame - f;
if (d < 0 || d > 20) return null;
const t = d / 20;
const at = track(f, path);
return (
<div
key={f}
style={{
position: "absolute",
left: at.x - 28,
top: at.y - 28,
width: 56,
height: 56,
borderRadius: 28,
border: `2px solid ${c.brand300}`,
background: `${c.brand}22`,
opacity: 1 - t,
transform: `scale(${0.3 + t * 1.1})`,
}}
/>
);
})}
<svg
width={26}
height={30}
viewBox="0 0 26 30"
style={{
position: "absolute",
left: x - 3,
top: y - 2,
transform: `scale(${1 - dip * 0.18})`,
transformOrigin: "3px 2px",
filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.55))",
}}
>
<path d="M3 2 L3 24 L9 18.5 L13 27.5 L17 25.8 L13 17 L21 17 Z" fill="#fff" stroke="#000" strokeWidth={1.6} strokeLinejoin="round" />
</svg>
</div>
);
};
/** A text caret that blinks while idle and stays solid while typing. */
import React from "react";
import { AbsoluteFill, Sequence } from "remotion";
import { PRODUCT } from "../brand";
import { c } from "../theme";
import { Stage } from "./Backdrops";
import { accentStyle, LookContext, LOOKS, type Look, type LookName } from "./looks";
import { Mark } from "./Mark";
import { GradientText } from "./showcase";
import { Backdrop } from "./Stage";
/** Thumbnails are 240-frame compositions rendered at their last frame, so everything has finished animating. */
export const THUMB_FRAMES = 240;
export const THUMB_FRAME = THUMB_FRAMES - 1;
const isDark = (look: Look) => look.name === "studio" || look.name === "cinematic";
/** The title's second line in the look's accent: gradient in showcase, otherwise the accent colour in the accent face. */
const AccentLine = ({ look, children }: { look: Look; children: string }) => {
if (isDark(look)) return <span style={{ color: c.brand300, ...accentStyle(look) }}>{children}</span>;
if (look.gradient) return <GradientText>{children}</GradientText>;
return <span style={{ color: look.accent, ...accentStyle(look) }}>{children}</span>;
};
export type ThumbnailProps = {
eyebrow: string;
title: [string, string];
Scene: () => React.JSX.Element;
/** Frame of the scene to hold: pick a resolved, telling moment. */
at: number;
/** The video's look; the thumbnail is drawn in it, and so is the scene. Defaults to studio. */
look?: LookName;
/** Pixels cropped off the top of the scene (its headline). */
crop?: number;
zoom?: number;
};
/**
* A 16:9 thumbnail in the video's look: brand, eyebrow and a two-line title on the left; one of the
* video's own scenes on the right, held at frame `at` of that scene, with its headline cropped off
* (`crop`). Dark looks (studio, cinematic) sit on the glowing backdrop; light looks (editorial,
* graphic, showcase) on their own ground, in their ink and accent. Thumbnails must show the real
* product: reuse a scene, never draw a fake one.
*/
export const Thumbnail = ({ eyebrow, title, Scene, at, look: lookName = "studio", crop = 214, zoom = 0.86 }: ThumbnailProps) => {
const look = LOOKS[lookName];
const dark = isDark(look);
const canvas = dark ? c.canvas : look.canvas;
const ink = dark ? c.ink : look.ink;
return (
<LookContext.Provider value={look}>
<AbsoluteFill style={{ fontFamily: look.font, background: canvas }}>
{dark ? <Backdrop glow={0.7} /> : <Stage />}
<div style={{ position: "absolute", left: 700, top: 70, width: 1920 * zoom, height: (1080 - crop) * zoom, overflow: "hidden", borderRadius: 18, boxShadow: dark ? undefined : look.cardShadow }}>
<div style={{ width: 1920, height: 1080, transformOrigin: "0 0", transform: `scale(${zoom}) translateY(${-crop}px)` }}>
<Sequence from={THUMB_FRAME - at} layout="none">
<Scene />
</Sequence>
</div>
</div>
<div style={{ position: "absolute", inset: 0, background: `linear-gradient(90deg, ${canvas} 36%, ${canvas}e0 44%, transparent 60%)` }} />
import React from "react";
import { MarkIcon } from "../brand";
import { c } from "../theme";
/** The brand tile: a rounded square with the logo glyph. */
export const Mark = ({ size = 28 }: { size?: number }) => (
<div style={{ width: size, height: size, borderRadius: size * 0.28, background: c.brand, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<MarkIcon size={size * 0.58} strokeWidth={2.25} />
</div>
);
17:export const Odometer = ({ value, from = 0, at = 0, dur = 36, size = 160, color, prefix = "", suffix = "", group = true, weight }: { value: number; from?: number; at?: number; dur?: number; size?: number; color?: string; prefix?: string; suffix?: string; group?: boolean; weight?: number }) => {
18- const frame = useCurrentFrame();
19- const look = useLook();
20- const at_ = (f: number) => lerp(from, value, progress(f, at, at + dur, easeOut));
21- const n = at_(frame);
22- const prev = at_(frame - 1);
23- const digits = Math.max(String(Math.floor(Math.abs(value))).length, String(Math.floor(Math.abs(from))).length);
24- const h = size * 1.04;
25- const cols: ReactNode[] = [];
26- for (let k = digits - 1; k >= 0; k--) {
27- const place = 10 ** k;
28- const whole = Math.floor(n);
29- const base = Math.floor(n / place) % 10;
30- const lower = whole % place;
31- const roll = lower === place - 1 ? n - whole : 0;
32- const pos = base + roll;
33- const speed = Math.abs(n - prev) / place;
34- const shown = k === 0 || n >= place - 0.001;
35- const blur = Math.min(size * 0.04, speed * size * 0.06);
36- cols.push(
37- <span key={k} style={{ display: "inline-block", height: h, overflow: "hidden", width: shown ? "1ch" : 0, transition: "none", verticalAlign: "top" }}>
38- <span style={{ display: "block", transform: `translateY(${-pos * h}px)`, filter: blur > 0.3 ? `blur(${blur}px)` : undefined }}>
39- {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((d, i) => (
40- <span key={i} style={{ display: "block", height: h, lineHeight: `${h}px`, textAlign: "center" }}>{d}</span>
41- ))}
42- </span>
43- </span>,
44- );
45- if (group && k > 0 && k % 3 === 0 && shown) cols.push(<span key={`s${k}`} style={{ display: "inline-block", height: h, lineHeight: `${h}px`, verticalAlign: "top" }}>,</span>);
46- }
47- return (
48- // position: relative paints the whole number (comma and suffix included) above an absolutely positioned
49- // sibling backdrop; without it only the transformed digit columns would.
50- <div style={{ position: "relative", fontFamily: look.font, fontSize: size, fontWeight: weight ?? look.weight, letterSpacing: size * look.tracking, color: color ?? look.ink, fontVariantNumeric: "tabular-nums", lineHeight: 1, whiteSpace: "nowrap", display: "inline-flex", alignItems: "flex-start" }}>
51- {prefix && <span style={{ height: h, lineHeight: `${h}px`, whiteSpace: "pre" }}>{prefix}</span>}
52- {cols}
53- {suffix && <span style={{ height: h, lineHeight: `${h}px`, whiteSpace: "pre" }}>{suffix}</span>}
54- </div>
55- );
56-};
57-
58-/* -------------------------------------------------------------------- words */
59-
60-/**
61- * A line of words that rise out of a mask one by one. Wrap words in *asterisks* to colour them
62- * with the accent (`tone="accent"`) or the alarm colour (`tone="alarm"`): "100 invites a week. *That's it.*"
Shell cwd was reset to /home/user/unread
Now the theme, the fonts and the music drop-out:
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
import re
p='src/theme.ts'; s=open(p).read()
s=s.replace('''export const c = {
brand: "#0075ff",
brand300: "#4c9aff",
canvas: "#09090b",
surface: "#18181b",
ink: "#fafafa",
muted: "#a1a1aa",
faint: "#71717a",''','''export const c = {
brand: "#5b9bff",
brand300: "#8fb8ff",
canvas: "#0a0b0e",
surface: "#17181d",
ink: "#f2f3f5",
muted: "#a3a8b3",
faint: "#8a909c",''')
s=s.replace('export const fontFamily = stack(FONTS.sans);','export const fontFamily = stack(FONTS.geist);')
s=s.replace('export const monoFamily = stack(FONTS.mono, "monospace");','export const monoFamily = stack(FONTS.geistMono, "monospace");')
open(p,'w').write(s)
p='src/brand.ts'; s=open(p).read()
s=s.replace('import { Waypoints } from "lucide-react";','import { Headphones } from "lucide-react";')
s=s.replace('export const PRODUCT = "Acme";','export const PRODUCT = "Unread";')
s=s.replace('export const TAGLINE = "The one-line promise, said plainly.";','export const TAGLINE = "Your newsletters, as a podcast.";')
s=s.replace('export const MarkIcon = Waypoints;','export const MarkIcon = Headphones;')
open(p,'w').write(s)
p='src/kit/fonts.ts'; s=open(p).read()
s=s.replace('import ebGaramondItalic','import geist from "@fontsource-variable/geist/files/geist-latin-wght-normal.woff2";\nimport geistMono from "@fontsource-variable/geist-mono/files/geist-mono-latin-wght-normal.woff2";\nimport ebGaramondItalic',1)
s=s.replace(' mono: "JetBrains Mono Variable",\n',' mono: "JetBrains Mono Variable",\n /** Unread\'s own UI faces (the app uses Geist and Geist Mono) */\n geist: "Geist Variable",\n geistMono: "Geist Mono Variable",\n')
s=s.replace(' { family: FONTS.mono, url: jetbrainsMono, weight: "100 800", style: "normal" },\n',' { family: FONTS.mono, url: jetbrainsMono, weight: "100 800", style: "normal" },\n { family: FONTS.geist, url: geist, weight: "100 900", style: "normal" },\n { family: FONTS.geistMono, url: geistMono, weight: "100 900", style: "normal" },\n')
open(p,'w').write(s)
# build.tsx: scenes can silence the music (mute: true); it ramps out before and back after.
p='src/kit/build.tsx'; s=open(p).read()
s=s.replace('scenes: { id: string; frames: number; cues: Cue[]; in?: TransitionName; origin?: [number, number] }[];','scenes: { id: string; frames: number; cues: Cue[]; in?: TransitionName; origin?: [number, number]; mute?: boolean }[];')
s=s.replace('''export const musicVolume = (frame: number, total: number, spans: [number, number][], level = MUSIC) => {
let duck = 0;''','''export const musicVolume = (frame: number, total: number, spans: [number, number][], level = MUSIC, mutes: [number, number][] = []) => {
let duck = 0;
let mute = 0;
for (const [a, b] of mutes) mute = Math.max(mute, interpolate(frame, [a - 14, a, b, b + 10], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));''')
s=s.replace(' return (level.alone + (level.underVoice - level.alone) * duck) * fades;',' return (level.alone + (level.underVoice - level.alone) * duck) * fades * (1 - mute);')
s=s.replace(''' const spans: [number, number][] = v.scenes.flatMap''',''' // Scenes marked `mute` play without music: it fades out as they arrive and comes back as they leave.
const mutes: [number, number][] = v.scenes.flatMap((s, i) => (s.mute ? [[starts[i] + v.crossfade, starts[i] + frames[i] - v.crossfade] as [number, number]] : []));
const spans: [number, number][] = v.scenes.flatMap''')
s=s.replace('volume={(f) => musicVolume(f, total, spans, level)}','volume={(f) => musicVolume(f, total, spans, level, mutes)}')
open(p,'w').write(s)
EOF
grep -n "mute" src/kit/build.tsx | head; sed -i 's/{ id: "pause", frames: 96, in: "fade" as const, cues: \[\] },/{ id: "pause", frames: 96, in: "fade" as const, mute: true, cues: [] },/' src/videos/unread/timeline.ts; grep pause src/videos/unread/timeline.ts
22: scenes: { id: string; frames: number; cues: Cue[]; in?: TransitionName; origin?: [number, number]; mute?: boolean }[];
48:export const musicVolume = (frame: number, total: number, spans: [number, number][], level = MUSIC, mutes: [number, number][] = []) => {
50: let mute = 0;
51: for (const [a, b] of mutes) mute = Math.max(mute, interpolate(frame, [a - 14, a, b, b + 10], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
54: return (level.alone + (level.underVoice - level.alone) * duck) * fades * (1 - mute);
66:/** One component per scene id, played in the timeline's order with crossfades, narration, music and transition sounds. `burnCaptions` draws the narration into the picture (for muted feeds). */
74: // Scenes marked `mute` play without music: it fades out as they arrive and comes back as they leave.
75: const mutes: [number, number][] = v.scenes.flatMap((s, i) => (s.mute ? [[starts[i] + v.crossfade, starts[i] + frames[i] - v.crossfade] as [number, number]] : []));
99: <Audio src={staticFile(music)} volume={(f) => musicVolume(f, total, spans, level, mutes)} />
{ id: "pause", frames: 96, in: "fade" as const, mute: true, cues: [] },
Shell cwd was reset to /home/user/unread
Next, the rebuilt Unread screens (phone frame, onboarding, episode progress, Today, Now playing), copied from the app's code:
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/ui.tsx
File created successfully at: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/ui.tsx (file state is current in your context — no need to Read it back)
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src && cat kit/anchors.tsx; sed -n 40,112p kit/AppStage.tsx
import React, { createContext, useContext, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
/**
* Named points on the logical screen. An element wrapped in <Anchor name="save">
* reports its box in screen coordinates, whatever the camera is doing, so the
* cursor and camera aim at "save" instead of at hand-measured numbers. Boxes are
* re-measured on every frame, so an anchor that moves (a row sliding in, a page
* scrolling) is followed.
*/
export type Box = { x: number; y: number; w: number; h: number };
type Store = { boxes: Map<string, Box>; root: React.RefObject<HTMLDivElement | null>; screenW: number; bump: () => void; version: number };
const Ctx = createContext<Store | null>(null);
export const AnchorProvider = ({ children, screenW }: { children: ReactNode; screenW: number }) => {
const root = useRef<HTMLDivElement>(null);
const boxes = useRef(new Map<string, Box>()).current;
const [version, setVersion] = useState(0);
// A new value on every measurement change, so the camera and pointer re-render
// with the new boxes in the same frame, before it is captured
const store = useMemo<Store>(() => ({ boxes, root, screenW, bump: () => setVersion((v) => v + 1), version }), [boxes, screenW, version]);
return <Ctx.Provider value={store}>{children}</Ctx.Provider>;
};
export const useAnchorStore = () => {
const store = useContext(Ctx);
if (!store) throw new Error("Anchor used outside an AnchorProvider");
return store;
};
export const Anchor = ({ name, children, style, inline }: { name: string; children: ReactNode; style?: React.CSSProperties; inline?: boolean }) => {
const store = useContext(Ctx);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!store || !ref.current || !store.root.current) return;
const r = ref.current.getBoundingClientRect();
const o = store.root.current.getBoundingClientRect();
const k = o.width / store.screenW;
const box = { x: (r.left - o.left) / k, y: (r.top - o.top) / k, w: r.width / k, h: r.height / k };
const old = store.boxes.get(name);
if (!old || Math.abs(old.x - box.x) > 0.25 || Math.abs(old.y - box.y) > 0.25 || Math.abs(old.w - box.w) > 0.25 || Math.abs(old.h - box.h) > 0.25) {
store.boxes.set(name, box);
store.bump();
}
});
return (
<div ref={ref} style={{ display: inline ? "inline-flex" : undefined, ...style }}>
{children}
</div>
);
};
/** A point aimed at: an anchor (its centre, or a fraction of its box), or a raw screen point. */
export type Aim = { at?: string; x?: number; y?: number; fx?: number; fy?: number; dx?: number; dy?: number };
export const resolve = (boxes: Map<string, Box>, aim: Aim, fallback = { x: 720, y: 450 }) => {
if (aim.at) {
const b = boxes.get(aim.at);
if (!b) return { x: fallback.x, y: fallback.y };
return { x: b.x + b.w * (aim.fx ?? 0.5) + (aim.dx ?? 0), y: b.y + b.h * (aim.fy ?? 0.5) + (aim.dy ?? 0) };
}
return { x: (aim.x ?? fallback.x) + (aim.dx ?? 0), y: (aim.y ?? fallback.y) + (aim.dy ?? 0) };
};
</div>
);
};
/**
* A floating app window over the logical SCREEN (1440x900 by default). The camera and the
* pointer aim at anchors or raw screen points. Width and top follow the frame's shape unless
* given, so the same scene works in 16:9, 1:1 and 9:16.
*/
export const AppWindow = ({
children,
camera,
pointer,
pointerShow,
enter = 0,
exit,
width,
top,
}: {
children: ReactNode;
camera: CameraKey[];
pointer?: PointerKey[];
pointerShow?: [number, number];
enter?: number;
exit?: number;
width?: number;
top?: number;
}) => {
const frame = useCurrentFrame();
const look = useLook();
const dark = look.name === "studio" || look.name === "cinematic";
const { width: fw, height: fh, portrait } = useFrameShape();
const w = width ?? Math.round(portrait ? fw - 60 : fw * 0.78);
const tp = top ?? Math.round(portrait ? fh * 0.3 : fh * 0.213);
const scale = w / SCREEN.w;
// Portrait: a taller 4:5 window, and the camera always at least 2x, so it frames a readable column of
// the UI instead of a tiny strip. The same camera keys serve both shapes.
const height = portrait ? Math.round(w * 1.25) : SCREEN.h * scale;
const zoom = portrait ? (s: number) => Math.max(2, s * 1.5) : (s: number) => s;
const visibleH = portrait ? height / scale : Math.min(SCREEN.h, (fh - tp) / scale);
const t = progress(frame, enter, enter + 30);
const out = exit === undefined ? 0 : progress(frame, exit, exit + 16);
return (
<div style={{ position: "absolute", left: 0, right: 0, top: tp, display: "flex", justifyContent: "center", perspective: 2200 }}>
<div
style={{
width: w,
height,
borderRadius: 18,
overflow: "hidden",
position: "relative",
border: dark ? "1px solid rgba(255,255,255,0.09)" : `1px solid ${look.line}`,
// On light looks the window floats on a soft shadow; on dark ones it glows in the brand colour
boxShadow: dark ? `0 40px 120px -20px rgba(0,0,0,0.9), 0 0 0 1px rgba(0,0,0,0.6), 0 0 160px -40px ${c.brand}66` : "0 50px 100px -30px rgba(40,36,28,0.35), 0 12px 30px rgba(40,36,28,0.10)",
opacity: t * (1 - out),
transform: `translateY(${(1 - t) * 160 + out * 40}px) rotateX(${(1 - t) * 22}deg) scale(${0.9 + 0.1 * t - out * 0.04})`,
transformOrigin: "50% 0%",
fontFamily,
}}
>
<div style={{ width: SCREEN.w, height: SCREEN.h, transformOrigin: "0 0", transform: `scale(${scale})` }}>
<AnchorProvider screenW={SCREEN.w}>
<Screen camera={camera} pointer={pointer} show={pointerShow} visibleH={visibleH} zoom={zoom}>
{children}
</Screen>
</AnchorProvider>
</div>
</div>
</div>
);
};
/** Lets another video reuse a step with its own headline: wrap the scene in <StepCopy.Provider>. */
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src && sed -n 1,40p kit/AppStage.tsx
import React, { createContext, useContext, useLayoutEffect, type ReactNode } from "react";
import { AbsoluteFill, useCurrentFrame } from "remotion";
import { progress, track } from "../anim";
import { c, fontFamily, SCREEN } from "../theme";
import { AnchorProvider, resolve, useAnchorStore, type Aim } from "./anchors";
import { Cursor } from "./Cursor";
import { Stage } from "./Backdrops";
import { useLook } from "./looks";
import { Headline, useFrameShape } from "./Stage";
/** A camera keyframe: aim at an anchor (or a raw screen point) at frame `f`, zoomed by `s`. */
export type CameraKey = Aim & { f: number; s?: number };
/** A pointer keyframe: be at an anchor (or point) at frame `f`; `click` plays the click there. */
export type PointerKey = Aim & { f: number; click?: boolean };
const Screen = ({ camera, pointer, show, visibleH, zoom, children }: { camera: CameraKey[]; pointer?: PointerKey[]; show?: [number, number]; visibleH: number; zoom: (s: number) => number; children: ReactNode }) => {
const frame = useCurrentFrame();
const store = useAnchorStore();
// Anchors measure in their layout effects, which run before this div's ref is attached on the
// first render. One more pass once it is mounted lets them measure within the same frame, which
// matters for single-frame stills and for the first frame of every render tab.
useLayoutEffect(() => store.bump(), []); // eslint-disable-line react-hooks/exhaustive-deps
// The window can run off the bottom of the frame, so the camera frames only the part that shows
const keys = camera.map((k) => ({ f: k.f, s: zoom(k.s ?? 1), ...resolve(store.boxes, k, { x: SCREEN.w / 2, y: visibleH / 2 }) }));
const cam = track(frame, keys);
const s = cam.s;
const half = { w: SCREEN.w / (2 * s), h: visibleH / (2 * s) };
const x = Math.min(Math.max(cam.x, half.w), SCREEN.w - half.w);
const y = Math.min(Math.max(cam.y, half.h), SCREEN.h - half.h);
const path = pointer?.map((k) => ({ f: k.f, ...resolve(store.boxes, k) }));
const clicks = pointer?.filter((k) => k.click).map((k) => k.f) ?? [];
return (
<div
ref={store.root}
style={{ width: SCREEN.w, height: SCREEN.h, position: "relative", transformOrigin: "0 0", transform: `translate(${SCREEN.w / 2 - x * s}px, ${visibleH / 2 - y * s}px) scale(${s})` }}
>
{children}
{path && path.length > 0 && <Cursor path={path} clicks={clicks} show={show ?? [path[0].f, 1e6]} />}
</div>
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread && python3 - <<'EOF'
p='ui.tsx'; s=open(p).read()
s=s.replace('import React, { type CSSProperties, type ReactNode } from "react";','import React, { useLayoutEffect, type CSSProperties, type ReactNode } from "react";')
s=s.replace('import { progress } from "../../anim";\nimport { Anchor } from "../../kit/anchors";','import { progress, track } from "../../anim";\nimport { Anchor, AnchorProvider, resolve, useAnchorStore, type Aim } from "../../kit/anchors";\nimport { Sfx } from "../../kit/sfx";')
s=s.replace('''/** "● Unread": the BrandMark''','''export type TapKey = Aim & { f: number; tap?: boolean };
const TapLayer = ({ taps, show, children }: { taps: TapKey[]; show: [number, number]; children: ReactNode }) => {
const frame = useCurrentFrame();
const store = useAnchorStore();
useLayoutEffect(() => store.bump(), []); // eslint-disable-line react-hooks/exhaustive-deps
const path = taps.map((k) => ({ f: k.f, ...resolve(store.boxes, k, { x: 195, y: 600 }) }));
const at = path.length ? track(frame, path) : { x: 0, y: 0 };
const vis = Math.min(progress(frame, show[0], show[0] + 8), 1 - progress(frame, show[1], show[1] + 8));
const tapFrames = taps.filter((k) => k.tap).map((k) => k.f);
let press = 0;
for (const f of tapFrames) {
const d = frame - f;
if (d >= -3 && d <= 8) press = Math.max(press, d < 0 ? (d + 3) / 3 : 1 - d / 8);
}
return (
<div ref={store.root} style={{ position: "absolute", inset: 0 }}>
{children}
{tapFrames.map((f) => (
<Sfx key={f} name="mouse-click" at={f - 1} />
))}
{tapFrames.map((f) => {
const d = frame - f;
if (d < 0 || d > 18) return null;
const p = track(f, path);
const t = d / 18;
return <div key={`r${f}`} style={{ position: "absolute", left: p.x - 30, top: p.y - 30, width: 60, height: 60, borderRadius: 30, border: "2px solid rgba(255,255,255,0.8)", opacity: (1 - t) * vis, transform: `scale(${0.4 + t})`, pointerEvents: "none" }} />;
})}
{path.length > 0 && (
<div style={{ position: "absolute", left: at.x - 20, top: at.y - 20, width: 40, height: 40, borderRadius: 20, background: "rgba(255,255,255,0.32)", border: "1.5px solid rgba(255,255,255,0.75)", boxShadow: "0 6px 18px rgba(0,0,0,0.45)", opacity: vis, transform: `scale(${1 - press * 0.22})`, pointerEvents: "none" }} />
)}
</div>
);
};
/** The phone's screen with a fingertip that moves between anchors and taps (with the click sound). */
export const PhoneScreen = ({ children, taps = [], show = [0, 1e6] }: { children: ReactNode; taps?: TapKey[]; show?: [number, number] }) => (
<AnchorProvider screenW={PHONE.w}>
<TapLayer taps={taps} show={show}>
{children}
</TapLayer>
</AnchorProvider>
);
/** "● Unread": the BrandMark''')
open(p,'w').write(s)
EOF
echo ok
ok
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src && sed -n 145,165p kit/Motion.tsx; sed -n 60,92p kit/Motion.tsx; cat tsconfig.json 2>/dev/null || cat ../tsconfig.json
</div>
</div>
);
};
/** A rubber-stamp entrance: big and rotated, then slammed down. For a status that lands hard (Restricted, Approved, Paid). */
export const Stamp = ({ at, children, rotate = -7 }: { at: number; children: ReactNode; rotate?: number }) => {
const frame = useCurrentFrame();
const s = pop(frame, at, 320, 22);
return <div style={{ display: "inline-block", opacity: frame >= at ? 1 : 0, transform: `scale(${lerp(2.2, 1, s)}) rotate(${rotate}deg)` }}>{children}</div>;
};
/** Style for a card dealt onto the table: flies in from the top right with a spin and settles at a slight angle. */
export const dealt = (frame: number, at: number, rest = 0) => {
const s = pop(frame, at, 120, 16);
return { transform: `translate(${(1 - s) * 520}px, ${(1 - s) * -420}px) rotate(${lerp(28, rest, s)}deg)`, opacity: progress(frame, at, at + 6) } as const;
};
/** A white person card (avatar initials, name, role, and a footer), the floating-card unit of the editorial look. */
export const PersonCard = ({ name, role, footer, right, width = 300, children }: { name: string; role?: string; footer?: ReactNode; right?: ReactNode; width?: number; children?: ReactNode }) => {
const look = useLook();
/**
* A line of words that rise out of a mask one by one. Wrap words in *asterisks* to colour them
* with the accent (`tone="accent"`) or the alarm colour (`tone="alarm"`): "100 invites a week. *That's it.*"
*/
export const AccentLine = ({ text, at = 0, size = 44, tone = "accent", color, align = "center", gap = 3, weight }: { text: string; at?: number; size?: number; tone?: "accent" | "alarm"; color?: string; align?: "left" | "center"; gap?: number; weight?: number }) => {
const frame = useCurrentFrame();
const look = useLook();
const words: { w: string; hi: boolean }[] = [];
let hi = false;
for (const raw of text.split(" ")) {
const starts = raw.startsWith("*");
const ends = raw.endsWith("*");
if (starts) hi = true;
words.push({ w: raw.replace(/\*/g, ""), hi });
if (ends) hi = false;
}
return (
<div style={{ fontFamily: look.font, fontSize: size, fontWeight: weight ?? look.weight, letterSpacing: size * look.tracking, lineHeight: 1.15, color: color ?? look.ink, textAlign: align }}>
{words.map(({ w, hi: h }, i) => {
const t = progress(frame, at + i * gap, at + i * gap + 14);
return (
<span key={i} style={{ display: "inline-block", overflow: "hidden", verticalAlign: "bottom", paddingBottom: size * 0.12, marginBottom: -size * 0.12, marginRight: size * 0.24 }}>
<span style={{ display: "inline-block", transform: `translateY(${(1 - t) * 110}%)`, color: h ? (tone === "alarm" ? look.alarm : look.accent) : undefined, ...(h ? accentStyle(look) : {}) }}>{w}</span>
</span>
);
})}
</div>
);
};
/** A small uppercase label over a hairline rule that draws in from the centre; the rule can turn a colour (a limit reached). */
export const RuleLabel = ({ label, at = 0, width = 900, hot, note }: { label: string; at?: number; width?: number; hot?: number; note?: string }) => {
const frame = useCurrentFrame();
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"lib": [
"DOM",
"ES2022"
],
"resolveJsonModule": true
},
"include": [
"src"
]
}
Shell cwd was reset to /home/user/unread
Now the text component and the 13 scenes:
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/said.tsx
File created successfully at: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/said.tsx (file state is current in your context — no need to Read it back)
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread && python3 - <<'EOF'
p='said.tsx'; s=open(p).read()
old=''' <div style={{ display: "flex", flexDirection: "column", gap, ...style }}>
{lines.map((l, i) => {
// A line leaves once `keep` newer lines have started
const leaveAt = i + keep < lines.length ? starts[i + keep] - 8 : undefined;
if (leaveAt !== undefined && frame > leaveAt + 16) return null;
const dim = i < current ? progress(frame, starts[i + 1] - 6, starts[i + 1] + 8) : 0;
return <Said key={l.n} scene={scene} n={l.n} text={l.text} size={size} align={align} dim={dim} out={leaveAt ?? out} />;
})}
</div>'''
new=''' <div style={{ display: "flex", flexDirection: "column", ...style }}>
{lines.map((l, i) => {
// A line leaves once `keep` newer lines have started; its space closes up smoothly after it fades
const leaveAt = i + keep < lines.length ? starts[i + keep] - 8 : undefined;
const collapse = leaveAt === undefined ? 0 : progress(frame, leaveAt + 8, leaveAt + 26);
const dim = i < current ? progress(frame, starts[i + 1] - 6, starts[i + 1] + 8) : 0;
return (
<div key={l.n} style={{ maxHeight: (1 - collapse) * size * 5, paddingBottom: i < lines.length - 1 ? gap * (1 - collapse) : 0, overflow: collapse > 0 ? "hidden" : "visible" }}>
<Said scene={scene} n={l.n} text={l.text} size={size} align={align} dim={dim} out={leaveAt ?? out} />
</div>
);
})}
</div>'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
EOF
echo ok
ok
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/scenes.tsx
File created successfully at: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/scenes.tsx (file state is current in your context — no need to Read it back)
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/scenes.tsx'; s=open(p).read()
s=s.replace('import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";','import { AbsoluteFill, useCurrentFrame } from "remotion";')
s=s.replace('\n// Keep `interpolate` imported for scene tweaks without touching the import list.\nvoid interpolate;\n','\n')
open(p,'w').write(s)
EOF
cat > src/Root.tsx <<'EOF'
import React from "react";
import { Composition } from "remotion";
import { makeVideo, posterOffset, sceneFrames, totalFrames, withPoster, type VideoSpec, type VoiceSpec } from "./kit/build";
import { LookContext, LOOKS } from "./kit/looks";
import { Thumbnail, THUMB_FRAMES } from "./kit/Thumbnail";
import { FPS } from "./theme";
import { SCENES as UNREAD_SCENES } from "./videos/unread/scenes";
import { VIDEO as UNREAD } from "./videos/unread/timeline";
import UNREAD_VOICE from "./videos/unread/voice.json";
type Level = { alone: number; underVoice: number; ramp: number };
// One entry per video: [cut list, scenes, voice manifest, music file, music levels].
const VIDEOS: [VideoSpec, Record<string, () => React.JSX.Element>, VoiceSpec, string?, Level?][] = [
[UNREAD, UNREAD_SCENES, UNREAD_VOICE, "music/bed.wav", { alone: 0.34, underVoice: 0.12, ramp: 10 }],
];
// Thumbnails: [composition id (<Title>Thumbnail), eyebrow, [line 1, line 2], scene, frame of that scene].
const THUMBNAILS: [string, string, [string, string], () => React.JSX.Element, number][] = [
["UnreadThumbnail", "Unread", ["Your newsletters,", "as a podcast."], UNREAD_SCENES.merge, 400],
];
const lookOf = (thumbnailId: string) => VIDEOS.find(([spec]) => `${spec.title}Thumbnail` === thumbnailId)?.[0].look ?? "studio";
/** A scene on its own, in its video's look (for stills and previews). */
const inLook = (spec: VideoSpec, Scene: () => React.JSX.Element) => {
const S = () => (
<LookContext.Provider value={LOOKS[spec.look ?? "studio"]}>
<Scene />
</LookContext.Provider>
);
return S;
};
const thumbnail = ([id, eyebrow, title, Scene, at]: (typeof THUMBNAILS)[number]) => {
const T = () => <Thumbnail eyebrow={eyebrow} title={title} Scene={Scene} at={at} look={lookOf(id)} />;
return T;
};
// A video with a thumbnail opens on it (withPoster), so feeds that preview the first frame show it.
const OPEN_ON_THUMBNAIL = true;
const POSTERS: Record<string, () => React.JSX.Element> = Object.fromEntries(THUMBNAILS.map((t) => [t[0].replace(/Thumbnail$/, ""), thumbnail(t)]));
const compose = (spec: VideoSpec, scenes: Record<string, () => React.JSX.Element>, voice: VoiceSpec, music?: string, level?: Level) => {
const video = makeVideo(spec, scenes, voice, music, level);
const total = totalFrames(spec, voice);
const poster = OPEN_ON_THUMBNAIL ? POSTERS[spec.title] : undefined;
return poster ? { component: withPoster(video, poster, total), durationInFrames: total + posterOffset } : { component: video, durationInFrames: total };
};
export const Root = () => (
<>
{VIDEOS.map(([spec, scenes, voice, music, level]) => (
<React.Fragment key={spec.id}>
<Composition id={spec.title} {...compose(spec, scenes, voice, music, level)} fps={FPS} width={1920} height={1080} />
{spec.scenes.map(({ id }, i) => (
<Composition key={id} id={`${spec.id}-${id}`} component={inLook(spec, scenes[id])} durationInFrames={sceneFrames(spec, voice)[i]} fps={FPS} width={1920} height={1080} />
))}
</React.Fragment>
))}
{THUMBNAILS.map((entry) => (
<Composition key={entry[0]} id={entry[0]} component={thumbnail(entry)} durationInFrames={THUMB_FRAMES} fps={FPS} width={1920} height={1080} />
))}
</>
);
EOF
npx tsc 2>&1 | head -30; (npm run music && npm run sfx) 2>&1 | tail -4
public/sfx/mouse-click.wav
public/sfx/whoosh.wav
public/sfx/pop.wav
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && MUSIC_SECONDS=125 npm run music 2>&1 | tail -2; cat stills.sh | head -30
public/music/upbeat.wav 125.0 s at 128 BPM, -13.0 LUFS
public/music/bed.wav 125.0 s at 100 BPM, -13.4 LUFS
#!/usr/bin/env bash
# Half-size PNGs of single frames, to check a moment without rendering a whole video.
# ./stills.sh <composition>:<frame>[,<frame>...] ... e.g. ./stills.sh example-demo:60,130 ExampleVertical:200
# Bundles once, writes stills/<composition>-<frame>.png. Set CHROMIUM_LIBS as for render-all.sh if needed.
set -e
cd "$(dirname "$0")"
[ -n "$CHROMIUM_LIBS" ] && export LD_LIBRARY_PATH="$CHROMIUM_LIBS"
mkdir -p stills
npx remotion bundle src/index.ts --out-dir=build >/dev/null 2>&1
for spec in "$@"; do
comp=${spec%%:*}
frames=${spec#*:}
for f in ${frames//,/ }; do
npx remotion still build "$comp" "stills/$comp-$f.png" --frame="$f" --scale=0.5 >/dev/null 2>&1 || echo "failed $comp $f (run it without >/dev/null to see why)"
done
done
echo done
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && time ./stills.sh unread-inbox:120,320 unread-overlap:200 unread-commute:100 unread-pause:90 unread-reveal:120 2>&1 | tail -5; ls stills
done
real 0m32.251s
user 0m28.600s
sys 0m10.012s
unread-commute-100.png
unread-inbox-120.png
unread-inbox-320.png
unread-overlap-200.png
unread-pause-90.png
unread-reveal-120.png
Shell cwd was reset to /home/user/unread
The scenes compile. I'm checking still frames of each scene before rendering.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills && ffmpeg -v error -y -i unread-inbox-120.png -i unread-inbox-320.png -i unread-overlap-200.png -i unread-commute-100.png -i unread-pause-90.png -i unread-reveal-120.png -filter_complex "[0][1][2][3][4][5]xstack=inputs=6:layout=0_0|w0_0|0_h0|w0_h0|0_h0+h0|w0_h0+h0" sheet1.png && echo ok
ok
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/sheet1.png
[image omitted from the upload]
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread && python3 - <<'EOF'
p='ui.tsx'; s=open(p).read()
old=''' <span style={{ fontSize: size, fontWeight: 700, letterSpacing: -0.02 * size, color: u.text }}>Unread</span>'''
new=''' <span style={{ fontFamily, fontSize: size, fontWeight: 700, letterSpacing: -0.02 * size, color: u.text }}>Unread</span>'''
assert old in s; s=s.replace(old,new)
old=''' <div style={{ padding: "34px 40px 22px", display: "flex", alignItems: "center", gap: 18, borderBottom: "1px solid rgba(255,255,255,0.06)" }}>'''
new=''' <div style={{ position: "relative", zIndex: 2, background: "#121318", padding: "34px 40px 22px", display: "flex", alignItems: "center", gap: 18, borderBottom: "1px solid rgba(255,255,255,0.06)" }}>'''
assert old in s; s=s.replace(old,new)
open(p,'w').write(s)
p='said.tsx'; s=open(p).read()
s=s.replace('export const Said = ({ scene, n, text, size = 64, align = "left", dim = 0, out, color, tone = "accent", style }: { scene: string; n: number; text: string; size?: number; align?: "left" | "center"; dim?: number; out?: number; color?: string; tone?: "accent" | "alarm"; style?: CSSProperties }) => {',
'/** `hide` leaves out the first words on screen (still timed as spoken), e.g. a name the picture already shows. */\nexport const Said = ({ scene, n, text, size = 64, align = "left", dim = 0, out, color, tone = "accent", hide = 0, style }: { scene: string; n: number; text: string; size?: number; align?: "left" | "center"; dim?: number; out?: number; color?: string; tone?: "accent" | "alarm"; hide?: number; style?: CSSProperties }) => {')
s=s.replace(''' {words.map(({ w, hi, at }, i) => {
const t = progress(frame, at, at + 12);''',''' {words.map(({ w, hi, at }, i) => {
if (i < hide) return null;
const t = progress(frame, at, at + 12);''')
open(p,'w').write(s)
p='scenes.tsx'; s=open(p).read()
s=s.replace('<Said scene="reveal" n={0} size={64} align="center" text="Unread. Your newsletters, *as a podcast.*" style={{ color: u.text2 }} />','<Said scene="reveal" n={0} size={64} align="center" hide={1} text="Unread. Your newsletters, *as a podcast.*" style={{ color: u.text2 }} />')
s=s.replace('{ at: x0, icon: <House size={34} />, label: "Home" },\n { at: x1, icon: <Building2 size={34} />, label: "Office" },','{ at: x0, icon: <House size={48} />, label: "Home" },\n { at: x1, icon: <Building2 size={48} />, label: "Office" },')
s=s.replace('display: "flex", flexDirection: "column", alignItems: "center", gap: 70, color: u.muted, fontFamily, fontSize: 24,','display: "flex", flexDirection: "column", alignItems: "center", gap: 64, color: u.muted, fontFamily, fontSize: 28, top: 620,')
open(p,'w').write(s)
EOF
grep -n "top: 620" scenes.tsx | head -2
148: <div key={p.label} style={{ position: "absolute", left: p.at - 50, top: 640, width: 100, display: "flex", flexDirection: "column", alignItems: "center", gap: 64, color: u.muted, fontFamily, fontSize: 28, top: 620, ...rise(frame, 6, 20) }}>
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && sed -i 's/left: p.at - 50, top: 640, width: 100,/left: p.at - 50, width: 100,/' src/videos/unread/scenes.tsx && npx tsc && ./stills.sh unread-forward:40,170,280 unread-gate:60,125 unread-merge:120,215,380 unread-heard:100 unread-check:80,200 unread-voice:150 unread-play:120,420,560 unread-close:100,250 unread-inbox:100 unread-reveal:120 2>&1 | tail -3
done
Shell cwd was reset to /home/user/unread
[This command modified 1 file you've previously read: ../../../tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/scenes.tsx. Call Read before editing.]
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills && x() { out=$1; shift; n=$#; args=(); for f in "$@"; do args+=(-i "$f"); done; ffmpeg -v error -y "${args[@]}" -filter_complex "xstack=inputs=$n:layout=0_0|w0_0|0_h0|w0_h0|0_h0+h0|w0_h0+h0" $out; }; x sheet2.png unread-forward-40.png unread-forward-170.png unread-forward-280.png unread-gate-60.png unread-gate-125.png unread-heard-100.png; x sheet3.png unread-merge-120.png unread-merge-215.png unread-merge-380.png unread-check-80.png unread-check-200.png unread-voice-150.png; x sheet4.png unread-play-120.png unread-play-420.png unread-play-560.png unread-close-100.png unread-close-250.png unread-inbox-100.png; echo ok
ok
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/sheet2.png
[image omitted from the upload]
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/sheet3.png
[image omitted from the upload]
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/sheet4.png
[image omitted from the upload]
Most frames look right. I'm fixing four things: a fingertip that lingers on screen, the merge-scene layout, the phone placement, and zooms so the phone text is readable.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread && python3 - <<'EOF'
p='ui.tsx'; s=open(p).read()
s=s.replace('const TapLayer = ({ taps, show, children }: { taps: TapKey[]; show: [number, number]; children: ReactNode }) => {','const TapLayer = ({ taps, show, children }: { taps: TapKey[]; show: [number, number][]; children: ReactNode }) => {')
s=s.replace(' const vis = Math.min(progress(frame, show[0], show[0] + 8), 1 - progress(frame, show[1], show[1] + 8));',' // The fingertip shows only inside its windows, so it never parks over the screen between taps\n const vis = Math.max(0, ...show.map(([a, b]) => Math.min(progress(frame, a, a + 8), 1 - progress(frame, b, b + 8))));')
s=s.replace('export const PhoneScreen = ({ children, taps = [], show = [0, 1e6] }: { children: ReactNode; taps?: TapKey[]; show?: [number, number] }) => (','export const PhoneScreen = ({ children, taps = [], show = [[0, 1e6]] }: { children: ReactNode; taps?: TapKey[]; show?: [number, number][] }) => (')
s=s.replace('''/** "● Unread": the BrandMark''','''/** Pushes in on part of the phone: `z` is the zoom, (ox, oy) the point held still, in phone logical px. */
export const PhoneZoom = ({ z, ox, oy, scale = 1.1, children }: { z: number; ox: number; oy: number; scale?: number; children: ReactNode }) => (
<div style={{ transform: `scale(${z})`, transformOrigin: `${12 + ox * scale}px ${12 + oy * scale}px` }}>{children}</div>
);
/** "● Unread": the BrandMark''')
open(p,'w').write(s)
p='scenes.tsx'; s=open(p).read()
s=s.replace('const PHONE_X = 1130; // phone\'s left edge','const PHONE_X = 1190; // phone\'s left edge')
s=s.replace(' PhoneScreen,\n',' PhoneScreen,\n PhoneZoom,\n')
# Forward: zoom on the code card and status
s=s.replace(''' const enter = progress(frame, 0, 36);
return (
<AbsoluteFill>
<Ground glow={0.4} />
<div style={{ position: "absolute", left: LEFT, top: 300, width: TEXT_W }}>
<Eyebrow at={4} style={{ marginBottom: 26 }}>Setup · once</Eyebrow>''',''' const enter = progress(frame, 0, 36);
const zoom = 1 + 0.42 * progress(frame, codeAt - 14, codeAt + 12, ease);
return (
<AbsoluteFill>
<Ground glow={0.4} />
<div style={{ position: "absolute", left: LEFT, top: 300, width: TEXT_W }}>
<Eyebrow at={4} style={{ marginBottom: 26 }}>Setup · once</Eyebrow>''')
s=s.replace(''' <Phone scale={1.1}>
<PhoneScreen
taps={[
{ f: 24, x: 300, y: 560 },
{ f: tapCopy, at: "copy", tap: true },
{ f: tapCopy + 30, at: "copy", dy: 160 },
]}
show={[20, tapCopy + 30]}
>
<OnboardingScreen copied={frame >= tapCopy && frame < tapCopy + 70} code={progress(frame, codeAt, codeAt + 14)} got={pop(frame, gotAt, 200, 16)} />
</PhoneScreen>
</Phone>''',''' <PhoneZoom z={zoom} ox={195} oy={300}>
<Phone scale={1.1}>
<PhoneScreen
taps={[
{ f: 24, x: 300, y: 560 },
{ f: tapCopy, at: "copy", tap: true },
{ f: tapCopy + 30, at: "copy", dy: 160 },
]}
show={[[20, tapCopy + 30]]}
>
<OnboardingScreen copied={frame >= tapCopy && frame < tapCopy + 70} code={progress(frame, codeAt, codeAt + 14)} got={pop(frame, gotAt, 200, 16)} />
</PhoneScreen>
</Phone>
</PhoneZoom>''')
# Voice: zoom on the step list
s=s.replace(''' <Phone scale={1.1}>
<div style={{ padding: "22px 24px 0", display: "flex", flexDirection: "column", gap: 18 }}>''',''' <PhoneZoom z={1 + 0.38 * progress(frame, 30, 64, ease)} ox={195} oy={640}>
<Phone scale={1.1}>
<div style={{ padding: "22px 24px 0", display: "flex", flexDirection: "column", gap: 18 }}>''')
s=s.replace(''' <InProgressCard at={step} />
</div>
<TabBar />
</Phone>''',''' <InProgressCard at={step} />
</div>
<TabBar />
</Phone>
</PhoneZoom>''')
# Play: two fingertip windows, and a push-in on the transcript while chapter 1 plays
s=s.replace(''' <Phone scale={1.1}>
<PhoneScreen
taps={[''',''' <PhoneZoom z={1 + 0.4 * progress(frame, tapChapter + 26, tapChapter + 50, ease) - 0.4 * progress(frame, n3.from + 30, n3.from + 56, ease)} ox={195} oy={230}>
<Phone scale={1.1}>
<PhoneScreen
taps={[''')
s=s.replace(''' show={[6, tapChapter + 24]}''',''' show={[
[6, tapPlay + 22],
[tapChapter - 26, tapChapter + 20],
]}''')
s=s.replace(''' <NowPlayingScreen active={frame >= h2.from ? 0 : -1} time={time} />
</div>
</PhoneScreen>
</Phone>''',''' <NowPlayingScreen active={frame >= h2.from ? 0 : -1} time={time} />
</div>
</PhoneScreen>
</Phone>
</PhoneZoom>''')
# Merge: a wider space, chips spread across it, labels under it
s=s.replace('''const STORIES = [
{ id: "a", label: "Write the design doc first", from: NL.el, x: 360, y: 180 },
{ id: "b", label: "Why design docs save weeks", from: NL.sd, x: 690, y: 330 },
{ id: "c", label: "Running 1:1s your team looks forward to", from: NL.el, x: 120, y: 470 },
{ id: "d", label: "How a feed scales past a million users", from: NL.sd, x: 760, y: 560 },
{ id: "e", label: "Turn one blog post into five", from: NL.bw, x: 210, y: 650 },
];''','''const STORIES = [
{ id: "a", label: "Write the design doc first", from: NL.el, x: 330, y: 60 },
{ id: "b", label: "Why design docs save weeks", from: NL.sd, x: 760, y: 200 },
{ id: "c", label: "Running 1:1s your team looks forward to", from: NL.el, x: 40, y: 330 },
{ id: "d", label: "How a feed scales past a million users", from: NL.sd, x: 880, y: 420 },
{ id: "e", label: "Turn one blog post into five", from: NL.bw, x: 330, y: 500 },
];''')
s=s.replace(''' const PX = 420;
const PY = 300;
// A and B drift towards a meeting point, then fold into the chapter row
const meet = { x: 500, y: 250 };''',''' const PX = 260;
const PY = 300;
// A and B drift towards a meeting point, then fold into the chapter row
const meet = { x: 620, y: 150 };''')
s=s.replace(''' <div style={{ position: "absolute", left: PX - 60, top: PY - 30, width: 1200, height: 720, borderRadius: 36, border: "1px solid rgba(255,255,255,0.06)", background: "radial-gradient(ellipse at 40% 40%, rgba(91,155,255,0.07), transparent 70%)", ...rise(frame, 0, 30, 24) }}>
<div style={{ position: "absolute", left: 28, bottom: 22, display: "flex", gap: 14 }}>''',''' <div style={{ position: "absolute", left: PX - 100, top: PY - 40, width: 1600, height: 660, borderRadius: 36, border: "1px solid rgba(255,255,255,0.06)", background: "radial-gradient(ellipse at 40% 40%, rgba(91,155,255,0.07), transparent 70%)", ...rise(frame, 0, 30, 24) }}>
<div style={{ position: "absolute", left: 0, right: 0, bottom: -74, display: "flex", justifyContent: "center", gap: 14 }}>''')
s=s.replace(''' <div style={{ position: "absolute", left: PX + 760, top: PY + 170, opacity: ask * (1 - fold),''',''' <div style={{ position: "absolute", left: PX + 1000, top: PY + 70, opacity: ask * (1 - fold),''')
s=s.replace(''' <div style={{ position: "absolute", left: PX + 260, top: PY + 190, width: 900, opacity: fold,''',''' <div style={{ position: "absolute", left: PX + 250, top: PY + 90, width: 900, opacity: fold,''')
s=s.replace(''' { n: 0, text: "*EmbeddingGemma* and *pgvector* find the same story across newsletters." },''',''' { n: 0, text: "*EmbeddingGemma* and *pgvector* find the same story across newsletters." },''')
s=s.replace(''' size={56}
align="center"
keep={1}
lines={[
{ n: 0, text: "*EmbeddingGemma*''',''' size={52}
align="center"
keep={1}
lines={[
{ n: 0, text: "*EmbeddingGemma*''')
s=s.replace(''' style={{ width: 1500, alignItems: "center" }}
/>
</div>
{/* The embedding space */}''',''' style={{ width: 1720, alignItems: "center" }}
/>
</div>
{/* The embedding space */}''')
open(p,'w').write(s)
EOF
cd ../../.. && npx tsc && ./stills.sh unread-merge:60,215,380 unread-forward:200 unread-play:200,470 unread-voice:120 2>&1 | tail -1
done
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills && x() { out=$1; shift; n=$#; args=(); for f in "$@"; do args+=(-i "$f"); done; ffmpeg -v error -y "${args[@]}" -filter_complex "xstack=inputs=$n:layout=0_0|w0_0|0_h0|w0_h0|0_h0+h0|w0_h0+h0" $out; }; x sheet5.png unread-merge-60.png unread-merge-215.png unread-merge-380.png unread-forward-200.png unread-play-200.png unread-play-470.png; echo ok
ok
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/sheet5.png
[image omitted from the upload]
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && sed -i 's/ox={195} oy={300}/ox={360} oy={300}/; s/ox={195} oy={640}/ox={360} oy={640}/; s/ox={195} oy={230}/ox={360} oy={230}/' src/videos/unread/scenes.tsx && sed -i 's/{ id: "forward", frames: 330/{ id: "forward", frames: 300/; s/{ id: "check", frames: 260/{ id: "check", frames: 240/; s/{ id: "close", frames: 260/{ id: "close", frames: 330/' src/videos/unread/timeline.ts && npm run voice 2>&1 | grep -c unread/ && npx tsc && sed -n 1,40p render-all.sh
13
#!/usr/bin/env bash
# Renders videos into out/, normalises their loudness, writes captions, and adds thumbnails as cover art.
# ./render-all.sh every video and cutdown (all compositions except scenes and thumbnails)
# ./render-all.sh Example just these compositions
# Output names are kebab-case: ExampleVertical -> out/example-vertical.mp4
# If the browser lacks system libraries (WSL without sudo), set CHROMIUM_LIBS to the extracted lib dir.
# Renders use up to 4 threads, never more than the machine's cores; override with RENDER_CONCURRENCY.
set -e
cd "$(dirname "$0")"
[ -n "$CHROMIUM_LIBS" ] && export LD_LIBRARY_PATH="$CHROMIUM_LIBS"
npx remotion bundle src/index.ts --out-dir=build >/dev/null 2>&1
comps="$*"
if [ -z "$comps" ]; then
comps=$(npx remotion compositions build --quiet 2>/dev/null | tr ' ' '\n' | grep -v -- '-' | grep -v 'Thumbnail$' | tr '\n' ' ')
fi
cores=$( (nproc || sysctl -n hw.ncpu) 2>/dev/null || echo 2)
concurrency=${RENDER_CONCURRENCY:-$(( cores < 4 ? cores : 4 ))}
kebab() { echo "$1" | sed -E 's/([a-z0-9])([A-Z])/\1-\2/g' | tr '[:upper:]' '[:lower:]'; }
for c in $comps; do
out="out/$(kebab "$c").mp4"
mkdir -p out
echo "rendering $c -> $out"
log="out/$(kebab "$c").render.log"
if ! npx remotion render build "$c" "$out" --concurrency="$concurrency" >"$log" 2>&1; then
echo "render failed for $c; last lines of $log:"
tail -20 "$log"
exit 1
fi
rm -f "$log"
./normalise.sh "$out"
# A <Name>Thumbnail composition (for the base name, so cutdowns share it) becomes the cover art
base_name="${c%Vertical}"
if grep -q "\"${base_name}Thumbnail\"" src/Root.tsx; then
thumb="out/$(kebab "$base_name")-thumbnail"
if [ ! -f "$thumb-1280.jpg" ] || [ "$c" = "$base_name" ]; then
npx remotion still build "${base_name}Thumbnail" "$thumb.png" --frame=239 >/dev/null 2>&1
npx remotion still build "${base_name}Thumbnail" "$thumb-1280.jpg" --frame=239 --scale=0.6667 --image-format=jpeg --jpeg-quality=90 >/dev/null 2>&1
fi
npx remotion ffmpeg -hide_banner -loglevel error -y -i "$out" -i "$thumb-1280.jpg" -map 0:v:0 -map 0:a:0 -map 1 -c copy -disposition:v:1 attached_pic "${out%.mp4}.cover.mp4"
mv "${out%.mp4}.cover.mp4" "$out"
Shell cwd was reset to /home/user/unread
[This command modified 1 file you've previously read: ../../../tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/scenes.tsx. Call Read before editing.]
Stills check out. Starting the full render in the background:
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && nproc && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -5 render.log
Command running in background with ID: bt592sze9. Output is being written to: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/bt592sze9.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains /home/user/unread; directory changes made by the backgrounded command do not apply to subsequent commands.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && mkdir -p stills2 && npx remotion still build UnreadThumbnail stills2/thumb.png --frame=239 --scale=0.5 >/dev/null 2>&1; echo $?
1
Shell cwd was reset to /home/user/unread
$ sleep 5; cat /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/render.log; ls /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out 2>/dev/null; tail -c 400 /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/*.log 2>/dev/null
rendering Unread -> out/unread.mp4
unread.render.log
6, time remaining: 13m 8s
Rendered 9/3296, time remaining: 13m 38s
Rendered 10/3296, time remaining: 13m 22s
Rendered 11/3296, time remaining: 12m 9s
Rendered 12/3296, time remaining: 11m 8s
Rendered 13/3296, time remaining: 10m 54s
Rendered 14/3296, time remaining: 10m 58s
Rendered 15/3296, time remaining: 10m 14s
Rendered 16/3296, time remaining: 9m 52s
Rendered 17/3296, time remaining: 10m 37s
The render is running: 3,296 frames, about 12 minutes on this one-core machine. When it finishes, I'll check frames from the actual MP4 and the audio levels, then send you the video.
<task-notification>
<task-id>bt592sze9</task-id>
<tool-use-id>toolu_01CvmChm39W12Hd9iCnWt6JT</tool-use-id>
<output-file>/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/bt592sze9.output</output-file>
<status>completed</status>
<summary>Background command "cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && nproc && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -5 render.log" completed (exit code 0)</summary>
</task-notification>
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && cat render.log; ls -la out; ffprobe -v error -show_entries format=duration -of csv=p=0 out/unread.mp4; node scripts/audio-report.mjs out/unread.mp4 2>&1 | head -40
rendering Unread -> out/unread.mp4
out/unread.mp4: -19.26 LUFS -> -16 LUFS
out/unread.vtt: 33 cues
exit 0
total 22528
drwxr-xr-x 2 root root 4096 Oct 25 21:41 .
drwxr-xr-x 11 root root 4096 Oct 25 21:36 ..
-rw-r--r-- 1 root root 66537 Oct 25 21:41 unread-thumbnail-1280.jpg
-rw-r--r-- 1 root root 848134 Oct 25 21:41 unread-thumbnail.png
-rw-r--r-- 1 root root 22132694 Oct 25 21:41 unread.mp4
-rw-r--r-- 1 root root 2279 Oct 25 21:41 unread.vtt
110.000000
out/unread.mp4: 109.9 s, peak -1.4 dBFS, RMS -19.3 dBFS
spectrum: <250 Hz 23%, 250-1k 59%, 1-4k 13%, >4k 5%
0s | .++++###########+#+..########+###+##+#######|
5s |##+##++###++##++.+#+####+#+++##+##+++++###+###++..|
10s |###+#############+......+###########.+############|
15s |#######+..########....+#####+##+#+++##+#+.+++++##+|
20s |###+#++####++#####+###########++++++##+++. ++###|
25s |###+.. ####+###+ .++++++#####+....+######+|
30s |++..##+##+#+++...#####++#######+##.+##+########+++|
35s |.+#####+####+#+####+######+#++#++++++++########+++|
40s |++++##+++....#############+#####.############+.+++|
45s |++##+++....######+###########+#+######++++########|
50s |####.######+#+++####+#########+######+#+++#######+|
55s |++..#+####+#++++++##++##+++.+++########+++++++##++|
60s |..#######+#+++#####.#+.++++++#######++++++++#+####|
65s |#########+#######+####+.+######++###########+..#+#|
70s |.##++#######++++##++####+++++######+####++##.#####|
75s |###+##++++#####################+#+.+++##+++....###|
80s |#+#####+#####+++########+#####+###+###+..+########|
85s |###++##+#+##########+++###+###########+.##+#######|
90s |##+++###################+############.##+#####+#++|
95s |+########+#+++.++++##########+##.......######+####|
100s |###########+####+##########+++##########+++####+++|
105s |++##+++++++########+++++++##+++++..#####++++.... |
legend: # voice-level + music or effects . quiet (blank) silent; each character is 0.1 s
Shell cwd was reset to /home/user/unread
The render finished: 110 s, normalised to −16 LUFS. The music drops out for the "So… I stopped reading" pause at about 25 s. Next I'm checking frames from the actual MP4.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && mkdir -p check && for t in 0.4 4 13 24.5 28 33 45 52 60 68 75 88 98 107; do ffmpeg -v error -y -ss $t -i out/unread.mp4 -frames:v 1 -vf scale=640:-1 check/f_$t.jpg; done; ffmpeg -v error -y $(for t in 0.4 4 13 24.5 28 33 45 52 60 68 75 88 98 107 107; do echo -i check/f_$t.jpg; done) -filter_complex "xstack=inputs=15:grid=3x5" check/sheet.jpg && echo ok
ok
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/check/sheet.jpg
[image omitted from the upload]
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && cat > src/kit/Mark.tsx <<'EOF'
import React from "react";
import { c } from "../theme";
/** Unread's mark: the brand-blue dot that sits before the wordmark. */
export const Mark = ({ size = 28 }: { size?: number }) => (
<div style={{ width: size, height: size, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<div style={{ width: size * 0.56, height: size * 0.56, borderRadius: "50%", background: c.brand }} />
</div>
);
EOF
sed -i 's/ reveal: \[{ at: 22, text: "Unread. Your newsletters, as a podcast." }\],/ reveal: [{ at: 10, text: "Unread. Your newsletters, as a podcast." }],/' src/videos/unread/narration.ts && grep -n "reveal:" src/videos/unread/narration.ts && npm run voice 2>&1 | grep reveal && npx tsc && head -20 out/unread.vtt && cp out/unread.mp4 out/unread-v1.mp4
19: reveal: [{ at: 10, text: "Unread. Your newsletters, as a podcast." }],
unread/reveal: 1 line(s), ends 105/140
WEBVTT
1
00:00:01.033 --> 00:00:02.700
This is my inbox.
2
00:00:02.700 --> 00:00:05.700
124 unread newsletters.
3
00:00:05.700 --> 00:00:08.867
I subscribed to every one of them on purpose.
4
00:00:08.867 --> 00:00:11.967
They're important, but I'm too lazy to read them.
5
00:00:12.700 --> 00:00:15.967
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log
Command running in background with ID: b7ucskf1c. Output is being written to: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/b7ucskf1c.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains /home/user/unread; directory changes made by the backgrounded command do not apply to subsequent commands.
The final render is running (about 12 minutes). I've kept the first version as out/unread-v1.mp4 so you can compare the two.
<task-notification>
<task-id>b7ucskf1c</task-id>
<tool-use-id>toolu_01FBtHst7pBYBf92SYzvctFR</tool-use-id>
<output-file>/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/b7ucskf1c.output</output-file>
<status>completed</status>
<summary>Background command "cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log" completed (exit code 0)</summary>
</task-notification>
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && cat render.log; ffprobe -v error -show_entries format=duration -of csv=p=0 out/unread.mp4; node scripts/audio-report.mjs out/unread.mp4 2>&1 | head -3; for t in 0.4 26.5 27.6; do ffmpeg -v error -y -ss $t -i out/unread.mp4 -frames:v 1 -vf scale=640:-1 check/g_$t.jpg; done; ffmpeg -v error -y -i check/g_0.4.jpg -i check/g_26.5.jpg -i check/g_27.6.jpg -filter_complex hstack=3 check/sheet2.jpg
rendering Unread -> out/unread.mp4
out/unread.mp4: -19.30 LUFS -> -16 LUFS
out/unread.vtt: 33 cues
exit 0
109.700000
out/unread.mp4: 109.6 s, peak -1.3 dBFS, RMS -19.3 dBFS
spectrum: <250 Hz 22%, 250-1k 59%, 1-4k 13%, >4k 5%
0s | .++++###########+#+..########+###+##+#######|
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/check/sheet2.jpg
[image omitted from the upload]
{"files":["/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.mp4","/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad...
3 files delivered to user.
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.mp4 → file_uuid: a6fe71d0-a49c-4546-9ab6-518ed3aa5b52
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread-thumbnail.png → file_uuid: fe5983a8-6d1c-4a31-9684-5b111b501096
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.vtt → file_uuid: 5bd14e3e-913a-4d9d-820d-f3186bd2c129
The video is done and attached above: 1:50 at 16:9, with its thumbnail and a captions file.
What's in it: the dark Cinematic look in Unread's own blue, following the script. Every spoken word also appears on screen as it's said, so it works with the sound off. It opens on the thumbnail for under a second, because X and similar feeds use the first frame as the preview. The music cuts out for "So… I stopped reading." and comes back with the logo.
The files:
- unread.mp4, the video. The first render is kept as out/unread-v1.mp4 if you want to compare.
- unread-thumbnail.png, also embedded in the MP4 as its cover.
- unread.vtt, captions you can upload with the video.
All three are in /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/.
What's real and what isn't:
- Real: the screens are rebuilt from the app's code, with its colours, fonts and exact labels: the onboarding step, the Gmail confirmation code card, "Making your episode…" with its nine steps, Your morning brief, Now playing with "Source" and "Also covered by", and the "Newsletter · also in …" chapter label. The host's first two lines use the app's real intro template.
- Made up: every subject line and chapter title ("Write the design doc first", "Why design docs save weeks", and the rest), the "40% faster" line that gets cut, the "Caching" story, the episode times, the address alias and the confirmation code. Those titles are shown under the real newsletters' names. If you send me real subject lines from your inbox, I'll swap them in; a re-render takes about 12 minutes.
- Voices: ElevenLabs eleven_v4, with River as you and Alice as the host. Both are AI-generated. It used about 1,160 characters. The key in this environment reports a Starter plan rather than Pro, which still allows commercial use.
- Music: generated by the video kit itself, so there's no licence to worry about, but it's a placeholder-quality bed. ElevenLabs Music could make a better one, but I didn't spend your credits on it.
What I checked: still frames of every scene, frames taken from the finished MP4, and the audio levels. The loudness is −16 LUFS and nothing clips. The voice and music alternate as planned, with silence where the pause is. Things I couldn't check:
- How it feels in motion.
- How the voices and music sound.
- Whether the "SAME STORY" stamp lands on the beat.
- Whether the phone screens are readable at the size you'll embed. I added zooms where the phone text is small.
The length: it came out about 15 s longer than the planned 95 s, because River speaks in a relaxed way and the episode itself gets about 14 s. If you want it shorter, the quickest cut is dropping the host's long "4 stories today, from…" line, which saves about 6 s.
To change wording, edit src/videos/unread/narration.ts, then run npm run voice and ./render-all.sh Unread. Only changed lines use new ElevenLabs characters. Remotion, which renders the video, is free for individuals and small teams.
[image omitted from the upload]
[image omitted from the upload]
Show an actual Gmail inbox design (see image) for that this is my inbox
I'm rebuilding the opening and closing inbox to look like your Gmail screenshots (light Material design, cards, avatars, unread dots, Compose button, the 99+ badge), using your real rows.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread && grep -n "MailFolder\|MAIL\b\|Odometer" scenes.tsx | head; grep -n "^import\|lucide" ui.tsx | head
6:import { Odometer, Stamp } from "../../kit/Motion";
17: MailFolder,
72: <MailFolder count={<Odometer value={124} at={l1.from + 4} dur={Math.round(l1.frames * 0.8)} size={32} weight={600} color={u.text} group={false} />} scroll={frame * 0.8} rowsOpacity={1 - toTiles} />
634: <MailFolder count="124" scroll={0} />
1:import React, { useLayoutEffect, type CSSProperties, type ReactNode } from "react";
2:import { Check, CirclePlay, Inbox, Link2, SlidersHorizontal, ChevronDown, Pause, Play, SkipBack, SkipForward } from "lucide-react";
3:import { useCurrentFrame } from "remotion";
4:import { progress, track } from "../../anim";
5:import { Anchor, AnchorProvider, resolve, useAnchorStore, type Aim } from "../../kit/anchors";
6:import { Sfx } from "../../kit/sfx";
7:import { fontFamily, monoFamily } from "../../theme";
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && npm i --save-exact @fontsource-variable/roboto@5.2.8 2>&1 | tail -1; ls node_modules/@fontsource-variable/roboto/files | grep "latin-wght-normal"
Run `npm audit` for details.
roboto-latin-wght-normal.woff2
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/kit/fonts.ts'; s=open(p).read()
s=s.replace('import geistMono from','import roboto from "@fontsource-variable/roboto/files/roboto-latin-wght-normal.woff2";\nimport geistMono from',1)
s=s.replace(' geistMono: "Geist Mono Variable",\n',' geistMono: "Geist Mono Variable",\n /** Android\'s system face, for the Gmail inbox the story opens on */\n roboto: "Roboto Variable",\n')
s=s.replace(' { family: FONTS.geistMono, url: geistMono, weight: "100 900", style: "normal" },\n',' { family: FONTS.geistMono, url: geistMono, weight: "100 900", style: "normal" },\n { family: FONTS.roboto, url: roboto, weight: "100 900", style: "normal" },\n')
open(p,'w').write(s)
p='src/videos/unread/ui.tsx'; s=open(p).read()
s=s.replace('import { Check, CirclePlay, Inbox, Link2, SlidersHorizontal, ChevronDown, Pause, Play, SkipBack, SkipForward } from "lucide-react";','import { Check, CirclePlay, Inbox, Link2, Mail as MailIcon, Menu, Pencil, SlidersHorizontal, ChevronDown, Pause, Play, SkipBack, SkipForward, Star, Video } from "lucide-react";')
s=s.replace('import { fontFamily, monoFamily } from "../../theme";','import { FONTS, stack } from "../../kit/fonts";\nimport { fontFamily, monoFamily } from "../../theme";')
s += '''
/**
* The user's own Gmail inbox on Android, rebuilt from their screenshots (Oct 2026): Material 3 light
* theme, one card per thread, the senders and subjects exactly as they appear there (emoji left out).
*/
export const GMAIL_ROWS = [
{ from: "Neo Kim", date: "Oct 23", subject: "11 Networking Concepts Explained", preview: "#195: Part 2 - Break Into Computer Networking" },
{ from: "Neo Kim", date: "Oct 22", subject: "The 80/20 of AI Concepts", preview: "#194: A Practical Guide to 14 Advanced AI Concepts" },
{ from: "Gregor Ojstersek from Engineering Leadership", date: "Oct 22", subject: "How to Communicate Your Impact", preview: "An adapted chapter from my upcoming book" },
{ from: "Neo Kim", date: "Oct 20", subject: "64 AI Engineering Concepts, Clearly Explained", preview: "#193: Part 2 - RLHF, Pretraining, Vectors" },
{ from: "Gregor Ojstersek from Engineering Leadership", date: "Oct 20", subject: "How to Use AI to Become a More E…", preview: "An adapted chapter from my book" },
{ from: "Neo Kim", date: "Oct 19", subject: "Networking - A Deep Dive", preview: "#192: Part 1 - Must Know Networking Concepts" },
{ from: "Simon Hoiberg", date: "Oct 17", subject: "Someone vibe coded Photoshop", preview: "Someone vibe coded Photoshop from scratch" },
{ from: "Gregor Ojstersek from Engineering Leadership", date: "Oct 15", subject: "How to Build Better Software with…", preview: "An adapted chapter from my upcoming book" },
{ from: "Neo Kim", date: "Oct 15", subject: "HTTP1 vs HTTP2 vs HTTP3", preview: "#190: Part 1 - 23 Concepts Every Engineer" },
{ from: "Neo Kim", date: "Oct 13", subject: "AI Engineering Playbook", preview: "Download My Playbook for Free" },
{ from: "Gregor Ojstersek from Engineering Leadership", date: "Oct 12", subject: "How to Use AI to Learn Faster", preview: "An adapted chapter from my upcoming book" },
{ from: "Neo Kim", date: "Oct 12", subject: "How to Scale an “AI App” to 1 Million Users", preview: "#189: Part 7 - Generative AI Masterclass" },
{ from: "Neo Kim", date: "Oct 11", subject: "If I had to start with system design…", preview: "Get 2+ months free" },
];
/** Material 3 light colours, sampled from the screenshots. */
const g = {
page: "#f8efe9",
card: "#fff8f4",
ink: "#201a16",
ink2: "#52443b",
dot: "#7a5a46",
bar: "#f3e6dd",
pill: "#ffdcc3",
fab: "#ffdcbb",
badge: "#ba1a1a",
};
const AVATAR: Record<string, string> = { N: "#f6bf26", G: "#a1887f", S: "#a8b4e8" };
const roboto = stack(FONTS.roboto);
const clip: CSSProperties = { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" };
const GmailRow = ({ from, date, subject, preview }: (typeof GMAIL_ROWS)[number]) => (
<div style={{ display: "flex", gap: 14, padding: "14px 14px 14px 16px", margin: "0 8px 4px", borderRadius: 14, background: g.card }}>
<span style={{ width: 40, height: 40, borderRadius: 20, background: AVATAR[from[0]] ?? "#a1887f", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 19, flex: "none" }}>{from[0]}</span>
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ ...clip, flex: 1, fontSize: 16, fontWeight: 700 }}>{from}</span>
<span style={{ fontSize: 12, fontWeight: 700 }}>{date}</span>
<span style={{ width: 8, height: 8, borderRadius: 4, background: g.dot, flex: "none" }} />
</div>
<span style={{ ...clip, fontSize: 14, fontWeight: 700 }}>{subject}</span>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ ...clip, flex: 1, fontSize: 14, color: g.ink2 }}>{preview}</span>
<Star size={20} color={g.ink2} strokeWidth={1.6} />
</div>
</div>
</div>
);
/** Gmail's inbox list: search bar, the thread cards (scrolled by `scroll` px, looping), Compose and the bottom bar. */
export const GmailScreen = ({ scroll = 0 }: { scroll?: number }) => {
const rowH = 96;
const loop = GMAIL_ROWS.length * rowH;
const y = scroll % loop;
return (
<div style={{ position: "absolute", inset: 0, background: g.page, color: g.ink, fontFamily: roboto, overflow: "hidden" }}>
<div style={{ position: "absolute", left: 0, right: 0, top: 128 - y }}>
{[...GMAIL_ROWS, ...GMAIL_ROWS, ...GMAIL_ROWS].map((r, i) => (
<div key={i} style={{ height: rowH }}>
<GmailRow {...r} />
</div>
))}
</div>
<div style={{ position: "absolute", left: 0, right: 0, top: 0, height: 128, background: g.page, padding: "26px 16px 0" }}>
<div style={{ height: 52, borderRadius: 26, background: g.bar, display: "flex", alignItems: "center", gap: 16, padding: "0 8px 0 18px" }}>
<Menu size={22} color={g.ink} />
<span style={{ flex: 1, fontSize: 16, color: g.ink2 }}>Search in mail</span>
<span style={{ width: 34, height: 34, borderRadius: 17, background: "#5c6bc0", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 14, fontWeight: 500 }}>L</span>
</div>
<div style={{ fontSize: 13, color: g.ink2, padding: "18px 10px 0", fontWeight: 500 }}>Inbox</div>
</div>
<div style={{ position: "absolute", right: 18, bottom: 98, height: 56, padding: "0 20px 0 16px", borderRadius: 16, background: g.fab, display: "flex", alignItems: "center", gap: 12, boxShadow: "0 3px 8px rgba(0,0,0,0.18)", fontSize: 15, fontWeight: 500 }}>
<Pencil size={20} />
Compose
</div>
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 80, background: g.bar, display: "flex", alignItems: "center", justifyContent: "space-around" }}>
<span style={{ position: "relative", width: 64, height: 32, borderRadius: 16, background: g.pill, display: "flex", alignItems: "center", justifyContent: "center" }}>
<MailIcon size={22} fill={g.ink} color={g.pill} />
<span style={{ position: "absolute", left: 38, top: -8, padding: "1px 6px", borderRadius: 9, background: g.badge, color: "#fff", fontSize: 11, fontWeight: 600 }}>99+</span>
</span>
<Video size={24} color={g.ink2} />
</div>
</div>
);
};
'''
open(p,'w').write(s)
p='src/videos/unread/scenes.tsx'; s=open(p).read()
s=s.replace(' MailFolder,\n',' GmailScreen,\n')
s=s.replace('import { Odometer, Stamp } from "../../kit/Motion";','import { Stamp } from "../../kit/Motion";')
open(p,'w').write(s)
EOF
sed -n 50,90p src/videos/unread/scenes.tsx; sed -n 610,640p src/videos/unread/scenes.tsx
export const Inbox = () => {
const frame = useCurrentFrame();
const l1 = line("inbox", 1);
const l2 = line("inbox", 2);
const enter = progress(frame, 0, 40);
const toTiles = progress(frame, l2.from + 6, l2.from + 40);
return (
<AbsoluteFill>
<Ground glow={0.28} />
<div style={{ position: "absolute", left: LEFT, top: 300, width: 860 }}>
<SaidStack
scene="inbox"
size={68}
lines={[
{ n: 0, text: "This is my inbox." },
{ n: 1, text: "*124* unread newsletters." },
{ n: 2, text: "I subscribed to every one of them on purpose." },
{ n: 3, text: "They're important, but I'm *too lazy* to read them." },
]}
/>
</div>
<div style={{ position: "absolute", left: 1060, top: 160, opacity: enter, transform: `translateY(${(1 - enter) * 60}px) scale(${0.96 + 0.04 * enter})`, filter: `blur(${(1 - enter) * 10}px)` }}>
<MailFolder count={<Odometer value={124} at={l1.from + 4} dur={Math.round(l1.frames * 0.8)} size={32} weight={600} color={u.text} group={false} />} scroll={frame * 0.8} rowsOpacity={1 - toTiles} />
{/* The rows become a wall of tiles: one per unopened issue */}
<div style={{ position: "absolute", left: 40, top: 150, width: 680, display: "flex", flexWrap: "wrap", gap: 10, opacity: toTiles }}>
{Array.from({ length: 124 }, (_, i) => {
const t = progress(frame, l2.from + 10 + (i % 31) * 0.9 + Math.floor(i / 31) * 3, l2.from + 30 + (i % 31) * 0.9 + Math.floor(i / 31) * 3);
const pulse = 0.55 + 0.45 * Math.sin(frame / 22 + i * 1.7);
return <div key={i} style={{ width: 32, height: 32, borderRadius: 8, background: i % 5 === 0 ? u.brand : "rgba(91,155,255,0.35)", opacity: t * (0.55 + 0.45 * pulse), transform: `scale(${0.4 + 0.6 * t})` }} />;
})}
</div>
</div>
</AbsoluteFill>
);
};
export const Overlap = () => {
const frame = useCurrentFrame();
const l0 = line("overlap", 0);
const l1 = line("overlap", 1);
const hl = progress(frame, l0.from + l0.frames * 0.55, l0.from + l0.frames * 0.55 + 16);
};
export const Close = () => {
const frame = useCurrentFrame();
const look = useLook();
const l1 = line("close", 1);
const endAt = l1.from + l1.frames + 18;
const end = progress(frame, endAt, endAt + 24);
const enter = progress(frame, 0, 36);
return (
<AbsoluteFill>
<Ground glow={0.35 + end * 0.5} />
<div style={{ opacity: 1 - end, filter: `blur(${end * 10}px)` }}>
<div style={{ position: "absolute", left: LEFT, top: 330, width: 840 }}>
<SaidStack
scene="close"
size={70}
lines={[
{ n: 0, text: "My inbox still says *124 unread.*" },
{ n: 1, text: "I'm *caught up* anyway." },
]}
/>
</div>
<div style={{ position: "absolute", left: 1060, top: 160, opacity: enter, transform: `translateY(${(1 - enter) * 60}px)` }}>
<MailFolder count="124" scroll={0} />
</div>
</div>
<AbsoluteFill style={{ alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 34, opacity: end, transform: `scale(${0.96 + 0.04 * end})` }}>
<BrandMark size={140} />
<div style={{ fontFamily: look.font, fontSize: 60, fontWeight: 500, letterSpacing: -1.5, color: u.text2 }}>
Hear each story <span style={{ color: c.brand300, fontFamily: look.accentFont?.family, fontStyle: "italic", fontSize: "1.14em" }}>once.</span>
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/scenes.tsx'; s=open(p).read()
start=s.index(''' <div style={{ position: "absolute", left: 1060, top: 160, opacity: enter, transform: `translateY(${(1 - enter) * 60}px) scale(${0.96 + 0.04 * enter})`, filter: `blur(${(1 - enter) * 10}px)` }}>
<MailFolder''')
end=s.index(''' </AbsoluteFill>
);
};
export const Overlap''')
s=s[:start]+''' {/* Their own Gmail inbox, scrolling through issue after unopened issue (faster on "124") */}
<div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)`, filter: `blur(${(1 - enter) * 10}px)` }}>
<Phone scale={1.1}>
<GmailScreen scroll={frame * 0.9 + 760 * progress(frame, l1.from, l1.from + l1.frames + 10, ease)} />
</Phone>
</div>
'''+s[end:]
s=s.replace(''' const l2 = line("inbox", 2);
const enter = progress(frame, 0, 40);
const toTiles = progress(frame, l2.from + 6, l2.from + 40);''',''' const enter = progress(frame, 0, 40);''')
s=s.replace(''' <div style={{ position: "absolute", left: 1060, top: 160, opacity: enter, transform: `translateY(${(1 - enter) * 60}px)` }}>
<MailFolder count="124" scroll={0} />
</div>''',''' <div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
<Phone scale={1.1}>
<GmailScreen scroll={0} />
</Phone>
</div>''')
open(p,'w').write(s)
EOF
npx tsc && ./stills.sh unread-inbox:30,120,300 unread-close:90 2>&1 | tail -1 && cd stills && ffmpeg -v error -y -i unread-inbox-30.png -i unread-inbox-120.png -i unread-inbox-300.png -i unread-close-90.png -filter_complex "xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0" sheet6.png
done
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/sheet6.png
[image omitted from the upload]
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad && sed -i 's|<MailIcon size={22} fill={g.ink} color={g.pill} />|<MailIcon size={22} color={g.ink} strokeWidth={2} />|' unread-video/src/videos/unread/ui.tsx && curl -sS "https://api.github.com/repos/AntennaPod/AntennaPod/contents/app/src/main/play/listings/en-US/graphics/phone-screenshots" | python3 -c "import json,sys; d=json.load(sys.stdin); [print(x['name'], x['download_url']) for x in d] if isinstance(d,list) else print(d)" 2>&1 | head -20
{'message': 'GitHub access to this repository is not enabled for this session. Use add_repo to request access. If add_repo answers that read access is already available and you need GitHub API or write access, call add_repo again with access:"push" to attach the repository with credentials.', 'documentation_url': 'https://docs.anthropic.com/en/docs/claude-code/github-actions'}
Shell cwd was reset to /home/user/unread
[This command modified 1 file you've previously read: ../../../tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/ui.tsx. Call Read before editing.]
$ curl -sS -L https://antennapod.org/ | grep -oE '(src|href)="[^"]+\.(png|jpg|webp)"' | head -20; curl -sS -o /dev/null -w "%{http_code}\n" https://raw.githubusercontent.com/AntennaPod/AntennaPod/master/README.md
src="/assets/images/screenshots/en/00.png"
src="/assets/images/screenshots/en/00.png"
src="/assets/images/screenshots/en/00.png"
200
$ mkdir -p /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/ref-antennapod && cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/ref-antennapod && for i in 00 01 02 03 04 05 06 07; do curl -sS -f -o $i.png https://antennapod.org/assets/images/screenshots/en/$i.png && echo got $i; done; for i in 1 2 3 4 5 6 7 8; do curl -sS -f -o gh$i.png "https://raw.githubusercontent.com/AntennaPod/AntennaPod/master/app/src/main/play/listings/en-US/graphics/phone-screenshots/0$i.png" && echo gh $i; done; file *.png | head
got 00
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
curl: (22) The requested URL returned error: 404
00.png: PNG image data, 400 x 711, 8-bit/color RGB, non-interlaced
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/ref-antennapod && for p in "fastlane/metadata/android/en-US/images/phoneScreenshots" "app/src/main/play/listings/en-US/graphics/phone-screenshots" "metadata/en-US/images/phoneScreenshots"; do for i in 1 01 00 2 02; do curl -sS -f -o "t_${i}.png" "https://raw.githubusercontent.com/AntennaPod/AntennaPod/master/$p/$i.png" 2>/dev/null && echo "ok $p/$i"; done; done; curl -sS -L "https://f-droid.org/en/packages/de.danoeh.antennapod/" | grep -oE 'https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/[^"]+' | sort -u | head
https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png
https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/01.png
https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/02.png
https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/03.png
https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/04.png
https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/05.png
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/ref-antennapod && rm -f t_*.png && for i in 00 01 02 03 04 05; do curl -sS -f -o fd$i.png https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/$i.png; done; file fd*.png; ffmpeg -v error -y -i fd00.png -i fd01.png -i fd02.png -i fd03.png -i fd04.png -i fd05.png -filter_complex "[0]scale=360:-1[a];[1]scale=360:-1[b];[2]scale=360:-1[c];[3]scale=360:-1[d];[4]scale=360:-1[e];[5]scale=360:-1[f];[a][b][c][d][e][f]hstack=6" sheet.png && echo ok
fd00.png: PNG image data, 1120 x 1920, 8-bit/color RGBA, non-interlaced
fd01.png: PNG image data, 1120 x 1920, 8-bit/color RGBA, non-interlaced
fd02.png: PNG image data, 1698 x 2911, 8-bit/color RGBA, non-interlaced
fd03.png: PNG image data, 1120 x 1920, 8-bit/color RGBA, non-interlaced
fd04.png: PNG image data, 1120 x 1920, 8-bit/color RGBA, non-interlaced
fd05.png: PNG image data, 1120 x 1920, 8-bit/color RGBA, non-interlaced
ok
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/ref-antennapod/sheet.png
[image omitted from the upload]
[Image: original 2160x617, displayed at 2000x571. Multiply coordinates by 1.08 to map to original image.]
I found AntennaPod's official screenshots, including its "Add podcast → Add podcast by RSS address" screen, which is exactly this flow. I'm looking at them up close:
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/ref-antennapod && ffmpeg -v error -y -i fd05.png -vf "crop=820:1460:150:560" add.png && ffmpeg -v error -y -i fd01.png -vf "crop=820:1460:150:560" subs.png && ffmpeg -v error -y -i add.png -i subs.png -filter_complex hstack=2 pair.png && echo ok
ok
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/ref-antennapod/pair.png
[image omitted from the upload]
I'm pulling AntennaPod's exact UI strings from its source, and checking what Unread's feed actually publishes (title, author, cover):
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/ref-antennapod && for p in ui/i18n/src/main/res/values/strings.xml core/src/main/res/values/strings.xml; do curl -sS -f -o strings.xml "https://raw.githubusercontent.com/AntennaPod/AntennaPod/develop/$p" && echo "got $p" && break; done; grep -nE 'name="(add_podcast_by_url|addFeedUrl|add_feed_label|feedurl_label|confirm_label|cancel_label|subscribe_label|open_podcast|feed_url|feedurl|subscribing_label|add_podcast|search_podcast_hint|discover_more|subscriptions_label|episodes_label|new_episode|preview|loading_label|downloading_label|feed_new_episodes_action_add_inbox|home_label|queue_label|inbox_label|more_label)[^"]*"' strings.xml | head -40
got ui/i18n/src/main/res/values/strings.xml
15: <string name="add_feed_label">Add podcast</string>
16: <string name="add_feed_label_short">Add</string>
17: <string name="episodes_label">Episodes</string>
18: <string name="episodes_label_short">Episodes</string>
19: <string name="home_label">Home</string>
20: <string name="home_label_short">Home</string>
21: <string name="queue_label">Queue</string>
22: <string name="queue_label_short">Queue</string>
23: <string name="inbox_label">Inbox</string>
24: <string name="inbox_label_short">Inbox</string>
31: <string name="subscriptions_label">Subscriptions</string>
32: <string name="subscriptions_label_short">Subscriptions</string>
122: <string name="confirm_label">Confirm</string>
123: <string name="cancel_label">Cancel</string>
187: <plurals name="new_episode_notification_message">
191: <plurals name="new_episode_notification_title">
195: <string name="new_episode_notification_group_text">Your subscriptions have new episodes.</string>
229: <string name="open_podcast">Open podcast</string>
609: <string name="new_episode_notification_enabled">Notification enabled</string>
610: <string name="new_episode_notification_disabled">Notification disabled</string>
803: <string name="subscribe_label">Subscribe</string>
804: <string name="preview_episodes">Episodes preview</string>
891: <string name="search_podcast_hint">Search podcast…</string>
895: <string name="add_podcast_by_url">Add podcast by RSS address</string>
901: <string name="discover_more">Discover more »</string>
Shell cwd was reset to /home/user/unread
$ cd /home/user/unread/packages/core/src && ls feed; grep -n "<title>\|itunes:author\|itunes:image\|<description>\|channel" feed/*.ts | grep -v test | head -20
index.ts
rss.ts
feed/rss.ts:66: <title>${xml(e.title)}</title>
feed/rss.ts:69: <description>${cdata(showNotesHtml(e.transcript))}</description>
feed/rss.ts:73: <itunes:image href="${xml(feedUrls.cover(base, input.feedToken, e.id))}"/>
feed/rss.ts:82: <channel>
feed/rss.ts:83: <title>${xml(input.ownerLabel)}</title>
feed/rss.ts:86: <description>Your newsletters, minus the overlap. A private feed made by Unread.</description>
feed/rss.ts:88: <itunes:author>Unread</itunes:author>
feed/rss.ts:89: <itunes:image href="${xml(`${base}/podcast-cover.png`)}"/>
feed/rss.ts:96: </channel>
$ cd /home/user/unread && sed -n 55,100p packages/core/src/feed/rss.ts; grep -rn "ownerLabel" --include=*.ts apps packages | grep -v test | head -5; ls apps/web/public | head; grep -rn "feed\|Private feed" apps/web/src/features/sources/*.tsx | grep -iE "label|feed url|private" | head -8; cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/ref-antennapod && grep -n -B2 -A2 'add_podcast_by_url' strings.xml | head; grep -nE '"(rss_address|feedurl_hint|url_label|edit_url|feed_url_hint|add_podcast_by_url_hint|subscribed_label|loading_more|preview_episodes|episode_actions|feed_auto_download)' strings.xml | head
/**
* Private RSS 2.0 feed with iTunes and Podcasting 2.0 tags (prd §7, v1 §7):
* itunes:block, not explicit, last 30 episodes, chapters JSON per episode.
*/
export function buildFeedXml(input: FeedInput): string {
const base = input.baseUrl.replace(/\/$/, "");
const items = input.episodes
.slice(0, 30)
.map(
(e) => ` <item>
<title>${xml(e.title)}</title>
<guid isPermaLink="false">unread-${e.id}</guid>
<pubDate>${e.createdAt.toUTCString()}</pubDate>
<description>${cdata(showNotesHtml(e.transcript))}</description>
<content:encoded>${cdata(showNotesHtml(e.transcript))}</content:encoded>
<enclosure url="${xml(feedUrls.audio(base, input.feedToken, e.id))}" length="${e.audioBytes}" type="audio/mpeg"/>
<itunes:duration>${Math.round(e.durationS)}</itunes:duration>
<itunes:image href="${xml(feedUrls.cover(base, input.feedToken, e.id))}"/>
<itunes:explicit>false</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<podcast:chapters url="${xml(feedUrls.chapters(base, input.feedToken, e.id))}" type="application/json+chapters"/>
</item>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${xml(input.ownerLabel)}</title>
<link>${xml(base)}</link>
<atom:link href="${xml(feedUrls.feed(base, input.feedToken))}" rel="self" type="application/rss+xml"/>
<description>Your newsletters, minus the overlap. A private feed made by Unread.</description>
<language>en</language>
<itunes:author>Unread</itunes:author>
<itunes:image href="${xml(`${base}/podcast-cover.png`)}"/>
<itunes:category text="News"/>
<itunes:explicit>false</itunes:explicit>
<itunes:block>yes</itunes:block>
<itunes:type>episodic</itunes:type>
<podcast:locked>yes</podcast:locked>
${items}
</channel>
</rss>
`;
}
apps/web/src/app/feed/[token]/route.ts:17: ownerLabel: "Unread",
packages/core/src/feed/rss.ts:16: ownerLabel: string;
packages/core/src/feed/rss.ts:83: <title>${xml(input.ownerLabel)}</title>
podcast-cover.png
voices
893- <string name="search_podcastindex_label">Search Podcast Index</string>
894- <string name="search_fyyd_label">Search fyyd</string>
895: <string name="add_podcast_by_url">Add podcast by RSS address</string>
896- <string name="rss_address">RSS address</string>
897- <string name="rss_address_invalid">The RSS address you entered is not valid.</string>
131: <string name="url_label">URL</string>
154: <string name="feed_auto_download_always">Always</string>
155: <string name="feed_auto_download_never">Never</string>
804: <string name="preview_episodes">Episodes preview</string>
870: <string name="edit_url_menu">Edit feed URL</string>
871: <string name="edit_url_menu_summary">Risky: can permanently break episodes list, history, and playback state</string>
872: <string name="edit_url_confirmation_msg">Changing the RSS address can easily break the playback state and episode listings of the podcast. We do NOT recommend changing it and will NOT provide support if anything goes wrong. This cannot be undone. The broken subscription CANNOT be repaired by simply changing the address back. We recommend creating a backup before continuing.</string>
896: <string name="rss_address">RSS address</string>
897: <string name="rss_address_invalid">The RSS address you entered is not valid.</string>
Shell cwd was reset to /home/user/unread
$ cd /home/user/unread/apps/web/src && grep -rln "feedUrl\|Private feed\|private feed\|podcast app" features app | head; grep -rn -i "private feed\|podcast app\|feedUrl" features/sources app/\(app\)/sources 2>/dev/null | head -12; file /home/user/unread/apps/web/public/podcast-cover.png
features/landing/content.ts
features/feed/serve-object.ts
app/(app)/sources/page.tsx
app/privacy/page.tsx
app/(app)/sources/page.tsx:33: const feedUrl = `${serverEnv().APP_BASE_URL.replace(/\/$/, "")}/feed/${user.feedToken}.xml`;
app/(app)/sources/page.tsx:129: <SectionCard title="Listen in your podcast app" description="Add this private feed once. New episodes appear on their own. Keep it to yourself.">
app/(app)/sources/page.tsx:130: <FieldRow action={<CopyButton value={feedUrl} />}>{feedUrl}</FieldRow>
/home/user/unread/apps/web/public/podcast-cover.png: PNG image data, 3000 x 3000, 8-bit/color RGBA, non-interlaced
$ sed -n 40,140p "app/(app)/sources/page.tsx"; grep -rn "export function SectionCard" -A14 components features | head -20; grep -rn "export function FieldRow" -A10 components features | head -14; cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad && ffmpeg -v error -y -i /home/user/unread/apps/web/public/podcast-cover.png -vf scale=400:400 ref-antennapod/cover-small.png
.from(schema.items)
.leftJoin(schema.newsletters, eq(schema.newsletters.id, schema.items.newsletterId))
.where(and(eq(schema.items.userId, user.id), eq(schema.items.status, "queued")))
.orderBy(desc(schema.items.createdAt))
.limit(40),
db().select().from(schema.userTopics).where(eq(schema.userTopics.userId, user.id)),
]);
return (
<PageContainer className="flex flex-col gap-6">
<PageHeading eyebrow="SOURCES" title="Sources" />
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-2 lg:items-start">
<div className="flex flex-col gap-6">
<SectionCard title="Forward your newsletters here" description="Forward issues by hand or with an auto-forward rule. Only mail from the addresses below is accepted.">
<FieldRow action={<CopyButton value={address} />}>{address}</FieldRow>
<details className="group rounded-xl border border-line bg-ground p-3">
<summary className="min-h-11 cursor-pointer list-none py-2.5 text-sm font-medium">Set up auto-forwarding</summary>
<div className="pt-2">
<ForwardingGuides />
</div>
</details>
<div className="flex flex-col gap-2">
<h3 className="m-0 text-[13px] font-semibold text-text-2">Allowed to forward</h3>
<ul className="m-0 flex list-none flex-col p-0">
{senders.map((s) => (
<li key={s.id} className="flex items-center gap-2 border-b border-surface-2 py-2 last:border-b-0">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{s.email}</span>
{s.verifiedAt ? (
<span className="text-xs text-muted-foreground">Verified</span>
) : (
<span className="text-xs text-text-2">
To verify, send any email from this address to {address} with <span className="font-mono">{s.verificationCode}</span> in the subject.
</span>
)}
</div>
{s.email.toLowerCase() !== sessionUser.email?.toLowerCase() ? (
<form action={removeSenderAction}>
<input type="hidden" name="senderId" value={s.id} />
<Button type="submit" variant="ghost" size="icon" aria-label={`Remove ${s.email}`}>
✕
</Button>
</form>
) : null}
</li>
))}
</ul>
<AddSenderForm />
</div>
<form action={rotateAliasAction}>
<Button type="submit" variant="link" className="h-11 px-0">
Get a new forwarding address
</Button>
</form>
</SectionCard>
<SectionCard title="Newsletters" description="Every newsletter you've forwarded. Turn one off to leave it out of episodes.">
{newsletters.length === 0 ? (
<p className="m-0 text-sm text-muted-foreground">None yet. Forward an issue to get started.</p>
) : (
<ul className="m-0 flex list-none flex-col p-0">
{newsletters.map((n) => (
<li key={n.id} className="flex items-center gap-3 border-b border-surface-2 py-2.5 last:border-b-0">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[15px] font-medium">{n.displayName}</span>
<span className="text-xs text-muted-foreground">
{n.lastIssueAt ? `Last issue ${relativeTime(n.lastIssueAt)}` : n.senderEmail}
</span>
</div>
<PrioritySelect newsletterId={n.id} value={n.priority} name={n.displayName} />
<form action={toggleNewsletterAction}>
<input type="hidden" name="newsletterId" value={n.id} />
<button
type="submit"
role="switch"
aria-checked={n.enabled}
aria-label={`Include ${n.displayName} in episodes`}
className={`relative flex h-11 w-14 cursor-pointer items-center`}
>
<span className={`absolute inset-x-1 h-7 rounded-full ${n.enabled ? "bg-brand" : "bg-line"}`} />
<span className={`absolute size-5 rounded-full bg-foreground transition-all ${n.enabled ? "left-8" : "left-2"}`} />
</button>
</form>
</li>
))}
</ul>
)}
</SectionCard>
<SectionCard title="Listen in your podcast app" description="Add this private feed once. New episodes appear on their own. Keep it to yourself.">
<FieldRow action={<CopyButton value={feedUrl} />}>{feedUrl}</FieldRow>
<div className="grid grid-cols-2 gap-2">
<Button asChild variant="secondary" className="rounded-xl">
<a href={`podcast://${feedHost}/feed/${user.feedToken}.xml`}>Apple Podcasts</a>
</Button>
<Button asChild variant="secondary" className="rounded-xl">
<a href={`pktc://subscribe/${feedHost}/feed/${user.feedToken}.xml`}>Pocket Casts</a>
</Button>
</div>
<form action={rotateFeedAction}>
<Button type="submit" variant="link" className="h-11 px-0">
components/ui/section-card.tsx:4:export function SectionCard({ title, description, children, className, id }: { title: string; description?: ReactNode; children: ReactNode; className?: string; id?: string }) {
components/ui/section-card.tsx-5- return (
components/ui/section-card.tsx-6- <section id={id} aria-label={title} className={cn("flex flex-col gap-3 rounded-[18px] border border-line bg-surface p-4", className)}>
components/ui/section-card.tsx-7- <div className="flex flex-col gap-1">
components/ui/section-card.tsx-8- <h2 className="m-0 text-base font-semibold">{title}</h2>
components/ui/section-card.tsx-9- {description ? <p className="m-0 text-[13px] leading-[1.45] text-muted-foreground">{description}</p> : null}
components/ui/section-card.tsx-10- </div>
components/ui/section-card.tsx-11- {children}
components/ui/section-card.tsx-12- </section>
components/ui/section-card.tsx-13- );
components/ui/section-card.tsx-14-}
components/ui/field-row.tsx:5:export function FieldRow({ children, action, className }: { children: ReactNode; action?: ReactNode; className?: string }) {
components/ui/field-row.tsx-6- return (
components/ui/field-row.tsx-7- <div className={cn("flex h-12 items-center gap-2 rounded-xl border border-line bg-ground pr-1.5 pl-3", className)}>
components/ui/field-row.tsx-8- <span className="min-w-0 flex-1 truncate font-mono text-[13px] text-text-2">{children}</span>
components/ui/field-row.tsx-9- {action}
components/ui/field-row.tsx-10- </div>
components/ui/field-row.tsx-11- );
components/ui/field-row.tsx-12-}
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/ref-antennapod/cover-small.png
[image omitted from the upload]
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/podcast-app.tsx
File created successfully at: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/podcast-app.tsx (file state is current in your context — no need to Read it back)
$ cd /home/user/unread/apps/web/src && sed -n 138,146p "app/(app)/sources/page.tsx"; cp /home/user/unread/apps/web/public/podcast-cover.png /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/public/podcast-cover.png; grep -n "play:" -A6 /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/src/videos/unread/narration.ts
</div>
<form action={rotateFeedAction}>
<Button type="submit" variant="link" className="h-11 px-0">
Reset feed URL (the old one stops working)
</Button>
</form>
</SectionCard>
</div>
39: play: [
40- { at: 40, voice: "host", text: "This is Unread for Monday, 26 October.", say: "This is Unread for Monday, the twenty-sixth of October." },
41- { at: 0, voice: "host", text: "4 stories today, from Engineering Leadership, The System Design Newsletter and Blogging Wizard.", say: "Four stories today, from Engineering Leadership, The System Design Newsletter and Blogging Wizard." },
42- { at: 0, voice: "host", text: "Engineering Leadership and The System Design Newsletter both covered this." },
43- { at: 0, text: "Every story once. In my own podcast app." },
44- ],
45- close: [
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/podcast-app.tsx'; s=open(p).read()
s=s.replace('<span style={{ fontSize: 14, color: u.link, padding: "10px 0" }}>Get a new feed address</span>','<span style={{ fontSize: 14, color: u.link, padding: "10px 0" }}>Reset feed URL (the old one stops working)</span>')
open(p,'w').write(s)
p='src/videos/unread/narration.ts'; s=open(p).read()
s=s.replace(''' { at: 0, voice: "host", text: "Engineering Leadership and The System Design Newsletter both covered this." },
{ at: 0, text: "Every story once. In my own podcast app." },
],''',''' { at: 0, voice: "host", text: "Engineering Leadership and The System Design Newsletter both covered this." },
],
feed: [
{ at: 14, text: "Every story once. In my own podcast app." },
{ at: 0, text: "Add the private feed once. New episodes appear on their own." },
],''')
open(p,'w').write(s)
p='src/videos/unread/timeline.ts'; s=open(p).read()
s=s.replace(''' { id: "play", frames: 420, cues: [] },''',''' { id: "play", frames: 420, cues: [] },
{ id: "feed", frames: 330, cues: [] },''')
open(p,'w').write(s)
p='src/videos/unread/scenes.tsx'; s=open(p).read()
# Play: the episode only; the podcast app is its own scene now
s=s.replace(''' const h2 = line("play", 2);
const n3 = line("play", 3);''',''' const h2 = line("play", 2);''')
s=s.replace(''' const hostOut = n3.from - 10;''',''' const hostOut = 1e6;''')
start=s.index(''' <div style={{ position: "absolute", left: LEFT, top: 330, width: 820 }}>
<Said scene="play" n={3}''')
end=s.index(''' <div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
<PhoneZoom z={1 + 0.4 * progress(frame, tapChapter + 26''')
s=s[:start]+s[end:]
s=s.replace('''<PhoneZoom z={1 + 0.4 * progress(frame, tapChapter + 26, tapChapter + 50, ease) - 0.4 * progress(frame, n3.from + 30, n3.from + 56, ease)} ox={360} oy={230}>''','''<PhoneZoom z={1 + 0.4 * progress(frame, tapChapter + 26, tapChapter + 50, ease)} ox={360} oy={230}>''')
s=s.replace('import { line, Said, SaidStack } from "./said";','import { line, Said, SaidStack } from "./said";\nimport { AddPodcastScreen, SourcesFeedScreen, SubscriptionsScreen } from "./podcast-app";')
s=s.replace('''export const Close = () => {''','''export const Feed = () => {
const frame = useCurrentFrame();
const l0 = line("feed", 0);
const l1 = line("feed", 1);
// Unread's Sources page → Copy → AntennaPod: Add podcast by RSS address → paste → Confirm → Subscriptions
const tapCopy = 44;
const toPodcasts = l0.from + l0.frames - 6;
const tapRss = toPodcasts + 26;
const pasteAt = Math.max(l1.from + 4, tapRss + 26);
const tapConfirm = pasteAt + 30;
const toSubs = tapConfirm + 10;
const swap = progress(frame, toPodcasts, toPodcasts + 16, ease);
const subs = progress(frame, toSubs, toSubs + 14, ease);
const enter = progress(frame, 0, 30);
return (
<AbsoluteFill>
<Ground glow={0.45} />
<div style={{ position: "absolute", left: LEFT, top: 300, width: TEXT_W }}>
<Eyebrow at={4} style={{ marginBottom: 26 }}>Private feed · AntennaPod</Eyebrow>
<SaidStack
scene="feed"
size={62}
lines={[
{ n: 0, text: "Every story once. *In my own podcast app.*" },
{ n: 1, text: "Add the private feed once. *New episodes appear on their own.*" },
]}
/>
</div>
<div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
<Phone scale={1.1}>
<PhoneScreen
taps={[
{ f: 18, x: 300, y: 520 },
{ f: tapCopy, at: "feed-copy", tap: true },
{ f: tapCopy + 24, at: "feed-copy", dy: 120 },
{ f: tapRss - 16, at: "rss", fx: 0.3, dy: 50 },
{ f: tapRss, at: "rss", fx: 0.3, tap: true },
{ f: pasteAt - 8, at: "rss-field", fx: 0.4 },
{ f: pasteAt - 2, at: "rss-field", fx: 0.4, tap: true },
{ f: tapConfirm, at: "confirm", tap: true },
{ f: tapConfirm + 20, at: "confirm", dy: 120 },
]}
show={[
[14, tapCopy + 22],
[tapRss - 20, tapConfirm + 16],
]}
>
<SourcesFeedScreen copied={frame >= tapCopy} />
<div style={{ position: "absolute", inset: 0, transform: `translateX(${(1 - swap) * 100}%)`, boxShadow: swap < 1 ? "-20px 0 40px rgba(0,0,0,0.4)" : undefined }}>
<AddPodcastScreen dialog={progress(frame, tapRss + 4, tapRss + 14)} pasted={frame >= pasteAt} />
</div>
{subs > 0 && (
<div style={{ position: "absolute", inset: 0, opacity: subs }}>
<SubscriptionsScreen tile={pop(frame, toSubs + 10, 160, 16)} badge={pop(frame, toSubs + 24, 220, 14)} />
</div>
)}
</PhoneScreen>
</Phone>
</div>
<Sfx name="pop" at={toSubs + 10} volume={0.3} />
</AbsoluteFill>
);
};
export const Close = () => {''')
s=s.replace(''' play: Play,
close: Close,''',''' play: Play,
feed: Feed,
close: Close,''')
open(p,'w').write(s)
EOF
grep -n "n3" src/videos/unread/scenes.tsx; npx tsc && npm run voice 2>&1 | grep -E "generated|play|feed"
unread/play: 3 line(s), ends 480/420, scene grows by 92 frames
generated (40 chars, narrator): Every story once. In my own podcast app.
generated (60 chars, narrator): Add the private feed once. New episodes appear on their own.
unread/feed: 2 line(s), ends 227/330
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && sed -i 's/{ id: "feed", frames: 330/{ id: "feed", frames: 300/' src/videos/unread/timeline.ts && npm run voice 2>&1 | grep feed && ./stills.sh unread-feed:50,150,180,270 unread-play:470 unread-inbox:200 2>&1 | tail -1 && cd stills && ffmpeg -v error -y -i unread-feed-50.png -i unread-feed-150.png -i unread-feed-180.png -i unread-feed-270.png -i unread-play-470.png -i unread-inbox-200.png -filter_complex "xstack=inputs=6:layout=0_0|w0_0|0_h0|w0_h0|0_h0+h0|w0_h0+h0" sheet7.png
unread/feed: 2 line(s), ends 227/300
done
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/sheet7.png
[image omitted from the upload]
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/scenes.tsx'; s=open(p).read()
old=''' <div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
<Phone scale={1.1}>
<PhoneScreen
taps={[
{ f: 18, x: 300, y: 520 },'''
new=''' <div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
{/* Push in on the dialog while the address is pasted, then on Unread's new tile */}
<PhoneZoom z={1 + 0.32 * (progress(frame, tapRss + 6, tapRss + 26, ease) - progress(frame, toSubs - 4, toSubs + 10, ease))} ox={250} oy={330}>
<PhoneZoom z={1 + 0.7 * progress(frame, toSubs + 14, toSubs + 40, ease)} ox={70} oy={150}>
<Phone scale={1.1}>
<PhoneScreen
taps={[
{ f: 18, x: 300, y: 520 },'''
assert old in s; s=s.replace(old,new)
old=''' </PhoneScreen>
</Phone>
</div>
<Sfx name="pop" at={toSubs + 10} volume={0.3} />'''
new=''' </PhoneScreen>
</Phone>
</PhoneZoom>
</PhoneZoom>
</div>
<Sfx name="pop" at={toSubs + 10} volume={0.3} />'''
assert old in s; s=s.replace(old,new)
open(p,'w').write(s)
EOF
npx tsc && ./stills.sh unread-feed:175,280 2>&1 | tail -1 && cd stills && ffmpeg -v error -y -i unread-feed-175.png -i unread-feed-280.png -filter_complex hstack=2 sheet8.png
done
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/sheet8.png
[image omitted from the upload]
Both push-ins frame well. Rendering the new version (the previous one is kept as unread-v2.mp4):
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && cp out/unread.mp4 out/unread-v2.mp4 && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log
Command running in background with ID: bsv17o5na. Output is being written to: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/bsv17o5na.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains /home/user/unread; directory changes made by the backgrounded command do not apply to subsequent commands.
While it renders, one thing I noticed in your inbox screenshots. Unread names a newsletter after the sender name on the email, which in your inbox is "Neo Kim" and "Gregor Ojstersek from Engineering Leadership". So the real app would probably label that chapter "Gregor Ojstersek from Engineering Leadership · also in Neo Kim", not the cleaner names the video shows. I can fix that in the app after the render; there's more detail in my final report.
No wait, ignore that podcast app link posting part, ignore 8t
Okay, I'll drop the podcast app scene. I'm stopping the render that includes it and restoring the previous ending, where the narrator says "Every story once. In my own podcast app." over the private-feed card. The new Gmail inbox stays.
$ pkill -f "remotion render" ; pkill -f render-all.sh; sleep 1; pgrep -fa "remotion" | head -3; cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/narration.ts'; s=open(p).read()
old=''' { at: 0, voice: "host", text: "Engineering Leadership and The System Design Newsletter both covered this." },
],
feed: [
{ at: 14, text: "Every story once. In my own podcast app." },
{ at: 0, text: "Add the private feed once. New episodes appear on their own." },
],'''
new=''' { at: 0, voice: "host", text: "Engineering Leadership and The System Design Newsletter both covered this." },
{ at: 0, text: "Every story once. In my own podcast app." },
],'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
p='src/videos/unread/timeline.ts'; s=open(p).read()
s=s.replace(''' { id: "feed", frames: 300, cues: [] },\n''',''); open(p,'w').write(s)
p='src/videos/unread/scenes.tsx'; s=open(p).read()
s=s.replace('import { line, Said, SaidStack } from "./said";\nimport { AddPodcastScreen, SourcesFeedScreen, SubscriptionsScreen } from "./podcast-app";','import { line, Said, SaidStack } from "./said";')
a=s.index('export const Feed = () => {'); b=s.index('export const Close = () => {')
s=s[:a]+s[b:]
s=s.replace(''' play: Play,
feed: Feed,
close: Close,''',''' play: Play,
close: Close,''')
s=s.replace(''' const h2 = line("play", 2);
const tapPlay''',''' const h2 = line("play", 2);
const n3 = line("play", 3);
const tapPlay''')
s=s.replace(''' const hostOut = 1e6;''',''' const hostOut = n3.from - 10;''')
anchor=''' <div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
<PhoneZoom z={1 + 0.4 * progress(frame, tapChapter + 26, tapChapter + 50, ease)} ox={360} oy={230}>'''
assert anchor in s
s=s.replace(anchor,''' <div style={{ position: "absolute", left: LEFT, top: 330, width: 820 }}>
<Said scene="play" n={3} size={68} text="Every story once. *In my own podcast app.*" />
<div style={{ marginTop: 50, display: "inline-flex", flexDirection: "column", gap: 10, padding: "22px 28px", borderRadius: 22, background: "#121318", border: "1px solid rgba(255,255,255,0.08)", fontFamily, ...useInOut(n3.from + Math.round(n3.frames * 0.45)) }}>
<span style={{ fontSize: 20, color: u.muted }}>Private podcast feed</span>
<span style={{ fontFamily: monoFamily, fontSize: 24, color: u.text }}>getunread.site/feed/••••••••.xml</span>
<span style={{ fontSize: 20, color: u.brand }}>Works in your podcast app</span>
</div>
</div>
<div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
<PhoneZoom z={1 + 0.4 * progress(frame, tapChapter + 26, tapChapter + 50, ease) - 0.4 * progress(frame, n3.from + 30, n3.from + 56, ease)} ox={360} oy={230}>''')
open(p,'w').write(s)
EOF
rm -f src/videos/unread/podcast-app.tsx public/podcast-cover.png && npx tsc && npm run voice 2>&1 | grep -E "generated|play" && cp out/unread-v2.mp4 out/unread.mp4 && ls out
Exit code 144
$ ps aux | grep -E "remotion|render-all" | grep -v grep | head -5; cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && grep -c feed src/videos/unread/timeline.ts
1
Shell cwd was reset to /home/user/unread
The render is stopped. I'm applying the revert now:
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && tail -3 render.log; python3 - <<'EOF'
p='src/videos/unread/narration.ts'; s=open(p).read()
old=''' { at: 0, voice: "host", text: "Engineering Leadership and The System Design Newsletter both covered this." },
],
feed: [
{ at: 14, text: "Every story once. In my own podcast app." },
{ at: 0, text: "Add the private feed once. New episodes appear on their own." },
],'''
new=''' { at: 0, voice: "host", text: "Engineering Leadership and The System Design Newsletter both covered this." },
{ at: 0, text: "Every story once. In my own podcast app." },
],'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
p='src/videos/unread/timeline.ts'; s=open(p).read()
assert ' { id: "feed", frames: 300, cues: [] },\n' in s
s=s.replace(''' { id: "feed", frames: 300, cues: [] },\n''',''); open(p,'w').write(s)
p='src/videos/unread/scenes.tsx'; s=open(p).read()
s=s.replace('import { line, Said, SaidStack } from "./said";\nimport { AddPodcastScreen, SourcesFeedScreen, SubscriptionsScreen } from "./podcast-app";','import { line, Said, SaidStack } from "./said";')
a=s.index('export const Feed = () => {'); b=s.index('export const Close = () => {')
s=s[:a]+s[b:]
s=s.replace(''' play: Play,
feed: Feed,
close: Close,''',''' play: Play,
close: Close,''')
assert ''' const h2 = line("play", 2);
const tapPlay''' in s
s=s.replace(''' const h2 = line("play", 2);
const tapPlay''',''' const h2 = line("play", 2);
const n3 = line("play", 3);
const tapPlay''')
s=s.replace(''' const hostOut = 1e6;''',''' const hostOut = n3.from - 10;''')
anchor=''' <div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
<PhoneZoom z={1 + 0.4 * progress(frame, tapChapter + 26, tapChapter + 50, ease)} ox={360} oy={230}>'''
assert anchor in s
s=s.replace(anchor,''' <div style={{ position: "absolute", left: LEFT, top: 330, width: 820 }}>
<Said scene="play" n={3} size={68} text="Every story once. *In my own podcast app.*" />
<div style={{ marginTop: 50, display: "inline-flex", flexDirection: "column", gap: 10, padding: "22px 28px", borderRadius: 22, background: "#121318", border: "1px solid rgba(255,255,255,0.08)", fontFamily, ...useInOut(n3.from + Math.round(n3.frames * 0.45)) }}>
<span style={{ fontSize: 20, color: u.muted }}>Private podcast feed</span>
<span style={{ fontFamily: monoFamily, fontSize: 24, color: u.text }}>getunread.site/feed/••••••••.xml</span>
<span style={{ fontSize: 20, color: u.brand }}>Works in your podcast app</span>
</div>
</div>
<div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
<PhoneZoom z={1 + 0.4 * progress(frame, tapChapter + 26, tapChapter + 50, ease) - 0.4 * progress(frame, n3.from + 30, n3.from + 56, ease)} ox={360} oy={230}>''')
open(p,'w').write(s)
print("edited")
EOF
rm -f src/videos/unread/podcast-app.tsx public/podcast-cover.png && npx tsc && npm run voice 2>&1 | grep -E "generated|play" ; ls out
Rendered 230/3462, time remaining: 5m 17s
Rendered 231/3462, time remaining: 5m 19s
exit 1
edited
unread/play: 4 line(s), ends 587/420, scene grows by 199 frames
unread-thumbnail-1280.jpg
unread-thumbnail.png
unread-v1.mp4
unread-v2.mp4
unread.mp4
unread.render.log
unread.vtt
Shell cwd was reset to /home/user/unread
The revert compiled, and the narration came from cache, so no new ElevenLabs characters were used. Checking one frame from the restored ending, then rendering:
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && rm -f out/unread.render.log && ./stills.sh unread-play:560 2>&1 | tail -1
done
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/unread-play-560.png
[image omitted from the upload]
The restored ending looks the same as before. Rendering the version with the Gmail inbox now:
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log
Command running in background with ID: b3etom9g3. Output is being written to: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/b3etom9g3.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains /home/user/unread; directory changes made by the backgrounded command do not apply to subsequent commands.
I've dropped the podcast app scene, and the code for it is deleted. The video goes back to ending with "Every story once. In my own podcast app." and the private-feed card. The one new change is the Gmail inbox in the opening and the closing scene.
The final render is running, about 12 minutes. When it's done, I'll check frames from the MP4 and send it over. The previous version is kept as out/unread-v2.mp4.
Yeah, instead add something like "so you can listen to them on the go"
I'll add it to the title reveal, so it reads "Unread. Your newsletters, as a podcast. So you can listen to them on the go." That links the promise back to the commute. Stopping the current render first:
$ for p in $(ps -eo pid,args | grep -E "[r]ender-all.sh|[r]emotion render|[r]emotion/cli" | awk '{print $1}'); do [ "$p" != "$$" ] && kill $p 2>/dev/null; done; sleep 2; ps -eo pid,args | grep -E "[r]emotion" | head -3; echo stopped
stopped
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/narration.ts'; s=open(p).read()
old=' reveal: [{ at: 10, text: "Unread. Your newsletters, as a podcast." }],'
new=''' reveal: [
{ at: 10, text: "Unread. Your newsletters, as a podcast." },
{ at: 0, text: "So you can listen to them on the go." },
],'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
p='src/videos/unread/scenes.tsx'; s=open(p).read()
old=''' <Said scene="reveal" n={0} size={64} align="center" hide={1} text="Unread. Your newsletters, *as a podcast.*" style={{ color: u.text2 }} />
</div>'''
new=''' <Said scene="reveal" n={0} size={64} align="center" hide={1} text="Unread. Your newsletters, *as a podcast.*" style={{ color: u.text2 }} />
<Said scene="reveal" n={1} size={50} align="center" text="So you can listen to them *on the go.*" style={{ color: u.muted, marginTop: 22 }} />
</div>'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
EOF
npx tsc && npm run voice 2>&1 | grep -E "generated|reveal" && ./stills.sh unread-reveal:200 2>&1 | tail -1
generated (39 chars, narrator): Unread. Your newsletters, as a podcast.
generated (36 chars, narrator): So you can listen to them on the go.
unread/reveal: 2 line(s), ends 157/140, scene grows by 49 frames
done
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/unread-reveal-200.png
File does not exist. Note: your current working directory is /home/user/unread.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/narration.ts'; s=open(p).read()
old=''' reveal: [
{ at: 10, text: "Unread. Your newsletters, as a podcast." },
{ at: 0, text: "So you can listen to them on the go." },
],'''
new=' reveal: [{ at: 10, text: "Unread. Your newsletters, as a podcast." }],'
assert old in s; s=s.replace(old,new)
old=''' { at: 0, text: "Every story once. In my own podcast app." },
],'''
new=''' { at: 0, text: "Every story once. In my own podcast app." },
{ at: 0, text: "So I can listen to them on the go." },
],'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
p='src/videos/unread/scenes.tsx'; s=open(p).read()
s=s.replace('''
<Said scene="reveal" n={1} size={50} align="center" text="So you can listen to them *on the go.*" style={{ color: u.muted, marginTop: 22 }} />''','')
old=''' <Said scene="play" n={3} size={68} text="Every story once. *In my own podcast app.*" />'''
new=''' <SaidStack
scene="play"
size={64}
lines={[
{ n: 3, text: "Every story once. *In my own podcast app.*" },
{ n: 4, text: "So I can listen to them *on the go.*" },
]}
/>'''
assert old in s; s=s.replace(old,new)
open(p,'w').write(s)
EOF
npx tsc && npm run voice 2>&1 | grep -E "generated|reveal|play"
unread/reveal: 1 line(s), ends 105/140
generated (40 chars, narrator): Every story once. In my own podcast app.
generated (34 chars, narrator): So I can listen to them on the go.
unread/play: 5 line(s), ends 636/420, scene grows by 248 frames
Shell cwd was reset to /home/user/unread
Understood: no "on the go" line anywhere. I'm removing it, keeping the Gmail inbox you asked for, and rendering that.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/narration.ts'; s=open(p).read()
old=''' { at: 0, text: "Every story once. In my own podcast app." },
{ at: 0, text: "So I can listen to them on the go." },
],'''
new=''' { at: 0, text: "Every story once. In my own podcast app." },
],'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
p='src/videos/unread/scenes.tsx'; s=open(p).read()
old=''' <SaidStack
scene="play"
size={64}
lines={[
{ n: 3, text: "Every story once. *In my own podcast app.*" },
{ n: 4, text: "So I can listen to them *on the go.*" },
]}
/>'''
new=''' <Said scene="play" n={3} size={68} text="Every story once. *In my own podcast app.*" />'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
EOF
npx tsc && npm run voice 2>&1 | grep -E "generated|reveal|play"; ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log
Command running in background with ID: bo0offkgc. Output is being written to: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/bo0offkgc.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains /home/user/unread; directory changes made by the backgrounded command do not apply to subsequent commands.
I've removed the "on the go" line, so the script is back exactly as it was. The only change from the version you've seen is the Gmail-style inbox at the start and end. It's rendering now, about 12 minutes, and I'll send it when it's done.
If you meant to keep the previous video with no changes at all, it's still there as out/unread-v2.mp4.
<task-notification>
<task-id>bo0offkgc</task-id>
<tool-use-id>toolu_012GwZcW8jWUns49YTrjr5V8</tool-use-id>
<output-file>/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/bo0offkgc.output</output-file>
<status>completed</status>
<summary>Background command "cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/narration.ts'; s=open(p).read()
old=''' { at: 0, text: "Every story once. In my own podcast app." },
{ at: 0, text: "So I can listen to them on the go." },
],'''
new=''' { at: 0, text: "Every story once. In my own podcast app." },
],'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
p='src/videos/unread/scenes.tsx'; s=open(p).read()
old=''' <SaidStack
scene="play"
size={64}
lines={[
{ n: 3, text: "Every story once. In my own podcast app." },
{ n: 4, text: "So I can listen to them on the go." },
]}
/>'''
new=''' <Said scene="play" n={3} size={68} text="Every story once. In my own podcast app." />'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
EOF
npx tsc && npm run voice 2>&1 | grep -E "generated|reveal|play"; ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log" completed (exit code 0)</summary>
</task-notification>
$ cat /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/bo0offkgc.output; cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && ffprobe -v error -show_entries format=duration -of csv=p=0 out/unread.mp4; node scripts/audio-report.mjs out/unread.mp4 2>&1 | head -2; for t in 2 6 11 104; do ffmpeg -v error -y -ss $t -i out/unread.mp4 -frames:v 1 -vf scale=640:-1 check/h_$t.jpg; done; ffmpeg -v error -y -i check/h_2.jpg -i check/h_6.jpg -i check/h_11.jpg -i check/h_104.jpg -filter_complex "xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0" check/sheet3.jpg
unread/reveal: 1 line(s), ends 105/140
unread/play: 4 line(s), ends 587/420, scene grows by 199 frames
rendering Unread -> out/unread.mp4
out/unread.mp4: -19.30 LUFS -> -16 LUFS
out/unread.vtt: 33 cues
exit 0
[exited with code 0]
109.700000
out/unread.mp4: 109.6 s, peak -1.3 dBFS, RMS -19.3 dBFS
spectrum: <250 Hz 22%, 250-1k 59%, 1-4k 13%, >4k 5%
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/check/sheet3.jpg
[image omitted from the upload]
{"files":["/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.mp4","/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad...
2 files delivered to user.
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.mp4 → file_uuid: c357bdf0-248f-48df-aeb4-c3b73bf4eaef
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.vtt → file_uuid: 2501f7ae-ddbb-4ea2-9d6a-75cd715268b7
The updated video is attached: 1:50, loudness at −16 LUFS. "This is my inbox" now opens on a phone showing your Gmail inbox, scrolling through your real threads: Neo Kim, Gregor Ojstersek from Engineering Leadership, and Simon Hoiberg, with the 99+ badge and the Compose button. The closing line ("My inbox still says 124 unread") comes back to the same inbox. Everything else is the same as before, and I checked frames from the finished file.
The video is at /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.mp4. The earlier versions are next to it as unread-v1.mp4 and unread-v2.mp4.
Two things to decide on:
- Real and made-up content are now side by side. The inbox shows your real subject lines, but the next scenes still use my made-up "design doc" stories under the same newsletter names. Your inbox does have a natural real overlap: on Oct 20, Neo Kim sent "64 AI Engineering Concepts, Clearly Explained" and Gregor sent "How to Use AI to Become a More E…". I didn't switch to that pair because stamping "SAME STORY" on two real articles would be a claim about their content that I can't check. If you know they really cover the same ground, tell me and I'll swap them in. That would cost a re-render, about 12 minutes.
-
The app may label these newsletters differently from the video. Unread names a newsletter after the email's sender name, which in your inbox is "Neo Kim" and "Gregor Ojstersek from Engineering Leadership". The real app would show those names, not the "The System Design Newsletter" and "Engineering Leadership" in the video. A small fix in
packages/core/src/inbound/sender.tswould turn Substack's "Name from Publication" into just the publication. If you want it, I can make that change on the branch after the video.
next is the article, remember it is heavily weighted n most judged by the judges, so it has be relevant n cotains as much necessary details as possible
submission template:
"This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady
What I Built
<!-- What does it do, and what effort does it save? Who gets to be a little lazier because of it? -->
Demo
<!-- Share a deployed link or a video demo. -->
Code
<!-- Show us the code! You can embed a GitHub repo directly into your post. -->
How I Built It
<!-- Which open-source AI did you use (open-weight models, agent harnesses, frameworks, local inference), and how is your project built around it? -->
Why Does Open Innovation Matter?
<!-- Why does open innovation matter for what you built? What did it make possible that a closed API wouldn't? -->
My Agent Session
<!-- Optional, but judges love it. Save your session with DevRelay and embed it with the agent_session tag (see the challenge page), or link to it. -->
The Lazy Payoff
<!-- Optional, but this is where you can make your post stand out. How much time or effort does it actually save? Did you (or someone else) use it this week? If not, remove this section. -->
Prize Categories
<!-- Which partner categories are you entering? List every one that applies, or remove this section. -->
<!-- Team Submissions: Please pick one member to publish the submission and credit teammates by listing their DEV usernames directly in the body of the post. -->
<!-- Thanks for participating! -->"
I'll start with the rest of the challenge page (the full list of prize categories and any submission rules), then pull exact details from the codebase so every claim in the article is accurate.
{"url":"https://dev.to/challenges/hacktoberfest-week3-2026-10-19","prompt":"List EVERY prize/partner category name with its exact requirement text (all 20+), plus any rules about agent sessions (DevRe...
This excerpt contains only part of the prize list, and the 20+ categories you asked for aren't all here. Two sections are cut off: the Qualcomm section's heading appears before this excerpt begins, and the Backboard section is truncated.
**Prize categories visible in this excerpt:**
1. **Best Use of Tinker**: $200 USD + exclusive winner badge. Requirement: "Use Thinking Machines' Tinker to fine-tune a model for a specific task" (the full text also asks for a clear improvement over a baseline).
2. **Best Use of Arduino**: $200 USD + exclusive winner badge. Requirement: "Build with an Arduino UNO Q: run a model on the board, build a physical agent that senses and acts" (the full text also lists optimizing a model with Qualcomm AI Hub).
3. **Best Use of DigitalOcean**: $200 USD + exclusive winner badge. Requirement: "Build or deploy your project on DigitalOcean: host your app or agent, run an open-weight model on a GPU Droplet" (the full text also mentions the Gradient AI Platform).
4. **Best Use of Gemma**: $200 USD + exclusive winner badge. Requirement: "Use Gemma, Google's open-weight model, in building your project: run it locally, fine-tune it, or serve it" (the full text also mentions Google Cloud).
5. **Partner categories**: $100 for each winner. This heading introduces the partner sections below it.
6. **Best Use of Auth0 for AI Agents**: $100 USD + exclusive winner badge. Requirement: "Use Auth0 for AI Agents: sign users in to an agent, call tools on a user's behalf with Token Vault" (the full text lists additional options).
7. **Backboard**: The category name and requirement are cut off in this excerpt.
**Not found in this excerpt:**
- DevRelay or agent session rules
- agent_session tag syntax
- Required tags
- Judging criteria wording
- Submission or deadline rules
These may appear in the earlier or later portions of the page.
[WebFetch note: this page's text is 298058 characters long and the answer above covers only characters 100000 to 200000; the final 98058 were not read — to read on, call WebFetch again with the same url and offset: 200000.]
$ ls docs/decisions; git log --oneline | wc -l; git log --reverse --format="%ad %s" --date=short | head -3; git log -1 --format="%ad" --date=short; find . -path ./node_modules -prune -o \( -name "*.test.ts" -o -name "*.test.tsx" \) -print | grep -v node_modules | wc -l; grep -rhoE "^\s*(it|test)\(" --include=*.test.ts --include=*.test.tsx apps packages 2>/dev/null | wc -l
0001-monorepo-and-web-stack.md
0002-auth0-sign-in.md
0003-database-and-ids.md
0004-inbound-email-cloudflare.md
0005-model-and-embeddings.md
0006-voices.md
0007-payments-and-promos.md
0008-workflow-and-hosting.md
0009-news-links-storage-mail.md
README.md
21
2026-10-23 first commit
2026-10-23 added prd
2026-10-23 U0: monorepo, Next.js app, Drizzle schema, Auth0 sign-in, design tokens
2026-10-25
16
60
{"url":"https://dev.to/challenges/hacktoberfest-week3-2026-10-19","prompt":"List EVERY prize/partner category name with its requirement text, plus any rules about agent sessions (DevRelay, agent_sessi...
Several categories and rules fall before or after this excerpt, so they aren't covered here. The excerpt has no Render, TabPFN, Tinker, Arduino, DigitalOcean, Gemma, or Auth0 requirement text, and it has no agent_session liquid tag syntax.
**Partner categories in this excerpt** (each carries a $100 USD prize and an exclusive winner badge)
| Category | Requirement (quoted, ≤125 characters) |
|---|---|
| Best Use of Backboard | "Build with R-CLI, Backboard's open-source terminal coding agent," |
| Best Use of ElevenLabs | "Give an open-source agent a voice, transcribe audio for a local model, or generate narration for your demo." |
| Best Use of Browserbase | "Give your agent a real browser: drive a live site end to end using Stagehand's SDK," |
| Best Use of Entire | "Share the agent sessions behind your project in the write-up, or search past agent work to explain why code exists." |
| Best Use of GitHub Copilot | "Build with Copilot's coding agent, the Copilot CLI, or the new Copilot app," |
| Best Use of Mastra | "Orchestrate an agent over open models, add memory and tools to a chat agent, or build a workflow that triages GitHub issues." |
| Best Use of MongoDB Atlas | "Use Atlas as your data layer: Atlas Vector Search for retrieval, long-term memory for an agent," |
| Best Use of Ultramarine | "Put Ultramarine, Paper Instruments' model, to work on something real: research, analysis, drafting," and "Call it with the OpenAI or Anthropic SDKs, and say what your run actually cost." |
| Best Use of Sentry Agent Tracing | "Show your agent's work: how it's set up, how it performs (latency, tokens, cost)" and "Include traces or screenshots in your write-up." |
| Best Use of SerpApi | "Give an agent live web search, ground a RAG app in fresh results, or track news for an open-source project." |
| Best Use of a .tech Domain | "Put your project live on a .tech domain you claimed through Hacktoberfest, and link to it in your write-up." |
| Best Use of Temporal | "Make your agent durable. Wrap it in a Temporal workflow so it survives failures," |
| Best Use of Tiger Data | "Store embeddings with pgvector, run hybrid keyword and vector search for an agent," |
**Agent sessions**
- DevRelay is optional. The page says: "Save your agent session with DevRelay and embed it in your post, or link to it." It adds: "It's optional, but it helps the judges understand your process."
- The Sentry category asks for traces or screenshots in the write-up.
**Required tags**
- "The template adds the required tags:" #devchallenge and #hf26challenge.
**Deadline**
- Week 3 rules: "Contest entry period ends October 25, 2026 at 11:59 PM PDT."
- FAQ: "all submissions and their respective repositories must be started and completed within the challenge window."
{"url":"https://dev.to/challenges/hacktoberfest-week3-2026-10-19","prompt":"Quote exactly: (1) the full list of featured and partner prize category names in order with requirement text for each (Rende...
The page content is cut off partway through the prize categories, so I can only answer part of your request.
**(1) Prize categories**
Only two categories are visible, both under "Featured categories" ($200 for each winner):
- **Best Use of Render**: "Use Render as your project's AI runtime, to host an agent's front end, or to run Hermes or OpenClaw." Prize: $200 USD + exclusive winner badge.
- **Best Use of TabPFN**: "to forecast, predict, classify, or spot anomalies from historical data like a CSV." Prior Labs "favors projects that use TabPFN inside a wider prediction agent." Prize: $200 USD + exclusive winner badge.
Thinking Machines (Tinker) appears only as a logo in the credits section, with no category text. Qualcomm, ElevenLabs, SerpApi, Temporal, Tiger Data, Sentry, Backboard, and Auth0 are named in your request, but their category text is not in the visible content. The page says there are 20 partner categories in total, and the full list is on the HF26 DEV Challenge Hub.
**(2) "My Agent Session" / DevRelay instructions**
- Instructions: "Save your agent session with DevRelay and embed it in your post, or link to it. It's optional, but it helps the judges understand your process."
- Embed syntax: not provided. The submission template only says to embed it "with the agent_session tag (see the challenge page)," and the challenge page text is truncated.
**(3) Post tags**
The submission template's front matter uses: `devchallenge, hf26challenge`. The page doesn't explicitly label these as required.
**(4) Judging criteria**
- Writing Quality (weighted most heavily)
- Relevance to the Prompt and Theme
- Creativity
- Technical Execution
- Use of Partner Technology (optional)
[WebFetch note: this page's text is 298058 characters long and the answer above covers only characters 0 to 100000; the final 198058 were not read — to read on, call WebFetch again with the same url and offset: 100000.]
$ for f in docs/decisions/000{3,5,6,8}*.md; do echo "=== $f"; sed -n 1,60p $f | cut -c1-400; done
=== docs/decisions/0003-database-and-ids.md
# 0003 · Database, schema and ids
**Status:** Accepted (U0, 2026-10-23; revised for PRD v2 and the Kokoro/Bachs/promo update on 2026-10-24)
## Decision
- **Postgres on Tiger Data (Tiger Cloud)**, with two extensions:
- **pgvector** for clustering stories across newsletters and for "already heard". Migration `0000_enable-pgvector.sql` runs `CREATE EXTENSION IF NOT EXISTS vector`.
- **TimescaleDB** for `listening_events`. Migration `0002_listening-events-hypertable.sql` calls `create_hypertable('listening_events', by_range('occurred_at', INTERVAL '7 days'), if_not_exists => TRUE)`, but only when the extension is available, so local Postgres without it keeps an ordinary table.
- Connect with the service URL and `sslmode=require`.
- **Drizzle ORM 0.45 + drizzle-kit 0.31**, using the `postgres` (postgres-js) driver and `casing: "snake_case"`. Migrations are checked in under `packages/db/migrations` and applied with `pnpm db:migrate`.
- **ULIDs as `text` primary keys.** Join tables (`chapter_sources`, `heard_items`, `usage_monthly`) use composite keys.
- **Embeddings are `vector(768)`** on `items`: EmbeddingGemma's full output size, with a cosine HNSW index. It is trained Matryoshka-style, so it can be truncated to 512/256/128 later (a migration plus a re-embed).
## Tables (PRD v2 §7)
`users` (plan, cadence, weekly day, news opt-in/mode/edition, voices, feed token, inbound alias), `verified_senders`, `newsletters`, `inbound_messages`, `items`, `user_topics`, `episodes` (voice provider, characters, voice model, cost), `chapters`, `chapter_sources` → `items`, `heard_items` (with `cluster_key`), `listening_events` (hypertable), `usage_monthly`, `subscriptions`, `billing_events`,
## Additions to the PRD
- `users.onboarded_at/created_at/updated_at`; `newsletters.last_issue_at` (for "last issue" in the Sources list).
- `inbound_messages`: `from_email`, `reject_reason`, and `confirmation_code`/`confirmation_url`, so the Gmail forwarding code can be shown in the app. `message_id` is unique, which makes webhook retries idempotent. `user_id` can be null for mail to an unknown alias (logged, then dropped).
- `items`: `inbound_message_id`, `limited` (paywalled link), `published_at`.
- `step_runs` with a unique `(episode_id, step, attempt)`, so activities can be idempotent.
- **Voices:** the PRD's single `voice_id` is split into `kokoro_voice_id` (default `af_heart`) and `elevenlabs_voice_id`. When a promo trial ends and the user drops from ElevenLabs back to Kokoro, both choices are kept and nothing is lost (PRD §8.3). `episodes.voice_provider` records which provider made each episode.
- **Plans:** `users.plan` is the *paid* plan, written only by Bachs webhooks. Features read the **effective plan** (paid Plus OR an unexpired promo trial OR Free), computed in one function (U5).
- **Billing:** `subscriptions.status` is our own enum (`trialing/active/past_due/cancelled/expired`); the webhook maps Bachs statuses onto it. `billing_events.id` is the Bachs event id, so a replayed webhook changes nothing.
- **Promo codes:** codes are unique case-insensitively, and a check constraint keeps `redeemed_count ≤ max_redemptions`, so concurrent redemptions can't over-redeem (the increment happens in the same transaction as the insert). Each user can redeem a code once.
## Constraints and defaults
- One episode per user per local date; `episode_minutes ∈ {5,10,20}`; `weekly_day` 0–6; `delivery_time` matches `HH:mm`.
- New users start on `free`, `weekly` (Monday), 10 minutes, news off, no voice picked yet.
- Case-insensitive unique keys on topics, verified senders and newsletter senders.
- Deleting a user cascades to every table with a foreign key. `listening_events` has no foreign keys (it's a hypertable), so account deletion removes its rows explicitly. Objects in storage are deleted separately.
## Pool mode
`createDb({ url, prepare: false })` is available if we connect through a transaction-mode pooler.
=== docs/decisions/0005-model-and-embeddings.md
# 0005 · Gemma for thinking, EmbeddingGemma for clustering
**Status:** Accepted (2026-10-24)
## Model: Gemma 4 31B via the Gemini API, OpenRouter fallback
- **Model id:** `gemma-4-31b-it`. Google's "Run Gemma with the Gemini API" page lists `gemma-4-31b-it` and `gemma-4-26b-a4b-it`. OpenRouter lists `google/gemma-4-31b-it` (checked against `/api/v1/models` on 2026-10-24).
- **Endpoint:** Google documents Gemma on the **native** `generateContent` API, with system instructions supported. Its OpenAI-compatible page (`/v1beta/openai/chat/completions`) doesn't list Gemma explicitly. So `GEMINI_API_STYLE=native` is the default, and `openai` switches to the compatibility layer once it's confirmed working with a real key.
- **Fallback:** OpenRouter Chat Completions with the same model family. On 429, 5xx or a timeout, the primary gets 3 attempts with exponential backoff and jitter, then the call goes to the fallback (`withFallback`).
- **Structured output:** Gemma on the Gemini API has no documented JSON mode, so prompts state the JSON contract. `generateStructured` pulls the first JSON value out of the reply, validates it with Zod plus semantic checks (budget, known source ids), and feeds errors back to the model, for up to 3 attempts.
- **What Gemma decides (P4):** borderline same-story pairs, ranking, and every chapter script, plus the repair of lines that fail the fact check. The intro and outro are templates, so they can't hallucinate.
## Embeddings: EmbeddingGemma on the worker's CPU
- The Gemini API doesn't serve EmbeddingGemma, so it runs in the worker through **transformers.js 3.8.1** (`onnx-community/embeddinggemma-300m-ONNX`, q8), giving 768-dimension vectors.
- **Measured in the dev container:** about 4.6 s to load the model, then about 1.3 s per document in batches. Same story from different outlets scores about 0.93 cosine similarity; unrelated stories about 0.30.
- **Thresholds:** 0.83 or above is the same s
… [3233 characters trimmed for the upload] …
venLabs' own `previewUrl` clips. The default is George (`JBFqnCBsd6RMkjVDRZzb`).
- **"Upgrade to Studio voices" (Free):** `GET /voice-preview/<voiceId>` generates the same intro once per voice with the Studio model, caches it in storage (`audio_assets` `preview-<id>`), and is limited to signed-in users, known voices, and 10 new previews an hour per user.
- **SFX:** `pnpm generate-assets` creates `sfx-intro` (2.5 s), `sfx-transition` (1 s) and `sfx-outro` (2.5 s) with `textToSoundEffects.convert`, once. Assembly picks them up for every plan.
=== docs/decisions/0008-workflow-and-hosting.md
# 0008 · Temporal workflow, polling fallback, Render hosting
**Status:** Accepted (2026-10-24)
## Episode workflow
- **One request row per intent** (`episode_requests`: scheduled or manual). A unique index makes scheduled runs idempotent per user per local date.
- **Temporal (TypeScript SDK 1.24):**
- `makeEpisodeWorkflow(requestId)` runs these activities: start → prepare script (steps 1–6) → voice (heartbeats, 30-minute timeout) → cover → publish, with fail on error. Each activity is idempotent because step outputs are stored in object storage and checked before redoing work.
- Two Temporal Schedules are created on worker start: `unread-dispatch` every 10 minutes (due users plus orphaned pending requests, as child workflows with ID `episode-request-<id>` and `REJECT_DUPLICATE`), and `unread-maintenance` hourly (purge raw mail older than 30 days, trial lifecycle).
- Tested with `TestWorkflowEnvironment.createTimeSkipping()`: correct step order, the voice step retried after a transient failure, and no publish when the request is skipped.
- "Make one now" starts the workflow straight from the web app. If Temporal is unreachable, the dispatcher picks the request up.
- Connection: `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, and `TEMPORAL_API_KEY` (TLS + API key for Temporal Cloud).
- **Without Temporal** (the PRD cut line): the same worker runs a poller that dispatches every minute, claims pending requests with `FOR UPDATE SKIP LOCKED`, and runs the same step functions. `pnpm dispatch` does one pass, for a Render cron job.
- **Schedule rules:** a run is due from 30 minutes before the user's delivery time, in their timezone; weekly users only on their chosen day. An episode is made only with 2 or more new items (or news); otherwise the request is skipped at no cost.
## Hosting (`render.yaml`)
- **unread-web:** native Node 22 runtime, `pnpm --filter @unread/web build/start`, `preDeployCommand: pnpm db:migrate`, custom domain `getunread.site`.
- **unread-worker:** a Docker background worker (`apps/worker/Dockerfile`: Debian with ffmpeg, plus the ONNX models baked in by `warm-models`). Use Standard or above (Kokoro is CPU-bound; EmbeddingGemma needs about 1 GB of RAM).
- **Inbound email:** a Cloudflare Email Worker (ADR 0004), deployed with wrangler.
- **Database:** Tiger Cloud (ADR 0003). **Object storage:** S3-compatible, preferably Cloudflare R2 (ADR 0009).
$ cd /home/user/unread/packages/core/src && ls pipeline; grep -n "export function checkLine" -A40 pipeline/check.ts | head -60; sed -n 85,110p pipeline/cluster.ts
build-script.ts
check.ts
cluster.ts
collect.ts
index.ts
plan.ts
script.ts
signals.ts
types.ts
40:export function checkLine(line: ScriptLine, allowed: Set<string>, sourceText: Map<string, string>): string[] {
41- const problems: string[] = [];
42- const cited = line.sourceIds.filter((id) => allowed.has(id));
43- if (cited.length === 0) problems.push("cites no valid source");
44- const words = line.text.trim().split(/\s+/).length;
45- if (words > MAX_WORDS_PER_LINE) problems.push(`${words} words (max ${MAX_WORDS_PER_LINE})`);
46- const haystack = normalizeText(cited.map((id) => sourceText.get(id) ?? "").join("\n"));
47- const sourceNumbers = new Set(numbersIn(haystack));
48- for (const n of numbersIn(line.text)) {
49- if (!sourceNumbers.has(n)) problems.push(`number ${n} is not in the cited sources`);
50- }
51- for (const q of quotesIn(line.text)) {
52- if (!haystack.includes(q)) problems.push(`quote "${q}" is not in the cited sources`);
53- }
54- return problems;
55-}
56-
57-export function findProblems(script: EpisodeScript, sourceText: Map<string, string>): LineProblem[] {
58- const problems: LineProblem[] = [];
59- script.chapters.forEach((chapter, chapterIndex) => {
60- const allowed = new Set(chapter.sources.map((s) => s.itemId));
61- chapter.lines.forEach((line, lineIndex) => {
62- const p = checkLine(line, allowed, sourceText);
63- if (p.length) problems.push({ chapterIndex, lineIndex, problems: p });
64- });
65- });
66- return problems;
67-}
68-
69-const repairSchema = z.object({ line: scriptLineSchema.nullable() });
70-
71-async function regenerateLine(options: {
72- model: ModelClient;
73- chapter: ScriptChapter;
74- line: ScriptLine;
75- problems: string[];
76- sourceText: Map<string, string>;
77-}): Promise<ScriptLine | null> {
78- const { chapter, line } = options;
79- const idFor = new Map(chapter.sources.map((s, i) => [s.itemId, `S${i + 1}`]));
80- const itemFor = new Map(chapter.sources.map((s, i) => [`S${i + 1}`, s.itemId]));
}),
);
}
// Already heard: nearest earlier story for this user (pgvector, cosine).
const heardItemIds: string[] = [];
const fresh: Candidate[] = [];
for (const c of candidates) {
if (!c.embedding) {
fresh.push(c);
continue;
}
const vector = `[${c.embedding.join(",")}]`;
const [nearest] = await db.execute<{ similarity: number }>(sql`
select 1 - (i.embedding <=> ${vector}::vector) as similarity
from ${schema.heardItems} h
join ${schema.items} i on i.id = h.item_id
where h.user_id = ${userId} and i.embedding is not null and i.id <> ${c.id}
order by i.embedding <=> ${vector}::vector
limit 1`);
if (nearest && Number(nearest.similarity) >= HEARD) heardItemIds.push(c.id);
else fresh.push(c);
}
if (heardItemIds.length > 0) {
await db.update(schema.items).set({ status: "skipped" }).where(inArray(schema.items.id, heardItemIds));
}
$ cd /home/user/unread && sed -n 1,25p packages/core/src/pipeline/signals.ts | head -25; sed -n 1,30p packages/core/src/inbound/blocks.ts | grep -E "^\s*(//|\*|/\*\*)" | head -15; sed -n 1,20p packages/core/src/inbound/split.ts | grep -E "^\s*(//|\*|/\*\*)"; ls; head -30 README.md 2>/dev/null
import type { Database } from "@unread/db";
import { sql } from "drizzle-orm";
import type { SkipRates } from "./plan";
/**
* Skip rate per source over the last 60 days, from the listening-events
* hypertable (prd §7). Keyed by the same labels the planner sees.
*/
export async function loadSkipRates(db: Database, userId: string): Promise<SkipRates> {
const rows = await db.execute<{ label: string; skips: number; plays: number }>(sql`
select coalesce(n.display_name, case i.source_kind when 'news' then 'News · ' || coalesce(i.topic_label, 'Top stories') else 'Your link' end) as label,
count(*) filter (where e.event = 'skip')::int as skips,
count(*) filter (where e.event in ('play', 'complete', 'skip'))::int as plays
from listening_events e
join chapter_sources cs on cs.chapter_id = e.chapter_id and cs.role = 'primary'
join items i on i.id = cs.item_id
left join newsletters n on n.id = i.newsletter_id
where e.user_id = ${userId} and e.occurred_at > now() - interval '60 days'
group by 1`);
return new Map(rows.filter((r) => Number(r.plays) >= 3).map((r) => [r.label, Number(r.skips) / Number(r.plays)]));
}
/** Share of the text inside <b>/<strong>; a bold-only short line reads as a heading. */
/** Drop the "Forwarded message / From: / Subject:" header a manual forward adds. */
CLAUDE.md
README.md
apps
docs
package.json
packages
pnpm-lock.yaml
pnpm-workspace.yaml
render.yaml
tsconfig.base.json
# Unread
Forward your newsletters to Unread and get one short, deduplicated podcast episode with the parts that matter, in a voice you choose, delivered to the podcast app you already use.
- Product spec: [docs/prd-unread.md](docs/prd-unread.md) · Design: [docs/design/](docs/design/README.md) · Decisions: [docs/decisions/](docs/decisions/README.md) · Deploy: [docs/deploy.md](docs/deploy.md)
## How it works
```
newsletter ─► Cloudflare Email Routing ─► Email Worker ─signed─► /inbound/email
(SPF/DKIM) (apps/inbound-worker) verified sender? clean, split into stories
│
Temporal schedule / "Make one now" ─► make-episode workflow (apps/worker)
collect → EmbeddingGemma + pgvector clustering → Gemma plan → Gemma script → fact check
→ Kokoro (Free) / ElevenLabs (Plus) → ffmpeg −16 LUFS → seeded cover → publish
│
private RSS feed (/feed/<token>.xml) + web player (Next.js, apps/web)
```
## Repo
| Path | What |
|---|---|
| `apps/web` | Next.js 16: onboarding, Home, Now playing, Sources, Settings, admin; inbound, feed, audio, billing routes |
| `apps/worker` | Temporal worker (or poller), CLIs: `brief`, `make-episode`, `dispatch`, `promo`, `warm-models` |
| `apps/inbound-worker` | Cloudflare Email Worker relaying signed raw MIME |
| `packages/core` | Inbound parsing, pipeline, model client, voices, audio, cover, feed, plans, promos, billing, news, links |
| `packages/db` | Drizzle schema + migrations (Postgres on Tiger Data, pgvector, Timescale) |
## Status
{
"query": "unread"
}
{
"repos": [
{
"full_name": "codewithveek/unread",
"url": "https://github.com/codewithveek/unread",
"pushed_at": "2026-10-25T22:12:24Z",
"visibility": "public",
"can_push": true
}
],
"has_more": false
}
The repo is public, so I can embed it. I'm also exporting a few full-resolution frames from the video to use as images in the post.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 -c "
import json; v=json.load(open('src/videos/unread/voice.json'))
ids=['inbox','overlap','commute','pause','reveal','forward','gate','merge','heard','check','voice','play','close']
at=15;
for i in ids:
print(i, round(at/30,2)); at+=v['scenes'][i]['minFrames']-18
"
inbox 0.5
overlap 12.23
commute 19.53
pause 23.6
reveal 27.47
forward 31.53
gate 40.93
merge 46.0
heard 59.4
check 63.8
voice 71.2
play 78.53
close 98.57
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && mkdir -p /home/user/unread/docs/submission && O=/home/user/unread/docs/submission; e() { ffmpeg -v error -y -ss $1 -i out/unread.mp4 -frames:v 1 -q:v 2 $O/$2.jpg; }; e 9.5 inbox; e 18.5 same-story; e 39 gmail-code; e 54.5 gemma-confirms; e 58.5 one-chapter; e 69.5 fact-check; e 75.5 voice; e 95 now-playing; e 104.5 end-card; ls -la $O; ffmpeg -v error -y $(for f in inbox same-story gmail-code gemma-confirms one-chapter fact-check voice now-playing end-card; do echo -i $O/$f.jpg; done) -filter_complex "[0]scale=640:-1[a];[1]scale=640:-1[b];[2]scale=640:-1[c];[3]scale=640:-1[d];[4]scale=640:-1[e];[5]scale=640:-1[f];[6]scale=640:-1[g];[7]scale=640:-1[h];[8]scale=640:-1[i];[a][b][c][d][e][f][g][h][i]xstack=inputs=9:grid=3x3" check/post-images.jpg
total 1076
drwxr-xr-x 2 root root 4096 Oct 25 23:16 .
drwxr-xr-x 6 root root 4096 Oct 25 23:16 ..
-rw-r--r-- 1 root root 73714 Oct 25 23:16 end-card.jpg
-rw-r--r-- 1 root root 132119 Oct 25 23:16 fact-check.jpg
-rw-r--r-- 1 root root 100439 Oct 25 23:16 gemma-confirms.jpg
-rw-r--r-- 1 root root 174622 Oct 25 23:16 gmail-code.jpg
-rw-r--r-- 1 root root 133663 Oct 25 23:16 inbox.jpg
-rw-r--r-- 1 root root 108151 Oct 25 23:16 now-playing.jpg
-rw-r--r-- 1 root root 105377 Oct 25 23:16 one-chapter.jpg
-rw-r--r-- 1 root root 113507 Oct 25 23:16 same-story.jpg
-rw-r--r-- 1 root root 138415 Oct 25 23:16 voice.jpg
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/check/post-images.jpg
[image omitted from the upload]
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && O=/home/user/unread/docs/submission; e() { ffmpeg -v error -y -ss $1 -i out/unread.mp4 -frames:v 1 -q:v 2 $O/$2.jpg; }; e 11.3 inbox; e 53.2 gemma-confirms; e 92 now-playing; e 107.5 end-card; rm -f $O/voice.jpg; ffmpeg -v error -y -i $O/inbox.jpg -i $O/gemma-confirms.jpg -i $O/now-playing.jpg -i $O/end-card.jpg -filter_complex "[0]scale=640:-1[a];[1]scale=640:-1[b];[2]scale=640:-1[c];[3]scale=640:-1[d];[a][b][c][d]xstack=inputs=4:grid=2x2" check/post-images2.jpg
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/check/post-images2.jpg
[image omitted from the upload]
Now I'm writing the article, with every technical claim taken from the code and the design-decision records (ADRs):
$ ls LICENSE* 2>/dev/null; grep -n "PLUS_PRICE\s*=" -r apps/web/src | head -2; grep -rn "eleven_v4\|eleven_flash" packages/core/src/voice/*.ts | grep -v test | head -3; grep -n "MAX_WORDS_PER_LINE =" -r packages/core/src; cp /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread-thumbnail-1280.jpg docs/submission/cover.jpg; grep -n "SAME\|0.83\|0.72\|0.85" packages/core/src/pipeline/cluster.ts | head -5
apps/web/src/features/landing/content.ts:8:export const PLUS_PRICE = "$12";
packages/core/src/voice/elevenlabs.ts:7: * model (eleven_v4 supersedes Multilingual v2/v3, ADR 0006). Cents are list
packages/core/src/voice/elevenlabs.ts:11: flash: { id: "eleven_flash_v2_5", centsPer1kChars: 4 },
packages/core/src/voice/elevenlabs.ts:12: studio: { id: "eleven_v4", centsPer1kChars: 8 },
packages/core/src/pipeline/check.ts:6:export const MAX_WORDS_PER_LINE = 30;
9:export const SAME_STORY = 0.83;
10:export const BORDERLINE = 0.72;
11:export const HEARD = 0.85;
44: "You decide whether two newsletter snippets report the SAME news story (same event, announcement or finding), not just the same topic.",
123: if (sim >= SAME_STORY) uf.union(a, b);
/home/user/unread/docs/submission/dev-post.md
File created successfully at: /home/user/unread/docs/submission/dev-post.md (file state is current in your context — no need to Read it back)
Before handing it over I'm checking a few claims against the code: the model client's endpoints, model download sizes, and trial voices.
$ cd /home/user/unread/packages/core/src && grep -n "BASE_URL\|baseUrl\|OPENROUTER\|GEMINI_API_STYLE\|MODEL" config.ts | head -20; grep -rn "trial" voice/*.ts plans/*.ts 2>/dev/null | grep -i "flash\|standard\|quality" | head -5
11: DATABASE_URL: z.url(),
12: APP_BASE_URL: z.url().default("http://localhost:3000"),
28: GEMMA_MODEL: z.string().default("gemma-4-31b-it"),
30: GEMINI_API_STYLE: z.enum(["native", "openai"]).default("native"),
31: OPENROUTER_API_KEY: optional,
32: OPENROUTER_MODEL: z.string().default("google/gemma-4-31b-it"),
33: MODEL_TIMEOUT_MS: z.coerce.number().int().positive().default(90_000),
35: EMBEDDING_MODEL: z.string().default("onnx-community/embeddinggemma-300m-ONNX"),
37: MODEL_CACHE_DIR: z.string().default(join(tmpdir(), "unread-models")),
38: KOKORO_MODEL: z.string().default("onnx-community/Kokoro-82M-v1.0-ONNX"),
96:/** `PG_URL` (Tiger Data) wins over `DATABASE_URL`, which stays as a fallback for local setups. */
98: const env = envSchema.parse({ ...source, DATABASE_URL: source.PG_URL || source.DATABASE_URL });
$ cd /home/user/unread/docs/submission && python3 - <<'EOF'
p='dev-post.md'; s=open(p).read()
rep=[
("4. **Nothing is made up.** Every line of the script must cite the newsletter it came from. Any number or quote that isn't in that source is rewritten once, and cut if it still fails. Every chapter links back to the original issue.",
"4. **Nothing is made up.** Every line of the script must cite the newsletter it came from. Any number or quote that isn't in that source is rewritten once, and cut if it still fails. Every chapter lists its sources, with links."),
("It shows up in a private RSS feed, so Apple Podcasts, Pocket Casts or AntennaPod pick it up on their own, and in a web player on phone and desktop.",
"Episodes go to a private RSS feed that works in any podcast app that accepts a feed URL (the Sources page has one-tap buttons for Apple Podcasts and Pocket Casts), and to a web player on phone and desktop."),
("with ffmpeg and the ONNX models baked into the image, so a cold start doesn't download 400 MB.",
"with ffmpeg and the ONNX models baked into the image, so a cold start doesn't download the models first."),
("A trial is capped at 3 Studio episodes, redemption is atomic, and it needs a verified email.",
"A trial is capped at 3 ElevenLabs episodes, redemption is atomic, and it needs a verified email."),
("The `ModelClient` speaks to OpenAI-compatible endpoints, so moving to self-hosted Gemma, or to a fine-tuned one, means changing configuration, not code.",
"The model client already supports the OpenAI-compatible protocol, so pointing it at a self-hosted or fine-tuned Gemma is a small change, not a rewrite."),
("""- **Last week in my inbox:** 6 new issues from two newsletters between Oct 19 and Oct 23. Neo Kim sent "Networking: A Deep Dive", "64 AI Engineering Concepts, Clearly Explained", "The 80/20 of AI Concepts" and "11 Networking Concepts Explained". Gregor sent "How to Use AI to Become a More E[✏️ full title]" and "How to Communicate Your Impact". At their own read times, that's ✏️ *[about N] minutes of reading* I was never going to do.""",
"""- **Last week in my inbox:** 6 new issues from two newsletters between Oct 19 and Oct 23. Four came from Neo Kim, on networking and AI engineering, and two from Gregor, on using AI at work and communicating your impact. At their own read times, that's ✏️ *[about N] minutes of reading* I was never going to do."""),
]
for a,b in rep:
assert a in s, a[:60]; s=s.replace(a,b)
open(p,'w').write(s)
print(len(s.split()))
EOF
2935
The article is written. I'm committing it and the images to the branch, which also makes the image links in the post resolve:
$ cd /home/user/unread && git add docs/submission && git status --short && git commit -q -m "docs: DEV challenge write-up and its images
The submission post for Hacktoberfest Week 3 (Slow and Steady), with frames
from the demo video and the cover. Placeholders marked with a pencil need the
author's real numbers and the video link.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxm9VwL3ZDnjAUcQUyK8fw" && git push -u origin claude/nifty-galileo-tinqrd 2>&1 | tail -1 && sleep 3 && curl -sS -o /dev/null -w "%{http_code}\n" https://raw.githubusercontent.com/codewithveek/unread/claude/nifty-galileo-tinqrd/docs/submission/one-chapter.jpg
A docs/submission/cover.jpg
A docs/submission/dev-post.md
A docs/submission/end-card.jpg
A docs/submission/fact-check.jpg
A docs/submission/gemma-confirms.jpg
A docs/submission/gmail-code.jpg
A docs/submission/inbox.jpg
A docs/submission/now-playing.jpg
A docs/submission/one-chapter.jpg
A docs/submission/same-story.jpg
branch 'claude/nifty-galileo-tinqrd' set up to track 'origin/claude/nifty-galileo-tinqrd'.
200
{
"files": [
"/home/user/unread/docs/submission/dev-post.md"
],
"caption": "The DEV post, ready to paste. Fill in the ✏️ placeholders first.",
"status": "normal",
"display": "attach"
}
1 file delivered to user.
/home/user/unread/docs/submission/dev-post.md → file_uuid: a1788973-82f1-42d1-8ac2-580988e94877
The article is attached as docs/submission/dev-post.md, ready to paste into DEV. I committed it and pushed it to claude/nifty-galileo-tinqrd, along with six frames from the video for the post, plus a cover image. I checked that the image links load.
It follows your template exactly, with a short opening story before "What I Built" (124 unread, "important but too lazy", the 40-minute commute). It's long, about 2,900 words, because the judges weigh writing and detail:
- What I Built: the five steps, with the overlap split into the two features we agreed (same story merged, already heard skipped). It ties the Free plan's weekly, CPU-voiced episode to the theme, "Slow and Steady".
-
How I Built It:
- A table of the three open models and what each one does: Gemma 4 31B, EmbeddingGemma on your own CPU, and Kokoro.
- The pipeline diagram, with measured numbers: same-story similarity 0.93 against 0.30 for unrelated stories, the 0.83 / 0.72 / 0.85 thresholds, and Kokoro at 0.77× real time.
- Two real code excerpts: the "already heard" database query and the fact-check function.
- A short "What fought back" section on three problems I hit.
- Why Does Open Innovation Matter?: the cost argument (36 or 72 cents of ElevenLabs per 10-minute episode against $0 on Kokoro), the falling back to a second provider that only an open model allows, and a fact check anyone can read.
- Prize categories: Gemma, Render, ElevenLabs (it also voices the demo's narration), Tiger Data, Temporal, SerpApi and Auth0, each with what it does in Unread.
Before you publish:
1. Fill in the ✏️ placeholders: the YouTube link for the video, your DevRelay session, and the Lazy Payoff numbers (minutes of reading last week, and what your real episode came out as).
2. Confirm what production actually runs. I verified that the site is served by Render. The post also says production uses Tiger Data for the database, Temporal Cloud, a Gemma key and the SerpApi news option. If any of those isn't live, delete that line and its prize category.
3. Add a licence. The repo has no LICENSE file, which matters for an open-source challenge. I can add MIT in one commit.
4. Optional: create a promo code with pnpm promo create and give it to judges in the Demo section, so they can try the Studio voices.
Title: the front matter uses "I had 124 unread newsletters. Now they read themselves to me on my commute". Alternatives:
- "Too lazy to read 124 newsletters, so I made them a podcast that never repeats a story"
- "Unread: forward your newsletters once, hear each story once"
The deadline is tonight at 11:59 PM PDT (07:59 WAT). Download the video now if you haven't: it's only in this session's temporary scratchpad, which is deleted when the session ends.
In the video, it should be "...124 and still counting" (or similar) which indicates that the newsletters are still increasing, then talk about "set up auto-forwarding once, and Unread picks up every new newsletters for you as they arrive",
can you figure out a way to not mention EmbeddingGemma and Pgvector but keep what they do? (tell me how you will do this)
Here's how I'd handle all three changes. I'll wait for your go before touching anything, so they all land in a single 12-minute render.
1. "124 and still counting" (opening scene)
- New line: "A hundred and twenty-four unread newsletters, and still counting." On screen it reads "124 unread newsletters, and still counting."
- On screen: as "still counting" is said, two new emails slide in at the top of the Gmail list, the way new mail arrives in Gmail. One is from Neo Kim and one from Gregor, dated today, so the pile visibly keeps growing.
- Knock-on change in the closing scene: "My inbox still says 124 unread" no longer fits a count that keeps rising. I'd change it to "My inbox is still counting. I'm caught up anyway." The Gmail inbox stays on screen behind it.
2. Auto-forwarding (setup scene)
- New line 1: "Set up auto-forwarding once, and Unread picks up every new newsletter as it arrives."
- Line 2 becomes: "It even catches Gmail's confirmation code for you." I'd keep it, because it's a real detail that shows care.
- On screen: the app's real live status card does exactly this. After "Waiting for your first newsletter…", the "Got …" line grows as issues arrive: "Got Engineering Leadership", then "…, The System Design Newsletter", then "…, Blogging Wizard". That's the app's actual behaviour (it checks for new mail every 4 seconds), so it shows "picks up every new newsletter" without inventing anything.
3. Same-story matching without naming EmbeddingGemma or pgvector
- New line 1: "Unread compares what every story means, not just its words, and finds the ones that are really the same." That's what they do, said plainly: EmbeddingGemma turns meaning into a vector, and pgvector finds the close matches.
- Line 2 stays: "When it's a close call, Gemma 4 decides." Gemma is still the named open model.
- Line 3 stays: "Two newsletters. One chapter. Both credited."
- On screen: I'd drop the two labelled chips. Instead, each story card gets a small "meaning fingerprint", a strip of bars drawn from its own data. The two design-doc stories have nearly identical fingerprints despite different headlines. As they drift together, their bars line up and glow, with a small "match" reading. The other stories' fingerprints look different and stay apart. The "already heard" scene doesn't name them either: last week's story's fingerprint matches the new one, so the new one is skipped.
- The names stay in two places: the article, which explains them in full for the judges, and, if you want, the small credits line on the end card (Gemma 4 · EmbeddingGemma · Kokoro · ElevenLabs · Tiger Data · …). I'd keep them in the credits so the Tiger Data category is still visible in the video.
Cost: about 300 new ElevenLabs characters. The rest of the narration is reused from cache.
Two choices are yours:
1. Is the closing line "My inbox is still counting. I'm caught up anyway." okay?
2. Should EmbeddingGemma and Tiger Data stay in the end-card credits? I recommend yes.
- yes
- yes,
but hold on,
also i would like to relegate the Kokoro mention, let's make elevenlabs the only mention there, something around "Unread uses natural voices from eleven labs, so you feel it" and a list of voices UI then say something like " Choose from a variety of voices, so you can listen how you want it"?
fix those suggestions
All of it is going in. For the new voice scene, I'm finding the app's real voice picker to rebuild:
$ cd /home/user/unread/apps/web/src && grep -rln -i "voice" features app | grep -v test | head -20; ls features/settings
features/landing/sections.tsx
features/landing/content.ts
features/settings/actions.ts
features/settings/forms.tsx
features/settings/studio-voice-form.tsx
features/settings/voice-picker.tsx
features/settings/rules.ts
features/settings/studio-voices.ts
features/today/episode-card.tsx
features/today/pipeline-steps.ts
features/episodes/queries.ts
features/onboarding/actions.ts
features/onboarding/finish-form.tsx
app/voice-preview/[voiceId]/route.ts
app/(app)/admin/page.tsx
app/(app)/settings/page.tsx
app/(app)/billing/return/page.tsx
app/onboarding/page.tsx
app/privacy/page.tsx
actions.ts
episode-prefs-fields.tsx
forms.tsx
plan-context.ts
rules.ts
studio-voice-form.tsx
studio-voices.ts
voice-picker.tsx
$ cat features/settings/voice-picker.tsx; sed -n 1,200p features/settings/studio-voice-form.tsx; cat features/settings/studio-voices.ts | head -60; grep -n -i "voice" "app/(app)/settings/page.tsx" | head -30
"use client";
import { KOKORO_FREE_VOICES } from "@unread/core/voice/voices";
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";
/** Free voices with a short preview each (prd §8: Kokoro, 4 curated voices). */
export function KokoroVoicePicker({ name = "kokoroVoiceId", defaultValue }: { name?: string; defaultValue: string }) {
const [selected, setSelected] = useState(defaultValue);
const audio = useRef<HTMLAudioElement | null>(null);
const [previewing, setPreviewing] = useState<string | null>(null);
const preview = (id: string) => {
audio.current?.pause();
if (previewing === id) {
setPreviewing(null);
return;
}
const a = new Audio(`/voices/${id}.mp3`);
audio.current = a;
a.onended = () => setPreviewing(null);
void a.play();
setPreviewing(id);
};
return (
<fieldset className="m-0 grid grid-cols-2 gap-2 border-0 p-0">
<legend className="sr-only">Voice</legend>
{KOKORO_FREE_VOICES.map((v) => (
<div key={v.id} className={cn("flex items-center gap-2 rounded-xl border bg-surface p-2 pl-3", selected === v.id ? "border-brand bg-brand/10" : "border-line")}>
<label className="flex min-w-0 flex-1 cursor-pointer flex-col">
<input type="radio" name={name} value={v.id} checked={selected === v.id} onChange={() => setSelected(v.id)} className="sr-only" />
<span className="text-[15px] font-medium">{v.label}</span>
<span className="text-xs text-muted-foreground">{v.accent}</span>
</label>
<button
type="button"
onClick={() => preview(v.id)}
aria-label={`${previewing === v.id ? "Stop" : "Play"} ${v.label} preview`}
className="flex size-11 flex-none cursor-pointer items-center justify-center rounded-full border border-line text-sm"
>
{previewing === v.id ? "■" : "▶"}
</button>
</div>
))}
</fieldset>
);
}
"use client";
import type { StudioVoice } from "@unread/core/voice/elevenlabs";
import { useActionState, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { initialActionState } from "@/features/action-state";
import { cn } from "@/lib/utils";
import { updateVoiceAction } from "./actions";
/** Plus: pick from the ElevenLabs library (their own preview clips) and Flash vs Studio quality. */
export function StudioVoiceForm({
voices,
selected,
quality,
canUseStudio,
}: {
voices: StudioVoice[];
selected: string;
quality: "flash" | "studio";
canUseStudio: boolean;
}) {
const [state, action, pending] = useActionState(updateVoiceAction, initialActionState);
const [voice, setVoice] = useState(selected);
const [query, setQuery] = useState("");
const audio = useRef<HTMLAudioElement | null>(null);
const shown = voices.filter((v) => `${v.name} ${v.description}`.toLowerCase().includes(query.toLowerCase())).slice(0, 40);
const preview = (url: string | null) => {
audio.current?.pause();
if (!url) return;
audio.current = new Audio(url);
void audio.current.play();
};
return (
<form action={action} className="flex flex-col gap-3">
<input type="hidden" name="elevenlabsVoiceId" value={voice} />
<fieldset className="m-0 grid grid-cols-2 gap-2 border-0 p-0">
<legend className="mb-2 text-[13px] font-semibold text-text-2">Quality</legend>
{(["flash", "studio"] as const).map((q) => (
<label key={q} className={cn("flex min-h-11 cursor-pointer items-center gap-2 rounded-xl border bg-surface px-3 text-sm has-[:checked]:border-brand", !canUseStudio && q === "studio" && "cursor-not-allowed opacity-60")}>
<input type="radio" name="voiceQuality" value={q} defaultChecked={quality === q} disabled={!canUseStudio && q === "studio"} className="accent-[var(--brand)]" />
{q === "flash" ? "Standard" : `Studio${canUseStudio ? "" : " · paid Plus"}`}
</label>
))}
</fieldset>
<label className="flex h-11 items-center rounded-xl border border-line bg-ground px-3">
<span className="sr-only">Search voices</span>
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search voices" className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" />
</label>
<ul className="m-0 flex max-h-80 list-none flex-col gap-1 overflow-y-auto p-0" aria-label="Studio voices">
{shown.map((v) => (
<li key={v.id} className={cn("flex items-center gap-2 rounded-xl border bg-surface p-1.5 pl-3", voice === v.id ? "border-brand bg-brand/10" : "border-line")}>
<button type="button" onClick={() => setVoice(v.id)} aria-pressed={voice === v.id} className="flex min-h-11 min-w-0 flex-1 cursor-pointer flex-col justify-center text-left">
<span className="truncate text-[15px] font-medium">{v.name}</span>
{v.description ? <span className="truncate text-xs text-muted-foreground">{v.description}</span> : null}
</button>
{v.previewUrl ? (
<button type="button" onClick={() => preview(v.previewUrl)} aria-label={`Play ${v.name} preview`} className="flex size-11 flex-none cursor-pointer items-center justify-center rounded-full border border-line text-sm">
▶
</button>
) : null}
</li>
))}
</ul>
<Button type="submit" variant="secondary" disabled={pending} className="self-start">
Save Studio voice
</Button>
<p role="status" className={`m-0 min-h-5 text-sm ${state.error ? "text-destructive" : "text-muted-foreground"}`}>
{state.error ?? state.message ?? ""}
</p>
</form>
);
}
/** Free: hear the same intro in a Studio voice before upgrading (prd §8.1). */
export function StudioPreviewButton({ voiceId }: { voiceId: string }) {
const [state, setState] = useState<"idle" | "loading" | "playing" | "error">("idle");
const audio = useRef<HTMLAudioElement | null>(null);
const play = async () => {
if (state === "playing") {
audio.current?.pause();
setState("idle");
return;
}
setState("loading");
const a = new Audio(`/voice-preview/${voiceId}`);
audio.current = a;
a.onended = () => setState("idle");
a.onerror = () => setState("error");
try {
await a.play();
setState("playing");
} catch {
setState("error");
}
};
return (
<div className="flex items-center gap-3">
<Button type="button" variant="secondary" onClick={play}>
{state === "playing" ? "Stop" : state === "loading" ? "Loading…" : "Hear a Studio voice"}
</Button>
{state === "error" ? <span className="text-sm text-muted-foreground">Preview unavailable right now.</span> : null}
</div>
);
}
import "server-only";
import { DEFAULT_ELEVENLABS_VOICE, elevenLabsClient, listStudioVoices, type StudioVoice } from "@unread/core/voice/elevenlabs";
import { serverEnv } from "@/lib/env";
let cache: { at: number; voices: StudioVoice[] } | undefined;
const TTL_MS = 60 * 60 * 1000;
/** The Plus voice library (cached for an hour). Empty when ElevenLabs isn't configured or is down. */
export async function studioVoices(): Promise<StudioVoice[]> {
const key = serverEnv().ELEVENLABS_API_KEY;
if (!key) return [];
if (cache && Date.now() - cache.at < TTL_MS) return cache.voices;
try {
const voices = await listStudioVoices(elevenLabsClient(key));
cache = { at: Date.now(), voices };
return voices;
} catch (error) {
console.error("Could not list ElevenLabs voices", error);
return cache?.voices ?? [];
}
}
export async function isStudioVoice(id: string): Promise<boolean> {
return id === DEFAULT_ELEVENLABS_VOICE || (await studioVoices()).some((v) => v.id === id);
}
export { DEFAULT_ELEVENLABS_VOICE };
6:import { DeleteAccountForm, EpisodePrefsForm, PromoForm, VoiceForm } from "@/features/settings/forms";
8:import { StudioPreviewButton, StudioVoiceForm } from "@/features/settings/studio-voice-form";
9:import { DEFAULT_ELEVENLABS_VOICE, studioVoices } from "@/features/settings/studio-voices";
37: const voices = ctx.plan.plan === "plus" ? await studioVoices() : [];
45: <SectionCard id="plan" title={`Plan: ${planLabel}`} description={paid ? "Studio voices, daily episodes and up to 20 minutes." : "Free: a weekly digest in a free voice, up to 10 minutes."}>
103: title="Voice"
104: description={ctx.plan.plan === "plus" ? "Studio episodes use ElevenLabs. When Plus ends, episodes switch to this free voice." : "Free episodes use Kokoro, an open voice model that runs on our servers."}
106: {ctx.plan.plan === "plus" && voices.length > 0 ? (
107: <StudioVoiceForm
108: voices={voices}
109: selected={user.elevenlabsVoiceId ?? DEFAULT_ELEVENLABS_VOICE}
110: quality={user.voiceQuality}
114: {ctx.plan.plan === "free" && studioReady ? <StudioPreviewButton voiceId={DEFAULT_ELEVENLABS_VOICE} /> : null}
115: <h3 className="m-0 pt-2 text-[13px] font-semibold text-text-2">{ctx.plan.plan === "plus" ? "Free voice (used if Plus ends)" : "Free voice"}</h3>
116: <VoiceForm kokoroVoiceId={user.kokoroVoiceId} />
$ cd /home/user/unread/packages/core/src/voice && grep -n "export async function listStudioVoices" -A25 elevenlabs.ts; grep -n "DEFAULT_ELEVENLABS_VOICE =" elevenlabs.ts
65:export async function listStudioVoices(client: ElevenLabsClient): Promise<StudioVoice[]> {
66- const voices: StudioVoice[] = [];
67- let nextPageToken: string | undefined;
68- do {
69- const page = await client.voices.search({ pageSize: 100, ...(nextPageToken ? { nextPageToken } : {}) });
70- for (const v of page.voices) {
71- const labels = v.labels ?? {};
72- voices.push({
73- id: v.voiceId,
74- name: v.name ?? v.voiceId,
75- description: [labels.gender, labels.accent, labels.descriptive ?? labels.description, labels.use_case].filter(Boolean).join(" · "),
76- previewUrl: v.previewUrl ?? null,
77- });
78- }
79- nextPageToken = page.hasMore ? page.nextPageToken : undefined;
80- } while (nextPageToken && voices.length < 500);
81- return voices;
82-}
83-
84-/** The fixed SFX set (prd §8.1), generated once and reused on every plan. */
85-export const SFX_PROMPTS = {
86- "sfx-intro": { text: "Short warm podcast intro sting, soft synth swell with a gentle bell, modern and clean, no voice", durationSeconds: 2.5 },
87- "sfx-transition": { text: "Subtle soft whoosh transition for a podcast, airy and clean, no voice", durationSeconds: 1 },
88- "sfx-outro": { text: "Short gentle podcast outro sting, warm synth resolving chord, calm, no voice", durationSeconds: 2.5 },
89-} as const;
90-
18:export const DEFAULT_ELEVENLABS_VOICE = "JBFqnCBsd6RMkjVDRZzb";
$ cd /home/user/unread/apps/web/src && grep -n "PageHeading" "app/(app)/settings/page.tsx"; sed -n 100,106p "app/(app)/settings/page.tsx"; mkdir -p /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/public/previews && cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/public/previews && curl -sS https://api.elevenlabs.io/v1/voices -H "xi-api-key: $ELEVENLABS_API_KEY" | python3 -c "
import json,sys
for v in json.load(sys.stdin)['voices']:
if v['name'].split(' ')[0] in ('George','Lily','Brian','Alice','River','Daniel','Matilda'): print(v['name'].split(' ')[0], v.get('preview_url'))" > urls.txt; cat urls.txt | cut -c1-120
11:import { PageHeading } from "@/features/shell/page-heading";
42: <PageHeading eyebrow="SETTINGS" title="Settings" />
<div className="flex flex-col gap-6">
<SectionCard
title="Voice"
description={ctx.plan.plan === "plus" ? "Studio episodes use ElevenLabs. When Plus ends, episodes switch to this free voice." : "Free episodes use Kokoro, an open voice model that runs on our servers."}
>
{ctx.plan.plan === "plus" && voices.length > 0 ? (
George https://api.us.elevenlabs.io/v1/voices/JBFqnCBsd6RMkjVDRZzb/previews/audio?payload=[redacted]
River https://storage.googleapis.com/eleven-public-prod/premade/voices/SAz9YHcvj6GT2YYXdXww/e6c95f0b-2227-491a-b3d7-2249
Alice https://storage.googleapis.com/eleven-public-prod/premade/voices/Xb7hH8MSUJpSbSDYk0k2/d10f7534-11f6-41fe-a012-2de1
Matilda https://storage.googleapis.com/eleven-public-prod/premade/voices/XrExE9yKIg1WjnnlVkGX/b930e18d-6b4d-466e-bab2-0a
Brian https://api.us.elevenlabs.io/v1/voices/nPczCjzI2devNBz1zQrb/previews/audio?payload=[redacted]
Daniel https://api.us.elevenlabs.io/v1/voices/onwK4e9ZLuTAKqWW03F9/previews/audio?payload=[redacted]
Lily https://storage.googleapis.com/eleven-public-prod/premade/voices/pFZP5JQG7iQjIQuC4Bku/89b68b35-b3dd-4348-a84a-a3c13
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/public/previews && while read name url; do curl -sS -f -L -o $name.mp3 "$url" -H "xi-api-key: $ELEVENLABS_API_KEY" && echo "$name $(ffprobe -v error -show_entries format=duration -of csv=p=0 $name.mp3)"; done < urls.txt
George 3.422041
River 3.526531
Alice 3.526531
Matilda 2.638250
Brian 5.459592
Daniel 5.746875
Lily 3.108571
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/asr && cat > tr.mjs <<'EOF'
import { pipeline, env } from "@huggingface/transformers";
import fs from "node:fs";
env.cacheDir = process.env.TMPDIR + "/unread-models";
const asr = await pipeline("automatic-speech-recognition", "Xenova/whisper-base.en");
for (const f of process.argv.slice(2)) {
const b = fs.readFileSync(f);
const a = new Float32Array(b.buffer, b.byteOffset, b.byteLength / 4);
console.log(f.split("/").pop(), JSON.stringify((await asr(a)).text));
}
EOF
P=../unread-video/public/previews; for n in George River Alice Matilda Brian Daniel Lily; do ffmpeg -v error -y -i $P/$n.mp3 -ac 1 -ar 16000 -f f32le $P/$n.f32; done; node tr.mjs $P/*.f32 2>/dev/null
Alice.f32 " Just trust yourself, then you will know how to live."
Brian.f32 " The thing always happens that you really believe in, and the belief in a thing makes it happen."
Daniel.f32 " The world is round, and the place which may seem like the end may also be the beginning."
George.f32 " God has given you one face and you make yourself another."
Lily.f32 " I have never been hurt by anything I didn't say."
Matilda.f32 " Ideas are the beginning points of all fortunes."
River.f32 " Nature is a mutable cloud, which is always and never the same."
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && rm -f public/previews/*.f32 public/previews/urls.txt && python3 - <<'EOF'
p='src/videos/unread/narration.ts'; s=open(p).read()
rep=[
(' { at: 0, text: "124 unread newsletters.", say: "A hundred and twenty-four unread newsletters." },',
' { at: 0, text: "124 unread newsletters, and still counting.", say: "A hundred and twenty-four unread newsletters, and still counting." },'),
(''' { at: 14, text: "Forward them once, with one Gmail filter." },
{ at: 0, text: "Unread even catches Gmail's confirmation code for you." },''',
''' { at: 14, text: "Set up auto-forwarding once, and Unread picks up every new newsletter as it arrives." },
{ at: 0, text: "It even catches Gmail's confirmation code for you." },'''),
(''' { at: 14, text: "EmbeddingGemma and pgvector find the same story across newsletters.", say: "Embedding Gemma and P G vector find the same story across newsletters." },
{ at: 0, text: "Gemma 4 settles the close calls.", say: "Gemma four settles the close calls." },''',
''' { at: 14, text: "Unread compares what every story means, not just its words, and finds the ones that are really the same." },
{ at: 0, text: "When it's a close call, Gemma 4 decides.", say: "When it's a close call, Gemma four decides." },'''),
(''' { at: 14, text: "Kokoro, an open-weight voice, reads every free episode." },
{ at: 0, text: "Studio voices come from ElevenLabs.", say: "Studio voices come from Eleven Labs." },''',
''' { at: 14, text: "Unread uses natural voices from ElevenLabs, so it sounds like a real show.", say: "Unread uses natural voices from Eleven Labs, so it sounds like a real show." },
// Starts after the two voice previews the scene plays (George, cut off by Lily), see scenes.tsx
{ at: 300, text: "Choose from a variety of voices, so you can listen the way you want." },'''),
(' { at: 20, text: "My inbox still says 124 unread.", say: "My inbox still says a hundred and twenty-four unread." },',
' { at: 20, text: "My inbox is still counting." },'),
]
for a,b in rep:
assert a in s, a[:50]; s=s.replace(a,b)
open(p,'w').write(s)
EOF
npm run voice 2>&1 | grep -E "generated|inbox|forward|merge|voice|close"
> product-videos@1.3.0 voice
> node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/voice.mts
generated (17 chars, narrator): This is my inbox.
generated (65 chars, narrator): A hundred and twenty-four unread newsletters, and still counting.
generated (45 chars, narrator): I subscribed to every one of them on purpose.
unread/inbox: 4 line(s), ends 373/330, scene grows by 75 frames
generated (84 chars, narrator): Set up auto-forwarding once, and Unread picks up every new newsletter as it arrives.
generated (50 chars, narrator): It even catches Gmail's confirmation code for you.
unread/forward: 2 line(s), ends 248/300
generated (104 chars, narrator): Unread compares what every story means, not just its words, and finds the ones that are really the same.
generated (43 chars, narrator): When it's a close call, Gemma four decides.
generated (44 chars, narrator): Two newsletters. One chapter. Both credited.
unread/merge: 3 line(s), ends 399/420, scene grows by 11 frames
generated (75 chars, narrator): Unread uses natural voices from Eleven Labs, so it sounds like a real show.
generated (68 chars, narrator): Choose from a variety of voices, so you can listen the way you want.
unread/voice: 2 line(s), ends 409/220, scene grows by 221 frames
generated (27 chars, narrator): My inbox is still counting.
generated (21 chars, narrator): I'm caught up anyway.
unread/close: 2 line(s), ends 116/330
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 -c "
import json; v=json.load(open('src/videos/unread/voice.json'))
for k in ['inbox','forward','merge','voice','close']: print(k, v['scenes'][k]['minFrames'], [(l['from'],l['frames']) for l in v['scenes'][k]['lines']])
l0=v['scenes']['voice']['lines'][0]; e=l0['from']+l0['frames']; g=e+10; li=g+54; print('george',g,'lily',li,'L1 at', li+94+8)"
inbox 405 [(16, 47), (69, 116), (191, 89), (286, 87)]
forward 300 [(14, 148), (168, 80)]
merge 431 [(14, 188), (208, 82), (296, 103)]
voice 441 [(14, 141), (300, 109)]
close 330 [(20, 58), (84, 32)]
george 165 lily 219 L1 at 321
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && sed -i 's/ { at: 300, text: "Choose from a variety/ { at: 310, text: "Choose from a variety/' src/videos/unread/narration.ts && npm run voice 2>&1 | grep -E "generated|unread/voice"
unread/voice: 2 line(s), ends 419/220, scene grows by 231 frames
Shell cwd was reset to /home/user/unread
Now the screen components: incoming Gmail rows, the meaning fingerprint, and the Settings voice picker.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/ui.tsx'; s=open(p).read()
old='''/** Gmail's inbox list: search bar, the thread cards (scrolled by `scroll` px, looping), Compose and the bottom bar. */
export const GmailScreen = ({ scroll = 0 }: { scroll?: number }) => {
const rowH = 96;
const loop = GMAIL_ROWS.length * rowH;
const y = scroll % loop;
return (
<div style={{ position: "absolute", inset: 0, background: g.page, color: g.ink, fontFamily: roboto, overflow: "hidden" }}>
<div style={{ position: "absolute", left: 0, right: 0, top: 128 - y }}>
{[...GMAIL_ROWS, ...GMAIL_ROWS, ...GMAIL_ROWS].map((r, i) => (
<div key={i} style={{ height: rowH }}>
<GmailRow {...r} />
</div>
))}
</div>'''
new='''/** Issues that arrive while the inbox is on screen ("and still counting"). Illustrative subjects from senders in the list. */
export const NEW_ROWS = [
{ from: "Blogging Wizard", date: "9:12 AM", subject: "New on the blog this week", preview: "The latest posts, guides and checklists" },
{ from: "Gregor Ojstersek from Engineering Leadership", date: "7:58 AM", subject: "This week's article", preview: "An adapted chapter from my upcoming book" },
];
/**
* Gmail's inbox list: search bar, the thread cards (scrolled by `scroll` px, looping), Compose and the
* bottom bar. `incoming` (0 → 1 per row of NEW_ROWS) slides new mail in at the top, as Gmail does.
*/
export const GmailScreen = ({ scroll = 0, incoming = [] }: { scroll?: number; incoming?: number[] }) => {
const rowH = 96;
const loop = GMAIL_ROWS.length * rowH;
const y = scroll % loop;
const fresh = NEW_ROWS.map((r, i) => ({ r, t: incoming[i] ?? 0 })).filter((x) => x.t > 0).reverse();
return (
<div style={{ position: "absolute", inset: 0, background: g.page, color: g.ink, fontFamily: roboto, overflow: "hidden" }}>
<div style={{ position: "absolute", left: 0, right: 0, top: 128 - y }}>
{fresh.map(({ r, t }) => (
<div key={r.subject} style={{ height: rowH * t, opacity: t, overflow: "hidden" }}>
<GmailRow {...r} />
</div>
))}
{[...GMAIL_ROWS, ...GMAIL_ROWS, ...GMAIL_ROWS].map((r, i) => (
<div key={i} style={{ height: rowH }}>
<GmailRow {...r} />
</div>
))}
</div>'''
assert old in s; s=s.replace(old,new)
s += '''
/**
* A story's "meaning fingerprint": a strip of bars standing in for its embedding. Stories about the
* same thing share a `seed` (with a little per-story `noise`), so their strips nearly match.
*/
export const Fingerprint = ({ seed, noise = "", bars = 24, height = 26, color = u.brand, glow = 0 }: { seed: string; noise?: string; bars?: number; height?: number; color?: string; glow?: number }) => {
const base = seededRandom(seed);
const jitter = seededRandom(`${seed}:${noise}`);
return (
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height, filter: glow > 0 ? `drop-shadow(0 0 ${8 * glow}px ${color})` : undefined }}>
{Array.from({ length: bars }, (_, i) => {
const h = Math.min(1, Math.max(0.12, base() + (noise ? (jitter() - 0.5) * 0.16 : 0)));
return <span key={i} style={{ width: 4, height: h * height, borderRadius: 2, background: color, opacity: 0.45 + 0.55 * h }} />;
})}
</div>
);
};
/** The account's ElevenLabs voices, as listStudioVoices() describes them (name; gender · accent · description · use case). */
export const STUDIO_VOICES = [
{ name: "George - Warm, Captivating Storyteller", description: "male · british · mature · narrative_story" },
{ name: "Lily - Velvety Actress", description: "female · british · confident · informative_educational" },
{ name: "Alice - Clear, Engaging Educator", description: "female · british · professional · informative_educational" },
{ name: "Brian - Deep, Resonant and Comforting", description: "male · american · classy · social_media" },
{ name: "River - Relaxed, Neutral, Informative", description: "neutral · american · calm · conversational" },
{ name: "Daniel - Steady Broadcaster", description: "male · british · formal · informative_educational" },
{ name: "Matilda - Knowledgable, Professional", description: "female · american · upbeat · informative_educational" },
];
/**
* Settings → Voice on Plus (app/(app)/settings/page.tsx + features/settings/studio-voice-form.tsx):
* Quality (Standard / Studio), "Search voices", the voice list with a ▶ preview each, "Save Studio voice".
*/
export const VoiceSettingsScreen = ({ selected, playing }: { selected: string; playing: string | null }) => (
<div style={{ position: "absolute", inset: 0, background: u.ground, padding: "22px 24px 0", display: "flex", flexDirection: "column", gap: 18, fontFamily }}>
<BrandMark />
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ ...mono, fontSize: 13, letterSpacing: 0.6, color: u.muted }}>SETTINGS</span>
<span style={{ fontSize: 28, fontWeight: 700, letterSpacing: -0.8 }}>Settings</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12, borderRadius: 18, border: `1px solid ${u.line}`, background: u.surface, padding: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 16, fontWeight: 600 }}>Voice</span>
<span style={{ fontSize: 13, lineHeight: 1.45, color: u.muted }}>Studio episodes use ElevenLabs. When Plus ends, episodes switch to this free voice.</span>
</div>
<span style={{ fontSize: 13, fontWeight: 600, color: u.text2 }}>Quality</span>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{["Standard", "Studio"].map((q) => (
<span key={q} style={{ minHeight: 44, display: "flex", alignItems: "center", gap: 8, padding: "0 12px", borderRadius: 12, border: `1px solid ${q === "Studio" ? u.brand : u.line}`, background: u.surface, fontSize: 14 }}>
<span style={{ width: 14, height: 14, borderRadius: 7, border: `2px solid ${q === "Studio" ? u.brand : u.muted}`, display: "flex", alignItems: "center", justifyContent: "center" }}>
{q === "Studio" && <span style={{ width: 6, height: 6, borderRadius: 3, background: u.brand }} />}
</span>
{q}
</span>
))}
</div>
<div style={{ height: 44, display: "flex", alignItems: "center", padding: "0 12px", borderRadius: 12, border: `1px solid ${u.line}`, background: u.ground, fontSize: 14, color: u.muted }}>Search voices</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{STUDIO_VOICES.map((v) => {
const first = v.name.split(" ")[0];
const on = selected === first;
return (
<Anchor key={v.name} name={`voice-${first}`}>
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 6px 6px 12px", borderRadius: 12, border: `1px solid ${on ? u.brand : u.line}`, background: on ? "rgba(91,155,255,0.10)" : u.surface }}>
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", justifyContent: "center", minHeight: 44 }}>
<span style={{ fontSize: 15, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{v.name}</span>
<span style={{ fontSize: 12, color: u.muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{v.description}</span>
</div>
<Anchor name={`play-${first}`} inline>
<span style={{ width: 44, height: 44, borderRadius: 22, border: `1px solid ${playing === first ? u.brand : u.line}`, color: playing === first ? u.brand : u.text, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 14 }}>▶</span>
</Anchor>
</div>
</Anchor>
);
})}
</div>
</div>
</div>
);
'''
open(p,'w').write(s)
EOF
grep -n "export const Inbox" -A30 src/videos/unread/scenes.tsx | head -40
50:export const Inbox = () => {
51- const frame = useCurrentFrame();
52- const l1 = line("inbox", 1);
53- const enter = progress(frame, 0, 40);
54- return (
55- <AbsoluteFill>
56- <Ground glow={0.28} />
57- <div style={{ position: "absolute", left: LEFT, top: 300, width: 860 }}>
58- <SaidStack
59- scene="inbox"
60- size={68}
61- lines={[
62- { n: 0, text: "This is my inbox." },
63- { n: 1, text: "*124* unread newsletters." },
64- { n: 2, text: "I subscribed to every one of them on purpose." },
65- { n: 3, text: "They're important, but I'm *too lazy* to read them." },
66- ]}
67- />
68- </div>
69- {/* Their own Gmail inbox, scrolling through issue after unopened issue (faster on "124") */}
70- <div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)`, filter: `blur(${(1 - enter) * 10}px)` }}>
71- <Phone scale={1.1}>
72- <GmailScreen scroll={frame * 0.9 + 760 * progress(frame, l1.from, l1.from + l1.frames + 10, ease)} />
73- </Phone>
74- </div>
75- </AbsoluteFill>
76- );
77-};
78-
79-export const Overlap = () => {
80- const frame = useCurrentFrame();
Shell cwd was reset to /home/user/unread
Now the scene edits:
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && grep -n "export const Voice = " -A60 src/videos/unread/scenes.tsx | head -70; grep -n "const StoryChip" -A12 src/videos/unread/scenes.tsx; grep -n "Pill style={{ fontSize: 18, ...useInOut(Math.round(l0" -B3 -A3 src/videos/unread/scenes.tsx
479:export const Voice = () => {
480- const frame = useCurrentFrame();
481- const l0 = line("voice", 0);
482- const l1 = line("voice", 1);
483- const step = frame < l1.from + l1.frames + 4 ? 6 : frame < l1.from + l1.frames + 16 ? 7 : frame < l1.from + l1.frames + 28 ? 8 : 9;
484- const enter = progress(frame, 0, 36);
485- const bars = 36;
486- return (
487- <AbsoluteFill>
488- <Ground glow={0.4} />
489- <div style={{ position: "absolute", left: LEFT, top: 260, width: TEXT_W }}>
490- <SaidStack
491- scene="voice"
492- size={60}
493- lines={[
494- { n: 0, text: "*Kokoro,* an open-weight voice, reads every free episode." },
495- { n: 1, text: "Studio voices come from *ElevenLabs.*" },
496- ]}
497- />
498- {/* A waveform that builds while the voice is recorded */}
499- <div style={{ display: "flex", alignItems: "center", gap: 8, height: 120, marginTop: 50 }}>
500- {Array.from({ length: bars }, (_, i) => {
501- const live = progress(frame, 10 + i * 3, 20 + i * 3);
502- const h = 16 + Math.abs(Math.sin(i * 1.3 + frame / 7) * Math.cos(i * 0.7)) * 90;
503- return <div key={i} style={{ width: 10, height: h * live + 6, borderRadius: 5, background: u.brand, opacity: 0.35 + 0.65 * live }} />;
504- })}
505- </div>
506- <div style={{ display: "flex", gap: 14, marginTop: 40, flexWrap: "wrap" }}>
507- <Pill style={{ ...useInOut(l0.from + 4) }}>Kokoro · Free voice</Pill>
508- <Pill style={{ ...useInOut(l1.from + 8) }}>ElevenLabs · Studio voice</Pill>
509- <Pill style={{ fontSize: 18, ...useInOut(l1.from + 40) }}>Temporal · on schedule</Pill>
510- <Pill style={{ fontSize: 18, ...useInOut(l1.from + 48) }}>Render · web + worker</Pill>
511- </div>
512- </div>
513- <div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
514- <PhoneZoom z={1 + 0.38 * progress(frame, 30, 64, ease)} ox={360} oy={640}>
515- <Phone scale={1.1}>
516- <div style={{ padding: "22px 24px 0", display: "flex", flexDirection: "column", gap: 18 }}>
517- <BrandMark />
518- <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
519- <span style={{ fontFamily: monoFamily, fontSize: 13, letterSpacing: 0.6, color: u.muted }}>MONDAY, 26 OCTOBER</span>
520- <span style={{ fontSize: 28, fontWeight: 700, letterSpacing: -0.8 }}>Your morning brief</span>
521- </div>
522- <InProgressCard at={step} />
523- </div>
524- <TabBar />
525- </Phone>
526- </PhoneZoom>
527- </div>
528- </AbsoluteFill>
529- );
530-};
531-
532-/* ------------------------------------------------------------------ act 3 */
533-
534-export const Play = () => {
535- const frame = useCurrentFrame();
536- const h0 = line("play", 0);
537- const h1 = line("play", 1);
538- const h2 = line("play", 2);
539- const n3 = line("play", 3);
289:const StoryChip = ({ label, from, glow = 0 }: { label: string; from: string; glow?: number }) => (
290- <div style={{ display: "inline-flex", alignItems: "center", gap: 14, padding: "14px 22px", borderRadius: 16, background: "#15161b", border: `1px solid ${glow > 0.1 ? u.brand : "rgba(255,255,255,0.09)"}`, boxShadow: `0 16px 40px rgba(0,0,0,0.45), 0 0 ${glow * 50}px ${u.brand}77`, fontFamily, whiteSpace: "nowrap" }}>
291- <span style={{ width: 14, height: 14, borderRadius: 7, background: HUE[from], flex: "none" }} />
292- <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
293- <span style={{ fontSize: 16, color: u.muted }}>{from}</span>
294- <span style={{ fontSize: 24, fontWeight: 550, color: u.text }}>{label}</span>
295- </div>
296- </div>
297-);
298-
299-export const Merge = () => {
300- const frame = useCurrentFrame();
301- const l0 = line("merge", 0);
334- {/* The embedding space */}
335- <div style={{ position: "absolute", left: PX - 100, top: PY - 40, width: 1600, height: 660, borderRadius: 36, border: "1px solid rgba(255,255,255,0.06)", background: "radial-gradient(ellipse at 40% 40%, rgba(91,155,255,0.07), transparent 70%)", ...rise(frame, 0, 30, 24) }}>
336- <div style={{ position: "absolute", left: 0, right: 0, bottom: -74, display: "flex", justifyContent: "center", gap: 14 }}>
337: <Pill style={{ fontSize: 18, ...useInOut(Math.round(l0.from + 4)) }}>EmbeddingGemma · on our worker</Pill>
338: <Pill style={{ fontSize: 18, ...useInOut(Math.round(l0.from + l0.frames * 0.28)) }}>pgvector · Tiger Data</Pill>
339- </div>
340- </div>
341- {STORIES.map((s, i) => {
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
import re
p='src/videos/unread/ui.tsx'; s=open(p).read()
# Onboarding: "Got …" lists only the newsletters that have arrived so far
old=''' <span style={{ width: 24, height: 24, borderRadius: 12, background: u.brand, color: u.onBrand, display: "flex", alignItems: "center", justifyContent: "center", flex: "none", transform: `scale(${0.6 + 0.4 * got})` }}>
<Check size={15} strokeWidth={3} />
</span>
<span style={{ fontSize: 14, lineHeight: 1.35 }}>Got {NEWSLETTERS.join(", ")}</span>'''
new=''' <span style={{ width: 24, height: 24, borderRadius: 12, background: u.brand, color: u.onBrand, display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
<Check size={15} strokeWidth={3} />
</span>
<span style={{ fontSize: 14, lineHeight: 1.35 }}>Got {NEWSLETTERS.slice(0, got).join(", ")}</span>'''
assert old in s; s=s.replace(old,new)
s=s.replace(''' * Onboarding step 1 (app/onboarding/page.tsx + forwarding/live-status.tsx + forwarding-guides.tsx).
* `code` shows Gmail's confirmation card; `got` swaps "Waiting…" for "Got …".
*/
export const OnboardingScreen = ({ copied, code, got }: { copied: boolean; code: number; got: number }) => (''',''' * Onboarding step 1 (app/onboarding/page.tsx + forwarding/live-status.tsx + forwarding-guides.tsx).
* `code` shows Gmail's confirmation card (`codeGlow` rings it); `got` is how many newsletters have
* arrived: the live status swaps "Waiting…" for "Got …" and lists each one as it lands.
*/
export const OnboardingScreen = ({ copied, code, got, codeGlow = 0 }: { copied: boolean; code: number; got: number; codeGlow?: number }) => (''')
s=s.replace(''' <div style={{ opacity: code, transform: `translateY(${(1 - code) * 10}px)`, display: "flex", flexDirection: "column", gap: 8, borderRadius: 14, border: `1px solid ${u.brand}`, background: "rgba(91,155,255,0.10)", padding: 16 }}>''',''' <div style={{ opacity: code, transform: `translateY(${(1 - code) * 10}px)`, display: "flex", flexDirection: "column", gap: 8, borderRadius: 14, border: `1px solid ${u.brand}`, background: "rgba(91,155,255,0.10)", padding: 16, boxShadow: `0 0 0 ${4 * codeGlow}px rgba(91,155,255,${0.35 * codeGlow}), 0 0 ${30 * codeGlow}px rgba(91,155,255,${0.5 * codeGlow})` }}>''')
open(p,'w').write(s)
p='src/videos/unread/scenes.tsx'; s=open(p).read()
s=s.replace('import { line, Said, SaidStack } from "./said";','import { line, Said, SaidStack, wordsOf } from "./said";')
s=s.replace('import { AbsoluteFill, useCurrentFrame } from "remotion";','import { AbsoluteFill, Audio, Sequence, staticFile, useCurrentFrame } from "remotion";')
s=s.replace(' EPISODE,\n',' EPISODE,\n Fingerprint,\n')
s=s.replace(' useInOut,\n} from "./ui";',' useInOut,\n VoiceSettingsScreen,\n} from "./ui";')
# ---- Inbox: "and still counting" → the list jumps back to the top and new mail slides in
old=''' const l1 = line("inbox", 1);
const enter = progress(frame, 0, 40);'''
new=''' const l1 = line("inbox", 1);
const enter = progress(frame, 0, 40);
// "…and *still counting*": the list returns to the top and two new issues slide in
const stillAt = Math.round(wordsOf("inbox", 1, INBOX_COUNT)[4].at);
const back = progress(frame, stillAt - 4, stillAt + 14, ease);
const scroll = (frame * 0.9 + 760 * progress(frame, l1.from, stillAt, ease)) * (1 - back);'''
assert old in s; s=s.replace(old,new)
s=s.replace(''' { n: 1, text: "*124* unread newsletters." },''',''' { n: 1, text: INBOX_COUNT },''')
s=s.replace(''' <GmailScreen scroll={frame * 0.9 + 760 * progress(frame, l1.from, l1.from + l1.frames + 10, ease)} />''',''' <GmailScreen scroll={scroll} incoming={[progress(frame, stillAt + 12, stillAt + 26, ease), progress(frame, stillAt + 30, stillAt + 44, ease)]} />''')
s=s.replace('''export const Inbox = () => {''','''const INBOX_COUNT = "*124* unread newsletters, and *still counting.*";
export const Inbox = () => {''')
s=s.replace(''' {/* Their own Gmail inbox, scrolling through issue after unopened issue (faster on "124") */}''',''' {/* Their own Gmail inbox, scrolling through issue after unopened issue (faster on "124"); new mail keeps coming */}''')
# ---- Forward: Gmail's code first (it must come before auto-forwarding works), then each newsletter as it lands
old=''' const l1 = line("forward", 1);
const tapCopy = 54;
const codeAt = Math.round(l1.from + l1.frames * 0.45);
const gotAt = Math.max(l1.from + l1.frames + 10, codeAt + 50);
const enter = progress(frame, 0, 36);
const zoom = 1 + 0.42 * progress(frame, codeAt - 14, codeAt + 12, ease);'''
new=''' const l0 = line("forward", 0);
const l1 = line("forward", 1);
// Real order: Gmail's confirmation code arrives first (auto-forwarding waits for it), then the newsletters
const tapCopy = 30;
const codeAt = Math.max(tapCopy + 26, Math.round(l0.from + l0.frames * 0.3));
const arrivals = [0.58, 0.74, 0.9].map((f) => Math.round(l0.from + l0.frames * f));
const got = arrivals.filter((f) => frame >= f).length;
const glow = progress(frame, l1.from + l1.frames * 0.35, l1.from + l1.frames * 0.35 + 10) * (1 - progress(frame, l1.from + l1.frames + 20, l1.from + l1.frames + 40));
const enter = progress(frame, 0, 36);
const zoom = 1 + 0.42 * progress(frame, codeAt - 6, codeAt + 18, ease);'''
assert old in s; s=s.replace(old,new)
s=s.replace(''' { n: 0, text: "Forward them once, with *one Gmail filter.*" },
{ n: 1, text: "Unread even catches Gmail's *confirmation code* for you." },''',''' { n: 0, text: "Set up auto-forwarding *once,* and Unread picks up *every new newsletter* as it arrives." },
{ n: 1, text: "It even catches Gmail's *confirmation code* for you." },''')
s=s.replace('''
… [7537 characters trimmed for the upload] …
36);
const bars = 36;
return (
<AbsoluteFill>
<Ground glow={0.4} />
<div style={{ position: "absolute", left: LEFT, top: 260, width: TEXT_W }}>
<SaidStack
scene="voice"
size={60}
lines={[
{ n: 0, text: "Unread uses *natural voices* from ElevenLabs, so it sounds like *a real show.*" },
{ n: 1, text: "Choose from a variety of voices, so you can listen *the way you want.*" },
]}
/>
{/* The preview playing: its voice's name and a live waveform */}
<div style={{ display: "flex", alignItems: "center", gap: 22, marginTop: 50, opacity: progress(frame, george - 4, george + 6) * (1 - progress(frame, lilyEnd, lilyEnd + 12)) }}>
<Pill style={{ fontSize: 22 }}>▶ {playing ?? "Lily"} · ElevenLabs preview</Pill>
<div style={{ display: "flex", alignItems: "center", gap: 6, height: 90 }}>
{Array.from({ length: bars }, (_, i) => {
const h = playing ? 12 + Math.abs(Math.sin(i * 1.3 + frame / (playing === "Lily" ? 5 : 7)) * Math.cos(i * 0.7 + frame / 11)) * 70 : 6;
return <div key={i} style={{ width: 7, height: h, borderRadius: 4, background: u.brand }} />;
})}
</div>
</div>
</div>
<div style={{ position: "absolute", left: PHONE_X, top: 40, opacity: enter, transform: `translateY(${(1 - enter) * 80}px)` }}>
<PhoneZoom z={1 + 0.3 * progress(frame, 24, 56, ease)} ox={360} oy={520}>
<Phone scale={1.1}>
<PhoneScreen
taps={[
{ f: george - 22, at: "play-George", dy: 60 },
{ f: george, at: "play-George", tap: true },
{ f: lily, at: "play-Lily", tap: true },
{ f: pick - 18, at: "voice-Alice", fx: 0.4 },
{ f: pick, at: "voice-Alice", fx: 0.4, tap: true },
{ f: pick + 24, at: "voice-Alice", fx: 0.4, dy: 140 },
]}
show={[[george - 26, pick + 22]]}
>
<VoiceSettingsScreen selected={frame >= pick ? "Alice" : "George"} playing={playing} />
</PhoneScreen>
</Phone>
</PhoneZoom>
</div>
<Sequence from={george} durationInFrames={lily - george} layout="none">
<Audio src={staticFile("previews/George.mp3")} volume={(f) => 0.85 * (1 - progress(f, lily - george - 5, lily - george))} />
</Sequence>
<Sequence from={lily} durationInFrames={lilyEnd - lily + 4} layout="none">
<Audio src={staticFile("previews/Lily.mp3")} volume={0.85} />
</Sequence>
</AbsoluteFill>
);
};
'''+s[b:]
# ---- Close
s=s.replace(''' { n: 0, text: "My inbox still says *124 unread.*" },''',''' { n: 0, text: "My inbox is *still counting.*" },''')
s=s.replace(''' <GmailScreen scroll={0} />''',''' <GmailScreen scroll={0} incoming={[1, 1]} />''')
open(p,'w').write(s)
EOF
npx tsc 2>&1 | head -20
Shell cwd was reset to /home/user/unread
Typecheck passes. Checking stills of every changed scene:
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && ./stills.sh unread-inbox:150,200 unread-forward:80,150,215 unread-merge:150,200,380 unread-heard:80 unread-voice:180,240,380 unread-close:90 2>&1 | tail -1; cd stills && x() { out=$1; shift; n=$#; args=(); for f in "$@"; do args+=(-i "$f"); done; ffmpeg -v error -y "${args[@]}" -filter_complex "xstack=inputs=$n:layout=0_0|w0_0|0_h0|w0_h0|0_h0+h0|w0_h0+h0" $out; }; x s9.png unread-inbox-150.png unread-inbox-200.png unread-forward-80.png unread-forward-150.png unread-forward-215.png unread-close-90.png; x s10.png unread-merge-150.png unread-merge-200.png unread-merge-380.png unread-heard-80.png unread-voice-180.png unread-voice-380.png; echo ok
done
ok
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/s9.png
[image omitted from the upload]
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/s10.png
[image omitted from the upload]
Nearly everything looks right. One fix: the matching pair overlaps and hides one fingerprint, so I'm spacing them apart before rendering.
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && python3 - <<'EOF'
p='src/videos/unread/scenes.tsx'; s=open(p).read()
old=''' if (s.id === "a") return { x: lerp(s.x, meet.x - 120, drift), y: lerp(s.y, meet.y - 46, drift) };
if (s.id === "b") return { x: lerp(s.x, meet.x - 60, drift), y: lerp(s.y, meet.y + 46, drift) };'''
new=''' if (s.id === "a") return { x: lerp(s.x, meet.x - 120, drift), y: lerp(s.y, meet.y - 66, drift) };
if (s.id === "b") return { x: lerp(s.x, meet.x - 60, drift), y: lerp(s.y, meet.y + 60, drift) };'''
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
EOF
npx tsc && ./stills.sh unread-merge:230 2>&1 | tail -1 && cp out/unread.mp4 out/unread-v3.mp4
done
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/stills/unread-merge-230.png
[image omitted from the upload]
The pair now sits cleanly, with matching fingerprints, the "close call · 0.79 match" reading, then "Gemma 4: same story?". Rendering:
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log
Command running in background with ID: b6cpu4tg4. Output is being written to: /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/b6cpu4tg4.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains /home/user/unread; directory changes made by the backgrounded command do not apply to subsequent commands.
The render is running (about 13 minutes; the voice scene made it a bit longer). Once it's done, I'll re-export the article's images from the new cut, so they match the video, and push them.
<task-notification>
<task-id>b6cpu4tg4</task-id>
<tool-use-id>toolu_01Xk5kerVnMaSDtxV6HwcsDX</tool-use-id>
<output-file>/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/tasks/b6cpu4tg4.output</output-file>
<status>completed</status>
<summary>Background command "cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && ./render-all.sh Unread > render.log 2>&1; echo "exit $?" >> render.log; tail -4 render.log" completed (exit code 0)</summary>
</task-notification>
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && cat render.log | tail -3; ffprobe -v error -show_entries format=duration -of csv=p=0 out/unread.mp4; node scripts/audio-report.mjs out/unread.mp4 2>&1 | head -2; python3 -c "
import json; v=json.load(open('src/videos/unread/voice.json'))
ids=['inbox','overlap','commute','pause','reveal','forward','gate','merge','heard','check','voice','play','close']
at=15
for i in ids:
print(i, round(at/30,2)); at+=v['scenes'][i]['minFrames']-18
"
out/unread.mp4: -19.07 LUFS -> -16 LUFS
out/unread.vtt: 33 cues
exit 0
118.300000
out/unread.mp4: 118.3 s, peak -1.5 dBFS, RMS -19.3 dBFS
spectrum: <250 Hz 22%, 250-1k 59%, 1-4k 13%, >4k 6%
inbox 0.5
overlap 13.4
commute 20.7
pause 24.77
reveal 28.63
forward 32.7
gate 42.1
merge 47.17
heard 60.93
check 65.33
voice 72.73
play 87.17
close 107.2
Shell cwd was reset to /home/user/unread
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && for t in 7.5 38 54 79 85.5 110; do ffmpeg -v error -y -ss $t -i out/unread.mp4 -frames:v 1 -vf scale=640:-1 check/k_$t.jpg; done; ffmpeg -v error -y $(for t in 7.5 38 54 79 85.5 110; do echo -i check/k_$t.jpg; done) -filter_complex "xstack=inputs=6:grid=2x3" check/sheet4.jpg; node scripts/audio-report.mjs out/unread.mp4 2>&1 | sed -n 17,20p
70s |#######++#+##+#.+++++++++######+#####+##########+#|
75s |#######+++.+#########+#####+++++++###+######+#+###|
80s |#########+++####++++########+++################+#+|
85s |+###############+..########+++++++#+####+######+++|
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/check/sheet4.jpg
[image omitted from the upload]
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video && O=/home/user/unread/docs/submission; e() { ffmpeg -v error -y -ss $1 -i out/unread.mp4 -frames:v 1 -q:v 2 $O/$2.jpg; }; e 12.4 inbox; e 19.2 same-story; e 41.3 gmail-code; e 56.4 gemma-confirms; e 59.8 one-chapter; e 71 fact-check; e 103.6 now-playing; e 116.5 end-card; e 84 voices; ffmpeg -v error -y $(for f in inbox same-story gmail-code gemma-confirms one-chapter fact-check now-playing end-card voices; do echo -i $O/$f.jpg; done) -filter_complex "$(for i in 0 1 2 3 4 5 6 7 8; do printf "[$i]scale=640:-1[v$i];"; done)[v0][v1][v2][v3][v4][v5][v6][v7][v8]xstack=inputs=9:grid=3x3" check/post3.jpg
Shell cwd was reset to /home/user/unread
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/check/post3.jpg
[image omitted from the upload]
$ python3 - <<'EOF'
p='docs/submission/dev-post.md'; s=open(p).read()
old=''
new=old+'\n\n'
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
EOF
git add docs/submission && git commit -q -m "docs: refresh the write-up's images from the new video cut
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxm9VwL3ZDnjAUcQUyK8fw" && git push -u origin claude/nifty-galileo-tinqrd 2>&1 | tail -1
branch 'claude/nifty-galileo-tinqrd' set up to track 'origin/claude/nifty-galileo-tinqrd'.
{"files":["/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.mp4","/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad...
2 files delivered to user.
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.mp4 → file_uuid: d16ee029-a4a6-4b4d-9016-54f75de6ad76
/tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.vtt → file_uuid: d1894043-c894-4d42-8d33-f43cf7344488
The new cut is attached: 1:58, at the same loudness as before. I checked frames from the finished file. All the changes you asked for are in:
- "Still counting": you say "124 unread newsletters, and still counting". The Gmail list jumps back to the top and two new emails slide in. The ending is now "My inbox is still counting. I'm caught up anyway." over the same inbox.
- Auto-forwarding: "Set up auto-forwarding once, and Unread picks up every new newsletter as it arrives." Gmail's confirmation code appears first, as it does in reality. Then the app's live status lists each newsletter as it lands: Engineering Leadership, then The System Design Newsletter, then Blogging Wizard. On "It even catches Gmail's confirmation code for you", the code card lights up.
- No EmbeddingGemma or pgvector in the narration: "Unread compares what every story means, not just its words, and finds the ones that are really the same." Each story card now shows a "meaning fingerprint". The two design-doc stories have nearly identical ones, show "close call · 0.79 match", and then "When it's a close call, Gemma 4 decides." The "already heard" story uses the same fingerprint idea. Both names are still in the end-card credits, as you agreed.
- Voices: Kokoro is no longer mentioned in the narration. "Unread uses natural voices from ElevenLabs, so it sounds like a real show." The screen is the app's real Settings → Voice picker, filled with voices from your ElevenLabs account. Tapping ▶ on George plays his actual ElevenLabs preview clip, and tapping Lily cuts him off and plays hers, just as the app does. Then "Choose from a variety of voices, so you can listen the way you want." as Alice gets selected. Kokoro now appears only in the small end-card credits.
Made up: the two emails that slide in ("New on the blog this week" from Blogging Wizard, "This week's article" from Gregor) use generic subjects, because I don't have real ones for today.
Article: I re-exported its images from this cut, added a picture of the voice picker, and pushed both. I left Kokoro in the article on purpose: it's the strongest open-source argument for the judges (the free plan costs $0 per character). Say if you want it toned down there too.
The video is at /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad/unread-video/out/unread.mp4, and the previous cut is kept next to it as unread-v3.mp4. Download it before this session ends, because the folder is temporary. The deadline is 07:59 WAT this morning.
give me title n description for yt
Title (pick one):
- I had 124 unread newsletters, so I turned them into a podcast | Unread
- Too lazy to read my newsletters, so I built an AI that reads them to me
- Unread: forward your newsletters once, hear each story once
Description:
My inbox has 124 unread newsletters, and still counting. They're important, but I'm too lazy to read them. So I built Unread: set up auto-forwarding once, and every new newsletter becomes part of one short, private podcast episode that I listen to on my commute.
What makes it different:
• Hear each story once. When two newsletters cover the same story, Unread merges them into one chapter that credits both. If you heard a story last week, it's skipped.
• Nothing made up. Every line cites the newsletter it came from, and any number that isn't in the source gets cut.
• Natural voices from ElevenLabs, or a free voice that runs on open models.
• A private feed for your own podcast app, plus a web player with chapters, a transcript and sources.
Try it: https://getunread.site
Code: https://github.com/codewithveek/unread
Write-up: [DEV post link]
Built for the DEV Hacktoberfest Open-Source AI Challenge, Week 3: Slow and Steady.
Gemma 4 · EmbeddingGemma · Kokoro · ElevenLabs · Tiger Data (pgvector) · Temporal · Render · Cloudflare Email Workers · Auth0
Chapters
0:00 124 unread, and still counting
0:28 Meet Unread: set up auto-forwarding once
0:42 Same story, heard once
1:00 Repeats skipped, every fact checked
1:12 Natural voices from ElevenLabs
1:27 Press play
1:47 Still counting, caught up anyway
The narrator and the episode's host are AI voices from ElevenLabs. The app screens are rebuilt from Unread's own code. Newsletter names come from my own inbox; the story titles in the episode are examples.
#buildinpublic #opensource #ai #podcast #newsletters
Tags: unread, newsletter to podcast, ai podcast, gemma, elevenlabs, open source ai, hacktoberfest, dev challenge, text to speech, productivity
When you upload:
- Upload the captions file (unread.vtt) as English subtitles.
- Use docs/submission/cover.jpg as the custom thumbnail. It's on the branch in the repo; a copy is unread-thumbnail-1280.jpg in the video's out/ folder.
- Paste the YouTube link into the ✏️ spot in the DEV post, and the DEV post link into this description.
YouTube only turns the timestamps into chapters if the first one is 0:00 and each runs at least 10 seconds. These do, which is why some scenes are grouped together.
merge to main
$ git status --short && git diff --stat && git fetch origin main 2>&1 | tail -1 && git log --oneline origin/main -3 && git log --oneline origin/main..HEAD
183aca6..5d5dd58 main -> origin/main
5d5dd58 chore: add MIT license
b3a3494 docs: note resumable model download workaround for the Windows machine
73fa6a7 fix: make episode runs and playback robust; UX polish on Today
f608ed5 docs: refresh the write-up's images from the new video cut
fd35d00 docs: DEV challenge write-up and its images
$ sed -i 's#raw.githubusercontent.com/codewithveek/unread/claude/nifty-galileo-tinqrd/#raw.githubusercontent.com/codewithveek/unread/main/#g' docs/submission/dev-post.md && python3 - <<'EOF'
p='docs/submission/dev-post.md'; s=open(p).read()
old="The repo was started on October 23, inside the challenge window."
new="Unread is MIT-licensed. The repo was started on October 23, inside the challenge window."
assert old in s; s=s.replace(old,new); open(p,'w').write(s)
EOF
grep -c "unread/main/docs" docs/submission/dev-post.md && git commit -qam "docs: point the write-up's images at main and mention the MIT licence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxm9VwL3ZDnjAUcQUyK8fw" && git push -q origin claude/nifty-galileo-tinqrd && git checkout -q -B main origin/main && git merge --no-ff -q claude/nifty-galileo-tinqrd -m "Merge branch 'claude/nifty-galileo-tinqrd': DEV challenge write-up
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxm9VwL3ZDnjAUcQUyK8fw" && git log --oneline -5 && git push origin main 2>&1 | tail -2
8
remote:
remote: GitHub found 7 vulnerabilities on codewithveek/unread's default branch (4 high, 3 moderate). To find out more, visit:
remote: https://github.com/codewithveek/unread/security/dependabot
remote:
d11a583 Merge branch 'claude/nifty-galileo-tinqrd': DEV challenge write-up
b811278 docs: point the write-up's images at main and mention the MIT licence
f608ed5 docs: refresh the write-up's images from the new video cut
5d5dd58 chore: add MIT license
b3a3494 docs: note resumable model download workaround for the Windows machine
To https://github.com/codewithveek/unread
5d5dd58..d11a583 main -> main
[This command modified 1 file you've previously read: docs/submission/dev-post.md. Call Read before editing.]
$ sleep 2; for f in cover inbox voices; do curl -sS -o /dev/null -w "$f %{http_code}\n" https://raw.githubusercontent.com/codewithveek/unread/main/docs/submission/$f.jpg; done; git checkout -q claude/nifty-galileo-tinqrd && git merge -q --ff-only main && git push -q origin claude/nifty-galileo-tinqrd && git status -sb | head -1
cover 200
inbox 200
voices 200
remote:
remote: GitHub found 7 vulnerabilities on codewithveek/unread's default branch (4 high, 3 moderate). To find out more, visit:
remote: https://github.com/codewithveek/unread/security/dependabot
remote:
## claude/nifty-galileo-tinqrd...origin/claude/nifty-galileo-tinqrd
The branch is merged into main and pushed; main is now at d11a583.
-
Merged in: the DEV write-up (
docs/submission/dev-post.md), its 10 images and the cover. It merged cleanly on top of your MIT licence and the two app fixes. -
Image links: the post now loads its images from
maininstead of the branch, so they keep working if the branch goes away. I checked that they load frommain. - Licence: the Code section now says Unread is MIT-licensed, since your licence commit landed. That closes the licence item from my earlier checklist.
-
Branch:
claude/nifty-galileo-tinqrdhas been fast-forwarded to matchmain.
When I pushed, GitHub reported 7 Dependabot vulnerabilities on main (4 high, 3 moderate). I haven't looked into them. They don't block the submission, but I can review and update the affected dependencies after the deadline if you want.
give me your session that i can embed
{
"query": "DevRelay save agent session embed dev.to agent_session liquid tag Claude Code",
"mode": "extended"
}
Web search results for query: "DevRelay save agent session embed dev.to agent_session liquid tag Claude Code"
Links: [{"title":"Sift - DEV Community","url":"https://dev.to/parth_kulkarni_f56a656693/sift-1m93"},{"title":"🪶Quill: The Autonomous, Local-First Post-Call AI Assistant - DEV Community","url":"https://dev.to/archangel_f64f7dd81378a0f/quill-the-autonomous-local-first-post-call-ai-assistant-399f"},{"title":"Yard Sloth: walk your yard once, let open agents babysit the garden - DEV Community","url":"https://dev.to/myluco/yard-sloth-walk-your-yard-once-let-open-agents-babysit-the-garden-364a"},{"title":"Hacktoberfest Open-Source AI Challenge: Week 3 - DEV Challenge — DEV Community","url":"https://dev.to/challenges/hacktoberfest-week3-2026-10-19"},{"title":"Share, Embed, and Curate Agent Sessions on DEV [Beta] - DEV Community","url":"https://dev.to/devteam/share-embed-and-curate-agent-sessions-on-dev-beta-5bj6"},{"title":"Two Small Agents, One Local Model: LazyMate + SellerMate for Slow and Steady 🦥 - DEV Community","url":"https://dev.to/mdarifulhaque/two-small-agents-one-local-model-lazymate-sellermate-for-slow-and-steady-4h8m"},{"title":"Hello DEV! 👋 Building AI Agents with Antigravity & DevRelay - DEV Community","url":"https://dev.to/atharvvanalakar/hello-dev-building-ai-agents-with-antigravity-devrelay-5dd6"},{"title":"DEV & MLH inside your coding agent","url":"https://devrelay.com/"},{"title":"Session","url":"https://learn.microsoft.com/en-us/agent-framework/concepts/agents/conversations/session"},{"title":"Liquid Session Variables","url":"https://support.storeconnect.com/articles/liquid-session-variables"}]
The search turned up community posts and DEV pages, but no official DevRelay documentation for Claude Code setup, so treat the details below as unverified.
- **Embed syntax:** Community posts show the tag as `{% agent_session id_or_slug %}`, and one post notes the numeric ID also works. Yard Sloth explains saving via Cursor + DevRelay (submit_agent_session), an optional CLI (devrelay sessions submit), and embedding on DEV with {% agent_session id_or_slug %}. A Quill post shows a slug-based tag and the numeric form.
- **Auth:** One post notes that MLH/DevRelay auth is required and that secrets should be scrubbed before upload.
- **What DevRelay is:** DevRelay is a local MCP server plus a set of skills. The server talks to DEV and MLH for your agent, and the skills tell your agent when to use it. Its page lists saving an agent transcript to DEV and embedding it in a post as a feature.
- **Claude Code specifically:** DEV's beta guide is a separate, non-DevRelay route. It locates Claude Code transcripts in ~/.claude/projects/ as the most recently modified .jsonl file and uploads them to DEV's agent_sessions API with a DEV API key. I did not find a DevRelay-specific Claude Code install guide.
- **Challenge context:** The Hacktoberfest Week 3 page says that saving your agent session with DevRelay and embedding it in your post, or linking to it, is optional, but it helps the judges understand your process.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
{"url":"https://dev.to/devteam/share-embed-and-curate-agent-sessions-on-dev-beta-5bj6","prompt":"Give the exact steps and the exact API request (endpoint, method, headers, body fields, file format) to...
The page doesn't document an embed liquid tag, so I can't give that syntax. It also doesn't include a full upload script. I've summarized the API details and quoted only short fragments.
**File to upload**
- Claude Code transcripts are under `~/.claude/projects/`, searched recursively.
- The uploader targets the most recently modified transcript file, which is a `.jsonl` file.
**API request**
- **Method and endpoint:** POST to `/api/agent_sessions` on the DEV host, which defaults to https://dev.to.
- **Headers:**
- `Content-Type: application/json`
- `Accept: application/vnd.forem.api-v1+json`
- `api-key: <your API key>`
- **Body fields (JSON):**
- `title`: the session title.
- `tool_name`: the tool identifier. The page says `"auto"` makes the server detect the tool from the transcript content.
- `body`: the transcript file's contents as a string.
- **Success:** The example checks for HTTP 201. The response JSON includes a `url` or `slug`.
- **API key:** Generate one at `https://dev.to/settings/extensions`. The example stores `DEVTO_API_KEY` and `DEVTO_HOST` in `~/.config/devto/config`.
A minimal sketch of the request, written from the field descriptions above:
```bash
jq -n --arg title "My session" --arg tool_name "auto" \
--rawfile body "transcript.jsonl" \
'{title: $title, tool_name: $tool_name, body: $body}' > request.json
curl -X POST "https://dev.to/api/agent_sessions" \
-H "Content-Type: application/json" \
-H "Accept: application/vnd.forem.api-v1+json" \
-H "api-key: $DEVTO_API_KEY" \
-d @request.json
```
**Curating and scrubbing**
- Upload by dragging and dropping a transcript on the Agent Sessions page (`dev.to/agent_sessions/new`).
- Curate by mass enabling or disabling pieces of the transcript. The result is what gets saved for a whole-session embed.
- Slice to create sub-transcripts from any part of a session. A session can have multiple slices.
- Click Save & Upload to save the curated result.
- Uploads are unlisted by default. The "Make Public" button shares them publicly.
- The parser has a redaction layer, but the page warns that it won't catch every case. Review transcripts for secrets before uploading.
DEV accepts Claude Code transcripts directly. I'm finding this session's transcript and making a copy with personal and secret data removed, so you can upload it.
$ ls -la ~/.claude/projects/ 2>/dev/null; find ~/.claude/projects -name "*.jsonl" -printf "%T@ %s %p\n" 2>/dev/null | sort -n | tail -5
total 12
drwx------ 3 root root 4096 Oct 25 08:37 -home-user-unread
drwx------ 3 root root 4096 Oct 25 08:37 .
drwxr-xr-x 11 root root 4096 Oct 26 06:04 ..
1792994703.9382506630 20674951 /root/.claude/projects/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67.jsonl
$ cd /tmp/claude-0/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67/scratchpad && mkdir -p agent-session && cat > agent-session/scrub.py <<'EOF'
import json, re, sys
src, dst = sys.argv[1], sys.argv[2]
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[redacted]\.com")
SECRETS = [
(re.compile(r"\b(sk|pk|rk|whsec)_(live|test|sandbox)?_?[A-Za-z0-9]{16,}"), "[redacted-key]"),
(re.compile(r"\beyJ[A-Za-z0-9_-]{30,}(\.[A-Za-z0-9_-]+)*"), "[redacted-token]"),
(re.compile(r"(xi-api-key:\s*)[A-Za-z0-9]{20,}"), r"\1[redacted]"),
(re.compile(r"(api-key:\s*)[A-Za-z0-9]{20,}"), r"\1[redacted]"),
(re.compile(r"payload=[A-Za-z0-9%_=-]+"), "payload=[redacted]"),
]
stats = {"images": 0, "truncated": 0, "emails": 0, "secrets": 0}
def clean_str(s):
s2, n = EMAIL.subn("[redacted-email]", s); stats["emails"] += n
for rx, rep in SECRETS:
s2, n = rx.subn(rep, s2); stats["secrets"] += n
if len(s2) > 12000:
stats["truncated"] += 1
s2 = s2[:6000] + f"\n… [{len(s2) - 9000} characters trimmed for the upload] …\n" + s2[-3000:]
return s2
def walk(x):
if isinstance(x, dict):
if x.get("type") == "image" or (isinstance(x.get("source"), dict) and x["source"].get("type") == "base64"):
stats["images"] += 1
return {"type": "text", "text": "[image omitted from the upload]"}
return {k: walk(v) for k, v in x.items()}
if isinstance(x, list):
return [walk(v) for v in x]
if isinstance(x, str):
return clean_str(x)
return x
n = 0
with open(src) as f, open(dst, "w") as out:
for line in f:
line = line.strip()
if not line:
continue
out.write(json.dumps(walk(json.loads(line)), ensure_ascii=False) + "\n"); n += 1
print(n, "lines", stats)
EOF
python3 agent-session/scrub.py /root/.claude/projects/-home-user-unread/aaac30b9-cf70-59b4-a860-98bfeb8e2c67.jsonl agent-session/unread-video-and-writeup-session.jsonl && ls -la agent-session; grep -c "[redacted]" agent-session/unread-video-and-writeup-session.jsonl; grep -oE "ELEVENLABS_API_KEY=[^ \"]{6,}" agent-session/*.jsonl | head -2; grep -oiE "a478" agent-session/*.jsonl | wc -l
I built Unread with an AI coding agent, spec first: a product spec (docs/prd-unread.md) with a build order and a cut line, a CLAUDE.md with the code standards, and a decision record for every third-party choice. The agent built milestone by milestone, U0 to U5: repo and auth, inbound email, the script pipeline, voice and feed, UI, then plans, Temporal and deploy. It checked each one against real models and real ffmpeg before moving on.
The Lazy Payoff
- Setup: one Gmail filter, once. About two minutes.
- Last week in my inbox: 6 new issues from two newsletters between Oct 19 and Oct 23. Four came from Neo Kim, on networking and AI engineering, and two from Gregor, on using AI at work and communicating your impact. At their own read times, that's ✏️ 55 minutes of reading I was never going to do.
- What Unread made from it: ✏️ one 10-minute episode, 6 chapters, heard on the way to the office.
- Effort after setup: zero. The filter forwards, the schedule makes the episode, and my podcast app downloads it.
- What it costs to make: $0 in voice on Free (CPU time only); about 36 cents for a 10-minute episode on ElevenLabs Flash.
My inbox still says 124 unread. I'm caught up anyway.
Prize Categories
- Best Use of Gemma. Gemma 4 31B makes every editorial call: borderline same-story pairs, ranking, every cited chapter script, and repairing lines that fail the fact check. EmbeddingGemma runs locally on CPU for clustering and "already heard".
-
Best Use of Render. The web app (Node,
getunread.site, migrations as a pre-deploy step) and the Docker background worker that runs EmbeddingGemma, Kokoro and ffmpeg. -
Best Use of ElevenLabs. Studio (
eleven_v4) and Standard (eleven_flash_v2_5) voices for Plus, with request stitching and per-line caching. The demo video's narration and the episode host are ElevenLabs too. -
Best Use of Tiger Data. Postgres with pgvector (
vector(768), cosine HNSW index) for same-story clustering and "already heard", and a TimescaleDB hypertable of listening events whose skip rates feed the ranking. -
Best Use of Temporal.
makeEpisodeWorkflowwith idempotent activities, heartbeats on voice, a 10-minute dispatch Schedule and an hourly maintenance Schedule, tested with time skipping. - Best Use of SerpApi. Opt-in news: pick topics and your country's edition, and Unread adds a news segment (always, or only on quiet days) from Google News results from the last 36 hours.
-
Best Use of Auth0 for AI Agents. Sign-in, with
requireUser()checked in every page and route handler; the sign-in email becomes the first verified sender.






Top comments (0)