Skip to content
UI Kit
Accent color

React kanban board

Kanban boards with limits per column, cards you move by mouse, touch, keyboard or menu, swimlanes, a sprint with burndown, card details and filters.

5 variantsOne of 4 components in Tasks and planning

Cards that move with any input

A kanban board shows work as cards that travel from column to column, and the whole team sees where things stand. This one moves cards by mouse, touch, keyboard and menu, so lifting a card with Space and moving it with the arrow keys works as well as dragging.

Board has columns with work in progress limits that warn, fold away and take new cards. Swimlanes adds one row per person or priority, moving a card into another row hands it over or changes its priority. Sprint puts dates, goal, days left and done points on top, with a burndown against the ideal line and points per column that follow every move. Card details opens a card next to the board with status, people, due date, labels, points, a checklist and comments, as a sheet on phones. Compact with filters packs long boards densely and filters by search, label and person with a live count.

Set limits on the columns where work piles up, often review, the warning then shows a jam before it hurts. Keep labels to a handful of colors. The board takes columns, cards, people and tags as props, so it shows your data from the first render.

Variants

BoardNew in 1.2

Columns with work in progress limits that warn, fold away and take new cards. Drag cards, or lift them with Space and move them with the arrow keys.

SwimlanesNew in 1.2

One row per person or priority across the same columns. Moving a card into another row hands it over or changes its priority.

SprintNew in 1.2

Sprint head with dates, goal, days left and done points, a burndown against the ideal line and points per column that follow every move.

Card detailsNew in 1.2

The board next to the open card with status, people, due date, labels, points, a checklist with progress and comments. On phones the card opens as a sheet.

Compact with filtersNew in 1.2

Dense columns for long boards, filtered by search, label and person, with a live count of the cards on show.

Details

What Kanban board brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/kanban-board.tsx
npm packages
None
Languages
English and German, 90 texts of its own
Studio
Adjust in code
In collections
SaaS app screens

Props

The KanbanBoardProps interface from components/ui/kanban-board.tsx, with the comments from the code.

  • variantoptional
    KanbanBoardVariant

    boardswimlanessprintcard-detailcompact

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlerequired
    string
  • subtitleoptional
    string
  • columnsoptional
    KanbanColumn[]
  • cardsoptional
    KanbanCard[]

    All cards, in order. The order inside a column is the order here.

  • tagsoptional
    KanbanLabel[]

    Labels the cards refer to by id

  • peopleoptional
    KanbanPerson[]
  • meoptional
    string

    Person id of the signed in user (comments)

  • todayoptional
    string

    YYYY-MM-DD that counts as today

  • lanesoptional
    'person' | 'priority'

    swimlanes: one row per person or per priority

  • sprintoptional
    KanbanSprint

    sprint

  • selectedoptional
    string

    card-detail: the card open at the start

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on move, add, update, comment and complete-sprint. Without it the block simulates the request.

Built-in texts

Kanban board writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 90 texts
newCardEnglish New cardGerman Neue Karte
addCardEnglish Add cardGerman Karte hinzufügen
addCardToEnglish Add a card to {column}German Karte zu {column} hinzufügen
cardTitleEnglish What needs doing?German Was steht an?
addEnglish Add cardGerman Karte anlegen
cancelEnglish CancelGerman Abbrechen
addedEnglish {card} added to {column}.German {card} in {column} angelegt.
countOfEnglish {n} of {limit}German {n} von {limit}
overLimitEnglish {n} over the limitGerman {n} über dem Limit
atLimitEnglish At the limitGerman Limit erreicht
collapseEnglish Collapse {column}German {column} einklappen
expandEnglish Expand {column}German {column} ausklappen
moveToEnglish Move toGerman Verschieben nach
moveUpEnglish Move upGerman Nach oben
moveDownEnglish Move downGerman Nach unten
openCardEnglish Open detailsGerman Details öffnen
cardMenuEnglish Actions for {card}German Aktionen für {card}
dragHintEnglish Press Space or Enter to pick up a card. The arrow keys move it, Space or Enter drops it, Escape puts it back.German Leertaste oder Enter nimmt eine Karte auf. Die Pfeiltasten verschieben sie, Leertaste oder Enter legt sie ab, Escape legt sie zurück.
dragHintDetailEnglish Press Enter to open a card and Space to pick it up. The arrow keys move it, Space drops it, Escape puts it back.German Enter öffnet eine Karte, die Leertaste nimmt sie auf. Die Pfeiltasten verschieben sie, die Leertaste legt sie ab, Escape legt sie zurück.
grabbedEnglish {card} picked up. {column}, position {pos} of {n}.German {card} aufgenommen. {column}, Position {pos} von {n}.
movedKbEnglish {column}, position {pos} of {n}.German {column}, Position {pos} von {n}.
droppedEnglish {card} dropped in {column}, position {pos} of {n}.German {card} in {column} abgelegt, Position {pos} von {n}.
movedEnglish {card} moved to {column}.German {card} nach {column} verschoben.
cancelledEnglish {card} is back in {column}.German {card} liegt wieder in {column}.
overNoteEnglish {column} is over its limit.German {column} ist über dem Limit.
draggableEnglish draggable cardGerman verschiebbare Karte
overdueEnglish OverdueGerman Überfällig
dueTodayEnglish TodayGerman Heute
dueTomorrowEnglish TomorrowGerman Morgen
dueOnEnglish Due {date}German Fällig {date}
pointsEnglish {n} pointsGerman {n} Punkte
ptsEnglish {n} ptsGerman {n} Pkt.
checklistEnglish {done} of {n} doneGerman {done} von {n} erledigt
commentsEnglish {n} commentsGerman {n} Kommentare
assignedEnglish Assigned to {names}German Zugewiesen an {names}
unassignedEnglish Not assignedGerman Nicht zugewiesen
priorityEnglish Priority {p}German Priorität {p}
urgentEnglish UrgentGerman Dringend
highEnglish HighGerman Hoch
mediumEnglish MediumGerman Mittel
lowEnglish LowGerman Niedrig
noneEnglish No priorityGerman Ohne Priorität
columnsEnglish ColumnsGerman Spalten
dropHereEnglish Drop hereGerman Hier ablegen
emptyEnglish Nothing here yetGerman Noch leer
noMatchEnglish No card matchesGerman Keine Karte passt
undoEnglish UndoGerman Rückgängig
laneEnglish {n} cardsGerman {n} Karten
collapseLaneEnglish Collapse {lane}German {lane} einklappen
expandLaneEnglish Expand {lane}German {lane} ausklappen
daysLeftEnglish {n} days leftGerman Noch {n} Tage
lastDayEnglish Last dayGerman Letzter Tag
burndownEnglish BurndownGerman Burndown
idealEnglish IdealGerman Ideal
remainingEnglish Open pointsGerman Offene Punkte
pointsDoneEnglish {done} of {total} points doneGerman {done} von {total} Punkten erledigt
completeSprintEnglish Complete sprintGerman Sprint abschließen
completingEnglish Closing…German Wird abgeschlossen…
completedEnglish Sprint closed. {n} open cards move to the next sprint.German Sprint abgeschlossen. {n} offene Karten gehen in den nächsten Sprint.
goalEnglish Sprint goalGerman Sprintziel
cardsDoneEnglish Cards doneGerman Karten erledigt
velocityEnglish Points a dayGerman Punkte am Tag
paceEnglish PaceGerman Tempo
onTrackEnglish On trackGerman Im Plan
behindEnglish {n} points behindGerman {n} Punkte zurück
detailsEnglish Card detailsGerman Kartendetails
titleEnglish TitleGerman Titel
statusEnglish StatusGerman Status
assigneesEnglish PeopleGerman Personen
dueDateEnglish Due dateGerman Fällig am
labelsTitleEnglish LabelsGerman Labels
estimateEnglish PointsGerman Punkte
descriptionEnglish DescriptionGerman Beschreibung
descPlaceholderEnglish Add the details the team needsGerman Ergänze, was das Team wissen muss
checklistTitleEnglish ChecklistGerman Checkliste
addItemEnglish Add itemGerman Punkt hinzufügen
itemPlaceholderEnglish Add an itemGerman Punkt hinzufügen
removeItemEnglish Remove {item}German {item} entfernen
commentsTitleEnglish CommentsGerman Kommentare
commentPlaceholderEnglish Write a commentGerman Kommentar schreiben
postEnglish Post commentGerman Kommentar senden
noCardEnglish Pick a card to see its details here.German Wähle eine Karte, dann stehen hier ihre Details.
searchEnglish Search cardsGerman Karten suchen
filterLabelsEnglish Filter by labelGerman Nach Label filtern
filterPeopleEnglish Filter by personGerman Nach Person filtern
showingEnglish {n} of {total} cardsGerman {n} von {total} Karten
clearEnglish Show allGerman Alle zeigen
showOfEnglish Show cards of {name}German Karten von {name} zeigen
showLabelEnglish Show cards with {name}German Karten mit {name} zeigen
justEnglish just nowGerman gerade eben

Accessibility

Read from the source of the component.

  • Kanban board handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Tab and Space itself.
  • Sets aria-checked, aria-current, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed and aria-roledescription, so screen readers announce purpose and state.
  • Uses the roles button, checkbox, group, radio, radiogroup and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,791 lines
components/ui/kanban-board.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, formatPercent, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { DropdownMenu, type MenuItem } from '@/components/ui/dropdown-menu';
import { Icon } from '@/components/ui/icon';
import { LineChart } from '@/components/ui/line-chart';
import { useScrollFade } from '@/components/ui/motion';
import { Sheet } from '@/components/ui/sheet';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type KanbanBoardVariant = 'board' | 'swimlanes' | 'sprint' | 'card-detail' | 'compact';

export type KanbanTone = 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
export type KanbanPriority = 'urgent' | 'high' | 'medium' | 'low';

export interface KanbanLabel {
  id: string;
  name: string;
  tone?: KanbanTone;
}

export interface KanbanPerson {
  id: string;
  name: string;
}

export interface KanbanColumn {
  id: string;
  title: string;
  /** Work in progress limit. The column warns once it holds more cards. */
  limit?: number;
  /** Cards here count as done (sprint points, burndown) */
  done?: boolean;
}

export interface KanbanComment {
  id: string;
  /** Person id */
  author: string;
  text: string;
  /** ISO date and time */
  time: string;
}

export interface KanbanCard {
  id: string;
  title: string;
  /** Column id */
  column: string;
  /** Short reference, e.g. NW-142 */
  key?: string;
  /** Label ids */
  tags?: string[];
  /** Person ids, the first one is the owner */
  assignees?: string[];
  /** YYYY-MM-DD */
  due?: string;
  points?: number;
  priority?: KanbanPriority;
  description?: string;
  checklist?: { id: string; text: string; done: boolean }[];
  comments?: KanbanComment[];
}

export interface KanbanSprint {
  name: string;
  /** YYYY-MM-DD, first and last working day */
  start: string;
  end: string;
  goal?: string;
  /** Points still open at the end of each working day before today */
  remaining?: number[];
}

