diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 1443982d39..e2d664f432 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -397,6 +397,46 @@ not a restyle of the ordinary manual-approval badge. border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); } +/* +FNXC:PlannerOversight 2026-07-05-00:00: +FN-7592 replaces the overseer badge's uppercase text label with a small `Eye` icon so it +reads as a compact glyph rather than a wide pill. Size the badge to the icon (no min-width, +tight padding) and color it per `PlannerOverseerState` via the `data-planner-overseer-state` +attribute so operators can distinguish watching/steering/recovering/awaiting-confirmation at +a glance without reading text. Colors reuse existing semantic tokens: neutral/info for the +passive "watching" state, warning for the more active "steering"/"recovering" states, and the +triage token for "awaiting-confirmation" (a human-decision hold), matching the hue conventions +used elsewhere in this file (e.g. .card-oversight-badge--*, .card-status-badge--triage). +*/ +.card-planner-overseer-state { + padding: calc(var(--space-xs) / 2); + line-height: 0; +} + +.card-planner-overseer-state svg { + width: 12px; + height: 12px; +} + +.card-planner-overseer-state[data-planner-overseer-state="watching"] { + background: color-mix(in srgb, var(--color-info) 15%, transparent); + color: var(--color-info); + border-color: color-mix(in srgb, var(--color-info) 40%, transparent); +} + +.card-planner-overseer-state[data-planner-overseer-state="steering"], +.card-planner-overseer-state[data-planner-overseer-state="recovering"] { + background: color-mix(in srgb, var(--color-warning) 18%, transparent); + color: var(--color-warning); + border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); +} + +.card-planner-overseer-state[data-planner-overseer-state="awaiting-confirmation"] { + background: color-mix(in srgb, var(--triage) 18%, transparent); + color: var(--triage); + border-color: color-mix(in srgb, var(--triage) 45%, transparent); +} + .card.awaiting-input { border-left: 3px solid var(--color-warning); background: color-mix(in srgb, var(--color-warning) 6%, transparent); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 2bcb016f3d..e312111e4e 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react"; import { createPortal } from "react-dom"; -import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight } from "lucide-react"; +import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight, Eye } from "lucide-react"; import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction, MergeResult, PlannerOversightLevel } from "@fusion/core"; import { DEFAULT_PLANNER_OVERSIGHT_LEVEL, @@ -2889,15 +2889,25 @@ function TaskCardComponent({ board payload) plus a repaint-correct memo comparator; FN-7516 owns the styled badge/design and surface-by-surface rendering. This is a minimal, type-safe, guarded read only — nothing renders for an absent field or the "idle" state. + + FNXC:PlannerOversight 2026-07-05-00:00: + FN-7592 replaces the uppercase text label with a small state-colored `Eye` icon so + the badge reads as a compact glyph. The readable label and composed tooltip stay + available for accessibility: `aria-label` carries the state name (screen readers) + and `title` keeps the existing tooltip (hover). Per-state color comes from the + `data-planner-overseer-state` attribute in TaskCard.css — do not fork the label + logic here; `plannerOverseerStateLabel`/`plannerOverseerBadgeTooltip` remain the + single source of truth. */} {task.plannerOverseerState && task.plannerOverseerState.state !== "idle" && ( - {plannerOverseerStateLabel(task.plannerOverseerState.state, t)} + )} {showStalledReview && stalledReview && ( diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 0f48411f2d..9cd637d2ab 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -26,7 +26,9 @@ vi.mock("lucide-react", () => ({ Zap: () => , AlertTriangle: () => null, ArrowUpRight: () => null, - Eye: () => null, + // FN-7592: the overseer badge now renders an icon child instead of a text label, + // so tests must see a real SVG (like Zap) rather than a no-op render. + Eye: () => , })); vi.mock("../ProviderIcon", () => ({ @@ -317,9 +319,13 @@ describe("TaskCard", () => { render(); + // FN-7592: the badge is now an icon-only glyph. The readable label moved from + // textContent to aria-label; the composed tooltip is unchanged on title. const badge = screen.getByTestId("planner-overseer-state-badge"); - expect(badge.textContent).not.toBe("awaiting-confirmation"); - expect(badge.textContent).toBe("Awaiting confirmation"); + expect(badge.querySelector("svg")).toBeInTheDocument(); + expect(badge.getAttribute("aria-label")).not.toBe("awaiting-confirmation"); + expect(badge.getAttribute("aria-label")).toBe("Awaiting confirmation"); + expect(badge.getAttribute("data-planner-overseer-state")).toBe("awaiting-confirmation"); const title = badge.getAttribute("title") ?? ""; expect(title).not.toBe("Planner overseer: awaiting-confirmation"); @@ -343,8 +349,12 @@ describe("TaskCard", () => { }, }); const { unmount } = render(); + // FN-7592: icon-only badge — assert the accessible name via aria-label and the + // per-state color hook via data-planner-overseer-state, not raw text content. let badge = screen.getByTestId("planner-overseer-state-badge"); - expect(badge.textContent).toBe("Overseer watching"); + expect(badge.querySelector("svg")).toBeInTheDocument(); + expect(badge.getAttribute("aria-label")).toBe("Overseer watching"); + expect(badge.getAttribute("data-planner-overseer-state")).toBe("watching"); expect(badge.getAttribute("title")).not.toMatch(/undefined/); unmount(); @@ -361,7 +371,12 @@ describe("TaskCard", () => { }); render(); badge = screen.getByTestId("planner-overseer-state-badge"); - expect(badge.textContent).toBe("Overseer recovering"); + expect(badge.querySelector("svg")).toBeInTheDocument(); + expect(badge.getAttribute("aria-label")).toBe("Overseer recovering"); + // Distinct states expose distinct data-planner-overseer-state values, which is the + // hook TaskCard.css keys per-state color off of (jsdom cannot compute color-mix()). + expect(badge.getAttribute("data-planner-overseer-state")).toBe("recovering"); + expect(badge.getAttribute("data-planner-overseer-state")).not.toBe("watching"); const title = badge.getAttribute("title") ?? ""; expect(title).not.toMatch(/undefined/); expect(title.length).toBeGreaterThan(0);