Rank anything by comparing two items at a time. Add text, images or colors, pick your favorite in each duel (buttons, keyboard or swipe), and let the ranking emerge.
Live: https://versus.steevepommier.com/ (en français; the app itself is at /app/), also on https://costardrouge.github.io/versus/, without publishing
- Duels with buttons, arrow keys (
←A,→B,↓tie,Sskip,⌘/Ctrl+Zundo) or swipe. - Items of any kind: text, images (drag and drop or paste, downscaled in the browser), solid colors and gradients with an inline color editor. Paste a whole list (plain lines, Markdown, a spreadsheet row) to add it at once.
- Four scoring methods, switchable at any time without losing duels:
Method Algorithm Best for Balanced (default) Bradley-Terry, fitted on all duels at once, with a ± margin per item Photos, products, anything that doesn't change Dynamic Elo, duel by duel Preferences that evolve over time Simple Smoothed win rate Readability Exact sort Binary insertion sort Settling a short list in the fewest duels - Method comparison on the results page, to see where the ranking is still fragile.
- Demos built from fixed data (same items and duels for everyone), resettable and translatable.
- English and French, detected from the browser and switchable.
- A home page in English and French with live demos: a virtual pointer plays the app, six topics to try, the four methods on the same duels.
- Search and share ready: a head per page with hreflang, Open Graph cards in both languages, structured data, favicons for every platform, a bilingual sitemap and
llms.txt, all generated from one file (seedocs/seo.md). - Light and dark themes that follow the system setting or can be pinned from the header; the choice is applied before the first paint.
- Installable and offline: a service worker keeps the app on the device, so it opens without a network; new versions are offered, never forced (see
docs/pwa.md). - Export and import: save your rankings and votes in a file, or one ranking to send, and open them in another browser, on another device or in the installed app.
- Rankings stay in the browser (
localStorage); only what you publish is sent. Visits are counted with a self-hosted, cookie-free Umami, never loaded for visitors who decline (a switch on the legal notice, Do Not Track, Global Privacy Control), and never with a ranking's content or id (seedocs/analytics.md). - Published boards: publish a ranking, share the link, and let a crowd vote in real time (on versus.steevepommier.com; the GitHub Pages copy has no backend). See
docs/published-boards.md. - Notifications (Web Push, sent by the Worker with WebCrypto only): a voter is told when a vote closes, an author as voters come in and when a picture is reviewed; asked on a tap, never on arrival (see
docs/published-boards.md#notifications). - Share as an image: your ranking, the crowd's, you against the crowd, or a single duel, drawn in the browser in post, story or landscape format and handed to the system share sheet, the clipboard or a download. A board's link unfurls with its own card, a duel link with its two items.
- Moderation: visitors report a board (anonymously, with a reason); the publisher's page at
/admin/lists boards with their reports, hides or features them, removes items and takes boards down (seedocs/published-boards.md#moderation). - Pictures through review (off by default): when the publisher turns them on, a published ranking's pictures are sent for review and shown once approved from the moderation page; until then the items show as text.
- Official templates and a Popular section: fixed lists on divisive topics (consoles, languages, phones, pastries…) published as real boards everyone votes on, each with an indexable page (
/t/<slug>/,/fr/t/<slug>/) listed in the sitemap once it has a crowd; the gallery's Popular section shows them with the boards the publisher put forward, to vote or start one's own ranking from.
Requires Node.js 22 or newer; the project is developed and built on Node 26 (see .nvmrc).
npm install
npm run dev # local server with hot reload
npm run check # lint, typecheck, tests, build: what CI runs| Script | What it does |
|---|---|
npm run dev |
Vite dev server |
npm run build |
Production build into dist/ |
npm run preview |
Serve the production build |
npm test / npm run test:watch |
Unit tests (Vitest) |
npm run coverage |
Tests with coverage; the scoring engine must stay above 90% |
npm run e2e |
End-to-end tests in Chromium (Playwright): duels by keyboard and swipe, the items list, the color editor, a published board voted to the end |
npm run lint / npm run format |
Biome lint and format check / auto-fix |
npm run typecheck |
TypeScript in strict mode (app and Worker) |
npm run worker:dev |
The whole app and the published boards API on http://localhost:8787 (npm run dev proxies /api to it) |
npm run worker:deploy |
Build, deploy to Cloudflare and apply the D1 migrations (needs a Cloudflare account) |
npm run icons |
Redraw the favicons, app icons and social card into public/ from build/site.ts |
npm run screenshots |
Take the install dialog's screenshots of the app into public/ (needs Playwright's Chromium: npx playwright install chromium, or CHROMIUM_PATH) |
npm run e2e |
End-to-end tests: Playwright's Chromium against the app and the Worker run locally (npx playwright install chromium once, or CHROMIUM_PATH) |
npm run vapid |
Print a key pair for notifications, to set as the Worker's secrets VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY |
src/
core/ pure, framework-free logic (fully unit tested)
scoring.ts Bradley-Terry, Elo, win rate, exact sort, pair selection
colors.ts hex/HSL conversions, gradients, harmonies
demos.ts demo data and deterministic simulation
board.ts published boards: votes, visibility, pair assignment
protocol.ts messages and views shared by the app and the Worker
i18n/ en.ts (source of keys), fr.ts (type-checked against en), helpers; landing-*.ts for the home page
landing/ the home page, rendered at build time in each language, then animated
admin/ the publisher's moderation page (/admin/), client of the Worker's admin routes
app/
ui.ts mount(): the entry point
gallery.ts, workspace.ts, items.ts, duel.ts, results.ts, color.ts one module per view
publish.ts, board.ts, remote.ts publishing, the published board page, the API client
rankings.ts navigation and actions on whole rankings
events.ts delegated event listeners
state.ts, dom.ts, header.ts, format.ts, storage.ts shared state, DOM helpers, header, formatting, localStorage
styles.css
build/ build-time SEO: site facts, head tags, JSON-LD, manifest, robots, sitemap, llms.txt (docs/seo.md)
scripts/ icons.ts: icons and social card
worker/ Cloudflare Worker: the site, the API, one Durable Object per published board
tests/ Vitest suites (core, jsdom views, the Worker in workerd) and Playwright end-to-end tests (tests/e2e)
worker/ serves the app and the API for shared boards: each published board is a Durable Object with its own SQLite storage and WebSockets, a D1 registry backs the admin API, and per-IP limits and Turnstile guard publishing. The rules live in src/core/board.ts, shared with the app. It runs locally (npm run worker:dev), in the tests, and live at versus.steevepommier.com; CI deploys it, see docs/online-architecture.md.
.github/workflows/ci.yml runs on every pull request and push to main: Biome, TypeScript, Vitest with coverage, a production build, and the Playwright end-to-end tests (the app with a faked API, and against the real Worker). On main, once both pass, the build is deployed to GitHub Pages, and the Worker (app + API) to Cloudflare once the repository has the CLOUDFLARE_ACCOUNT_ID variable and the CLOUDFLARE_API_TOKEN secret (setup). Dependabot keeps npm packages and actions up to date.
The Pages source must be set once to GitHub Actions in Settings → Pages.
CLAUDE.md: working memory for Claude Code (conventions, code map, domain rules).docs/decisions.md: decision log.docs/roadmap.md: done, next, later, open questions.docs/published-boards.md: agreed behavior of published (shared) boards.docs/online-architecture.md: backend for published boards on Cloudflare (worker/), and how to deploy it.
The texts are typed against English, so TypeScript flags a missing key; the language lists are not, so each needs it:
- App: copy
src/i18n/fr.tsand translate it; register it insrc/i18n/index.ts(LANGS,MESSAGES,PLURALS); add its button to the language switcher inapp/index.html; add its labels to the demos (src/core/demos.ts). - Home page and legal notice:
src/i18n/landing-*.tsandlegal-*.ts, the page shells (copyfr/index.htmlandfr/mentions-legales/index.html), the language lists insrc/landing/(markup.ts,frame.ts,sections.ts) andsrc/legal/mount.ts. - SEO:
build/site.ts(LANGUAGES, the pages, titles, descriptions, social cards,LOCALES), thennpm run iconsfor its social card. - Published boards:
BoardLanginsrc/core/types.ts, its use insrc/core/board.ts, the templates (src/core/templates.ts,TEMPLATE_LANGS), the Worker's texts (src/i18n/unfurl.ts) and routes (worker/src/index.ts,worker/src/templates.ts). - Moderation page:
src/i18n/admin.tsandsrc/admin/admin.ts.
grep -rn "'fr'" src build worker/src lists the places that name a language.
MIT © 2026 Steeve Pommier