Navigácia
ELOWEN · Dokumentácia

Posledná aktualizácia: 27. októbra 2026

Prechádzať dokumentáciu · Kanban board page and browser data plane

Tento článok zatiaľ nie je preložený. Je dostupný v angličtine.

Plugin development

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.