Kanban board page and browser data plane
Routes and runtime
web-src/index.tsx registers one page component for the keys '', ':boardId' and
':boardId/:cardId' at UI API 74, so /p/kanban, /p/kanban/<board> and
/p/kanban/<board>/<card> are the deep links alerts use. pagePath(boardId?, cardId?) in
web-src/runtime.ts writes them. The root page opens the first visible board with a replacing
navigation, or offers "New board" when there is none. Whole-page states (no board, a board that is not
visible, a board without columns for a member who cannot add them) are one EmptyState inside a
SettingsGroup, centred at most max-w-lg wide, exactly as Email draws its page without an account; loading
and failure keep their host components inside the page padding. The manifest pairs web.entry
with web.css: "web/index.css"; the host loads this generated utility stylesheet independently
of its own CSS, so plugin-only classes do not depend on the prebuilt host bundle.
runtime() narrows window.ElowenUiRuntime to the components, hooks and utilities the board and the
card detail draw. Components the kit types (Badge, AvatarStack, ContextMenu, the Sortable*
members, RowPicker, CardSelectCheckbox, SelectionActionBar and the rest) take the kit's own types;
untyped host components are narrowed to the props passed. AssertPublished fails the typecheck when a
name is not published. CardGridItem takes the kit's published surface-ref props; Button
additionally spells the ref used by a drag handle.
kanbanApi(route, { params, body, query? }) calls one kanbanRoutes entry through the host request path.
Method and path come from a table the compiler holds to the literal types of kanbanRoutes, so the
bundle does not import the server's Zod validators. Path parameters are URI-encoded; a 204 resolves
undefined. routeUrl(route, params) composes a same-origin address (attachment downloads, previews)
through apiUrl.
Data plane
Every read uses the host's query cache under the prefix ['plugin', 'kanban']:
useBoards(), useBoard(boardId) (the board snapshot) and useCard(boardId, cardId) (the card
detail). There is no plugin-local client or cache. The host invalidates every query on any plugin
event, so the backend's per-member board-changed event refetches open boards without a subscription.
useKanbanMutation(route, { optimistic?, optimisticCard?, onSuccess? }) is the one write path for a
board-scoped route. optimistic patches the cached snapshot and optimisticCard the cached detail in
onMutate, after cancelling their reads; a refusal restores both and is toasted. All kanban queries are
invalidated when the last concurrent kanban mutation settles, so an earlier answer cannot overwrite a
later optimistic step. Callers use mutate; the hook already reports a refusal.
useCreateBoard() and useHandoff() follow the same rules. The handoff predicts nothing and places
the server's card in the snapshot.
kanbanErrorMessage(error, s) turns a refusal into the plugin's localized sentence: the string of its
code, never server prose. A failure that never reached the plugin uses the host's apiErrorMessage.
kanbanMessage(id, s) names a message ID the server stored (a refused handoff's agentStatusReason);
the server stores only its own error message IDs, so any other ID throws.
Positions and drag
Columns, cards and checklist items carry gap integers (POSITION_GAP 1024). slotPosition(before, after) picks a free integer between two neighbours. When none is free it returns the position of
the item to precede. The server then moves every other item of that container with a position at or
after it down by one, and applyCardMove/applyColumnMove mirror that shift in the cache.
The board renders the host SortableBoard: one container per column, with itemIds set to the cards the
filters leave on screen and order set to the column's whole card order. SortableBoardItemMove.itemIds
is therefore the target column's whole order after a drop, and positionInOrder derives the new position
from the real neighbours, including hidden cards. Column moves use the same rule among columns. A mouse
drags a card from anywhere after a short travel, so a click still opens it. Touch and keyboard drag from
the card's grip, so a long press on the card opens its menu. Only a board's managers can drag columns.
onItemMove(move, onSettled) passes the shared board's acknowledgement to moveCard.mutate's native
onSettled option. The host retains the cross-column preview while onMutate awaits cache cancellation,
so the destination grip does not remount and lose focus. Success or rollback releases that preview.
When a refreshed snapshot removes a dragged card, the shared board discards its preview and overlay
and cancels the drop without calling the mutation; tile renderers only receive current snapshot IDs.
Board surface
On a phone the page owns the screen's edges (web.wordmark: false), so the board takes the shell's
--shell-gutter back and lines up with every other register; the columns scroll sideways inside it, and a
whole-page state centres in the page as Email's does. Columns and tiles are neutral surfaces with hairlines:
colour is kept for label badges, the column state dots and Chetty's state badges.
BoardHeader draws the board's name as the page's h1 (text-page-title, text-section-title on a phone),
then the board switcher: the host ActionMenu (kebab with a chevron trigger) listing every board with its
linked Project's ProjectIcon and the open one checked, and that Project beside it. On its right are the
members' AvatarStack in a ghost Button (opening the members dialog; a phone reaches Members from the board
menu), the board menu and "New card", which is a plus with the same name on a phone. The board menu starts
with "New board"; renaming, linking a Project and deletion appear only with the manage permission. Host type
roles outrank a bundle's responsive variants, so the title's role follows useMobileViewport() rather than a
breakpoint class. Every size, colour, spacing and radius on the page comes from the host roles and tokens and
every control is a host component at its own variant and size; tests/contract/pluginBundleDesignTokens.test.ts
guards the literals. The one raw button is a tile's title, the named open control CardGridItem asks its
caller to render (as the MCP and memory cards do). The toolbar is
the host PageToolbar with search, the three one-press filters (my cards, overdue, handed to Chetty) as
icon buttons named by aria-label and title with aria-pressed, and the Filters panel: the assignee
(avatars and @username) and label (colour dots, LabelDot) as RowPickers and the priority (signal icons)
as SelectMenu, whose choices become chips. Filtering is client-side over the snapshot.
A column header is one row: a grip for managers (a fine pointer sees it on hover or focus), the state dot
(review warning, done success, otherwise muted; the role is its spoken text and tooltip), the name, the
visible card count and a menu for managers. Host headings balance-wrap from an unlayered base rule that no
utility on the heading can override, so the name truncates in a block inside the h2. Rename is an inline
field. A role row is disabled while another column holds that role. Deletion is offered only when the
column's cardCount is zero and asks first.
Column.cardCount is the server's count of every card in the column, archived ones included, and the
server refuses to delete a column that still holds any. The cache keeps it coherent: applyCardMove,
applyBulkMove and replaceCard move counts with the cards, insertCard adds the created card,
removeCards (archive) keeps the count, and deleteCard (permanent deletion, active or archived) lowers
it. A card moved from its detail while archived changes only the counts. The settling refetch stays
authoritative. "Add card" and "Add column" are inline fields that keep themselves open for the next entry; the
created item joins the cache with the server's answer. AddCard is that card field.
A tile draws the title as its open control, then at most one icon row each for the assignee (Avatar and
name, or Chetty) and the due date (an overdue date in the muted danger ink, with the overdue word for screen
readers), Chetty's working, waiting or interrupted badge with the label badges, and a neutral Progress with
its percentage only when the card has a checklist. Priority, attachment and comment counts live in the
detail. The checkbox and the grip sit beside the title; a fine pointer sees them on hover or focus and while
any card is ticked. Its context menu
(boardMenus.ts) opens, renames in place, moves, assigns, toggles labels, sets the priority, opens the due-date
dialog, hands the card to Chetty or opens its conversation, and archives. The selection bar moves, assigns
and archives through the bulk route. It toggles a label with one setLabels per card, because the bulk
route replaces a card's whole label set.
Calendar view
The board has two views, and the address is their one owner: ?view=calendar shows CalendarView, anything
else the columns. The header's host Segmented switches them with a replacing navigation, and opening or
closing a card, switching boards and archiving the open card keep the suffix, so a deep link and a refresh
return to the same view. The month, the open day field and the expanded days are page state, not address.
calendarModel.ts is plain date arithmetic over local calendar dates (YYYY-MM-DD, the wire's due date):
monthGrid(month, locale) returns whole weeks from the one holding the 1st to the one holding the last day,
starting on Monday for cs and sk and on Sunday for en (the FIRST_WEEKDAY table, total over the app's
three languages, which runtime.ts types as AppLocale); weekdays gives the headings and cardsByDay the
dated cards per day in board order. The cards are the snapshot's after the board's own filters, so search and
filters apply to both views. There is no new read or route: the board snapshot carries every active card.
On a wider screen the month is an ARIA grid built from host primitives and tokens inside the shared
SortableBoard; it is the one surface the plugin draws itself. The weekday row keeps its own height and the
weeks share the rest equally. Each day is a container
(useSortableBoardContainer(iso, { disabled: true }), so days never move) and each chip an item a mouse drags
after a short travel. A chip is the shared Button at size="sm": default for an open card, outline for
one in the Done column, with its first label's LabelDot, the title and the assignee's Avatar. A drop on
another day is one optimistic updateCard with the day's dueDate; a drop on the same day changes nothing. A
day shows two chips and a ghost "+N" button that expands it in place (the host's MorePill is not published to
bundles). Its "+" (revealed on hover or focus for a fine pointer) or a click on its empty space opens AddCard
with that dueDate, which creates the card in the first column through the same createCard route, position
and validation as a column's field; "New card" opens today's. A chip opens the card detail. The keyboard and
the phone move a card with the detail's due-date field: chips have no drag handle. On a phone the month is a
list of its days (Agenda) that scrolls to today, with the same chips and "+". The list is relative, the
containing block of each row's screen-reader day label, so the labels scroll inside it; positioned outside
it, they once gave the shell's <main> a month of empty space to scroll into.
Detail and dialogs
BoardPage mounts CardDetailRail({ snapshot, cardId, onClose }) while the route names a card, and
BoardDialogs({ dialog, snapshot, onClose }) while a BoardDialog (boardModel.ts) is open:
createBoard, renameBoard, members, project, labels, archived, deleteBoard, or
due for one card. Both consume runtime.ts and hooks.ts; neither owns a second client or error mapping.
People pickers (assignee, member add, mention) show the Avatar, the name and a muted @username badge;
Project pickers show each ProjectIcon. Explanations such as Whole team and the Project link sit behind the
SettingsRow help mark instead of a paragraph, and an empty list (labels, checklist, attachments, comments)
shows only the control that adds the first entry.
Tests
tests/kanbanBoard.int.test.tsx renders the column, tiles and selection bar inside the candidate's real
SortableBoard through the registry UI fixture. It also covers positions, filters, request encoding,
optimistic rollback, the card menu, the month layout per language, the calendar's address, its day field and
its filters. The fixture publishes the API 74 SortableList, SortableBoard,
SortableBoardItems, AvatarStack and sortable hooks through @elowen-host/* aliases;
hostRuntimeParity asserts their identity with the candidate. The suite is integration because
BoardHeader reads Projects through the real host useProjects / elowenClient.projects() data plane,
which reaches fetch; its plugin API stub does not replace that host request.