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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue