feat: harden kanban responsiveness and visual system
This commit is contained in:
parent
ce2010fd92
commit
75cc86e259
14 changed files with 239 additions and 55 deletions
|
|
@ -18,6 +18,12 @@
|
|||
{"id":"bb-92d.5","title":"Implement Windows path normalization utilities","description":"Create centralized helpers for canonical path keys, display formatting, and cross-drive normalization to avoid duplicate project identities.","acceptance_criteria":"Canonicalization is consistent for C:\\ and D:\\ style paths.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:46.0751161-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:27:27.7164974-08:00","closed_at":"2026-02-11T17:27:27.7164974-08:00","close_reason":"Implemented Windows path normalization utilities with canonicalization, keying, and display transformations.","labels":["paths","windows"],"dependencies":[{"issue_id":"bb-92d.5","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:46.0767429-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-92d.6","title":"Add guardrail test preventing direct writes to .beads/issues.jsonl","description":"Enforce read/write boundary by scanning source for forbidden direct file write patterns targeting Beads issue files.","acceptance_criteria":"Guardrail test fails on boundary violations and passes when write path uses bd bridge only.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:46.9013352-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:28:27.4699395-08:00","closed_at":"2026-02-11T17:28:27.4699395-08:00","close_reason":"Added guardrail scanner and automated test to block direct writes to .beads/issues.jsonl.","labels":["guardrail","safety"],"dependencies":[{"issue_id":"bb-92d.6","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:46.9029535-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-ag8","title":"TEMP_DELETE_ME","status":"closed","priority":4,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:10:04.5765506-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:10:10.3812634-08:00","closed_at":"2026-02-11T17:10:10.3812634-08:00","close_reason":"cleanup temp test issue"}
|
||||
{"id":"bb-bc4","title":"Kanban Responsive Design Hardening","description":"Refine tracer-bullet Kanban into a production-grade, responsive experience across mobile/tablet/desktop using tokenized Tailwind styling and strict architecture boundaries. Scope includes layout reachability, card/column sizing integrity, improved visual language, and small-screen detail-panel behavior.","acceptance_criteria":"At 390x844, 768x1024, and 1440x900 all status columns are reachable, cards are not clipped, controls remain usable, and detail interactions work without direct JSONL write-path regressions.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:41.814041-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:21.5796629-08:00","closed_at":"2026-02-11T18:59:21.5796629-08:00","close_reason":"Responsive design hardening scope completed with tests and Playwright evidence.","labels":["design-system","kanban","responsive","ui"],"dependencies":[{"issue_id":"bb-bc4","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T18:50:41.817863-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T18:51:20.344-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bc4.1","title":"Rework board responsiveness and horizontal reachability","description":"Implement intentional responsive board behavior: fluid column sizing, explicit horizontal board scrolling strategy, and viewport-safe wrappers so every status column is reachable without layout breakage. Use relative sizing constraints and avoid rigid fixed-width assumptions.","acceptance_criteria":"Board supports reliable horizontal reachability at all target breakpoints; no hidden/unreachable status columns.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:42.8356269-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:17.3199003-08:00","closed_at":"2026-02-11T18:59:17.3199003-08:00","close_reason":"Implemented fluid horizontal board reachability with snap and overflow containment across breakpoints.","labels":["kanban","layout","responsive"],"dependencies":[{"issue_id":"bb-bc4.1","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:42.837217-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bc4.2","title":"Fix column/card sizing and overflow behavior","description":"Correct card and column sizing to prevent clipping, overflow artifacts, and unreadable metadata blocks. Ensure card internals wrap/truncate intentionally and columns maintain consistent density and scroll behavior.","acceptance_criteria":"Cards remain fully readable within columns, no clipped card content, and column internals scroll predictably.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:43.8439541-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:18.1823946-08:00","closed_at":"2026-02-11T18:59:18.1823946-08:00","close_reason":"Fixed card/column overflow and sizing with clamp-based widths, scroll-safe columns, and improved text wrapping.","labels":["cards","kanban","overflow"],"dependencies":[{"issue_id":"bb-bc4.2","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:43.8457677-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.2","depends_on_id":"bb-bc4.1","type":"blocks","created_at":"2026-02-11T18:50:43.8490043-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bc4.3","title":"Redesign tokenized theme and visual hierarchy","description":"Upgrade visual system quality using semantic tokens for surface/text/status/priority states, stronger typography hierarchy, and improved contrast. Move away from flat/basic palette while preserving clarity and performance.","acceptance_criteria":"UI theme shows clear hierarchy and contrast, aligns with premium demo quality expectations, and remains consistent across components.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:44.8548956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:19.0348391-08:00","closed_at":"2026-02-11T18:59:19.0348391-08:00","close_reason":"Redesigned semantic tokens/theme contrast and hierarchy to improve production visual quality.","labels":["design-system","theme","tokens"],"dependencies":[{"issue_id":"bb-bc4.3","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:44.8564376-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.3","depends_on_id":"bb-bc4.1","type":"blocks","created_at":"2026-02-11T18:50:44.8606805-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bc4.4","title":"Implement mobile/tablet detail panel interaction model","description":"Adapt detail panel behavior for small screens (overlay or drawer model) with safe viewport sizing, accessible dismissal, and non-destructive navigation. Desktop retains efficient side-panel behavior.","acceptance_criteria":"Detail view is usable on mobile/tablet and does not trap or obscure board interaction irrecoverably.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:45.8342573-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:19.8911935-08:00","closed_at":"2026-02-11T18:59:19.8911935-08:00","close_reason":"Implemented mobile detail overlay flow while preserving desktop sticky side-detail behavior.","labels":["detail-panel","mobile","ux"],"dependencies":[{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:45.8360334-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4.2","type":"blocks","created_at":"2026-02-11T18:51:10.0929812-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4.3","type":"blocks","created_at":"2026-02-11T18:51:10.9352149-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bc4.5","title":"Playwright multi-breakpoint visual verification","description":"Capture and review before/after screenshots at 390x844, 768x1024, and 1440x900 to validate reachability, clipping, control usability, and detail-panel behavior. Store artifacts under artifacts/ with explicit naming conventions.","acceptance_criteria":"Required six screenshots exist (before/after x 3 breakpoints) and observations confirm responsive/visual acceptance criteria.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:47.0018379-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:20.7427588-08:00","closed_at":"2026-02-11T18:59:20.7427588-08:00","close_reason":"Captured required Playwright before/after screenshots at mobile/tablet/desktop and validated layout usability.","labels":["playwright","verification","visual"],"dependencies":[{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:47.0034039-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4.4","type":"blocks","created_at":"2026-02-11T18:51:11.7817934-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4.3","type":"blocks","created_at":"2026-02-11T18:51:12.6236762-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn","title":"Dependency Graph (React Flow)","description":"Visualize issue relationships and blocked chains through an interactive graph backed by parsed dependency edges.","acceptance_criteria":"Graph renders dependencies correctly and supports navigation to issue details.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:09.2057278-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:09.2057278-08:00","labels":["graph","react-flow"],"dependencies":[{"issue_id":"bb-bvn","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:22.6642419-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn.1","title":"Parse dependency edges and build adjacency structures","description":"Extract edges for blocks, parent, relates_to, duplicates, and supersedes to support graph rendering and analysis.","acceptance_criteria":"Adjacency output is complete and consistent for all supported edge types.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.0434044-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:10.0434044-08:00","labels":["graph","parser"],"dependencies":[{"issue_id":"bb-bvn.1","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.0449367-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn.2","title":"Implement React Flow graph view with pan/zoom/select interactions","description":"Render nodes and edges with interactive navigation and issue selection integration.","acceptance_criteria":"Users can pan, zoom, and select nodes to inspect linked issue context.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.8683725-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:10.8683725-08:00","labels":["graph","ui"],"dependencies":[{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.8694189-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn.1","type":"blocks","created_at":"2026-02-11T17:12:36.8736785-08:00","created_by":"zenchantlive"}]}
|
||||
|
|
|
|||
64
package-lock.json
generated
64
package-lock.json
generated
|
|
@ -15,10 +15,12 @@
|
|||
"react-dom": "19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.21.0",
|
||||
|
|
@ -1167,6 +1169,22 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
|
|
@ -2011,6 +2029,52 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs"
|
||||
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^11.18.2",
|
||||
|
|
@ -18,10 +18,12 @@
|
|||
"react-dom": "19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.21.0",
|
||||
|
|
|
|||
31
scripts/capture-kanban.mjs
Normal file
31
scripts/capture-kanban.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { chromium } from 'playwright';
|
||||
import path from 'node:path';
|
||||
|
||||
const url = process.argv[2];
|
||||
const mode = process.argv[3];
|
||||
|
||||
if (!url || !mode) {
|
||||
console.error('Usage: node scripts/capture-kanban.mjs <url> <before|after>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const shots = [
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1440, height: 900 },
|
||||
];
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
for (const shot of shots) {
|
||||
const page = await browser.newPage({ viewport: { width: shot.width, height: shot.height } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(700);
|
||||
await page.screenshot({
|
||||
path: path.join('artifacts', `kanban-${shot.name}-${mode}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
|
@ -3,15 +3,15 @@
|
|||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-bg: #0a111f;
|
||||
--color-surface: #111c31;
|
||||
--color-surface-muted: #17263f;
|
||||
--color-surface-raised: #1b2e4a;
|
||||
--color-text-strong: #f5f8ff;
|
||||
--color-text-body: #cfdae8;
|
||||
--color-text-muted: #8da1bd;
|
||||
--color-border-soft: rgba(130, 152, 185, 0.24);
|
||||
--color-border-strong: rgba(167, 188, 218, 0.44);
|
||||
--color-bg: #090c14;
|
||||
--color-surface: #101827;
|
||||
--color-surface-muted: #192336;
|
||||
--color-surface-raised: #22314a;
|
||||
--color-text-strong: #f6f8ff;
|
||||
--color-text-body: #d8e0f1;
|
||||
--color-text-muted: #9caccc;
|
||||
--color-border-soft: rgba(145, 166, 204, 0.3);
|
||||
--color-border-strong: rgba(187, 209, 246, 0.62);
|
||||
|
||||
--status-open: #60a5fa;
|
||||
--status-progress: #fbbf24;
|
||||
|
|
@ -38,9 +38,10 @@ body {
|
|||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 15% 8%, rgba(56, 189, 248, 0.2), transparent 32%),
|
||||
radial-gradient(circle at 80% 12%, rgba(167, 139, 250, 0.16), transparent 38%),
|
||||
linear-gradient(180deg, #060c17 0%, #0a111f 40%, #0d1525 100%);
|
||||
radial-gradient(circle at 10% 12%, rgba(12, 138, 215, 0.34), transparent 36%),
|
||||
radial-gradient(circle at 84% 20%, rgba(250, 122, 91, 0.18), transparent 30%),
|
||||
radial-gradient(circle at 68% 88%, rgba(57, 189, 154, 0.14), transparent 36%),
|
||||
linear-gradient(155deg, #05070d 0%, #0b1322 42%, #121e34 100%);
|
||||
color: var(--color-text-body);
|
||||
font-family: 'Segoe UI', Inter, system-ui, sans-serif;
|
||||
font-family: 'Segoe UI', 'Aptos', Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ const config: Config = {
|
|||
},
|
||||
},
|
||||
boxShadow: {
|
||||
card: '0 6px 18px rgba(15, 23, 42, 0.08)',
|
||||
panel: '0 18px 42px rgba(15, 23, 42, 0.2)',
|
||||
card: '0 14px 36px rgba(4, 8, 17, 0.45)',
|
||||
panel: '0 24px 56px rgba(4, 8, 17, 0.58)',
|
||||
},
|
||||
borderRadius: {
|
||||
xl2: '1rem',
|
||||
|
|
|
|||
31
tests/guards/kanban-responsive-contract.test.mjs
Normal file
31
tests/guards/kanban-responsive-contract.test.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
async function read(relativePath) {
|
||||
return fs.readFile(path.join(ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('kanban board uses intentional horizontal scroll affordances', async () => {
|
||||
const board = await read('src/components/kanban/kanban-board.tsx');
|
||||
|
||||
assert.match(board, /snap-x/);
|
||||
assert.match(board, /overflow-x-auto/);
|
||||
});
|
||||
|
||||
test('kanban page defines mobile detail drawer behavior', async () => {
|
||||
const page = await read('src/components/kanban/kanban-page.tsx');
|
||||
|
||||
assert.match(page, /fixed inset-0/);
|
||||
assert.match(page, /lg:hidden/);
|
||||
});
|
||||
|
||||
test('kanban controls use fluid full-width sizing on small viewports', async () => {
|
||||
const controls = await read('src/components/kanban/kanban-controls.tsx');
|
||||
|
||||
assert.match(controls, /w-full/);
|
||||
assert.match(controls, /sm:w-/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue