A rubber duck that sits above your taskbar and reacts to how you code.
A desk companion aimed at programmers: the duck taps a wing per keystroke, sparks up when you hit a flow state, gets confused when you spam backspace, and dozes off when you stop. Its eyes follow your cursor, it blinks, and it is docked to the taskbar — drag it along, but not off.
It reacts to typing in any application, not just its own window. That is the whole point, and it is also why the next section is the longest one.
| When you | The duck |
|---|---|
| press a key | taps one wing — one key, one tap |
| type fast for more than 5 seconds | sparks up (flow) |
| hit a run of backspaces | gets confused |
| stop for 90 seconds | falls asleep, zZz |
| move the mouse | follows it with its pupils |
Right-click the tray icon to hide the duck, reset its position, or quit.
The duck reacts to typing in your editor, not in its own window. On any OS that requires a system-wide keyboard hook — mechanically the same thing a keylogger installs. Pretending otherwise would be dishonest, so instead the design makes the dangerous part small and auditable.
Nothing about your typing is stored. Anywhere. Ever. Not to disk, not in memory beyond a second or so. Keystrokes drive the duck's mood and are then gone. The only thing this app persists is where you dragged the duck to.
The raw keycode lives for exactly one function call.
src/main/input/classifier.ts is the only module
in the app that ever sees one. It reduces the keycode to one of five categories
and returns that:
type | newline | delete | nav | modifier
The keycode itself is never stored, never logged, never sent over IPC, and never written to disk. The renderer — the part that could in principle talk to a network — is architecturally incapable of learning which key you pressed, because that information does not survive the trip.
Five buckets is enough to drive every reaction the duck has, and far too coarse
to reconstruct text. a, b and 7 are indistinguishable once classified.
This is enforced, not just promised:
classifier.test.tssweeps every possible keycode and asserts the output is always one of the five categories.- One test replays a real recording off the global hook — someone typing "hello duck", backspacing, pressing Enter, then Ctrl+S — and asserts the ten letters collapse into ten identical values. The raw stream spells the words out; the classified stream cannot.
- ESLint forbids
src/shared/**from importing Electron or Node APIs, so the code shared with the renderer cannot reach for a filesystem or a socket.
The mouse gets the same treatment. The duck's eyes follow your cursor, which means main watches the pointer globally too. It does not pass the position on: it computes a direction from the duck's centre and sends that unit vector. The renderer aims the pupils without ever learning where your mouse is, and nothing about the cursor is written to disk. Same rule as keystrokes — send the least that does the job.
No network calls of any kind. The overlay's CSP blocks remote origins outright. There is no telemetry, no update check, no analytics.
Windows 10 or 11, 64-bit. Grab the installer from Releases.
The build is unsigned, and SmartScreen will warn you on first run. Click "More info" → "Run anyway". This is expected: an app that installs a global keyboard hook without a code-signing certificate is exactly the shape of thing SmartScreen and antivirus heuristics are built to flag. That is the honest cost of the feature — a certificate is out of scope for now, and the source is right here if you would rather build it yourself.
The duck is docked to the Windows taskbar, and that is baked in deeper than a build target:
setAlwaysOnTop(true, 'screen-saver')is the level that floats above the taskbar; plain always-on-top sits below it.screen.screenToDipPoint, used to reconcile the hook's physical pixels with the window's DIPs, is a Windows-only Electron API.- The e2e suite runs on
windows-latestbecause that is the platform the overlay actually targets.
macOS and Linux are not built, not tested, and not promised. On macOS the global
hook would need Accessibility permission and Gatekeeper would block an unsigned
app; on Linux uiohook-napi needs X11 and would go deaf under Wayland. Those are
real ports, not a config flag.
corepack enable
pnpm install
pnpm devThe duck appears bottom-right, sitting on the taskbar. Drag it along the taskbar — it is docked, so it slides sideways and will not float off; the spot is remembered.
| Command | Does |
|---|---|
pnpm dev |
Run in development with hot reload |
pnpm build |
Build all three processes |
pnpm test |
Unit tests |
pnpm typecheck |
Typecheck main/preload and renderer |
pnpm lint |
ESLint |
pnpm format |
Prettier |
pnpm test:e2e |
Playwright against the real built app |
pnpm preview |
Compose the layers into one image per state |
pnpm exec electron-builder --win |
Build the installer into release/ |
Tagging v* and pushing builds the installer on a Windows runner and publishes
it to Releases — see .github/workflows/release.yml.
Electron · React · TypeScript · Vite (electron-vite) · pnpm via corepack · Vitest · ESLint + Prettier · husky + commitlint · GitHub Actions
Three processes, with main as the source of truth:
uiohook keyboard (main)
-> classifier.ts keycode -> category, keycode goes no further
-> 50ms batching coalesced so key-repeat cannot flood IPC
-> IPC 'input:batch' -> preload (contextBridge) -> renderer
-> companion state machine -> duck layers
uiohook mouse (main)
-> hit test over the duck? -> setIgnoreMouseEvents
-> drag slides along the taskbar; Y is always derived
-> gaze direction only -> IPC -> pupils
-> on drop window position -> electron-store
Both global hooks live in main, and so does the only thing that persists: where the duck sits. Nothing counts or stores the batches themselves.
Everything the pointer does lives in main too, off the same global hook. That is
not incidental: the renderer cannot reliably hit-test a click-through window, and
the drag region it needed for dragging was what stopped the events arriving. See
src/main/input/pointer.ts.
src/shared/ is typechecked twice — once under the Node config, once under the
web config — which mechanically keeps it free of both Node-only and DOM-only
APIs. classifier and the companion state machine are pure modules with no
Electron import, so they unit-test without booting the app. The machine is a
reducer over (state, event), which makes "asleep after 90 seconds" an instant
test rather than a 90-second wait.
The duck is pixel art, composed from layered parts rather than sprite sheets: body, wings, pupils, keyboard and effects are separate images stacked and moved independently. Pupils have to be their own layer to follow the cursor at all, and single parts are also the only thing an image model can produce consistently — strips of aligned frames are not.
Everything moves in whole art pixels. Fractional offsets and rotation resample
the grid and turn pixel art to mush, so layers only ever translate by integers
or mirror with scaleX(-1) (an exact flip). Effects animate with CSS steps()
for the same reason, and animation stays on the compositor — an overlay that is
visible all day repainting from JS every frame would burn CPU for nothing.
Geometry and palette live in src/shared/duck-art.json,
read by the app and pnpm preview, so the two cannot drift apart.
Typing animation is event-driven: the state machine counts keystroke batches and its parity picks which wing taps next — one keystroke, one tap. Nothing loops on a timer. Blinking works the same way: the lid and the pupils share one CSS timeline with opposite opacity, so no state and no timer are involved in either.
assets/duck/*.png is the art — each sprite drawn by hand at exactly the
size its rect gives, with no import step and no pipeline. pnpm preview stacks
the layers into one image per state, so a misplaced part is obvious without
launching anything.
There was an importer that downscaled generated art automatically. It is gone: averaging is a photo algorithm and cannot preserve a 1px outline, so it turned the duck's dark edge into patchy orange. Drawing at final size means there is nothing to resample at all.
