Development
September 19, 2026 · View on GitHub
Setup
Node 20 or newer (CI uses 22) and pnpm 10.
git clone https://github.com/ThinkyMiner/Winnow.git
cd Winnow
pnpm install
pnpm typecheck && pnpm test
pnpm build
For the eval harness and the probe script, put a Jev key in .env at the repo root:
JEV_API_KEY=sk-...
.env, dist/, .eval-cache/, and node_modules/ are gitignored.
Commands
| Command | What it does |
|---|---|
pnpm dev | Vite dev server. Open http://localhost:5173/dev/preview.html (or whatever port Vite prints) |
pnpm build | Vite + @crxjs/vite-plugin build into dist/ |
pnpm typecheck | tsc --noEmit over src, scripts, dev, and vite.config.ts (strict, noUncheckedIndexedAccess) |
pnpm test | Vitest, src/**/*.test.ts and scripts/**/*.test.ts; 124 tests in 16 files |
pnpm eval [--no-cache] [--report-only] [--items a,b] | Judge the fixtures with real Jev calls; see eval.md |
pnpm probe | One raw request to POST /v1/systemone with a choice, a noul, and a score question; prints status, selected headers, and the body |
Project layout
manifest.json MV3 manifest; version lives here and in package.json
vite.config.ts crx plugin, extra onboarding input, vitest include globs
src/
types.ts contracts: Jev wire types, enums, Judgment, CardModel, Settings, messages
config.ts model pin, endpoints, retry, caps, cache limits, THRESHOLD_META, storage keys
background/ service worker (index, router, storage, messaging)
jev/ client, questions, verdict, cache, readerState, index (judgePage/judgeFeed)
__test__/ tests for jev/ plus chromeMock.ts
extract/ article (Readability), youtube, feed adapters, prefetch
__fixtures__/ HTML fixtures for article tests
ui/ card, badge, templates, styles
content/ page.ts and feed.ts entry points
onboarding/ index.html + main.ts
options/ index.html + main.ts
scripts/
eval/ run.ts, metrics.ts, and their tests
probe-jev.ts
fixtures/
items/*.json 40 ExtractedContent fixtures
golden.json expected verdicts, split, reviewed flag
reader.json fixed ReaderState for the eval
dev/
preview.html/.ts renders every card and badge state from dev/fixtures.ts
public/icons/ 16/32/48/128 px extension icons copied into dist/ (see Brand assets)
assets/brand/ mark, lockups, app icons, social pack; see assets/brand/README.md
docs/ this directory
.github/ CI, release, issue and PR templates
Module boundaries worth knowing:
src/types.tsandsrc/config.tsare imported everywhere and import nothing from the rest ofsrc/(types.ts takesThresholdsfrom config).src/jev/client.ts,questions.ts,verdict.ts, andreaderState.summarizeForStatehave nochrome.*references soscripts/eval/run.tscan import them in Node.- Only
src/background/andsrc/jev/see the API key.src/ui/never imports fromsrc/jev/orsrc/extract/; it renders aCardModel.
Testing conventions
- Tests are co-located:
foo.tshasfoo.test.tsnext to it. The exception issrc/jev/__test__/, which holds the jev tests and the sharedchromeMock.ts. - Tests that need a DOM start with the comment
// @vitest-environment happy-domon line 1 (src/ui/*.test.ts,src/extract/article|feed|youtube.test.ts). Everything else runs in Node. - happy-dom has no
IntersectionObserver;feed.test.tsstubs one withvi.stubGlobaland fake timers. Copy that pattern for anything observer-based. src/jev/__test__/chromeMock.tsexportsinstallChromeMock(), which installs an in-memorychrome.storage.local(get/set/remove/clear, values deep-cloned) onglobalThisand returns the backingMapso tests can inspect it. Use it for anything touchingcache.ts,readerState.ts, orstorage.ts.src/background/router.test.tsmocks../jevand../extract/prefetchwithvi.mockand testshandle()end to end, including the privileged-sender check.scripts/eval/verdict.thresholds.test.tsbuilds hand-madeJudgments through amk(overrides)helper and asserts which rule fires. When you add a rule, add a case there.- No network in tests.
client.test.tsstubsfetch.
The dev preview
pnpm dev
Open /dev/preview.html. It mounts a card for each of: loading, error (a 429 jev_error), ready article, ready video (with Skip-to chips), and low confidence; then a fake feed list with a badge per fixture; then an event log that prints feedback, seek, and dismiss callbacks. A Dark checkbox flips color-scheme and sets data-wi-theme so the shadow roots follow. No Jev, no extension APIs. Use it for any change under src/ui/.
Loading the built extension
pnpm build
Then chrome://extensions → Developer mode → Load unpacked → pick dist/. After each rebuild, click the reload icon on the extension card. The onboarding tab opens on first install; the toolbar icon opens options.
Content scripts are injected on the next page load, not into already-open tabs. The service worker log is under the extension card's "service worker" link; note that router.log is compiled out unless you set DEBUG = true in src/background/router.ts, and even then it prints message types and error codes only.
Automating the load (Chrome 137+)
Branded Chrome 137 and later ignores the --load-extension command-line flag. If you script extension loading (screenshots, end-to-end checks), launch Chrome with --remote-debugging-pipe --enable-unsafe-extension-debugging and call the DevTools Protocol method Extensions.loadUnpacked with the absolute path to dist/. Chrome for Testing and Chromium builds still honour --load-extension.
Brand assets
assets/brand/ holds the identity described in brand/DESIGN.md; the quick reference is assets/brand/README.md.
| File | What it is |
|---|---|
mark.svg, mark-white.svg, mark-ink.svg | The three-piece W mark in Winnow Blue, white, and Ink. mark.svg is the canonical geometry; the others are colour swaps of it, never redrawn |
favicon.svg | The blue mark, for the website |
logo.svg, logo-white.svg | Lockup: mark + "Winnow" as an Inter 700 <text> element (falls back to the system sans) |
app-icon-primary.png, app-icon-light.png, app-icon-dark.png | 512 px app icons: white mark on blue, blue mark on white with a 1 px Border stroke, white mark on Ink |
og.png | Copy of social/winnow-og-link-preview-1200x630.png |
social/ | Nine social sizes, named by use and dimensions |
brand-board.png | The reference board the assets were derived from |
public/icons/{16,32,48,128}.png are the primary app icon (Winnow Blue rounded square, corner radius 22% of the side, white mark 62% of the width; 70% at 16 px for legibility). manifest.json references 16, 48, and 128, so those sizes must stay exact.
Icons are rendered from an HTML file holding one inline SVG sized N×N over a transparent body, with headless Chrome:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new \
--screenshot=out.png --window-size=N,N --default-background-color=00000000 icon.html
sips -g pixelWidth -g pixelHeight out.png
Edit the colours or the scale in the SVG, re-render each size, and check the 16 px one zoomed (image-rendering: pixelated) before committing.
Probing Jev
pnpm probe is the script that produced the observations in jev-contract.md. Run it when you suspect the API changed (new error shape, new rate-limit headers, different answering model). If reality differs from the contract, update the contract with the date and the raw output, then fix the client.
Release process
-
Bump the version in both
manifest.jsonandpackage.json(they are0.1.0today; keep them equal). -
Move the
[Unreleased]entries inCHANGELOG.mdunder a new## [X.Y.Z] - YYYY-MM-DDheading and add the compare links at the bottom. -
Commit, then tag and push the tag:
git tag vX.Y.Z git push origin vX.Y.Z -
.github/workflows/release.ymlruns on anyv*tag: installs with pnpm 10 on Node 22, runspnpm build, zips the contents ofdist/asWinnow-vX.Y.Z.zip, and attaches it to a GitHub Release with generated notes. -
Check the release page, then install from the zip once as a user would (install.md) before announcing it.
CI (.github/workflows/ci.yml) runs pnpm typecheck, pnpm test, and pnpm build on pushes to main/master and on pull requests.