Skip to content

RemiKG/sourdough

Repository files navigation

Sourdough

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.


Quick start

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), use npm 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 Hacker News extension — the real product

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's Post shape (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 to chrome.storage (localStorage fallback).
  • Everything below engine/ is shared verbatim with the web app — the engine is the product; only the post source differs. (ranker.scoreAll now takes the post set, defaulting to the bundled snapshot, so the demo is untouched.)

What's on screen (the 9 designed surfaces)

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.


Architecture

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

The engine, in depth

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.

The UI layer

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.


Design system

The styles are a token-based design system, shipped as CSS. main.ts imports base.css (which @imports tokens.cssfonts.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.


PWA / offline

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.


How it was verified

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.


Honesty notes (what's real, what's a demo stand-in)

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 .dough file; 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: 0 is 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.

About

Own your algorithm. On-device. 0% rented. A cozy on-device recommender that re-ranks your Hacker News feed for YOU.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors