Deckhand Kanban
FreeA kanban board in your own colours that deals its cards like a deck on sort.
Ferry app, autumn release
Backlog
4Offline boarding pass after the tunnel
Explore a fare calendar for weekend sailings
Receipts emailed twice for group bookings
Larger tap targets on the passenger stepper
Ready
4Interview six commuters about the monthly pass
Trip card height jumps when the delay banner arrives
Dark mode pass on the boarding pass screen
Regression test for the Del Norte seat map
In progress
5/4, over the limitSeat map drops the wheelchair bays on the 7:40 Larkspur ferry
Apple Pay sheet shows the fare before the Clipper discount
Tide-delay banner copy, three lengths
Accessibility audit for the autumn release
Recruit riders from the Vallejo route
In review
2VoiceOver reads departure times as numbers
Refund flow for weather cancellations
Done
5Ship schedule sync to 4-minute intervals
Fare rules for bikes on the Vallejo route
Onboarding: drop the account wall before the first trip
Read the accessible flag on the second deck
Recruit riders from the Larkspur route
Enter opens the card. Space picks it up, arrow keys move it, Space drops it, Escape cancels.
Type in the filter for field suggestions, switch views in the tabs, or press Deal to watch the cards shuffle. Click a card to open it; its parent and sub-issue chips jump to the linked card.
npx shadcn@latest add @ui-shrushank/kanban-board-templateThe install writes a single file to components/ui/KanbanBoardTemplate.tsx, which exports KanbanBoardTemplate.
import { KanbanBoardTemplate } from "@/components/templates/KanbanBoardTemplate";<KanbanBoardTemplate />Needs framer-motion installed. The shadcn CLI adds it for you.
A project board modelled on GitHub Projects, for a fictional ferry app team (Harbor, Sprint 14): the same board, table and roadmap layouts, the same saved view tabs, the same field:value filter and two-field sort, and Insights charts over whatever the view shows. It spends its effort on the parts most boards get wrong: colours are the team's own rather than a preset row, every card says how far along it is, sub-issues are real cards linked both ways, and sorting is something you can watch happen.
- A view is a saved config (layout, filter query, up to two sort rules, the field to use as columns, swimlanes or row groups, the field to sum, the fields a card shows). Editing one marks its tab with a dot until Save view or Discard, as GitHub does. The filter is GitHub's grammar: qualifiers (
is:,assignee:,label:,status:,priority:,due:,parent:,title:, andrepo:once issues are imported), commas for "any of", a leading-to exclude, quotes around spaced values, bare words against the title, key and assignee. The input is transparent over a mirror that draws qualifiers as tokens, and the suggestion list hangs under the word being typed, first fields and then that field's values with their avatar, colour or priority and a count. Grouping is data: dropping a card in a Priority column sets its priority, in a person's column assigns it, in a swimlane sets that field too. In a sorted view the drop slot shows where the sort will place the card rather than where the pointer is. Sorting a board deals the cards like a deck: the real cards are re-laid out invisibly, clones gather into a stack, riffle, and deal back along arcs, on the Web Animations API so nothing re-renders in flight - Only a card in flight casts a lift shadow, since fifteen stacked into a smudge in the first build. Every card is tinted with its status column's colour through
color-mix, and every card carries a progress bar: its sub-issues done out of total when it has any, otherwise how far along the columns it has travelled. Sub-issues are ordinary cards with a parent - A child shows a chip for its parent and a parent shows a chip per child, and following one scrolls to the linked card and flashes it (or opens it when the view hides it). The roadmap draws a bar per card from start to due date over a day grid with weekends shaded and a line at today, opens scrolled to today, and takes drags on the bar or either end, snapped to days. Insights draws a burn-up in points against an even pace, points by assignee stacked in the column colours, cards by status, and a label donut in the labels' own colours, all with the library's charts. GitHub import reads a repo's issues over the REST API: labels keep their colours, a milestone's due date becomes the card's, a task list becomes sub-issue cards, a task that names another issue links that issue's card, and an issue already on the board is skipped. Public repos need nothing
- A token for a private one stays in memory and goes only to api.github.com. Sounds are synthesised with Web Audio through one master gain of 0.3 with a mute switch, and the board has its own light and dark switch that pins
.darkordata-light.
- Every pointer action has a keyboard twin. A card is focusable
- Enter opens it, Space picks it up, arrows move it within and across columns, Space drops and Escape puts it back, each step read out by one shared live region. Keyboard moves land instantly rather than animating.
/focuses the filter - Arrows, Enter and Tab drive its suggestions. The View menu's panels, the tabs, the table rows and the roadmap bars all take arrow keys (Shift with an arrow changes a bar's end date only). Card links stay out of the tab order because the card sheet carries the same parent and sub-issue links as buttons. Reduced motion turns the deal into a plain re-order and drops the tilt and slides, keeping the fades.
Every line below was read out of the file you are about to copy, so you can check each one against the source at the bottom of this page.
- Uses the native
role="img",role="button",role="menu",role="menuitem",role="dialog",role="menuitemcheckbox",role="alert",role="progressbar",role="combobox",role="listbox",role="presentation",role="option",role="menuitemradio",role="radiogroup",role="radio",role="tab",role="tablist",role="group"semantics rather than a styleddiv, so assistive technology announces what the control is and how it behaves. - Carries
aria-activedescendant,aria-autocomplete,aria-busy,aria-checked,aria-controls,aria-describedby,aria-disabled,aria-expanded,aria-haspopup,aria-label,aria-labelledby,aria-live,aria-modal,aria-pressed,aria-roledescription,aria-selected,aria-sort,aria-valuemax,aria-valuemin,aria-valuenow, so its state is exposed and not left to the visual treatment alone. - Announces its own state changes through an
aria-liveregion, so a change that is only visual is still heard. - Has an explicit
prefers-reduced-motionbranch: the animation is dropped for an instant state change, and the component stays fully usable without it. - Handles keys directly, so every pointer gesture it supports has a keyboard equivalent.
- Draws a
focus-visiblering that is separate from its hover treatment, so keyboard users get an affordance mouse users do not take away. - Ships screen-reader-only text for the part of its affordance that is purely visual.
- Pairs every input with a real
<label>, so the field keeps its name after the placeholder disappears. - Manages
tabIndexitself, so the group is one tab stop and the arrow keys move within it.
One file, no runtime package. Copy it, or install it with the command at the top of this page.
"use client";import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode,} from "react";import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity,} from "framer-motion";import { Geist, Geist_Mono } from "next/font/google";import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide, BookMarked, CalendarDays, CalendarPlus, CaseSensitive, ChartGantt, ChartLine, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDashed, Columns3, Copy, CornerDownRight, Ellipsis, ExternalLink, Hash, History, Layers, LayoutGrid, List, ListTodo, LoaderCircle, Moon, Pencil, Plus, Rows2, Rows3, Search, Settings2, SignalHigh, SquareKanban, Sun, Table2, Tag, Trash2, UserRound, UsersRound, Volume2, VolumeX, X, type LucideIcon,} from "lucide-react";import { ColorPicker } from "@/components/ui/ColorPicker";import { Select } from "@/components/ui/Select";import { LineChart } from "@/components/ui/LineChart";import { BarChart } from "@/components/ui/BarChart";import { PieChart, type PieSlice } from "@/components/ui/PieChart";import type { ChartPoint, SeriesSpec } from "@/components/ui/ChartFrame";import { AvatarGroup } from "@/components/ui/AvatarGroup";import { LabelChip } from "@/components/ui/LabelChip";import { cn } from "@/lib/utils";/** * Loaded here rather than in the app layout so the template is * self-contained: copy the file and the faces come with it. */const geist = Geist({ subsets: ["latin"], display: "swap" });const geistMono = Geist_Mono({ subsets: ["latin"], display: "swap", variable: "--font-deck-mono" });/* ------------------------------------------------------------------------ *//* Model *//* ------------------------------------------------------------------------ */type Priority = 0 | 1 | 2 | 3 | 4;interface Person { id: string; name: string; initials: string; role: string; /** A solid fill that carries a white label. */ color: string; avatarUrl?: string;}interface Label { id: string; name: string; color: string;}interface Column { id: string; name: string; color: string; /** Work-in-progress limit. The count turns amber past it. */ limit?: number;}interface Activity { id: string; text: string; at: string;}interface Card { id: string; key: string; title: string; description: string; columnId: string; labelIds: string[]; assigneeId: string | null; priority: Priority; /** ISO dates, yyyy-mm-dd. */ start: string | null; due: string | null; /** Story points. */ estimate: number | null; /** A sub-issue points at its parent; the parent's progress counts its children. */ parentId: string | null; createdAt: string; /** Set when the card reaches Done, cleared if it leaves. Drives the burn-up. */ closedAt: string | null; /** GitHub's own sub-issue count, for an imported parent whose children stayed on GitHub. */ remoteProgress?: { done: number; total: number }; activity: Activity[]; /** Set on cards pulled from GitHub: the issue page, and a key for skipping repeats. */ source?: { url: string; ref: string };}interface Board { columns: Column[]; labels: Label[]; /** The seed team, plus anyone an imported issue was assigned to. */ people: Person[]; /** Order in this array is the order inside each column. */ cards: Card[]; seq: number;}const PRIORITIES = ["No priority", "Low", "Medium", "High", "Urgent"] as const;/** Where the "done" chime and the sprint progress read from. */const DONE_ID = "done";/** * A fixed "today" keeps the demo deterministic: every due-date chip renders * the same on the server and in the browser. In a real app this is the * current date. */const TODAY = "2026-09-29";const SPRINT_START = "2026-09-22";const SPRINT_END = "2026-10-05";const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];function dayNumber(iso: string) { const [y, m, d] = iso.split("-").map(Number); return Math.round(Date.UTC(y, m - 1, d) / 86_400_000);}function shortDate(iso: string) { const [, m, d] = iso.split("-").map(Number); return `${MONTHS[m - 1]} ${d}`;}function dueState(iso: string | null, columnId: string): "none" | "overdue" | "soon" | "later" { if (!iso || columnId === DONE_ID) return "none"; const diff = dayNumber(iso) - dayNumber(TODAY); if (diff < 0) return "overdue"; if (diff <= 2) return "soon"; return "later";}/* ------------------------------------------------------------------------ *//* Seed *//* ------------------------------------------------------------------------ */const PEOPLE: Person[] = [ { id: "ines", name: "Ines Okafor", initials: "IO", role: "Product design", color: "bg-emerald-700" }, { id: "tomas", name: "Tomas Reyes", initials: "TR", role: "iOS", color: "bg-amber-700" }, { id: "priya", name: "Priya Nair", initials: "PN", role: "Payments", color: "bg-emerald-800" }, { id: "jonah", name: "Jonah Weiss", initials: "JW", role: "Web", color: "bg-amber-800" }, { id: "mei", name: "Mei Tanaka", initials: "MT", role: "QA", color: "bg-stone-600" },];// Label and column colours are user data, so they are whatever the team// picked: a board is where people decide these for themselves.const SEED_LABELS: Label[] = [ { id: "bug", name: "Bug", color: "#e5484d" }, { id: "design", name: "Design", color: "#8e4ec6" }, { id: "ios", name: "iOS", color: "#0090ff" }, { id: "payments", name: "Payments", color: "#12a594" }, { id: "a11y", name: "Accessibility", color: "#d6409f" }, { id: "research", name: "Research", color: "#f5a524" },];const SEED_COLUMNS: Column[] = [ { id: "backlog", name: "Backlog", color: "#a8a29e" }, { id: "ready", name: "Ready", color: "#0090ff" }, { id: "doing", name: "In progress", color: "#f5a524", limit: 4 }, { id: "review", name: "In review", color: "#8e4ec6" }, { id: DONE_ID, name: "Done", color: "#30a46c" },];interface SeedCard { title: string; column: string; labels: string[]; who: string | null; priority: Priority; estimate: number | null; start?: string; due?: string; /** 1-based index of the parent in this list. */ parent?: number; created?: string; closed?: string; description?: string;}// Three levels on purpose: the audit has sub-issues, and one of those (the// seat map bug) has its own, so progress rolls up the way it does in a real// sprint. Several cards arrive mid-sprint, which is what the burn-up's rising// scope line is for.const SEED_CARDS: SeedCard[] = [ { title: "Seat map drops the wheelchair bays on the 7:40 Larkspur ferry", column: "doing", labels: ["bug", "a11y"], who: "tomas", priority: 4, estimate: 5, start: "2026-09-24", due: "2026-09-30", parent: 16, description: "The bays render as regular seats when the vessel swaps from the Mendocino to the Del Norte. Seat map JSON has them, the iOS renderer ignores the `accessible` flag on the second deck.", }, { title: "Apple Pay sheet shows the fare before the Clipper discount", column: "doing", labels: ["payments", "ios"], who: "priya", priority: 3, estimate: 3, start: "2026-09-23", due: "2026-10-02", description: "Riders see $14.50 in the sheet and $7.25 on the receipt. The discount is applied server-side after authorisation; move it before we build the PKPaymentRequest.", }, { title: "Tide-delay banner copy, three lengths", column: "doing", labels: ["design"], who: "ines", priority: 2, estimate: 2, start: "2026-09-26", due: "2026-10-01", description: "Short for the lock screen, medium for the trip card, long for the delay sheet. Needs sign-off from ops on the wording for cancelled sailings.", }, { title: "VoiceOver reads departure times as numbers", column: "review", labels: ["a11y", "ios"], who: "mei", priority: 3, estimate: 2, start: "2026-09-25", due: "2026-09-29", parent: 16, description: '"Seven four zero" instead of "7:40 AM". Use a DateFormatter for the accessibility label, not the display string.', }, { title: "Refund flow for weather cancellations", column: "review", labels: ["payments"], who: "priya", priority: 2, estimate: 5, start: "2026-09-22", due: "2026-10-03", parent: 2, description: "Auto-refund to the original method when a sailing is cancelled for weather. Partial refunds stay manual for now.", }, { title: "Interview six commuters about the monthly pass", column: "ready", labels: ["research"], who: "ines", priority: 1, estimate: 3, start: "2026-09-26", due: "2026-10-06", description: "Recruit from the Larkspur and Vallejo routes. Focus on when they buy, not what they pay.", }, { title: "Trip card height jumps when the delay banner arrives", column: "ready", labels: ["bug", "design"], who: "jonah", priority: 2, estimate: 2, start: "2026-10-01", due: "2026-10-04", parent: 3, created: "2026-09-24", description: "Reserve the banner's space when a sailing is within 30 minutes so the card does not shove the list down.", }, { title: "Dark mode pass on the boarding pass screen", column: "ready", labels: ["design", "ios"], who: "tomas", priority: 1, estimate: 3, description: "QR code needs a white quiet zone in dark mode or the turnstile scanners miss it.", }, { title: "Offline boarding pass after the tunnel", column: "backlog", labels: ["ios"], who: null, priority: 2, estimate: 5, description: "Cache the last issued pass so riders can board when Ferry Building wifi drops.", }, { title: "Explore a fare calendar for weekend sailings", column: "backlog", labels: ["research", "design"], who: null, priority: 0, estimate: 8, }, { title: "Receipts emailed twice for group bookings", column: "backlog", labels: ["bug", "payments"], who: "priya", priority: 3, estimate: 2, start: "2026-09-24", due: "2026-09-26", parent: 2, created: "2026-09-25", description: "One per rider plus one to the purchaser. The purchaser should get a single combined receipt.", }, { title: "Larger tap targets on the passenger stepper", column: "backlog", labels: ["a11y"], who: "jonah", priority: 1, estimate: 1, parent: 16, description: "Current buttons are 32pt. Go to 44pt and keep the stepper on one line.", }, { title: "Ship schedule sync to 4-minute intervals", column: DONE_ID, labels: ["ios"], who: "tomas", priority: 2, estimate: 2, start: "2026-09-18", due: "2026-09-24", created: "2026-09-18", closed: "2026-09-24", }, { title: "Fare rules for bikes on the Vallejo route", column: DONE_ID, labels: ["payments"], who: "priya", priority: 1, estimate: 3, start: "2026-09-22", due: "2026-09-25", parent: 2, closed: "2026-09-25", }, { title: "Onboarding: drop the account wall before the first trip", column: DONE_ID, labels: ["design"], who: "ines", priority: 2, estimate: 3, start: "2026-09-19", due: "2026-09-23", created: "2026-09-19", closed: "2026-09-23", }, { title: "Accessibility audit for the autumn release", column: "doing", labels: ["a11y"], who: "mei", priority: 3, estimate: 8, start: "2026-09-22", due: "2026-10-06", description: "Everything a VoiceOver or Switch Control rider hits between opening the app and boarding. Each finding is a sub-issue.", }, { title: "Read the accessible flag on the second deck", column: DONE_ID, labels: ["bug", "ios"], who: "tomas", priority: 3, estimate: 2, start: "2026-09-24", due: "2026-09-26", parent: 1, created: "2026-09-24", closed: "2026-09-27", }, { title: "Regression test for the Del Norte seat map", column: "ready", labels: ["ios"], who: "mei", priority: 2, estimate: 1, start: "2026-09-30", due: "2026-10-01", parent: 1, created: "2026-09-27", }, { title: "Recruit riders from the Larkspur route", column: DONE_ID, labels: ["research"], who: "ines", priority: 1, estimate: 1, start: "2026-09-26", due: "2026-09-28", parent: 6, created: "2026-09-26", closed: "2026-09-28", }, { title: "Recruit riders from the Vallejo route", column: "doing", labels: ["research"], who: "ines", priority: 1, estimate: 1, start: "2026-09-29", due: "2026-10-02", parent: 6, created: "2026-09-26", },];/** Keys run on from the seed: HBR-128 to HBR-147, so the first new card is HBR-148. */const FIRST_NEW_KEY = 128 + SEED_CARDS.length;function seedBoard(): Board { const cards: Card[] = SEED_CARDS.map((s, i) => ({ id: `c${i + 1}`, key: `HBR-${128 + i}`, title: s.title, description: s.description ?? "", columnId: s.column, labelIds: s.labels, assigneeId: s.who, priority: s.priority, estimate: s.estimate, start: s.start ?? null, due: s.due ?? null, parentId: s.parent ? `c${s.parent}` : null, createdAt: s.created ?? SPRINT_START, closedAt: s.closed ?? null, activity: [ { id: `c${i + 1}a0`, text: `Created in ${SEED_COLUMNS.find((c) => c.id === s.column)?.name}`, at: shortDate(s.created ?? SPRINT_START), }, ], })); return { columns: SEED_COLUMNS, labels: SEED_LABELS, people: PEOPLE, cards, seq: 1000 };}/* ------------------------------------------------------------------------ *//* Board operations *//* ------------------------------------------------------------------------ *//** Moves a card to `columnId`, before `beforeId`, or to the end of the column. */function moveCard(cards: Card[], id: string, columnId: string, beforeId: string | null): Card[] { const card = cards.find((c) => c.id === id); if (!card) return cards; const rest = cards.filter((c) => c.id !== id); const moved = { ...card, columnId }; let at = beforeId ? rest.findIndex((c) => c.id === beforeId) : -1; if (at < 0) { const last = rest.map((c) => c.columnId).lastIndexOf(columnId); at = last < 0 ? rest.length : last + 1; } rest.splice(at, 0, moved); return rest;}/** Moves a card, applying `patch`, to before `beforeId`, or after the last card `inCell` accepts. */function placeCard( cards: Card[], id: string, patch: Partial<Card>, beforeId: string | null, inCell: (c: Card) => boolean): Card[] { const card = cards.find((c) => c.id === id); if (!card) return cards; const rest = cards.filter((c) => c.id !== id); let at = beforeId ? rest.findIndex((c) => c.id === beforeId) : -1; if (at < 0) { let last = -1; rest.forEach((c, i) => { if (inCell(c)) last = i; }); at = last < 0 ? rest.length : last + 1; } rest.splice(at, 0, { ...card, ...patch }); return rest;}/* ------------------------------------------------------------------------ *//* Sub-issues *//* ------------------------------------------------------------------------ */interface Progress { done: number; total: number;}function childrenOf(cards: Card[]) { const m = new Map<string, Card[]>(); for (const c of cards) if (c.parentId) m.set(c.parentId, [...(m.get(c.parentId) ?? []), c]); return m;}/** * How much of a card is covered: its sub-issues that reached Done. An * imported parent whose children stayed on GitHub carries GitHub's own count. */function progressOf(card: Card, kids: Map<string, Card[]>): Progress | null { const children = kids.get(card.id) ?? []; if (!children.length) return card.remoteProgress ?? null; return { done: children.filter((c) => c.columnId === DONE_ID).length, total: children.length };}/** A card and everything beneath it, so a parent picker cannot make a loop. */function familyOf(id: string, kids: Map<string, Card[]>): Set<string> { const out = new Set([id]); const stack = [id]; while (stack.length) { for (const c of kids.get(stack.pop()!) ?? []) { if (!out.has(c.id)) { out.add(c.id); stack.push(c.id); } } } return out;}/** Cards above `card`, walking up its parents. */function ancestorsOf(card: Card, cards: Card[]): Set<string> { const out = new Set<string>(); let p = card.parentId; while (p && !out.has(p)) { out.add(p); p = cards.find((c) => c.id === p)?.parentId ?? null; } return out;}interface BoardCtx { board: Board; people: Map<string, Person>; labels: Map<string, Label>; kids: Map<string, Card[]>;}/** What a person is called in a query: their id, without an import prefix. */const handleOf = (p: Person) => p.id.replace(/^gh:/, "").toLowerCase();/* ------------------------------------------------------------------------ *//* Filter query *//* *//* GitHub's grammar: `field:value` qualifiers, commas for "any of", *//* a leading `-` to exclude, quotes around values with spaces, and bare *//* words matched against the title, the key and the assignee's name. *//* Different qualifiers AND together; values inside one qualifier OR. *//* ------------------------------------------------------------------------ */type QField = "is" | "assignee" | "label" | "status" | "priority" | "due" | "parent" | "title" | "repo";interface QToken { start: number; end: number; /** Index just past the colon, for qualifiers. */ keyEnd: number; neg: boolean; field: QField | null; values: string[];}const Q_FIELDS: { key: QField; label: string; icon: LucideIcon; hint: string }[] = [ { key: "is", label: "Is", icon: LayoutGrid, hint: "open, done, overdue" }, { key: "assignee", label: "Assignee", icon: UserRound, hint: "a person, or none" }, { key: "label", label: "Label", icon: Tag, hint: "any label" }, { key: "status", label: "Status", icon: CircleDashed, hint: "a column" }, { key: "priority", label: "Priority", icon: SignalHigh, hint: "urgent to none" }, { key: "due", label: "Due date", icon: CalendarDays, hint: "overdue, this-week" }, { key: "parent", label: "Parent issue", icon: CornerDownRight, hint: "a parent's key" }, { key: "title", label: "Title", icon: CaseSensitive, hint: "words in the title" }, { key: "repo", label: "Repository", icon: BookMarked, hint: "an imported repo" },];const Q_KEYS = new Set<string>(Q_FIELDS.map((f) => f.key));const PRIORITY_KEYS = ["none", "low", "medium", "high", "urgent"] as const;function splitValues(s: string) { const out: string[] = []; let cur = ""; let inQ = false; for (const ch of s) { if (ch === '"') { inQ = !inQ; continue; } if (ch === "," && !inQ) { out.push(cur); cur = ""; continue; } cur += ch; } out.push(cur); return out.map((v) => v.trim()).filter(Boolean);}const quoteValue = (v: string) => (/[\s,"]/.test(v) ? `"${v.replace(/"/g, "")}"` : v);function tokenize(q: string): QToken[] { const out: QToken[] = []; let i = 0; while (i < q.length) { if (/\s/.test(q[i])) { i++; continue; } const start = i; let inQ = false; while (i < q.length && (inQ || !/\s/.test(q[i]))) { if (q[i] === '"') inQ = !inQ; i++; } const raw = q.slice(start, i); const m = raw.match(/^(-?)([a-zA-Z]+):(.*)$/); if (m && Q_KEYS.has(m[2].toLowerCase())) { const keyEnd = start + m[1].length + m[2].length + 1; out.push({ start, end: i, keyEnd, neg: m[1] === "-", field: m[2].toLowerCase() as QField, values: splitValues(m[3]) }); } else { out.push({ start, end: i, keyEnd: start, neg: false, field: null, values: [raw.replace(/"/g, "")] }); } } return out;}function fieldMatch(field: QField, v: string, c: Card, ctx: BoardCtx): boolean { switch (field) { case "is": if (v === "open") return c.columnId !== DONE_ID; if (v === "done" || v === "closed") return c.columnId === DONE_ID; if (v === "overdue") return dueState(c.due, c.columnId) === "overdue"; if (v === "due-soon") return dueState(c.due, c.columnId) === "soon"; if (v === "assigned") return !!c.assigneeId; if (v === "unassigned") return !c.assigneeId; if (v === "parent") return (ctx.kids.get(c.id)?.length ?? 0) > 0; if (v === "sub-issue") return !!c.parentId; if (v === "imported") return !!c.source; return false; case "assignee": { if (v === "none") return !c.assigneeId; const p = c.assigneeId ? ctx.people.get(c.assigneeId) : undefined; return !!p && (handleOf(p) === v || p.name.toLowerCase() === v); } case "label": return c.labelIds.some((id) => ctx.labels.get(id)?.name.toLowerCase() === v); case "status": return ctx.board.columns.find((col) => col.id === c.columnId)?.name.toLowerCase() === v; case "priority": return PRIORITY_KEYS[c.priority] === v; case "due": { if (v === "none") return !c.due; if (!c.due) return false; const diff = dayNumber(c.due) - dayNumber(TODAY); if (v === "overdue") return diff < 0 && c.columnId !== DONE_ID; if (v === "today") return diff === 0; if (v === "this-week") return diff >= 0 && diff <= 7; if (v === "next-week") return diff > 7 && diff <= 14; return c.due === v; } case "parent": { if (v === "none") return !c.parentId; const p = c.parentId ? ctx.board.cards.find((x) => x.id === c.parentId) : undefined; return !!p && p.key.toLowerCase() === v; } case "title": return c.title.toLowerCase().includes(v); case "repo": return !!c.source && c.source.ref.toLowerCase().startsWith(v + "#"); }}function cardMatches(c: Card, tokens: QToken[], ctx: BoardCtx): boolean { for (const t of tokens) { if (t.field === null) { const v = t.values[0]?.toLowerCase(); if (!v) continue; const who = c.assigneeId ? ctx.people.get(c.assigneeId)?.name.toLowerCase() ?? "" : ""; if (!(c.title.toLowerCase().includes(v) || c.key.toLowerCase().includes(v) || who.includes(v))) return false; continue; } // A qualifier still being typed ("label:") filters nothing yet. if (!t.values.length) continue; const any = t.values.some((raw) => fieldMatch(t.field!, raw.toLowerCase(), c, ctx)); if (any === t.neg) return false; } return true;}/** What the caret is sitting in: a field name being typed, or a field's value. */type QContext = | { kind: "field"; from: number; to: number; prefix: string; neg: boolean } | { kind: "value"; field: QField; from: number; to: number; prefix: string };function contextAt(q: string, caret: number): QContext | null { const tok = tokenize(q).find((t) => t.start <= caret && caret <= t.end); if (!tok) return { kind: "field", from: caret, to: caret, prefix: "", neg: false }; const part = q.slice(tok.start, caret); const m = part.match(/^(-?)([a-zA-Z]+):(.*)$/); if (m) { if (!Q_KEYS.has(m[2].toLowerCase())) return null; const after = m[3]; const lastComma = after.lastIndexOf(","); const from = tok.start + m[1].length + m[2].length + 1 + (lastComma + 1); const rest = q.slice(caret, tok.end); const nextComma = rest.indexOf(","); return { kind: "value", field: m[2].toLowerCase() as QField, from, to: nextComma < 0 ? tok.end : caret + nextComma, prefix: after.slice(lastComma + 1).replace(/"/g, ""), }; } if (part.includes(":")) return null; return { kind: "field", from: tok.start, to: tok.end, prefix: part.replace(/^-/, ""), neg: part.startsWith("-") };}/* ------------------------------------------------------------------------ *//* Sorting *//* ------------------------------------------------------------------------ */type SortField = "title" | "status" | "assignee" | "labels" | "priority" | "progress" | "estimate" | "due" | "created" | "repo";type SortDir = "asc" | "desc";interface SortRule { field: SortField; dir: SortDir;}/** `dir` is the direction a field starts in: urgent first, soonest first, newest first. */const SORT_FIELDS: { field: SortField; label: string; icon: LucideIcon; dir: SortDir }[] = [ { field: "title", label: "Title", icon: List, dir: "asc" }, { field: "status", label: "Status", icon: CircleDashed, dir: "asc" }, { field: "assignee", label: "Assignees", icon: UsersRound, dir: "asc" }, { field: "labels", label: "Labels", icon: Tag, dir: "asc" }, { field: "priority", label: "Priority", icon: SignalHigh, dir: "desc" }, { field: "progress", label: "Sub-issues progress", icon: ListTodo, dir: "desc" }, { field: "estimate", label: "Estimate", icon: Hash, dir: "desc" }, { field: "due", label: "Due date", icon: CalendarDays, dir: "asc" }, { field: "created", label: "Created", icon: CalendarPlus, dir: "desc" }, { field: "repo", label: "Repository", icon: BookMarked, dir: "asc" },];const sortLabel = (f: SortField) => SORT_FIELDS.find((s) => s.field === f)!.label;function sortValue(field: SortField, c: Card, ctx: BoardCtx): string | number | null { switch (field) { case "title": return c.title.toLowerCase(); case "status": return ctx.board.columns.findIndex((col) => col.id === c.columnId); case "assignee": return c.assigneeId ? ctx.people.get(c.assigneeId)?.name.toLowerCase() ?? null : null; case "labels": { const names = c.labelIds.map((id) => ctx.labels.get(id)?.name.toLowerCase() ?? "").sort(); return names.length ? names.join(" ") : null; } case "priority": return c.priority || null; case "progress": { const p = progressOf(c, ctx.kids); return p ? p.done / p.total : null; } case "estimate": return c.estimate; case "due": return c.due ? dayNumber(c.due) : null; case "created": return Number(c.id.replace(/\D/g, "")) || 0; case "repo": return c.source ? c.source.ref.split("#")[0].toLowerCase() : null; }}/** Stable, up to two rules; empty values sort last whichever way the rule points. */function sortCards(cards: Card[], rules: SortRule[], ctx: BoardCtx): Card[] { if (!rules.length) return cards; return [...cards].sort((a, b) => { for (const r of rules) { const va = sortValue(r.field, a, ctx); const vb = sortValue(r.field, b, ctx); if (va === vb) continue; if (va === null) return 1; if (vb === null) return -1; const d = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb), undefined, { numeric: true }); if (d) return r.dir === "asc" ? d : -d; } return 0; });}/* ------------------------------------------------------------------------ *//* Grouping (columns and swimlanes) *//* *//* A group is a slice of the board plus the patch that puts a card in it, *//* so dropping a card into "High" sets its priority and dropping it into *//* Priya's column assigns it to her. Status groups are the real columns. *//* ------------------------------------------------------------------------ */type GroupBy = "status" | "priority" | "assignee";type Lanes = "none" | GroupBy;interface Group { id: string; name: string; patch: Partial<Card>; match: (c: Card) => boolean; column?: Column; priority?: Priority; /** `null` is the Unassigned group. */ person?: Person | null;}const GROUP_OPTIONS: { key: GroupBy; label: string }[] = [ { key: "status", label: "Status" }, { key: "priority", label: "Priority" }, { key: "assignee", label: "Assignee" },];const ALL_LANE: Group = { id: "all", name: "All", patch: {}, match: () => true };function groupsFor(by: GroupBy, board: Board): Group[] { if (by === "status") { return board.columns.map((col) => ({ id: col.id, name: col.name, column: col, patch: { columnId: col.id }, match: (c: Card) => c.columnId === col.id, })); } if (by === "priority") { return ([4, 3, 2, 1, 0] as Priority[]).map((p) => ({ id: `p${p}`, name: PRIORITIES[p], priority: p, patch: { priority: p }, match: (c: Card) => c.priority === p, })); } return [ ...board.people.map((p) => ({ id: `a:${p.id}`, name: p.name, person: p, patch: { assigneeId: p.id }, match: (c: Card) => c.assigneeId === p.id, })), { id: "a:none", name: "Unassigned", person: null, patch: { assigneeId: null }, match: (c: Card) => !c.assigneeId }, ];}/* ------------------------------------------------------------------------ *//* Views *//* ------------------------------------------------------------------------ */type Layout = "board" | "table" | "roadmap";type SumBy = "count" | "estimate";type FieldKey = "key" | "priority" | "labels" | "assignee" | "due" | "progress" | "estimate" | "parent";const FIELD_OPTIONS: { key: FieldKey; label: string }[] = [ { key: "key", label: "Key" }, { key: "priority", label: "Priority" }, { key: "labels", label: "Labels" }, { key: "assignee", label: "Assignee" }, { key: "due", label: "Due date" }, { key: "progress", label: "Sub-issues progress" }, { key: "estimate", label: "Estimate" }, { key: "parent", label: "Parent issue" },];interface ViewConfig { layout: Layout; query: string; sorts: SortRule[]; columnBy: GroupBy; /** Swimlanes on the board, row groups in the table. */ lanes: Lanes; sum: SumBy; fields: FieldKey[];}interface SavedView { id: string; name: string; config: ViewConfig;}const DEFAULT_FIELDS: FieldKey[] = ["key", "priority", "labels", "assignee", "due", "progress", "parent"];const DEFAULT_CONFIG: ViewConfig = { layout: "board", query: "", sorts: [], columnBy: "status", lanes: "none", sum: "count", fields: DEFAULT_FIELDS,};const SEED_VIEWS: SavedView[] = [ { id: "sprint", name: "Sprint board", config: DEFAULT_CONFIG }, { id: "all", name: "All work", config: { ...DEFAULT_CONFIG, layout: "table", sorts: [ { field: "status", dir: "asc" }, { field: "priority", dir: "desc" }, ], fields: [...DEFAULT_FIELDS, "estimate"], }, }, { id: "people", name: "By assignee", config: { ...DEFAULT_CONFIG, columnBy: "assignee", sum: "estimate", query: "is:open" } }, { id: "triage", name: "Triage", config: { ...DEFAULT_CONFIG, lanes: "priority", query: "status:Backlog,Ready" } }, { id: "payments", name: "Priya's payments", config: { ...DEFAULT_CONFIG, query: "assignee:priya label:Payments" } },];const sameConfig = (a: ViewConfig, b: ViewConfig) => JSON.stringify(a) === JSON.stringify(b);/* ------------------------------------------------------------------------ *//* Sound *//* *//* Synthesised with Web Audio, so there are no files to ship. Everything *//* runs through one master gain at 0.3: a board is a tool people sit in all *//* day, and the sounds are there to confirm, not to announce. *//* ------------------------------------------------------------------------ */const MASTER_VOLUME = 0.3;class Sfx { enabled = true; private ctx: AudioContext | null = null; private out: GainNode | null = null; private ac() { if (!this.enabled || typeof window === "undefined") return null; if (!this.ctx) { const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!Ctor) return null; this.ctx = new Ctor(); this.out = this.ctx.createGain(); this.out.gain.value = MASTER_VOLUME; this.out.connect(this.ctx.destination); } if (this.ctx.state === "suspended") void this.ctx.resume(); return this.ctx; } /** A dry click: picking a card up. */ tick(vol = 0.12, freq = 1400) { const a = this.ac(); if (!a || !this.out) return; const o = a.createOscillator(); const g = a.createGain(); const t = a.currentTime; o.type = "triangle"; o.frequency.setValueAtTime(freq, t); o.frequency.exponentialRampToValueAtTime(freq * 0.6, t + 0.04); g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.06); o.connect(g).connect(this.out); o.start(t); o.stop(t + 0.07); } /** A soft low tap: a card settling into place. */ thud(vol = 0.22) { const a = this.ac(); if (!a || !this.out) return; const o = a.createOscillator(); const g = a.createGain(); const t = a.currentTime; o.type = "sine"; o.frequency.setValueAtTime(170, t); o.frequency.exponentialRampToValueAtTime(64, t + 0.09); g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.12); o.connect(g).connect(this.out); o.start(t); o.stop(t + 0.13); } /** Filtered noise, the edge of a card brushing another. */ flick(vol = 0.14, freq = 2600) { const a = this.ac(); if (!a || !this.out) return; const n = Math.floor(a.sampleRate * 0.05); const buf = a.createBuffer(1, n, a.sampleRate); const d = buf.getChannelData(0); for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 3); const s = a.createBufferSource(); const f = a.createBiquadFilter(); const g = a.createGain(); s.buffer = buf; f.type = "bandpass"; f.frequency.value = freq * (0.85 + Math.random() * 0.3); f.Q.value = 0.9; g.gain.value = vol; s.connect(f).connect(g).connect(this.out); s.start(); } private notes(freqs: number[], gap: number, vol: number, tail: number) { const a = this.ac(); if (!a || !this.out) return; freqs.forEach((f, i) => { const o = a.createOscillator(); const g = a.createGain(); const t = a.currentTime + i * gap; o.type = "sine"; o.frequency.value = f; g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(vol, t + 0.01); g.gain.exponentialRampToValueAtTime(0.001, t + tail); o.connect(g).connect(this.out!); o.start(t); o.stop(t + tail + 0.05); }); } /** Two rising notes: something new on the board. */ pop() { this.notes([660, 990], 0.06, 0.08, 0.18); } /** A three-note major arpeggio: a card reached Done, or a deal finished. */ chime() { this.notes([784, 1047, 1319], 0.09, 0.1, 0.5); }}/* ------------------------------------------------------------------------ *//* Shared bits *//* ------------------------------------------------------------------------ */const EASE_OUT = [0.23, 1, 0.32, 1] as const;const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;const FOCUS = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400";const INPUT = "rounded-md border border-stone-300 bg-stone-100 px-3 text-sm text-stone-900 outline-none placeholder:text-stone-400 focus:border-emerald-600 focus:ring-2 focus:ring-emerald-600/25 dark:border-stone-700 dark:bg-stone-950 dark:text-stone-100 dark:placeholder:text-stone-600 dark:focus:border-emerald-400 dark:focus:ring-emerald-400/25";const PRIMARY = cn( "inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-emerald-700 px-3.5 text-sm font-medium text-white transition-colors duration-150 hover:bg-emerald-800 active:scale-[0.98] active:bg-emerald-900 disabled:pointer-events-none disabled:opacity-50", FOCUS);const GHOST = cn( "inline-flex h-9 items-center justify-center gap-1.5 rounded-md border border-stone-300 px-3.5 text-sm font-medium text-stone-700 transition-colors duration-150 hover:bg-stone-100 active:scale-[0.98] dark:border-stone-700 dark:text-stone-200 dark:hover:bg-stone-800", FOCUS);const ICON_BTN = cn( "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-stone-500 transition-colors duration-150 hover:bg-stone-200/70 hover:text-stone-900 active:scale-[0.98] dark:text-stone-400 dark:hover:bg-stone-800 dark:hover:text-stone-100", FOCUS);const FLOATING = "rounded-xl border border-stone-200 bg-white ring-1 ring-stone-900/5 shadow-[0_1px_2px_-1px_rgba(0,0,0,0.06),0_4px_12px_-4px_rgba(0,0,0,0.08)] dark:border-stone-700 dark:bg-stone-800 dark:ring-white/10 dark:shadow-none";const EYEBROW = "text-[11px] font-medium uppercase tracking-[0.08em] text-stone-500 dark:text-stone-500";/** A column's hue tinted into the lane, derived in CSS so any pick works. */function laneVars(color: string): CSSProperties { return { "--col": color, "--col-wash": `color-mix(in oklab, ${color} 9%, transparent)`, "--col-wash-dark": `color-mix(in oklab, ${color} 12%, transparent)`, "--col-drop": `color-mix(in oklab, ${color} 16%, transparent)`, "--col-line": `color-mix(in oklab, ${color} 55%, transparent)`, } as CSSProperties;}function PriorityGlyph({ priority, className }: { priority: Priority; className?: string }) { const label = PRIORITIES[priority]; if (priority === 4) { return ( <svg viewBox="0 0 14 14" className={cn("h-3.5 w-3.5 text-amber-700 dark:text-amber-400", className)} role="img" aria-label={label} > <rect x="1" y="1" width="12" height="12" rx="3" fill="currentColor" /> <rect x="6.25" y="3.5" width="1.5" height="4.5" rx="0.75" className="fill-white dark:fill-stone-900" /> <rect x="6.25" y="9.25" width="1.5" height="1.5" rx="0.75" className="fill-white dark:fill-stone-900" /> </svg> ); } return ( <svg viewBox="0 0 14 14" className={cn("h-3.5 w-3.5", className)} role="img" aria-label={label}> {[0, 1, 2].map((i) => ( <rect key={i} x={1 + i * 4.5} y={priority === 0 ? 6 : 10 - i * 3} width="3" height={priority === 0 ? 2 : 3 + i * 3} rx="1" className={priority > i ? "fill-stone-700 dark:fill-stone-300" : "fill-stone-300 dark:fill-stone-700"} /> ))} </svg> );}function Initials({ person, size = "sm" }: { person: Person; size?: "xs" | "sm" }) { return ( <span title={person.name} className={cn( "inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold text-white ring-2 ring-white dark:ring-stone-900", size === "xs" ? "h-6 w-6 text-[11px]" : "h-7 w-7 text-[11px]", person.color )} > {person.avatarUrl ? ( // eslint-disable-next-line @next/next/no-img-element <img src={person.avatarUrl} alt="" className="h-full w-full object-cover" draggable={false} /> ) : ( person.initials )} </span> );}/** Closes on an outside press, but not on a press inside a colour picker's portal. */function useDismiss(open: boolean, ref: React.RefObject<HTMLElement | null>, close: () => void) { useEffect(() => { if (!open) return; const onDown = (e: PointerEvent) => { const t = e.target as Element; if (ref.current?.contains(t) || t.closest?.("[data-color-picker]")) return; close(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !document.querySelector("[data-color-picker]")) { e.stopPropagation(); close(); } }; document.addEventListener("pointerdown", onDown); document.addEventListener("keydown", onKey, true); return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey, true); }; }, [open, ref, close]);}/* ------------------------------------------------------------------------ *//* Card *//* ------------------------------------------------------------------------ */interface CardViewProps { card: Card; bc: BoardCtx; fields: Set<FieldKey>; grabbed?: boolean; hidden?: boolean; /** Lit while a link to this card is hovered, or just after one was followed. */ highlighted?: boolean; helpId?: string; onPointerDown?: (e: ReactPointerEvent<HTMLDivElement>) => void; onOpen?: () => void; onKeyDown?: (e: ReactKeyboardEvent<HTMLDivElement>) => void; onJump?: (id: string) => void; onHoverLink?: (id: string | null) => void;}function CardView({ card, bc, fields, grabbed, hidden, highlighted, helpId, onPointerDown, onOpen, onKeyDown, onJump, onHoverLink,}: CardViewProps) { const person = card.assigneeId ? bc.people.get(card.assigneeId) : undefined; const due = dueState(card.due, card.columnId); const isDone = card.columnId === DONE_ID; const col = bc.board.columns.find((c) => c.id === card.columnId); const parent = card.parentId ? bc.board.cards.find((c) => c.id === card.parentId) : undefined; const children = bc.kids.get(card.id) ?? []; const progress = cardProgress(card, bc); // Links are buttons inside the card. They stay out of the tab order (the // sheet carries the same links for keyboard users) and never start a drag. const link = (target: Card, body: ReactNode, key: string) => ( <button key={key} type="button" data-card-link tabIndex={-1} title={`${target.key} ${target.title}`} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); onJump?.(target.id); }} onMouseEnter={() => onHoverLink?.(target.id)} onMouseLeave={() => onHoverLink?.(null)} className="inline-flex h-5 min-w-0 max-w-full items-center gap-1 rounded-full border border-stone-300/80 bg-white/70 px-1.5 text-[11px] text-stone-600 transition-colors duration-150 hover:border-stone-400 hover:text-stone-900 dark:border-stone-700 dark:bg-stone-900/60 dark:text-stone-400 dark:hover:border-stone-500 dark:hover:text-stone-100" > {body} </button> ); return ( <div data-card-id={card.id} role="button" tabIndex={0} aria-roledescription="draggable card" aria-describedby={helpId} aria-pressed={grabbed ? true : undefined} onPointerDown={onPointerDown} onClick={onOpen} onKeyDown={onKeyDown} style={cardVars(col?.color ?? "#a8a29e")} className={cn( "group relative flex cursor-grab select-none flex-col gap-2 rounded-xl border p-3 text-left transition-[border-color,box-shadow,opacity] duration-150", "border-[color:var(--card-line)] bg-[var(--card-bg)] dark:border-[color:var(--card-line-dark)] dark:bg-[var(--card-bg-dark)]", "[-webkit-touch-callout:none]", grabbed ? "shadow-[0_1px_2px_-1px_rgba(0,0,0,0.06),0_12px_24px_-12px_rgba(28,25,23,0.35)] ring-2 ring-emerald-600 dark:ring-emerald-400" : "shadow-[0_1px_2px_-1px_rgba(0,0,0,0.06)] dark:shadow-none", highlighted && !grabbed && "ring-2 ring-emerald-600/70 ring-offset-2 ring-offset-stone-50 dark:ring-emerald-400/70 dark:ring-offset-stone-950", hidden && "opacity-0", FOCUS )} > {(fields.has("key") || fields.has("priority")) && ( <div className="flex items-center gap-2"> {fields.has("key") && ( <span className="font-[family-name:var(--font-deck-mono)] text-[11px] leading-4 text-stone-500 dark:text-stone-500"> {card.key} </span> )} <span className="flex-1" /> {fields.has("priority") && <PriorityGlyph priority={card.priority} />} </div> )} {fields.has("parent") && parent && ( <div className="flex min-w-0"> {link( parent, <> <CornerDownRight aria-hidden className="h-3 w-3 shrink-0" /> <span className="sr-only">Sub-issue of</span> <span className="shrink-0 font-[family-name:var(--font-deck-mono)]">{parent.key}</span> <span className="truncate">{parent.title}</span> </>, "parent" )} </div> )} <p className={cn( "line-clamp-3 text-sm font-medium leading-5 text-stone-900 dark:text-stone-100", isDone && "text-stone-600 dark:text-stone-400" )} > {card.title} </p> {fields.has("labels") && card.labelIds.length > 0 && ( <div className="flex flex-wrap gap-1"> {card.labelIds.map((id) => { const l = bc.labels.get(id); return l ? ( <LabelChip key={id} color={l.color} size="sm"> {l.name} </LabelChip> ) : null; })} </div> )} {fields.has("progress") && <ProgressMeter p={progress} />} {fields.has("parent") && children.length > 0 && ( <div className="flex min-w-0 flex-wrap items-center gap-1"> <span className="text-[11px] text-stone-500">Sub-issues</span> {children.slice(0, 4).map((ch) => link( ch, <> <Dot color={bc.board.columns.find((c) => c.id === ch.columnId)?.color ?? "#a8a29e"} /> <span className={cn("font-[family-name:var(--font-deck-mono)]", ch.columnId === DONE_ID && "line-through")}> {ch.key} </span> </>, ch.id ) )} {children.length > 4 && <span className="text-[11px] text-stone-500">+{children.length - 4}</span>} </div> )} {((fields.has("due") && card.due) || (fields.has("estimate") && card.estimate !== null) || (fields.has("assignee") && person)) && ( <div className="flex min-h-6 items-center gap-3 text-xs text-stone-500 dark:text-stone-500"> {fields.has("due") && card.due && ( <span className={cn( "inline-flex items-center gap-1 tabular-nums", due === "overdue" && "font-medium text-red-700 dark:text-red-400", due === "soon" && "font-medium text-amber-700 dark:text-amber-400" )} > <CalendarDays aria-hidden className="h-3.5 w-3.5" /> <span className="sr-only">{due === "overdue" ? "Overdue, due" : "Due"}</span> {shortDate(card.due)} </span> )} {fields.has("estimate") && card.estimate !== null && ( <span className="inline-flex h-5 items-center rounded-full bg-stone-900/5 px-1.5 text-[11px] tabular-nums text-stone-600 dark:bg-white/10 dark:text-stone-400"> {card.estimate} pt{card.estimate === 1 ? "" : "s"} </span> )} <span className="flex-1" /> {fields.has("assignee") && person && <Initials person={person} size="xs" />} </div> )} </div> );}/* ------------------------------------------------------------------------ *//* Column *//* ------------------------------------------------------------------------ */function ColumnMenu({ column, count, onRename, onDelete, canDelete,}: { column: Column; count: number; onRename: () => void; onDelete: () => void; canDelete: boolean;}) { const reduce = useReducedMotion(); const [open, setOpen] = useState(false); const ref = useRef<HTMLDivElement>(null); const close = useCallback(() => setOpen(false), []); useDismiss(open, ref, close); const item = "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-stone-700 transition-colors duration-150 hover:bg-stone-100 disabled:pointer-events-none disabled:opacity-50 dark:text-stone-200 dark:hover:bg-stone-700 " + FOCUS; return ( <div ref={ref} className="relative"> <button type="button" aria-label={`${column.name} options`} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((o) => !o)} className={cn(ICON_BTN, "h-7 w-7")} > <Ellipsis aria-hidden className="h-4 w-4" /> </button> <AnimatePresence> {open && ( <motion.div role="menu" initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: -2 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={{ duration: 0.16, ease: EASE_OUT }} style={{ transformOrigin: "top right" }} className={cn(FLOATING, "absolute right-0 top-8 z-30 w-52 p-1")} > <button type="button" role="menuitem" className={item} onClick={() => { setOpen(false); onRename(); }} > <Pencil aria-hidden className="h-3.5 w-3.5" /> Rename </button> <button type="button" role="menuitem" disabled={!canDelete} className={cn(item, "text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40")} onClick={() => { setOpen(false); onDelete(); }} > <Trash2 aria-hidden className="h-3.5 w-3.5" /> {count > 0 ? `Delete, keeping ${count} ${count === 1 ? "card" : "cards"}` : "Delete column"} </button> </motion.div> )} </AnimatePresence> </div> );}function QuickAdd({ onAdd, onClose }: { onAdd: (title: string) => void; onClose: () => void }) { const [text, setText] = useState(""); const ref = useRef<HTMLTextAreaElement>(null); useEffect(() => ref.current?.focus(), []); const submit = () => { const t = text.trim(); if (!t) return; onAdd(t); setText(""); }; return ( <div className="rounded-xl border border-stone-300 bg-white p-2 dark:border-stone-700 dark:bg-stone-900"> <textarea ref={ref} rows={2} value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } else if (e.key === "Escape") { e.stopPropagation(); onClose(); } }} aria-label="New card title" placeholder="What needs doing?" className="block w-full resize-none bg-transparent px-1 text-sm leading-5 text-stone-900 outline-none placeholder:text-stone-400 dark:text-stone-100 dark:placeholder:text-stone-600" /> <div className="mt-2 flex items-center gap-2"> <button type="button" onClick={submit} disabled={!text.trim()} className={cn(PRIMARY, "h-8 px-3 text-xs")}> Add card </button> <button type="button" onClick={onClose} className={cn(ICON_BTN, "h-8 w-8")} aria-label="Close"> <X aria-hidden className="h-4 w-4" /> </button> <span className="ml-auto text-[11px] text-stone-500">Enter to add</span> </div> </div> );}/* ------------------------------------------------------------------------ *//* New column form (lane and dropdown share it) *//* ------------------------------------------------------------------------ */function NewColumnForm({ swatches, onCreate, onCancel, compact,}: { swatches: string[]; onCreate: (name: string, color: string) => void; onCancel: () => void; compact?: boolean;}) { const [name, setName] = useState(""); const [color, setColor] = useState("#0d9488"); const ref = useRef<HTMLInputElement>(null); useEffect(() => ref.current?.focus(), []); return ( <form className={cn( "flex flex-col gap-2", !compact && "rounded-2xl border border-stone-200 bg-white p-3 dark:border-stone-800 dark:bg-stone-900" )} onSubmit={(e) => { e.preventDefault(); if (name.trim()) onCreate(name.trim(), color); }} > {!compact && <span className={EYEBROW}>New column</span>} <div className="flex items-center gap-2"> <ColorPicker value={color} onChange={setColor} label="Column colour" swatches={swatches} triggerClassName={cn("h-8 w-8 shrink-0 rounded-md ring-1 ring-inset ring-black/10 dark:ring-white/15", FOCUS)} /> <input ref={ref} value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); onCancel(); } }} maxLength={24} placeholder="Column name" aria-label="Column name" className={cn(INPUT, "h-8 min-w-0 flex-1")} /> </div> <div className="flex gap-2"> <button type="submit" disabled={!name.trim()} className={cn(PRIMARY, "h-8 px-3 text-xs")}> Add column </button> <button type="button" onClick={onCancel} className={cn(GHOST, "h-8 px-3 text-xs")}> Cancel </button> </div> </form> );}/* ------------------------------------------------------------------------ *//* Label menu (toolbar and editor share it) *//* ------------------------------------------------------------------------ */function LabelMenu({ labels, selected, onToggle, onCreate, onRecolor, swatches, trigger, align = "start",}: { labels: Label[]; selected: Set<string>; onToggle: (id: string) => void; onCreate: (name: string, color: string) => void; onRecolor: (id: string, hex: string) => void; swatches: string[]; trigger: (props: { open: boolean; toggle: () => void }) => ReactNode; align?: "start" | "end";}) { const reduce = useReducedMotion(); const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [color, setColor] = useState("#d97706"); const ref = useRef<HTMLDivElement>(null); const close = useCallback(() => setOpen(false), []); useDismiss(open, ref, close); const create = () => { const n = name.trim(); if (!n) return; onCreate(n, color); setName(""); }; return ( <div ref={ref} className="relative"> {trigger({ open, toggle: () => setOpen((o) => !o) })} <AnimatePresence> {open && ( <motion.div role="dialog" aria-label="Labels" initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: -2 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={{ duration: 0.18, ease: EASE_OUT }} style={{ transformOrigin: align === "end" ? "top right" : "top left" }} className={cn(FLOATING, "absolute top-full z-30 mt-1.5 w-72 p-2", align === "end" ? "right-0" : "left-0")} > <ul className="flex max-h-48 flex-col gap-0.5 overflow-y-auto overscroll-contain" data-lenis-prevent> {labels.map((l) => { const on = selected.has(l.id); return ( <li key={l.id} className="flex items-center gap-1"> <button type="button" role="menuitemcheckbox" aria-checked={on} onClick={() => onToggle(l.id)} className={cn( "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-stone-100 dark:hover:bg-stone-700", FOCUS )} > <span aria-hidden className={cn( "flex h-4 w-4 shrink-0 items-center justify-center rounded border", on ? "border-emerald-700 bg-emerald-700 text-white" : "border-stone-300 dark:border-stone-600" )} > {on && <Check className="h-3 w-3" strokeWidth={3} />} </span> <LabelChip color={l.color} size="sm"> {l.name} </LabelChip> </button> <ColorPicker value={l.color} onChange={(hex) => onRecolor(l.id, hex)} label={`${l.name} label colour`} swatches={swatches} triggerClassName={cn( "mr-1 h-5 w-5 shrink-0 rounded-full ring-1 ring-inset ring-black/15 transition-shadow duration-150 hover:ring-2 dark:ring-white/25", FOCUS )} /> </li> ); })} </ul> <form className="mt-2 flex items-center gap-1.5 border-t border-stone-200 pt-2 dark:border-stone-700" onSubmit={(e) => { e.preventDefault(); create(); }} > <ColorPicker value={color} onChange={setColor} label="New label colour" swatches={swatches} triggerClassName={cn("h-8 w-8 shrink-0 rounded-md ring-1 ring-inset ring-black/10 dark:ring-white/15", FOCUS)} /> <input value={name} onChange={(e) => setName(e.target.value)} maxLength={20} placeholder="New label" aria-label="New label name" className={cn(INPUT, "h-8 min-w-0 flex-1 px-2 text-xs")} /> <button type="submit" disabled={!name.trim()} className={cn(ICON_BTN, "h-8 w-8")} aria-label="Create label"> <Plus aria-hidden className="h-4 w-4" /> </button> </form> </motion.div> )} </AnimatePresence> </div> );}/* ------------------------------------------------------------------------ *//* Card fields (the sheet and the create dialog render the same form) *//* ------------------------------------------------------------------------ */function AutoTextarea({ value, onChange, className, inputRef, ...rest}: { value: string; onChange: (v: string) => void; inputRef?: React.RefObject<HTMLTextAreaElement | null>;} & Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange">) { const ref = useRef<HTMLTextAreaElement>(null); useLayoutEffect(() => { const el = ref.current; if (!el) return; el.style.height = "0px"; el.style.height = el.scrollHeight + "px"; }, [value]); return ( <textarea ref={(el) => { ref.current = el; if (inputRef) inputRef.current = el; }} rows={1} value={value} onChange={(e) => onChange(e.target.value)} className={cn("block w-full resize-none overflow-hidden bg-transparent outline-none", className)} {...rest} /> );}function FieldRow({ label, children }: { label: string; children: ReactNode }) { return ( <div className="grid grid-cols-[96px_1fr] items-center gap-3"> <span className="text-xs text-stone-500 dark:text-stone-500">{label}</span> <div className="min-w-0">{children}</div> </div> );}interface CardFieldsProps { card: Card; board: Board; swatches: string[]; patch: (p: Partial<Card>) => void; createLabel: (name: string, color: string) => string; createColumn: (name: string, color: string) => string; recolor: Recolor; titleRef?: React.RefObject<HTMLTextAreaElement | null>;}interface Recolor { column: (id: string, hex: string) => void; label: (id: string, hex: string) => void;}function CardFields({ card, board, swatches, patch, createLabel, createColumn, recolor, titleRef }: CardFieldsProps) { const [addingColumn, setAddingColumn] = useState(false); const labelMap = useMemo(() => new Map(board.labels.map((l) => [l.id, l])), [board.labels]); const kids = useMemo(() => childrenOf(board.cards), [board.cards]); // A card cannot sit under itself or anything beneath it. const parentOptions = useMemo(() => { const family = familyOf(card.id, kids); return board.cards.filter((c) => !family.has(c.id)); }, [board.cards, card.id, kids]); const colIndex = Math.max( 0, board.columns.findIndex((c) => c.id === card.columnId) ); const col = board.columns[colIndex]; return ( <div className="flex flex-col gap-6"> <AutoTextarea inputRef={titleRef} value={card.title} onChange={(v) => patch({ title: v.replace(/\n/g, " ") })} aria-label="Title" placeholder="Card title" className="text-lg font-semibold leading-7 tracking-tight text-stone-900 placeholder:text-stone-400 dark:text-stone-100 dark:placeholder:text-stone-600" /> <div className="flex flex-col gap-3"> <FieldRow label="Status"> <div className="flex items-center gap-2"> {col && ( <ColorPicker value={col.color} onChange={(hex) => recolor.column(col.id, hex)} label={`${col.name} column colour`} swatches={swatches} triggerClassName={cn( "h-8 w-8 shrink-0 rounded-md ring-1 ring-inset ring-black/10 transition-shadow duration-150 hover:ring-2 hover:ring-black/20 dark:ring-white/15 dark:hover:ring-white/30", FOCUS )} /> )} <Select size="sm" className="w-full" label="Status" options={[...board.columns.map((c) => c.name), "+ New column"]} value={colIndex} onChange={(i) => { if (i === board.columns.length) setAddingColumn(true); else patch({ columnId: board.columns[i].id }); }} /> </div> <AnimatePresence initial={false}> {addingColumn && ( <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0, transition: { duration: 0.12 } }} transition={{ duration: 0.2, ease: EASE_OUT }} className="overflow-hidden" > <div className="pt-2"> <NewColumnForm compact swatches={swatches} onCancel={() => setAddingColumn(false)} onCreate={(name, color) => { patch({ columnId: createColumn(name, color) }); setAddingColumn(false); }} /> </div> </motion.div> )} </AnimatePresence> </FieldRow> <FieldRow label="Priority"> <Select size="sm" className="w-full" label="Priority" icon={<PriorityGlyph priority={card.priority} />} options={[...PRIORITIES]} value={card.priority} onChange={(i) => patch({ priority: i as Priority })} /> </FieldRow> <FieldRow label="Assignee"> <Select size="sm" className="w-full" label="Assignee" options={["Unassigned", ...board.people.map((p) => p.name)]} value={card.assigneeId ? board.people.findIndex((p) => p.id === card.assigneeId) + 1 : 0} onChange={(i) => patch({ assigneeId: i === 0 ? null : board.people[i - 1].id })} /> </FieldRow> <FieldRow label="Dates"> <div className="grid grid-cols-2 gap-2"> <input type="date" value={card.start ?? ""} max={card.due ?? undefined} onChange={(e) => patch({ start: e.target.value || null })} aria-label="Start date" className={cn(INPUT, "h-8 w-full px-2 text-xs tabular-nums [color-scheme:light] dark:[color-scheme:dark]")} /> <input type="date" value={card.due ?? ""} min={card.start ?? undefined} onChange={(e) => patch({ due: e.target.value || null })} aria-label="Due date" className={cn(INPUT, "h-8 w-full px-2 text-xs tabular-nums [color-scheme:light] dark:[color-scheme:dark]")} /> </div> </FieldRow> <FieldRow label="Estimate"> <input type="number" min={0} max={99} inputMode="numeric" value={card.estimate ?? ""} placeholder="Points" onChange={(e) => patch({ estimate: e.target.value === "" ? null : Math.max(0, Math.min(99, Number(e.target.value))) }) } aria-label="Estimate in story points" className={cn(INPUT, "h-8 w-24 text-xs tabular-nums")} /> </FieldRow> <FieldRow label="Parent"> <Select size="sm" className="w-full" label="Parent issue" options={["No parent", ...parentOptions.map((c) => `${c.key} ${c.title}`)]} value={card.parentId ? parentOptions.findIndex((c) => c.id === card.parentId) + 1 : 0} onChange={(i) => patch({ parentId: i === 0 ? null : parentOptions[i - 1].id })} /> </FieldRow> <FieldRow label="Labels"> <div className="flex flex-wrap items-center gap-1.5"> {card.labelIds.map((id) => { const l = labelMap.get(id); return l ? ( <LabelChip key={id} color={l.color} onRemove={() => patch({ labelIds: card.labelIds.filter((x) => x !== id) })}> {l.name} </LabelChip> ) : null; })} <LabelMenu labels={board.labels} selected={new Set(card.labelIds)} swatches={swatches} onToggle={(id) => patch({ labelIds: card.labelIds.includes(id) ? card.labelIds.filter((x) => x !== id) : [...card.labelIds, id] }) } onCreate={(name, color) => patch({ labelIds: [...card.labelIds, createLabel(name, color)] })} onRecolor={recolor.label} trigger={({ open, toggle }) => ( <button type="button" aria-expanded={open} onClick={toggle} className={cn( "inline-flex h-6 items-center gap-1 rounded-full border border-dashed border-stone-300 px-2 text-xs text-stone-600 transition-colors duration-150 hover:border-stone-400 hover:text-stone-900 dark:border-stone-700 dark:text-stone-400 dark:hover:border-stone-500 dark:hover:text-stone-100", FOCUS )} > <Plus aria-hidden className="h-3 w-3" /> Label </button> )} /> </div> </FieldRow> </div> <div className="flex flex-col gap-2"> <span className={EYEBROW}>Description</span> <AutoTextarea value={card.description} onChange={(v) => patch({ description: v })} aria-label="Description" placeholder="Add the context someone picking this up would need." className={cn(INPUT, "min-h-24 py-2 leading-6")} /> </div> </div> );}/* ------------------------------------------------------------------------ *//* Overlays *//* ------------------------------------------------------------------------ *//** Keeps Tab inside `ref`, and hands focus back to whatever opened it. */function useFocusTrap(ref: React.RefObject<HTMLElement | null>, initial?: React.RefObject<HTMLElement | null>) { useEffect(() => { const opener = document.activeElement as HTMLElement | null; const el = ref.current; (initial?.current ?? el)?.focus({ preventScroll: true }); const onKey = (e: KeyboardEvent) => { if (e.key !== "Tab" || !el) return; const f = Array.from( el.querySelectorAll<HTMLElement>('button:not([disabled]), input, textarea, select, [tabindex]:not([tabindex="-1"])') ).filter((n) => n.offsetParent !== null); if (!f.length) return; const first = f[0]; const last = f[f.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; el?.addEventListener("keydown", onKey); return () => { el?.removeEventListener("keydown", onKey); opener?.focus?.({ preventScroll: true }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []);}/** Escape closes, unless a picker or listbox inside is the thing that is open. */function useEscape(onClose: () => void) { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; if (document.querySelector("[data-color-picker]")) return; if ((document.activeElement as HTMLElement | null)?.getAttribute("aria-expanded") === "true") return; onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]);}function CardSheet({ card, board, bc, swatches, patch, createLabel, createColumn, onDelete, onClose, recolor, onOpenCard, onAddChild, onLink, onUnlink,}: Omit<CardFieldsProps, "titleRef"> & { bc: BoardCtx; onDelete: () => void; onClose: () => void; onOpenCard: (id: string) => void; onAddChild: (title: string) => void; onLink: (childId: string) => void; onUnlink: (childId: string) => void;}) { const reduce = useReducedMotion(); const ref = useRef<HTMLDivElement>(null); const titleId = useId(); useFocusTrap(ref); useEscape(onClose); const col = board.columns.find((c) => c.id === card.columnId); const parent = card.parentId ? board.cards.find((c) => c.id === card.parentId) : undefined; return ( <motion.aside ref={ref} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} initial={reduce ? { opacity: 0 } : { opacity: 0, x: 32 }} animate={{ opacity: 1, x: 0 }} exit={reduce ? { opacity: 0 } : { opacity: 0, x: 16, transition: { duration: 0.16, ease: EASE_OUT } }} transition={{ duration: 0.28, ease: EASE_DRAWER }} className="pointer-events-auto absolute inset-y-0 right-0 z-40 flex w-[440px] max-w-full flex-col border-l border-stone-200 bg-white outline-none dark:border-stone-800 dark:bg-stone-900" > <header className="flex shrink-0 items-center gap-2 border-b border-stone-200 px-5 py-3 dark:border-stone-800"> <span aria-hidden className="h-2 w-2 rounded-full ring-1 ring-inset ring-black/15 dark:ring-white/20" style={{ background: col?.color }} /> <h2 id={titleId} className="font-[family-name:var(--font-deck-mono)] text-xs text-stone-600 dark:text-stone-400"> {card.key} <span className="sr-only">, {card.title}</span> </h2> <span className="text-xs text-stone-500">in {col?.name}</span> <span className="flex-1" /> {card.source && ( <a href={card.source.url} target="_blank" rel="noreferrer" className={ICON_BTN} aria-label={`Open ${card.source.ref} on GitHub`} title="Open on GitHub" > <ExternalLink aria-hidden className="h-4 w-4" /> </a> )} <button type="button" onClick={onDelete} className={cn(ICON_BTN, "hover:text-red-700 dark:hover:text-red-400")} aria-label="Delete card" > <Trash2 aria-hidden className="h-4 w-4" /> </button> <button type="button" onClick={onClose} className={ICON_BTN} aria-label="Close"> <X aria-hidden className="h-4 w-4" /> </button> </header> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-5" data-lenis-prevent> {parent && ( <button type="button" onClick={() => onOpenCard(parent.id)} className={cn( "mb-3 inline-flex max-w-full items-center gap-1.5 rounded-full border border-stone-300 px-2.5 py-1 text-xs text-stone-600 transition-colors duration-150 hover:border-stone-400 hover:text-stone-900 dark:border-stone-700 dark:text-stone-400 dark:hover:border-stone-500 dark:hover:text-stone-100", FOCUS )} > <CornerDownRight aria-hidden className="h-3.5 w-3.5 shrink-0" /> <span className="shrink-0">Sub-issue of</span> <span className="shrink-0 font-[family-name:var(--font-deck-mono)]">{parent.key}</span> <span className="truncate">{parent.title}</span> </button> )} <CardFields card={card} board={board} swatches={swatches} patch={patch} createLabel={createLabel} createColumn={createColumn} recolor={recolor} /> <div className="mt-6"> <SubIssues card={card} bc={bc} onOpenCard={onOpenCard} onAddChild={onAddChild} onLink={onLink} onUnlink={onUnlink} /> </div> <div className="mt-8 flex flex-col gap-3"> <span className={cn(EYEBROW, "inline-flex items-center gap-1.5")}> <History aria-hidden className="h-3.5 w-3.5" /> Activity </span> <ol className="relative flex flex-col gap-3 border-l border-stone-200 pl-4 dark:border-stone-800"> {[...card.activity].reverse().map((a) => ( <li key={a.id} className="relative text-xs text-stone-600 dark:text-stone-400"> <span aria-hidden className="absolute -left-[21px] top-1 h-2 w-2 rounded-full border-2 border-white bg-stone-400 dark:border-stone-900 dark:bg-stone-600" /> {a.text} <span className="ml-1.5 text-stone-500">{a.at}</span> </li> ))} </ol> </div> </div> </motion.aside> );}function CreateDialog({ draft, setDraft, board, swatches, createLabel, createColumn, recolor, onCreate, onClose,}: { recolor: Recolor; draft: Card; setDraft: (fn: (d: Card) => Card) => void; board: Board; swatches: string[]; createLabel: (name: string, color: string) => string; createColumn: (name: string, color: string) => string; onCreate: () => void; onClose: () => void;}) { const reduce = useReducedMotion(); const ref = useRef<HTMLDivElement>(null); const titleRef = useRef<HTMLTextAreaElement>(null); const headingId = useId(); useFocusTrap(ref, titleRef); useEscape(onClose); const ok = draft.title.trim().length > 0; return ( <div className="pointer-events-auto absolute inset-0 z-40 flex items-start justify-center overflow-y-auto overscroll-contain p-4 pt-12 sm:pt-16" data-lenis-prevent > <motion.div ref={ref} role="dialog" aria-modal="true" aria-labelledby={headingId} initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98, transition: { duration: 0.14, ease: EASE_OUT } }} transition={{ duration: 0.24, ease: EASE_OUT }} onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && ok) onCreate(); }} className="relative w-full max-w-lg rounded-2xl border border-stone-200 bg-white ring-1 ring-stone-900/5 shadow-[0_1px_2px_-1px_rgba(0,0,0,0.06),0_24px_48px_-16px_rgba(28,25,23,0.25)] dark:border-stone-800 dark:bg-stone-900 dark:ring-white/10 dark:shadow-none" > <div className="flex items-center gap-2 border-b border-stone-200 px-5 py-3 dark:border-stone-800"> <h2 id={headingId} className="text-sm font-medium text-stone-900 dark:text-stone-100"> New card </h2> <span className="font-[family-name:var(--font-deck-mono)] text-xs text-stone-500">{draft.key}</span> <span className="flex-1" /> <button type="button" onClick={onClose} className={ICON_BTN} aria-label="Close"> <X aria-hidden className="h-4 w-4" /> </button> </div> <div className="px-5 py-5"> <CardFields card={draft} board={board} swatches={swatches} patch={(p) => setDraft((d) => ({ ...d, ...p }))} createLabel={createLabel} createColumn={createColumn} recolor={recolor} titleRef={titleRef} /> </div> <div className="flex items-center gap-2 border-t border-stone-200 px-5 py-3 dark:border-stone-800"> <span className="hidden text-xs text-stone-500 sm:inline"> <kbd className="font-[family-name:var(--font-deck-mono)]">Ctrl</kbd> or{" "} <kbd className="font-[family-name:var(--font-deck-mono)]">Cmd</kbd> + Enter to create </span> <span className="flex-1" /> <button type="button" onClick={onClose} className={GHOST}> Cancel </button> <button type="button" onClick={onCreate} disabled={!ok} className={PRIMARY}> Create card </button> </div> </motion.div> </div> );}/* ------------------------------------------------------------------------ *//* GitHub import *//* *//* Reads a repo's issues from GitHub's REST API in the browser. Public repos *//* need nothing; a token (kept in memory, never stored) opens private ones *//* and lifts the signed-out limit of 60 requests an hour. *//* ------------------------------------------------------------------------ */interface GhIssue { id: number; number: number; title: string; body: string | null; state: "open" | "closed"; html_url: string; labels: Array<string | { name?: string; color?: string }>; assignee: { login: string; avatar_url: string } | null; milestone: { due_on: string | null } | null; created_at: string; closed_at: string | null; sub_issues_summary?: { total: number; completed: number }; pull_request?: unknown;}function GithubMark({ className }: { className?: string }) { return ( <svg viewBox="0 0 16 16" aria-hidden className={className} fill="currentColor"> <path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z" /> </svg> );}/** "owner/repo", or any github.com URL inside the repo. */function parseRepo(input: string): string | null { const m = input.trim().match(/^(?:https?:\/\/)?(?:www\.)?(?:github\.com\/)?([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:[/?#].*)?$/i); return m ? `${m[1]}/${m[2]}` : null;}const TASK_RE = /^\s*[-*]\s+\[( |x|X)\]\s+(.+)$/gm;function priorityFromLabels(names: string[]): Priority { const t = names.join(" ").toLowerCase(); if (/\b(urgent|critical|blocker|p0)\b/.test(t)) return 4; if (/\b(high|p1)\b/.test(t)) return 3; if (/\b(medium|p2)\b/.test(t)) return 2; if (/\b(low|p3)\b/.test(t)) return 1; return 0;}/** Imported assignees take the next rung of the same ladder the seed team uses. */const IMPORT_LADDER = ["bg-emerald-900", "bg-amber-900", "bg-stone-700", "bg-emerald-700", "bg-amber-700"];/** A task-list line that points at another issue: "- [ ] #123" or "- [x] Fix it (#123)". */const ISSUE_REF_RE = /(?:^|[\s(])#(\d+)\b/;/** * Issues become cards; a task list inside an issue becomes sub-issue cards * under it, and a task line that names another issue links that issue's card * as a sub-issue instead of duplicating it. GitHub's own sub-issue count is * kept for a parent whose children were not imported. */function importIssues(b: Board, repo: string, issues: GhIssue[], openCol: string): { board: Board; added: number } { const labels = [...b.labels]; const people = [...b.people]; let cards = [...b.cards]; let seq = b.seq; let added = 0; const doneCol = b.columns.some((c) => c.id === DONE_ID) ? DONE_ID : openCol; const links: { parentRef: string; childRef: string }[] = []; for (const issue of issues) { const ref = `${repo}#${issue.number}`; if (cards.some((c) => c.source?.ref === ref)) continue; seq += 1; const id = `n${seq}`; const ghLabels = issue.labels .map((l) => (typeof l === "string" ? { name: l, color: undefined } : l)) .filter((l): l is { name: string; color?: string } => !!l.name); const labelIds = ghLabels.map((l) => { const found = labels.find((x) => x.name.toLowerCase() === l.name.toLowerCase()); if (found) return found.id; const lid = `gh-${seq}-${labels.length}`; labels.push({ id: lid, name: l.name, color: l.color ? `#${l.color}` : "#78716c" }); return lid; }); let assigneeId: string | null = null; if (issue.assignee) { assigneeId = `gh:${issue.assignee.login}`; if (!people.some((p) => p.id === assigneeId)) { people.push({ id: assigneeId, name: issue.assignee.login, initials: issue.assignee.login.slice(0, 2).toUpperCase(), role: "GitHub", color: IMPORT_LADDER[people.length % IMPORT_LADDER.length], avatarUrl: `${issue.assignee.avatar_url}${issue.assignee.avatar_url.includes("?") ? "&" : "?"}s=64`, }); } } const body = issue.body ?? ""; const tasks = Array.from(body.matchAll(TASK_RE)).map((m) => ({ text: m[2].trim(), done: m[1] !== " " })); const description = body .replace(TASK_RE, "") .replace(/\n{3,}/g, "\n\n") .trim() .slice(0, 1200); const columnId = issue.state === "closed" ? doneCol : openCol; const summary = issue.sub_issues_summary; const card: Card = { id, key: `#${issue.number}`, title: issue.title, description, columnId, labelIds, assigneeId, priority: priorityFromLabels(ghLabels.map((l) => l.name)), estimate: null, start: null, due: issue.milestone?.due_on?.slice(0, 10) ?? null, parentId: null, createdAt: issue.created_at?.slice(0, 10) ?? TODAY, closedAt: issue.closed_at?.slice(0, 10) ?? null, remoteProgress: summary && summary.total > 0 ? { done: summary.completed, total: summary.total } : undefined, activity: [{ id: `${id}a0`, text: `Imported from ${ref}`, at: shortDate(TODAY) }], source: { url: issue.html_url, ref }, }; cards = moveCard([...cards, card], id, columnId, null); added += 1; tasks.forEach((t, j) => { const m = t.text.match(ISSUE_REF_RE); if (m) { links.push({ parentRef: ref, childRef: `${repo}#${m[1]}` }); return; } const cid = `${id}t${j}`; const col = t.done ? doneCol : columnId === doneCol ? openCol : columnId; const child: Card = { ...card, id: cid, key: `#${issue.number}.${j + 1}`, title: t.text, description: "", columnId: col, priority: 0, due: null, parentId: id, closedAt: t.done ? card.closedAt ?? TODAY : null, remoteProgress: undefined, activity: [{ id: `${cid}a0`, text: `From the task list on ${ref}`, at: shortDate(TODAY) }], source: undefined, }; cards = moveCard([...cards, child], cid, col, null); added += 1; }); } for (const { parentRef, childRef } of links) { const parent = cards.find((c) => c.source?.ref === parentRef); const child = cards.find((c) => c.source?.ref === childRef); if (parent && child && !child.parentId && child.id !== parent.id) { cards = cards.map((c) => (c.id === child.id ? { ...c, parentId: parent.id } : c)); } } return { board: { ...b, labels, people, cards, seq }, added };}type Fetched = { repo: string; issues: GhIssue[] };function GithubImportDialog({ board, onImport, onClose,}: { board: Board; onImport: (repo: string, issues: GhIssue[], column: string) => void; onClose: () => void;}) { const reduce = useReducedMotion(); const ref = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement>(null); const headingId = useId(); useFocusTrap(ref, inputRef); useEscape(onClose); const [repoText, setRepoText] = useState(""); const [token, setToken] = useState(""); const [showToken, setShowToken] = useState(false); const [includeClosed, setIncludeClosed] = useState(false); const [col, setCol] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const [fetched, setFetched] = useState<Fetched | null>(null); const [picked, setPicked] = useState<Set<number>>(() => new Set()); const abort = useRef<AbortController | null>(null); useEffect(() => () => abort.current?.abort(), []); const onBoard = useMemo(() => new Set(board.cards.map((c) => c.source?.ref).filter(Boolean)), [board.cards]); const load = async () => { const repo = parseRepo(repoText); if (!repo) { setError("Use owner/repo, for example facebook/react, or paste the repo's URL."); return; } abort.current?.abort(); const ctl = new AbortController(); abort.current = ctl; setLoading(true); setError(null); setFetched(null); try { const res = await fetch( `https://api.github.com/repos/${repo}/issues?state=${includeClosed ? "all" : "open"}&per_page=50&sort=updated`, { signal: ctl.signal, headers: { Accept: "application/vnd.github+json", ...(token.trim() ? { Authorization: `Bearer ${token.trim()}` } : {}), }, } ); if (!res.ok) { if (res.status === 404) throw new Error(`No repo called ${repo} that this ${token ? "token" : "browser"} can see. Private repos need a token.`); if (res.status === 401) throw new Error("GitHub did not accept that token."); if ((res.status === 403 || res.status === 429) && res.headers.get("x-ratelimit-remaining") === "0") { const reset = Number(res.headers.get("x-ratelimit-reset")) * 1000; const when = reset ? new Date(reset).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : "later"; throw new Error( `GitHub allows 60 signed-out requests an hour and they are spent. Add a token, or try again after ${when}.` ); } throw new Error(`GitHub answered ${res.status}. Try again in a moment.`); } const issues = ((await res.json()) as GhIssue[]).filter((i) => !i.pull_request); setFetched({ repo, issues }); setPicked(new Set(issues.filter((i) => !onBoard.has(`${repo}#${i.number}`)).map((i) => i.id))); } catch (e) { if ((e as Error).name === "AbortError") return; setError((e as Error).message || "Could not reach GitHub."); } finally { if (abort.current === ctl) setLoading(false); } }; const fresh = fetched?.issues.filter((i) => !onBoard.has(`${fetched.repo}#${i.number}`)) ?? []; const chosen = fresh.filter((i) => picked.has(i.id)); const allOn = fresh.length > 0 && chosen.length === fresh.length; return ( <div className="pointer-events-auto absolute inset-0 z-40 flex items-start justify-center overflow-y-auto overscroll-contain p-4 pt-12 sm:pt-16" data-lenis-prevent > <motion.div ref={ref} role="dialog" aria-modal="true" aria-labelledby={headingId} initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98, transition: { duration: 0.14, ease: EASE_OUT } }} transition={{ duration: 0.24, ease: EASE_OUT }} className="relative flex max-h-[calc(100%-32px)] w-full max-w-lg flex-col rounded-2xl border border-stone-200 bg-white ring-1 ring-stone-900/5 shadow-[0_1px_2px_-1px_rgba(0,0,0,0.06),0_24px_48px_-16px_rgba(28,25,23,0.25)] dark:border-stone-800 dark:bg-stone-900 dark:ring-white/10 dark:shadow-none" > <div className="flex shrink-0 items-center gap-2 border-b border-stone-200 px-5 py-3 dark:border-stone-800"> <GithubMark className="h-4 w-4 text-stone-900 dark:text-stone-100" /> <h2 id={headingId} className="text-sm font-medium text-stone-900 dark:text-stone-100"> Import issues from GitHub </h2> <span className="flex-1" /> <button type="button" onClick={onClose} className={ICON_BTN} aria-label="Close"> <X aria-hidden className="h-4 w-4" /> </button> </div> <form className="flex shrink-0 flex-col gap-3 px-5 pt-5" onSubmit={(e) => { e.preventDefault(); void load(); }} > <div className="flex gap-2"> <input ref={inputRef} value={repoText} onChange={(e) => setRepoText(e.target.value)} placeholder="owner/repo" aria-label="Repository" spellCheck={false} autoCapitalize="off" className={cn(INPUT, "h-9 min-w-0 flex-1 font-[family-name:var(--font-deck-mono)] text-xs")} /> <button type="submit" disabled={loading || !repoText.trim()} className={PRIMARY}> {loading ? <LoaderCircle aria-hidden className="h-4 w-4 animate-spin motion-reduce:animate-none" /> : null} {loading ? "Fetching" : "Fetch issues"} </button> </div> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-stone-600 dark:text-stone-400"> <label className="inline-flex cursor-pointer items-center gap-2"> <input type="checkbox" checked={includeClosed} onChange={(e) => setIncludeClosed(e.target.checked)} className="h-4 w-4 rounded accent-emerald-700" /> Include closed (they land in Done) </label> <button type="button" onClick={() => setShowToken((v) => !v)} aria-expanded={showToken} className={cn("underline-offset-2 hover:underline", FOCUS)} > {showToken ? "Hide token" : "Private repo? Add a token"} </button> </div> {showToken && ( <div className="flex flex-col gap-1"> <input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="github_pat_..." aria-label="GitHub token" autoComplete="off" className={cn(INPUT, "h-8 font-[family-name:var(--font-deck-mono)] text-xs")} /> <span className="text-[11px] leading-4 text-stone-500"> A fine-grained token with read access to issues. It stays in this tab's memory and is sent only to api.github.com. </span> </div> )} </form> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-4" data-lenis-prevent aria-live="polite"> {error && ( <p role="alert" className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-xs leading-5 text-stone-700 dark:border-stone-700 dark:bg-stone-950 dark:text-stone-300" > {error} </p> )} {fetched && fetched.issues.length === 0 && ( <p className="py-6 text-center text-sm text-stone-500"> No {includeClosed ? "" : "open "}issues in {fetched.repo}. </p> )} {fetched && fetched.issues.length > 0 && ( <div className="flex flex-col gap-2"> <div className="flex items-center gap-2"> <span className={EYEBROW}>{fetched.repo}</span> <span className="text-xs tabular-nums text-stone-500"> {chosen.length} of {fresh.length} selected </span> <span className="flex-1" /> {fresh.length > 0 && ( <button type="button" onClick={() => setPicked(allOn ? new Set() : new Set(fresh.map((i) => i.id)))} className={cn("text-xs text-stone-600 underline-offset-2 hover:underline dark:text-stone-400", FOCUS)} > {allOn ? "Select none" : "Select all"} </button> )} </div> <ul className="flex flex-col divide-y divide-stone-200 rounded-xl border border-stone-200 dark:divide-stone-800 dark:border-stone-800"> {fetched.issues.map((i) => { const already = onBoard.has(`${fetched.repo}#${i.number}`); return ( <li key={i.id}> <label className={cn( "flex items-start gap-3 px-3 py-2.5", already ? "opacity-60" : "cursor-pointer hover:bg-stone-50 dark:hover:bg-stone-800/60" )} > <input type="checkbox" disabled={already} checked={already || picked.has(i.id)} onChange={() => setPicked((s) => { const next = new Set(s); if (next.has(i.id)) next.delete(i.id); else next.add(i.id); return next; }) } className="mt-0.5 h-4 w-4 shrink-0 rounded accent-emerald-700" /> <span className="min-w-0 flex-1"> <span className="block text-sm leading-5 text-stone-900 dark:text-stone-100">{i.title}</span> <span className="mt-0.5 flex items-center gap-2 text-xs text-stone-500"> <span className="font-[family-name:var(--font-deck-mono)]">#{i.number}</span> <span className={cn( "inline-flex items-center gap-1", i.state === "open" ? "text-green-700 dark:text-green-400" : "text-stone-500" )} > <span aria-hidden className={cn("h-1.5 w-1.5 rounded-full", i.state === "open" ? "bg-green-600" : "bg-stone-400")} /> {i.state === "open" ? "Open" : "Closed"} </span> {already && <span>On the board</span>} </span> </span> </label> </li> ); })} </ul> </div> )} {!fetched && !error && ( <p className="py-4 text-xs leading-5 text-stone-500"> Labels come across in their GitHub colours, the milestone's due date becomes the card's, and each task-list item becomes a sub-issue card (or links the issue it names). Issues already on the board are skipped. </p> )} </div> <div className="flex shrink-0 flex-wrap items-center gap-2 border-t border-stone-200 px-5 py-3 dark:border-stone-800"> <span className="text-xs text-stone-500">Open issues go to</span> <Select size="sm" label="Column for open issues" options={board.columns.map((c) => c.name)} value={Math.min(col, board.columns.length - 1)} onChange={setCol} /> <span className="flex-1" /> <button type="button" onClick={onClose} className={GHOST}> Cancel </button> <button type="button" disabled={chosen.length === 0} onClick={() => fetched && onImport(fetched.repo, chosen, board.columns[Math.min(col, board.columns.length - 1)].id)} className={PRIMARY} > Import {chosen.length > 0 ? chosen.length : ""} {chosen.length === 1 ? "card" : "cards"} </button> </div> </motion.div> </div> );}/* ------------------------------------------------------------------------ *//* Progress and status tint *//* ------------------------------------------------------------------------ *//** A card's fill and border, tinted with its status column's colour in CSS. */function cardVars(color: string): CSSProperties { return { "--card-bg": `color-mix(in oklab, ${color} 7%, #ffffff)`, "--card-line": `color-mix(in oklab, ${color} 34%, #ffffff)`, "--card-bg-dark": `color-mix(in oklab, ${color} 11%, #1c1917)`, "--card-line-dark": `color-mix(in oklab, ${color} 40%, #1c1917)`, } as CSSProperties;}interface CardProgress { value: number; label: string; items: Progress | null; complete: boolean; text: string;}/** * Every card gets a reading. Its sub-issues when it has any; * otherwise how far along the columns it has travelled, so a card with no * breakdown still shows where it stands. */function cardProgress(card: Card, ctx: BoardCtx): CardProgress { const items = progressOf(card, ctx.kids); if (items) { return { value: items.done / items.total, label: `${items.done}/${items.total}`, items, complete: items.done === items.total, text: `${items.done} of ${items.total} sub-issues done`, }; } const cols = ctx.board.columns; const i = Math.max( 0, cols.findIndex((c) => c.id === card.columnId) ); const value = card.columnId === DONE_ID ? 1 : cols.length > 1 ? i / (cols.length - 1) : 0; const pct = Math.round(value * 100); return { value, label: `${pct}%`, items: null, complete: value === 1, text: `${pct} percent of the way through the columns` };}function ProgressMeter({ p, className }: { p: CardProgress; className?: string }) { const segs = p.items && p.items.total <= 12 ? p.items.total : 0; const fill = p.complete ? "bg-green-600" : "bg-emerald-700 dark:bg-emerald-500"; return ( <div className={cn("flex items-center gap-2", className)} role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(p.value * 100)} aria-label={p.text} > {segs ? ( <div className="flex h-1.5 min-w-0 flex-1 gap-0.5" aria-hidden> {Array.from({ length: segs }, (_, i) => ( <span key={i} className={cn("flex-1 rounded-full", i < p.items!.done ? fill : "bg-stone-200 dark:bg-stone-700")} /> ))} </div> ) : ( <div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-stone-200 dark:bg-stone-700" aria-hidden> <div className={cn("h-full origin-left rounded-full transition-transform duration-300", fill)} style={{ transform: `scaleX(${p.value})` }} /> </div> )} <span className={cn( "shrink-0 text-[11px] leading-4 tabular-nums", p.complete ? "text-green-700 dark:text-green-400" : "text-stone-500" )} > {p.label} </span> </div> );}/* ------------------------------------------------------------------------ *//* Filter bar *//* ------------------------------------------------------------------------ */interface QItem { kind: "field" | "value"; insert: string; label: string; hint?: string; adorn?: ReactNode; disabled?: boolean;}const Dot = ({ color }: { color: string }) => ( <span aria-hidden className="h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-inset ring-black/15 dark:ring-white/20" style={{ background: color }} />);function suggestionsFor(c: QContext | null, bc: BoardCtx, hasRepos: boolean): QItem[] { if (!c) return []; const pre = c.prefix.toLowerCase(); if (c.kind === "field") { return Q_FIELDS.filter( (f) => (f.key !== "repo" || hasRepos) && (!pre || f.key.startsWith(pre) || f.label.toLowerCase().startsWith(pre)) ).map((f) => { const Icon = f.icon; return { kind: "field" as const, insert: `${c.neg ? "-" : ""}${f.key}:`, label: f.label, hint: f.hint, adorn: <Icon aria-hidden className="h-4 w-4 shrink-0 text-stone-500" />, }; }); } const count = (pred: (card: Card) => boolean) => String(bc.board.cards.filter(pred).length); let values: { value: string; label: string; hint?: string; adorn?: ReactNode; disabled?: boolean }[] = []; switch (c.field) { case "is": values = [ { value: "open", label: "open", hint: "not in Done" }, { value: "done", label: "done", hint: "in Done" }, { value: "overdue", label: "overdue", hint: "past due" }, { value: "due-soon", label: "due-soon", hint: "within 2 days" }, { value: "assigned", label: "assigned" }, { value: "unassigned", label: "unassigned" }, { value: "parent", label: "parent", hint: "has sub-issues" }, { value: "sub-issue", label: "sub-issue", hint: "has a parent" }, ...(hasRepos ? [{ value: "imported", label: "imported", hint: "from GitHub" }] : []), ]; break; case "assignee": values = [ ...bc.board.people.map((p) => ({ value: handleOf(p), label: p.name, hint: count((card) => card.assigneeId === p.id), adorn: <Initials person={p} size="xs" />, })), { value: "none", label: "No assignee", hint: count((card) => !card.assigneeId) }, ]; break; case "label": values = bc.board.labels.map((l) => ({ value: l.name, label: l.name, hint: count((card) => card.labelIds.includes(l.id)), adorn: <Dot color={l.color} />, })); break; case "status": values = bc.board.columns.map((col) => ({ value: col.name, label: col.name, hint: count((card) => card.columnId === col.id), adorn: <Dot color={col.color} />, })); break; case "priority": values = ([4, 3, 2, 1, 0] as Priority[]).map((p) => ({ value: PRIORITY_KEYS[p], label: PRIORITIES[p], hint: count((card) => card.priority === p), adorn: <PriorityGlyph priority={p} />, })); break; case "due": values = [ { value: "overdue", label: "overdue" }, { value: "today", label: "today" }, { value: "this-week", label: "this-week", hint: "next 7 days" }, { value: "next-week", label: "next-week" }, { value: "none", label: "none", hint: "no due date" }, ]; break; case "parent": values = [ ...bc.board.cards .filter((card) => (bc.kids.get(card.id)?.length ?? 0) > 0) .map((card) => ({ value: card.key, label: `${card.key} ${card.title}`, hint: String(bc.kids.get(card.id)!.length) })), { value: "none", label: "No parent" }, ]; break; case "title": values = [{ value: "", label: "Type words from the title", disabled: true }]; break; case "repo": values = Array.from(new Set(bc.board.cards.map((card) => card.source?.ref.split("#")[0]).filter(Boolean) as string[])).map( (r) => ({ value: r, label: r }) ); break; } return values .filter((v) => v.disabled || !pre || v.value.toLowerCase().includes(pre) || v.label.toLowerCase().includes(pre)) .map((v) => ({ kind: "value" as const, insert: quoteValue(v.value), label: v.label, hint: v.hint, adorn: v.adorn, disabled: v.disabled, }));}/** The query drawn behind a transparent input, so qualifiers read as tokens. */function highlightQuery(q: string, tokens: QToken[]): ReactNode[] { const out: ReactNode[] = []; let pos = 0; tokens.forEach((t, i) => { if (t.start > pos) out.push(q.slice(pos, t.start)); if (t.field) { out.push( <span key={`k${i}`} className={t.neg ? "text-red-700 dark:text-red-400" : "text-stone-600 dark:text-stone-400"}> {q.slice(t.start, t.keyEnd)} </span> ); if (t.end > t.keyEnd) { out.push( <span key={`v${i}`} className="rounded-sm bg-emerald-500/15 text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-300" > {q.slice(t.keyEnd, t.end)} </span> ); } } else { out.push(<span key={`t${i}`}>{q.slice(t.start, t.end)}</span>); } pos = t.end; }); if (pos < q.length) out.push(q.slice(pos)); return out;}function FilterBar({ value, onChange, bc, hasRepos, count, total,}: { value: string; onChange: (v: string) => void; bc: BoardCtx; hasRepos: boolean; count: number; total: number;}) { const reduce = useReducedMotion(); const inputRef = useRef<HTMLInputElement>(null); const mirrorRef = useRef<HTMLDivElement>(null); const measureRef = useRef<HTMLSpanElement>(null); const wrapRef = useRef<HTMLDivElement>(null); const listId = useId(); const [caret, setCaret] = useState(0); const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const listRef = useRef<HTMLUListElement>(null); const tokens = useMemo(() => tokenize(value), [value]); const at = useMemo(() => contextAt(value, caret), [value, caret]); const items = useMemo(() => suggestionsFor(at, bc, hasRepos), [at, bc, hasRepos]); const show = open && items.length > 0; const act = Math.min(active, Math.max(0, items.length - 1)); // "/" focuses the filter from anywhere on the board, as it does on GitHub. useEffect(() => { const onKey = (e: KeyboardEvent) => { const el = document.activeElement; const typing = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement | null)?.isContentEditable; if (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey) { e.preventDefault(); inputRef.current?.focus(); setOpen(true); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); // Hang the list under the word being typed, the way GitHub does. Written // straight to the element: it is layout, not state. useLayoutEffect(() => { if (!show || !listRef.current) return; const w = wrapRef.current?.offsetWidth ?? 0; const x = 36 + (measureRef.current?.offsetWidth ?? 0) - (inputRef.current?.scrollLeft ?? 0); listRef.current.style.left = `${Math.max(0, Math.min(x - 8, w - 288))}px`; }, [show, value, at?.from]); const sync = () => { const el = inputRef.current; if (!el) return; setCaret(el.selectionStart ?? el.value.length); if (mirrorRef.current) mirrorRef.current.scrollLeft = el.scrollLeft; }; const pick = (item: QItem) => { if (!at || item.disabled) return; const after = value.slice(at.to); const insert = item.insert + (item.kind === "value" && !/^[\s,]/.test(after) ? " " : ""); const next = value.slice(0, at.from) + insert + after; const pos = at.from + insert.length; onChange(next); setActive(0); requestAnimationFrame(() => { const el = inputRef.current; if (!el) return; el.focus(); el.setSelectionRange(pos, pos); setCaret(pos); if (mirrorRef.current) mirrorRef.current.scrollLeft = el.scrollLeft; }); }; const step = (dir: 1 | -1) => { if (!items.length) return; let i = act; for (let n = 0; n < items.length; n++) { i = (i + dir + items.length) % items.length; if (!items[i].disabled) break; } setActive(i); document.getElementById(`${listId}-${i}`)?.scrollIntoView({ block: "nearest" }); }; return ( <div ref={wrapRef} className="relative min-w-0 flex-1"> <div className="relative flex h-9 items-center rounded-md border border-stone-300 bg-white transition-[border-color,box-shadow] duration-150 focus-within:border-emerald-600 focus-within:ring-2 focus-within:ring-emerald-600/25 dark:border-stone-700 dark:bg-stone-950 dark:focus-within:border-emerald-400 dark:focus-within:ring-emerald-400/25"> <Search aria-hidden className="pointer-events-none absolute left-3 h-4 w-4 text-stone-500" /> <div ref={mirrorRef} aria-hidden className="pointer-events-none absolute inset-y-0 left-9 right-28 overflow-hidden whitespace-pre text-sm leading-[34px] text-stone-900 dark:text-stone-100" > {highlightQuery(value, tokens)} </div> <span ref={measureRef} aria-hidden className="invisible absolute left-0 top-0 whitespace-pre text-sm"> {value.slice(0, at?.from ?? caret)} </span> <input ref={inputRef} role="combobox" aria-label="Filter cards" aria-expanded={show} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={show ? `${listId}-${act}` : undefined} value={value} spellCheck={false} autoComplete="off" placeholder="Filter by keyword or by field" onChange={(e) => { onChange(e.target.value); setOpen(true); setActive(0); requestAnimationFrame(sync); }} onFocus={() => { setOpen(true); sync(); }} onBlur={() => setOpen(false)} onClick={() => { setOpen(true); sync(); }} onKeyUp={(e) => { if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) { setActive(0); sync(); } }} onScroll={sync} onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); if (!show) setOpen(true); else step(1); } else if (e.key === "ArrowUp" && show) { e.preventDefault(); step(-1); } else if ((e.key === "Enter" || e.key === "Tab") && show && !items[act]?.disabled) { e.preventDefault(); pick(items[act]); } else if (e.key === "Escape") { if (show) { e.stopPropagation(); setOpen(false); } else inputRef.current?.blur(); } }} className="h-full min-w-0 flex-1 bg-transparent pl-9 pr-28 text-sm text-transparent caret-stone-900 outline-none placeholder:text-stone-400 selection:bg-emerald-600/25 dark:caret-stone-100 dark:placeholder:text-stone-600" /> <div className="absolute right-1.5 flex items-center gap-1"> {value.trim() && ( <span className="rounded-full bg-stone-100 px-2 text-[11px] leading-5 tabular-nums text-stone-600 dark:bg-stone-800 dark:text-stone-400"> {count} of {total} </span> )} {value && ( <button type="button" aria-label="Clear filter" onMouseDown={(e) => e.preventDefault()} onClick={() => { onChange(""); setCaret(0); inputRef.current?.focus(); }} className={cn(ICON_BTN, "h-6 w-6")} > <X aria-hidden className="h-3.5 w-3.5" /> </button> )} </div> </div> <AnimatePresence> {show && ( <motion.ul ref={listRef} id={listId} role="listbox" aria-label={at?.kind === "value" ? `${Q_FIELDS.find((f) => f.key === at.field)?.label} values` : "Filter fields"} initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={{ duration: 0.15, ease: EASE_OUT }} style={{ transformOrigin: "top left" }} onMouseDown={(e) => e.preventDefault()} data-lenis-prevent className={cn(FLOATING, "absolute top-full z-40 mt-1.5 max-h-72 w-72 overflow-y-auto overscroll-contain p-1")} > {at?.kind === "value" && ( <li role="presentation" className={cn(EYEBROW, "px-2 pb-1 pt-1.5")}> {Q_FIELDS.find((f) => f.key === at.field)?.label} </li> )} {items.map((it, i) => ( <li key={`${it.kind}-${it.insert}-${i}`} id={`${listId}-${i}`} role="option" aria-selected={i === act} aria-disabled={it.disabled || undefined} onMouseEnter={() => !it.disabled && setActive(i)} onClick={() => pick(it)} className={cn( "flex h-8 items-center gap-2 rounded-md px-2 text-sm", it.disabled ? "cursor-default text-stone-500" : i === act ? "cursor-pointer bg-stone-100 text-stone-900 dark:bg-stone-700 dark:text-stone-100" : "cursor-pointer text-stone-700 dark:text-stone-200" )} > {it.adorn} <span className="min-w-0 flex-1 truncate">{it.label}</span> {it.hint && <span className="shrink-0 text-xs tabular-nums text-stone-500">{it.hint}</span>} </li> ))} </motion.ul> )} </AnimatePresence> </div> );}/* ------------------------------------------------------------------------ *//* View menu *//* ------------------------------------------------------------------------ */const MENU_ROW = "flex h-9 w-full items-center gap-2 rounded-md px-2 text-left text-sm text-stone-700 transition-colors duration-150 hover:bg-stone-100 disabled:pointer-events-none disabled:opacity-50 dark:text-stone-200 dark:hover:bg-stone-700 " + FOCUS;const LAYOUTS: { key: Layout; label: string; icon: LucideIcon }[] = [ { key: "table", label: "Table", icon: Table2 }, { key: "board", label: "Board", icon: SquareKanban }, { key: "roadmap", label: "Roadmap", icon: ChartGantt },];function SortPanel({ sorts, onDone, hasRepos }: { sorts: SortRule[]; onDone: (s: SortRule[]) => void; hasRepos: boolean }) { // Picks stay a draft until Done, so the board deals once, for the final order, not on every click. const [draft, setDraft] = useState(sorts); return ( <div className="flex flex-col"> <p className="px-2 pb-2 text-xs text-stone-500">Select up to 2 fields</p> {SORT_FIELDS.filter((f) => f.field !== "repo" || hasRepos).map((f) => { const at = draft.findIndex((s) => s.field === f.field); const rule = draft[at]; const Icon = f.icon; const DirIcon = rule?.dir === "asc" ? ArrowUpNarrowWide : ArrowDownWideNarrow; return ( <div key={f.field} className="flex items-center gap-0.5"> <button type="button" role="menuitemcheckbox" aria-checked={!!rule} data-menu-item className={MENU_ROW} onClick={() => { if (rule) setDraft(draft.filter((s) => s.field !== f.field)); else setDraft([...(draft.length >= 2 ? draft.slice(0, 1) : draft), { field: f.field, dir: f.dir }]); }} > <span className="flex w-4 shrink-0 justify-center"> {rule && <Check aria-hidden className="h-4 w-4 text-emerald-700 dark:text-emerald-400" />} </span> <Icon aria-hidden className="h-4 w-4 shrink-0 text-stone-500" /> <span className="min-w-0 flex-1 truncate">{f.label}</span> {rule && draft.length > 1 && <span className="text-xs tabular-nums text-stone-500">{at + 1}</span>} </button> {rule && ( <button type="button" data-menu-item aria-label={`${f.label}, ${rule.dir === "asc" ? "ascending" : "descending"}. Switch direction`} title={rule.dir === "asc" ? "Ascending" : "Descending"} onClick={() => setDraft(draft.map((s) => (s.field === f.field ? { ...s, dir: s.dir === "asc" ? "desc" : "asc" } : s))) } className={cn(ICON_BTN, "h-8 w-8")} > <DirIcon aria-hidden className="h-4 w-4" /> </button> )} </div> ); })} <div className="mt-1.5 flex justify-between gap-2 border-t border-stone-200 px-1 pt-2.5 pb-1 dark:border-stone-700"> <button type="button" data-menu-item disabled={!draft.length} onClick={() => setDraft([])} className={cn(GHOST, "h-8 px-3 text-xs")} > No sorting </button> <button type="button" data-menu-item onClick={() => onDone(draft)} className={cn(PRIMARY, "h-8 px-3 text-xs")}> Done </button> </div> </div> );}type Panel = "main" | "fields" | "column" | "lanes" | "sort" | "sum";function ViewMenu({ config, setConfig, setSorts, dirty, onSave, onDiscard, hasRepos,}: { config: ViewConfig; setConfig: (fn: (c: ViewConfig) => ViewConfig) => void; setSorts: (s: SortRule[]) => void; dirty: boolean; onSave: () => void; onDiscard: () => void; hasRepos: boolean;}) { const reduce = useReducedMotion(); const [open, setOpen] = useState(false); const [panel, setPanel] = useState<Panel>("main"); const [dir, setDir] = useState(1); const ref = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); const panelRef = useRef<HTMLDivElement>(null); const cameFrom = useRef<Panel>("main"); const go = (p: Panel) => { cameFrom.current = panel === "main" ? p : "main"; setDir(p === "main" ? -1 : 1); setPanel(p); }; useEffect(() => { if (!open) return; const onDown = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; e.stopPropagation(); if (panel !== "main") go("main"); else setOpen(false); }; document.addEventListener("pointerdown", onDown); document.addEventListener("keydown", onKey, true); return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey, true); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, panel]); // Focus follows the panel: the first row of a sub-panel, or the row that opened it on the way back. useEffect(() => { if (!open) return; const root = panelRef.current; if (!root) return; const target = panel === "main" && cameFrom.current !== "main" ? root.querySelector<HTMLElement>(`[data-panel-row="${cameFrom.current}"]`) : root.querySelector<HTMLElement>("[data-menu-item]:not([disabled])"); target?.focus({ preventScroll: true }); }, [open, panel]); const roving = (e: ReactKeyboardEvent) => { if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; const rows = Array.from(panelRef.current?.querySelectorAll<HTMLElement>("[data-menu-item]:not([disabled])") ?? []); const i = rows.indexOf(document.activeElement as HTMLElement); e.preventDefault(); rows[(i + (e.key === "ArrowDown" ? 1 : -1) + rows.length) % rows.length]?.focus(); }; const board = config.layout === "board"; const lanesLabel = board ? "Swimlanes" : "Group by"; const laneOptions = GROUP_OPTIONS.filter((o) => !board || o.key !== config.columnBy); const summary = { fields: FIELD_OPTIONS.filter((f) => config.fields.includes(f.key)) .map((f) => f.label) .join(", "), column: GROUP_OPTIONS.find((o) => o.key === config.columnBy)!.label, lanes: config.lanes === "none" ? "none" : GROUP_OPTIONS.find((o) => o.key === config.lanes)!.label, sort: config.sorts.map((s) => `${sortLabel(s.field)} ${s.dir === "asc" ? "↑" : "↓"}`).join(", ") || "Manual", sum: config.sum === "count" ? "Count" : "Estimate", }; const radio = (on: boolean, label: string, onClick: () => void, key: string) => ( <button key={key} type="button" role="menuitemradio" aria-checked={on} data-menu-item className={MENU_ROW} onClick={onClick}> <span className="flex w-4 shrink-0 justify-center"> {on && <Check aria-hidden className="h-4 w-4 text-emerald-700 dark:text-emerald-400" />} </span> {label} </button> ); const row = (p: Panel, icon: LucideIcon, label: string, value: string) => { const Icon = icon; return ( <button type="button" data-menu-item data-panel-row={p} className={MENU_ROW} onClick={() => go(p)}> <Icon aria-hidden className="h-4 w-4 shrink-0 text-stone-500" /> <span className="shrink-0">{label}</span> <span className="min-w-0 flex-1 truncate text-right text-xs text-stone-500">{value}</span> <ChevronRight aria-hidden className="h-4 w-4 shrink-0 text-stone-400 dark:text-stone-500" /> </button> ); }; const titles: Record<Panel, string> = { main: "View", fields: "Fields", column: "Column by", lanes: lanesLabel, sort: "Sort by", sum: "Field sum", }; return ( <div ref={ref} className="relative shrink-0"> <button ref={triggerRef} type="button" aria-haspopup="menu" aria-expanded={open} onClick={() => { setPanel("main"); cameFrom.current = "main"; setOpen((o) => !o); }} className={cn(GHOST, "relative h-9 px-3")} > <Settings2 aria-hidden className="h-4 w-4" /> View {dirty && ( <span aria-label="Unsaved changes" className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-emerald-600 ring-2 ring-stone-50 dark:bg-emerald-400 dark:ring-stone-950" /> )} </button> <AnimatePresence> {open && ( <motion.div role="menu" aria-label="View options" initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: -4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: 0.12 } }} transition={{ duration: 0.18, ease: EASE_OUT }} style={{ transformOrigin: "top right" }} className={cn(FLOATING, "absolute right-0 top-full z-40 mt-1.5 w-80 overflow-hidden")} > <motion.div layout={!reduce} transition={{ duration: 0.18, ease: EASE_OUT }} className="overflow-hidden"> <AnimatePresence mode="popLayout" initial={false} custom={dir}> <motion.div key={panel} ref={panelRef} custom={dir} initial={reduce ? { opacity: 0 } : { opacity: 0, x: dir * 24 }} animate={{ opacity: 1, x: 0 }} exit={reduce ? { opacity: 0 } : { opacity: 0, x: dir * -24, transition: { duration: 0.12 } }} transition={{ duration: 0.18, ease: EASE_OUT }} onKeyDown={roving} className="p-1.5" > {panel === "main" ? ( <> <div role="radiogroup" aria-label="Layout" className="grid grid-cols-3 gap-0.5 rounded-md bg-stone-100 p-0.5 dark:bg-stone-950" > {LAYOUTS.map((l) => { const Icon = l.icon; const on = config.layout === l.key; return ( <button key={l.key} type="button" role="radio" aria-checked={on} data-menu-item onClick={() => setConfig((c) => ({ ...c, layout: l.key, lanes: l.key === "board" && c.lanes === c.columnBy ? "none" : c.lanes, })) } className={cn( "inline-flex h-8 items-center justify-center gap-1.5 rounded text-xs font-medium transition-colors duration-150", on ? "bg-white text-stone-900 shadow-[0_1px_2px_-1px_rgba(0,0,0,0.12)] dark:bg-stone-800 dark:text-stone-100" : "text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100", FOCUS )} > <Icon aria-hidden className="h-3.5 w-3.5" /> {l.label} </button> ); })} </div> <div className="mt-1.5 flex flex-col"> {row("fields", Rows3, "Fields", summary.fields)} {board && row("column", Columns3, "Column by", summary.column)} {row("lanes", Rows2, lanesLabel, summary.lanes)} {row("sort", ArrowUpDown, "Sort by", summary.sort)} {board && row("sum", Hash, "Field sum", summary.sum)} </div> <div className="mt-1.5 flex justify-end gap-2 border-t border-stone-200 px-1 pt-2.5 pb-1 dark:border-stone-700"> <button type="button" data-menu-item disabled={!dirty} onClick={onDiscard} className={cn(GHOST, "h-8 px-3 text-xs")} > Discard </button> <button type="button" data-menu-item disabled={!dirty} onClick={() => { onSave(); setOpen(false); }} className={cn(PRIMARY, "h-8 px-3 text-xs")} > Save view </button> </div> </> ) : ( <> <div className="mb-1 flex items-center gap-1"> <button type="button" aria-label="Back" data-menu-item onClick={() => go("main")} className={cn(ICON_BTN, "h-7 w-7")} > <ChevronLeft aria-hidden className="h-4 w-4" /> </button> <span className="text-sm font-medium text-stone-900 dark:text-stone-100">{titles[panel]}</span> </div> {panel === "fields" && FIELD_OPTIONS.map((f) => { const on = config.fields.includes(f.key); return ( <button key={f.key} type="button" role="menuitemcheckbox" aria-checked={on} data-menu-item className={MENU_ROW} onClick={() => setConfig((c) => ({ ...c, fields: on ? c.fields.filter((x) => x !== f.key) : FIELD_OPTIONS.map((o) => o.key).filter((k) => k === f.key || c.fields.includes(k)), })) } > <span aria-hidden className={cn( "flex h-4 w-4 shrink-0 items-center justify-center rounded border", on ? "border-emerald-700 bg-emerald-700 text-white" : "border-stone-300 dark:border-stone-600" )} > {on && <Check className="h-3 w-3" strokeWidth={3} />} </span> {f.label} </button> ); })} {panel === "column" && GROUP_OPTIONS.map((o) => radio( config.columnBy === o.key, o.label, () => setConfig((c) => ({ ...c, columnBy: o.key, lanes: c.lanes === o.key ? "none" : c.lanes })), o.key ) )} {panel === "lanes" && ( <> {radio(config.lanes === "none", "None", () => setConfig((c) => ({ ...c, lanes: "none" })), "none")} {laneOptions.map((o) => radio(config.lanes === o.key, o.label, () => setConfig((c) => ({ ...c, lanes: o.key })), o.key) )} </> )} {panel === "sort" && ( <SortPanel sorts={config.sorts} hasRepos={hasRepos} onDone={(next) => { setOpen(false); triggerRef.current?.focus({ preventScroll: true }); if (JSON.stringify(next) !== JSON.stringify(config.sorts)) setSorts(next); }} /> )} {panel === "sum" && ( <> {radio(config.sum === "count", "Count", () => setConfig((c) => ({ ...c, sum: "count" })), "count")} {radio( config.sum === "estimate", "Estimate", () => setConfig((c) => ({ ...c, sum: "estimate" })), "estimate" )} </> )} </> )} </motion.div> </AnimatePresence> </motion.div> </motion.div> )} </AnimatePresence> </div> );}/* ------------------------------------------------------------------------ *//* View tabs *//* ------------------------------------------------------------------------ */const layoutIcon = (l: Layout) => LAYOUTS.find((x) => x.key === l)!.icon;function ViewTabs({ views, activeId, dirty, configOf, onSelect, onNew, onRename, onDuplicate, onDelete,}: { views: SavedView[]; activeId: string; dirty: Set<string>; configOf: (v: SavedView) => ViewConfig; onSelect: (id: string) => void; onNew: () => string; onRename: (id: string, name: string) => void; onDuplicate: (id: string) => void; onDelete: (id: string) => void;}) { const reduce = useReducedMotion(); const [renaming, setRenaming] = useState<string | null>(null); const [menu, setMenu] = useState<{ id: string; left: number } | null>(null); const rootRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null); const closeMenu = useCallback(() => setMenu(null), []); useDismiss(!!menu, menuRef, closeMenu); const onKeys = (e: ReactKeyboardEvent) => { if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; const tabs = Array.from(rootRef.current?.querySelectorAll<HTMLElement>('[role="tab"]') ?? []); const i = tabs.indexOf(document.activeElement as HTMLElement); if (i < 0) return; e.preventDefault(); tabs[(i + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length]?.focus(); }; const item = "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-stone-700 transition-colors duration-150 hover:bg-stone-100 disabled:pointer-events-none disabled:opacity-50 dark:text-stone-200 dark:hover:bg-stone-700 " + FOCUS; return ( <div ref={rootRef} className="relative flex shrink-0 items-end gap-1 border-b border-stone-200 px-5 dark:border-stone-800"> <div role="tablist" aria-label="Views" onKeyDown={onKeys} className="scrollbar-hide flex min-w-0 items-end overflow-x-auto" data-lenis-prevent > {views.map((v) => { const on = v.id === activeId; const Icon = layoutIcon(configOf(v).layout); return ( <div key={v.id} className="relative flex shrink-0 items-center"> {renaming === v.id ? ( <input autoFocus defaultValue={v.name} aria-label="View name" maxLength={32} onFocus={(e) => e.currentTarget.select()} onBlur={(e) => { const n = e.target.value.trim(); if (n) onRename(v.id, n); setRenaming(null); }} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") setRenaming(null); }} className={cn(INPUT, "my-1.5 h-7 w-40 px-2 text-xs")} /> ) : ( <button type="button" role="tab" aria-selected={on} tabIndex={on ? 0 : -1} onClick={() => onSelect(v.id)} onDoubleClick={() => setRenaming(v.id)} className={cn( "relative inline-flex h-10 items-center gap-1.5 px-3 text-sm transition-colors duration-150", on ? "font-medium text-stone-900 dark:text-stone-100" : "text-stone-500 hover:text-stone-900 dark:hover:text-stone-100", FOCUS )} > <Icon aria-hidden className="h-4 w-4 shrink-0" /> <span className="whitespace-nowrap">{v.name}</span> {dirty.has(v.id) && ( <span aria-label="unsaved" className="h-1.5 w-1.5 rounded-full bg-emerald-600 dark:bg-emerald-400" /> )} {on && ( <motion.span layoutId="deck-view-tab" transition={reduce ? { duration: 0 } : { type: "spring", duration: 0.3, bounce: 0.1 }} className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-emerald-700 dark:bg-emerald-400" /> )} </button> )} {on && renaming !== v.id && ( <button type="button" aria-label={`${v.name} options`} aria-haspopup="menu" aria-expanded={menu?.id === v.id} onClick={(e) => { const r = e.currentTarget.getBoundingClientRect(); const rr = rootRef.current!.getBoundingClientRect(); setMenu((m) => (m ? null : { id: v.id, left: Math.min(r.left - rr.left, rr.width - 184) })); }} className={cn(ICON_BTN, "-ml-1 h-6 w-6")} > <ChevronDown aria-hidden className="h-3.5 w-3.5" /> </button> )} <span aria-hidden className="mx-1 h-4 w-px bg-stone-200 dark:bg-stone-800" /> </div> ); })} </div> <button type="button" onClick={() => setRenaming(onNew())} className={cn( "inline-flex h-10 shrink-0 items-center gap-1.5 px-2 text-sm text-stone-500 transition-colors duration-150 hover:text-stone-900 dark:hover:text-stone-100", FOCUS )} > <Plus aria-hidden className="h-4 w-4" /> New view </button> <AnimatePresence> {menu && ( <motion.div ref={menuRef} role="menu" initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: -2 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={{ duration: 0.16, ease: EASE_OUT }} style={{ left: menu.left, transformOrigin: "top left" }} className={cn(FLOATING, "absolute top-full z-40 mt-1 w-44 p-1")} > <button type="button" role="menuitem" className={item} onClick={() => { setRenaming(menu.id); setMenu(null); }} > <Pencil aria-hidden className="h-3.5 w-3.5" /> Rename view </button> <button type="button" role="menuitem" className={item} onClick={() => { onDuplicate(menu.id); setMenu(null); }} > <Copy aria-hidden className="h-3.5 w-3.5" /> Duplicate view </button> <button type="button" role="menuitem" disabled={views.length < 2} className={cn(item, "text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40")} onClick={() => { onDelete(menu.id); setMenu(null); }} > <Trash2 aria-hidden className="h-3.5 w-3.5" /> Delete view </button> </motion.div> )} </AnimatePresence> </div> );}/* ------------------------------------------------------------------------ *//* Table layout *//* ------------------------------------------------------------------------ */const TABLE_COLS: { key: FieldKey | "title" | "status"; label: string; sort?: SortField; width: string }[] = [ { key: "title", label: "Title", sort: "title", width: "min-w-[320px]" }, { key: "status", label: "Status", sort: "status", width: "w-36" }, { key: "assignee", label: "Assignee", sort: "assignee", width: "w-40" }, { key: "labels", label: "Labels", sort: "labels", width: "w-48" }, { key: "priority", label: "Priority", sort: "priority", width: "w-32" }, { key: "due", label: "Due", sort: "due", width: "w-24" }, { key: "progress", label: "Progress", sort: "progress", width: "w-40" }, { key: "estimate", label: "Estimate", sort: "estimate", width: "w-24" }, { key: "parent", label: "Parent", width: "w-48" },];function TableView({ rows, lanes, bc, config, onSortColumn, onOpen, onAdd,}: { rows: Card[]; lanes: Group[]; bc: BoardCtx; config: ViewConfig; onSortColumn: (f: SortField) => void; onOpen: (id: string) => void; onAdd: (title: string, lane: Group) => void;}) { const reduce = useReducedMotion(); const [adding, setAdding] = useState<string | null>(null); const cols = TABLE_COLS.filter((c) => c.key === "title" || c.key === "status" || config.fields.includes(c.key as FieldKey)); const byId = useMemo(() => new Map(bc.board.cards.map((c) => [c.id, c])), [bc.board.cards]); const cell = (c: Card, key: (typeof TABLE_COLS)[number]["key"]): ReactNode => { switch (key) { case "title": return ( <span className="flex min-w-0 items-center gap-2"> {config.fields.includes("key") && ( <span className="shrink-0 font-[family-name:var(--font-deck-mono)] text-[11px] text-stone-500">{c.key}</span> )} <span className="truncate font-medium text-stone-900 dark:text-stone-100">{c.title}</span> </span> ); case "status": { const col = bc.board.columns.find((x) => x.id === c.columnId); return col ? ( <span className="inline-flex items-center gap-1.5"> <Dot color={col.color} /> {col.name} </span> ) : null; } case "assignee": { const p = c.assigneeId ? bc.people.get(c.assigneeId) : undefined; return p ? ( <span className="inline-flex min-w-0 items-center gap-1.5"> <Initials person={p} size="xs" /> <span className="truncate">{p.name}</span> </span> ) : ( <span className="text-stone-400 dark:text-stone-600">Unassigned</span> ); } case "labels": return ( <span className="flex min-w-0 gap-1 overflow-hidden"> {c.labelIds.slice(0, 2).map((id) => { const l = bc.labels.get(id); return l ? ( <LabelChip key={id} color={l.color} size="sm"> {l.name} </LabelChip> ) : null; })} {c.labelIds.length > 2 && <span className="text-[11px] text-stone-500">+{c.labelIds.length - 2}</span>} </span> ); case "priority": return ( <span className="inline-flex items-center gap-1.5"> <PriorityGlyph priority={c.priority} /> {PRIORITIES[c.priority]} </span> ); case "due": { const s = dueState(c.due, c.columnId); return c.due ? ( <span className={cn( "tabular-nums", s === "overdue" && "text-red-700 dark:text-red-400", s === "soon" && "text-amber-700 dark:text-amber-400" )} > {shortDate(c.due)} </span> ) : null; } case "progress": return <ProgressMeter p={cardProgress(c, bc)} />; case "estimate": return c.estimate !== null ? <span className="tabular-nums">{c.estimate}</span> : null; case "parent": { const p = c.parentId ? byId.get(c.parentId) : undefined; return p ? ( <span className="flex min-w-0 items-center gap-1 text-stone-600 dark:text-stone-400"> <CornerDownRight aria-hidden className="h-3.5 w-3.5 shrink-0" /> <span className="truncate">{p.title}</span> </span> ) : null; } } }; const groups = lanes.map((l) => ({ lane: l, cards: rows.filter(l.match) })).filter((g) => g.cards.length || lanes.length === 1); return ( <div className="flex-1 overflow-x-auto overscroll-x-contain px-5 pb-5" data-lenis-prevent> <table className="w-full min-w-[960px] border-separate border-spacing-0 text-sm"> <thead> <tr> {cols.map((c) => { const at = c.sort ? config.sorts.findIndex((s) => s.field === c.sort) : -1; const rule = config.sorts[at]; return ( <th key={c.key} scope="col" aria-sort={at === 0 ? (rule.dir === "asc" ? "ascending" : "descending") : undefined} className={cn( "sticky top-0 z-10 border-b border-stone-200 bg-stone-50 px-3 py-2 text-left text-xs font-medium text-stone-600 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-400", c.width )} > {c.sort ? ( <button type="button" onClick={() => onSortColumn(c.sort!)} className={cn( "inline-flex items-center gap-1 rounded hover:text-stone-900 dark:hover:text-stone-100", FOCUS )} > {c.label} {rule && (rule.dir === "asc" ? ( <ArrowUpNarrowWide aria-hidden className="h-3.5 w-3.5" /> ) : ( <ArrowDownWideNarrow aria-hidden className="h-3.5 w-3.5" /> ))} </button> ) : ( c.label )} </th> ); })} </tr> </thead> {groups.map(({ lane, cards }) => ( <tbody key={lane.id}> {lanes.length > 1 && ( <tr> <th colSpan={cols.length} scope="rowgroup" className="border-b border-stone-200 bg-stone-100/70 px-3 py-2 text-left text-xs font-semibold text-stone-700 dark:border-stone-800 dark:bg-stone-900/60 dark:text-stone-300" > <span className="inline-flex items-center gap-2"> {lane.column && <Dot color={lane.column.color} />} {lane.priority !== undefined && <PriorityGlyph priority={lane.priority} />} {lane.person && <Initials person={lane.person} size="xs" />} {lane.name} <span className="font-normal tabular-nums text-stone-500">{cards.length}</span> </span> </th> </tr> )} {cards.map((c) => ( <motion.tr key={c.id} layout={reduce ? false : "position"} transition={{ duration: 0.2, ease: EASE_OUT }} tabIndex={0} onClick={() => onOpen(c.id)} onKeyDown={(e) => { if (e.key === "Enter") onOpen(c.id); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const tr = e.currentTarget; const next = ( e.key === "ArrowDown" ? tr.nextElementSibling : tr.previousElementSibling ) as HTMLElement | null; if (next?.tabIndex === 0) next.focus(); } }} className="group cursor-pointer outline-none transition-colors duration-150 hover:bg-white focus-visible:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-600 dark:hover:bg-stone-900 dark:focus-visible:bg-stone-900 dark:focus-visible:outline-emerald-400" > {cols.map((col) => ( <td key={col.key} className={cn( "h-10 max-w-0 whitespace-nowrap border-b border-stone-200/80 px-3 text-stone-700 dark:border-stone-800/80 dark:text-stone-300", col.width )} > {cell(c, col.key)} </td> ))} </motion.tr> ))} <tr> <td colSpan={cols.length} className="border-b border-stone-200/80 px-1.5 py-1 dark:border-stone-800/80"> {adding === lane.id ? ( <form onSubmit={(e) => { e.preventDefault(); const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement; const t = input.value.trim(); if (t) onAdd(t, lane); input.value = ""; }} > <input name="title" autoFocus placeholder="Title, then Enter" aria-label="New item title" onBlur={() => setAdding(null)} onKeyDown={(e) => e.key === "Escape" && setAdding(null)} className={cn(INPUT, "h-8 w-full max-w-md text-xs")} /> </form> ) : ( <button type="button" onClick={() => setAdding(lane.id)} className={cn( "inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-xs text-stone-500 hover:bg-stone-100 hover:text-stone-900 dark:hover:bg-stone-800 dark:hover:text-stone-100", FOCUS )} > <Plus aria-hidden className="h-3.5 w-3.5" /> Add item </button> )} </td> </tr> </tbody> ))} </table> {rows.length === 0 && <p className="py-10 text-center text-sm text-stone-500">Nothing matches this filter.</p>} </div> );}/* ------------------------------------------------------------------------ *//* Roadmap layout *//* *//* One row per card, a bar from its start date to its due date, drawn over *//* a day grid with the weekend shaded and a line at today. Drag a bar to *//* move it, drag either end to change that date; both snap to whole days. *//* ------------------------------------------------------------------------ */const DAY_MS = 86_400_000;const isoOfDay = (n: number) => { const d = new Date(n * DAY_MS); return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;};type Zoom = "week" | "month";function RoadmapView({ rows, lanes, bc, onOpen, onDates,}: { rows: Card[]; lanes: Group[]; bc: BoardCtx; onOpen: (id: string) => void; onDates: (id: string, start: string | null, due: string | null) => void;}) { const reduce = useReducedMotion(); const [zoom, setZoom] = useState<Zoom>("week"); const [edit, setEdit] = useState<{ id: string; start: number; end: number } | null>(null); const moved = useRef(false); const scrollRef = useRef<HTMLDivElement>(null); const dayW = zoom === "week" ? 40 : 16; const today = dayNumber(TODAY); const dated = rows.filter((c) => c.start || c.due); const days = dated.flatMap((c) => [c.start, c.due].filter(Boolean).map((d) => dayNumber(d!))); const first = Math.min(today - 7, ...days) - 2; const last = Math.max(today + 14, ...days) + 4; const span = last - first + 1; const x = (d: number) => (d - first) * dayW; // Open on today, with a couple of days of context before it, the way GitHub's roadmap does. useEffect(() => { const sc = scrollRef.current; if (sc) sc.scrollLeft = Math.max(0, (today - first - 2) * dayW); // eslint-disable-next-line react-hooks/exhaustive-deps }, [zoom]); const beginDrag = (e: ReactPointerEvent, c: Card, mode: "move" | "start" | "end") => { if (e.button !== 0) return; e.stopPropagation(); e.preventDefault(); const s0 = c.start ? dayNumber(c.start) : dayNumber(c.due!); const e0 = c.due ? dayNumber(c.due) : s0; const x0 = e.clientX; moved.current = false; setEdit({ id: c.id, start: s0, end: e0 }); const move = (ev: PointerEvent) => { const d = Math.round((ev.clientX - x0) / dayW); if (d) moved.current = true; if (mode === "move") setEdit({ id: c.id, start: s0 + d, end: e0 + d }); else if (mode === "start") setEdit({ id: c.id, start: Math.min(s0 + d, e0), end: e0 }); else setEdit({ id: c.id, start: s0, end: Math.max(e0 + d, s0) }); }; const up = (ev: PointerEvent) => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); const d = Math.round((ev.clientX - x0) / dayW); let s = s0; let en = e0; if (mode === "move") { s += d; en += d; } else if (mode === "start") s = Math.min(s0 + d, e0); else en = Math.max(e0 + d, s0); setEdit(null); if (d) onDates(c.id, isoOfDay(s), isoOfDay(en)); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }; const onBarKey = (e: ReactKeyboardEvent, c: Card) => { if (e.key === "Enter") return onOpen(c.id); if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; e.preventDefault(); const d = e.key === "ArrowRight" ? 1 : -1; const s = c.start ? dayNumber(c.start) : dayNumber(c.due!); const en = c.due ? dayNumber(c.due) : s; // Shift+arrow changes the end date only; plain arrows move the whole bar. if (e.shiftKey) onDates(c.id, isoOfDay(s), isoOfDay(Math.max(s, en + d))); else onDates(c.id, isoOfDay(s + d), isoOfDay(en + d)); }; const groups = lanes.map((l) => ({ lane: l, cards: rows.filter(l.match) })).filter((g) => g.cards.length || lanes.length === 1); return ( <div className="flex flex-1 flex-col px-5 pb-5"> <div className="mb-2 flex items-center gap-2"> <div role="radiogroup" aria-label="Zoom" className="inline-grid grid-cols-2 gap-0.5 rounded-md bg-stone-200/60 p-0.5 dark:bg-stone-900" > {(["week", "month"] as Zoom[]).map((z) => ( <button key={z} type="button" role="radio" aria-checked={zoom === z} onClick={() => setZoom(z)} className={cn( "h-7 rounded px-3 text-xs font-medium capitalize transition-colors duration-150", zoom === z ? "bg-white text-stone-900 shadow-[0_1px_2px_-1px_rgba(0,0,0,0.12)] dark:bg-stone-800 dark:text-stone-100" : "text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100", FOCUS )} > {z} </button> ))} </div> <span className="text-xs text-stone-500">Drag a bar to move it, or an end to change that date. Arrow keys work too.</span> </div> <div ref={scrollRef} className="overflow-x-auto overscroll-x-contain rounded-xl border border-stone-200 bg-white dark:border-stone-800 dark:bg-stone-900" data-lenis-prevent > <div className="relative" style={{ width: 288 + span * dayW }}> {/* Header: months over days */} <div className="sticky top-0 z-20 flex border-b border-stone-200 bg-white dark:border-stone-800 dark:bg-stone-900"> <div className="sticky left-0 z-10 w-72 shrink-0 border-r border-stone-200 bg-white px-3 py-2 text-xs font-medium text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-400"> Title </div> <div className="relative h-12" style={{ width: span * dayW }}> {Array.from({ length: span }, (_, i) => { const iso = isoOfDay(first + i); const d = new Date((first + i) * DAY_MS); const dow = d.getUTCDay(); const dom = d.getUTCDate(); const showNum = zoom === "week" || dow === 1; return ( <div key={iso} className="absolute top-0 h-full" style={{ left: i * dayW, width: dayW }}> {(dom === 1 || i === 0) && ( <span className="absolute left-1 top-1 whitespace-nowrap text-[11px] font-medium text-stone-700 dark:text-stone-300"> {MONTHS[d.getUTCMonth()]} </span> )} {showNum && ( <span className={cn( "absolute bottom-1 left-0 w-full text-center text-[11px] tabular-nums", first + i === today ? "font-semibold text-emerald-700 dark:text-emerald-400" : "text-stone-500" )} > {dom} </span> )} </div> ); })} </div> </div> {/* Grid: weekends and today */} <div aria-hidden className="pointer-events-none absolute bottom-0 left-72 top-12" style={{ width: span * dayW }}> {Array.from({ length: span }, (_, i) => { const dow = new Date((first + i) * DAY_MS).getUTCDay(); return dow === 0 || dow === 6 ? ( <div key={i} className="absolute inset-y-0 bg-stone-100/70 dark:bg-stone-950/40" style={{ left: i * dayW, width: dayW }} /> ) : null; })} <div className="absolute inset-y-0 w-0.5 bg-emerald-600 dark:bg-emerald-400" style={{ left: x(today) + dayW / 2 - 1 }} /> </div> {groups.map(({ lane, cards }) => ( <div key={lane.id} role="group" aria-label={lanes.length > 1 ? lane.name : "Roadmap"}> {lanes.length > 1 && ( <div className="sticky left-0 flex h-8 w-72 items-center gap-2 border-b border-r border-stone-200 bg-stone-50 px-3 text-xs font-semibold text-stone-700 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-300"> {lane.column && <Dot color={lane.column.color} />} {lane.priority !== undefined && <PriorityGlyph priority={lane.priority} />} {lane.person && <Initials person={lane.person} size="xs" />} {lane.name} <span className="font-normal tabular-nums text-stone-500">{cards.length}</span> </div> )} {cards.map((c) => { const col = bc.board.columns.find((k) => k.id === c.columnId); const color = col?.color ?? "#a8a29e"; const live = edit?.id === c.id ? edit : null; const hasDates = !!(c.start || c.due); const s = live ? live.start : c.start ? dayNumber(c.start) : c.due ? dayNumber(c.due) : 0; const en = live ? live.end : c.due ? dayNumber(c.due) : s; const p = cardProgress(c, bc); return ( <div key={c.id} className="group/row flex h-10 border-b border-stone-100 dark:border-stone-800/60"> <button type="button" onClick={() => onOpen(c.id)} className={cn( "sticky left-0 z-10 flex w-72 shrink-0 items-center gap-2 border-r border-stone-200 bg-white px-3 text-left text-sm hover:bg-stone-50 dark:border-stone-800 dark:bg-stone-900 dark:hover:bg-stone-800", FOCUS )} > <Dot color={color} /> <span className="shrink-0 font-[family-name:var(--font-deck-mono)] text-[11px] text-stone-500"> {c.key} </span> <span className="truncate text-stone-900 dark:text-stone-100">{c.title}</span> </button> <div className="relative" style={{ width: span * dayW }}> {hasDates ? ( <div role="button" tabIndex={0} aria-label={`${c.key} ${c.title}, ${c.start ? shortDate(isoOfDay(s)) + " to " : "due "}${shortDate( isoOfDay(en) )}`} onPointerDown={(e) => beginDrag(e, c, "move")} onClick={() => { if (!moved.current) onOpen(c.id); }} onKeyDown={(e) => onBarKey(e, c)} style={{ ...cardVars(color), left: x(s) + 2, width: Math.max(dayW - 4, (en - s + 1) * dayW - 4) }} className={cn( "absolute top-1.5 flex h-7 cursor-grab touch-none select-none items-center overflow-hidden rounded-md border bg-[var(--card-bg)] border-[color:var(--card-line)] text-xs text-stone-800 dark:bg-[var(--card-bg-dark)] dark:border-[color:var(--card-line-dark)] dark:text-stone-200", live ? "shadow-[0_6px_16px_-8px_rgba(28,25,23,0.35)]" : "transition-[left,width] duration-150", reduce && "transition-none", FOCUS )} > <span aria-hidden className="absolute inset-y-0 left-0 opacity-25" style={{ width: `${p.value * 100}%`, background: color }} /> <span aria-hidden onPointerDown={(e) => beginDrag(e, c, "start")} className="absolute inset-y-0 left-0 w-2 cursor-ew-resize hover:bg-black/10 dark:hover:bg-white/10" /> <span className="relative truncate px-2.5">{c.title}</span> <span aria-hidden onPointerDown={(e) => beginDrag(e, c, "end")} className="absolute inset-y-0 right-0 w-2 cursor-ew-resize hover:bg-black/10 dark:hover:bg-white/10" /> </div> ) : ( <button type="button" onClick={() => onDates(c.id, TODAY, isoOfDay(today + 3))} className={cn( "absolute top-2 h-6 rounded-md border border-dashed border-stone-300 px-2 text-[11px] text-stone-500 opacity-0 transition-opacity duration-150 group-hover/row:opacity-100 focus-visible:opacity-100 dark:border-stone-700", FOCUS )} style={{ left: x(today) + 4 }} > Add dates </button> )} </div> </div> ); })} </div> ))} </div> </div> </div> );}/* ------------------------------------------------------------------------ *//* Insights *//* *//* GitHub's Insights, over whatever the current view's filter shows: a *//* burn-up across the sprint, work by status and by person, and the label *//* mix. The charts are the library's own. *//* ------------------------------------------------------------------------ */function InsightsView({ cards, bc }: { cards: Card[]; bc: BoardCtx }) { const d0 = dayNumber(SPRINT_START); const d1 = dayNumber(SPRINT_END); const today = dayNumber(TODAY); const pts = (c: Card) => c.estimate ?? 1; const burn: ChartPoint[] = Array.from({ length: d1 - d0 + 1 }, (_, i) => { const d = d0 + i; const past = d <= today; const scope = cards.filter((c) => dayNumber(c.createdAt) <= d).reduce((s, c) => s + pts(c), 0); const done = cards.filter((c) => c.closedAt && dayNumber(c.closedAt) <= d).reduce((s, c) => s + pts(c), 0); const total = cards.reduce((s, c) => s + pts(c), 0); return { x: shortDate(isoOfDay(d)), values: { scope: past ? scope : null, done: past ? done : null, ideal: Math.round((total * i) / (d1 - d0)), }, }; }); const burnSeries: SeriesSpec[] = [ { id: "scope", label: "Scope, points" }, { id: "done", label: "Completed, points" }, // A reference, not data: stone-500 keeps it quiet and still clears 3:1 on white. { id: "ideal", label: "Even pace", color: "#78716c" }, ]; const statusPoints: ChartPoint[] = bc.board.columns.map((col) => ({ x: col.name, values: { cards: cards.filter((c) => c.columnId === col.id).length }, })); const people = [...bc.board.people.filter((p) => cards.some((c) => c.assigneeId === p.id))]; const personSeries: SeriesSpec[] = bc.board.columns.map((col) => ({ id: col.id, label: col.name, color: col.color })); const personPoints: ChartPoint[] = [ ...people.map((p) => ({ id: p.id as string | null, name: p.name })), { id: null, name: "Unassigned" }, ].map((p) => ({ x: p.name, values: Object.fromEntries( bc.board.columns.map((col) => [ col.id, cards.filter((c) => c.assigneeId === p.id && c.columnId === col.id).reduce((s, c) => s + pts(c), 0), ]) ), })); const labelSlices: PieSlice[] = bc.board.labels .map((l) => ({ id: l.id, label: l.name, value: cards.filter((c) => c.labelIds.includes(l.id)).length, color: l.color })) .filter((s) => s.value > 0); const open = cards.filter((c) => c.columnId !== DONE_ID); const tiles = [ { label: "Open", value: open.length }, { label: "Done", value: cards.length - open.length }, { label: "Overdue", value: cards.filter((c) => dueState(c.due, c.columnId) === "overdue").length }, { label: "Points left", value: open.reduce((s, c) => s + pts(c), 0) }, ]; const panel = "rounded-xl border border-stone-200 bg-white p-4 dark:border-stone-800 dark:bg-stone-900"; return ( <div className="flex-1 px-5 pb-5"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4"> {tiles.map((t) => ( <div key={t.label} className={panel}> <span className={EYEBROW}>{t.label}</span> <p className="mt-1 text-[28px] font-semibold leading-none tracking-tight tabular-nums text-stone-900 dark:text-stone-100"> {t.value} </p> </div> ))} </div> <div className="mt-3 grid gap-3 lg:grid-cols-3"> <section className={cn(panel, "lg:col-span-2")} aria-labelledby="deck-burnup"> <h3 id="deck-burnup" className="text-sm font-medium text-stone-900 dark:text-stone-100"> Burn-up, Sprint 14 </h3> <p className="mb-2 text-xs text-stone-500"> Points in scope against points done, day by day, with the pace that would land on Oct 5. </p> <LineChart label="Sprint 14 burn-up in story points" summary={`Scope is ${burn.filter((b) => b.values.scope !== null).at(-1)?.values.scope ?? 0} points with ${ burn.filter((b) => b.values.done !== null).at(-1)?.values.done ?? 0 } done as of ${shortDate(TODAY)}.`} data={burn} series={burnSeries} curve="linear" height={240} y={{ zero: true }} /> </section> <section className={panel} aria-labelledby="deck-labels"> <h3 id="deck-labels" className="text-sm font-medium text-stone-900 dark:text-stone-100"> Cards by label </h3> <p className="mb-2 text-xs text-stone-500">In the labels' own colours.</p> {labelSlices.length ? ( <PieChart label="Cards by label" data={labelSlices} variant="donut" size={200} centre={{ value: String(cards.length), caption: "cards" }} /> ) : ( <p className="py-10 text-center text-xs text-stone-500">No labelled cards in this view.</p> )} </section> <section className={panel} aria-labelledby="deck-status"> <h3 id="deck-status" className="text-sm font-medium text-stone-900 dark:text-stone-100"> Cards by status </h3> <p className="mb-2 text-xs text-stone-500">Where the work is sitting.</p> <BarChart label="Cards by status" data={statusPoints} series={[{ id: "cards", label: "Cards" }]} height={220} legend={false} /> </section> <section className={cn(panel, "lg:col-span-2")} aria-labelledby="deck-people"> <h3 id="deck-people" className="text-sm font-medium text-stone-900 dark:text-stone-100"> Points by assignee </h3> <p className="mb-2 text-xs text-stone-500">Stacked by status, in each column's colour.</p> <BarChart label="Story points by assignee, stacked by status" data={personPoints} series={personSeries} layout="stacked" orientation="horizontal" height={Math.max(180, personPoints.length * 36 + 48)} /> </section> </div> </div> );}/* ------------------------------------------------------------------------ *//* Sub-issues (in the card sheet) *//* ------------------------------------------------------------------------ */function SubIssues({ card, bc, onOpenCard, onAddChild, onLink, onUnlink,}: { card: Card; bc: BoardCtx; onOpenCard: (id: string) => void; onAddChild: (title: string) => void; onLink: (childId: string) => void; onUnlink: (childId: string) => void;}) { const [text, setText] = useState(""); const children = bc.kids.get(card.id) ?? []; const done = children.filter((c) => c.columnId === DONE_ID).length; const blocked = new Set([...familyOf(card.id, bc.kids), ...ancestorsOf(card, bc.board.cards)]); const candidates = bc.board.cards.filter((c) => !blocked.has(c.id)); return ( <div className="flex flex-col gap-2"> <div className="flex items-center gap-2"> <span className={EYEBROW}>Sub-issues</span> {children.length > 0 && ( <span className="text-xs tabular-nums text-stone-500"> {done} of {children.length} done </span> )} </div> {children.length > 0 && ( <ProgressMeter p={{ value: done / children.length, label: `${Math.round((done / children.length) * 100)}%`, items: { done, total: children.length }, complete: done === children.length, text: `${done} of ${children.length} sub-issues done`, }} /> )} <ul className="flex flex-col"> {children.map((c) => { const col = bc.board.columns.find((x) => x.id === c.columnId); const p = c.assigneeId ? bc.people.get(c.assigneeId) : undefined; return ( <li key={c.id} className="group/kid flex items-center gap-1 rounded-md hover:bg-stone-100 dark:hover:bg-stone-800"> <button type="button" onClick={() => onOpenCard(c.id)} className={cn("flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm", FOCUS)} > {col && <Dot color={col.color} />} <span className="shrink-0 font-[family-name:var(--font-deck-mono)] text-[11px] text-stone-500">{c.key}</span> <span className={cn( "truncate", c.columnId === DONE_ID ? "text-stone-500 line-through" : "text-stone-800 dark:text-stone-200" )} > {c.title} </span> <span className="flex-1" /> {p && <Initials person={p} size="xs" />} </button> <button type="button" aria-label={`Remove ${c.key} from sub-issues`} onClick={() => onUnlink(c.id)} className={cn(ICON_BTN, "h-6 w-6 opacity-0 group-hover/kid:opacity-100 focus-visible:opacity-100")} > <X aria-hidden className="h-3.5 w-3.5" /> </button> </li> ); })} </ul> <div className="flex gap-2"> <form className="min-w-0 flex-1" onSubmit={(e) => { e.preventDefault(); const t = text.trim(); if (!t) return; onAddChild(t); setText(""); }} > <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Create a sub-issue, then Enter" aria-label="New sub-issue title" className={cn(INPUT, "h-8 w-full text-xs")} /> </form> {candidates.length > 0 && ( <Select size="sm" align="end" label="Add an existing card as a sub-issue" options={["Add existing", ...candidates.map((c) => `${c.key} ${c.title}`)]} value={0} onChange={(i) => i > 0 && onLink(candidates[i - 1].id)} /> )} </div> </div> );}/* ------------------------------------------------------------------------ *//* Theme *//* *//* The board follows the host page until someone flips the switch, then *//* pins itself: `.dark` turns every dark: utility on underneath, and *//* `data-light` turns them off (the Tailwind dark variant is *//* `&:is(.dark *):not([data-light] *)`). *//* ------------------------------------------------------------------------ */function subscribeHtmlClass(cb: () => void) { const mo = new MutationObserver(cb); mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); return () => mo.disconnect();}function useHostDark() { return useSyncExternalStore( subscribeHtmlClass, () => document.documentElement.classList.contains("dark"), () => false );}/* ------------------------------------------------------------------------ *//* The deal *//* *//* Sorting gathers every card into a deck in the middle of the board, *//* riffles it, and deals it back out in the new order. The real cards are *//* re-laid out underneath (invisible) so the clones fly to exact positions. *//* WAAPI keeps it off React: nothing re-renders while cards are in flight. *//* ------------------------------------------------------------------------ */const T = (x: number, y: number, r = 0) => `translate(${x}px,${y}px) rotate(${r}deg)`;/** * One shadow per state, not per card: fifteen cards each casting a full lift * shadow stack into a dark smudge under the deck. A card in flight casts a * soft lift; a card resting in the deck casts a hairline contact shadow; the * deck as a whole reads from the top card's shadow alone. */const LIFT = "0 10px 22px -10px rgba(28,25,23,0.22), 0 1px 3px rgba(28,25,23,0.06)";const STACKED = "0 1px 1px rgba(28,25,23,0.05)";const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));/* ------------------------------------------------------------------------ *//* Template *//* ------------------------------------------------------------------------ */interface Drop { group: string; lane: string; before: string | null;}interface DragState { id: string; w: number; h: number; ox: number; oy: number; origin: Drop;}/** * The box the board scrolls in: the nearest scrolling ancestor (a preview * frame), or the window when the template is the page itself. */function scrollParentOf(el: HTMLElement | null): HTMLElement | null { for (let n = el?.parentElement; n; n = n.parentElement) { const oy = getComputedStyle(n).overflowY; if ((oy === "auto" || oy === "scroll") && n.scrollHeight > n.clientHeight) return n; } return null;}function viewportOf(el: HTMLElement | null) { const sp = scrollParentOf(el); return sp ? sp.getBoundingClientRect() : { top: 0, bottom: window.innerHeight };}/** Height of whatever the board scrolls in, so overlays can pin to what is on screen. */function useViewportHeight(ref: React.RefObject<HTMLElement | null>) { const [h, setH] = useState<number | null>(null); useEffect(() => { const sp = scrollParentOf(ref.current) ?? ref.current?.parentElement ?? null; const measure = () => { const own = scrollParentOf(ref.current); setH(own ? own.clientHeight : window.innerHeight); }; measure(); const ro = new ResizeObserver(measure); if (sp) ro.observe(sp); window.addEventListener("resize", measure); return () => { ro.disconnect(); window.removeEventListener("resize", measure); }; }, [ref]); return h;}const columnName = (b: Board, id: string) => b.columns.find((c) => c.id === id)?.name ?? "";const logLine = (c: Card, text: string): Card => ({ ...c, activity: [...c.activity, { id: `${c.id}a${Date.now()}${c.activity.length}`, text, at: shortDate(TODAY) }],});/** Writes an activity line for every field an edit or a move changed, and stamps the close date. */function describeChange(before: Card, after: Card, b: Board): Card { let c = after; if (before.columnId !== after.columnId) { c = logLine(c, `Moved from ${columnName(b, before.columnId)} to ${columnName(b, after.columnId)}`); if (after.columnId === DONE_ID) c = { ...c, closedAt: TODAY }; else if (before.columnId === DONE_ID) c = { ...c, closedAt: null }; } if (before.priority !== after.priority) c = logLine(c, `Priority set to ${PRIORITIES[after.priority]}`); if (before.assigneeId !== after.assigneeId) { c = logLine(c, after.assigneeId ? `Assigned to ${b.people.find((p) => p.id === after.assigneeId)?.name}` : "Unassigned"); } if (before.parentId !== after.parentId) { c = logLine( c, after.parentId ? `Made a sub-issue of ${b.cards.find((x) => x.id === after.parentId)?.key}` : "No longer a sub-issue" ); } return c;}/** Row by row across the cells in DOM order: the order a dealer lays a spread. */function cellOrder(root: HTMLElement) { const lists = Array.from(root.querySelectorAll<HTMLElement>("[data-cell]")).map((cell) => Array.from(cell.querySelectorAll<HTMLElement>("[data-card-id]")).map((n) => n.dataset.cardId!) ); const out: string[] = []; const rows = Math.max(0, ...lists.map((l) => l.length)); for (let r = 0; r < rows; r++) lists.forEach((l) => l[r] && out.push(l[r])); return out;}export function KanbanBoardTemplate() { const reduce = useReducedMotion() ?? false; const hostDark = useHostDark(); const [forced, setForced] = useState<"light" | "dark" | null>(null); const dark = (forced ?? (hostDark ? "dark" : "light")) === "dark"; const [board, setBoard] = useState(seedBoard); const [views, setViews] = useState<SavedView[]>(SEED_VIEWS); const [activeView, setActiveView] = useState(SEED_VIEWS[0].id); const [drafts, setDrafts] = useState<Record<string, ViewConfig>>({}); const [insights, setInsights] = useState(false); const [openId, setOpenId] = useState<string | null>(null); const [creating, setCreating] = useState<Card | null>(null); const [importing, setImporting] = useState(false); const [adding, setAdding] = useState<string | null>(null); const [addingColumn, setAddingColumn] = useState(false); const [renaming, setRenaming] = useState<string | null>(null); const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set()); const [sound, setSound] = useState(true); const [announce, setAnnounce] = useState(""); const [busy, setBusy] = useState(false); const [dealing, setDealing] = useState(false); const [hoverLink, setHoverLink] = useState<string | null>(null); const [flash, setFlash] = useState<string | null>(null); const [sfx] = useState(() => new Sfx()); const rootRef = useRef<HTMLDivElement>(null); const fxRef = useRef<HTMLDivElement>(null); const scrollerRef = useRef<HTMLDivElement>(null); const helpId = useId(); // Laid out by the board's own width, not the viewport's: a template boxed // into a docs column is narrow on a wide screen. const [compact, setCompact] = useState(false); const viewportH = useViewportHeight(rootRef); useEffect(() => { const el = rootRef.current; if (!el) return; const ro = new ResizeObserver(([e]) => setCompact(e.contentRect.width < 880)); ro.observe(el); return () => ro.disconnect(); }, []); useEffect(() => { sfx.enabled = sound; }, [sfx, sound]); /* ------------------------------ views ------------------------------ */ const view = views.find((v) => v.id === activeView) ?? views[0]; const config = drafts[view.id] ?? view.config; const configOf = useCallback((v: SavedView) => drafts[v.id] ?? v.config, [drafts]); const dirtyIds = useMemo( () => new Set(views.filter((v) => drafts[v.id] && !sameConfig(drafts[v.id], v.config)).map((v) => v.id)), [views, drafts] ); const dirty = dirtyIds.has(view.id); const setConfig = useCallback( (fn: (c: ViewConfig) => ViewConfig) => setDrafts((d) => ({ ...d, [view.id]: fn(d[view.id] ?? view.config) })), [view] ); const dropDraft = (id: string) => setDrafts((d) => { const next = { ...d }; delete next[id]; return next; }); const saveView = () => { setViews((vs) => vs.map((v) => (v.id === view.id ? { ...v, config } : v))); dropDraft(view.id); setAnnounce(`${view.name} saved`); }; const newView = () => { const id = `v${Date.now().toString(36)}`; setViews((vs) => [...vs, { id, name: `View ${vs.length + 1}`, config: DEFAULT_CONFIG }]); setActiveView(id); setInsights(false); return id; }; const duplicateView = (id: string) => { const src = views.find((v) => v.id === id); if (!src) return; const copy = { id: `v${Date.now().toString(36)}`, name: `${src.name} copy`, config: configOf(src) }; setViews((vs) => [...vs.slice(0, vs.indexOf(src) + 1), copy, ...vs.slice(vs.indexOf(src) + 1)]); setActiveView(copy.id); }; const deleteView = (id: string) => { if (views.length < 2) return; const i = views.findIndex((v) => v.id === id); const rest = views.filter((v) => v.id !== id); setViews(rest); dropDraft(id); if (activeView === id) setActiveView(rest[Math.max(0, i - 1)].id); }; /* ------------------------------ derived ------------------------------ */ const labelMap = useMemo(() => new Map(board.labels.map((l) => [l.id, l])), [board.labels]); const peopleMap = useMemo(() => new Map(board.people.map((p) => [p.id, p])), [board.people]); const kids = useMemo(() => childrenOf(board.cards), [board.cards]); const bc = useMemo<BoardCtx>(() => ({ board, people: peopleMap, labels: labelMap, kids }), [board, peopleMap, labelMap, kids]); const swatches = useMemo( () => Array.from(new Set([...board.columns.map((c) => c.color), ...board.labels.map((l) => l.color)])).slice(0, 10), [board.columns, board.labels] ); const tokens = useMemo(() => tokenize(config.query), [config.query]); const filtering = tokens.some((t) => t.values.length > 0); const visible = useMemo(() => board.cards.filter((c) => cardMatches(c, tokens, bc)), [board.cards, tokens, bc]); const display = useMemo(() => sortCards(visible, config.sorts, bc), [visible, config.sorts, bc]); const layout = config.layout; const sorted = config.sorts.length > 0; const fields = useMemo(() => new Set(config.fields), [config.fields]); const hasRepos = board.cards.some((c) => !!c.source); const groups = useMemo(() => groupsFor(config.columnBy, board), [config.columnBy, board]); const lanes = useMemo(() => { if (config.lanes === "none" || (layout === "board" && config.lanes === config.columnBy)) return [ALL_LANE]; return groupsFor(config.lanes, board); }, [config.lanes, config.columnBy, layout, board]); const cellCards = (g: Group, l: Group) => display.filter((c) => g.match(c) && l.match(c)); const cellOf = (c: Card) => ({ g: groups.find((g) => g.match(c)) ?? groups[0], l: lanes.find((l) => l.match(c)) ?? lanes[0] }); const total = board.cards.length; const doneCount = board.cards.filter((c) => c.columnId === DONE_ID).length; /* ---------------------------- mutations ---------------------------- */ const patchCard = useCallback( (id: string, p: Partial<Card>) => { const before = board.cards.find((c) => c.id === id); if (before && p.columnId === DONE_ID && before.columnId !== DONE_ID) sfx.chime(); setBoard((b) => { const card = b.cards.find((c) => c.id === id); if (!card) return b; let cards = b.cards; if (p.columnId && p.columnId !== card.columnId) cards = moveCard(cards, id, p.columnId, null); cards = cards.map((c) => (c.id === id ? describeChange(card, { ...c, ...p }, b) : c)); return { ...b, cards }; }); }, [board.cards, sfx] ); const createLabel = useCallback( (name: string, color: string) => { const id = `l${Date.now().toString(36)}`; setBoard((b) => ({ ...b, labels: [...b.labels, { id, name, color }] })); sfx.pop(); return id; }, [sfx] ); const createColumn = useCallback( (name: string, color: string) => { const id = `col${Date.now().toString(36)}`; setBoard((b) => ({ ...b, columns: [...b.columns, { id, name, color }] })); setAnnounce(`Column ${name} added`); sfx.pop(); return id; }, [sfx] ); const recolor = useMemo<Recolor>( () => ({ column: (id, hex) => setBoard((b) => ({ ...b, columns: b.columns.map((c) => (c.id === id ? { ...c, color: hex } : c)) })), label: (id, hex) => setBoard((b) => ({ ...b, labels: b.labels.map((l) => (l.id === id ? { ...l, color: hex } : l)) })), }), [] ); const blankCard = (b: Board, columnId: string, title = "", extra: Partial<Card> = {}): Card => { const n = b.seq + 1; return { id: `n${n}`, key: `HBR-${FIRST_NEW_KEY + (n - 1001)}`, title, description: "", columnId, labelIds: [], assigneeId: null, priority: 0, estimate: null, start: null, due: null, parentId: null, createdAt: TODAY, closedAt: columnId === DONE_ID ? TODAY : null, activity: [{ id: `n${n}a0`, text: `Created in ${columnName(b, columnId)}`, at: shortDate(TODAY) }], ...extra, }; }; const insertCard = (card: Card) => { setBoard((b) => ({ ...b, seq: b.seq + 1, cards: placeCard([...b.cards, card], card.id, {}, null, (c) => c.columnId === card.columnId), })); sfx.pop(); setAnnounce(`Card ${card.key} added to ${columnName(board, card.columnId)}`); }; const addChild = (parentId: string, title: string) => { const parent = board.cards.find((c) => c.id === parentId); const first = board.columns[0]?.id; if (!parent || !first) return; insertCard(blankCard(board, first, title, { parentId, labelIds: parent.labelIds })); }; const deleteCard = (id: string) => { const card = board.cards.find((c) => c.id === id); setOpenId(null); setBoard((b) => ({ ...b, cards: b.cards.filter((c) => c.id !== id).map((c) => (c.parentId === id ? { ...c, parentId: null } : c)), })); if (card) setAnnounce(`Card ${card.key} deleted`); }; const deleteColumn = (id: string) => { setBoard((b) => { const rest = b.columns.filter((c) => c.id !== id); if (!rest.length) return b; const target = rest[0].id; return { ...b, columns: rest, cards: b.cards.map((c) => (c.columnId === id ? { ...c, columnId: target } : c)) }; }); }; const onImport = (repo: string, issues: GhIssue[], column: string) => { const { board: next, added } = importIssues(board, repo, issues, column); setBoard(next); setImporting(false); if (added) { sfx.pop(); setAnnounce(`Imported ${added} ${added === 1 ? "card" : "cards"} from ${repo}`); } }; /** Follows a card link: scrolls to the card and flashes it, or opens it when this view hides it. */ const jumpTo = (id: string) => { setHoverLink(null); const el = rootRef.current?.querySelector<HTMLElement>(`[data-cell] [data-card-id="${id}"]`); if (!el) { setOpenId(id); return; } el.scrollIntoView({ block: "nearest", inline: "center", behavior: reduce ? "auto" : "smooth" }); el.focus({ preventScroll: true }); setFlash(id); window.setTimeout(() => setFlash((f) => (f === id ? null : f)), 1400); }; /* ---------------------------- the deal ---------------------------- */ const rearrange = async (change: () => void) => { const root = rootRef.current; const fx = fxRef.current; if (!root || !fx || reduce || layout !== "board" || insights) { change(); return; } const nodes = new Map<string, HTMLElement>(); root.querySelectorAll<HTMLElement>("[data-cell] [data-card-id]").forEach((n) => nodes.set(n.dataset.cardId!, n)); const prevOrder = cellOrder(root).filter((id) => nodes.has(id)); if (prevOrder.length === 0) { change(); return; } setBusy(true); const s = 1.15; const lr = fx.getBoundingClientRect(); type Clone = { el: HTMLElement; x: number; y: number; w: number; h: number; rot: number; dx: number; dy: number }; const clones = new Map<string, Clone>(); for (const id of prevOrder) { const src = nodes.get(id)!; const r = src.getBoundingClientRect(); const el = src.cloneNode(true) as HTMLElement; el.removeAttribute("data-card-id"); el.removeAttribute("tabindex"); el.removeAttribute("role"); el.setAttribute("aria-hidden", "true"); const x = r.left - lr.left; const y = r.top - lr.top; Object.assign(el.style, { position: "absolute", left: "0", top: "0", margin: "0", width: r.width + "px", height: r.height + "px", transform: T(x, y), willChange: "transform", }); fx.appendChild(el); clones.set(id, { el, x, y, w: r.width, h: r.height, rot: 0, dx: 0, dy: 0 }); } setDealing(true); change(); await frame(); const targets = new Map<string, { x: number; y: number }>(); root.querySelectorAll<HTMLElement>("[data-cell] [data-card-id]").forEach((n) => { const r = n.getBoundingClientRect(); targets.set(n.dataset.cardId!, { x: r.left - lr.left, y: r.top - lr.top }); }); const nextOrder = cellOrder(root).filter((id) => clones.has(id)); const w = clones.get(prevOrder[0])!.w; const deckX = lr.width / 2 - w / 2; const vp = viewportOf(root); const visTop = Math.max(lr.top, vp.top); const visBottom = Math.min(lr.bottom, vp.bottom); const deckY = Math.max(24, (visTop + visBottom) / 2 - lr.top - 80); // 1. Gather into a deck. await Promise.all( prevOrder.map((id, i) => { const c = clones.get(id)!; c.rot = (Math.random() - 0.5) * 7; c.dx = deckX - i * 0.4; c.dy = deckY - i * 0.7; c.el.style.zIndex = String(i); return sleep((i * 24) / s).then(() => { c.el.style.boxShadow = LIFT; sfx.flick(0.08, 3000); return c.el .animate( [ { transform: T(c.x, c.y) }, { transform: T(c.x, c.y - 16, c.rot / 2), offset: 0.2 }, { transform: T(c.dx, c.dy, c.rot) }, ], { duration: 500 / s, easing: "cubic-bezier(.55,0,.25,1)", fill: "forwards" } ) .finished.then(() => { c.el.style.boxShadow = i === prevOrder.length - 1 ? LIFT : STACKED; }); }); }) ); // 2. Riffle: split the deck left and right and bring it back together. await sleep(100 / s); for (let k = 0; k < 12; k++) setTimeout(() => sfx.flick(0.07, 3400), (80 + k * 22) / s); await Promise.all( prevOrder.map((id, i) => { const c = clones.get(id)!; const side = i % 2 ? 1 : -1; return c.el.animate( [ { transform: T(c.dx, c.dy, c.rot) }, { transform: T(c.dx + side * w * 0.58, c.dy - 10, c.rot + side * 5), offset: 0.45 }, { transform: T(c.dx, c.dy, c.rot) }, ], { duration: 540 / s, delay: ((i % 2) * 40) / s, easing: "cubic-bezier(.45,0,.3,1)", fill: "forwards" } ).finished; }) ); // 3. Deal out, in the new order, arcing over the board. nextOrder.forEach((id, k) => (clones.get(id)!.el.style.zIndex = String(nextOrder.length - k))); await sleep(140 / s); await Promise.all( nextOrder.map((id, k) => sleep((k * 60) / s).then(() => { const c = clones.get(id)!; const t = targets.get(id) ?? { x: c.x, y: c.y }; c.el.style.zIndex = String(100 + k); c.el.style.boxShadow = LIFT; sfx.flick(0.14); return c.el .animate( [ { transform: T(c.dx, c.dy, c.rot) }, { transform: T((c.dx + t.x) / 2, Math.min(c.dy, t.y) - 40, c.rot / 3), offset: 0.45 }, { transform: T(t.x, t.y) }, ], { duration: 420 / s, easing: "cubic-bezier(.25,.8,.3,1)", fill: "forwards" } ) .finished.then(() => { c.el.style.boxShadow = ""; sfx.tick(0.07); }); }) ) ); sfx.chime(); setDealing(false); await frame(); fx.replaceChildren(); setBusy(false); }; const applySorts = (next: SortRule[]) => { if (busy) return; const change = () => setConfig((c) => ({ ...c, sorts: next })); if (next.length) setAnnounce(`Sorted by ${next.map((s) => sortLabel(s.field)).join(", then ")}`); if (layout === "board" && next.length && JSON.stringify(next) !== JSON.stringify(config.sorts)) void rearrange(change); else change(); }; /** Deal again by the current sort, or by priority when the view is in manual order. */ const onDeal = () => { if (busy) return; if (sorted) void rearrange(() => {}); else applySorts([{ field: "priority", dir: "desc" }]); }; const onSortColumn = (f: SortField) => { const cur = config.sorts[0]; applySorts( cur?.field === f ? [{ field: f, dir: cur.dir === "asc" ? "desc" : "asc" }] : [{ field: f, dir: SORT_FIELDS.find((s) => s.field === f)!.dir }] ); }; /* --------------------------- pointer drag --------------------------- */ const [drag, setDragState] = useState<DragState | null>(null); const dragRef = useRef<DragState | null>(null); const setDrag = (d: DragState | null) => { dragRef.current = d; setDragState(d); }; const [drop, setDrop] = useState<Drop | null>(null); const dropRef = useRef<Drop | null>(null); const settling = useRef(false); const suppressClick = useRef(false); const pointer = useRef({ x: 0, y: 0 }); const gx = useMotionValue(0); const gy = useMotionValue(0); const vx = useVelocity(gx); const tiltTarget = useTransform(vx, [-1600, 0, 1600], [-7, 0, 7], { clamp: true }); const tilt = useSpring(tiltTarget, { stiffness: 320, damping: 26 }); const setDropBoth = (d: Drop | null) => { const cur = dropRef.current; if (cur && d && cur.group === d.group && cur.lane === d.lane && cur.before === d.before) return; dropRef.current = d; setDrop(d); }; /** In a sorted view a card lands where the sort puts it, so the slot shows that place, not the pointer's. */ const beforeFor = (card: Card, g: Group, l: Group, fallback: string | null) => { if (!sorted) return fallback; const moved = { ...card, ...g.patch, ...l.patch }; const list = sortCards([...cellCards(g, l).filter((c) => c.id !== card.id), moved], config.sorts, bc); return list[list.indexOf(moved) + 1]?.id ?? null; }; const commitMove = (id: string, g: Group, l: Group, before: string | null, snapshot: Card) => { setBoard((b) => ({ ...b, cards: placeCard(b.cards, id, { ...g.patch, ...l.patch }, before, (c) => g.match(c) && l.match(c)).map((c) => c.id === id ? describeChange(snapshot, c, b) : c ), })); }; const hitTest = (x: number, y: number, card: Card) => { const root = rootRef.current; if (!root) return; const cells = Array.from(root.querySelectorAll<HTMLElement>("[data-cell]")); let cell = cells.find((el) => { const r = el.getBoundingClientRect(); return x >= r.left && x <= r.right && (lanes.length === 1 || (y >= r.top && y <= r.bottom)); }); if (!cell && lanes.length > 1) { let best = Infinity; for (const el of cells) { const r = el.getBoundingClientRect(); if (x < r.left || x > r.right) continue; const dist = y < r.top ? r.top - y : y - r.bottom; if (dist < best) { best = dist; cell = el; } } } if (!cell) return; const g = groups.find((k) => k.id === cell!.dataset.group); const l = lanes.find((k) => k.id === cell!.dataset.lane); if (!g || !l) return; let before: string | null = null; for (const el of Array.from(cell.querySelectorAll<HTMLElement>("[data-card-id]"))) { if (el.dataset.cardId === card.id) continue; const r = el.getBoundingClientRect(); if (y < r.top + r.height / 2) { before = el.dataset.cardId!; break; } } setDropBoth({ group: g.id, lane: l.id, before: beforeFor(card, g, l, before) }); }; const settle = async (d: DragState, target: Drop) => { settling.current = true; const root = rootRef.current; const ph = root?.querySelector<HTMLElement>("[data-placeholder]"); if (root && ph && !reduce) { const rr = root.getBoundingClientRect(); const r = ph.getBoundingClientRect(); await Promise.all([ animate(gx, r.left - rr.left, { duration: 0.2, ease: EASE_OUT }), animate(gy, r.top - rr.top, { duration: 0.2, ease: EASE_OUT }), ]); } const card = board.cards.find((c) => c.id === d.id); const g = groups.find((k) => k.id === target.group); const l = lanes.find((k) => k.id === target.lane); if (card && g && l) { commitMove(d.id, g, l, target.before, card); const becameDone = (g.patch.columnId ?? l.patch.columnId) === DONE_ID && card.columnId !== DONE_ID; if (becameDone) sfx.chime(); if (target.group !== d.origin.group || target.lane !== d.origin.lane) { setAnnounce(`${card.key} moved to ${g.name}${lanes.length > 1 ? `, ${l.name}` : ""}`); } } sfx.thud(0.2); setDrag(null); setDropBoth(null); settling.current = false; }; const onCardPointerDown = (e: ReactPointerEvent<HTMLDivElement>, card: Card) => { if (busy || settling.current || e.button !== 0 || grab) return; if ((e.target as HTMLElement).closest("[data-card-link]")) return; const el = e.currentTarget; const startX = e.clientX; const startY = e.clientY; const touch = e.pointerType === "touch"; let started = false; let holdTimer: number | undefined; suppressClick.current = false; const start = (x: number, y: number) => { const root = rootRef.current; if (!root) return; started = true; suppressClick.current = true; const r = el.getBoundingClientRect(); const rr = root.getBoundingClientRect(); const { g, l } = cellOf(card); const list = cellCards(g, l); const idx = list.findIndex((c) => c.id === card.id); const origin = { group: g.id, lane: l.id, before: list[idx + 1]?.id ?? null }; gx.jump(r.left - rr.left); gy.jump(r.top - rr.top); setDrag({ id: card.id, w: r.width, h: r.height, ox: x - r.left, oy: y - r.top, origin }); dropRef.current = origin; setDrop(origin); document.body.style.userSelect = "none"; sfx.tick(0.1); }; const preventTouchScroll = (ev: TouchEvent) => { if (started) ev.preventDefault(); }; const move = (ev: PointerEvent) => { pointer.current = { x: ev.clientX, y: ev.clientY }; const dist = Math.hypot(ev.clientX - startX, ev.clientY - startY); if (!started) { if (touch) { // On touch a quick move is a scroll; a press-and-hold is a drag. if (dist > 8) cleanup(); return; } if (dist < 5) return; start(ev.clientX, ev.clientY); } const root = rootRef.current; if (!root) return; const rr = root.getBoundingClientRect(); const d = dragRef.current; if (d) { gx.set(ev.clientX - rr.left - d.ox); gy.set(ev.clientY - rr.top - d.oy); } hitTest(ev.clientX, ev.clientY, card); }; const up = () => { cleanup(); if (!started) return; document.body.style.userSelect = ""; const d = dragRef.current; if (d && dropRef.current) void settle(d, dropRef.current); }; const key = (ev: KeyboardEvent) => { if (ev.key !== "Escape" || !started) return; ev.preventDefault(); cleanup(); document.body.style.userSelect = ""; const d = dragRef.current; if (d) { setDropBoth(d.origin); requestAnimationFrame(() => void settle(d, d.origin)); } }; function cleanup() { window.clearTimeout(holdTimer); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); window.removeEventListener("keydown", key); window.removeEventListener("touchmove", preventTouchScroll); } window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", up); window.addEventListener("keydown", key); window.addEventListener("touchmove", preventTouchScroll, { passive: false }); if (touch) holdTimer = window.setTimeout(() => start(startX, startY), 280); }; // Edge auto-scroll while dragging: the board sideways, and the page (or the frame) up and down. useEffect(() => { if (!drag) return; let raf = 0; const EDGE = 40; const tickScroll = () => { const { x, y } = pointer.current; const sc = scrollerRef.current; if (sc) { const r = sc.getBoundingClientRect(); if (x < r.left + EDGE) sc.scrollLeft -= Math.ceil((r.left + EDGE - x) / 8); else if (x > r.right - EDGE) sc.scrollLeft += Math.ceil((x - (r.right - EDGE)) / 8); if (y < r.top + EDGE && y > r.top - EDGE) sc.scrollTop -= Math.ceil((r.top + EDGE - y) / 8); else if (y > r.bottom - EDGE && y < r.bottom + EDGE) sc.scrollTop += Math.ceil((y - (r.bottom - EDGE)) / 8); } const page = scrollParentOf(rootRef.current); const vr = viewportOf(rootRef.current); const dy = y < vr.top + EDGE ? -Math.ceil((vr.top + EDGE - y) / 6) : y > vr.bottom - EDGE ? Math.ceil((y - (vr.bottom - EDGE)) / 6) : 0; if (dy) { if (page) page.scrollTop += dy; else window.scrollBy(0, dy); } raf = requestAnimationFrame(tickScroll); }; raf = requestAnimationFrame(tickScroll); return () => cancelAnimationFrame(raf); }, [drag]); /* -------------------------- keyboard drag -------------------------- */ const [grab, setGrab] = useState<{ id: string; origin: Drop; snapshot: Card } | null>(null); const focusId = useRef<string | null>(null); useEffect(() => { if (!focusId.current) return; rootRef.current ?.querySelector<HTMLElement>(`[data-cell] [data-card-id="${focusId.current}"]`) ?.focus({ preventScroll: false }); focusId.current = null; }); const onCardKey = (e: ReactKeyboardEvent<HTMLDivElement>, card: Card) => { if (busy) return; const { g, l } = cellOf(card); const gi = groups.indexOf(g); const list = cellCards(g, l); const idx = list.findIndex((c) => c.id === card.id); const focusCard = (id: string) => rootRef.current?.querySelector<HTMLElement>(`[data-cell] [data-card-id="${id}"]`)?.focus(); const where = (tg: Group) => `${tg.name}${lanes.length > 1 ? `, ${l.name}` : ""}`; if (!grab) { if (e.key === "Enter") { e.preventDefault(); setOpenId(card.id); } else if (e.key === " ") { e.preventDefault(); setGrab({ id: card.id, origin: { group: g.id, lane: l.id, before: list[idx + 1]?.id ?? null }, snapshot: card }); sfx.tick(0.1); setAnnounce(`Picked up ${card.key}. Arrow keys to move, Space to drop, Escape to cancel.`); } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const n = list[idx + (e.key === "ArrowDown" ? 1 : -1)]; if (n) focusCard(n.id); } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") { e.preventDefault(); const step = e.key === "ArrowRight" ? 1 : -1; for (let j = gi + step; j >= 0 && j < groups.length; j += step) { const target = cellCards(groups[j], l); const n = target[Math.min(idx, target.length - 1)]; if (n) { focusCard(n.id); break; } } } return; } // Moves apply straight to the board and are not animated: a keyboard // action repeated down a column should land instantly. const place = (tg: Group, before: string | null, msg: string) => { setBoard((b) => ({ ...b, cards: placeCard(b.cards, card.id, { ...tg.patch, ...l.patch }, before, (c) => tg.match(c) && l.match(c)), })); focusId.current = card.id; setAnnounce(msg); }; if ((e.key === "ArrowUp" || e.key === "ArrowDown") && sorted) { e.preventDefault(); setAnnounce("This view is sorted, so the order in a column follows the sort."); } else if (e.key === "ArrowUp" && idx > 0) { e.preventDefault(); place(g, list[idx - 1].id, `${where(g)}, position ${idx} of ${list.length}`); } else if (e.key === "ArrowDown" && idx < list.length - 1) { e.preventDefault(); place(g, list[idx + 2]?.id ?? null, `${where(g)}, position ${idx + 2} of ${list.length}`); } else if ((e.key === "ArrowRight" || e.key === "ArrowLeft") && groups[gi + (e.key === "ArrowRight" ? 1 : -1)]) { e.preventDefault(); const tg = groups[gi + (e.key === "ArrowRight" ? 1 : -1)]; const target = cellCards(tg, l); const at = Math.min(idx, target.length); place(tg, beforeFor(card, tg, l, target[at]?.id ?? null), where(tg)); } else if (e.key === " " || e.key === "Enter" || e.key === "Tab") { if (e.key !== "Tab") e.preventDefault(); const snap = grab.snapshot; setGrab(null); sfx.thud(0.2); setBoard((b) => ({ ...b, cards: b.cards.map((c) => (c.id === card.id ? describeChange(snap, c, b) : c)) })); if (card.columnId === DONE_ID && snap.columnId !== DONE_ID) sfx.chime(); setAnnounce(`Dropped ${card.key} in ${where(g)}`); } else if (e.key === "Escape") { e.preventDefault(); const snap = grab.snapshot; const og = groups.find((k) => k.id === grab.origin.group) ?? g; const ol = lanes.find((k) => k.id === grab.origin.lane) ?? l; setBoard((b) => ({ ...b, cards: placeCard( b.cards, card.id, { columnId: snap.columnId, priority: snap.priority, assigneeId: snap.assigneeId }, grab.origin.before, (c) => og.match(c) && ol.match(c) ), })); focusId.current = card.id; setGrab(null); setAnnounce(`Move cancelled. ${card.key} is back in ${og.name}`); } }; /* ------------------------------ render ------------------------------ */ const openCard = openId ? board.cards.find((c) => c.id === openId) : undefined; const dragCard = drag ? board.cards.find((c) => c.id === drag.id) : undefined; const layoutOn = !reduce && !dealing && !grab; const progress = total ? doneCount / total : 0; const newCard = (columnId = board.columns[0]?.id, extra: Partial<Card> = {}) => { if (!columnId) return; setCreating(blankCard(board, columnId, "", extra)); }; const renderCard = (card: Card) => ( <CardView card={card} bc={bc} fields={fields} helpId={helpId} grabbed={grab?.id === card.id} hidden={dealing} highlighted={hoverLink === card.id || flash === card.id} onPointerDown={(e) => onCardPointerDown(e, card)} onOpen={() => { if (suppressClick.current) { suppressClick.current = false; return; } if (!busy && !grab) setOpenId(card.id); }} onKeyDown={(e) => onCardKey(e, card)} onJump={jumpTo} onHoverLink={setHoverLink} /> ); const groupColor = (g: Group) => g.column?.color ?? "#a8a29e"; const renderCell = (g: Group, l: Group, framed: boolean) => { const key = `${g.id}|${l.id}`; const cards = cellCards(g, l).filter((c) => c.id !== drag?.id); const isDrop = !!drag && drop?.group === g.id && drop?.lane === l.id; const bi = isDrop && drop!.before ? cards.findIndex((c) => c.id === drop!.before) : -1; const phAt = isDrop ? (bi < 0 ? cards.length : bi) : -1; const columnId = g.patch.columnId ?? l.patch.columnId ?? board.columns[0]?.id; return ( <motion.div key={key} layoutScroll data-cell data-group={g.id} data-lane={l.id} style={laneVars(groupColor(g))} className={cn( "flex min-h-16 flex-col gap-2", framed ? cn( "w-[280px] shrink-0 rounded-xl border p-2 transition-colors duration-150", isDrop ? "border-[color:var(--col-line)] bg-[var(--col-drop)]" : "border-stone-200/80 bg-stone-100/60 dark:border-stone-800/80 dark:bg-stone-900/40" ) : "px-2 pb-2" )} > {cards.map((card, i) => ( <CardSlot key={card.id} layoutOn={layoutOn}> {i === phAt && <Placeholder h={drag!.h} />} {renderCard(card)} </CardSlot> ))} {phAt === cards.length && <Placeholder h={drag!.h} />} {cards.length === 0 && !isDrop && adding !== key && ( <p className="rounded-xl border border-dashed border-stone-300 px-3 py-5 text-center text-xs text-stone-500 dark:border-stone-700"> {filtering ? "Nothing matches here" : "Drop a card here"} </p> )} {adding === key ? ( <QuickAdd onClose={() => setAdding(null)} onAdd={(title) => columnId && insertCard(blankCard(board, columnId, title, { ...g.patch, ...l.patch, columnId }))} /> ) : ( <button type="button" onClick={() => setAdding(key)} disabled={busy} className={cn( "flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-2 text-xs text-stone-500 transition-colors duration-150 hover:bg-stone-200/60 hover:text-stone-900 dark:hover:bg-stone-800 dark:hover:text-stone-100", FOCUS )} > <Plus aria-hidden className="h-3.5 w-3.5" /> Add card </button> )} </motion.div> ); }; const renderHeader = (g: Group) => { const col = g.column; const count = board.cards.filter(g.match).length; const over = col?.limit !== undefined && count > col.limit; const shown = display.filter(g.match); const sum = config.sum === "estimate" ? shown.reduce((s, c) => s + (c.estimate ?? 0), 0) : null; return ( <div className="flex shrink-0 items-center gap-2 px-3 pb-2 pt-3"> {col ? ( <ColorPicker value={col.color} onChange={(hex) => recolor.column(col.id, hex)} label={`${col.name} colour`} swatches={swatches} triggerClassName={cn( "h-4 w-4 shrink-0 rounded-full ring-1 ring-inset ring-black/15 transition-shadow duration-150 hover:ring-2 hover:ring-black/25 dark:ring-white/25 dark:hover:ring-white/40", FOCUS )} /> ) : g.priority !== undefined ? ( <PriorityGlyph priority={g.priority} /> ) : g.person ? ( <Initials person={g.person} size="xs" /> ) : ( <span aria-hidden className="h-6 w-6 shrink-0 rounded-full border border-dashed border-stone-400 dark:border-stone-600" /> )} {col && renaming === col.id ? ( <input autoFocus defaultValue={col.name} aria-label="Column name" maxLength={24} onBlur={(e) => { const v = e.target.value.trim(); if (v) setBoard((b) => ({ ...b, columns: b.columns.map((c) => (c.id === col.id ? { ...c, name: v } : c)) })); setRenaming(null); }} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") setRenaming(null); }} className={cn(INPUT, "h-7 min-w-0 flex-1 px-2 text-sm font-medium")} /> ) : ( <h2 className="min-w-0 truncate text-sm font-semibold text-stone-900 dark:text-stone-100" onDoubleClick={col ? () => setRenaming(col.id) : undefined} > {g.name} </h2> )} <span className={cn( "inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 text-[11px] font-medium tabular-nums", over ? "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-400" : "bg-stone-200/70 text-stone-600 dark:bg-stone-800 dark:text-stone-400" )} title={col?.limit !== undefined ? `Limit ${col.limit}` : undefined} > {count} {col?.limit !== undefined && <span className="opacity-70">/{col.limit}</span>} {over && <span className="sr-only">, over the limit</span>} </span> {sum !== null && <span className="shrink-0 text-xs tabular-nums text-stone-500">{sum} pts</span>} <span className="flex-1" /> {lanes.length === 1 && ( <button type="button" aria-label={`Add card to ${g.name}`} onClick={() => setAdding(`${g.id}|${ALL_LANE.id}`)} className={cn(ICON_BTN, "h-7 w-7")} disabled={busy} > <Plus aria-hidden className="h-4 w-4" /> </button> )} {col && ( <ColumnMenu column={col} count={count} canDelete={board.columns.length > 1} onRename={() => setRenaming(col.id)} onDelete={() => deleteColumn(col.id)} /> )} </div> ); }; const columnClass = (isDrop: boolean) => cn( "relative flex w-[280px] shrink-0 flex-col rounded-2xl border transition-[background-color,border-color] duration-150", "bg-[linear-gradient(to_bottom,var(--col-wash),transparent_140px)] dark:bg-[linear-gradient(to_bottom,var(--col-wash-dark),transparent_140px)]", isDrop ? "border-[color:var(--col-line)] bg-[var(--col-drop)] dark:bg-[var(--col-drop)]" : "border-stone-200/80 dark:border-stone-800/80", "bg-stone-100/60 dark:bg-stone-900/40" ); const addColumnLane = config.columnBy === "status" && ( <div className="w-[280px] shrink-0"> {addingColumn ? ( <NewColumnForm swatches={swatches} onCancel={() => setAddingColumn(false)} onCreate={(name, color) => { createColumn(name, color); setAddingColumn(false); requestAnimationFrame(() => scrollerRef.current?.scrollTo({ left: scrollerRef.current.scrollWidth, behavior: reduce ? "auto" : "smooth" }) ); }} /> ) : ( <button type="button" onClick={() => setAddingColumn(true)} disabled={busy} className={cn( "flex h-12 w-full items-center justify-center gap-1.5 rounded-2xl border border-dashed border-stone-300 text-sm text-stone-600 transition-colors duration-150 hover:border-stone-400 hover:bg-white/60 hover:text-stone-900 dark:border-stone-700 dark:text-stone-400 dark:hover:border-stone-600 dark:hover:bg-stone-900/60 dark:hover:text-stone-100", FOCUS )} > <Plus aria-hidden className="h-4 w-4" /> Add column </button> )} </div> ); const boardBody = lanes.length === 1 ? ( <motion.div ref={scrollerRef} layoutScroll data-lenis-prevent className="flex flex-1 items-start gap-3 overflow-x-auto overscroll-x-contain px-5 pb-5" aria-busy={busy} > <AnimatePresence initial={false}> {groups.map((g) => ( <motion.section key={g.id} layout={layoutOn ? "position" : false} initial={reduce ? { opacity: 0 } : { opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, transition: { duration: 0.12 } }} transition={{ duration: 0.22, ease: EASE_OUT }} data-col-id={g.id} aria-label={`${g.name}, ${cellCards(g, ALL_LANE).length} cards`} style={laneVars(groupColor(g))} className={columnClass(!!drag && drop?.group === g.id)} > {renderHeader(g)} {renderCell(g, ALL_LANE, false)} </motion.section> ))} </AnimatePresence> {addColumnLane} </motion.div> ) : ( <motion.div ref={scrollerRef} layoutScroll data-lenis-prevent className="flex-1 overflow-x-auto overscroll-x-contain px-5 pb-5" aria-busy={busy} > <div className="inline-flex min-w-full flex-col gap-3"> <div className="flex gap-3 pb-1"> {groups.map((g) => ( <div key={g.id} style={laneVars(groupColor(g))} className={cn(columnClass(false), "rounded-xl")}> {renderHeader(g)} </div> ))} </div> {lanes.map((l) => { const open = !collapsed.has(l.id); const n = display.filter(l.match).length; // A filtered view hides the lanes it emptied; an unfiltered one keeps every lane as a drop target. if (filtering && n === 0) return null; return ( <section key={l.id} aria-label={`${l.name}, ${n} cards`}> <button type="button" aria-expanded={open} onClick={() => setCollapsed((s) => { const next = new Set(s); if (next.has(l.id)) next.delete(l.id); else next.add(l.id); return next; }) } className={cn( "sticky left-0 mb-2 inline-flex h-8 items-center gap-2 rounded-md px-1 text-sm font-semibold text-stone-800 dark:text-stone-200", FOCUS )} > <ChevronDown aria-hidden className={cn("h-4 w-4 text-stone-500 transition-transform duration-150", !open && "-rotate-90")} /> {l.column && <Dot color={l.column.color} />} {l.priority !== undefined && <PriorityGlyph priority={l.priority} />} {l.person && <Initials person={l.person} size="xs" />} {l.name} <span className="font-normal tabular-nums text-stone-500">{n}</span> </button> {open && <div className="flex gap-3">{groups.map((g) => renderCell(g, l, true))}</div>} </section> ); })} </div> </motion.div> ); return ( <div ref={rootRef} className={cn( geist.className, geistMono.variable, forced === "dark" && "dark", "relative isolate flex min-h-full flex-col overflow-x-clip" )} data-light={forced === "light" ? "" : undefined} > <div className="relative flex min-h-0 flex-1 flex-col bg-stone-50 text-stone-900 [background-image:radial-gradient(rgba(28,25,23,0.07)_1px,transparent_1px)] [background-size:20px_20px] dark:bg-stone-950 dark:text-stone-100 dark:[background-image:radial-gradient(rgba(255,255,255,0.05)_1px,transparent_1px)]"> {/* Header */} <header className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-3 border-b border-stone-200 bg-white/80 px-5 py-3 dark:border-stone-800 dark:bg-stone-900/70"> <div className="flex min-w-0 items-center gap-3"> <svg viewBox="0 0 28 28" className="h-7 w-7 shrink-0" aria-hidden> <rect x="7" y="3" width="16" height="21" rx="3.5" className="fill-emerald-700/25 dark:fill-emerald-400/25" transform="rotate(9 15 14)" /> <rect x="5" y="4" width="16" height="21" rx="3.5" className="fill-emerald-700" /> <rect x="8.5" y="9" width="9" height="2" rx="1" className="fill-white/85" /> <rect x="8.5" y="13" width="6" height="2" rx="1" className="fill-white/55" /> </svg> <div className="min-w-0"> <div className="flex items-center gap-1.5 text-xs text-stone-500 dark:text-stone-500"> <span>Harbor</span> <span aria-hidden>/</span> <span>Sprint 14</span> </div> <h1 className="truncate text-base font-semibold tracking-tight text-stone-900 dark:text-stone-100"> Ferry app, autumn release </h1> </div> </div> <div className={cn("items-center gap-3", compact ? "hidden" : "flex")} aria-label="Sprint progress"> <div className="h-1.5 w-28 overflow-hidden rounded-full bg-stone-200 dark:bg-stone-800"> <motion.div className="h-full origin-left rounded-full bg-emerald-700" initial={false} animate={{ scaleX: progress }} transition={{ duration: 0.4, ease: EASE_OUT }} /> </div> <span className="text-xs tabular-nums text-stone-600 dark:text-stone-400"> {doneCount} of {total} done <span className="text-stone-500"> · 6 days left</span> </span> </div> <span className="flex-1" /> {!compact && <AvatarGroup people={PEOPLE} maxVisible={4} />} <div className="flex items-center gap-1"> <button type="button" onClick={() => setSound((v) => !v)} aria-pressed={sound} aria-label={sound ? "Sound on" : "Sound off"} title={sound ? "Sound on" : "Sound off"} className={ICON_BTN} > {sound ? <Volume2 aria-hidden className="h-4 w-4" /> : <VolumeX aria-hidden className="h-4 w-4" />} </button> <button type="button" onClick={() => setForced(dark ? "light" : "dark")} aria-label={dark ? "Light board theme" : "Dark board theme"} title={dark ? "Light board theme" : "Dark board theme"} className={ICON_BTN} > <AnimatePresence mode="popLayout" initial={false}> <motion.span key={dark ? "moon" : "sun"} initial={reduce ? { opacity: 0 } : { opacity: 0, rotate: -45, scale: 0.8 }} animate={{ opacity: 1, rotate: 0, scale: 1 }} exit={reduce ? { opacity: 0 } : { opacity: 0, rotate: 45, scale: 0.8 }} transition={{ duration: 0.2, ease: EASE_OUT }} className="inline-flex" > {dark ? <Moon aria-hidden className="h-4 w-4" /> : <Sun aria-hidden className="h-4 w-4" />} </motion.span> </AnimatePresence> </button> </div> <button type="button" onClick={() => setInsights((v) => !v)} aria-pressed={insights} className={cn( GHOST, insights && "border-emerald-700 bg-emerald-500/10 text-emerald-800 hover:bg-emerald-500/15 dark:border-emerald-400 dark:text-emerald-300" )} aria-label="Insights" > <ChartLine aria-hidden className="h-4 w-4" /> {!compact && <span>Insights</span>} </button> <button type="button" onClick={() => setImporting(true)} className={GHOST} disabled={busy} aria-label="Import from GitHub" > <GithubMark className="h-4 w-4" /> {!compact && <span>Import</span>} </button> <button type="button" onClick={() => newCard()} className={PRIMARY} disabled={busy}> <Plus aria-hidden className="h-4 w-4" /> New card </button> </header> {/* Tabs and toolbar stay pinned while the board scrolls under them. The strip inherits the board's ground colour rather than naming its own pair, so it can never end up on the other theme. */} <div className="sticky top-0 z-20 bg-inherit"> <ViewTabs views={views} activeId={insights ? "" : view.id} dirty={dirtyIds} configOf={configOf} onSelect={(id) => { setActiveView(id); setInsights(false); }} onNew={newView} onRename={(id, name) => setViews((vs) => vs.map((v) => (v.id === id ? { ...v, name } : v)))} onDuplicate={duplicateView} onDelete={deleteView} /> {/* Toolbar */} <div className="flex shrink-0 items-center gap-2 px-5 py-3"> <FilterBar value={config.query} onChange={(q) => setConfig((c) => ({ ...c, query: q }))} bc={bc} hasRepos={hasRepos} count={visible.length} total={total} /> {layout === "board" && !insights && ( <button type="button" onClick={onDeal} disabled={busy || visible.length === 0} title={sorted ? "Gather the cards and deal them again" : "Gather the cards and deal them back by priority"} className={cn(GHOST, "h-9 px-3")} > <Layers aria-hidden className="h-4 w-4" /> {!compact && "Deal"} </button> )} {!insights && ( <ViewMenu config={config} setConfig={setConfig} setSorts={applySorts} dirty={dirty} onSave={saveView} onDiscard={() => dropDraft(view.id)} hasRepos={hasRepos} /> )} </div> </div> {insights ? ( <InsightsView cards={visible} bc={bc} /> ) : layout === "table" ? ( <TableView rows={display} lanes={lanes} bc={bc} config={config} onSortColumn={onSortColumn} onOpen={setOpenId} onAdd={(title, lane) => { const first = board.columns[0]?.id; if (first) insertCard(blankCard(board, lane.patch.columnId ?? first, title, lane.patch)); }} /> ) : layout === "roadmap" ? ( <RoadmapView rows={display} lanes={lanes} bc={bc} onOpen={setOpenId} onDates={(id, start, due) => patchCard(id, { start, due })} /> ) : ( boardBody )} </div> {/* Drag ghost: follows the pointer, tilts with horizontal speed. */} {drag && dragCard && ( <motion.div aria-hidden className="pointer-events-none absolute left-0 top-0 z-50" style={{ x: gx, y: gy, rotate: reduce ? 0 : tilt, width: drag.w }} > <motion.div initial={{ scale: 1 }} animate={{ scale: reduce ? 1 : 1.03 }} transition={{ duration: 0.16, ease: EASE_OUT }} className="rounded-xl shadow-[0_18px_36px_-12px_rgba(28,25,23,0.35),0_2px_6px_rgba(28,25,23,0.08)] dark:shadow-[0_18px_36px_-12px_rgba(0,0,0,0.6)]" > <CardView card={dragCard} bc={bc} fields={fields} /> </motion.div> </motion.div> )} {/* Deal layer: WAAPI clones fly here while a sort plays out. */} <div ref={fxRef} aria-hidden className="pointer-events-none absolute inset-0 z-50 overflow-hidden" /> {/* Overlays, pinned to the visible part of the board however far it has scrolled. */} <div className="pointer-events-none absolute inset-0 z-30"> <div className="pointer-events-none sticky top-0 overflow-hidden" style={{ height: viewportH ?? "100dvh" }}> <AnimatePresence> {(openCard || creating || importing) && ( <motion.div key="scrim" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.14 } }} transition={{ duration: 0.2 }} className="pointer-events-auto absolute inset-0 z-30 bg-stone-950/25 dark:bg-black/50" onClick={() => { setOpenId(null); setCreating(null); setImporting(false); }} /> )} {openCard && ( <CardSheet key="sheet" card={openCard} board={board} bc={bc} swatches={swatches} patch={(p) => patchCard(openCard.id, p)} createLabel={createLabel} createColumn={createColumn} recolor={recolor} onDelete={() => deleteCard(openCard.id)} onClose={() => setOpenId(null)} onOpenCard={setOpenId} onAddChild={(title) => addChild(openCard.id, title)} onLink={(childId) => patchCard(childId, { parentId: openCard.id })} onUnlink={(childId) => patchCard(childId, { parentId: null })} /> )} {importing && ( <GithubImportDialog key="github" board={board} onImport={onImport} onClose={() => setImporting(false)} /> )} {creating && ( <CreateDialog key="create" draft={creating} setDraft={(fn) => setCreating((d) => (d ? fn(d) : d))} board={board} swatches={swatches} createLabel={createLabel} createColumn={createColumn} recolor={recolor} onClose={() => setCreating(null)} onCreate={() => { insertCard({ ...creating, title: creating.title.trim() }); setCreating(null); }} /> )} </AnimatePresence> </div> </div> <p id={helpId} className="sr-only"> Enter opens the card. Space picks it up, arrow keys move it, Space drops it, Escape cancels. </p> <div aria-live="polite" className="sr-only"> {announce} </div> </div> );}function CardSlot({ layoutOn, children }: { layoutOn: boolean; children: ReactNode }) { return ( <motion.div layout={layoutOn ? "position" : false} initial={{ opacity: 0, y: 6, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0 }} transition={{ type: "spring", duration: 0.35, bounce: 0.12 }} className="flex flex-col gap-2" > {children} </motion.div> );}function Placeholder({ h }: { h: number }) { return ( <motion.div data-placeholder layout="position" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.12 }} style={{ height: h }} className="shrink-0 rounded-xl border-2 border-dashed border-[color:var(--col-line)] bg-[var(--col-drop)]" /> );}PressCtrl+Cto copy