diff --git a/.claude/skills/release-screenshot/SKILL.md b/.claude/skills/release-screenshot/SKILL.md
new file mode 100644
index 0000000..b97e019
--- /dev/null
+++ b/.claude/skills/release-screenshot/SKILL.md
@@ -0,0 +1,60 @@
+---
+name: release-screenshot
+description: Use when cutting a FeedZero landing-page release (the /release flow, or any time `screenshot.png` needs to be refreshed) to regenerate the marketing hero image with the latest app build.
+---
+
+# Release Screenshot
+
+## When to use
+
+- Cutting a new release in `feedzero-landing` (typically as part of `/release`).
+- The hero `screenshot.png` looks stale because the app's UI has shifted (sidebar layout, reader pane, etc.).
+- Manually requested ("retake the screenshot").
+
+If the only change is copy or release notes, you don't need to regenerate the screenshot.
+
+## What it produces
+
+`/home/DeadEye3164/builder/feedzero-landing/screenshot.png`, a 1440×900 capture of the app showing:
+
+- A populated sidebar: 4 folders (News, Technology, Design, Science) + 2 unfiled feeds, 20 feeds total.
+- A populated article list pane (Ars Technica selected).
+- A featured article ("Inside the quiet revolution rewriting silicon") in the reader pane with an Unsplash hero image, figcaption, and body copy.
+- No "feeds stored locally" warning, no changelog dialog, no onboarding banner.
+
+## How to run
+
+From `/home/DeadEye3164/builder/feedzero-landing`:
+
+```sh
+node take-screenshot.mjs --scene landing
+```
+
+That auto-starts a Vite dev server on `:3001` against the sister repo at `../feedzero`, seeds the encrypted IndexedDB directly via `initFresh()` + `db.addFolder/addFeed/addArticles`, opens the featured article URL, and writes `screenshot.png`.
+
+If a dev server is already running, pass `--url http://localhost:PORT` to skip the spawn step.
+
+## Verification (before claiming done)
+
+1. Confirm the file changed: `git diff --stat screenshot.png` should show a modification.
+2. **Look at the image.** Use the Read tool on `screenshot.png` to view it. The hero image must have rendered (Unsplash CDN occasionally times out — re-run if the right pane looks blank).
+3. Confirm there's no amber "Your feeds are stored locally" banner in the bottom-left of the sidebar.
+
+## Things that commonly go wrong
+
+| Symptom | Cause / fix |
+|---------|-------------|
+| Screenshot taken before the hero image decoded | The script waits 2.5s after `figure img` appears; bump the timeout in `screenshotLanding` if Unsplash is slow that day |
+| `initFresh failed` / module import error | The sister repo `../feedzero` is missing or out of date. Check `ls ../feedzero/src/core/storage/key-manager.ts` exists |
+| Sidebar layout shifted, screenshot looks wrong | The app's component structure changed. Update the seed data or selectors in `screenshotLanding` |
+| Cloud-sync warning re-appears | `feedzero:local-warning-dismissed` localStorage key was renamed in the app — search `app-sidebar.tsx` for the new key and update the seed |
+
+## After regenerating
+
+The screenshot lives in the landing repo and ships with the deploy. Commit it alongside the release-notes update for the cut:
+
+```sh
+git add screenshot.png releases.mjs releases.xml releases/index.html
+```
+
+(`releases.xml` and `releases/index.html` come from `node build-releases.mjs` — the regular release flow handles those.)
diff --git a/docs/self-hosting/index.html b/docs/self-hosting/index.html
index 1ddec6b..20d9166 100644
--- a/docs/self-hosting/index.html
+++ b/docs/self-hosting/index.html
@@ -38,11 +38,15 @@
+ HTTPS is non-negotiable. FeedZero encrypts your data at rest with the browser's Web Crypto API, which browsers gate behind a secure context: HTTPS, or http://localhost. Plain http://<lan-ip>:3000 will refuse to start with a clear error pointing here. Put Caddy or nginx with a TLS cert in front of the server before opening it. The app detects this and tells you what to do — but save yourself the round-trip and set up TLS first.
+
+
What you get
FeedZero is MIT-licensed. A self-hosted deployment unlocks every shipped feature. There is no license check, no kill switch, no telemetry. Features still in development stay locked until they ship, and they reach self-hosters the same day they reach hosted users.
VITE_SELF_HOSTED=1 # build-time master switch
+SELF_HOSTED=1 # runtime mirror (server reads this)
SYNC_STORAGE=filesystem # or "vercel-blob" / "upstash" if you want HA
+
VITE_SELF_HOSTED=1 + SELF_HOSTED=1 are now the only two flags you need. The legacy VITE_PAID_TIER_VISIBLE and LAUNCH_PAID_TIER are forced off by the master switch — you can leave them unset.
Deploy. Vercel builds and serves. Open the deployment URL.
Add your domain in the Vercel project settings if you have one. Otherwise the vercel.app subdomain works.
@@ -82,7 +86,7 @@
Build-time flags (set before npm run build:all)
Variable
What it does
VITE_SELF_HOSTED
Set to 1 to bypass tier gates. Every shipped feature is treated as unlocked regardless of stored license tier. This is the flag that distinguishes "self-hosted" from "hosted dev mode".
-
VITE_PAID_TIER_VISIBLE
Leave unset for self-hosted. Setting it would show Subscribe buttons and pricing widgets, which is wrong for a self-hosted deployment where there is nothing to subscribe to.
+
VITE_PAID_TIER_VISIBLE
Ignored when VITE_SELF_HOSTED=1. The master switch wins. Subscribe / pricing UI is hidden regardless.
@@ -90,7 +94,9 @@
Runtime flags (set on the server)
Variable
What it does
-
LAUNCH_PAID_TIER
Leave unset. Setting it would cause /api/sync to reject requests without a valid bearer token, which is pointless for self-hosters who have no license server.
+
SELF_HOSTED
Set to 1. Runtime mirror of the build-time VITE_SELF_HOSTED flag. Tells the server to (a) force the paid-tier API enforcement off regardless of LAUNCH_PAID_TIER and (b) send a browser-like User-Agent to upstream feeds. Set this in your service environment, not just .env.production — the standalone Hono server reads it from process.env at runtime.
+
FEED_USER_AGENT
Optional. Overrides the User-Agent the proxy sends to upstream feeds. Useful if you'd rather identify your instance honestly with contact info (e.g. "MyReader/1.0 (+https://example.com/contact)"). When unset, self-hosters get a modern Firefox UA by default to avoid Cloudflare-class WAFs blocking the FeedZero identifier (see #97).
+
LAUNCH_PAID_TIER
Ignored when SELF_HOSTED=1. The master switch wins.
SYNC_STORAGE
Picks the sync-vault adapter: filesystem (default, fine for a single instance), vercel-blob (needs BLOB_READ_WRITE_TOKEN), upstash (needs UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN), or memory (dev only; vault evaporates on restart).
GITHUB_FEEDBACK_TOKEN + GITHUB_REPO
Optional. Routes the in-app feedback form to GitHub Issues. Without it, the feedback button stays inactive.
@@ -100,12 +106,50 @@
Building from source (without Vercel)
If you would rather not use Vercel, FeedZero ships a standalone Hono server that serves the SPA and API routes in one process.
git clone https://github.com/forcingfx/feedzero.git
cd feedzero
-echo "VITE_SELF_HOSTED=1" >> .env.production
+echo "VITE_SELF_HOSTED=1" > .env.production
+echo "SELF_HOSTED=1" >> .env.production
npm install
npm run build:all
-npm run serve # binds to PORT (default 3000)
+SELF_HOSTED=1 npm run serve # binds to PORT (default 3000)
Reverse-proxy that with nginx, Caddy, or your container platform of choice. The Hono server is plain Node and runs anywhere Node runs.
+
Minimal Caddyfile
+
If you have a domain pointing at the host, this is the entire reverse-proxy setup. Caddy obtains and renews a Let's Encrypt cert automatically, which solves the secure-context requirement above.
The app loaded over plain HTTP from a non-localhost origin. Browsers won't expose the Web Crypto API in that context. Fixes, in order of preference: put TLS in front of the server (Caddyfile above), browse via http://localhost:3000 from the host itself, or trust a self-signed cert for the LAN address.
+
+
Some feeds work on my.feedzero.app but fail on my instance
+
Most commonly: the upstream is rate-limiting your IP (HTTP 429) or blocking it outright (403). The hosted deployment shares Vercel infrastructure IPs with known reputation; a fresh self-host IP doesn't have that. The app now tells you which one is happening — "Upstream rate-limited" vs "Upstream blocked" vs "No RSS feed found at this URL" — instead of conflating them. Three things help:
+
+
The browser-like User-Agent default (automatic when SELF_HOSTED=1) reduces UA-based WAF blocks.
+
Set FEED_USER_AGENT to a custom identifier with contact info. Some operators allow-list specific UAs.
+
For specific feeds that consistently 403, copy the RSS link directly into the "Add feed" box rather than the homepage URL — the underlying feed often has different access rules than the site.
+
+
+
"No cloud vault was found for this passphrase"
+
Two real causes: (a) this is your first device — the passphrase has never been pushed yet, so push from this device first; or (b) you mistyped the passphrase on a restore. Word order and spelling both matter.
+
+
Sync across two devices on my LAN isn't working
+
Both devices must load the app from the same origin (same hostname, same scheme). Cross-device sync works by deriving a vault ID from the passphrase and looking it up on the server — that lookup is per-origin. If device A loads from https://feedzero.lan and device B from http://192.168.1.42:3000, they're operating in different storage origins and never see each other's data. Pick one canonical URL, pin DNS to it, and use it everywhere.
+
+
"Subscribe" UI is showing even though I'm self-hosted
+
You're on a build that didn't pick up VITE_SELF_HOSTED=1. The flag is build-time — rebuild after changing it. Confirm: cat .env.production shows the line, then npm run build:all and reload. As of the master-switch change, VITE_SELF_HOSTED=1 alone is sufficient — you don't need to also set VITE_PAID_TIER_VISIBLE or LAUNCH_PAID_TIER.
+
+
What you give up vs. the hosted deployment
+
Self-hosting is supported. It is not magical. Honest list of what changes:
+
+
Upstream rate-limiting smoothing. The hosted deployment uses Upstash to cap each client to 300 req/60s, which keeps you below most upstream rate-limit windows. Self-host has no limiter; bursts can trigger 429s the hosted deployment wouldn't.
+
IP reputation. Vercel's IPs are seen by upstreams more often than yours. Fresh datacenter/residential IPs face stricter WAF treatment.
+
Automatic TLS. Your reverse proxy provides it. Caddy is the path of least resistance.
+
Managed storage backups. Filesystem adapter writes to data/. Back it up yourself.
+
The Stats page popular-feeds widget. Centralized catalog backend; configure Upstash to enable.
+
+
Sync on a self-hosted server
Sync is end-to-end encrypted. Your passphrase derives a vault ID and an AES-GCM key on the client. The server only stores ciphertext. The same architecture that keeps hosted users private from us also means you do not need to trust your own server with plaintext. It just needs to hold opaque bytes.
Pick a sync adapter:
diff --git a/index.html b/index.html
index 3257161..4391779 100644
--- a/index.html
+++ b/index.html
@@ -44,7 +44,7 @@
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
"description": "An open source RSS reader that runs in your browser. Reads RSS, Atom, and JSON Feed. End-to-end encrypted sync across devices.",
"screenshot": "https://feedzero.app/screenshot.png",
- "softwareVersion": "0.8.2",
+ "softwareVersion": "0.9.0",
"author": { "@type": "Person", "name": "Arjun Muralidharan" },
"license": "https://github.com/forcingfx/feedzero/blob/main/LICENSE",
"downloadUrl": "https://my.feedzero.app",
@@ -861,7 +861,7 @@
About
To read across devices, a $5/month subscription syncs an encrypted vault from one browser to the next. A four-word passphrase is the only key. The server holds ciphertext and nothing else.
- Speed comes from caching feeds in memory and from keyboard shortcuts that cover everything you do. No loading screens between articles. Open source under the MIT license. Currently in alpha (v0.8.2).
+ Speed comes from caching feeds in memory and from keyboard shortcuts that cover everything you do. No loading screens between articles. Open source under the MIT license. Currently in alpha (v0.9.0).
@@ -918,7 +918,7 @@
Fast. Keyboard-driven.
Subscriptions
Bring your subscriptions. Take them with you.
-
Coming from another reader? Import your list. Leaving? Export it. Your subscriptions belong to you, not us.
+
Coming from another reader? Import your list, with folders preserved. Leaving? Export it the same way. Your subscriptions belong to you, not us.
@@ -943,7 +943,7 @@
Bring your subscriptions. Take them with you.
Sync
End-to-end encrypted sync.
-
Subscribe and pick a four-word passphrase. That passphrase is the only thing you need on a new device. Encryption happens in the browser. The server stores ciphertext it cannot read.
+
Subscribe and save the four-word passphrase the app generates for you. That passphrase is the only thing you need on a new device. Encryption happens in the browser. The server stores ciphertext it cannot read.
@@ -1143,7 +1143,46 @@
Release notes
-
+
+
+ v0.9.0
+ Settings unification, log in for existing license holders, OPML folders
+
+
+
+
The sidebar's settings dropdown collapsed into a single button opening a unified five-tab dialog. License holders can now log in to a fresh device via a two-step wizard. OPML import and export preserve folder organization.
+
+
Added
+
+
Added a Log in wizard for license holders on a fresh device. Settings → Account → Already have a FeedZero account opens a two-step ceremony: paste the license token, then optionally enter the sync passphrase to decrypt cloud data. The wizard surfaces alongside the Subscribe call to action so paying users do not accidentally re-purchase.
+
Added five Settings tabs (Account, Reading, Help, Import, Export) reachable from a single Settings button in the sidebar that replaces the previous dropdown menu. Cmd/Ctrl+, opens the same dialog.
+
Added OPML folder preservation. Importing an OPML from Feedly, NetNewsWire, or Inoreader now recreates the parent outline groups as folders and assigns each feed accordingly. Export round-trips the same structure.
+
Added a Reading tab inside Settings that exposes the article-flood grouping toggle and the Auto-organize launcher.
+
Added a Help tab inside Settings with inline keyboard shortcuts, a Send feedback button, and a What's new link.
+
+
+
+
Changed
+
+
Consolidated every in-app upgrade button into a single chokepoint. Clicking Upgrade anywhere (sidebar feed-limit notice, Auto-organize gate, feature gates) opens Settings → Account on the Plan card. Stripe Checkout is reachable only from the Plan card's Subscribe buttons, so users always see the tier comparison before committing.
+
Removed the floating tier pill (Free, Personal, Pro) from the sidebar. It was not clickable and gave a false signal that something there was actionable. The current tier remains visible inside Settings → Account.
+
Replaced the layered safety controls in Settings → Account with a smaller If-you-lose-access card that shows the support email, an Email-my-license-to-me button, and an Open-recovery-page link. The downloadable plain-text recovery sheet was dropped as redundant with the email-self action.
+
Widened the desktop sidebar default from 14rem to 18rem so feed titles have room to breathe.
+
Raised the desktop and mobile breakpoint from 768px to 1024px. The three-panel reading layout is fundamentally a laptop-and-up layout; below 1024px the mobile snap-scroll layout activates cleanly.
+
+
+
+
Fixed
+
+
Fixed the sidebar visibly changing width every time the user navigated between the feeds list, Explore, and Stats. The cause was per-route resizable-panel-group identifiers, which the library persists separately. A single stable identifier across all routes preserves the user-dragged width regardless of navigation.
+
Fixed the canvas going blank when the browser window was resized between roughly 530 and 767 pixels wide. The desktop layout's panel minimum sizes summed to ~530 pixels but the mobile fallback only kicked in below 768 pixels; the gap left panels clipped by overflow:hidden. Raising the breakpoint to 1024 pixels eliminates the dead zone.
+
Fixed the sync passphrase reveal overflowing the dialog when the passphrase wrapped long. The container now wraps and scrolls if needed.
+
Fixed paid users being able to click Delete all data and orphan their Stripe subscription. The Danger Zone now shows Cancel subscription first with a Manage subscription button for paid users; free users see the existing destructive flow.
+
+
+
+
+ v0.8.2Article flood grouping, cloud restore, and sync race fix
diff --git a/releases.mjs b/releases.mjs
index 29fcaf1..a84b583 100644
--- a/releases.mjs
+++ b/releases.mjs
@@ -27,6 +27,32 @@
* Each is an array of plain strings. Omit empty sections entirely.
*/
export const releases = [
+ {
+ version: "0.9.0",
+ date: "2026-05-17T12:00:00Z",
+ title: "Settings unification, log in for existing license holders, OPML folders",
+ subtitle: "The sidebar's settings dropdown collapsed into a single button opening a unified five-tab dialog. License holders can now log in to a fresh device via a two-step wizard. OPML import and export preserve folder organization.",
+ added: [
+ "Added a Log in wizard for license holders on a fresh device. Settings → Account → Already have a FeedZero account opens a two-step ceremony: paste the license token, then optionally enter the sync passphrase to decrypt cloud data. The wizard surfaces alongside the Subscribe call to action so paying users do not accidentally re-purchase.",
+ "Added five Settings tabs (Account, Reading, Help, Import, Export) reachable from a single Settings button in the sidebar that replaces the previous dropdown menu. Cmd/Ctrl+, opens the same dialog.",
+ "Added OPML folder preservation. Importing an OPML from Feedly, NetNewsWire, or Inoreader now recreates the parent outline groups as folders and assigns each feed accordingly. Export round-trips the same structure.",
+ "Added a Reading tab inside Settings that exposes the article-flood grouping toggle and the Auto-organize launcher.",
+ "Added a Help tab inside Settings with inline keyboard shortcuts, a Send feedback button, and a What's new link.",
+ ],
+ changed: [
+ "Consolidated every in-app upgrade button into a single chokepoint. Clicking Upgrade anywhere (sidebar feed-limit notice, Auto-organize gate, feature gates) opens Settings → Account on the Plan card. Stripe Checkout is reachable only from the Plan card's Subscribe buttons, so users always see the tier comparison before committing.",
+ "Removed the floating tier pill (Free, Personal, Pro) from the sidebar. It was not clickable and gave a false signal that something there was actionable. The current tier remains visible inside Settings → Account.",
+ "Replaced the layered safety controls in Settings → Account with a smaller If-you-lose-access card that shows the support email, an Email-my-license-to-me button, and an Open-recovery-page link. The downloadable plain-text recovery sheet was dropped as redundant with the email-self action.",
+ "Widened the desktop sidebar default from 14rem to 18rem so feed titles have room to breathe.",
+ "Raised the desktop and mobile breakpoint from 768px to 1024px. The three-panel reading layout is fundamentally a laptop-and-up layout; below 1024px the mobile snap-scroll layout activates cleanly.",
+ ],
+ fixed: [
+ "Fixed the sidebar visibly changing width every time the user navigated between the feeds list, Explore, and Stats. The cause was per-route resizable-panel-group identifiers, which the library persists separately. A single stable identifier across all routes preserves the user-dragged width regardless of navigation.",
+ "Fixed the canvas going blank when the browser window was resized between roughly 530 and 767 pixels wide. The desktop layout's panel minimum sizes summed to ~530 pixels but the mobile fallback only kicked in below 768 pixels; the gap left panels clipped by overflow:hidden. Raising the breakpoint to 1024 pixels eliminates the dead zone.",
+ "Fixed the sync passphrase reveal overflowing the dialog when the passphrase wrapped long. The container now wraps and scrolls if needed.",
+ "Fixed paid users being able to click Delete all data and orphan their Stripe subscription. The Danger Zone now shows Cancel subscription first with a Manage subscription button for paid users; free users see the existing destructive flow.",
+ ],
+ },
{
version: "0.8.2",
date: "2026-05-15T12:00:00Z",
diff --git a/releases.xml b/releases.xml
index c2a35fc..282b3ad 100644
--- a/releases.xml
+++ b/releases.xml
@@ -3,12 +3,45 @@
FeedZero Release NotesWhat changed in FeedZero.feedzero:changelog
- 2026-05-15T12:00:00Z
+ 2026-05-17T12:00:00ZFeedZero
+
+ feedzero:release:0.9.0
+ v0.9.0: Settings unification, log in for existing license holders, OPML folders
+
+ 2026-05-17T12:00:00Z
+ 2026-05-17T12:00:00Z
+ The sidebar's settings dropdown collapsed into a single button opening a unified five-tab dialog. License holders can now log in to a fresh device via a two-step wizard. OPML import and export preserve folder organization.
+ The sidebar's settings dropdown collapsed into a single button opening a unified five-tab dialog. License holders can now log in to a fresh device via a two-step wizard. OPML import and export preserve folder organization.
+
Added
+
+
Added a Log in wizard for license holders on a fresh device. Settings → Account → Already have a FeedZero account opens a two-step ceremony: paste the license token, then optionally enter the sync passphrase to decrypt cloud data. The wizard surfaces alongside the Subscribe call to action so paying users do not accidentally re-purchase.
+
Added five Settings tabs (Account, Reading, Help, Import, Export) reachable from a single Settings button in the sidebar that replaces the previous dropdown menu. Cmd/Ctrl+, opens the same dialog.
+
Added OPML folder preservation. Importing an OPML from Feedly, NetNewsWire, or Inoreader now recreates the parent outline groups as folders and assigns each feed accordingly. Export round-trips the same structure.
+
Added a Reading tab inside Settings that exposes the article-flood grouping toggle and the Auto-organize launcher.
+
Added a Help tab inside Settings with inline keyboard shortcuts, a Send feedback button, and a What's new link.
+
+
Changed
+
+
Consolidated every in-app upgrade button into a single chokepoint. Clicking Upgrade anywhere (sidebar feed-limit notice, Auto-organize gate, feature gates) opens Settings → Account on the Plan card. Stripe Checkout is reachable only from the Plan card's Subscribe buttons, so users always see the tier comparison before committing.
+
Removed the floating tier pill (Free, Personal, Pro) from the sidebar. It was not clickable and gave a false signal that something there was actionable. The current tier remains visible inside Settings → Account.
+
Replaced the layered safety controls in Settings → Account with a smaller If-you-lose-access card that shows the support email, an Email-my-license-to-me button, and an Open-recovery-page link. The downloadable plain-text recovery sheet was dropped as redundant with the email-self action.
+
Widened the desktop sidebar default from 14rem to 18rem so feed titles have room to breathe.
+
Raised the desktop and mobile breakpoint from 768px to 1024px. The three-panel reading layout is fundamentally a laptop-and-up layout; below 1024px the mobile snap-scroll layout activates cleanly.
+
+
Fixed
+
+
Fixed the sidebar visibly changing width every time the user navigated between the feeds list, Explore, and Stats. The cause was per-route resizable-panel-group identifiers, which the library persists separately. A single stable identifier across all routes preserves the user-dragged width regardless of navigation.
+
Fixed the canvas going blank when the browser window was resized between roughly 530 and 767 pixels wide. The desktop layout's panel minimum sizes summed to ~530 pixels but the mobile fallback only kicked in below 768 pixels; the gap left panels clipped by overflow:hidden. Raising the breakpoint to 1024 pixels eliminates the dead zone.
+
Fixed the sync passphrase reveal overflowing the dialog when the passphrase wrapped long. The container now wraps and scrolls if needed.
+
Fixed paid users being able to click Delete all data and orphan their Stripe subscription. The Danger Zone now shows Cancel subscription first with a Manage subscription button for paid users; free users see the existing destructive flow.
+
]]>
+ FeedZero
+ feedzero:release:0.8.2v0.8.2: Article flood grouping, cloud restore, and sync race fix
diff --git a/screenshot.png b/screenshot.png
index 4fc338e..ff113d5 100644
Binary files a/screenshot.png and b/screenshot.png differ
diff --git a/take-screenshot.mjs b/take-screenshot.mjs
index 3c76f88..c9378ee 100644
--- a/take-screenshot.mjs
+++ b/take-screenshot.mjs
@@ -6,7 +6,7 @@
* Usage:
* node take-screenshot.mjs # starts dev server, takes screenshot, stops server
* node take-screenshot.mjs --url URL # uses an already-running instance
- * node take-screenshot.mjs --scene X # "explore" (default) or "feeds"
+ * node take-screenshot.mjs --scene X # "explore" (default), "feeds", or "landing"
*
* Requires: playwright (installed in ../feedzero/node_modules)
*/
@@ -146,6 +146,201 @@ async function screenshotFeeds(page, baseUrl) {
await page.screenshot({ path: OUTPUT });
}
+/**
+ * Seeds 4 folders, 20 feeds, and articles by directly invoking the app's
+ * own storage modules through Vite's module graph. The featured article
+ * gets a hero image so the resulting screenshot looks like a real reading
+ * session instead of an empty shell.
+ */
+async function screenshotLanding(page, baseUrl) {
+ await page.goto(`${baseUrl}/`);
+ // Wait for Vite to have loaded the app shell so /src/* dynamic imports resolve.
+ await page.waitForFunction(() => !!document.querySelector("#root"));
+ await page.waitForTimeout(500);
+
+ const featured = await page.evaluate(async () => {
+ const km = await import("/src/core/storage/key-manager.ts");
+ const db = await import("/src/core/storage/db.ts");
+
+ const init = await km.initFresh("screenshot-fixture", { sync: false, skipServerCleanup: true });
+ if (!init.ok) throw new Error("initFresh failed: " + init.error);
+
+ const now = Date.now();
+ const day = 86_400_000;
+
+ const folders = [
+ { id: crypto.randomUUID(), name: "News", color: "#0ea5e9", createdAt: now },
+ { id: crypto.randomUUID(), name: "Technology", color: "#8b5cf6", createdAt: now },
+ { id: crypto.randomUUID(), name: "Design", color: "#f59e0b", createdAt: now },
+ { id: crypto.randomUUID(), name: "Science", color: "#10b981", createdAt: now },
+ ];
+ for (const f of folders) {
+ const r = await db.addFolder(f);
+ if (!r.ok) throw new Error("addFolder failed: " + r.error);
+ }
+ const [news, tech, design, science] = folders;
+
+ const feedDefs = [
+ // News (4)
+ { title: "Reuters", site: "https://reuters.com", folderId: news.id },
+ { title: "BBC News", site: "https://bbc.com/news", folderId: news.id },
+ { title: "NPR", site: "https://npr.org", folderId: news.id },
+ { title: "The Guardian", site: "https://theguardian.com", folderId: news.id },
+ // Technology (6)
+ { title: "Ars Technica", site: "https://arstechnica.com", folderId: tech.id },
+ { title: "Hacker News", site: "https://news.ycombinator.com", folderId: tech.id },
+ { title: "The Verge", site: "https://theverge.com", folderId: tech.id },
+ { title: "Wired", site: "https://wired.com", folderId: tech.id },
+ { title: "MIT Technology Review", site: "https://technologyreview.com",folderId: tech.id },
+ { title: "TechCrunch", site: "https://techcrunch.com", folderId: tech.id },
+ // Design (4)
+ { title: "Smashing Magazine", site: "https://smashingmagazine.com", folderId: design.id },
+ { title: "A List Apart", site: "https://alistapart.com", folderId: design.id },
+ { title: "CSS-Tricks", site: "https://css-tricks.com", folderId: design.id },
+ { title: "Nielsen Norman Group", site: "https://nngroup.com", folderId: design.id },
+ // Science (4)
+ { title: "Quanta Magazine", site: "https://quantamagazine.org", folderId: science.id },
+ { title: "Nature", site: "https://nature.com", folderId: science.id },
+ { title: "Scientific American",site: "https://scientificamerican.com", folderId: science.id },
+ { title: "Phys.org", site: "https://phys.org", folderId: science.id },
+ // Unfiled (2) — total 20
+ { title: "xkcd", site: "https://xkcd.com", folderId: undefined },
+ { title: "Pitchfork", site: "https://pitchfork.com", folderId: undefined },
+ ];
+
+ const feeds = feedDefs.map((f, i) => ({
+ id: crypto.randomUUID(),
+ url: f.site + "/rss",
+ title: f.title,
+ description: "",
+ siteUrl: f.site,
+ folderId: f.folderId,
+ createdAt: now - i * 1000,
+ updatedAt: now - i * 1000,
+ }));
+ for (const f of feeds) {
+ const r = await db.addFeed(f);
+ if (!r.ok) throw new Error("addFeed failed: " + r.error);
+ }
+
+ // Featured feed + article. Ars Technica gets a hero image so the right
+ // pane shows real-looking content with a picture, not just text.
+ const ars = feeds.find((f) => f.title === "Ars Technica");
+ const featuredArticleId = crypto.randomUUID();
+ const heroImg =
+ "https://images.unsplash.com/photo-1518770660439-4636190af475?w=1400&q=85&auto=format&fit=crop";
+
+ const featuredContent = `
+
+
+ The new chip stacks logic and memory in a single die.
+
+
The fab in Hsinchu has been quiet about its newest process node, but
+ engineers familiar with the line describe a manufacturing breakthrough
+ that could reshape how we build everyday computing devices for the next
+ decade.
+
By co-locating SRAM directly above the compute fabric, the design
+ avoids the long copper traces that dominate power budgets in modern
+ accelerators. Early benchmarks suggest a 40% reduction in idle draw
+ without compromising peak throughput.
+
Independent reviewers will not get their hands on silicon until late
+ next quarter, but if the published figures hold, the implications for
+ battery-powered devices are substantial.
+
What changed
+
The core innovation is not the transistor density — it is the
+ packaging. By thinning the memory die to under 25 microns and bonding
+ it directly to the logic substrate, the team eliminated an entire
+ hierarchy of off-chip caches.
+
This is the kind of incremental, deeply physical engineering work
+ that rarely makes headlines, and almost always reshapes the industry
+ in retrospect.
+ `;
+
+ const articles = [];
+ // 20+ articles spread across the featured feed and a few siblings, so
+ // the middle "article list" pane is populated and looks alive.
+ const featuredArticle = {
+ id: featuredArticleId,
+ feedId: ars.id,
+ guid: "featured",
+ title: "Inside the quiet revolution rewriting silicon",
+ link: ars.siteUrl + "/2026/05/silicon",
+ content: featuredContent,
+ summary: "A new packaging technique cuts idle power by 40% without sacrificing throughput.",
+ author: "Marian Keller",
+ publishedAt: now - 30 * 60_000,
+ read: false,
+ createdAt: now,
+ };
+ articles.push(featuredArticle);
+
+ const arsTitles = [
+ "Open-source RISC-V is finally hitting its stride",
+ "How a tiny capacitor change saved a satellite launch",
+ "What the new GPU pricing tells us about AI demand",
+ "The slow death of x86 in datacenter inference",
+ "Why your phone's modem still runs proprietary firmware",
+ ];
+ arsTitles.forEach((t, i) => {
+ articles.push({
+ id: crypto.randomUUID(),
+ feedId: ars.id,
+ guid: "ars-" + i,
+ title: t,
+ link: ars.siteUrl + "/2026/05/" + i,
+ content: "
" + t + ".
",
+ summary: t,
+ author: "Ars staff",
+ publishedAt: now - (i + 1) * 3 * 3600_000,
+ read: i >= 3,
+ createdAt: now,
+ });
+ });
+
+ // Sprinkle a handful of articles on other feeds so unread counts show.
+ const others = feeds.filter((f) => f.id !== ars.id).slice(0, 14);
+ others.forEach((feed, fi) => {
+ const count = 1 + (fi % 4);
+ for (let i = 0; i < count; i++) {
+ articles.push({
+ id: crypto.randomUUID(),
+ feedId: feed.id,
+ guid: feed.id + "-" + i,
+ title: feed.title + ": story " + (i + 1),
+ link: feed.siteUrl + "/" + i,
+ content: "
Placeholder.
",
+ summary: "",
+ author: "",
+ publishedAt: now - (fi * 5 + i) * 7200_000 - day,
+ read: false,
+ createdAt: now,
+ });
+ }
+ });
+
+ const addRes = await db.addArticles(articles);
+ if (!addRes.ok) throw new Error("addArticles failed: " + addRes.error);
+
+ localStorage.setItem("feedzero:onboarding-complete", "true");
+ // Hide the "feeds are stored locally" warning that otherwise sits in the
+ // bottom-left of the sidebar — we want a clean marketing shot.
+ localStorage.setItem("feedzero:local-warning-dismissed", "true");
+
+ return { feedId: ars.id, articleId: featuredArticleId };
+ });
+
+ // Reload into the seeded state and open the featured article directly.
+ await page.goto(`${baseUrl}/feeds/${featured.feedId}/articles/${featured.articleId}`);
+
+ // Wait for the article body to render — the figure/img we injected confirms
+ // the reader pane has hydrated with the seeded content.
+ await page.waitForSelector("article img, figure img", { timeout: 15000 });
+ // Give the hero image a moment to actually decode.
+ await page.waitForTimeout(2500);
+
+ await page.screenshot({ path: OUTPUT });
+}
+
async function main() {
const { url, scene } = parseArgs();
let server = null;
@@ -167,6 +362,8 @@ async function main() {
if (scene === "feeds") {
await screenshotFeeds(page, baseUrl);
+ } else if (scene === "landing") {
+ await screenshotLanding(page, baseUrl);
} else {
await screenshotExplore(page, baseUrl);
}