Zum Inhalt springen
UI Kit
Accent

Kanban Board für React

Kanban-Boards mit Limits je Spalte, Karten per Maus, Touch, Tastatur oder Menü verschieben, Swimlanes, Sprint mit Burndown, Kartendetails und Filter.

5 VariantsEine von 4 Components in Aufgaben und Planung

Karten, die jeder Eingabe folgen

Ein Kanban-Board zeigt Arbeit als Karten, die von Spalte zu Spalte wandern, und das ganze Team sieht, wo alles steht. Dieses bewegt Karten per Maus, Touch, Tastatur und Menü, eine Karte mit der Leertaste aufnehmen und mit den Pfeiltasten verschieben klappt also genauso gut wie Ziehen.

Das „Board“ hat Spalten mit Limits, die warnen, einklappen und neue Karten aufnehmen. „Swimlanes“ ergänzt eine Zeile je Person oder Priorität, eine Karte in eine andere Zeile zu ziehen übergibt sie oder ändert ihre Priorität. „Sprint“ setzt Zeitraum, Ziel, Resttage und erledigte Punkte nach oben, dazu ein Burndown gegen die Ideallinie und Punkte je Spalte, die jeder Bewegung folgen. „Kartendetails“ öffnet eine Karte neben dem Board mit Status, Personen, Fälligkeit, Labels, Punkten, Checkliste und Kommentaren, am Telefon als Sheet. „Kompakt mit Filtern“ packt lange Boards dicht und filtert nach Suche, Label und Person mit laufender Zahl.

Setz Limits auf die Spalten, in denen sich Arbeit staut, oft die Prüfung, dann zeigt die Warnung einen Stau, bevor er bremst. Halte die Labels bei wenigen Farben. Das Board nimmt Spalten, Karten, Personen und Labels als Props und zeigt deine Daten ab dem ersten Rendern.

Variants

BoardNeu in 1.2

Spalten mit Limits, die warnen, einklappen und neue Karten aufnehmen. Karten ziehen oder mit der Leertaste aufnehmen und mit den Pfeiltasten verschieben.

SwimlanesNeu in 1.2

Eine Zeile je Person oder Priorität über dieselben Spalten. Eine Karte in eine andere Zeile zu ziehen übergibt sie oder ändert ihre Priorität.

SprintNeu in 1.2

Sprintkopf mit Zeitraum, Ziel, Resttagen und erledigten Punkten, ein Burndown gegen die Ideallinie und Punkte je Spalte, die jeder Bewegung folgen.

KartendetailsNeu in 1.2

Das Board neben der offenen Karte mit Status, Personen, Fälligkeit, Labels, Punkten, Checkliste mit Fortschritt und Kommentaren. Am Telefon als Sheet.

Kompakt mit FilternNeu in 1.2

Dichte Spalten für lange Boards, gefiltert nach Suche, Label und Person, mit einer laufenden Zahl der sichtbaren Karten.

Details

Was Kanban-Board mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/kanban-board.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 90 eigene Texte
Studio
Im Code anpassen
In Sammlungen
SaaS-App-Screens

Props

Die Schnittstelle KanbanBoardProps aus components/ui/kanban-board.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    KanbanBoardVariant

    boardswimlanessprintcard-detailcompact

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

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

  • titlePflicht
    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.

Eingebaute Texte

