A simple "spin the wheel" web app for picking winners from a pool of names.
- Vue 59.2%
- TypeScript 32.7%
- CSS 7.9%
- JavaScript 0.2%
| .zed | ||
| app | ||
| docs | ||
| i18n/locales | ||
| public | ||
| test | ||
| .gitignore | ||
| .nuxtrc | ||
| .prettierignore | ||
| .prettierrc | ||
| eslint.config.mjs | ||
| LICENSE | ||
| nuxt.config.ts | ||
| package.json | ||
| pnpm-lock.yaml | ||
| README.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
Orbis
A simple "spin the wheel" web app for picking winners from a pool of names.
Features
- Admin + presentation views — open
/to manage the pool and/presentin a separate window for a chrome-free, projector-friendly wheel. - Persistent state — entries, winners, and settings survive a reload via
localStorage. - Bulk paste — paste a newline- or comma-separated list to populate the pool.
- Optional dedupe — show each name once on the wheel even if entered multiple times (case-insensitive).
- Optional auto-remove — pull the winner out of the pool after each spin.
Screenshots
| Admin | Presentation |
|---|---|
![]() |
![]() |
Setup
pnpm install
Development
pnpm dev
Opens at http://localhost:3000.
- Add names to the pool (one at a time, or paste a list).
- Click Open presentation to spawn
/presentin a second window — put it on a projector or external display. - Hit Spin. Both views animate together; the winner pops up on the presentation with confetti.
Scripts
| Command | What it does |
|---|---|
pnpm dev |
Run the dev server |
pnpm build |
Build for production |
pnpm preview |
Preview the production build |
pnpm lint |
ESLint with --fix |
pnpm format |
Prettier write |
pnpm test |
Run all test projects |
pnpm test:unit |
Pure logic — store actions, spin math (property test) |
pnpm test:nuxt |
Component integration via mountSuspended + happy-dom |
pnpm test:browser |
Real Chromium via @vitest/browser + Playwright (CSS transitions) |
The browser tests need Playwright's Chromium binary, installed once with:
pnpm exec playwright install chromium
Architecture notes
- Store-driven: the wheel is a pure view over
useWheelStore. The spin's target angle is computed up front instartSpinand persisted on the store, so admin and presentation animate to the same name even if one tab reloads mid-spin. - Cross-tab sync (
app/plugins/wheel-cross-tab.client.ts): aBroadcastChannelbroadcasts the store state on every mutation, with a value-equality check to avoid ping-pong loops. - Pointer at 90°: the right-side pointer is the source of truth for "who won."
startSpin's rotation math is verified by a property test that decodes the segment under the pointer fromtargetAngleand checks it matcheswinnerIndex. - Test-only spin duration:
store.spinDurationMsdefaults to 10s but tests set it to 200ms so real CSS transitions complete in ~hundreds of ms.

