New Tool: Personal Kanban Board
Added a drag-and-drop Personal Kanban board with multiple boards, editable columns, and JSON export/import — all in a single vanilla JS file.
Added a new tool tonight: a Personal Kanban board. It lives at /kanban/index.html and is a fully self-contained HTML/CSS/JS app with no dependencies.
What it does
Three columns per board — To Do, WIP, and Done — with full drag-and-drop between them. Cards can be reordered within a column too, with a live amber drop-indicator line showing exactly where a card will land.
Everything is editable: click a column header to rename it, double-click a board tab to rename the board. Multiple boards are supported as tabs (same pattern as Sticky Notes), and you can drag tabs to reorder them.
The details
- Cards are
contenteditabledivs — click to edit, Enter to save, Shift+Enter for a new line inside the card, Esc to cancel - New empty cards that are abandoned without typing are automatically discarded
- The last board can’t be deleted — closing it clears its cards instead
- Auto-saves to
localStorageon every action with a brief “Saved.” flash - Full JSON export and import — the entire state (all boards, columns, cards) in one file
- Keyboard shortcuts:
Nfor new card,Ctrl+Tfor new board,Ctrl+E/Ifor export/import,?for help
Design
Follows the site’s Warm Dark / Studio Amber system: IBM Plex Sans throughout, CSS custom properties for all colors, no animations. Each column gets a colored top accent stripe — amber for To Do, neutral for WIP, sage green for Done — so they’re visually distinct at a glance without being loud.
The landing page is at /tools/kanban and the board is featured on the homepage.
Post-Mortem: AI-Assisted Development with gstack
This tool was built entirely with Claude Code and gstack — an open-source AI builder framework by Garry Tan. I’m sharing the full session below because the process itself is instructive, especially the part where the AI found ten bugs in its own code before it shipped.
If you’re a developer curious about AI-assisted workflows, or if you’re evaluating how much to trust AI-generated code, this is worth reading.
The Implementation Plan — what was designed before a line was written
Before writing any code, Claude explored the existing codebase (Sticky Notes tool, Dice Roller tool, Astro page patterns, the design system) and produced this implementation plan. It was reviewed and approved before execution began.
Context: Adding a new standalone “Personal Kanban” tool. The tool provides a multi-board Kanban experience with three columns (To Do, WIP, Done), drag-and-drop cards, board/column renaming, and JSON import/export — following the exact same patterns established by the Sticky Notes and Dice Roller tools.
Data schema stored at kanban-v1 in localStorage:
{
boards: [{
id: string, // uid()
name: string,
columns: [{
id: string,
name: string, // "To Do" | "WIP" | "Done" (user-renameable)
cards: [{ id: string, text: string }]
}] // always exactly 3 columns
}],
activeBoard: string
}
Layout: flex column, full height, overflow hidden
#toolbar— New Card button, Export/Import buttons, hidden file input#tabbar— Board tabs (same pattern as sticky-notes: contenteditable name, × close, drag-to-reorder,+button)#board-area— flex row,overflow-x: auto,padding: 20px 24px
JS architecture:
uid()
load() / save() / initDefaults()
activeBoard() helper
render() → renderTabs() + renderBoard()
buildColumnEl(col) → buildCardEl(card, col)
addBoard() / removeBoard(id) / switchBoard(id) / reorderBoard(srcId, tgtId)
addCard(colId) / deleteCard(colId, cardId) / updateCardText(...)
moveCard(srcColId, srcCardId, tgtColId, insertBeforeCardId)
exportJSON() / importJSON()
toggleHelp() / hideHelp()
Drag-and-drop strategy (HTML5 Drag API):
- Module-level
dragSrcColId/dragSrcCardIdtrack the active drag dragstarton card: set src vars, mark card with.draggingCSS classdragoveron.col-cards: insert a.drop-indicatordiv at the calculated insertion point usinggetDragAfterElement(container, y)— a reduce over all non-dragging cards finding the one whose midpoint is just below the cursordrop: callmoveCard()with the insertBefore card iddragend: clear all state, remove indicators
Files to create/modify:
public/kanban/index.html— the full standalone appsrc/pages/tools/kanban.astro— landing page with 6 feature cardssrc/pages/tools/index.astro— add kanban as first tool entrysrc/pages/index.astro— add featured card at top (trim to 3 total)
Verification checklist from the plan:
- Persistence — add cards, reload, verify cards survive
- Column rename — rename “To Do” → “Backlog”, reload, confirm persists per-board
- Board tabs — add 3 boards, drag-reorder, rename, close; last board clears instead of deletes
- Drag within column — reorder 4 cards, reload, verify new order in state
- Drag across columns — drag card to WIP mid-list, reload, verify position
- Drop indicator — amber line tracks mouse during drag, disappears on drop/dragend
- Card editing — click text → editable; blur saves;
Nkey blocked while editing - Export/Import — export, modify state, import file, confirm full restore
- Build —
npm run buildpasses; all pages render
The Adversarial Review — ten bugs found in the AI's own code, before shipping
After the code was written and the build passed, gstack’s /ship workflow ran an adversarial code review — a separate Claude subagent tasked with thinking like an attacker and chaos engineer, explicitly told to find failure modes with no compliments allowed.
It found ten real issues. All ten were fixed before the push.
What was found
HIGH — uid() collision risk
The original uid() function used only 4 base-36 random characters: 36^4 = 1,679,616 possible values. Cards created within the same millisecond share a timestamp prefix, making collisions realistic under rapid-fire operations (adding many cards, importing a large file). A collision means two cards share an ID. moveCard uses findIndex which returns the first match — the wrong card gets moved silently. deleteCard uses filter which deletes both cards silently.
Fix: use crypto.randomUUID() with a longer fallback.
HIGH — localStorage quota silently swallowed
localStorage.setItem() throws QuotaExceededError when storage is full. The original save() had no try/catch. The exception propagated up to nothing. The UI showed “Auto-saved.” while the data was not actually saved. On next load, the user gets the previous state back silently — with no indication anything went wrong.
Fix: wrap setItem in try/catch, show ”⚠ Storage full — not saved!” on failure.
HIGH — import validation too shallow
The import validator only checked data.boards was a non-empty array. After that, state = data replaced everything with untrusted data. Multiple crash paths post-import:
- A board with 0 columns:
board.columns[0].idis accessed without a length guard when pressingNor clicking ”+ New Card” —Cannot read properties of undefined - A column without a
cardsarray:col.cards.forEachthrows immediately - A card with no
textproperty: renders as the string"undefined"silently - An empty
board.namefrom an imported file: the rename fallbacknewName || board.nameis'' || ''='', and the board gets stuck with no visible name permanently
Fix: deep-validate and repair each board, column, and card on import — sanitize rather than reject.
MEDIUM — deleteCard called during blur event (re-entrant DOM teardown)
When an empty card loses focus, the blur handler calls deleteCard(), which calls renderBoard(), which does area.innerHTML = '' — destroying the DOM node that is currently the target of the in-flight blur event. Chrome handles this fine, but on Safari the event continues dispatching on the detached node and can fire follow-on focusout events on ancestor elements, which can trigger the column rename handler unexpectedly.
Fix: defer with setTimeout(() => deleteCard(...), 0) so the blur event fully resolves before the DOM is torn down.
MEDIUM — tab drag doesn’t clear card drag globals
Tab drag and card drag both use the HTML5 Drag API and share the global dragSrcCardId variable. When a tab drag starts, dragSrcCardId is not cleared. If the browser loses focus mid-drag (alt-tab, mobile background) and dragend never fires, dragSrcCardId remains set. The next tab drag then has dragSrcCardId !== null, which causes the column drop handler to not early-return for tab drops — calling moveCard() with stale IDs while also reordering the tab.
Fix: explicitly clear dragSrcColId and dragSrcCardId at the start of every tab dragstart.
MEDIUM — drag opacity set via setTimeout (race condition)
The original code set card opacity with setTimeout(() => { cardEl.style.opacity = '0.4'; }, 0). Between dragstart firing and the callback running, opacity is not yet set. Any dragover event during that zero-delay window sees the dragging card with no opacity set. getDragAfterElement was filtering by parseFloat(el.style.opacity) !== 0.4 — but parseFloat('') is NaN, and NaN !== 0.4 is true, so the dragging card is included in position calculations for that first event. The drop indicator lands one position off.
Fix: use a CSS class .dragging { opacity: 0.4 } added synchronously in dragstart, and filter by classList.contains('dragging') instead.
MEDIUM — revokeObjectURL races with blob fetch on Firefox
In exportJSON(), URL.revokeObjectURL(url) was called immediately after a.click(). Chrome completes the blob fetch synchronously before revocation, so it works. Firefox does not — the blob URL is revoked before the fetch completes, producing an empty or failed download.
Fix: setTimeout(() => URL.revokeObjectURL(url), 100).
MEDIUM — addCard focuses new card by DOM position, not ID
The addCard function used a setTimeout that queried the last .card in the column to focus it. If the user double-clicks ”+ Add card” quickly, renderBoard() runs twice — adding two empty cards — and when the first setTimeout fires, lastEl is now the second empty card. The first empty card has no text, blurs, triggers deleteCard, and two interleaved deletions are in flight.
Fix: capture card.id before renderBoard(), then query [data-card-id="${newCardId}"] in the timeout — target by identity, not position.
LOW — board/column name stuck as empty string
If an imported board has name: "", the rename blur handler does board.name = newName || board.name. Both sides are falsy, so the empty name is preserved. Every subsequent rename attempt is a no-op. The board is stuck with no visible name.
Fix: board.name = newName || board.name || 'Board' — and same for column names.
MEDIUM — removeBoard didn’t verify ID before wiping last board
The last-board protection wiped state.boards[0].columns.forEach(col => { col.cards = [] }) without verifying that id === state.boards[0].id. A stale close-button closure (from a re-rendered tab) calling removeBoard with an old ID would silently wipe all cards from the wrong board.
Fix: if (state.boards[0].id !== id) return; before clearing.
Takeaway
The first pass of code — written by AI, with a clean build and a passing structural review — had three HIGH-severity bugs and five MEDIUM-severity bugs. None of them would have caused visible test failures. All of them would have surprised a real user in a real scenario.
The adversarial review found them in one pass. All ten were fixed in about ten minutes of targeted edits. The code that shipped is meaningfully better than the code that was written.
Lessons Learned — what this session demonstrates about AI-assisted development
This was a complete AI-assisted development session: planning, exploration, implementation, review, and deployment. A few things worth noting for developers who are thinking about adopting similar workflows.
1. The plan phase is not optional
Before writing a line of code, the AI explored the existing codebase — reading the Sticky Notes tool, the Dice Roller, the Astro layouts, the design system — and produced a concrete plan: data schema, function signatures, drag strategy, CSS architecture, files to modify. The plan was reviewed and approved before execution.
This matters because AI code without a plan tends to invent patterns instead of continuing existing ones. The plan forced the new tool to follow the same localStorage schema, tab management pattern, import/export convention, and design tokens as every other tool on the site. The result is code that looks like it belongs.
2. The adversarial review found bugs the structural review missed
The /ship workflow ran two reviews: a structured pre-landing review (checking for SQL injection, N+1 queries, trust boundary violations, dead code) and then a separate adversarial review. The structural review came back clean. The adversarial review found ten bugs.
The difference is framing. The structural review asks “is this code correct?” The adversarial review asks “how will this code fail?” Those are different questions, and they produce different results. An empty-string board name isn’t incorrect — it’s just an edge case nobody thought to test. A QuotaExceededError silently swallowed isn’t a logic bug — it’s a failure mode that only appears when your storage is full. The adversarial mindset surfaces these.
3. AI finds its own bugs better than humans find AI bugs
This is counterintuitive but I think it’s true. The adversarial subagent had full context on how the code worked — it understood the drag state machine, the closure structure, the event dispatch order — and was able to reason about interactions that a human reviewer scanning the same code would likely miss. The revokeObjectURL race condition on Firefox, the setTimeout opacity race in getDragAfterElement, the stale dragSrcCardId across tab drags — these require deep understanding of the specific code paths to find. The AI found all of them in a single pass.
Human code review is still valuable. But “have a human read the AI-generated code” is not the right mental model. “Have a different AI adversarially review the first AI’s work” is more effective.
4. Workflow rules compound over time
Two permanent rules were added to the project’s CLAUDE.md during this session:
- Always write a blog post at the end of every working session
- Featured Tools on the homepage must always show exactly 3 cards (newest in, oldest out)
These aren’t reminders — they’re constraints encoded into the AI’s instructions. Every future session inherits them automatically. The AI doesn’t ask, it doesn’t forget, it just does it. This is the right way to think about workflow automation: not “automate the task” but “encode the rule so the task never needs to be remembered.”
5. The tool that shipped is better than the tool that was written
The first version of the kanban tool was complete, functional, and built-clean. It also had three HIGH-severity bugs. The shipped version has none. The difference is entirely attributable to the adversarial review step — a step that adds maybe five minutes to the total workflow time.
The lesson isn’t “AI code is buggy.” The lesson is: all first-pass code has bugs, human or AI. The workflow that catches and fixes them before shipping is what matters.
This session used Claude Code for development and gstack by Garry Tan for the review and ship workflow. gstack is open source and available to anyone building with Claude Code.