export interface KanbanBoardProps {
  variant?: KanbanBoardVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  columns?: KanbanColumn[];
  /** All cards, in order. The order inside a column is the order here. */
  cards?: KanbanCard[];
  /** Labels the cards refer to by id */
  tags?: KanbanLabel[];
  people?: KanbanPerson[];
  /** Person id of the signed in user (comments) */
  me?: string;
  /** YYYY-MM-DD that counts as today */
  today?: string;
  /** swimlanes: one row per person or per priority */
  lanes?: 'person' | 'priority';
  /** sprint */
  sprint?: KanbanSprint;
  /** card-detail: the card open at the start */
  selected?: string;
  /** Called on move, add, update, comment and complete-sprint. Without it the block simulates the request. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    newCard: 'New card',
    addCard: 'Add card',
    addCardTo: 'Add a card to {column}',
    cardTitle: 'What needs doing?',
    add: 'Add card',
    cancel: 'Cancel',
    added: '{card} added to {column}.',
    countOf: '{n} of {limit}',
    overLimit: '{n} over the limit',
    atLimit: 'At the limit',
    collapse: 'Collapse {column}',
    expand: 'Expand {column}',
    moveTo: 'Move to',
    moveUp: 'Move up',
    moveDown: 'Move down',
    openCard: 'Open details',
    cardMenu: 'Actions for {card}',
    dragHint: 'Press Space or Enter to pick up a card. The arrow keys move it, Space or Enter drops it, Escape puts it back.',
    dragHintDetail: 'Press Enter to open a card and Space to pick it up. The arrow keys move it, Space drops it, Escape puts it back.',
    grabbed: '{card} picked up. {column}, position {pos} of {n}.',
    movedKb: '{column}, position {pos} of {n}.',
    dropped: '{card} dropped in {column}, position {pos} of {n}.',
    moved: '{card} moved to {column}.',
    cancelled: '{card} is back in {column}.',
    overNote: ' {column} is over its limit.',
    draggable: 'draggable card',
    overdue: 'Overdue',
    dueToday: 'Today',
    dueTomorrow: 'Tomorrow',
    dueOn: 'Due {date}',
    points: '{n} points',
    pts: '{n} pts',
    checklist: '{done} of {n} done',
    comments: '{n} comments',
    assigned: 'Assigned to {names}',
    unassigned: 'Not assigned',
    priority: 'Priority {p}',
    urgent: 'Urgent',
    high: 'High',
    medium: 'Medium',
    low: 'Low',
    none: 'No priority',
    columns: 'Columns',
    dropHere: 'Drop here',
    empty: 'Nothing here yet',
    noMatch: 'No card matches',
    undo: 'Undo',
    lane: '{n} cards',
    collapseLane: 'Collapse {lane}',
    expandLane: 'Expand {lane}',
    daysLeft: '{n} days left',
    lastDay: 'Last day',
    burndown: 'Burndown',
    ideal: 'Ideal',
    remaining: 'Open points',
    pointsDone: '{done} of {total} points done',
    completeSprint: 'Complete sprint',
    completing: 'Closing…',
    completed: 'Sprint closed. {n} open cards move to the next sprint.',
    goal: 'Sprint goal',
    cardsDone: 'Cards done',
    velocity: 'Points a day',
    pace: 'Pace',
    onTrack: 'On track',
    behind: '{n} points behind',
    details: 'Card details',
    title: 'Title',
    status: 'Status',
    assignees: 'People',
    dueDate: 'Due date',
    labelsTitle: 'Labels',
    estimate: 'Points',
    description: 'Description',
    descPlaceholder: 'Add the details the team needs',
    checklistTitle: 'Checklist',
    addItem: 'Add item',
    itemPlaceholder: 'Add an item',
    removeItem: 'Remove {item}',
    commentsTitle: 'Comments',
    commentPlaceholder: 'Write a comment',
    post: 'Post comment',
    noCard: 'Pick a card to see its details here.',
    search: 'Search cards',
    filterLabels: 'Filter by label',
    filterPeople: 'Filter by person',
    showing: '{n} of {total} cards',
    clear: 'Show all',
    showOf: 'Show cards of {name}',
    showLabel: 'Show cards with {name}',
    just: 'just now',
  },
  de: {
    newCard: 'Neue Karte',
    addCard: 'Karte hinzufügen',
    addCardTo: 'Karte zu {column} hinzufügen',
    cardTitle: 'Was steht an?',
    add: 'Karte anlegen',
    cancel: 'Abbrechen',
    added: '{card} in {column} angelegt.',
    countOf: '{n} von {limit}',
    overLimit: '{n} über dem Limit',
    atLimit: 'Limit erreicht',
    collapse: '{column} einklappen',
    expand: '{column} ausklappen',
    moveTo: 'Verschieben nach',
    moveUp: 'Nach oben',
    moveDown: 'Nach unten',
    openCard: 'Details öffnen',
    cardMenu: 'Aktionen für {card}',
    dragHint: 'Leertaste oder Enter nimmt eine Karte auf. Die Pfeiltasten verschieben sie, Leertaste oder Enter legt sie ab, Escape legt sie zurück.',
    dragHintDetail: 'Enter öffnet eine Karte, die Leertaste nimmt sie auf. Die Pfeiltasten verschieben sie, die Leertaste legt sie ab, Escape legt sie zurück.',
    grabbed: '{card} aufgenommen. {column}, Position {pos} von {n}.',
    movedKb: '{column}, Position {pos} von {n}.',
    dropped: '{card} in {column} abgelegt, Position {pos} von {n}.',
    moved: '{card} nach {column} verschoben.',
    cancelled: '{card} liegt wieder in {column}.',
    overNote: ' {column} ist über dem Limit.',
    draggable: 'verschiebbare Karte',
    overdue: 'Überfällig',
    dueToday: 'Heute',
    dueTomorrow: 'Morgen',
    dueOn: 'Fällig {date}',
    points: '{n} Punkte',
    pts: '{n} Pkt.',
    checklist: '{done} von {n} erledigt',
    comments: '{n} Kommentare',
    assigned: 'Zugewiesen an {names}',
    unassigned: 'Nicht zugewiesen',
    priority: 'Priorität {p}',
    urgent: 'Dringend',
    high: 'Hoch',
    medium: 'Mittel',
    low: 'Niedrig',
    none: 'Ohne Priorität',
    columns: 'Spalten',
    dropHere: 'Hier ablegen',
    empty: 'Noch leer',
    noMatch: 'Keine Karte passt',
    undo: 'Rückgängig',
    lane: '{n} Karten',
    collapseLane: '{lane} einklappen',
    expandLane: '{lane} ausklappen',
    daysLeft: 'Noch {n} Tage',
    lastDay: 'Letzter Tag',
    burndown: 'Burndown',
    ideal: 'Ideal',
    remaining: 'Offene Punkte',
    pointsDone: '{done} von {total} Punkten erledigt',
    completeSprint: 'Sprint abschließen',
    completing: 'Wird abgeschlossen…',
    completed: 'Sprint abgeschlossen. {n} offene Karten gehen in den nächsten Sprint.',
    goal: 'Sprintziel',
    cardsDone: 'Karten erledigt',
    velocity: 'Punkte am Tag',
    pace: 'Tempo',
    onTrack: 'Im Plan',
    behind: '{n} Punkte zurück',
    details: 'Kartendetails',
    title: 'Titel',
    status: 'Status',
    assignees: 'Personen',
    dueDate: 'Fällig am',
    labelsTitle: 'Labels',
    estimate: 'Punkte',
    description: 'Beschreibung',
    descPlaceholder: 'Ergänze, was das Team wissen muss',
    checklistTitle: 'Checkliste',
    addItem: 'Punkt hinzufügen',
    itemPlaceholder: 'Punkt hinzufügen',
    removeItem: '{item} entfernen',
    commentsTitle: 'Kommentare',
    commentPlaceholder: 'Kommentar schreiben',
    post: 'Kommentar senden',
    noCard: 'Wähle eine Karte, dann stehen hier ihre Details.',
    search: 'Karten suchen',
    filterLabels: 'Nach Label filtern',
    filterPeople: 'Nach Person filtern',
    showing: '{n} von {total} Karten',
    clear: 'Alle zeigen',
    showOf: 'Karten von {name} zeigen',
    showLabel: 'Karten mit {name} zeigen',
    just: 'gerade eben',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/* ------------------------------------------------------------------ */
/* Motion                                                              */
/* ------------------------------------------------------------------ */
/*
 * kbn-up: entrance once the board scrolls in (data-kbn on the scope).
 * kbn-pop: a count that changed. kbn-flash: a card that just landed.
 * kbn-in: something that appears after an action. The server renders the
 * end state, with reduced motion nothing moves.
 */
const CSS =
  '@keyframes kbn-up{from{opacity:0;transform:translateY(10px)}}@keyframes kbn-pop{0%{transform:scale(.7)}60%{transform:scale(1.15)}}@keyframes kbn-flash{from{box-shadow:0 0 0 3px color-mix(in oklab,var(--primary) 40%,transparent)}}@keyframes kbn-tick{from{stroke-dashoffset:14}}@keyframes kbn-grow{from{transform:scaleX(0)}}' +
  '.kbn-grow{transform-origin:left}.kbn-ghost{transform-origin:30% 20%}' +
  '@media (prefers-reduced-motion:no-preference){[data-kbn=play] .kbn-up{animation:kbn-up .6s var(--ease-kit) var(--kbn-d,0ms) both}[data-kbn=wait] .kbn-up{opacity:0}[data-kbn=play] .kbn-grow{animation:kbn-grow 1s var(--ease-kit) .2s both}.kbn-pop{animation:kbn-pop .4s var(--ease-kit)}.kbn-flash{animation:kbn-flash 1.4s ease-out}.kbn-in{animation:kbn-up .4s var(--ease-kit) both}.kbn-tick{stroke-dasharray:14;animation:kbn-tick .3s var(--ease-kit) both}.kbn-ghost>*{animation:kbn-lift .18s var(--ease-kit) both}@keyframes kbn-lift{from{transform:none;box-shadow:var(--shadow-sm)}}}';

function Style() {
  return (
    <style href="kitb5-kanban-board" precedence="default">
      {CSS}
    </style>
  );
}

function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(([e]) => {
      if (!e?.isIntersecting) return;
      setState('play');
      io.disconnect();
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kbn': state }] as const;
}

function useWide(query = '(min-width: 1024px)') {
  const [wide, setWide] = useState(true);
  useEffect(() => {
    const m = window.matchMedia(query);
    const on = () => setWide(m.matches);
    on();
    m.addEventListener('change', on);
    return () => m.removeEventListener('change', on);
  }, [query]);
  return wide;
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

const TONE: Record<KanbanTone, string> = {
  accent: 'bg-[color-mix(in_oklab,var(--primary)_13%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
  success: 'bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground',
  warning: 'bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-warning-foreground',
  danger: 'bg-[color-mix(in_oklab,var(--destructive)_12%,var(--card))] text-destructive',
  info: 'bg-[color-mix(in_oklab,var(--info)_15%,var(--card))] text-[color-mix(in_oklab,var(--info)_70%,var(--foreground))]',
  neutral: 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-muted-foreground',
};

const PRIO: Record<KanbanPriority, { bars: number; tone: string }> = {
  urgent: { bars: 3, tone: 'text-destructive' },
  high: { bars: 3, tone: 'text-warning-foreground' },
  medium: { bars: 2, tone: 'text-muted-foreground' },
  low: { bars: 1, tone: 'text-subtle-foreground' },
};

const DOT = ['bg-subtle-foreground', 'bg-primary', 'bg-warning', 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--foreground))]', 'bg-success'];

function PrioBars({ p, t }: { p: KanbanPriority; t: Texts }) {
  const s = PRIO[p];
  return (
    <span className={cn('inline-flex shrink-0 items-end gap-[2px]', s.tone)} title={fill(t.priority, { p: t[p] })}>
      <span className="sr-only">{fill(t.priority, { p: t[p] })}</span>
      {[0, 1, 2].map((i) => (
        <span key={i} aria-hidden className={cn('w-[3px] rounded-[1px]', i < s.bars ? 'bg-current' : 'bg-track')} style={{ height: 5 + i * 3 }} />
      ))}
      {p === 'urgent' && <Icon name="alert-circle" size={12} className="ms-1" />}
    </span>
  );
}

const dayMs = 86400000;
const dayNum = (iso: string) => Math.round(Date.parse(iso.slice(0, 10) + 'T00:00:00Z') / dayMs);
const tag = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const fmtDay = (iso: string, l: Locale, o: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' }) => new Intl.DateTimeFormat(tag(l), { ...o, timeZone: 'UTC' }).format(new Date(dayNum(iso) * dayMs));

function dueOf(due: string, today: string, l: Locale, t: Texts) {
  const d = dayNum(due) - dayNum(today);
  if (d < 0) return { text: fmtDay(due, l), tone: 'overdue' as const, sr: `${t.overdue}, ${fmtDay(due, l)}` };
  if (d === 0) return { text: t.dueToday, tone: 'soon' as const, sr: fill(t.dueOn, { date: t.dueToday }) };
  if (d === 1) return { text: t.dueTomorrow, tone: 'soon' as const, sr: fill(t.dueOn, { date: t.dueTomorrow }) };
  return { text: fmtDay(due, l), tone: 'later' as const, sr: fill(t.dueOn, { date: fmtDay(due, l) }) };
}

function ago(l: Locale, iso: string, now: string, t: Texts) {
  const rtf = new Intl.RelativeTimeFormat(tag(l), { numeric: 'auto' });
  const m = Math.round((Date.parse(iso + 'Z') - Date.parse(now + 'Z')) / 60000);
  if (Math.abs(m) < 1) return t.just;
  if (Math.abs(m) < 60) return rtf.format(m, 'minute');
  if (Math.abs(m) < 1440) return rtf.format(Math.round(m / 60), 'hour');
  return rtf.format(Math.round(m / 1440), 'day');
}

const field =
  'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const iconBtn = cn('grid size-11 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:size-8', ring);

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full min-w-0 max-w-[112rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Toast({ text, onUndo, t }: { text: string; onUndo?: () => void; t: Texts }) {
  if (!text) return null;
  return (
    <div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
      <div key={text} className="kbn-in pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-popover py-2 ps-4 pe-2 text-[13.5px] text-popover-foreground shadow-[var(--shadow-lg)]">
        <Icon name="check-circle" size={16} className="shrink-0 text-success-foreground" />
        <span className="min-w-0 flex-1 text-pretty">{text}</span>
        {onUndo && (
          <button type="button" onClick={onUndo} className={cn('h-11 shrink-0 cursor-pointer rounded-md px-3 text-[13px] font-semibold text-foreground hover:bg-accent sm:h-8', ring)}>
            {t.undo}
          </button>
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Card                                                                */
/* ------------------------------------------------------------------ */

interface Ctx {
  t: Texts;
  locale: Locale;
  today: string;
  tag: (id: string) => KanbanLabel | undefined;
  person: (id: string) => KanbanPerson | undefined;
}

function CardBody({ card, ctx, dense, menu }: { card: KanbanCard; ctx: Ctx; dense?: boolean; menu?: React.ReactNode }) {
  const { t, locale, today } = ctx;
  const tags = (card.tags ?? []).map(ctx.tag).filter((x): x is KanbanLabel => !!x);
  const people = (card.assignees ?? []).map(ctx.person).filter((x): x is KanbanPerson => !!x);
  const due = card.due ? dueOf(card.due, today, locale, t) : null;
  const list = card.checklist ?? [];
  const done = list.filter((x) => x.done).length;
  return (
    <>
      {dense ? (
        <div className="flex min-h-6 items-start gap-1.5 [&>:first-child]:mt-1">
          {card.priority && <PrioBars p={card.priority} t={t} />}
          <p className="min-w-0 flex-1 text-pretty text-[13px] leading-snug font-medium text-foreground">{card.title}</p>
          <span className="-me-1.5 -my-1.5">{menu}</span>
        </div>
      ) : (
        <>
          <div className="flex min-h-6 items-center gap-1.5">
            {card.priority && <PrioBars p={card.priority} t={t} />}
            {card.key && <span className="shrink-0 text-[11.5px] font-medium tabular-nums text-subtle-foreground">{card.key}</span>}
            <span className="ms-auto -me-1.5 -my-1">{menu}</span>
          </div>
          <p className="mt-0.5 line-clamp-2 text-pretty text-[14px] font-medium leading-snug text-foreground">{card.title}</p>
          {tags.length > 0 && (
            <span className="mt-2 flex flex-wrap gap-1">
              {tags.map((x) => (
                <span key={x.id} className={cn('rounded px-1.5 py-px text-[11px] font-medium', TONE[x.tone ?? 'neutral'])}>
                  {x.name}
                </span>
              ))}
            </span>
          )}
        </>
      )}
      <div className={cn('flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[12px] text-muted-foreground', dense ? 'mt-1' : 'mt-3')}>
        {dense && card.key && <span className="font-medium tabular-nums text-subtle-foreground">{card.key}</span>}
        {due && (
          <span className={cn('inline-flex items-center gap-1 tabular-nums', due.tone === 'overdue' ? 'font-medium text-destructive' : due.tone === 'soon' ? 'font-medium text-warning-foreground' : '')}>
            <Icon name={due.tone === 'overdue' ? 'alert-circle' : 'calendar'} size={12} />
            <span aria-hidden>{due.text}</span>
            <span className="sr-only">{due.sr}</span>
          </span>
        )}
        {list.length > 0 && (
          <span className={cn('inline-flex items-center gap-1 tabular-nums', done === list.length && 'text-success-foreground')}>
            <Icon name="check-square" size={12} />
            <span aria-hidden>
              {done}/{list.length}
            </span>
            <span className="sr-only">{fill(t.checklist, { done, n: list.length })}</span>
          </span>
        )}
        {!dense && (card.comments?.length ?? 0) > 0 && (
          <span className="inline-flex items-center gap-1 tabular-nums">
            <Icon name="message-circle" size={12} />
            <span aria-hidden>{card.comments!.length}</span>
            <span className="sr-only">{fill(t.comments, { n: card.comments!.length })}</span>
          </span>
        )}
        <span className="ms-auto flex items-center gap-2">
          {card.points != null && (
            <span className="inline-grid h-5 min-w-5 place-items-center rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] px-1.5 text-[11px] font-semibold tabular-nums text-foreground" title={fill(t.points, { n: card.points })}>
              <span aria-hidden>{card.points}</span>
              <span className="sr-only">{fill(t.points, { n: card.points })}</span>
            </span>
          )}
          {people.length > 0 && (
            <span className="flex -space-x-1.5" title={fill(t.assigned, { names: people.map((p) => p.name).join(', ') })}>
              <span className="sr-only">{fill(t.assigned, { names: people.map((p) => p.name).join(', ') })}</span>
              {people.slice(0, 3).map((p) => (
                <span key={p.id} aria-hidden className="rounded-full ring-2 ring-card">
                  <Avatar name={p.name} size="xs" />
                </span>
              ))}
            </span>
          )}
        </span>
      </div>
    </>
  );
}

/* ------------------------------------------------------------------ */
/* Board engine                                                        */
/* ------------------------------------------------------------------ */

export interface Lane {
  id: string;
  label: string;
  person?: string;
}

interface BoardProps {
  ctx: Ctx;
  columns: KanbanColumn[];
  cards: KanbanCard[];
  setCards: (next: KanbanCard[] | ((c: KanbanCard[]) => KanbanCard[])) => void;
  lanes?: Lane[];
  laneOf?: (c: KanbanCard) => string;
  toLane?: (c: KanbanCard, lane: string) => KanbanCard;
  visible?: (c: KanbanCard) => boolean;
  dense?: boolean;
  openOnEnter?: boolean;
  selected?: string | null;
  onOpen?: (id: string) => void;
  headExtra?: (col: KanbanColumn, cards: KanbanCard[]) => React.ReactNode;
  onAction?: KanbanBoardProps['onAction'];
  /** Ask the board to open the add form in a column (header button) */
  addRequest?: { col: string; n: number } | null;
  className?: string;
}

const NONE = '';
const keyOf = (lane: string, col: string) => `${lane}|${col}`;
const splitKey = (k: string) => {
  const i = k.lastIndexOf('|');
  return [k.slice(0, i), k.slice(i + 1)] as const;
};

/** Takes a card out and puts it back in its new list, before another card or at the end */
function placeCard(cards: KanbanCard[], id: string, update: (c: KanbanCard) => KanbanCard, before: string | null, listOf: (c: KanbanCard) => string) {
  const card = cards.find((c) => c.id === id);
  if (!card) return cards;
  const moved = update(card);
  const rest = cards.filter((c) => c.id !== id);
  let at = before ? rest.findIndex((c) => c.id === before) : -1;
  if (at < 0) {
    const k = listOf(moved);
    let last = -1;
    rest.forEach((c, i) => {
      if (listOf(c) === k) last = i;
    });
    at = last < 0 ? rest.length : last + 1;
  }
  rest.splice(at, 0, moved);
  return rest;
}

interface DragState {
  id: string;
  list: string;
  before: string | null;
  w: number;
}

function Board({ ctx, columns, cards, setCards, lanes, laneOf = () => NONE, toLane = (c) => c, visible = () => true, dense, openOnEnter, selected, onOpen, headExtra, onAction, addRequest, className }: BoardProps) {
  const { t } = ctx;
  const id = useId();
  const domId = (cid: string) => `${id}-c-${cid}`;
  const [collapsed, setCollapsed] = useState<string[]>([]);
  const [laneShut, setLaneShut] = useState<string[]>([]);
  const [adding, setAdding] = useState<string | null>(null);
  const [draft, setDraft] = useState('');
  const [drag, setDrag] = useState<DragState | null>(null);
  const [kb, setKb] = useState<{ id: string; origin: KanbanCard[] } | null>(null);
  const [say, setSay] = useState('');
  const [toast, setToast] = useState<{ text: string; undo?: KanbanCard[] } | null>(null);
  const [fresh, setFresh] = useState<string | null>(null);
  const [mcol, setMcol] = useState(0);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const [fadeRef, fade] = useScrollFade<HTMLDivElement>();
  const [tabsRef, tabsFade] = useScrollFade<HTMLElement>();
  const scroller = useRef<HTMLDivElement | null>(null);
  const ghost = useRef<HTMLDivElement>(null);
  const suppress = useRef(false);
  const toastTimer = useRef<number>(0);
  const mounted = useRef(false);
  useEffect(() => {
    mounted.current = true;
  }, []);

  const laneIds = lanes?.map((l) => l.id) ?? [NONE];
  const openCols = columns.filter((c) => lanes || !collapsed.includes(c.id));
  const listOf = (c: KanbanCard) => keyOf(laneOf(c), c.column);
  const inList = (list: KanbanCard[], lane: string, col: string) => list.filter((c) => c.column === col && laneOf(c) === lane && visible(c));
  const colName = (cid: string) => columns.find((c) => c.id === cid)?.title ?? cid;
  const laneName = (lid: string) => lanes?.find((l) => l.id === lid)?.label ?? '';
  const where = (lane: string, col: string) => (lanes ? `${laneName(lane)}, ${colName(col)}` : colName(col));
  const over = (list: KanbanCard[], col: string) => {
    const c = columns.find((x) => x.id === col);
    return !!c?.limit && list.filter((x) => x.column === col).length > c.limit;
  };

  const flash = (cid: string) => {
    setFresh(cid);
    window.setTimeout(() => setFresh((f) => (f === cid ? null : f)), 1500);
  };
  const notify = (text: string, undo?: KanbanCard[]) => {
    setToast({ text, undo });
    window.clearTimeout(toastTimer.current);
    toastTimer.current = window.setTimeout(() => setToast(null), 5000);
  };

  /** A finished move by pointer or menu: board, announcement, toast with undo, callback */
  const commit = (cid: string, list: string, before: string | null) => {
    const card = cards.find((c) => c.id === cid);
    if (!card) return;
    const [lane, col] = splitKey(list);
    const next = placeCard(cards, cid, (c) => toLane({ ...c, column: col }, lane), before, listOf);
    if (next.map((c) => c.id + c.column + laneOf(c)).join() === cards.map((c) => c.id + c.column + laneOf(c)).join()) return;
    const before0 = cards;
    setCards(next);
    flash(cid);
    const target = inList(next, lane, col);
    const pos = target.findIndex((c) => c.id === cid) + 1;
    const text = listOf(card) === list ? fill(t.dropped, { card: card.title, column: where(lane, col), pos, n: target.length }) : fill(t.moved, { card: card.title, column: where(lane, col) });
    setSay(text + (over(next, col) ? fill(t.overNote, { column: colName(col) }) : ''));
    notify(text, before0);
    void onAction?.('move', { id: cid, column: col, lane: lanes ? lane : undefined, position: pos });
  };

  /* ---------- pointer and touch ---------- */
  const onPointerDown = (e: React.PointerEvent<HTMLElement>, card: KanbanCard, list: string, nextId: string | null) => {
    if (e.button !== 0 || kb) return;
    if ((e.target as HTMLElement).closest('button,a,input,textarea,select,[role=menu]')) return;
    const el = e.currentTarget;
    const r = el.getBoundingClientRect();
    const pid = e.pointerId;
    const s = { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, ox: e.clientX - r.left, oy: e.clientY - r.top, began: false, touch: e.pointerType === 'touch' };
    let timer = 0;
    let raf = 0;
    let cur: DragState = { id: card.id, list, before: nextId, w: r.width };
    const place = () => {
      if (ghost.current) ghost.current.style.transform = `translate3d(${round(s.x - s.ox, 1)}px, ${round(s.y - s.oy, 1)}px, 0)`;
    };
    const hit = () => {
      const under = document.elementFromPoint(s.x, s.y) as HTMLElement | null;
      const listEl = under?.closest<HTMLElement>('[data-list]');
      if (!listEl || !scroller.current?.contains(listEl)) return;
      const k = listEl.dataset.list!;
      let before: string | null = null;
      for (const c of listEl.querySelectorAll<HTMLElement>('[data-card]')) {
        if (c.dataset.card === card.id) continue;
        const b = c.getBoundingClientRect();
        if (s.y < b.top + b.height / 2) {
          before = c.dataset.card!;
          break;
        }
      }
      if (cur.list !== k || cur.before !== before) {
        cur = { ...cur, list: k, before };
        setDrag(cur);
      }
    };
    const tick = () => {
      const sc = scroller.current;
      let moved = false;
      if (sc) {
        const b = sc.getBoundingClientRect();
        const edge = Math.min(72, b.width * 0.14);
        if (s.y > b.top - 40 && s.y < b.bottom + 40) {
          const dx = s.x < b.left + edge ? -Math.ceil((b.left + edge - s.x) / 4) : s.x > b.right - edge ? Math.ceil((s.x - b.right + edge) / 4) : 0;
          if (dx) {
            const was = sc.scrollLeft;
            sc.scrollLeft += dx;
            moved ||= sc.scrollLeft !== was;
          }
        }
      }
      const H = window.innerHeight;
      const dy = s.y < 90 ? -Math.ceil((90 - s.y) / 5) : s.y > H - 70 ? Math.ceil((s.y - H + 70) / 5) : 0;
      if (dy) {
        const was = window.scrollY;
        window.scrollBy(0, dy);
        moved ||= window.scrollY !== was;
      }
      if (moved) hit();
      raf = requestAnimationFrame(tick);
    };
    const begin = () => {
      s.began = true;
      setDrag(cur);
      document.body.style.userSelect = 'none';
      if (s.touch) navigator.vibrate?.(8);
      requestAnimationFrame(place);
      raf = requestAnimationFrame(tick);
    };
    const done = (ok: boolean) => {
      document.removeEventListener('pointermove', move);
      document.removeEventListener('pointerup', up);
      document.removeEventListener('pointercancel', cancel);
      document.removeEventListener('keydown', key, true);
      document.removeEventListener('touchmove', touch);
      window.clearTimeout(timer);
      cancelAnimationFrame(raf);
      document.body.style.userSelect = '';
      if (!s.began) return;
      suppress.current = true;
      window.setTimeout(() => (suppress.current = false), 60);
      setDrag(null);
      if (ok) commit(cur.id, cur.list, cur.before);
    };
    const move = (ev: PointerEvent) => {
      if (ev.pointerId !== pid) return;
      s.x = ev.clientX;
      s.y = ev.clientY;
      if (!s.began) {
        const d = Math.hypot(s.x - s.sx, s.y - s.sy);
        if (s.touch) {
          if (d > 10) done(false);
          return;
        }
        if (d < 5) return;
        begin();
      }
      place();
      hit();
    };
    const up = (ev: PointerEvent) => ev.pointerId === pid && done(true);
    const cancel = (ev: PointerEvent) => ev.pointerId === pid && done(false);
    const key = (ev: KeyboardEvent) => {
      if (ev.key !== 'Escape' || !s.began) return;
      ev.preventDefault();
      ev.stopPropagation();
      done(false);
      setSay(fill(t.cancelled, { card: card.title, column: where(laneOf(card), card.column) }));
    };
    const touch = (ev: TouchEvent) => {
      if (s.began && ev.cancelable) ev.preventDefault();
    };
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
    document.addEventListener('pointercancel', cancel);
    document.addEventListener('keydown', key, true);
    document.addEventListener('touchmove', touch, { passive: false });
    if (s.touch) timer = window.setTimeout(begin, 280);
  };

  /* ---------- keyboard ---------- */
  const position = (list: KanbanCard[], cid: string) => {
    const c = list.find((x) => x.id === cid)!;
    const l = inList(list, laneOf(c), c.column);
    return { pos: l.findIndex((x) => x.id === cid) + 1, n: l.length, lane: laneOf(c), col: c.column };
  };
  const kbMove = (cid: string, dx: number, dy: number) => {
    const card = cards.find((c) => c.id === cid);
    if (!card) return;
    const lane = laneOf(card);
    let tLane = lane;
    let tCol = card.column;
    let before: string | null = null;
    const cur = inList(cards, lane, card.column);
    const idx = cur.findIndex((c) => c.id === cid);
    if (dx) {
      const ci = openCols.findIndex((c) => c.id === card.column);
      const nc = openCols[ci + dx];
      if (!nc) return;
      tCol = nc.id;
      const target = inList(cards, lane, tCol);
      before = target[Math.min(idx, target.length)]?.id ?? null;
    } else {
      const ni = idx + dy;
      if (ni >= 0 && ni < cur.length) {
        before = cur.filter((c) => c.id !== cid)[ni]?.id ?? null;
      } else if (lanes) {
        const li = laneIds.indexOf(lane) + dy;
        if (li < 0 || li >= laneIds.length) return;
        tLane = laneIds[li];
        const target = inList(cards, tLane, tCol);
        before = dy > 0 ? target[0]?.id ?? null : null;
        setLaneShut((s) => s.filter((x) => x !== tLane));
      } else return;
    }
    const next = placeCard(cards, cid, (c) => toLane({ ...c, column: tCol }, tLane), before, listOf);
    setCards(next);
    const p = position(next, cid);
    setMcol(Math.max(0, columns.findIndex((c) => c.id === tCol)));
    setSay(fill(t.movedKb, { column: where(p.lane, p.col), pos: p.pos, n: p.n }) + (over(next, tCol) && dx ? fill(t.overNote, { column: colName(tCol) }) : ''));
  };
  const onKey = (e: React.KeyboardEvent, card: KanbanCard) => {
    // Keys inside the card menu (its trigger and the menu, which bubbles here through the portal) belong to the menu
    if (e.target !== e.currentTarget) return;
    const lifted = kb?.id === card.id;
    if (!lifted) {
      if (kb) return;
      if (e.key === 'Enter' && openOnEnter && onOpen) {
        e.preventDefault();
        return onOpen(card.id);
      }
      if (e.key === ' ' || e.key === 'Enter') {
        e.preventDefault();
        setKb({ id: card.id, origin: cards });
        const p = position(cards, card.id);
        setSay(fill(t.grabbed, { card: card.title, column: where(p.lane, p.col), pos: p.pos, n: p.n }));
      }
      return;
    }
    const map: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
    if (map[e.key]) {
      e.preventDefault();
      kbMove(card.id, ...map[e.key]);
    } else if (e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') {
      if (e.key !== 'Tab') e.preventDefault();
      const p = position(cards, card.id);
      setKb(null);
      flash(card.id);
      setSay(fill(t.dropped, { card: card.title, column: where(p.lane, p.col), pos: p.pos, n: p.n }));
      void onAction?.('move', { id: card.id, column: p.col, lane: lanes ? p.lane : undefined, position: p.pos });
    } else if (e.key === 'Escape') {
      e.preventDefault();
      e.stopPropagation();
      setCards(kb.origin);
      setKb(null);
      const o = kb.origin.find((c) => c.id === card.id)!;
      setSay(fill(t.cancelled, { card: card.title, column: where(laneOf(o), o.column) }));
    }
  };
  // The lifted card keeps the focus while it changes lists
  useIso(() => {
    if (!kb) return;
    const el = document.getElementById(domId(kb.id));
    if (el && document.activeElement !== el) el.focus({ preventScroll: true });
    el?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
  }, [cards, kb]);

  /* ---------- menu moves ---------- */
  const menuMove = (card: KanbanCard, col: string) => {
    const lane = laneOf(card);
    commit(card.id, keyOf(lane, col), null);
    requestAnimationFrame(() => document.getElementById(domId(card.id))?.focus({ preventScroll: true }));
    setMcol(Math.max(0, columns.findIndex((c) => c.id === col)));
  };
  const step = (card: KanbanCard, dy: number) => {
    const list = inList(cards, laneOf(card), card.column);
    const i = list.findIndex((c) => c.id === card.id);
    const ni = i + dy;
    if (ni < 0 || ni >= list.length) return;
    const before = list.filter((c) => c.id !== card.id)[ni]?.id ?? null;
    commit(card.id, listOf(card), before);
  };
  const menuFor = (card: KanbanCard, list: KanbanCard[]): MenuItem[] => {
    const i = list.findIndex((c) => c.id === card.id);
    return [
      ...(onOpen ? [{ label: t.openCard, icon: 'panel-right' as const, onSelect: () => onOpen(card.id) }, { type: 'separator' as const }] : []),
      { type: 'label', label: t.moveTo },
      ...columns.filter((c) => c.id !== card.column).map((c) => ({ label: c.title, icon: 'arrow-right' as const, onSelect: () => menuMove(card, c.id) })),
      { type: 'separator' },
      { label: t.moveUp, icon: 'arrow-up', disabled: i <= 0, onSelect: () => step(card, -1) },
      { label: t.moveDown, icon: 'arrow-down', disabled: i < 0 || i >= list.length - 1, onSelect: () => step(card, 1) },
    ];
  };

  /* ---------- add ---------- */
  useEffect(() => {
    if (!addRequest) return;
    const ci = columns.findIndex((c) => c.id === addRequest.col);
    setCollapsed((s) => s.filter((x) => x !== addRequest.col));
    setAdding(keyOf(laneIds[0], addRequest.col));
    setDraft('');
    setMcol(Math.max(0, ci));
    const sec = scroller.current?.querySelector<HTMLElement>(`[data-col="${addRequest.col}"]`);
    sec?.scrollIntoView({ block: 'nearest', inline: 'start', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [addRequest]);
  const submitAdd = (list: string) => {
    const title = draft.trim();
    if (!title) return setAdding(null);
    const [lane, col] = splitKey(list);
    const nid = `k${Date.now()}`;
    const card = toLane({ id: nid, title, column: col }, lane);
    setCards((cs) => placeCard([...cs, card], nid, (c) => c, null, listOf));
    setDraft('');
    flash(nid);
    const text = fill(t.added, { card: title, column: where(lane, col) });
    setSay(text);
    void onAction?.('add', { card });
  };

  /* ---------- phone tabs follow the scroll position ---------- */
  const setScroller = (el: HTMLDivElement | null) => {
    scroller.current = el;
    fadeRef.current = el;
  };
  useEffect(() => {
    const sc = scroller.current;
    if (!sc) return;
    const on = () => {
      if (window.innerWidth >= 640) return;
      const secs = [...sc.querySelectorAll<HTMLElement>('[data-col-head]')];
      const mid = sc.scrollLeft + sc.clientWidth / 2;
      let best = 0;
      secs.forEach((s, i) => {
        if (s.offsetLeft <= mid) best = i;
      });
      setMcol(best);
    };
    sc.addEventListener('scroll', on, { passive: true });
    return () => sc.removeEventListener('scroll', on);
  }, []);
  const goCol = (i: number) => {
    setMcol(i);
    const sc = scroller.current;
    const s = sc?.querySelectorAll<HTMLElement>('[data-col-head]')[i];
    if (sc && s) sc.scrollTo({ left: s.offsetLeft - parseFloat(getComputedStyle(sc).paddingLeft), behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
  };

  useEffect(() => {
    const nav = tabsRef.current;
    const b = nav?.children[mcol] as HTMLElement | undefined;
    if (nav && b && nav.scrollWidth > nav.clientWidth) nav.scrollTo({ left: b.offsetLeft - 16, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
  }, [mcol, tabsRef]);

  const dragCard = drag ? cards.find((c) => c.id === drag.id) : undefined;

  /* ---------- render ---------- */
  const renderCard = (card: KanbanCard, list: KanbanCard[], k: string, i: number) => {
    const lifted = kb?.id === card.id;
    const hidden = drag?.id === card.id;
    const nextId = list[i + 1]?.id ?? null;
    return (
      <li key={card.id} data-card={card.id} className={cn(hidden && 'hidden')} style={{ '--kbn-d': `${Math.min(i, 6) * 45}ms` } as React.CSSProperties}>
        <div
          id={domId(card.id)}
          role="button"
          tabIndex={0}
          aria-roledescription={t.draggable}
          aria-describedby={`${id}-hint`}
          aria-pressed={lifted || undefined}
          aria-current={selected === card.id ? 'true' : undefined}
          aria-label={card.title}
          onPointerDown={(e) => onPointerDown(e, card, k, nextId)}
          onContextMenu={(e) => {
            if (drag) e.preventDefault();
          }}
          onClick={(e) => {
            if (suppress.current || kb || (e.target as HTMLElement).closest('button,a,[role=menu]') !== null && e.target !== e.currentTarget) return;
            onOpen?.(card.id);
          }}
          onKeyDown={(e) => onKey(e, card)}
          onBlur={() => {
            if (kb?.id !== card.id) return;
            // Focus left the lifted card (click elsewhere): it stays where it is now
            requestAnimationFrame(() => {
              if (document.activeElement?.id !== domId(card.id)) setKb((x) => (x?.id === card.id ? null : x));
            });
          }}
          className={cn(
            'kbn-up group relative block cursor-grab touch-manipulation select-none rounded-lg border bg-card text-left shadow-[var(--highlight),var(--shadow-sm)] [-webkit-touch-callout:none] transition-[box-shadow,transform,border-color] duration-200 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] active:cursor-grabbing motion-reduce:transition-none motion-reduce:hover:translate-y-0',
            dense ? 'px-2.5 py-2' : 'p-3',
            ring,
            selected === card.id ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_40%,transparent),var(--shadow-sm)]' : 'border-border',
            lifted && 'z-10 -translate-y-1 scale-[1.02] rotate-[0.6deg] border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_35%,transparent),var(--shadow-lg)] hover:-translate-y-1',
            fresh === card.id && 'kbn-flash',
          )}
        >
          <CardBody
            card={card}
            ctx={ctx}
            dense={dense}
            menu={
              <DropdownMenu
                align="end"
                items={menuFor(card, list)}
                trigger={
                  <button type="button" aria-label={fill(t.cardMenu, { card: card.title })} className={cn(iconBtn, 'size-11 text-subtle-foreground sm:size-7')}>
                    <Icon name="more-horizontal" size={15} />
                  </button>
                }
              />
            }
          />
        </div>
      </li>
    );
  };

  const placeholder = (h = dense ? 56 : 92) => (
    <li key="__drop" aria-hidden className="grid place-items-center rounded-lg border-2 border-dashed border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,transparent)] text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" style={{ height: h }}>
      {t.dropHere}
    </li>
  );

  const renderList = (lane: string, col: KanbanColumn) => {
    const k = keyOf(lane, col.id);
    const list = inList(cards, lane, col.id);
    const items: React.ReactNode[] = [];
    const showPh = drag && drag.list === k;
    list.forEach((c, i) => {
      if (showPh && drag!.before === c.id) items.push(placeholder());
      items.push(renderCard(c, list, k, i));
    });
    if (showPh && (!drag!.before || !list.some((c) => c.id === drag!.before))) items.push(placeholder());
    const formOpen = adding === k;
    return (
      <>
        <ul className={cn('flex min-h-16 flex-1 flex-col gap-2', dense && 'gap-1.5')}>
          {items}
          {!list.length && !showPh && !formOpen && (
            <li className="grid min-h-12 flex-1 place-items-center rounded-lg border border-dashed border-border text-[12.5px] text-subtle-foreground">{cards.some((c) => c.column === col.id && laneOf(c) === lane) ? t.noMatch : t.empty}</li>
          )}
        </ul>
        {formOpen ? (
          <form
            className="kbn-in mt-2 rounded-lg border border-border-strong bg-card p-2 shadow-[var(--shadow-md)]"
            onSubmit={(e) => {
              e.preventDefault();
              submitAdd(k);
            }}
          >
            <label htmlFor={`${id}-new-${k}`} className="sr-only">
              {fill(t.addCardTo, { column: where(lane, col.id) })}
            </label>
            <textarea
              id={`${id}-new-${k}`}
              autoFocus={mounted.current}
              rows={2}
              value={draft}
              onChange={(e) => setDraft(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Escape') (e.preventDefault(), setAdding(null));
                if (e.key === 'Enter' && !e.shiftKey) (e.preventDefault(), submitAdd(k));
              }}
              placeholder={t.cardTitle}
              className="block w-full resize-none bg-transparent px-1.5 py-1 text-base text-foreground outline-none placeholder:text-subtle-foreground sm:text-[13.5px]"
            />
            <div className="mt-1 flex justify-end gap-1.5">
              <Button type="button" size="sm" variant="ghost" onClick={() => setAdding(null)}>
                {t.cancel}
              </Button>
              <Button type="submit" size="sm" iconLeft="plus">
                {t.add}
              </Button>
            </div>
          </form>
        ) : (
          <button
            type="button"
            onClick={() => {
              setAdding(k);
              setDraft('');
            }}
            aria-label={fill(t.addCardTo, { column: where(lane, col.id) })}
            className={cn('mt-2 flex h-11 w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] hover:text-foreground sm:h-9', ring)}
          >
            <Icon name="plus" size={15} />
            {t.addCard}
          </button>
        )}
      </>
    );
  };

  const colHead = (col: KanbanColumn, ci: number, lane?: boolean) => {
    const all = cards.filter((c) => c.column === col.id);
    const n = all.length;
    const isOver = !!col.limit && n > col.limit;
    const atLimit = !!col.limit && n === col.limit;
    const shut = collapsed.includes(col.id);
    return (
      <div data-col-head className={cn('min-w-0', lane ? 'px-1 pb-2' : 'px-1 pb-2.5')}>
        <div className="flex h-9 items-center gap-2">
          <span aria-hidden className={cn('size-2 shrink-0 rounded-full', col.done ? 'bg-success' : DOT[ci % DOT.length])} />
          <h2 id={`${id}-h-${col.id}`} className="min-w-0 truncate text-[13.5px] font-semibold text-foreground">
            {col.title}
          </h2>
          <span
            key={n}
            className={cn(
              'kbn-pop inline-flex h-5 shrink-0 items-center rounded-md px-1.5 text-[11.5px] font-semibold tabular-nums',
              isOver ? 'bg-[color-mix(in_oklab,var(--warning)_24%,var(--card))] text-warning-foreground' : atLimit ? 'bg-[color-mix(in_oklab,var(--foreground)_10%,var(--card))] text-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-muted-foreground',
            )}
          >
            {col.limit ? <><span aria-hidden>{n}/{col.limit}</span><span className="sr-only">{fill(t.countOf, { n, limit: col.limit })}</span></> : n}
          </span>
          <span className="ms-auto flex items-center">
            {headExtra?.(col, all)}
            {!lane && (
              <button type="button" aria-expanded={!shut} aria-label={fill(shut ? t.expand : t.collapse, { column: col.title })} onClick={() => setCollapsed((s) => (shut ? s.filter((x) => x !== col.id) : [...s, col.id]))} className={cn(iconBtn, 'max-sm:hidden')}>
                <Icon name="chevrons-left" size={15} />
              </button>
            )}
          </span>
        </div>
        {col.limit != null && (
          <div className="mt-1.5 flex items-center gap-2" aria-live="polite">
            <span aria-hidden className="relative h-1 flex-1 overflow-hidden rounded-full bg-track">
              <span className={cn('kbn-grow absolute inset-y-0 start-0 rounded-full transition-[width] duration-500 ease-[var(--ease-kit)]', isOver ? 'bg-warning' : atLimit ? 'bg-foreground' : 'bg-primary')} style={{ width: `${round(Math.min(100, (n / col.limit) * 100), 1)}%` }} />
            </span>
            {(isOver || atLimit) && (
              <span className={cn('kbn-in shrink-0 text-[11.5px] font-medium', isOver ? 'text-warning-foreground' : 'text-muted-foreground')}>{isOver ? fill(t.overLimit, { n: n - col.limit }) : t.atLimit}</span>
            )}
          </div>
        )}
      </div>
    );
  };

  const tabs = (
    <nav ref={tabsRef} aria-label={t.columns} className={cn("kit-no-scrollbar -mx-4 mt-5 flex gap-1 overflow-x-auto px-4 sm:hidden", tabsFade)}>
      {columns.map((c, i) => {
        const n = cards.filter((x) => x.column === c.id).length;
        const isOver = !!c.limit && n > c.limit;
        return (
          <button key={c.id} type="button" aria-current={mcol === i ? 'true' : undefined} onClick={() => goCol(i)} className={cn('relative inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium transition-colors', ring, mcol === i ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}>
            {c.title}
            <span className={cn('tabular-nums text-[12px]', mcol === i ? 'opacity-70' : isOver ? 'text-warning-foreground' : 'text-subtle-foreground')}>{n}</span>
          </button>
        );
      })}
    </nav>
  );

  const scrollerCls = cn('kit-no-scrollbar relative -mx-4 mt-3 overflow-x-auto px-4 pb-3 sm:-mx-6 sm:mt-5 sm:px-6 lg:-mx-8 lg:px-8', drag ? 'snap-none' : 'max-sm:snap-x max-sm:snap-mandatory max-sm:scroll-px-4', fade);

  return (
    <div ref={scope} {...enter} className={cn('min-w-0', className)}>
      <p id={`${id}-hint`} className="sr-only">
        {openOnEnter ? t.dragHintDetail : t.dragHint}
      </p>
      <p role="status" aria-live="assertive" className="sr-only">
        {say}
      </p>
      {tabs}
      {!lanes ? (
        <div ref={setScroller} data-scroller className={scrollerCls}>
          <div className="flex gap-3 sm:gap-4">
            {columns.map((col, ci) => {
              const shut = collapsed.includes(col.id);
              const k = keyOf(NONE, col.id);
              const n = cards.filter((c) => c.column === col.id).length;
              const isOver = !!col.limit && n > col.limit;
              const target = !!drag && drag.list === k;
              if (shut)
                return (
                  <section key={col.id} data-col={col.id} data-list={k} aria-labelledby={`${id}-h-${col.id}`} className={cn('kbn-up flex w-14 flex-none flex-col items-center rounded-xl border bg-surface py-2 transition-colors', target ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--surface))]' : isOver ? 'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))]' : 'border-border')}>
                    <span data-col-head />
                    <button type="button" aria-expanded={false} aria-label={fill(t.expand, { column: col.title })} onClick={() => setCollapsed((s) => s.filter((x) => x !== col.id))} className={cn('flex w-full flex-1 cursor-pointer flex-col items-center gap-3 rounded-lg py-1 text-muted-foreground hover:text-foreground', ring)}>
                      <Icon name="chevrons-right" size={15} />
                      <span className={cn('inline-grid h-5 min-w-5 place-items-center rounded-md px-1 text-[11.5px] font-semibold tabular-nums', isOver ? 'bg-[color-mix(in_oklab,var(--warning)_24%,var(--card))] text-warning-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))]')}>{n}</span>
                      <h2 id={`${id}-h-${col.id}`} className="text-[13px] font-semibold text-foreground [writing-mode:vertical-rl]">
                        {col.title}
                      </h2>
                    </button>
                  </section>
                );
              return (
                <section
                  key={col.id}
                  data-col={col.id}
                  data-list={k}
                  aria-labelledby={`${id}-h-${col.id}`}
                  className={cn(
                    'kbn-up flex min-w-0 shrink-0 basis-[calc(100%-1.75rem)] snap-center flex-col rounded-xl border p-2 transition-[border-color,background-color,box-shadow] duration-200 sm:basis-auto sm:flex-[1_0_17rem] lg:flex-[1_0_15rem] xl:flex-[1_0_12.75rem]',
                    dense && 'sm:flex-[1_0_14.5rem] lg:flex-[1_0_13rem] xl:flex-[1_0_12rem]',
                    target ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_5%,var(--surface))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent)]' : isOver ? 'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_5%,var(--surface))]' : 'border-border bg-surface',
                  )}
                  style={{ '--kbn-d': `${ci * 60}ms` } as React.CSSProperties}
                >
                  {colHead(col, ci)}
                  {renderList(NONE, col)}
                </section>
              );
            })}
          </div>
        </div>
      ) : (
        <div ref={setScroller} data-scroller className={scrollerCls}>
          <div className="grid gap-x-3 [--kb-col:calc(100vw-2.5rem)] sm:gap-x-4 sm:[--kb-col:16rem]" style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(var(--kb-col), 1fr))` }}>
            {columns.map((col, ci) => (
              <div key={col.id} className="max-sm:snap-center">
                {colHead(col, ci, true)}
              </div>
            ))}
            {lanes.map((lane) => {
              const shut = laneShut.includes(lane.id);
              const n = cards.filter((c) => laneOf(c) === lane.id && visible(c)).length;
              const person = lane.person ? ctx.person(lane.person) : undefined;
              return (
                <div key={lane.id} className="col-span-full grid grid-cols-subgrid border-t border-border pt-3 pb-4 first-of-type:border-t-0">
                  <div className="col-span-full">
                    <button type="button" aria-expanded={!shut} onClick={() => setLaneShut((s) => (shut ? s.filter((x) => x !== lane.id) : [...s, lane.id]))} className={cn('sticky start-4 flex h-11 max-w-[calc(100vw-2rem)] cursor-pointer items-center gap-2.5 rounded-lg pe-3 ps-1 text-left sm:start-6 sm:h-10 lg:start-8', ring)} aria-label={fill(shut ? t.expandLane : t.collapseLane, { lane: lane.label })}>
                      <Icon name="chevron-down" size={15} className={cn('shrink-0 text-muted-foreground transition-transform duration-300', shut && '-rotate-90')} />
                      {person ? <Avatar name={person.name} size="xs" /> : <span aria-hidden className="grid size-6 place-items-center rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-muted-foreground"><Icon name={lanes[0]?.person !== undefined || lane.id === NONE ? 'user' : 'flag'} size={12} /></span>}
                      <span className="truncate text-[14px] font-semibold text-foreground">{lane.label}</span>
                      <span className="shrink-0 text-[12.5px] tabular-nums text-subtle-foreground">{fill(t.lane, { n })}</span>
                    </button>
                  </div>
                  {!shut &&
                    columns.map((col) => {
                      const k = keyOf(lane.id, col.id);
                      const target = !!drag && drag.list === k;
                      return (
                        <div key={col.id} data-list={k} role="group" aria-label={where(lane.id, col.id)} className={cn('kbn-up mt-2 flex flex-col rounded-xl border p-2 transition-[border-color,background-color] duration-200 max-sm:snap-center', target ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_5%,var(--surface))]' : 'border-border bg-surface')}>
                          {renderList(lane.id, col)}
                        </div>
                      );
                    })}
                </div>
              );
            })}
          </div>
        </div>
      )}
      {dragCard &&
        drag &&
        createPortal(
          <div ref={ghost} aria-hidden className="kbn-ghost pointer-events-none fixed left-0 top-0 z-[100]" style={{ width: drag.w }}>
            <div className={cn('rotate-[2deg] scale-[1.03] rounded-lg border border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-card shadow-[var(--shadow-lg)]', dense ? 'px-2.5 py-2' : 'p-3')}>
              <CardBody card={dragCard} ctx={ctx} dense={dense} />
            </div>
          </div>,
          document.body,
        )}
      <Toast
        t={t}
        text={toast?.text ?? ''}
        onUndo={
          toast?.undo
            ? () => {
                setCards(toast.undo!);
                setToast(null);
                setSay(t.undo);
              }
            : undefined
        }
      />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Detail                                                              */
/* ------------------------------------------------------------------ */

function Check({ on, onChange, label }: { on: boolean; onChange: () => void; label: string }) {
  return (
    <button type="button" role="checkbox" aria-checked={on} aria-label={label} onClick={onChange} className={cn('relative grid size-11 shrink-0 cursor-pointer place-items-center rounded-md sm:size-7', ring)}>
      <span className={cn('grid size-[18px] place-items-center rounded-[5px] border transition-[background-color,border-color] duration-200', on ? 'border-transparent bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
        {on && (
          <svg viewBox="0 0 16 16" className="size-3" aria-hidden>
            <path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className="kbn-tick" />
          </svg>
        )}
      </span>
    </button>
  );
}

function Detail({ card, ctx, columns, tags, people, me, now, update }: { card: KanbanCard; ctx: Ctx; columns: KanbanColumn[]; tags: KanbanLabel[]; people: KanbanPerson[]; me?: string; now: string; update: (patch: Partial<KanbanCard>, action?: string) => void }) {
  const { t, locale } = ctx;
  const id = useId();
  const [item, setItem] = useState('');
  const [comment, setComment] = useState('');
  const list = card.checklist ?? [];
  const done = list.filter((x) => x.done).length;
  const pct = list.length ? (done / list.length) * 100 : 0;
  const lbl = 'text-[12px] font-medium text-muted-foreground';
  return (
    <div className="grid gap-6">
      <div>
        <p className="text-[12px] font-medium tabular-nums text-subtle-foreground">{card.key}</p>
        <label htmlFor={`${id}-title`} className="sr-only">
          {t.title}
        </label>
        <textarea id={`${id}-title`} rows={2} value={card.title} onChange={(e) => update({ title: e.target.value })} className={cn('mt-1 block w-full resize-none rounded-md bg-transparent text-[18px] font-semibold leading-snug tracking-[-0.015em] text-foreground hover:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)]', ring)} />
      </div>
      <dl className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2">
        <div className="grid gap-1.5">
          <dt>
            <label htmlFor={`${id}-status`} className={lbl}>
              {t.status}
            </label>
          </dt>
          <dd className="relative">
            <select id={`${id}-status`} value={card.column} onChange={(e) => update({ column: e.target.value }, 'move')} className={cn(field, 'cursor-pointer appearance-none pe-8')}>
              {columns.map((c) => (
                <option key={c.id} value={c.id}>
                  {c.title}
                </option>
              ))}
            </select>
            <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
          </dd>
        </div>
        <div className="grid gap-1.5">
          <dt>
            <label htmlFor={`${id}-due`} className={lbl}>
              {t.dueDate}
            </label>
          </dt>
          <dd>
            <input id={`${id}-due`} type="date" value={card.due ?? ''} onChange={(e) => update({ due: e.target.value || undefined })} className={cn(field, 'tabular-nums')} />
          </dd>
        </div>
        <div className="grid gap-1.5 sm:col-span-2">
          <dt className={lbl} id={`${id}-ppl`}>
            {t.assignees}
          </dt>
          <dd role="group" aria-labelledby={`${id}-ppl`} className="flex flex-wrap gap-1.5">
            {people.map((p) => {
              const on = (card.assignees ?? []).includes(p.id);
              return (
                <button key={p.id} type="button" aria-pressed={on} onClick={() => update({ assignees: on ? (card.assignees ?? []).filter((x) => x !== p.id) : [...(card.assignees ?? []), p.id] })} className={cn('inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-full border ps-1 pe-3 text-[12.5px] font-medium transition-colors sm:h-8', ring, on ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
                  <Avatar name={p.name} size="xs" />
                  {p.name.split(' ')[0]}
                </button>
              );
            })}
          </dd>
        </div>
        <div className="grid gap-1.5 sm:col-span-2">
          <dt className={lbl} id={`${id}-tags`}>
            {t.labelsTitle}
          </dt>
          <dd role="group" aria-labelledby={`${id}-tags`} className="flex flex-wrap gap-1.5">
            {tags.map((x) => {
              const on = (card.tags ?? []).includes(x.id);
              return (
                <button key={x.id} type="button" aria-pressed={on} onClick={() => update({ tags: on ? (card.tags ?? []).filter((y) => y !== x.id) : [...(card.tags ?? []), x.id] })} className={cn('inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium transition-[opacity,box-shadow] sm:h-7', ring, TONE[x.tone ?? 'neutral'], on ? 'shadow-[inset_0_0_0_1.5px_currentColor]' : 'opacity-60 hover:opacity-100')}>
                  {on && <Icon name="check" size={12} />}
                  {x.name}
                </button>
              );
            })}
          </dd>
        </div>
        <div className="grid gap-1.5 sm:col-span-2">
          <dt className={lbl} id={`${id}-pts`}>
            {t.estimate}
          </dt>
          <dd role="radiogroup" aria-labelledby={`${id}-pts`} className="flex flex-wrap gap-1">
            {[1, 2, 3, 5, 8, 13].map((n, i, all) => (
              <button
                key={n}
                type="button"
                role="radio"
                aria-checked={card.points === n}
                tabIndex={card.points === n || (card.points == null && i === 0) ? 0 : -1}
                onClick={() => update({ points: n })}
                onKeyDown={(e) => {
                  const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
                  if (!d) return;
                  e.preventDefault();
                  const j = (i + d + all.length) % all.length;
                  update({ points: all[j] });
                  (e.currentTarget.parentElement?.children[j] as HTMLElement | undefined)?.focus();
                }}
                className={cn('grid h-11 min-w-11 cursor-pointer place-items-center rounded-md px-2 text-[13px] font-semibold tabular-nums transition-colors sm:h-8 sm:min-w-9', ring, card.points === n ? 'bg-foreground text-background' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,var(--card))] text-muted-foreground hover:text-foreground')}
              >
                {n}
              </button>
            ))}
          </dd>
        </div>
      </dl>
      <div className="grid gap-1.5">
        <label htmlFor={`${id}-desc`} className={lbl}>
          {t.description}
        </label>
        <textarea id={`${id}-desc`} rows={3} value={card.description ?? ''} placeholder={t.descPlaceholder} onChange={(e) => update({ description: e.target.value })} className={cn(field, 'h-auto min-h-20 resize-y py-2 leading-relaxed sm:h-auto')} />
      </div>
      <section aria-labelledby={`${id}-cl`}>
        <div className="flex items-center gap-3">
          <h3 id={`${id}-cl`} className="text-[13.5px] font-semibold text-foreground">
            {t.checklistTitle}
          </h3>
          <span className="text-[12px] tabular-nums text-muted-foreground">{fill(t.checklist, { done, n: list.length })}</span>
        </div>
        <span aria-hidden className="mt-2 block h-1.5 overflow-hidden rounded-full bg-track">
          <span className={cn('block h-full rounded-full transition-[width] duration-500 ease-[var(--ease-kit)]', done === list.length && list.length ? 'bg-success' : 'bg-primary')} style={{ width: `${round(pct, 1)}%` }} />
        </span>
        <ul className="mt-2 grid">
          {list.map((x) => (
            <li key={x.id} className="kbn-in group/item flex items-center gap-1 rounded-md hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)]">
              <Check on={x.done} label={x.text} onChange={() => update({ checklist: list.map((y) => (y.id === x.id ? { ...y, done: !y.done } : y)) })} />
              <span className={cn('min-w-0 flex-1 text-[13.5px] transition-colors', x.done ? 'text-subtle-foreground line-through decoration-[color-mix(in_oklab,var(--foreground)_35%,transparent)]' : 'text-foreground')}>{x.text}</span>
              <button type="button" aria-label={fill(t.removeItem, { item: x.text })} onClick={() => update({ checklist: list.filter((y) => y.id !== x.id) })} className={cn(iconBtn, 'text-subtle-foreground hover:text-destructive')}>
                <Icon name="x" size={14} />
              </button>
            </li>
          ))}
        </ul>
        <form
          className="mt-2 flex gap-2"
          onSubmit={(e) => {
            e.preventDefault();
            if (!item.trim()) return;
            update({ checklist: [...list, { id: `i${Date.now()}`, text: item.trim(), done: false }] });
            setItem('');
          }}
        >
          <label htmlFor={`${id}-item`} className="sr-only">
            {t.addItem}
          </label>
          <input id={`${id}-item`} value={item} onChange={(e) => setItem(e.target.value)} placeholder={t.itemPlaceholder} className={cn(field, 'w-0 flex-1')} />
          <Button type="submit" size="sm" variant="outline" iconLeft="plus" className="max-sm:h-11">
            <span className="max-sm:sr-only">{t.addItem}</span>
          </Button>
        </form>
      </section>
      <section aria-labelledby={`${id}-cm`}>
        <h3 id={`${id}-cm`} className="text-[13.5px] font-semibold text-foreground">
          {t.commentsTitle}
        </h3>
        <ol className="mt-3 grid gap-4">
          {(card.comments ?? []).map((c) => {
            const p = ctx.person(c.author);
            return (
              <li key={c.id} className="kbn-in flex gap-3">
                <Avatar name={p?.name ?? '?'} size="sm" />
                <div className="min-w-0 flex-1">
                  <p className="flex flex-wrap items-baseline gap-x-2 text-[13px]">
                    <span className="font-semibold text-foreground">{p?.name}</span>
                    <time dateTime={c.time} className="text-[12px] text-subtle-foreground">
                      {ago(locale, c.time, now, t)}
                    </time>
                  </p>
                  <p className="mt-0.5 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{c.text}</p>
                </div>
              </li>
            );
          })}
        </ol>
        <form
          className="mt-4 grid gap-2"
          onSubmit={(e) => {
            e.preventDefault();
            if (!comment.trim()) return;
            update({ comments: [...(card.comments ?? []), { id: `m${Date.now()}`, author: me ?? people[0]?.id ?? '', text: comment.trim(), time: now }] }, 'comment');
            setComment('');
          }}
        >
          <label htmlFor={`${id}-comment`} className="sr-only">
            {t.commentPlaceholder}
          </label>
          <textarea id={`${id}-comment`} rows={2} value={comment} onChange={(e) => setComment(e.target.value)} placeholder={t.commentPlaceholder} className={cn(field, 'h-auto resize-none py-2 sm:h-auto')} />
          <Button type="submit" size="sm" iconLeft="send" disabled={!comment.trim()} className="justify-self-end max-sm:h-11 max-sm:w-full">
            {t.post}
          </Button>
        </form>
      </section>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* KanbanBoard                                                         */
/* ------------------------------------------------------------------ */

/**
 * A kanban board for apps: columns with work in progress limits, cards that
 * move by pointer, touch, keyboard and menu, swimlanes, a sprint with
 * burndown, a detail panel with checklist and comments, and dense filters.
 *   <KanbanBoard variant="board" title="Website relaunch" columns={…} cards={…} people={…} tags={…} />
 */
export function KanbanBoard({ variant = 'board', sidebar, labels, className, today = '2026-10-21', ...props }: KanbanBoardProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const { title, subtitle, columns = [], tags = [], people = [], lanes: laneMode = 'person', sprint, me, onAction } = props;
  const [cards, setCardsRaw] = useState<KanbanCard[]>(props.cards ?? []);
  const [sel, setSel] = useState<string | null>(variant === 'card-detail' ? props.selected ?? props.cards?.[0]?.id ?? null : null);
  const [sheet, setSheet] = useState(false);
  const [addReq, setAddReq] = useState<{ col: string; n: number } | null>(null);
  const [q, setQ] = useState('');
  const [fTags, setFTags] = useState<string[]>([]);
  const [fPeople, setFPeople] = useState<string[]>([]);
  const [sprintState, setSprintState] = useState<'open' | 'busy' | 'done'>('open');
  const wide = useWide();
  const now = `${today}T15:30`;
  const setCards = (next: KanbanCard[] | ((c: KanbanCard[]) => KanbanCard[])) => setCardsRaw((c) => (typeof next === 'function' ? next(c) : next));
  const ctx: Ctx = useMemo(() => ({ t, locale, today, tag: (i) => tags.find((x) => x.id === i), person: (i) => people.find((x) => x.id === i) }), [t, locale, today, tags, people]);

  const doneCols = columns.filter((c) => c.done).map((c) => c.id);
  const lastCol = columns[columns.length - 1]?.id;
  const isDone = (c: KanbanCard) => (doneCols.length ? doneCols.includes(c.column) : c.column === lastCol);

  const newCard = (
    <Button size="sm" iconLeft="plus" magnetic className="max-sm:w-full" onClick={() => setAddReq({ col: columns[0]?.id ?? '', n: Date.now() })}>
      {t.newCard}
    </Button>
  );
  const team = people.length > 0 && (
    <span className="flex -space-x-2 max-sm:hidden" title={people.map((p) => p.name).join(', ')}>
      {people.slice(0, 5).map((p) => (
        <span key={p.id} className="rounded-full ring-2 ring-background">
          <Avatar name={p.name} size="sm" />
        </span>
      ))}
    </span>
  );

  let body: React.ReactNode;
  if (variant === 'swimlanes') {
    const lanes: Lane[] =
      laneMode === 'priority'
        ? (['urgent', 'high', 'medium', 'low'] as const).map((p): Lane => ({ id: p, label: t[p] })).concat(cards.some((c) => !c.priority) ? [{ id: 'none', label: t.none }] : [])
        : people.map((p): Lane => ({ id: p.id, label: p.name, person: p.id })).concat(cards.some((c) => !c.assignees?.length) ? [{ id: NONE, label: t.unassigned }] : []);
    body = (
      <>
        <Header title={title} subtitle={subtitle}>
          {team}
          {newCard}
        </Header>
        <Board
          ctx={ctx}
          columns={columns}
          cards={cards}
          setCards={setCards}
          lanes={lanes}
          laneOf={(c) => (laneMode === 'priority' ? c.priority ?? 'none' : c.assignees?.[0] ?? NONE)}
          toLane={(c, lane) => (laneMode === 'priority' ? { ...c, priority: lane === 'none' ? undefined : (lane as KanbanPriority) } : { ...c, assignees: lane ? [lane, ...(c.assignees ?? []).filter((x) => x !== lane).slice(1)] : [] })}
          onAction={onAction}
          addRequest={addReq}
        />
      </>
    );
  } else if (variant === 'sprint') {
    const total = cards.reduce((s, c) => s + (c.points ?? 0), 0);
    const done = cards.filter(isDone).reduce((s, c) => s + (c.points ?? 0), 0);
    const days: string[] = [];
    if (sprint) {
      for (let d = dayNum(sprint.start); d <= dayNum(sprint.end); d++) {
        const wd = new Date(d * dayMs).getUTCDay();
        if (wd !== 0 && wd !== 6) days.push(new Date(d * dayMs).toISOString().slice(0, 10));
      }
    }
    const ti = days.findIndex((d) => d >= today);
    const past = sprint?.remaining ?? [];
    const data = days.map((d, i) => ({
      day: d,
      ideal: round(total - (total * i) / Math.max(1, days.length - 1), 1),
      actual: i < Math.min(past.length, ti < 0 ? days.length : ti) ? past[i] : i === ti ? total - done : null,
    }));
    const left = ti < 0 ? 0 : days.length - ti;
    const pct = total ? (done / total) * 100 : 0;
    const openCards = cards.filter((c) => !isDone(c)).length;
    const elapsed = Math.max(1, ti < 0 ? days.length : ti + 1);
    const gap = ti >= 0 ? Math.round(total - done - (data[ti]?.ideal ?? 0)) : 0;
    const stat = 'text-[12px] text-muted-foreground';
    const num = 'mt-0.5 text-[17px] font-semibold tabular-nums tracking-[-0.01em] text-foreground';
    body = (
      <>
        <Header title={title} subtitle={subtitle}>
          {team}
          <Button
            size="sm"
            variant="outline"
            iconLeft={sprintState === 'busy' ? 'loader' : sprintState === 'done' ? 'check' : 'flag'}
            disabled={sprintState !== 'open'}
            className="max-sm:w-full"
            onClick={async () => {
              setSprintState('busy');
              if (onAction) await onAction('complete-sprint', { done, total });
              else await new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : 800));
              setSprintState('done');
            }}
          >
            {sprintState === 'busy' ? t.completing : t.completeSprint}
          </Button>
        </Header>
        {sprint && (
          <section aria-label={sprint.name} className="kit-rise mt-6 grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
            <div className="flex flex-col gap-4 p-4 sm:p-5">
              <div className="flex flex-wrap items-center gap-2">
                <h2 className="text-[15px] font-semibold text-foreground">{sprint.name}</h2>
                <span className="text-[13px] tabular-nums text-muted-foreground">
                  {fmtDay(sprint.start, locale)}–{fmtDay(sprint.end, locale)}
                </span>
                <span className="inline-flex h-6 items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--primary)_11%,var(--card))] px-2 text-[12px] font-semibold text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
                  <Icon name="hourglass" size={12} />
                  {left <= 1 ? t.lastDay : fill(t.daysLeft, { n: left })}
                </span>
              </div>
              {sprint.goal && (
                <p className="text-pretty text-[14px] leading-relaxed text-foreground">
                  <span className="me-1.5 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{t.goal}</span>
                  {sprint.goal}
                </p>
              )}
              <dl className="grid grid-cols-3 gap-3 border-y border-border py-3">
                <div>
                  <dt className={stat}>{t.cardsDone}</dt>
                  <dd className={num}>
                    {cards.length - openCards}/{cards.length}
                  </dd>
                </div>
                <div>
                  <dt className={stat}>{t.velocity}</dt>
                  <dd className={num}>{new Intl.NumberFormat(tag(locale), { maximumFractionDigits: 1 }).format(round(done / elapsed, 1))}</dd>
                </div>
                <div>
                  <dt className={stat}>{t.pace}</dt>
                  <dd className={cn(num, 'text-[14px] leading-[1.6]', gap > 0 ? 'text-warning-foreground' : 'text-success-foreground')}>{gap > 0 ? fill(t.behind, { n: gap }) : t.onTrack}</dd>
                </div>
              </dl>
              <div className="mt-auto">
                <div className="flex items-baseline justify-between gap-3">
                  <p className="text-[13px] text-muted-foreground" aria-live="polite">
                    {fill(t.pointsDone, { done, total })}
                  </p>
                  <p className="text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{formatPercent(Math.round(pct), locale)}</p>
                </div>
                <span aria-hidden className="mt-2 block h-2 overflow-hidden rounded-full bg-track">
                  <span className="kbn-grow block h-full rounded-full bg-primary transition-[width] duration-700 ease-[var(--ease-kit)]" style={{ width: `${round(pct, 1)}%` }} />
                </span>
              </div>
              {sprintState === 'done' && (
                <p role="status" className="kbn-in flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
                  <Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
                  {fill(t.completed, { n: openCards })}
                </p>
              )}
            </div>
            <div className="border-t border-border p-4 sm:p-5 lg:border-s lg:border-t-0">
              <LineChart label={t.burndown} data={data} xKey="day" xFormat={{ weekday: 'short', day: 'numeric' }} tooltipXFormat={{ weekday: 'long', day: 'numeric', month: 'long' }} series={[{ key: 'actual', label: t.remaining }, { key: 'ideal', label: t.ideal, dashed: true }]} height={168} legend />
            </div>
          </section>
        )}
        <Board ctx={ctx} columns={columns} cards={cards} setCards={setCards} onAction={onAction} addRequest={addReq} headExtra={(_, list) => <span className="me-1 text-[12px] font-medium tabular-nums text-subtle-foreground">{fill(t.pts, { n: list.reduce((s, c) => s + (c.points ?? 0), 0) })}</span>} />
      </>
    );
  } else if (variant === 'card-detail') {
    const card = cards.find((c) => c.id === sel);
    const update = (patch: Partial<KanbanCard>, action = 'update') => {
      if (!card) return;
      setCards((cs) => cs.map((c) => (c.id === card.id ? { ...c, ...patch } : c)));
      void onAction?.(action, { id: card.id, ...patch });
    };
    const open = (cid: string) => {
      setSel(cid);
      if (!wide) setSheet(true);
    };
    const panel = card ? <Detail key={card.id} card={card} ctx={ctx} columns={columns} tags={tags} people={people} me={me} now={now} update={update} /> : null;
    body = (
      <>
        <Header title={title} subtitle={subtitle}>
          {team}
          {newCard}
        </Header>
        <div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_23rem] xl:grid-cols-[minmax(0,1fr)_26rem]">
          {/* The board scrolls sideways under its own edge, never under the detail panel */}
          <div className="min-w-0 lg:[&_[data-scroller]]:mx-0 lg:[&_[data-scroller]]:px-0">
            <Board ctx={ctx} columns={columns} cards={cards} setCards={setCards} onAction={onAction} addRequest={addReq} selected={sel} onOpen={open} openOnEnter />
          </div>
          <aside aria-label={t.details} className="kit-rise hidden min-w-0 lg:block">
            <div className="kit-scrollbar sticky top-6 mt-5 max-h-[calc(100dvh-3rem)] overflow-y-auto overscroll-contain rounded-xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)]">
              {panel ?? <p className="py-10 text-center text-[13.5px] text-muted-foreground">{t.noCard}</p>}
            </div>
          </aside>
        </div>
        {!wide && (
          <Sheet open={sheet} onOpenChange={setSheet} side="right" title={card?.key ? `${t.details} · ${card.key}` : t.details}>
            {panel}
          </Sheet>
        )}
      </>
    );
  } else if (variant === 'compact') {
    const ql = q.trim().toLowerCase();
    const visible = (c: KanbanCard) => (!ql || c.title.toLowerCase().includes(ql) || (c.key ?? '').toLowerCase().includes(ql)) && (!fTags.length || (c.tags ?? []).some((x) => fTags.includes(x))) && (!fPeople.length || (c.assignees ?? []).some((x) => fPeople.includes(x)));
    const shown = cards.filter(visible).length;
    const filtered = !!ql || fTags.length > 0 || fPeople.length > 0;
    const usedTags = tags.filter((x) => cards.some((c) => c.tags?.includes(x.id)));
    body = (
      <>
        <Header title={title} subtitle={subtitle}>
          {newCard}
        </Header>
        <div className="kit-rise mt-5 flex flex-col gap-3 rounded-xl border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)] lg:flex-row lg:items-center">
          <label className="relative block lg:w-60">
            <span className="sr-only">{t.search}</span>
            <Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
            <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(field, 'ps-9')} />
          </label>
          <div role="group" aria-label={t.filterLabels} className="kit-no-scrollbar -mx-3 flex gap-1.5 overflow-x-auto px-3 lg:mx-0 lg:flex-wrap lg:px-0">
            {usedTags.map((x) => {
              const on = fTags.includes(x.id);
              return (
                <button key={x.id} type="button" aria-pressed={on} aria-label={fill(t.showLabel, { name: x.name })} onClick={() => setFTags((s) => (on ? s.filter((y) => y !== x.id) : [...s, x.id]))} className={cn('inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium transition-[opacity,box-shadow] sm:h-8', ring, TONE[x.tone ?? 'neutral'], on ? 'shadow-[inset_0_0_0_1.5px_currentColor]' : 'opacity-70 hover:opacity-100')}>
                  {on && <Icon name="check" size={12} />}
                  {x.name}
                  <span className="tabular-nums opacity-70">{cards.filter((c) => c.tags?.includes(x.id)).length}</span>
                </button>
              );
            })}
          </div>
          <div role="group" aria-label={t.filterPeople} className="flex items-center gap-1 lg:ms-auto">
            {people.map((p) => {
              const on = fPeople.includes(p.id);
              return (
                <button key={p.id} type="button" aria-pressed={on} aria-label={fill(t.showOf, { name: p.name })} title={p.name} onClick={() => setFPeople((s) => (on ? s.filter((y) => y !== p.id) : [...s, p.id]))} className={cn('grid size-11 cursor-pointer place-items-center rounded-full transition-[opacity,transform] hover:-translate-y-0.5 sm:size-9', ring, fPeople.length && !on && 'opacity-45')}>
                  <span className={cn('rounded-full', on && 'ring-2 ring-primary ring-offset-2 ring-offset-card')}>
                    <Avatar name={p.name} size="sm" />
                  </span>
                </button>
              );
            })}
            <span className="ms-2 whitespace-nowrap text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
              {fill(t.showing, { n: shown, total: cards.length })}
            </span>
            {filtered && (
              <Button
                size="sm"
                variant="ghost"
                className="ms-1"
                onClick={() => {
                  setQ('');
                  setFTags([]);
                  setFPeople([]);
                }}
              >
                {t.clear}
              </Button>
            )}
          </div>
        </div>
        <Board ctx={ctx} columns={columns} cards={cards} setCards={setCards} onAction={onAction} addRequest={addReq} visible={visible} dense />
      </>
    );
  } else {
    body = (
      <>
        <Header title={title} subtitle={subtitle}>
          {team}
          {newCard}
        </Header>
        <Board ctx={ctx} columns={columns} cards={cards} setCards={setCards} onAction={onAction} addRequest={addReq} />
      </>
    );
  }

  const content = (
    <>
      <Style />
      <Main>{body}</Main>
    </>
  );
  if (!sidebar) return <div className={cn('min-h-dvh bg-background', className)}>{content}</div>;
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      {content}
    </Sidebar>
  );
}

Frequent questions

How do I install Kanban board?

With aiw add @ai-website/kanban-board. The command puts components/ui/kanban-board.tsx into your project, the code is yours from then on. It brings along avatar, button, dropdown-menu, icon, line-chart, motion, sheet and sidebar. It needs no further npm packages.

Which variants does Kanban board have?

5 variants, Board, Swimlanes, Sprint, Card details and Compact with filters. You pick one with the variant prop, for example variant="swimlanes".

Can I translate the texts of Kanban board?

Yes. Kanban board carries 90 texts of its own in English and German, such as “New card”, “Add card” and “What needs doing?”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Kanban board?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Kanban board with aiw_components and install it with aiw_add @ai-website/kanban-board. Name the variant in your prompt, for example variant="board", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard