FN-7592: replace overseer badge text label with a colored Eye icon

Swaps the planner-overseer status badge from an uppercase text pill to a compact icon glyph, keeping accessibility text on aria-label/title.

- Render a small lucide-react Eye icon instead of the state-label text inside the overseer badge
- Keep the readable state name on aria-label and the composed tooltip on title for accessibility
- Add per-state coloring (watching/steering/recovering/awaiting-confirmation) keyed off the data-planner-overseer-state attribute in TaskCard.css, sized tightly around the icon
- Update TaskCard tests to assert the icon renders and the accessible name moved to aria-label instead of textContent

Files changed:
 packages/dashboard/app/components/TaskCard.css     | 40 ++++++++++++++++++++++
 packages/dashboard/app/components/TaskCard.tsx     | 14 ++++++--
 .../app/components/__tests__/TaskCard.test.tsx     | 25 +++++++++++---
 3 files changed, 72 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7592

Fusion-Task-Lineage: d7ca93a6-9236-4fa0-a829-80f5b99dbd5f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 13:11:15 -07:00
parent 8a7507ad36
commit 09a10bc5e7
3 changed files with 72 additions and 7 deletions

View File

@@ -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);

View File

@@ -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" && (
<span
className="card-status-badge card-planner-overseer-state"
title={plannerOverseerBadgeTooltip(task.plannerOverseerState, t)}
aria-label={plannerOverseerStateLabel(task.plannerOverseerState.state, t)}
data-testid="planner-overseer-state-badge"
data-planner-overseer-state={task.plannerOverseerState.state}
>
{plannerOverseerStateLabel(task.plannerOverseerState.state, t)}
<Eye aria-hidden="true" />
</span>
)}
{showStalledReview && stalledReview && (

View File

@@ -26,7 +26,9 @@ vi.mock("lucide-react", () => ({
Zap: () => <svg data-testid="icon-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: () => <svg data-testid="icon-eye" />,
}));
vi.mock("../ProviderIcon", () => ({
@@ -317,9 +319,13 @@ describe("TaskCard", () => {
render(<TaskCard task={task} onOpenDetail={noop} addToast={noop} />);
// 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(<TaskCard task={watchingTask} onOpenDetail={noop} addToast={noop} />);
// 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(<TaskCard task={recoveringTask} onOpenDetail={noop} addToast={noop} />);
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);