- Prettier 3.6.2: .prettierrc.json + .prettierignore + format/format:check scripts; formatted the web/ tree. - ESLint 9 flat config (@eslint/js + typescript-eslint + react-hooks + react-refresh, eslint-config-prettier last so Prettier owns formatting); lint script; src/components/ui/** opts out of react-refresh (shadcn primitives co-export their cva variants). 0 problems. - Playwright 1.61.1: playwright.config.ts (build+preview webServer) + an e2e Board-renders smoke against a mocked /api; test:e2e script. Reusable workflow installs chromium itself. - ci-web.yaml: required-scripts -> format:check,lint,build,test,test:e2e; run-format-check/lint/e2e -> true; e2e-browser chromium. All five scripts verified green locally.
91 lines
3.1 KiB
TypeScript
91 lines
3.1 KiB
TypeScript
import { memo } from "react";
|
|
import { Handle, Position, type NodeProps } from "reactflow";
|
|
import { Lock, RotateCcw } from "lucide-react";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { cn } from "@/lib/utils";
|
|
import type { RFData } from "../layout";
|
|
|
|
// One pipeline stage on the map. Color encodes live state; a badge shows the
|
|
// live task count; gated (P3/inert) nodes render dimmed with a tag. ARIA role +
|
|
// label keep the map reachable for screen readers / keyboard users.
|
|
function MapNodeImpl({ data }: NodeProps<RFData>) {
|
|
const { meta, state, count, dimmed, onPath, loopBackTo } = data;
|
|
const canLoop = loopBackTo.length > 0;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
// Base
|
|
"relative flex w-[184px] h-[72px] flex-col items-center justify-center gap-0.5 rounded-lg border bg-card px-3 py-2 text-center",
|
|
// State-driven border + tint
|
|
state === "active" && "border-status-active bg-status-active-bg",
|
|
state === "awaiting_human" &&
|
|
"border-status-waiting bg-status-waiting-bg",
|
|
state === "parked" && "border-status-parked bg-status-parked-bg",
|
|
state === "idle" && "border-border bg-card",
|
|
// Path highlight
|
|
onPath && "ring-2 ring-primary shadow-md shadow-primary/20",
|
|
// Dimmed when a task is selected and this node is not on its path
|
|
dimmed && "opacity-30",
|
|
)}
|
|
role="group"
|
|
aria-label={`${meta.label} stage, ${state}, ${count} task${count === 1 ? "" : "s"}`}
|
|
tabIndex={0}
|
|
>
|
|
<Handle type="target" position={Position.Left} />
|
|
|
|
{/* Count badge — top-right corner */}
|
|
{count > 0 && (
|
|
<span
|
|
className="absolute -top-2 -right-2 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
|
|
aria-hidden
|
|
>
|
|
{count}
|
|
</span>
|
|
)}
|
|
|
|
{/* Label row */}
|
|
<div className="flex items-center gap-1 font-semibold text-sm leading-tight text-foreground">
|
|
{meta.label}
|
|
|
|
{meta.kind === "gate" && (
|
|
<span className="inline-flex" title="human gate">
|
|
<Lock
|
|
className="h-3 w-3 shrink-0 text-muted-foreground"
|
|
aria-hidden
|
|
/>
|
|
</span>
|
|
)}
|
|
|
|
{canLoop && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex items-center">
|
|
<RotateCcw
|
|
className="h-3 w-3 shrink-0 text-muted-foreground"
|
|
aria-label={`can send work back to ${loopBackTo.join(", ")}`}
|
|
/>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="text-xs">
|
|
can send work back to {loopBackTo.join(", ")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
|
|
{/* Agent name */}
|
|
<div className="text-xs text-muted-foreground leading-tight truncate max-w-full px-1">
|
|
{meta.agent}
|
|
</div>
|
|
|
|
<Handle type="source" position={Position.Right} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const MapNode = memo(MapNodeImpl);
|