// [nexus] Phase 11 — Projects list hero-stat card. // // A single card in the Projects list grid. The visual focal point is the // 72px Inter Black volt percentage (DESIGN.md §4 "performance stat"). The // card renders: // // • Title row — project name in Inter 700 uppercase 16px + status dot // • Hero stat — Inter Black 900, 72px, volt — the milestone % // • Sub-line — "Phase N of M · Next gate: {name}" in silver // • Progress bar — 8px charcoal track, volt fill, sharp 4px corners // • Footer — "$X burned · last activity {when}" in silver // // Data gaps (Phase 11): the shared `Project` type has no milestoneProgress, // no phasesCompleted/phasesTotal, no nextGate, no costBurned, no // lastActivity fields. When those values aren't available we render an // em-dash placeholder rather than fabricate a value. See the Phase 11 // report for the backend gap list. import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; export type ProjectCardStatus = "idle" | "working" | "waiting"; export interface ProjectCardProps { /** Uppercase slug displayed as the title (e.g. "NEXUS-DESIGN-MIGRATION"). */ slug: string; /** Status dot mapping — priority is waiting > working > idle. */ status: ProjectCardStatus; /** Milestone progress 0–100, or null when no data is available. */ progress: number | null; /** Optional "Phase N of M" counter (both null when no data). */ phase?: { current: number; total: number } | null; /** Optional next-gate display name. Null when unknown. */ nextGateName?: string | null; /** Burn total in cents. Null when no cost data available. */ costBurnedCents?: number | null; /** Last-activity relative string (already formatted). Null when unknown. */ lastActivity?: string | null; /** Click handler — the parent is responsible for routing. */ onClick?: () => void; /** Aria-label override when the slug alone is insufficient. */ ariaLabel?: string; } function StatusDot({ status }: { status: ProjectCardStatus }) { if (status === "waiting") { // Pale yellow literal hex — token doesn't ship until MIGRATION-PLAN §3. return ( ); } if (status === "working") { return ( ); } return ( ); } function formatCostUSD(cents: number | null | undefined): string { if (cents === null || cents === undefined) return "—"; const dollars = cents / 100; return `$${dollars.toFixed(2)}`; } function formatProgress(progress: number | null): ReactNode { if (progress === null) return "—%"; return `${Math.round(progress)}%`; } function formatPhase(phase: { current: number; total: number } | null | undefined): string { if (!phase) return "Phase — of —"; return `Phase ${phase.current} of ${phase.total}`; } export function ProjectCard({ slug, status, progress, phase, nextGateName, costBurnedCents, lastActivity, onClick, ariaLabel, }: ProjectCardProps) { const nextGateText = nextGateName ? `Next gate: ${nextGateName}` : "Next gate: —"; const subLine = `${formatPhase(phase)} · ${nextGateText}`; const costText = formatCostUSD(costBurnedCents); const activityText = lastActivity ? `last activity ${lastActivity}` : "last activity —"; const footerLine = `${costText} burned · ${activityText}`; // Progress bar width — clamp to 0..100. const barWidth = progress === null ? 0 : Math.max(0, Math.min(100, progress)); return ( ); }