feat: harden kanban responsiveness and visual system

This commit is contained in:
zenchantlive 2026-02-11 19:01:34 -08:00
parent ce2010fd92
commit 75cc86e259
14 changed files with 239 additions and 55 deletions

View file

@ -21,11 +21,22 @@ const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot
closed: { label: 'Done', dot: 'bg-emerald-300' },
};
const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = {
open: 'bg-sky-500/10',
in_progress: 'bg-amber-500/10',
blocked: 'bg-rose-500/10',
deferred: 'bg-slate-500/10',
closed: 'bg-emerald-500/10',
};
export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) {
return (
<section className="grid min-w-[980px] grid-cols-5 gap-3 xl:min-w-0 xl:grid-cols-5">
<section className="flex min-w-fit snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain pb-2">
{KANBAN_STATUSES.map((status) => (
<div key={status} className="rounded-2xl border border-border-soft bg-surface-muted/55 p-2.5">
<div
key={status}
className={`w-[clamp(17rem,24vw,22rem)] shrink-0 snap-start rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5`}
>
<div className="mb-2 flex items-center justify-between">
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
@ -33,7 +44,7 @@ export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardP
</strong>
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
</div>
<div className="grid gap-2">
<div className="grid h-[clamp(24rem,60vh,48rem)] content-start gap-2 overflow-y-auto pr-1">
<AnimatePresence initial={false}>
{columns[status].map((issue) => (
<KanbanCard key={issue.id} issue={issue} selected={selectedIssueId === issue.id} onSelect={onSelect} />

View file

@ -15,22 +15,22 @@ interface KanbanCardProps {
function priorityClass(priority: number): string {
switch (priority) {
case 0:
return 'border-rose-300/50 bg-rose-400/20 text-rose-100';
return 'border-rose-300/45 bg-rose-500/20 text-rose-50';
case 1:
return 'border-amber-300/40 bg-amber-400/20 text-amber-100';
return 'border-amber-300/40 bg-amber-500/20 text-amber-50';
case 2:
return 'border-sky-300/40 bg-sky-400/20 text-sky-100';
return 'border-sky-300/40 bg-sky-500/20 text-sky-50';
case 3:
return 'border-slate-300/35 bg-slate-400/20 text-slate-100';
return 'border-slate-300/35 bg-slate-500/22 text-slate-50';
default:
return 'border-slate-400/35 bg-slate-500/20 text-slate-100';
return 'border-slate-400/35 bg-slate-600/20 text-slate-50';
}
}
export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
const selectedClass = selected
? 'border-cyan-300/70 bg-surface-raised/95 shadow-card'
: 'border-border-soft bg-surface/90 hover:border-border-strong hover:bg-surface-raised/90';
? 'border-cyan-300/80 bg-surface-raised shadow-card ring-1 ring-cyan-300/35'
: 'border-border-soft bg-surface/95 shadow-[0_6px_18px_rgba(4,8,17,0.5)] hover:border-border-strong hover:bg-surface-raised/95';
return (
<motion.button
@ -40,16 +40,18 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
onClick={() => onSelect(issue)}
className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass}`}
>
<div className="font-mono text-[11px] text-text-muted">{issue.id}</div>
<div className="mt-1 text-sm font-semibold leading-5 text-text-strong">{issue.title}</div>
<div className="font-mono text-[11px] text-text-muted break-all">{issue.id}</div>
<div className="mt-1 text-sm font-semibold leading-5 text-text-strong break-words">{issue.title}</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<span className={`inline-flex items-center rounded-full border px-2 py-1 font-mono text-[11px] font-semibold ${priorityClass(issue.priority)}`}>
<span
className={`inline-flex items-center rounded-full border px-2 py-1 font-mono text-[11px] font-semibold ${priorityClass(issue.priority)}`}
>
P{issue.priority}
</span>
<Chip>{issue.issue_type}</Chip>
<Chip tone="status">deps {issue.dependencies.length}</Chip>
</div>
<div className="mt-2 truncate font-mono text-xs text-cyan-100/80">
<div className="mt-2 break-words font-mono text-xs text-cyan-100/90">
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
</div>
{issue.labels.length > 0 ? (

View file

@ -14,22 +14,22 @@ interface KanbanControlsProps {
export function KanbanControls({ filters, stats, onFiltersChange }: KanbanControlsProps) {
const inputClass =
'rounded-xl border border-border-soft bg-surface-muted/65 px-3 py-2 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-cyan-300/60 focus:ring-2 focus:ring-cyan-300/25';
'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-cyan-300/70 focus:ring-2 focus:ring-cyan-300/20';
return (
<section className="grid gap-3">
<motion.div layout className="flex flex-wrap gap-2.5">
<motion.div layout className="grid grid-cols-1 gap-2.5 sm:flex sm:flex-wrap sm:items-center">
<input
type="search"
value={filters.query ?? ''}
onChange={(event) => onFiltersChange({ ...filters, query: event.target.value })}
placeholder="Search by id/title/labels"
className={`${inputClass} min-w-60 flex-1`}
className={`${inputClass} w-full sm:min-w-[18rem] sm:flex-1`}
/>
<select
value={filters.type ?? ''}
onChange={(event) => onFiltersChange({ ...filters, type: event.target.value })}
className={`${inputClass} w-40`}
className={`${inputClass} w-full sm:w-44`}
aria-label="Type filter"
>
<option value="">All types</option>
@ -42,7 +42,7 @@ export function KanbanControls({ filters, stats, onFiltersChange }: KanbanContro
<select
value={filters.priority ?? ''}
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value })}
className={`${inputClass} w-32`}
className={`${inputClass} w-full sm:w-36`}
aria-label="Priority filter"
>
<option value="">All priorities</option>
@ -52,7 +52,7 @@ export function KanbanControls({ filters, stats, onFiltersChange }: KanbanContro
<option value="3">P3</option>
<option value="4">P4</option>
</select>
<label className="inline-flex items-center gap-2 rounded-xl border border-border-soft bg-surface-muted/60 px-3 py-2 text-sm text-text-body">
<label className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-border-soft bg-surface-muted/60 px-3 py-2 text-sm text-text-body sm:w-auto sm:justify-start">
<input
type="checkbox"
checked={filters.showClosed ?? false}

View file

@ -8,9 +8,12 @@ import { Chip } from '../shared/chip';
interface KanbanDetailProps {
issue: BeadIssue | null;
framed?: boolean;
}
export function KanbanDetail({ issue }: KanbanDetailProps) {
export function KanbanDetail({ issue, framed = true }: KanbanDetailProps) {
const frameClass = framed ? 'rounded-2xl border border-border-soft bg-surface/90 p-4 shadow-panel' : 'p-1';
return (
<AnimatePresence mode="wait" initial={false}>
{issue ? (
@ -20,16 +23,16 @@ export function KanbanDetail({ issue }: KanbanDetailProps) {
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 24 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="rounded-2xl border border-border-soft bg-surface/90 p-4 shadow-panel"
className={frameClass}
>
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-mono text-xs text-text-muted">{issue.id}</div>
<h2 className="mt-1 text-xl font-semibold text-text-strong">{issue.title}</h2>
<div className="font-mono text-xs text-text-muted break-all">{issue.id}</div>
<h2 className="mt-1 text-lg font-semibold leading-7 text-text-strong sm:text-xl">{issue.title}</h2>
</div>
<Chip tone="status">{issue.status}</Chip>
</div>
{issue.description ? <p className="mt-3 text-sm leading-6 text-text-body">{issue.description}</p> : null}
{issue.description ? <p className="mt-3 text-sm leading-6 text-text-body break-words">{issue.description}</p> : null}
<div className="mt-3 flex flex-wrap gap-1.5">
<Chip tone="priority">priority {issue.priority}</Chip>
<Chip>{issue.issue_type}</Chip>
@ -39,11 +42,11 @@ export function KanbanDetail({ issue }: KanbanDetailProps) {
<dl className="mt-4 grid gap-1.5 text-sm text-text-body">
<div>
<dt className="inline font-semibold text-text-strong">Created:</dt>{' '}
<dd className="inline">{issue.created_at || '-'}</dd>
<dd className="inline break-all">{issue.created_at || '-'}</dd>
</div>
<div>
<dt className="inline font-semibold text-text-strong">Updated:</dt>{' '}
<dd className="inline">{issue.updated_at || '-'}</dd>
<dd className="inline break-all">{issue.updated_at || '-'}</dd>
</div>
<div>
<dt className="inline font-semibold text-text-strong">Closed:</dt>{' '}
@ -65,7 +68,7 @@ export function KanbanDetail({ issue }: KanbanDetailProps) {
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 12 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="rounded-2xl border border-border-soft bg-surface/80 p-4"
className={framed ? 'rounded-2xl border border-border-soft bg-surface/80 p-4' : 'p-1'}
>
<strong className="text-text-strong">Details</strong>
<p className="mt-1 text-sm text-text-muted">Select a card to inspect full issue details.</p>

View file

@ -23,6 +23,7 @@ export function KanbanPage({ issues }: KanbanPageProps) {
showClosed: false,
});
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(issues[0]?.id ?? null);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const filteredIssues = useMemo(() => filterKanbanIssues(issues, filters), [issues, filters]);
const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]);
@ -34,25 +35,57 @@ export function KanbanPage({ issues }: KanbanPageProps) {
);
return (
<main className="mx-auto min-h-screen max-w-[1680px] px-4 py-4 sm:px-6 sm:py-6">
<header className="mb-4 rounded-2xl border border-border-soft bg-surface/70 px-4 py-4 backdrop-blur md:px-5">
<p className="font-mono text-xs uppercase tracking-[0.14em] text-cyan-200/80">BeadBoard</p>
<main className="mx-auto min-h-screen max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6">
<header className="mb-4 rounded-2xl border border-border-soft bg-surface/90 px-4 py-4 shadow-card backdrop-blur md:px-5">
<p className="font-mono text-xs uppercase tracking-[0.14em] text-cyan-100/80">BeadBoard</p>
<h1 className="mt-1 text-2xl font-semibold text-text-strong sm:text-3xl">Kanban Dashboard</h1>
<p className="mt-2 text-sm text-text-muted">Tracer Bullet 1 from live `.beads/issues.jsonl` on Windows-native paths.</p>
</header>
<KanbanControls filters={filters} stats={stats} onFiltersChange={setFilters} />
<section className="mt-3 grid grid-cols-1 gap-3 xl:grid-cols-[minmax(0,1fr)_360px]">
<motion.div layout className="overflow-x-auto rounded-2xl border border-border-soft bg-surface/55 p-2">
<section className="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,24rem)] xl:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
<motion.div layout className="overflow-x-auto rounded-2xl border border-border-soft bg-surface/80 p-2.5 shadow-card">
<KanbanBoard
columns={columns}
selectedIssueId={selectedIssue?.id ?? null}
onSelect={(issue) => setSelectedIssueId(issue.id)}
onSelect={(issue) => {
setSelectedIssueId(issue.id);
setMobileDetailOpen(true);
}}
/>
</motion.div>
<div className="xl:sticky xl:top-4 xl:self-start">
<div className="hidden lg:sticky lg:top-4 lg:block lg:self-start">
<KanbanDetail issue={selectedIssue} />
</div>
</section>
{mobileDetailOpen && selectedIssue ? (
<div className="fixed inset-0 z-40 lg:hidden">
<button
type="button"
className="absolute inset-0 bg-black/55"
aria-label="Close details"
onClick={() => setMobileDetailOpen(false)}
/>
<motion.div
initial={{ y: 36, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 36, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-x-3 bottom-3 top-20 overflow-y-auto rounded-2xl border border-border-soft bg-surface/95 p-3 shadow-panel"
>
<div className="mb-2 flex justify-end">
<button
type="button"
onClick={() => setMobileDetailOpen(false)}
className="rounded-lg border border-border-soft bg-surface-muted/70 px-3 py-1 text-xs font-semibold text-text-body"
>
Close
</button>
</div>
<KanbanDetail issue={selectedIssue} framed={false} />
</motion.div>
</div>
) : null}
</main>
);
}

View file

@ -6,9 +6,9 @@ interface ChipProps {
}
const CHIP_TONE_CLASS: Record<NonNullable<ChipProps['tone']>, string> = {
default: 'border-border-soft bg-surface-muted/70 text-text-body',
status: 'border-cyan-300/25 bg-cyan-400/15 text-cyan-100',
priority: 'border-amber-300/25 bg-amber-400/15 text-amber-100',
default: 'border-border-soft bg-surface-muted/75 text-text-body',
status: 'border-cyan-300/30 bg-cyan-500/20 text-cyan-50',
priority: 'border-amber-300/30 bg-amber-500/20 text-amber-50',
};
export function Chip({ children, tone = 'default' }: ChipProps) {

View file

@ -8,7 +8,7 @@ export function StatPill({ label, value, tone = 'default' }: StatPillProps) {
const valueToneClass = tone === 'critical' ? 'text-rose-300' : 'text-text-strong';
return (
<div className="min-w-20 rounded-xl border border-border-soft bg-surface-muted/65 px-3 py-2">
<div className="min-w-[5.25rem] rounded-xl border border-border-soft bg-surface-muted/72 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-muted">{label}</div>
<div className={`mt-0.5 text-lg font-semibold ${valueToneClass}`}>{value}</div>
</div>