Make finishing tasks fun. Drag a card to Done on your kanban board and get confetti, fireworks, a happy little chime, and a running streak β a tiny dopamine hit every time you finish something.
A zero-dependency Chrome extension (Manifest V3). It works on GitHub Projects today, and is built to gamify any kanban board β adding one (Trello, etc.) is a drop-in "provider" away. It reads completions from board structure and text, not brittle CSS selectors, so it keeps working as sites reshuffle their class names. The stats HUD only pops up while you're actually on a board.
- Automatic Done detection on GitHub Projects boards β drag a card into your Done column and it's celebrated, with no clicks or manual logging.
- Sound β a Web Audio chime (no audio files) that rotates through six major/pentatonic arpeggios so no two completions sound the same, with an occasional richer chord, plus adjustable volume.
- Confetti β a self-contained, full-viewport canvas burst with configurable particle count and duration, and per-burst color/spread variety. Never shifts page layout.
- Fireworks β emoji / firework / sparkle bursts (randomized per completion) that fire alongside confetti on a separate canvas. Adjustable intensity.
- Screen Glow β a brief success vignette that flashes around the screen edges in a randomized success hue. Transparent center, never blocks the page.
- π° Jackpot celebrations β ~15% of completions roll a "jackpot": all effects escalate together β a triumphant sound fanfare, a 3Γ confetti mega-burst, multiple fireworks, and a brighter/longer gold glow. Unpredictable by design.
- Toast β a slide-in notification showing
+1, tasks completed today, and all-time total. Pick bottom-left or bottom-right; tune how long it holds. - Stats HUD β a draggable, collapsible widget showing your π₯ streak, β today, and β‘ all-time counts, flashing green on increase and red on decrease. It appears only while you're on a board (and hides everywhere else), and remembers its position and collapsed state across reloads and SPA navigation.
- Streak tracking β consecutive days with at least one completion, computed from a local-date daily log.
- Per-plugin settings popup β every plugin's settings are auto-generated from its schema, with a master enable toggle each. Changes apply live, no reload.
- Modular plugin architecture β features are independent plugins wired through a shared event bus, so new behaviours are a drop-in folder away.
This extension ships as plain source β no build step, no dependencies, no bundler. Load it directly:
- Open
chrome://extensionsin Chrome (or any Chromium browser). - Toggle Developer mode on (top-right).
- Click Load unpacked.
- Select the
kanban-gamifyfolder (the one containingmanifest.json). - Open a GitHub Projects board and drop a card into your Done column.
If your Done column isn't literally named "Done", open the extension popup and set the Done column name to match (case-insensitive).
MV3 content scripts can't be ES modules directly (no static import). To keep a
true module architecture with no build step, manifest.json registers a tiny
loader.js as the only content script. It does a dynamic
import(chrome.runtime.getURL('content.js')), which is permitted in content
scripts and loads content.js as a real ES module. From there, everything under
core/ and plugins/ uses normal static import/export with relative paths
and .js extensions. Each runtime-loaded module is listed in the manifest's
web_accessible_resources (via globs). The popup is a normal extension page, so it
just uses <script type="module">.
loader.js ββdynamic importβββΆ content.js (ESM entry)
β
ββ registers plugins βββΆ core/plugin-registry.js
ββ starts detection βββΆ core/detector.js
β asks "is this a board?"
βΌ
providers/ (github-projects, β¦)
β emits
βΌ
core/event-bus.js
('task:done', 'task:undone', 'route:change', 'board:state')
β fan-out
ββββββββ¬βββββββ¬βββββββββββ¬βββββββ¬ββββββββ¬βββββββ
βΌ βΌ βΌ βΌ βΌ βΌ βΌ
sound confetti fireworks glow toast hud β¦
(all read/write via core/storage.js)
The core modules, each with a single job:
-
core/event-bus.jsβ a synchronous pub/sub singleton (on/once/off/emit/clear).emitisolates each handler in try/catch so one bad plugin can't break the others. Core events:task:done,task:undone,route:change,board:state(are we on a board?). -
core/storage.jsβ the only thing that toucheschrome.storage.local. Namespaced keys, core stats (total/daily log/today/streak), and per-plugin settings helpers. -
core/plugin-registry.jsβ owns plugin lifecycle. On init it reads each plugin's enabled flag, resolves its settings from its schema, callsinit(config), and subscribes the plugin to the bus. It watcheschrome.storage.onChangedso toggling/tuning a plugin in the popup re-inits it live. Every plugin call is guarded in try/catch. -
core/detector.jsβ the core feature, and board-agnostic. It asks the providers which one (if any) handles the current page, broadcastsboard:state(so the HUD knows when to show), and β on a board β runs a reconcile algorithm over a debouncedMutationObserver: it tracks the card identities currently in Done, suppresses the initial page load, ignores bulk re-renders, and emitstask:donefor cards entering Done /task:undonefor cards leaving. It handles SPA navigation (patchedhistorymethods + a title observer) to re-prime per view. -
providers/β a board provider teaches the detector how to read ONE kind of board:matches(url)(is this one of its boards?),findDoneColumn(name),findCards(column),cardIdentity(card),cardTitle(card),getBoardRoot(...).providers/github-projects.jsis the only one today; add another (e.g. Trello) to gamify a different board β see below.
Plugins are independent feature modules (sound, confetti, fireworks,
glow, toast, hud). Each one subscribes to bus events through the registry and
persists its own settings under its namespace. They never call each other directly
β they react to events β which is exactly what makes them composable.
A plugin is a single folder under plugins/ containing an index.js that
export defaults one plain object. Important: the plugin's index.js must
have zero side effects at module top level β only define and export the object.
(The popup imports it just to read the settings schema; importing must not touch
audio, the DOM, or storage.)
Create plugins/my-plugin/index.js:
// plugins/my-plugin/index.js
// No top-level side effects β only define and export the plugin object.
export default {
id: 'my-plugin', // unique; used in storage keys plugin:my-plugin:*
name: 'My Plugin', // shown as the section title in the popup
enabled: true, // default enabled state
// Called once when the plugin is enabled. config = { settings, EventBus, Storage }.
// Read live values via config.settings[key] (resolved from your schema defaults
// overlaid with stored plugin:my-plugin:{key} values).
init(config) {
this.settings = config.settings;
this.bus = config.EventBus;
this.storage = config.Storage;
},
// Fired on every completion. eventData =
// { cardTitle, columnName, timestamp, totalToday, allTime, jackpot }.
// `jackpot` is true ~15% of the time β escalate your effect when it is.
onTaskDone(eventData) {
console.log('[my-plugin] done:', eventData.cardTitle, this.settings.message);
if (eventData.jackpot) {/* go big */}
},
// Fired on GitHub SPA navigation. eventData = { url }.
onRouteChange(eventData) {},
// Tear down: remove DOM nodes, cancel timers, close resources.
destroy() {},
// Settings rendered automatically by the popup. Supported types:
// 'range' | 'toggle' | 'select' | 'number' | 'text'.
// Do NOT include an `enabled` field β the popup renders the master toggle
// separately from plugin:my-plugin:enabled.
getSettingsSchema() {
return [
{ key: 'message', label: 'Message', type: 'text', default: 'Nice work!' },
{ key: 'intensity', label: 'Intensity', type: 'range', min: 0, max: 100, step: 1, default: 50 },
];
},
};-
Drop the folder in:
plugins/my-plugin/index.js(as above). -
Add it to
plugins/index.js:import sound from './sound/index.js'; import confetti from './confetti/index.js'; import toast from './toast/index.js'; import hud from './hud/index.js'; import myPlugin from './my-plugin/index.js'; // β add the import export default [sound, confetti, toast, hud, myPlugin]; // β add to the array
That's it. No core file edit and no manifest edit are needed β the manifest's
web_accessible_resources already globs plugins/*/index.js, the registry wires
your plugin to the bus automatically, and the popup auto-generates your settings
section from getSettingsSchema(). Reload the extension on chrome://extensions
and your plugin is live.
The detector is board-agnostic β all the site-specific bits live in a provider. To gamify a different kanban board:
-
Create
providers/<name>.jsexporting an object shaped likeproviders/github-projects.js:export default { id: 'trello', name: 'Trello', matches(url = location.href) { /* is this a Trello board page? */ }, findDoneColumn(doneName) { /* return the Done list element, or null */ }, findCards(columnEl) { /* return the card elements in a list */ }, cardIdentity(cardEl) { /* a stable id string for a card */ }, cardTitle(cardEl) { /* a human-readable title */ }, getBoardRoot(doneColumnEl) { /* element to observe for mutations */ }, };
-
Register it in
providers/index.js(add the import + array entry). -
Add the site to the manifest β its host in
content_scripts[].matchesand inweb_accessible_resources[].matches(the only manifest edit needed; theproviders/*.jsresource glob already covers your new file).
Everything else β reconcile, celebrations, stats, the HUD, the popup β just works, and the HUD will show only on that board's pages.
The event bus is what makes inter-plugin reactions possible: any plugin can
emit a namespaced event, and any other plugin can on(...) it β they never
import or call each other. A plugin's init(config) receives config.EventBus,
so it can both publish new events and subscribe to existing ones. These four are
documented as natural next steps:
reaction-imageβ ontask:done, overlays a random image for ~2s usingeventData(e.g. card title) plus a user-supplied URL list defined in its settings schema (atextfield of newline-separated URLs). Pure consumer oftask:done.pomodoroβ subscribes toroute:changeto start/stop focus timers as you move around GitHub, and emitspomodoro:start/pomodoro:endon the bus. Because those are just bus events, other plugins (sound, toast, a future level-system) can react to them with no coupling to the pomodoro plugin itself.streak-freezeβ a game mechanic that consumes streak data from storage (core:dailyLogviaStorage.getStats()) to let you "freeze" a streak so a missed day doesn't reset it. Reads core stats; writes its ownplugin:streak-freeze:*state.level-systemβ listens totask:done, accumulates XP, and emitsplayer:levelupwhen you cross a threshold to trigger its own celebration (and any other plugin that chooses to listen). A clean example of one plugin's output becoming another plugin's input β entirely through the bus.
Why the bus matters: because publishing and subscribing are decoupled, a
plugin like level-system can emit player:levelup without knowing or caring
that confetti exists, and confetti could choose to celebrate level-ups by
subscribing to player:levelup β without either plugin importing the other. New
reactions are added by listening to events, not by editing existing plugins.
All persistence goes through core/storage.js into chrome.storage.local.
| Key | Shape | Meaning |
|---|---|---|
core:doneCount |
number |
The β‘ all-time figure = live count of cards currently in Done (a card entering Done raises it; leaving lowers it). |
core:dailyLog |
{ "YYYY-MM-DD": count, ... } |
Per-day net completion count (+1 per entry into Done, β1 per exit, clamped β₯ 0), keyed by local date. Drives today + streak. |
core:doneColumnName |
string (default "Done") |
Which column header marks a card as done (case-insensitive). |
core:totalCompleted |
number |
Legacy mirror of core:doneCount (kept for compatibility). |
Counting model: every move into Done is a completion β it celebrates and
adds +1 to today and all-time, every time (a card moved out and back celebrates
again). Every move out of Done subtracts 1. So the counts always mirror the
live Done column. Derived stats from Storage.getStats():
{ totalCompleted, dailyLog, today, streak } where totalCompleted is the live
Done count and streak is the consecutive days (ending today or yesterday) each
with at least one net completion. Reset stats clears core:dailyLog only
(today/streak β 0); the all-time count is preserved since it reflects the live
Done column.
| Key pattern | Example | Meaning |
|---|---|---|
plugin:{id}:enabled |
plugin:sound:enabled |
Master enable toggle for a plugin. |
plugin:{id}:{key} |
plugin:sound:volume |
A single setting value for a plugin. |
Built-in plugin settings (each also has its plugin:{id}:enabled flag):
| Plugin | Key | Type | Default |
|---|---|---|---|
sound |
volume |
range | 60 |
confetti |
particleCount |
range | 100 |
confetti |
duration |
range | 2.5 |
toast |
position |
select | 'bottom-left' |
toast |
holdDuration |
range | 2.5 |
hud |
defaultPosition |
select | 'top-right' |
hud |
showStreak |
toggle | true |
hud |
showToday |
toggle | true |
hud |
showAllTime |
toggle | true |
hud |
x / y |
number | (saved on drag) |
hud |
collapsed |
toggle | (saved on toggle) |
Key builders, if you're writing storage code:
Storage.coreKey(key) β core:${key}, and
Storage.pluginKey(pluginId, key) β plugin:${pluginId}:${key}.
The extension requests only storage. It runs content scripts on
https://github.com/* and has no network, host, or background access beyond that.
Provided as-is for personal use. No external dependencies.