Blog / Engineering

A Kanban board is a dashboard with opinions

October 13, 2026 · dashboards · kanban

dashboards kanban

We built two Kanban boards on Grafloria, a plain one and a full team board with covers, labels, checklists and comments. Neither needed a line of Kanban code in the library. A Kanban board turns out to be a dashboard with a few strong opinions: columns only trade places, cards keep their order, a full column says no. Each opinion is now a dashboard() option. Here is what they do, what the boards taught the kit, and the composer that closed itself.

A column is a section with opinions

A dashboard section is a tile that holds other tiles. A Kanban column is a section with one inner column of cards and four extra rules. In the plain board, one list looks like this:

dashboard({
  columns: 12, sizing: 'grow', float: false,
  autoHeight: true,                         // every card as tall as its content
  canDrop,                                  // WIP limits: a full column refuses a card
  widgets: COLUMNS.map((c) => ({
    id: c.id, title: c.name, span: 3, rows: inner + HEAD_ROWS, columns: 1, pinned: true,
    movable: 'row',                         // a column only trades places with its row
    resizable: false,
    background: c.tint,
    stack: true,                            // a list: cards keep their order
    caption: { text: c.name, height: HEAD_ROWS * (ROW + GAP), background: 'transparent', border: 'none' },
    maxRows: EMPTY_ROWS,
    widgets: CARDS[c.id].map(cardSpec),     // { id, kind: 'card', span: 1, rows, data }
  })),
  renderWidget: (w, host) => { if (w.kind === 'card') host.replaceChildren(paintCard(w.data)); },
  renderCaption: (w, host) => paintHead(w.id, host),
});

Everything else, from dragging a card between columns to undoing it, is the dashboard kit doing what it already did for any board.

The four opinions

movable: 'row': columns only trade places. On an ordinary board a section can go anywhere. A Kanban column cannot: drag "In progress" and it should swap with its neighbours, never drop into another column or below the row. With 'row', the carried column follows your hand, the sections on its row are relaid in the new order from the row's first column, and an order that would collide with something outside the row is not taken. The whole reorder is one undo step.

stack: true: cards keep their order. A dashboard grid normally places a dropped tile in the cell under the pointer. A list should not have cells: a card dropped in the middle goes between the two cards it is between. In a stack the insertion point is before the first card whose middle is below your hand, and the list takes exactly the rows its cards need, growing and giving rows back as cards come and go. That resize is layout, not an edit, so it never appears in the undo history.

autoHeight: cards are as tall as their content. A card with a cover image and three labels needs more height than one with a title. Rather than make you guess rows, the kit measures what was painted and rounds up to whole rows. It measures again when the width changes, when the content changes, after update() and after undo or redo. Like stacking, it is layout. On boards where heights are fixed by design (fit, split, and tab pages) it is ignored, with a single console warning that says why.

canDrop: a full column says no. Work-in-progress limits are one function:

// cards live in columns, and a column at its limit takes no more
function canDrop({ to }) {
  return COLUMNS.some((c) => c.id === to) && !isFull(to);
}

It is asked once per board per drag, never for the board the card came from. A refused card dims, nothing on that board moves out of its way, and on release it glides back home. While you drag, the column it would land in wears a blue ring, and one that refuses it an orange one. Code is trusted: addWidget, moveTo and undo are not asked, so a script can still put a fifth card in a column of four.

Bands that belong to the page

The full team board adds an "+ Add a card" band at the bottom of each list: footer: { height: 48 } on the section and a renderFooter to paint it. The kit reserves the height and draws the band, and presses on it belong to the page, so the button is a button, not a drag handle. A card dragged over the band lands at the end of the list. New lists come from addWidget with an empty widgets: [], one undo step like any other add. Inside a card or a caption, inputs and anything marked data-axdb-pass never start a drag, so editing a title in place does not pick the card up.

What the boards taught the kit

The plain board started as an experiment whose commit message says why it existed: to find the kit's gaps. It found them in one day. canDrop and autoHeight were the first two. Then came reorder-only sections, stack lists, tints and the drop ring. After that, runtime sections through addWidget and the footer band. Two behaviours came out of watching the board in use: a dropped card now glides home instead of jumping, and a carried list follows the hand. In the engine, a list keeps its order when one of its cards grows. All of it shipped together in @grafloria/element 0.5.1 on October 8.

Building the board was the cheapest way to find those gaps. A feature list would not have told us that a refused card should glide home rather than jump, or that a column being carried should stay under the hand; using the board did.

The composer that closed itself

The team board has a composer: press "+ Add a card", type a title, press Enter, and the card is added and a fresh composer opens for the next one. Except that on a slow machine the fresh one closed at once.

Enter called update() on the draft card, which repainted it, which replaced the draft's textarea with an ordinary card. That blurred the old textarea. Its blur handler waited 150 ms and then checked "is there a draft, and is focus outside my textarea?" There was a draft, but it was the new one, and focus was outside the old textarea because the old textarea no longer existed. So it finished the new, empty draft, and finishing an empty draft cancels it.

// before: any draft
ta.onblur = () => setTimeout(() => { if (draft && document.activeElement !== ta) finishDraft(); }, 150);
// after: only the draft this textarea belongs to
const mine = draft.id;
ta.onblur = () => setTimeout(() => { if (draft?.id === mine && document.activeElement !== ta) finishDraft(); }, 150);

The page's own check had covered this and passed. It checked that the composer was still open right after Enter, before the 150 ms timer had fired. On a fast machine the check ran first and saw an open composer; only slow CI ran it late enough to see the composer close. The check now waits past the timer. A test that asserts before the bug has had a chance to happen is not testing the bug.

Try them

The Kanban board has real Angular, React, Vue and Qwik ports behind its framework tabs. The Kanban studio is plain JavaScript, and its tabs say so. Fill "In progress" to its limit and drag a fourth card in, reorder the stages by their headers, and undo a cross-column move in one press. The options above are in @grafloria/element from 0.5.1 and in @grafloria/dashboard, MIT.