Own your algorithm. On-device. 0% rented. A cozy, on-device recommender that re-ranks your Hacker News feed for YOU, tells you in plain words why every post rose or sank, learns when you talk to it, and exports as a file you own.
Sourdough is a framework-free TypeScript single-page app, built with Vite, that ships as an offline PWA.
The product is presented as a browser extension, so the app renders a faux-browser shell (Chrome chrome + a real Hacker News front page) with the Sourdough extension UI living inside it — the whole experience, exactly as a user would meet it, in one URL.
npm install # install dependencies
npm run dev # Vite dev server (hot reload) → http://localhost:5173
npm run build # type-check (tsc --noEmit) + production build → dist/
npm run preview # serve the production build → http://localhost:4173- For the truest experience (and the auditable
network: 0), usenpm run build && npm run preview. The dev server opens a hot-reload WebSocket, which the in-app network monitor would (correctly) count — production mode is a true zero. - No backend, no API keys, no environment variables. Everything runs in the browser.
- Requirements: Node 18+ and a Chromium/Firefox/WebKit browser. There is no APK: Sourdough is a web/PWA product, not a mobile app.
The web app is the reproducible showcase. Sourdough also ships as a real Manifest V3 browser extension that re-ranks your live Hacker News feed in place, on-device, using the exact same engine — so it's genuinely usable in a real setting, not just a sandbox.
npm run build:ext # → dist-ext/ (content script + manifest + popup + icons)Then load it: open chrome://extensions, turn on Developer mode, click Load unpacked, and
pick the dist-ext/ folder. Visit https://news.ycombinator.com — your real feed re-ranks with the
rising-dough animation, every post gets a plain-language "why", you can steer it, and the panel
reads network calls out: 0.
src/extension/hn-adapter.ts— reads the live HN DOM into the engine'sPostshape (real title, domain, points, comments, true age from the timestamp) and keeps row handles for reordering.src/extension/topics.ts— infers the interpretable topics + length for real posts (the bundled snapshot ships these pre-tagged; live posts don't).src/extension/content.ts— scores the real posts, reorders the real<tr>rows with the FLIP motion + glow + per-row "why" stamp, captures dwell (IntersectionObserver), and injects the panel; persists tochrome.storage(localStorage fallback).- Everything below
engine/is shared verbatim with the web app — the engine is the product; only the post source differs. (ranker.scoreAllnow takes the post set, defaulting to the bundled snapshot, so the demo is untouched.)
Every screen of the product is implemented and wired together into one continuous app:
| # | Surface | Where it lives | Source |
|---|---|---|---|
| S1 | Welcome (cozy front door, shown once) | auto-opens on first load | src/ui/welcome.ts |
| S2 | Starter panel (the ambient hub) | the in-page strip, right of the feed | src/ui/strip.ts |
| S3 | Feed re-rank (the money shot) | the Hacker News list | src/ui/feed.ts + src/ui/flip.ts |
| S4 | Why it rose / sank (glass box) | tap Why this? on any card | src/ui/why.ts |
| S5 | Steer + the moving weight | Steer button → overlay | src/ui/steer.ts |
| S6 | Forks (per-mood starters) | Fork button → overlay | src/ui/forks.ts |
| S7 | Own it & carry it (export/import) | Export button → overlay | src/ui/exportimport.ts |
| S8 | Toolbar popup (the calm remote) | the toolbar Sourdough icon | src/ui/popup.ts |
| S9 | Power-user settings | the panel ⚙ / popup Settings | src/ui/settings.ts |
The on/off master switch and Do-Not-Disturb quiet mode live in S2, S8 and S9.
One line, end to end — all client-side:
capture → embed (cached) → score (online LR, glass box) → explain (per-card "why") → render (FLIP)
The codebase is split into a pure engine (the brain — no DOM) and a thin ui layer (render functions over the engine). A single framework-free store is the source of truth; surfaces subscribe and re-render on change.
src/
├─ main.ts boot: install network monitor, mount app, open welcome once
├─ engine/ the brain — pure, synchronous, on-device, DOM-free
│ ├─ types.ts Post, Starter, Scored, Contribution, the 7 features, the 6 "glass" weights
│ ├─ snapshot.ts a bundled Hacker News front page (30 posts) — why network:0 is literally true
│ ├─ embed.ts on-device text embedder (256-d hashing-trick) + cosine/centroid/nudge
│ ├─ ranker.ts the one genuinely-learned component: weighted-sum scorer + real SGD + the "why"
│ ├─ steer.ts NL → taste-rules parser (on-device); optional Prompt-API enrichment
│ ├─ starterfile.ts the .dough file: SHA-256 content-hashed, versioned, integrity-checked export/import
│ ├─ store.ts single source of truth: starters, settings, view; localStorage persistence
│ └─ netmonitor.ts wraps fetch/XHR/sendBeacon/WebSocket → the live, honest `network: 0` tally
├─ ui/ render functions + small controllers (no framework)
│ ├─ app.ts the faux-browser shell, routing, overlays, popup, theme, focus-trap
│ ├─ feed.ts the HN list, the re-rank, the per-card stamps, dwell capture
│ ├─ flip.ts the FLIP "rising dough" reorder animation (the one that sells the name)
│ ├─ welcome / strip / why / steer / forks / exportimport / popup / settings ← S1–S9
│ ├─ toast.ts the calm, non-nagging notifications
│ ├─ dom.ts tiny el()/clear()/asset()/wordmark() helpers
│ └─ icons.ts inline stroke-SVG icon set
├─ extension/ the real Manifest-V3 Hacker News extension (shares engine/)
│ ├─ content.ts runs on news.ycombinator.com: re-ranks the real rows in place
│ ├─ hn-adapter.ts live HN DOM → Post[] (+ row handles for reordering)
│ ├─ topics.ts infer interpretable topics/length for real posts
│ └─ ext-store.ts starter state + chrome.storage persistence
├─ styles/ base.css → tokens.css → fonts.css → app.css (the design system, as CSS)
└─ data / fonts / public/art the bundled snapshot, self-hosted fonts, and all the brand art
embed.ts — on-device embeddings. Free text → a unit-length 256-d vector via the classic
hashing trick (token unigrams + character trigrams, signed, L2-normalised). Cosine over these is
real text similarity; it loads instantly and runs identically on every machine, with a literal
network: 0. In a production extension this module is swapped for quantized all-MiniLM-L6-v2
(transformers.js / ONNX on WebGPU) behind the exact same embed() / cosine() interface —
everything downstream is unchanged. (The 256-d figure is reflected honestly in the export UI.)
ranker.ts — the one real, visible learned component. Each post's score is a literal
weighted sum of seven interpretable features (taste-similarity, dwell, long-form, source-affinity,
expand-rate, recency, and a learned-negative comment-velocity) — the logit of an online
logistic-regression scorer. Two properties make it a glass box, not a black box:
- It reconciles. A typical post (every feature at 0.5) scores exactly 0.50, and
baseline + Σ(contributions) = score. The per-card SHAP-style bars in S4 literally add up to the number shown. - It visibly learns. Feedback and steers run a real SGD step on logistic loss
(
wₖ += lr·(y − σ(z))·xₖ), and a multi-prototype taste vector is nudged toward/away from the post — so a weight visibly moves on screen when you correct it. This is the honest answer to "show me the gradient step from my click."
steer.ts — NL → rules, on-device. A deterministic parser segments a sentence on
conjunctions, matches topics and up/down intent, and emits structured taste-rules that
deterministically nudge the scorer (an agentic rule-extractor, not a chatbot). If the browser
exposes the Prompt API (Gemini Nano) it can widen phrase coverage, but it is never required —
the deterministic path always works, so the demo never depends on a flaky primitive.
starterfile.ts — own it & carry it. Your starter serialises to a .dough file (taste
vector + prototypes + rules + weights), content-hashed with SHA-256 via Web Crypto, versioned,
and integrity-checked — edit a byte and the hash won't match (catches corruption). Re-import on a fresh profile and
the feed knows you on the first scroll: cold-start solved by ownership, no server.
netmonitor.ts — the auditable privacy claim. Installed before the app boots, it wraps every
outbound primitive (fetch, XMLHttpRequest, sendBeacon, WebSocket) and counts real calls.
The network: 0 you see everywhere is this live tally, not a hard-coded zero — if anything
ever tried to call out, you'd see it tick. Open DevTools → Network and watch it stay empty.
store.ts — state. Holds every starter (fork), the active one, settings, and view state;
persists to localStorage only. A tiny pub/sub re-renders surfaces; a separate lightweight tick
channel updates the live counters during browsing without rebuilding the DOM. Scores are cached
and invalidated on weight/taste changes.
app.ts builds the browser shell and owns routing (feed ↔ settings), modal overlays (with a
focus-trap and ESC handling), the toolbar popup, and theme resolution (cream / system / dim,
honouring prefers-color-scheme). Surfaces are plain render functions that read the store and
attach their own listeners — no framework, no virtual DOM.
feed.ts + flip.ts are the heart of the demo. Browsing is captured via IntersectionObserver
(dwell), feeding signals to the active starter. Hitting Sourdough / Let it rise runs a true
FLIP reorder — First/Last/Invert/Play on transform+opacity only (compositor, 60fps), with a
top-down stagger, a 1→1.03→1 proofing overshoot, and a golden glow on landing — so the eye
literally sees the dough rise. prefers-reduced-motion or motion-off degrades to an
instant reorder with a brief opacity/glow flash.
The styles are a token-based design system, shipped as CSS. main.ts imports base.css
(which @imports tokens.css → fonts.css) and the app-specific app.css. One amber accent carries the whimsy; one functional
green means "nothing left your device"; direction is always glyph + signed number (never
red/green alone), so it stays colour-blind-safe. Fonts (Quicksand, Nunito, JetBrains Mono) are
self-hosted in src/styles/fonts/ and public/art/ — nothing loads from a CDN.
Accessibility: ≥4.5:1 text contrast, visible focus rings, full keyboard support with a modal
focus-trap and ESC-to-close, aria-live status region, and honoured reduced-motion / colour-scheme
preferences.
vite-plugin-pwa precaches the whole app (Workbox generateSW), so Sourdough installs and runs
fully offline — true to "nothing leaves your device." The manifest ships the app icon and brand
colours; base: './' keeps every asset path relative so it works from any host or sub-path.
The production build was driven end-to-end with Playwright — every surface screenshotted, the full re-ranked feed captured top-to-bottom, and each run asserting zero console errors and zero page errors — with every screen checked against the design for visual fidelity.
This is engineered to survive a depth probe, so the seams are stated plainly:
- Real: the weighted-sum scorer and its SGD learning; the reconciling per-card explanations;
the multi-prototype taste vector and its online nudges; the NL→rules steer; the SHA-256
content-hashed portable
.doughfile; the live network monitor; the FLIP animation. - Demo stand-ins (same interface, swappable): the embedder is the deterministic hashing-trick
model rather than WebGPU all-MiniLM (so it's instant and reproducible on any machine), and the
Hacker News page is a bundled snapshot rather than a live fetch (so
network: 0is literally true and the before/after is reproducible). The starter ships pre-warmed with an exaggerated taste profile — true to the metaphor of a culture "you've been feeding for weeks," and what makes the before/after pop.
Feed your starter. Let it rise. It's yours to keep.