feat: establish tokenized kanban design foundation

This commit is contained in:
zenchantlive 2026-02-11 18:38:51 -08:00
parent d82452b89c
commit ce2010fd92
18 changed files with 1544 additions and 162 deletions

View file

@ -1,5 +1,7 @@
'use client';
import { AnimatePresence } from 'framer-motion';
import { KANBAN_STATUSES } from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
@ -11,26 +13,32 @@ interface KanbanBoardProps {
onSelect: (issue: BeadIssue) => void;
}
const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot: string }> = {
open: { label: 'Open', dot: 'bg-sky-300' },
in_progress: { label: 'In Progress', dot: 'bg-amber-300' },
blocked: { label: 'Blocked', dot: 'bg-rose-300' },
deferred: { label: 'Deferred', dot: 'bg-slate-300' },
closed: { label: 'Done', dot: 'bg-emerald-300' },
};
export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) {
return (
<section
style={{
display: 'grid',
gridTemplateColumns: 'repeat(5, minmax(220px, 1fr))',
gap: '0.8rem',
overflowX: 'auto',
}}
>
<section className="grid min-w-[980px] grid-cols-5 gap-3 xl:min-w-0 xl:grid-cols-5">
{KANBAN_STATUSES.map((status) => (
<div key={status} style={{ background: '#edf2f7', borderRadius: 14, padding: '0.65rem', minHeight: 320 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.55rem' }}>
<strong style={{ fontSize: '0.85rem', color: '#1f2937' }}>{status}</strong>
<span style={{ fontSize: '0.8rem', color: '#475569' }}>{columns[status].length}</span>
<div key={status} className="rounded-2xl border border-border-soft bg-surface-muted/55 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}`} />
{STATUS_META[status].label}
</strong>
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
</div>
<div style={{ display: 'grid', gap: '0.55rem' }}>
{columns[status].map((issue) => (
<KanbanCard key={issue.id} issue={issue} selected={selectedIssueId === issue.id} onSelect={onSelect} />
))}
<div className="grid gap-2">
<AnimatePresence initial={false}>
{columns[status].map((issue) => (
<KanbanCard key={issue.id} issue={issue} selected={selectedIssueId === issue.id} onSelect={onSelect} />
))}
</AnimatePresence>
</div>
</div>
))}

View file

@ -1,5 +1,7 @@
'use client';
import { motion } from 'framer-motion';
import type { BeadIssue } from '../../lib/types';
import { Chip } from '../shared/chip';
@ -10,38 +12,53 @@ interface KanbanCardProps {
onSelect: (issue: BeadIssue) => void;
}
function priorityClass(priority: number): string {
switch (priority) {
case 0:
return 'border-rose-300/50 bg-rose-400/20 text-rose-100';
case 1:
return 'border-amber-300/40 bg-amber-400/20 text-amber-100';
case 2:
return 'border-sky-300/40 bg-sky-400/20 text-sky-100';
case 3:
return 'border-slate-300/35 bg-slate-400/20 text-slate-100';
default:
return 'border-slate-400/35 bg-slate-500/20 text-slate-100';
}
}
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';
return (
<button
<motion.button
layout
transition={{ duration: 0.18, ease: 'easeOut' }}
type="button"
onClick={() => onSelect(issue)}
style={{
width: '100%',
textAlign: 'left',
border: selected ? '2px solid #0f766e' : '1px solid #d7dee8',
borderRadius: 12,
padding: '0.7rem',
background: '#ffffff',
cursor: 'pointer',
}}
className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass}`}
>
<div style={{ fontSize: '0.74rem', color: '#5e6b7a' }}>{issue.id}</div>
<div style={{ fontWeight: 700, color: '#0f1720', margin: '0.15rem 0 0.5rem' }}>{issue.title}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginBottom: '0.45rem' }}>
<Chip>P{issue.priority}</Chip>
<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="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)}`}>
P{issue.priority}
</span>
<Chip>{issue.issue_type}</Chip>
<Chip>deps {issue.dependencies.length}</Chip>
<Chip tone="status">deps {issue.dependencies.length}</Chip>
</div>
<div style={{ fontSize: '0.8rem', color: '#314152' }}>
<div className="mt-2 truncate font-mono text-xs text-cyan-100/80">
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
</div>
{issue.labels.length > 0 ? (
<div style={{ marginTop: '0.45rem', display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
<div className="mt-2 flex flex-wrap gap-1.5">
{issue.labels.slice(0, 3).map((label) => (
<Chip key={`${issue.id}-${label}`}>#{label}</Chip>
))}
</div>
) : null}
</button>
</motion.button>
);
}

View file

@ -1,5 +1,7 @@
'use client';
import { motion } from 'framer-motion';
import type { KanbanFilterOptions, KanbanStats } from '../../lib/kanban';
import { StatPill } from '../shared/stat-pill';
@ -11,49 +13,63 @@ 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';
return (
<section style={{ display: 'grid', gap: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.6rem' }}>
<section className="grid gap-3">
<motion.div layout className="flex flex-wrap gap-2.5">
<input
type="search"
value={filters.query ?? ''}
onChange={(event) => onFiltersChange({ ...filters, query: event.target.value })}
placeholder="Search by id/title/labels"
style={{ flex: 1, minWidth: 260, borderRadius: 10, border: '1px solid #cbd5e1', padding: '0.5rem 0.6rem' }}
className={`${inputClass} min-w-60 flex-1`}
/>
<input
type="text"
<select
value={filters.type ?? ''}
onChange={(event) => onFiltersChange({ ...filters, type: event.target.value })}
placeholder="Type (task/bug/feature)"
style={{ width: 190, borderRadius: 10, border: '1px solid #cbd5e1', padding: '0.5rem 0.6rem' }}
/>
<input
type="number"
min={0}
max={4}
className={`${inputClass} w-40`}
aria-label="Type filter"
>
<option value="">All types</option>
<option value="task">Task</option>
<option value="bug">Bug</option>
<option value="feature">Feature</option>
<option value="epic">Epic</option>
<option value="chore">Chore</option>
</select>
<select
value={filters.priority ?? ''}
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value })}
placeholder="Priority"
style={{ width: 110, borderRadius: 10, border: '1px solid #cbd5e1', padding: '0.5rem 0.6rem' }}
/>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.45rem', color: '#334155', fontSize: '0.9rem' }}>
className={`${inputClass} w-32`}
aria-label="Priority filter"
>
<option value="">All priorities</option>
<option value="0">P0</option>
<option value="1">P1</option>
<option value="2">P2</option>
<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">
<input
type="checkbox"
checked={filters.showClosed ?? false}
onChange={(event) => onFiltersChange({ ...filters, showClosed: event.target.checked })}
className="h-4 w-4 accent-cyan-400"
/>
Show closed
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.6rem' }}>
</motion.div>
<motion.div layout className="flex flex-wrap gap-2">
<StatPill label="Total" value={stats.total} />
<StatPill label="Open" value={stats.open} />
<StatPill label="Active" value={stats.active} />
<StatPill label="Blocked" value={stats.blocked} />
<StatPill label="Done" value={stats.done} />
<StatPill label="P0" value={stats.p0} />
</div>
<StatPill label="P0" value={stats.p0} tone={stats.p0 > 0 ? 'critical' : 'default'} />
</motion.div>
</section>
);
}

View file

@ -1,5 +1,7 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import type { BeadIssue } from '../../lib/types';
import { Chip } from '../shared/chip';
@ -9,42 +11,66 @@ interface KanbanDetailProps {
}
export function KanbanDetail({ issue }: KanbanDetailProps) {
if (!issue) {
return (
<aside style={{ border: '1px solid #d7dee8', borderRadius: 14, padding: '0.9rem', background: '#ffffff' }}>
<strong style={{ color: '#0f1720' }}>Details</strong>
<p style={{ color: '#475569' }}>Select a card to inspect full issue details.</p>
</aside>
);
}
return (
<aside style={{ border: '1px solid #d7dee8', borderRadius: 14, padding: '0.9rem', background: '#ffffff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'start' }}>
<div>
<div style={{ fontSize: '0.76rem', color: '#475569' }}>{issue.id}</div>
<h2 style={{ margin: '0.1rem 0 0.3rem', fontSize: '1.2rem', color: '#0f1720' }}>{issue.title}</h2>
</div>
<Chip>{issue.status}</Chip>
</div>
{issue.description ? <p style={{ color: '#334155' }}>{issue.description}</p> : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.5rem' }}>
<Chip>priority {issue.priority}</Chip>
<Chip>{issue.issue_type}</Chip>
<Chip>{issue.assignee ? `@${issue.assignee}` : 'unassigned'}</Chip>
<Chip>{issue.dependencies.length} dependencies</Chip>
</div>
<div style={{ fontSize: '0.84rem', color: '#334155' }}>
<div><strong>Created:</strong> {issue.created_at || '-'}</div>
<div><strong>Updated:</strong> {issue.updated_at || '-'}</div>
</div>
{issue.labels.length > 0 ? (
<div style={{ marginTop: '0.6rem', display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{issue.labels.map((label) => (
<Chip key={`${issue.id}-${label}`}>#{label}</Chip>
))}
</div>
) : null}
</aside>
<AnimatePresence mode="wait" initial={false}>
{issue ? (
<motion.aside
key={issue.id}
initial={{ opacity: 0, x: 24 }}
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"
>
<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>
<Chip tone="status">{issue.status}</Chip>
</div>
{issue.description ? <p className="mt-3 text-sm leading-6 text-text-body">{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>
<Chip>{issue.assignee ? `@${issue.assignee}` : 'unassigned'}</Chip>
<Chip>{issue.dependencies.length} dependencies</Chip>
</div>
<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>
</div>
<div>
<dt className="inline font-semibold text-text-strong">Updated:</dt>{' '}
<dd className="inline">{issue.updated_at || '-'}</dd>
</div>
<div>
<dt className="inline font-semibold text-text-strong">Closed:</dt>{' '}
<dd className="inline">{issue.closed_at || '-'}</dd>
</div>
</dl>
{issue.labels.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-1.5">
{issue.labels.map((label) => (
<Chip key={`${issue.id}-${label}`}>#{label}</Chip>
))}
</div>
) : null}
</motion.aside>
) : (
<motion.aside
key="empty-detail"
initial={{ opacity: 0, x: 12 }}
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"
>
<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>
</motion.aside>
)}
</AnimatePresence>
);
}

View file

@ -1,5 +1,6 @@
'use client';
import { motion } from 'framer-motion';
import { useMemo, useState } from 'react';
import type { KanbanFilterOptions } from '../../lib/kanban';
@ -33,27 +34,24 @@ export function KanbanPage({ issues }: KanbanPageProps) {
);
return (
<main
style={{
minHeight: '100vh',
padding: '1.2rem',
background: 'linear-gradient(140deg, #f8fafc, #edf3f7 50%, #f8fbf9)',
color: '#0f1720',
fontFamily: 'Segoe UI, system-ui, sans-serif',
}}
>
<header style={{ marginBottom: '0.9rem' }}>
<h1 style={{ margin: 0, fontSize: '1.9rem' }}>BeadBoard Kanban</h1>
<p style={{ margin: '0.35rem 0 0', color: '#475569' }}>Tracer Bullet 1 baseline from live `.beads/issues.jsonl`</p>
<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>
<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 style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 320px', gap: '0.9rem', marginTop: '0.9rem' }}>
<KanbanBoard
columns={columns}
selectedIssueId={selectedIssue?.id ?? null}
onSelect={(issue) => setSelectedIssueId(issue.id)}
/>
<KanbanDetail issue={selectedIssue} />
<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">
<KanbanBoard
columns={columns}
selectedIssueId={selectedIssue?.id ?? null}
onSelect={(issue) => setSelectedIssueId(issue.id)}
/>
</motion.div>
<div className="xl:sticky xl:top-4 xl:self-start">
<KanbanDetail issue={selectedIssue} />
</div>
</section>
</main>
);

View file

@ -2,21 +2,18 @@ import type { ReactNode } from 'react';
interface ChipProps {
children: ReactNode;
tone?: 'default' | 'status' | 'priority';
}
export function Chip({ children }: 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',
};
export function Chip({ children, tone = 'default' }: ChipProps) {
return (
<span
style={{
border: '1px solid #d7dee8',
borderRadius: 999,
padding: '0.2rem 0.55rem',
fontSize: '0.75rem',
fontWeight: 600,
color: '#1f2a38',
background: '#f7fafc',
}}
>
<span className={`inline-flex items-center rounded-full border px-2 py-1 text-[11px] font-semibold ${CHIP_TONE_CLASS[tone]}`}>
{children}
</span>
);

View file

@ -1,21 +1,16 @@
interface StatPillProps {
label: string;
value: number;
tone?: 'default' | 'critical';
}
export function StatPill({ label, value }: StatPillProps) {
export function StatPill({ label, value, tone = 'default' }: StatPillProps) {
const valueToneClass = tone === 'critical' ? 'text-rose-300' : 'text-text-strong';
return (
<div
style={{
border: '1px solid #d7dee8',
borderRadius: 12,
padding: '0.6rem 0.8rem',
background: '#ffffff',
minWidth: 90,
}}
>
<div style={{ fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#5e6b7a' }}>{label}</div>
<div style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f1720' }}>{value}</div>
<div className="min-w-20 rounded-xl border border-border-soft bg-surface-muted/65 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>
);
}