ui: unify aero chrome surfaces and shared hero across kanban/graph
This commit is contained in:
parent
c8d7f8eb0d
commit
e6317594b6
18 changed files with 540 additions and 995 deletions
|
|
@ -3,25 +3,47 @@
|
|||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-bg: #0b0c10;
|
||||
--color-surface: #14171f;
|
||||
--color-surface-muted: #1c212b;
|
||||
--color-surface-raised: #252b38;
|
||||
--color-text-strong: #f8fafc;
|
||||
--color-text-body: #cbd5e1;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-border-soft: rgba(255, 255, 255, 0.08);
|
||||
--color-border-strong: rgba(255, 255, 255, 0.18);
|
||||
|
||||
--aurora-blue: rgba(125, 175, 245, 0.14);
|
||||
--aurora-amber: rgba(235, 185, 125, 0.11);
|
||||
--aurora-purple: rgba(185, 125, 245, 0.08);
|
||||
/* Aero Chrome foundation tokens */
|
||||
--bg-base: #070709;
|
||||
--glass-base: rgba(18, 18, 22, 0.4);
|
||||
--edge-top: rgba(255, 255, 255, 0.12);
|
||||
--edge-bottom: rgba(0, 0, 0, 0.8);
|
||||
--edge-side: rgba(255, 255, 255, 0.04);
|
||||
--elevation-tight: 0 4px 12px -2px rgba(0, 0, 0, 0.7);
|
||||
--elevation-ambient: 0 16px 32px -8px rgba(0, 0, 0, 0.95);
|
||||
|
||||
--status-open: #38bdf8;
|
||||
--status-rdy-glow: rgba(74, 222, 128, 0.9);
|
||||
--status-rdy-bg: rgba(74, 222, 128, 0.15);
|
||||
--status-blk-glow: rgba(248, 113, 113, 0.9);
|
||||
--status-blk-bg: rgba(248, 113, 113, 0.15);
|
||||
--status-wip-glow: rgba(96, 165, 250, 0.9);
|
||||
--status-wip-bg: rgba(96, 165, 250, 0.15);
|
||||
--status-wait-glow: rgba(160, 160, 180, 0.7);
|
||||
|
||||
/* Typography pairing */
|
||||
--font-ui-stack: var(--font-ui), 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono-stack: var(--font-ui), 'Segoe UI', system-ui, sans-serif;
|
||||
|
||||
/* Compatibility tokens consumed by existing components */
|
||||
--color-bg: var(--bg-base);
|
||||
--color-surface: rgba(32, 35, 45, 0.85);
|
||||
--color-surface-muted: rgba(40, 44, 55, 0.8);
|
||||
--color-surface-raised: rgba(52, 58, 72, 0.82);
|
||||
--color-text-strong: #ffffff;
|
||||
--color-text-body: #d1d1d6;
|
||||
--color-text-muted: #9494a0;
|
||||
--color-border-soft: rgba(255, 255, 255, 0.1);
|
||||
--color-border-strong: rgba(255, 255, 255, 0.22);
|
||||
|
||||
--aurora-blue: rgba(96, 165, 250, 0.12);
|
||||
--aurora-amber: rgba(251, 191, 36, 0.1);
|
||||
--aurora-purple: rgba(129, 140, 248, 0.08);
|
||||
|
||||
--status-open: #60a5fa;
|
||||
--status-progress: #fbbf24;
|
||||
--status-blocked: #f43f5e;
|
||||
--status-deferred: #94a3b8;
|
||||
--status-closed: #10b981;
|
||||
--status-blocked: #f87171;
|
||||
--status-deferred: #a3a3b0;
|
||||
--status-closed: #4ade80;
|
||||
|
||||
--priority-p0: #f43f5e;
|
||||
--priority-p1: #f59e0b;
|
||||
|
|
@ -42,36 +64,63 @@ body {
|
|||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 10% 10%, var(--aurora-blue), transparent 40%),
|
||||
radial-gradient(circle at 90% 10%, var(--aurora-amber), transparent 40%),
|
||||
radial-gradient(circle at 50% 90%, var(--aurora-purple), transparent 50%),
|
||||
#0b0c10;
|
||||
radial-gradient(circle at 15% 15%, rgba(60, 80, 120, 0.08) 0%, transparent 35%),
|
||||
radial-gradient(circle at 85% 20%, rgba(100, 80, 140, 0.06) 0%, transparent 35%),
|
||||
radial-gradient(circle at 50% 95%, rgba(50, 70, 100, 0.06) 0%, transparent 40%),
|
||||
linear-gradient(180deg, rgba(20, 22, 30, 0.98) 0%, rgba(10, 11, 14, 0.99) 100%);
|
||||
background-color: var(--bg-base);
|
||||
color: var(--color-text-body);
|
||||
font-family: 'DM Sans', 'Segoe UI', Inter, system-ui, sans-serif;
|
||||
font-family: var(--font-ui-stack);
|
||||
letter-spacing: -0.011em;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
|
||||
background-size: 2rem 2rem;
|
||||
pointer-events: none;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.25) transparent;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.35) rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(156, 163, 175, 0.55), rgba(107, 114, 128, 0.45));
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
background: linear-gradient(180deg, rgba(186, 194, 209, 0.72), rgba(124, 136, 156, 0.62));
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
|
|
@ -99,24 +148,53 @@ body {
|
|||
|
||||
|
||||
.workflow-card {
|
||||
border: 1px solid var(--color-border-soft);
|
||||
background: linear-gradient(165deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
||||
box-shadow: 0 4px 24px -2px rgba(0, 0, 0, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-top-color: rgba(255, 255, 255, 0.24);
|
||||
border-bottom-color: rgba(0, 0, 0, 0.9);
|
||||
background: linear-gradient(180deg, rgba(42, 44, 52, 0.6) 0%, rgba(22, 23, 28, 0.6) 100%);
|
||||
box-shadow:
|
||||
var(--elevation-ambient),
|
||||
var(--elevation-tight),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(24px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(120%);
|
||||
transform: translateZ(0);
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
.workflow-card-selected {
|
||||
border-color: rgba(56, 189, 248, 0.4);
|
||||
background: linear-gradient(165deg, rgba(56, 189, 248, 0.12), rgba(15, 23, 42, 0.8));
|
||||
box-shadow: 0 12px 32px -4px rgba(0, 0, 0, 0.45), inset 0 1px 1px rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(96, 165, 250, 0.42);
|
||||
border-top-color: rgba(96, 165, 250, 0.58);
|
||||
background: linear-gradient(180deg, rgba(60, 68, 88, 0.7) 0%, rgba(35, 40, 52, 0.7) 100%);
|
||||
box-shadow:
|
||||
0 20px 48px -8px rgba(0, 0, 0, 0.9),
|
||||
0 0 0 1px rgba(96, 165, 250, 0.25),
|
||||
0 0 40px rgba(96, 165, 250, 0.15),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: var(--glass-base);
|
||||
border: 1px solid var(--edge-side);
|
||||
border-top-color: var(--edge-top);
|
||||
border-bottom-color: var(--edge-bottom);
|
||||
box-shadow: var(--elevation-ambient);
|
||||
backdrop-filter: blur(24px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(120%);
|
||||
}
|
||||
|
||||
/* Shared dark form controls to avoid white-on-white browser defaults */
|
||||
.ui-field {
|
||||
border: 1px solid var(--color-border-soft);
|
||||
background: linear-gradient(160deg, rgba(28, 33, 43, 0.9), rgba(20, 23, 31, 0.92));
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--edge-top);
|
||||
border-bottom-color: var(--edge-bottom);
|
||||
background: linear-gradient(180deg, rgba(32, 34, 42, 0.72), rgba(17, 19, 26, 0.72));
|
||||
color: var(--color-text-strong);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
box-shadow:
|
||||
0 8px 20px -12px rgba(0, 0, 0, 0.85),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.ui-field::placeholder {
|
||||
|
|
@ -125,9 +203,9 @@ body {
|
|||
|
||||
.ui-field:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-border-strong);
|
||||
border-color: rgba(96, 165, 250, 0.48);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(125, 175, 245, 0.12),
|
||||
0 0 0 2px rgba(96, 165, 250, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
|
|
@ -141,6 +219,20 @@ body {
|
|||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.ui-text {
|
||||
font-family: var(--font-ui-stack);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.system-data {
|
||||
font-family: var(--font-mono-stack);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 450;
|
||||
letter-spacing: 0.015em;
|
||||
}
|
||||
|
||||
|
||||
.workflow-graph-legend {
|
||||
backdrop-filter: blur(12px);
|
||||
|
|
@ -185,6 +277,14 @@ body {
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.workflow-graph-flow .react-flow__edge-text {
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-mono-stack);
|
||||
paint-order: stroke;
|
||||
stroke: rgba(2, 6, 23, 0.95);
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
/* Node selection pulse animation - sky-blue ring expands and fades */
|
||||
@keyframes node-select-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.4); }
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { DM_Sans, JetBrains_Mono } from 'next/font/google';
|
||||
import { Noto_Sans } from 'next/font/google';
|
||||
import type { ReactNode } from 'react';
|
||||
import './globals.css';
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
const notoSans = Noto_Sans({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-ui',
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'BeadBoard',
|
||||
description: 'Windows-native Beads dashboard',
|
||||
|
|
@ -21,7 +16,7 @@ export const metadata: Metadata = {
|
|||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${dmSans.variable} ${jetbrainsMono.variable}`}>{children}</body>
|
||||
<body className={notoSans.variable}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { DependencyFlowStrip } from './dependency-flow-strip';
|
|||
import { GraphNodeCard, type GraphNodeData } from './graph-node-card';
|
||||
import { GraphSection } from './graph-section';
|
||||
import { ProjectScopeControls } from '../shared/project-scope-controls';
|
||||
import { WorkspaceHero } from '../shared/workspace-hero';
|
||||
|
||||
import { buildGraphModel, type GraphNode } from '../../lib/graph';
|
||||
import {
|
||||
|
|
@ -133,6 +134,7 @@ export function DependencyGraphPage({
|
|||
const requestedEpicId = searchParams.get('epic');
|
||||
const requestedTaskId = searchParams.get('task');
|
||||
const requestedTab = searchParams.get('tab');
|
||||
const heroTitle = activeTab === 'dependencies' ? 'Graph' : 'Tasks';
|
||||
const kanbanHref = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (projectScopeMode !== 'single') {
|
||||
|
|
@ -560,10 +562,24 @@ export function DependencyGraphPage({
|
|||
target: dep.target,
|
||||
className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted',
|
||||
animated: linkedToSelection,
|
||||
label: 'BLOCKS',
|
||||
labelStyle: {
|
||||
fill: linkedToSelection ? '#e2e8f0' : '#cbd5e1',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
},
|
||||
labelBgPadding: [6, 3],
|
||||
labelBgBorderRadius: 999,
|
||||
labelBgStyle: {
|
||||
fill: 'rgba(2, 6, 23, 0.92)',
|
||||
stroke: linkedToSelection ? 'rgba(125, 211, 252, 0.35)' : 'rgba(251, 191, 36, 0.25)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
style: {
|
||||
stroke: linkedToSelection ? '#7dd3fc' : '#fbbf24',
|
||||
strokeWidth: linkedToSelection ? 2.5 : 1.8,
|
||||
opacity: linkedToSelection ? 1 : 0.55,
|
||||
strokeWidth: linkedToSelection ? 2.8 : 2.1,
|
||||
opacity: linkedToSelection ? 1 : 0.78,
|
||||
},
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: linkedToSelection ? '#7dd3fc' : '#fbbf24', width: 14, height: 14 },
|
||||
});
|
||||
|
|
@ -659,36 +675,34 @@ export function DependencyGraphPage({
|
|||
|
||||
return (
|
||||
<main className="mx-auto max-w-[1880px] px-4 py-4 sm:px-6 sm:py-6 lg:px-10">
|
||||
{/* Page header */}
|
||||
<header className="mb-6 rounded-3xl border border-white/5 bg-[radial-gradient(circle_at_2%_2%,rgba(56,189,248,0.12),transparent_40%),linear-gradient(170deg,rgba(15,23,42,0.92),rgba(11,12,16,0.95))] px-5 py-5 sm:px-8 sm:py-8 shadow-[0_32px_64px_-16px_rgba(0,0,0,0.6)] backdrop-blur-2xl">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-sky-400/70 font-bold">BeadBoard Workspace</p>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-strong sm:text-4xl">Workflow Explorer</h1>
|
||||
<Link href={kanbanHref} className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-[10px] font-bold text-text-body transition-all hover:bg-white/10 hover:border-white/20 sm:px-4 sm:text-xs">
|
||||
← Kanban
|
||||
</Link>
|
||||
</div>
|
||||
<p className="hidden max-w-md text-sm leading-relaxed text-text-muted/90 sm:block">
|
||||
Epic-driven dependency visualization. Drill into task relationships, triage blockers, and understand downstream impact at a glance.
|
||||
</p>
|
||||
</div>
|
||||
{activeScope ? (
|
||||
<p className="mt-3 text-xs text-text-muted/90">
|
||||
<WorkspaceHero
|
||||
eyebrow="BeadBoard Workspace"
|
||||
title={heroTitle}
|
||||
description="Epic-driven dependency visualization. Drill into task relationships, triage blockers, and understand downstream impact at a glance."
|
||||
action={(
|
||||
<Link
|
||||
href={kanbanHref}
|
||||
className="ui-text rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-[10px] font-bold text-text-body transition-all hover:bg-white/10 hover:border-white/20 sm:px-4 sm:text-xs"
|
||||
>
|
||||
← Kanban
|
||||
</Link>
|
||||
)}
|
||||
scope={activeScope ? (
|
||||
<p className="ui-text text-xs text-text-muted/90">
|
||||
Scope:{' '}
|
||||
<span className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 font-mono text-[11px] text-text-body">
|
||||
<span className="system-data rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] text-text-body">
|
||||
{activeScope.source === 'local' ? 'local workspace' : activeScope.displayPath}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
) : undefined}
|
||||
controls={(
|
||||
<ProjectScopeControls
|
||||
projectScopeKey={projectScopeKey}
|
||||
projectScopeMode={projectScopeMode}
|
||||
projectScopeOptions={projectScopeOptions}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<section className="rounded-[2.5rem] border border-white/5 bg-[linear-gradient(180deg,rgba(255,255,255,0.015),rgba(255,255,255,0.005))] shadow-2xl backdrop-blur-sm overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export function GraphSection({
|
|||
</div>
|
||||
|
||||
{/* ReactFlow graph viewport */}
|
||||
<div className="relative h-[60vh] min-h-[35rem] overflow-hidden rounded-2xl border border-white/5 bg-[radial-gradient(circle_at_50%_50%,rgba(15,23,42,0.4),rgba(5,8,15,0.8))] shadow-inner">
|
||||
<div className="relative h-[60vh] min-h-[24rem] md:min-h-[35rem] overflow-hidden rounded-2xl border border-white/5 bg-[radial-gradient(circle_at_50%_50%,rgba(15,23,42,0.4),rgba(5,8,15,0.8))] shadow-inner">
|
||||
<ReactFlow
|
||||
className="workflow-graph-flow"
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
|
|
|
|||
|
|
@ -29,10 +29,14 @@ const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot
|
|||
};
|
||||
|
||||
const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = {
|
||||
ready: 'bg-sky-500/10',
|
||||
in_progress: 'bg-amber-500/10',
|
||||
blocked: 'bg-rose-500/10',
|
||||
closed: 'bg-emerald-500/10',
|
||||
ready:
|
||||
'bg-[radial-gradient(circle_at_0%_0%,rgba(56,189,248,0.2),transparent_62%),linear-gradient(180deg,rgba(22,27,40,0.66),rgba(10,12,20,0.84))]',
|
||||
in_progress:
|
||||
'bg-[radial-gradient(circle_at_0%_0%,rgba(251,191,36,0.2),transparent_62%),linear-gradient(180deg,rgba(22,27,40,0.66),rgba(10,12,20,0.84))]',
|
||||
blocked:
|
||||
'bg-[radial-gradient(circle_at_0%_0%,rgba(244,63,94,0.2),transparent_62%),linear-gradient(180deg,rgba(22,27,40,0.66),rgba(10,12,20,0.84))]',
|
||||
closed:
|
||||
'bg-[radial-gradient(circle_at_0%_0%,rgba(16,185,129,0.2),transparent_62%),linear-gradient(180deg,rgba(22,27,40,0.66),rgba(10,12,20,0.84))]',
|
||||
};
|
||||
|
||||
export function KanbanBoard({
|
||||
|
|
@ -86,8 +90,10 @@ export function KanbanBoard({
|
|||
key={status}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => onDropLane(status, event)}
|
||||
className={`rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5 transition ${
|
||||
activeStatus === status ? 'shadow-card' : 'opacity-90'
|
||||
className={`rounded-2xl border border-white/[0.04] ${STATUS_COLUMN_CLASS[status]} p-2.5 transition shadow-[0_24px_52px_-20px_rgba(0,0,0,0.82),0_10px_26px_-14px_rgba(0,0,0,0.75),inset_0_1px_0_rgba(255,255,255,0.08)] ${
|
||||
activeStatus === status
|
||||
? 'shadow-[0_30px_62px_-18px_rgba(0,0,0,0.86),0_0_0_1px_rgba(125,211,252,0.14)]'
|
||||
: 'opacity-95'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -103,11 +109,11 @@ export function KanbanBoard({
|
|||
}}
|
||||
className="flex w-full items-center justify-between rounded-lg px-1 py-0.5 text-left"
|
||||
>
|
||||
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
|
||||
<strong className="ui-text 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>
|
||||
<span className="system-data text-xs text-text-muted">{columns[status].length}</span>
|
||||
</button>
|
||||
{activeStatus === status ? (
|
||||
<button
|
||||
|
|
@ -127,6 +133,7 @@ export function KanbanBoard({
|
|||
<KanbanCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
issues={allIssues}
|
||||
parentEpic={parentEpicByIssueId.get(issue.id) ?? null}
|
||||
graphBaseHref={graphBaseHref}
|
||||
pending={pendingIssueIds.has(issue.id)}
|
||||
|
|
@ -150,11 +157,11 @@ export function KanbanBoard({
|
|||
key={issue.id}
|
||||
type="button"
|
||||
onClick={() => handleExpandAndSelect(status, issue)}
|
||||
className="max-w-full rounded-lg border border-border-soft bg-surface-muted/60 px-2 py-1 text-left hover:border-border-strong hover:bg-surface-raised/70"
|
||||
className="max-w-full rounded-lg border border-border-soft bg-gradient-to-b from-surface-muted/50 to-surface-muted/70 px-2 py-1 text-left hover:border-border-strong hover:from-surface-raised/70 hover:to-surface-raised/90 shadow-[0_1px_3px_rgba(0,0,0,0.1)]"
|
||||
title={issue.title}
|
||||
>
|
||||
<div className="font-mono text-[10px] text-text-muted">{issue.id}</div>
|
||||
<div className="line-clamp-1 text-xs font-medium text-text-body">{issue.title}</div>
|
||||
<div className="system-data text-[10px] text-text-muted">{issue.id}</div>
|
||||
<div className="ui-text line-clamp-1 text-sm font-medium text-text-body">{issue.title}</div>
|
||||
</button>
|
||||
))}
|
||||
{columns[status].length > 6 ? (
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import Link from 'next/link';
|
|||
import { motion } from 'framer-motion';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
import { formatUpdatedRecency } from '../../lib/kanban';
|
||||
import { hasOpenBlockers } from '../../lib/kanban';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
import { Chip } from '../shared/chip';
|
||||
|
||||
interface KanbanCardProps {
|
||||
issue: BeadIssue;
|
||||
issues?: BeadIssue[];
|
||||
parentEpic?: { id: string; title: string } | null;
|
||||
graphBaseHref: string;
|
||||
selected: boolean;
|
||||
|
|
@ -20,23 +21,61 @@ interface KanbanCardProps {
|
|||
onSelect: (issue: BeadIssue) => void;
|
||||
}
|
||||
|
||||
function priorityClass(priority: number): string {
|
||||
switch (priority) {
|
||||
case 0:
|
||||
return 'border-rose-300/45 bg-rose-500/20 text-rose-50';
|
||||
case 1:
|
||||
return 'border-amber-300/40 bg-amber-500/20 text-amber-50';
|
||||
case 2:
|
||||
return 'border-teal-300/40 bg-teal-500/20 text-teal-50';
|
||||
case 3:
|
||||
return 'border-slate-300/35 bg-slate-500/22 text-slate-50';
|
||||
function statusGradient(status: string): string {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
case 'open':
|
||||
return 'bg-[linear-gradient(145deg,rgba(34,45,42,0.92)_0%,rgba(24,32,30,0.88)_50%,rgba(18,28,26,0.9)_100%)]';
|
||||
case 'in_progress':
|
||||
return 'bg-[linear-gradient(145deg,rgba(42,40,32,0.92)_0%,rgba(32,30,24,0.88)_50%,rgba(26,24,18,0.9)_100%)]';
|
||||
case 'blocked':
|
||||
return 'bg-[linear-gradient(145deg,rgba(60,24,30,0.95)_0%,rgba(45,18,24,0.9)_50%,rgba(32,12,16,0.92)_100%)]';
|
||||
case 'closed':
|
||||
return 'bg-[linear-gradient(145deg,rgba(28,30,34,0.75)_0%,rgba(22,24,28,0.72)_50%,rgba(18,20,24,0.75)_100%)] opacity-75';
|
||||
default:
|
||||
return 'border-slate-400/35 bg-slate-600/20 text-slate-50';
|
||||
return 'bg-[linear-gradient(145deg,rgba(38,40,48,0.92)_0%,rgba(28,30,36,0.88)_50%,rgba(22,24,30,0.9)_100%)]';
|
||||
}
|
||||
}
|
||||
|
||||
function statusBorder(status: string): string {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
case 'open':
|
||||
return 'border-emerald-500/20';
|
||||
case 'in_progress':
|
||||
return 'border-amber-500/20';
|
||||
case 'blocked':
|
||||
return 'border-rose-500/20';
|
||||
case 'closed':
|
||||
return 'border-rose-500/30';
|
||||
default:
|
||||
return 'border-white/[0.06]';
|
||||
}
|
||||
}
|
||||
|
||||
function statusDotColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
case 'open':
|
||||
return 'bg-emerald-400';
|
||||
case 'in_progress':
|
||||
return 'bg-amber-400';
|
||||
case 'blocked':
|
||||
return 'bg-rose-400';
|
||||
case 'closed':
|
||||
return 'bg-slate-400';
|
||||
default:
|
||||
return 'bg-slate-400';
|
||||
}
|
||||
}
|
||||
|
||||
function titleColor(status: string): string {
|
||||
return status === 'closed' ? 'text-text-muted/70' : 'text-text-strong/95';
|
||||
}
|
||||
|
||||
export function KanbanCard({
|
||||
issue,
|
||||
issues = [],
|
||||
parentEpic = null,
|
||||
graphBaseHref,
|
||||
selected,
|
||||
|
|
@ -45,13 +84,15 @@ export function KanbanCard({
|
|||
onNativeDragStart,
|
||||
onSelect,
|
||||
}: KanbanCardProps) {
|
||||
const projectName = (issue as BeadIssue & { project?: { name?: string } }).project?.name ?? null;
|
||||
const unblocksCount = new Set(
|
||||
issue.dependencies.filter((dependency) => dependency.type === 'blocks').map((dependency) => dependency.target),
|
||||
).size;
|
||||
const blockerCount = issues.length > 0 ? (hasOpenBlockers(issues, issue.id) ?
|
||||
issue.dependencies.filter(d => d.type === 'blocks').filter(d => {
|
||||
const blocker = issues.find(i => i.id === d.target);
|
||||
return blocker && blocker.status !== 'closed';
|
||||
}).length : 0) : 0;
|
||||
|
||||
const selectedClass = selected
|
||||
? 'border-amber-200/60 bg-surface-raised shadow-card ring-1 ring-amber-200/20'
|
||||
: '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';
|
||||
? 'ring-1 ring-amber-200/20 shadow-[0_24px_48px_-18px_rgba(0,0,0,0.88),0_0_26px_rgba(251,191,36,0.14)]'
|
||||
: 'shadow-[0_18px_38px_-18px_rgba(0,0,0,0.82),0_6px_18px_-10px_rgba(0,0,0,0.72)] hover:shadow-[0_24px_52px_-16px_rgba(0,0,0,0.9),0_10px_26px_-10px_rgba(0,0,0,0.78)]';
|
||||
|
||||
const graphDetailHref = parentEpic
|
||||
? (() => {
|
||||
|
|
@ -78,52 +119,44 @@ export function KanbanCard({
|
|||
onSelect(issue);
|
||||
}
|
||||
}}
|
||||
className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass} ${
|
||||
className={`w-full cursor-pointer rounded-xl border ${statusBorder(issue.status)} ${statusGradient(issue.status)} px-3.5 py-3 text-left transition duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] ${selectedClass} ${
|
||||
pending ? 'opacity-70' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-mono text-[11px] text-text-muted break-all">{issue.id}</div>
|
||||
{projectName ? (
|
||||
<div className="mt-1">
|
||||
<span className="rounded-md border border-sky-300/25 bg-sky-500/10 px-1.5 py-0.5 font-mono text-[10px] text-sky-200">
|
||||
project: {projectName}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<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)}`}
|
||||
>
|
||||
P{issue.priority}
|
||||
</span>
|
||||
<Chip>{issue.issue_type}</Chip>
|
||||
<Chip tone="status">deps {issue.dependencies.length}</Chip>
|
||||
{unblocksCount > 0 ? <Chip tone="status">Unblocks {unblocksCount}</Chip> : null}
|
||||
{/* ID row with status dot */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${statusDotColor(issue.status)} shadow-[0_0_6px_currentColor]`} />
|
||||
<span className="system-data text-[11px] text-text-muted/60">{issue.id}</span>
|
||||
</div>
|
||||
<div className="mt-2 break-words font-mono text-xs text-amber-100/90">
|
||||
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
|
||||
|
||||
{/* Title */}
|
||||
<div className={`ui-text mt-2 text-sm font-semibold leading-5 break-words ${titleColor(issue.status)}`}>
|
||||
{issue.title}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-text-muted">{formatUpdatedRecency(issue.updated_at)}</div>
|
||||
|
||||
{/* Labels/Tags row */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{issue.labels.slice(0, 3).map((label) => (
|
||||
<Chip key={`${issue.id}-${label}`}>{label}</Chip>
|
||||
))}
|
||||
{blockerCount > 0 && (
|
||||
<Chip tone="status">{blockerCount} Blocker{blockerCount > 1 ? 's' : ''}</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parentEpic ? (
|
||||
<div className="mt-2">
|
||||
<div className="mt-2.5">
|
||||
<Link
|
||||
href={graphDetailHref ?? graphBaseHref}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-sky-300/25 bg-sky-500/10 px-2 py-1 font-mono text-[11px] text-sky-200 hover:border-sky-300/45 hover:bg-sky-500/15"
|
||||
className="system-data inline-flex items-center gap-1 rounded border border-white/8 bg-white/[0.04] px-1.5 py-0.5 text-[10px] text-text-muted/80 hover:text-text-body hover:bg-white/[0.08]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
epic: {parentEpic.title}
|
||||
{parentEpic.title}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{issue.labels.length > 0 ? (
|
||||
<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}
|
||||
{pending ? <div className="mt-2 text-[11px] font-medium text-amber-200">Saving…</div> : null}
|
||||
|
||||
{pending ? <div className="ui-text mt-2 text-[11px] font-medium text-amber-200">Saving…</div> : null}
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function KanbanControls({
|
|||
<option className="ui-option" value="3">P3</option>
|
||||
<option className="ui-option" value="4">P4</option>
|
||||
</select>
|
||||
<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">
|
||||
<label className="ui-text inline-flex w-full items-center justify-center gap-2 rounded-xl border border-border-soft bg-gradient-to-b from-surface-muted/50 to-surface-muted/70 px-3 py-2 text-sm text-text-body sm:w-auto sm:justify-start shadow-[0_1px_3px_rgba(0,0,0,0.1)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showClosed ?? false}
|
||||
|
|
@ -72,7 +72,7 @@ export function KanbanControls({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onNextActionable}
|
||||
className="w-full rounded-xl border border-border-soft bg-surface-muted/70 px-3 py-2 text-sm font-semibold text-text-body transition hover:border-border-strong hover:bg-surface-raised sm:w-auto"
|
||||
className="ui-text w-full rounded-xl border border-border-soft bg-gradient-to-b from-surface-muted/60 to-surface-muted/80 px-3 py-2 text-sm font-semibold text-text-body transition hover:from-surface-muted/75 hover:to-surface-muted/90 shadow-[0_1px_3px_rgba(0,0,0,0.1)] sm:w-auto"
|
||||
>
|
||||
Next Actionable
|
||||
</button>
|
||||
|
|
@ -85,9 +85,7 @@ export function KanbanControls({
|
|||
<StatPill label="Done" value={stats.done} />
|
||||
<StatPill label="P0" value={stats.p0} tone={stats.p0 > 0 ? 'critical' : 'default'} />
|
||||
</motion.div>
|
||||
{nextActionableFeedback ? (
|
||||
<p className="text-xs text-text-muted">{nextActionableFeedback}</p>
|
||||
) : null}
|
||||
{nextActionableFeedback ? <p className="ui-text text-xs text-text-muted">{nextActionableFeedback}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -22,6 +22,7 @@ import { KanbanBoard } from './kanban-board';
|
|||
import { KanbanControls } from './kanban-controls';
|
||||
import { KanbanDetail } from './kanban-detail';
|
||||
import { ProjectScopeControls } from '../shared/project-scope-controls';
|
||||
import { WorkspaceHero } from '../shared/workspace-hero';
|
||||
|
||||
interface KanbanPageProps {
|
||||
issues: BeadIssue[];
|
||||
|
|
@ -242,34 +243,42 @@ export function KanbanPage({
|
|||
|
||||
return (
|
||||
<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-text-muted">BeadBoard</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-text-strong sm:text-3xl">Kanban Dashboard</h1>
|
||||
<Link href={graphHref} className="rounded-lg border border-border-soft bg-surface-muted/70 px-2.5 py-1 text-xs text-text-body hover:bg-surface-raised">
|
||||
<WorkspaceHero
|
||||
eyebrow="BeadBoard Workspace"
|
||||
title="Swimlanes"
|
||||
description="Epic-driven dependency visualization. Drill into task relationships, triage blockers, and understand downstream impact at a glance."
|
||||
className="mb-4"
|
||||
action={(
|
||||
<Link
|
||||
href={graphHref}
|
||||
className="ui-text rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-[10px] font-bold text-text-body transition-all hover:bg-white/10 hover:border-white/20 sm:px-4 sm:text-xs"
|
||||
>
|
||||
Open Graph
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-text-muted">Tracer Bullet 1 from live `.beads/issues.jsonl` on Windows-native paths.</p>
|
||||
{activeScope ? (
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
)}
|
||||
scope={activeScope ? (
|
||||
<p className="ui-text text-xs text-text-muted/90">
|
||||
Scope:{' '}
|
||||
<span className="rounded-md border border-border-soft bg-surface-muted/50 px-2 py-0.5 font-mono text-[11px] text-text-body">
|
||||
<span className="system-data rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] text-text-body">
|
||||
{activeScope.source === 'local' ? 'local workspace' : activeScope.displayPath}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<ProjectScopeControls
|
||||
projectScopeKey={projectScopeKey}
|
||||
projectScopeMode={projectScopeMode}
|
||||
projectScopeOptions={projectScopeOptions}
|
||||
/>
|
||||
</div>
|
||||
{!allowMutations ? (
|
||||
<p className="mt-2 text-xs text-amber-200/90">Aggregate mode is read-only. Switch to single project mode to edit status/details.</p>
|
||||
) : null}
|
||||
</header>
|
||||
) : undefined}
|
||||
controls={(
|
||||
<>
|
||||
<ProjectScopeControls
|
||||
projectScopeKey={projectScopeKey}
|
||||
projectScopeMode={projectScopeMode}
|
||||
projectScopeOptions={projectScopeOptions}
|
||||
/>
|
||||
{!allowMutations ? (
|
||||
<p className="ui-text mt-2 text-xs text-amber-200/90">
|
||||
Aggregate mode is read-only. Switch to single project mode to edit status/details.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<KanbanControls
|
||||
filters={filters}
|
||||
stats={stats}
|
||||
|
|
@ -278,10 +287,10 @@ export function KanbanPage({
|
|||
nextActionableFeedback={nextActionableFeedback}
|
||||
/>
|
||||
{mutationError ? (
|
||||
<div className="mt-3 rounded-xl border border-rose-300/40 bg-rose-950/40 px-3 py-2 text-sm text-rose-100">{mutationError}</div>
|
||||
<div className="ui-text mt-3 rounded-xl border border-rose-300/40 bg-rose-950/40 px-3 py-2 text-sm text-rose-100">{mutationError}</div>
|
||||
) : null}
|
||||
<section
|
||||
className={`mt-3 overflow-hidden rounded-2xl border border-border-soft bg-surface/82 shadow-card ${
|
||||
className={`mt-3 overflow-hidden rounded-2xl border border-white/5 bg-[linear-gradient(180deg,rgba(255,255,255,0.02),rgba(255,255,255,0.005))] shadow-[0_28px_62px_-18px_rgba(0,0,0,0.8),0_8px_24px_-10px_rgba(0,0,0,0.72)] backdrop-blur-xl ${
|
||||
showDesktopDetail ? 'lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]' : ''
|
||||
}`}
|
||||
>
|
||||
|
|
@ -303,20 +312,20 @@ export function KanbanPage({
|
|||
/>
|
||||
</motion.div>
|
||||
{showDesktopDetail ? (
|
||||
<div className="hidden border-t border-border-soft bg-surface/72 p-3 lg:block lg:border-l lg:border-t-0">
|
||||
<aside className="rounded-xl border border-border-soft bg-surface/78 p-3">
|
||||
<div className="hidden border-t border-white/5 bg-[rgba(9,13,22,0.78)] p-3 lg:block lg:border-l lg:border-t-0">
|
||||
<aside className="rounded-xl border border-white/6 bg-[linear-gradient(180deg,rgba(42,44,52,0.54),rgba(18,20,30,0.78))] p-3 shadow-[0_18px_42px_-20px_rgba(0,0,0,0.85),inset_0_1px_0_rgba(255,255,255,0.08)]">
|
||||
<div className="mb-2 flex items-center justify-end gap-2 border-b border-border-soft pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDesktopDetailMinimized(true)}
|
||||
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body"
|
||||
className="ui-text rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body"
|
||||
>
|
||||
Minimize
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIssueId(null)}
|
||||
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-muted"
|
||||
className="ui-text rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-muted"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
|
|
@ -341,7 +350,7 @@ export function KanbanPage({
|
|||
<div className="fixed inset-0 z-40 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/82 backdrop-blur-md"
|
||||
aria-label="Close details"
|
||||
onClick={() => setMobileDetailOpen(false)}
|
||||
/>
|
||||
|
|
@ -350,13 +359,13 @@ export function KanbanPage({
|
|||
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/98 p-3 shadow-panel backdrop-blur-2xl"
|
||||
className="absolute inset-x-3 bottom-3 top-20 overflow-y-auto rounded-2xl border border-border-soft bg-surface/96 p-3 shadow-panel backdrop-blur-3xl"
|
||||
>
|
||||
<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"
|
||||
className="ui-text rounded-lg border border-border-soft bg-surface-muted/70 px-3 py-1 text-xs font-semibold text-text-body"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,14 +6,18 @@ interface ChipProps {
|
|||
}
|
||||
|
||||
const CHIP_TONE_CLASS: Record<NonNullable<ChipProps['tone']>, string> = {
|
||||
default: 'border-border-soft bg-surface-muted/75 text-text-body',
|
||||
status: 'border-zinc-300/30 bg-zinc-500/20 text-zinc-100',
|
||||
priority: 'border-amber-300/30 bg-amber-500/20 text-amber-50',
|
||||
default:
|
||||
'border border-border-soft bg-gradient-to-b from-surface-muted/60 to-surface-muted/85 text-text-body shadow-[0_1px_2px_rgba(0,0,0,0.15)]',
|
||||
status: 'border border-border-soft/80 bg-gradient-to-b from-zinc-500/15 to-zinc-500/25 text-zinc-100 shadow-[0_1px_2px_rgba(0,0,0,0.12)]',
|
||||
priority:
|
||||
'border border-amber-300/25 bg-gradient-to-b from-amber-500/15 to-amber-500/25 text-amber-50 shadow-[0_1px_2px_rgba(0,0,0,0.12)]',
|
||||
};
|
||||
|
||||
export function Chip({ children, tone = 'default' }: ChipProps) {
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border px-2 py-1 text-[11px] font-semibold ${CHIP_TONE_CLASS[tone]}`}>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-lg border px-2 py-1 text-[11px] font-semibold tracking-wide ${CHIP_TONE_CLASS[tone]}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -8,9 +8,9 @@ export function StatPill({ label, value, tone = 'default' }: StatPillProps) {
|
|||
const valueToneClass = tone === 'critical' ? 'text-rose-300' : 'text-text-strong';
|
||||
|
||||
return (
|
||||
<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 className="min-w-[5.25rem] rounded-xl border border-border-soft bg-gradient-to-b from-surface-muted/55 to-surface-muted/75 px-3 py-2 shadow-[0_2px_4px_rgba(0,0,0,0.15)]">
|
||||
<div className="ui-text text-[10px] uppercase tracking-[0.16em] text-text-muted">{label}</div>
|
||||
<div className={`system-data mt-0.5 text-lg font-semibold ${valueToneClass}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
39
src/components/shared/workspace-hero.tsx
Normal file
39
src/components/shared/workspace-hero.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
interface WorkspaceHeroProps {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
scope?: ReactNode;
|
||||
controls?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WorkspaceHero({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
scope,
|
||||
controls,
|
||||
className = '',
|
||||
}: WorkspaceHeroProps) {
|
||||
return (
|
||||
<header
|
||||
className={`mb-6 rounded-3xl border border-white/5 bg-[radial-gradient(circle_at_2%_2%,rgba(56,189,248,0.12),transparent_40%),linear-gradient(170deg,rgba(15,23,42,0.92),rgba(11,12,16,0.95))] px-5 py-5 sm:px-8 sm:py-8 shadow-[0_32px_64px_-16px_rgba(0,0,0,0.6)] backdrop-blur-2xl ${className}`}
|
||||
>
|
||||
<p className="system-data text-[10px] uppercase tracking-[0.2em] text-sky-400/70 font-bold">{eyebrow}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="ui-text text-2xl font-bold tracking-tight text-text-strong sm:text-4xl">{title}</h1>
|
||||
{action}
|
||||
</div>
|
||||
<p className="ui-text hidden max-w-md text-sm leading-relaxed text-text-muted/90 sm:block">{description}</p>
|
||||
</div>
|
||||
{scope ? <div className="mt-3">{scope}</div> : null}
|
||||
{controls ? <div className="mt-3">{controls}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -49,12 +49,20 @@ function issueSort(a: BeadIssue, b: BeadIssue): number {
|
|||
return b.updated_at.localeCompare(a.updated_at);
|
||||
}
|
||||
|
||||
function hasOpenBlockers(issues: BeadIssue[], targetId: string): boolean {
|
||||
return issues.some(
|
||||
(issue) =>
|
||||
issue.status !== 'closed' &&
|
||||
issue.dependencies.some((dep) => dep.type === 'blocks' && dep.target === targetId),
|
||||
);
|
||||
export function hasOpenBlockers(issues: BeadIssue[], targetId: string): boolean {
|
||||
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
const target = issueById.get(targetId);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return target.dependencies.some((dep) => {
|
||||
if (dep.type !== 'blocks') {
|
||||
return false;
|
||||
}
|
||||
const blocker = issueById.get(dep.target);
|
||||
return blocker ? blocker.status !== 'closed' : false;
|
||||
});
|
||||
}
|
||||
|
||||
function hasQualitySignal(issue: BeadIssue): boolean {
|
||||
|
|
@ -83,12 +91,14 @@ function deriveBlockedIds(issues: BeadIssue[]): Set<string> {
|
|||
const blockedIds = new Set<string>();
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const dep of issue.dependencies) {
|
||||
if (dep.type !== 'blocks') continue;
|
||||
const blocker = issueById.get(issue.id);
|
||||
if (!blocker) continue;
|
||||
if (blocker.status === 'closed') continue;
|
||||
blockedIds.add(dep.target);
|
||||
if (issue.status === 'closed') continue;
|
||||
const hasOpenBlocker = issue.dependencies.some((dep) => {
|
||||
if (dep.type !== 'blocks') return false;
|
||||
const blocker = issueById.get(dep.target);
|
||||
return blocker ? blocker.status !== 'closed' : false;
|
||||
});
|
||||
if (hasOpenBlocker) {
|
||||
blockedIds.add(issue.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,21 +209,16 @@ export function buildBlockedByTree(
|
|||
}
|
||||
|
||||
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
const incomingByTarget = new Map<string, string[]>();
|
||||
const blockersByIssue = new Map<string, string[]>();
|
||||
for (const issue of issues) {
|
||||
for (const dep of issue.dependencies) {
|
||||
if (dep.type !== 'blocks') continue;
|
||||
const list = incomingByTarget.get(dep.target) ?? [];
|
||||
list.push(issue.id);
|
||||
incomingByTarget.set(dep.target, list);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [targetId, blockerIds] of incomingByTarget.entries()) {
|
||||
incomingByTarget.set(
|
||||
targetId,
|
||||
[...new Set(blockerIds)].sort((a, b) => a.localeCompare(b)),
|
||||
);
|
||||
const blockers = [
|
||||
...new Set(
|
||||
issue.dependencies
|
||||
.filter((dep) => dep.type === 'blocks')
|
||||
.map((dep) => dep.target),
|
||||
),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
blockersByIssue.set(issue.id, blockers);
|
||||
}
|
||||
|
||||
const maxNodes = Math.max(1, options.maxNodes ?? 12);
|
||||
|
|
@ -225,7 +230,7 @@ export function buildBlockedByTree(
|
|||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as { id: string; level: number };
|
||||
const blockers = incomingByTarget.get(current.id) ?? [];
|
||||
const blockers = blockersByIssue.get(current.id) ?? [];
|
||||
for (const blockerId of blockers) {
|
||||
if (visited.has(blockerId) || queued.has(blockerId)) continue;
|
||||
queued.add(blockerId);
|
||||
|
|
@ -258,10 +263,16 @@ export function findIssueLane(columns: KanbanColumns, issueId: string): KanbanSt
|
|||
export function buildUnblocksCountByIssue(issues: BeadIssue[]): Map<string, number> {
|
||||
const unblocksByIssue = new Map<string, number>();
|
||||
for (const issue of issues) {
|
||||
const targets = new Set(
|
||||
unblocksByIssue.set(issue.id, 0);
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
const uniqueBlockers = new Set(
|
||||
issue.dependencies.filter((dep) => dep.type === 'blocks').map((dep) => dep.target),
|
||||
);
|
||||
unblocksByIssue.set(issue.id, targets.size);
|
||||
for (const blockerId of uniqueBlockers) {
|
||||
unblocksByIssue.set(blockerId, (unblocksByIssue.get(blockerId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return unblocksByIssue;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue