nexus/ui/src/components/KanbanBoard.tsx
Nexus Dev 3a41ec7b9c feat(nexus): design system phase 3 raw utility sweep
Third phase of the DESIGN.md migration. Removes every raw Tailwind
color palette utility (bg-red-*, text-amber-*, border-blue-*, etc.)
from component source and replaces them with the semantic tokens
introduced in phases 1 and 2.

Scope:
  - 84 files touched under ui/src/
  - ~420 raw palette utility instances replaced
  - 23 hardcoded hex fallbacks replaced with var(--token) refs
  - Zero raw palette utilities remain in component source
    (verified with rg '(bg|text|border|ring)-(red|blue|green|amber|
    yellow|cyan|violet|purple|pink|slate|zinc|neutral|sky|teal|
    emerald|indigo|rose|orange|fuchsia)-[0-9]+' ui/src)

Mapping rules applied:
  - red-* -> destructive
  - amber-/yellow-/orange-* -> warning
  - green-/emerald-* -> success
  - blue-/cyan-/sky-* -> primary (info/in-progress) or muted-foreground
  - slate-/gray-/zinc-/neutral-* -> muted / muted-foreground / border
  - violet-/purple-/pink-/indigo-/rose-/teal-* -> collapsed to
    primary or muted (most were one-off decorative choices, not
    role-bearing). Role-bearing uses go through lib/agent-role-colors
    which was rewritten in phase 2.
  - Opacity modifiers preserved (/10, /15, /20, etc.)
  - dark: variant duplicates removed (theme tokens auto-switch)

Hardcoded hex fallbacks fixed:
  - #6366f1 (indigo) -> var(--primary) / var(--volt)
  - #64748b (slate) -> var(--muted-foreground) / var(--silver)
  - #4f46e5 (indigo) -> var(--primary)
  - #89b4fa (old Catppuccin blue) -> var(--primary) / #faff69
  - OrgChart status dots (#22d3ee/#4ade80/#facc15/#f87171/#a3a3a3)
    -> var(--primary) / var(--success) / var(--warning) /
    var(--destructive) / var(--muted-foreground) per status
  - VoiceWaveform fallback #89b4fa -> #faff69 (volt)

Legitimate hex values left untouched (12 total):
  - lib/color-contrast.ts WCAG reference constants
  - lib/worktree-branding.ts contrast fallback references
  - lib/mention-chips.ts runtime-generated SVG fills
  - context/ThemeContext.tsx theme metadata brand hexes
  - components/ThemeSeedInput.tsx user-facing hex picker

Ambiguous decisions (flagged for visual QA):
  - AgentDetail.tsx invocation-source badges (timer/assignment/
    on_demand) collapsed to primary/muted — visual distinction
    is reduced, labels still differ. Consider chart-role slots
    if differentiation matters.
  - AgentDetail.tsx mixed-opacity amber banners: bg-warning/60
    against new warning base reads heavier than original amber-50
    base.
  - Live-state dots in KanbanBoard/AgentDetail: bg-blue-* ->
    bg-primary — will glow volt in dark mode, probably desirable.

Verification:
  - npx tsc --noEmit in ui/ — zero errors introduced. Pre-existing
    errors in AgentConfigForm, command.tsx, useKeyboardShortcuts,
    usePiperTts, useVadRecorder, PersonalAssistant remain, all
    unrelated to color work.
  - Dev server on :6100 returns 200.

Not changed in this commit:
  - ui/src/lib/company-routes.ts — separate routing fix for broken
    Assistant/ContentStudio/Convert links, committed next.
  - Test files — a few will need assertion updates but are out of
    phase 3 scope.

Phase 4 follow-ups (rounded-xl/2xl collapse, soft shadow removal,
gradient removal) noted in .planning/AUDIT-RADIUS-SHADOWS.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:40:32 +00:00

274 lines
7.1 KiB
TypeScript

import { useMemo, useState } from "react";
import { Link } from "@/lib/router";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import { useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import type { Issue } from "@paperclipai/shared";
const boardStatuses = [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
"done",
"cancelled",
];
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
interface Agent {
id: string;
name: string;
}
interface KanbanBoardProps {
issues: Issue[];
agents?: Agent[];
liveIssueIds?: Set<string>;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
/* ── Droppable Column ── */
function KanbanColumn({
status,
issues,
agents,
liveIssueIds,
}: {
status: string;
issues: Issue[];
agents?: Agent[];
liveIssueIds?: Set<string>;
}) {
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<div className="flex flex-col min-w-[260px] w-[260px] shrink-0">
<div className="flex items-center gap-2 px-2 py-2 mb-1">
<StatusIcon status={status} />
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{statusLabel(status)}
</span>
<span className="text-xs text-muted-foreground/60 ml-auto tabular-nums">
{issues.length}
</span>
</div>
<div
ref={setNodeRef}
className={`flex-1 min-h-[120px] rounded-md p-1 space-y-1 transition-colors ${
isOver ? "bg-accent/40" : "bg-muted/20"
}`}
>
<SortableContext
items={issues.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
{issues.map((issue) => (
<KanbanCard
key={issue.id}
issue={issue}
agents={agents}
isLive={liveIssueIds?.has(issue.id)}
/>
))}
</SortableContext>
</div>
</div>
);
}
/* ── Draggable Card ── */
function KanbanCard({
issue,
agents,
isLive,
isOverlay,
}: {
issue: Issue;
agents?: Agent[];
isLive?: boolean;
isOverlay?: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: issue.id, data: { issue } });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const agentName = (id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`rounded-md border bg-card p-2.5 cursor-grab active:cursor-grabbing transition-shadow ${
isDragging && !isOverlay ? "opacity-30" : ""
} ${isOverlay ? "shadow-lg ring-1 ring-primary/20" : "hover:shadow-sm"}`}
>
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="block no-underline text-inherit"
onClick={(e) => {
// Prevent navigation during drag
if (isDragging) e.preventDefault();
}}
>
<div className="flex items-start gap-1.5 mb-1.5">
<span className="text-xs text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{isLive && (
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
</span>
)}
</div>
<p className="text-sm leading-snug line-clamp-2 mb-2">{issue.title}</p>
<div className="flex items-center gap-2">
<PriorityIcon priority={issue.priority} />
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name ? (
<Identity name={name} size="xs" />
) : (
<span className="text-xs text-muted-foreground font-mono">
{issue.assigneeAgentId.slice(0, 8)}
</span>
);
})()}
</div>
</Link>
</div>
);
}
/* ── Main Board ── */
export function KanbanBoard({
issues,
agents,
liveIssueIds,
onUpdateIssue,
}: KanbanBoardProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
const columnIssues = useMemo(() => {
const grouped: Record<string, Issue[]> = {};
for (const status of boardStatuses) {
grouped[status] = [];
}
for (const issue of issues) {
if (grouped[issue.status]) {
grouped[issue.status].push(issue);
}
}
return grouped;
}, [issues]);
const activeIssue = useMemo(
() => (activeId ? issues.find((i) => i.id === activeId) : null),
[activeId, issues]
);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over) return;
const issueId = active.id as string;
const issue = issues.find((i) => i.id === issueId);
if (!issue) return;
// Determine target status: the "over" could be a column id (status string)
// or another card's id. Find which column the "over" belongs to.
let targetStatus: string | null = null;
if (boardStatuses.includes(over.id as string)) {
targetStatus = over.id as string;
} else {
// It's a card - find which column it's in
const targetIssue = issues.find((i) => i.id === over.id);
if (targetIssue) {
targetStatus = targetIssue.status;
}
}
if (targetStatus && targetStatus !== issue.status) {
onUpdateIssue(issueId, { status: targetStatus });
}
}
function handleDragOver(_event: DragOverEvent) {
// Could be used for visual feedback; keeping simple for now
}
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-3 overflow-x-auto pb-4 -mx-2 px-2">
{boardStatuses.map((status) => (
<KanbanColumn
key={status}
status={status}
issues={columnIssues[status] ?? []}
agents={agents}
liveIssueIds={liveIssueIds}
/>
))}
</div>
<DragOverlay>
{activeIssue ? (
<KanbanCard issue={activeIssue} agents={agents} isOverlay />
) : null}
</DragOverlay>
</DndContext>
);
}