Diese Texte schreibt Kanban-Board selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 90 Texte anzeigen
newCardEnglisch New cardDeutsch Neue Karte
addCardEnglisch Add cardDeutsch Karte hinzufügen
addCardToEnglisch Add a card to {column}Deutsch Karte zu {column} hinzufügen
cardTitleEnglisch What needs doing?Deutsch Was steht an?
addEnglisch Add cardDeutsch Karte anlegen
cancelEnglisch CancelDeutsch Abbrechen
addedEnglisch {card} added to {column}.Deutsch {card} in {column} angelegt.
countOfEnglisch {n} of {limit}Deutsch {n} von {limit}
overLimitEnglisch {n} over the limitDeutsch {n} über dem Limit
atLimitEnglisch At the limitDeutsch Limit erreicht
collapseEnglisch Collapse {column}Deutsch {column} einklappen
expandEnglisch Expand {column}Deutsch {column} ausklappen
moveToEnglisch Move toDeutsch Verschieben nach
moveUpEnglisch Move upDeutsch Nach oben
moveDownEnglisch Move downDeutsch Nach unten
openCardEnglisch Open detailsDeutsch Details öffnen
cardMenuEnglisch Actions for {card}Deutsch Aktionen für {card}
dragHintEnglisch Press Space or Enter to pick up a card. The arrow keys move it, Space or Enter drops it, Escape puts it back.Deutsch Leertaste oder Enter nimmt eine Karte auf. Die Pfeiltasten verschieben sie, Leertaste oder Enter legt sie ab, Escape legt sie zurück.
dragHintDetailEnglisch Press Enter to open a card and Space to pick it up. The arrow keys move it, Space drops it, Escape puts it back.Deutsch Enter öffnet eine Karte, die Leertaste nimmt sie auf. Die Pfeiltasten verschieben sie, die Leertaste legt sie ab, Escape legt sie zurück.
grabbedEnglisch {card} picked up. {column}, position {pos} of {n}.Deutsch {card} aufgenommen. {column}, Position {pos} von {n}.
movedKbEnglisch {column}, position {pos} of {n}.Deutsch {column}, Position {pos} von {n}.
droppedEnglisch {card} dropped in {column}, position {pos} of {n}.Deutsch {card} in {column} abgelegt, Position {pos} von {n}.
movedEnglisch {card} moved to {column}.Deutsch {card} nach {column} verschoben.
cancelledEnglisch {card} is back in {column}.Deutsch {card} liegt wieder in {column}.
overNoteEnglisch {column} is over its limit.Deutsch {column} ist über dem Limit.
draggableEnglisch draggable cardDeutsch verschiebbare Karte
overdueEnglisch OverdueDeutsch Überfällig
dueTodayEnglisch TodayDeutsch Heute
dueTomorrowEnglisch TomorrowDeutsch Morgen
dueOnEnglisch Due {date}Deutsch Fällig {date}
pointsEnglisch {n} pointsDeutsch {n} Punkte
ptsEnglisch {n} ptsDeutsch {n} Pkt.
checklistEnglisch {done} of {n} doneDeutsch {done} von {n} erledigt
commentsEnglisch {n} commentsDeutsch {n} Kommentare
assignedEnglisch Assigned to {names}Deutsch Zugewiesen an {names}
unassignedEnglisch Not assignedDeutsch Nicht zugewiesen
priorityEnglisch Priority {p}Deutsch Priorität {p}
urgentEnglisch UrgentDeutsch Dringend
highEnglisch HighDeutsch Hoch
mediumEnglisch MediumDeutsch Mittel
lowEnglisch LowDeutsch Niedrig
noneEnglisch No priorityDeutsch Ohne Priorität
columnsEnglisch ColumnsDeutsch Spalten
dropHereEnglisch Drop hereDeutsch Hier ablegen
emptyEnglisch Nothing here yetDeutsch Noch leer
noMatchEnglisch No card matchesDeutsch Keine Karte passt
undoEnglisch UndoDeutsch Rückgängig
laneEnglisch {n} cardsDeutsch {n} Karten
collapseLaneEnglisch Collapse {lane}Deutsch {lane} einklappen
expandLaneEnglisch Expand {lane}Deutsch {lane} ausklappen
daysLeftEnglisch {n} days leftDeutsch Noch {n} Tage
lastDayEnglisch Last dayDeutsch Letzter Tag
burndownEnglisch BurndownDeutsch Burndown
idealEnglisch IdealDeutsch Ideal
remainingEnglisch Open pointsDeutsch Offene Punkte
pointsDoneEnglisch {done} of {total} points doneDeutsch {done} von {total} Punkten erledigt
completeSprintEnglisch Complete sprintDeutsch Sprint abschließen
completingEnglisch Closing…Deutsch Wird abgeschlossen…
completedEnglisch Sprint closed. {n} open cards move to the next sprint.Deutsch Sprint abgeschlossen. {n} offene Karten gehen in den nächsten Sprint.
goalEnglisch Sprint goalDeutsch Sprintziel
cardsDoneEnglisch Cards doneDeutsch Karten erledigt
velocityEnglisch Points a dayDeutsch Punkte am Tag
paceEnglisch PaceDeutsch Tempo
onTrackEnglisch On trackDeutsch Im Plan
behindEnglisch {n} points behindDeutsch {n} Punkte zurück
detailsEnglisch Card detailsDeutsch Kartendetails
titleEnglisch TitleDeutsch Titel
statusEnglisch StatusDeutsch Status
assigneesEnglisch PeopleDeutsch Personen
dueDateEnglisch Due dateDeutsch Fällig am
labelsTitleEnglisch LabelsDeutsch Labels
estimateEnglisch PointsDeutsch Punkte
descriptionEnglisch DescriptionDeutsch Beschreibung
descPlaceholderEnglisch Add the details the team needsDeutsch Ergänze, was das Team wissen muss
checklistTitleEnglisch ChecklistDeutsch Checkliste
addItemEnglisch Add itemDeutsch Punkt hinzufügen
itemPlaceholderEnglisch Add an itemDeutsch Punkt hinzufügen
removeItemEnglisch Remove {item}Deutsch {item} entfernen
commentsTitleEnglisch CommentsDeutsch Kommentare
commentPlaceholderEnglisch Write a commentDeutsch Kommentar schreiben
postEnglisch Post commentDeutsch Kommentar senden
noCardEnglisch Pick a card to see its details here.Deutsch Wähle eine Karte, dann stehen hier ihre Details.
searchEnglisch Search cardsDeutsch Karten suchen
filterLabelsEnglisch Filter by labelDeutsch Nach Label filtern
filterPeopleEnglisch Filter by personDeutsch Nach Person filtern
showingEnglisch {n} of {total} cardsDeutsch {n} von {total} Karten
clearEnglisch Show allDeutsch Alle zeigen
showOfEnglisch Show cards of {name}Deutsch Karten von {name} zeigen
showLabelEnglisch Show cards with {name}Deutsch Karten mit {name} zeigen
justEnglisch just nowDeutsch gerade eben

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Kanban-Board reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Tab und Leertaste.
  • Setzt aria-checked, aria-current, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed und aria-roledescription, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen button, checkbox, group, radio, radiogroup und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.791 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Kanban-Board?

Mit aiw add @ai-website/kanban-board. Der Befehl legt die Datei components/ui/kanban-board.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, button, dropdown-menu, icon, line-chart, motion, sheet und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Kanban-Board?

5 Varianten, Board, Swimlanes, Sprint, Kartendetails und Kompakt mit Filtern. Du wählst sie mit der Prop variant, zum Beispiel variant="swimlanes".

Kann ich die Texte von Kanban-Board übersetzen?

Ja. Kanban-Board bringt 90 eigene Texte auf Deutsch und Englisch mit, etwa „Neue Karte“, „Karte hinzufügen“ und „Was steht an?“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Kanban-Board einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kanban-Board mit aiw_components und installieren sie mit aiw_add @ai-website/kanban-board. Nenn im Prompt die Variante, etwa variant="board", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert