From bac15ebd09133b64716810739cc1f106b2c38abe Mon Sep 17 00:00:00 2001 From: scotttong Date: Wed, 24 Jun 2026 14:42:27 -0700 Subject: [PATCH] feat: task status icons & colors (#8580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work, and task status is one of the most-scanned signals across its whole UI. > - The Tasks UI shows status through small coloured ring icons and chips spread across the list, kanban, task detail, the properties flyout, inline `@`-mentions and the breadcrumb. > - Those ring glyphs lean heavily on colour to distinguish states, which is hard to read for colour-blind users, and the status hues were hard-coded in component classes rather than a single source. > - We want color-blind-safe, distinct *shapes* per status plus a single `--status-*` colour-token system the chips and icons share. > - This pull request adds a unified `StatusGlyph` (one shape per status) and the `--status-*` colour-token system, and adopts them across every task-status surface so the new glyphs + colours render by default. > - The benefit is a more accessible, consistent status language with one source of truth for status hues. ## Linked Issues or Issue Description This is a **feature** (no public issue filed). Following the feature issue template: - **Problem / motivation:** Task status is communicated mostly by colour (ring fills/borders), which is hard to distinguish for colour-blind users, and the status hues are duplicated across component classes with no single source of truth. Several community PRs have nibbled at parts of this (see related PRs below). - **Proposed solution:** A single `StatusGlyph` component with a distinct *shape* per status (not just colour), backed by a `--status-*` CSS-variable colour system (base hues + AA-tuned icon hues + `.status-chip` / `.status-fill` color-mix helpers), adopted across all task-status surfaces. - **Alternatives considered:** Recolouring the existing rings in place (rejected — still colour-only, no shape differentiation). - **Roadmap alignment:** Additive UI only; no overlap with planned core work. Related community PRs (partial / different approaches to the same area — not duplicates): - Refs #3806 — Show issue ref and status icon in breadcrumbs and properties - Refs #1760 — Improve design of cancelled task status icon - Refs #1856 — a11y title/aria-label on status and priority icons ## What Changed - **Colour token system:** `--status-agent-*` / `--status-task-*` base hues, AA-tuned `--status-task-icon-*` hues (light + dark), and `.status-chip` / `.status-fill` color-mix helpers in `index.css`; matching status→CSS-var maps in `status-colors.ts`. - **`StatusGlyph`** — one `viewBox="0 0 24 24"` glyph per status with distinct, color-blind-safe shapes (dashed ring, open ring, half-fill, ring+dot, disc+check, ring+bar, ring+slash, and `in_queue` = the blocked shape recoloured blue). Sizes `sm`/`md`/`lg`. - **Adoption (renders by default)** across `StatusIcon`, `StatusBadge` (agent + issue chips), `MarkdownBody` inline mentions, `IssueRow`, `IssuesList`, `IssueProperties`, `BreadcrumbBar` + `BreadcrumbContext`, and the task-detail header/breadcrumb. - **Tests** for `StatusGlyph`, `StatusIcon`, `StatusBadge`, `IssueRow`, `IssuesList`, `MarkdownBody` lock the rendered behaviour. Scope notes: no experimental flag and no Theme Editor surfaces. The generic `StatusBadge` (runs/goals/approvals) is unchanged. Project-status recolour is deferred (no in-scope consumer). ## Verification - `pnpm --filter @paperclipai/shared build` — green (tsc). - `pnpm --filter @paperclipai/ui build` — green (tsc + vite). - Targeted unit tests green: `StatusGlyph`, `StatusIcon`, `StatusBadge`, `IssueRow`, `IssuesList`, `MarkdownBody`, plus consumer suites that render these (`IssueProperties`, `IssueDetail`, `Search`, `IssueChatThread`, `IssueFiltersPopover`, `InterruptHandoffViews`) — all passing. - Remaining: interactive light + dark visual confirmation across list / kanban / detail / properties / inline mentions / breadcrumb. The glyph shapes + AA-tuned hues were previously QA'd on the originating feature branch. ## Risks Low risk. Additive UI: a new component + CSS tokens, adopted at existing status call sites. The generic `StatusBadge` and all non-status UI are untouched; no schema/migration changes. Behaviour is exercised by unit + consumer test suites. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, with tool use / code execution (file edits, local builds + vitest). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Paperclip --- ui/src/components/BreadcrumbBar.tsx | 31 ++++- ui/src/components/IssueProperties.tsx | 1 + ui/src/components/IssueRow.test.tsx | 30 ++++- ui/src/components/IssueRow.tsx | 4 +- ui/src/components/IssuesList.test.tsx | 34 ++++++ ui/src/components/IssuesList.tsx | 4 +- ui/src/components/MarkdownBody.test.tsx | 32 ++++- ui/src/components/MarkdownBody.tsx | 6 +- ui/src/components/StatusBadge.test.tsx | 138 ++++++++------------- ui/src/components/StatusBadge.tsx | 156 +++++++----------------- ui/src/components/StatusGlyph.test.tsx | 103 ++++++++++++++++ ui/src/components/StatusGlyph.tsx | 139 +++++++++++++++++++++ ui/src/components/StatusIcon.test.tsx | 149 ++++++++-------------- ui/src/components/StatusIcon.tsx | 89 ++++++-------- ui/src/context/BreadcrumbContext.tsx | 14 ++- ui/src/index.css | 67 ++++++++++ ui/src/lib/status-colors.ts | 51 ++++++++ ui/src/pages/Inbox.test.tsx | 11 +- ui/src/pages/IssueDetail.tsx | 29 ++++- 19 files changed, 714 insertions(+), 374 deletions(-) create mode 100644 ui/src/components/StatusGlyph.test.tsx create mode 100644 ui/src/components/StatusGlyph.tsx diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index 98319075f6..a82acdd458 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -79,9 +79,16 @@ export function BreadcrumbBar() {
{menuButton}
-

- {breadcrumbs[0].label} -

+ {breadcrumbs[0].leading ? ( +

+ {breadcrumbs[0].leading} + {breadcrumbs[0].label} +

+ ) : ( +

+ {breadcrumbs[0].label} +

+ )}
{globalToolbarSlots}
@@ -102,10 +109,24 @@ export function BreadcrumbBar() { {i > 0 && } {isLast || !crumb.href ? ( - {crumb.label} + crumb.leading ? ( + + {crumb.leading} + {crumb.label} + + ) : ( + {crumb.label} + ) ) : ( - {crumb.label} + {crumb.leading ? ( + + {crumb.leading} + {crumb.label} + + ) : ( + {crumb.label} + )} )} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 3d6da72fa7..c67014bd18 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -2303,6 +2303,7 @@ export function IssueProperties({ onUpdate({ status })} showLabel diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 28f0c14fa5..7b80cd145d 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -84,6 +84,25 @@ describe("IssueRow", () => { container.remove(); }); + it("renders the list status glyph at lg (20px)", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const glyphs = container.querySelectorAll('svg[viewBox="0 0 24 24"]'); + expect(glyphs.length).toBeGreaterThan(0); + glyphs.forEach((glyph) => { + expect(glyph.getAttribute("width")).toBe("20"); + expect(glyph.getAttribute("height")).toBe("20"); + }); + + act(() => { + root.unmount(); + }); + }); + it("suppresses accent hover styling when the row is selected", () => { const root = createRoot(container); const issue = createIssue(); @@ -111,7 +130,10 @@ describe("IssueRow", () => { const markReadButton = container.querySelector('button[aria-label="Mark as read"]'); const unreadDot = markReadButton?.querySelector("span"); - const statusIcon = container.querySelector('span[class*="border-muted-foreground"]'); + // Selected rows neutralize the status glyph to muted via `!`-important + // utilities, which override the glyph's inline colour var. The glyph is an + // (SVGAnimatedString className), so match on the class attribute. + const statusGlyph = container.querySelector('svg[class*="text-muted-foreground"]'); expect(markReadButton).not.toBeNull(); expect(markReadButton?.className).toContain("hover:bg-muted/80"); @@ -119,9 +141,9 @@ describe("IssueRow", () => { expect(unreadDot).not.toBeNull(); expect(unreadDot?.className).toContain("bg-muted-foreground/70"); expect(unreadDot?.className).not.toContain("bg-blue-600"); - expect(statusIcon).not.toBeNull(); - expect(statusIcon?.className).toContain("!border-muted-foreground"); - expect(statusIcon?.className).toContain("!text-muted-foreground"); + expect(statusGlyph).not.toBeNull(); + expect(statusGlyph?.getAttribute("class")).toContain("!text-muted-foreground"); + expect(statusGlyph?.getAttribute("class")).toContain("!border-muted-foreground"); act(() => { root.unmount(); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 3a3aaf4b4d..394ccb0853 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -127,7 +127,7 @@ export function IssueRow({ )} > - {mobileLeading ?? } + {mobileLeading ?? } {productivityReviewIndicator} {parkedBlockerIndicator} {recoveryIndicator} @@ -148,7 +148,7 @@ export function IssueRow({ {desktopMetaLeading ?? ( <> - + {productivityReviewIndicator} {checklistStep} diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 12d57064db..3562fbef40 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -1893,4 +1893,38 @@ describe("IssuesList", () => { root.unmount(); }); }); + + // PAP-246 (QA of PAP-245/PAP-243a): the desktop row status glyph must render + // at lg (20px). The earlier IssueRow unit test passed because it rendered + // IssueRow WITHOUT IssuesList's own leading slots, hitting the + // `?? ` fallback — but the live list always supplies its + // own `statusSlot`. This asserts the real list-supplied slot is lg. + it("renders the desktop row status glyph at lg (20px)", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const glyphs = Array.from(container.querySelectorAll("svg")).filter( + (svg) => svg.getAttribute("width") === "20" && svg.getAttribute("height") === "20", + ); + expect(glyphs.length).toBeGreaterThan(0); + // No 16px (md) status glyph should leak through from the list's slot. + const mdGlyphs = Array.from(container.querySelectorAll("svg")).filter( + (svg) => svg.getAttribute("width") === "16" && svg.getAttribute("height") === "16", + ); + expect(mdGlyphs.length).toBe(0); + }); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 81d4af908e..aafe0354d2 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1828,7 +1828,7 @@ export function IssuesList({ ) : ( { e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} /> + onUpdateIssue(issue.id, { status: s })} /> ) } @@ -1855,7 +1855,7 @@ export function IssuesList({ checklistStepNumber={checklistStepNumber} statusSlot={( { e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} /> + onUpdateIssue(issue.id, { status: s })} /> )} /> diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 7167506f36..1f35741cd8 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -194,7 +194,7 @@ describe("MarkdownBody", () => { ]); expect(html).toContain('href="/issues/PAP-1271"'); - expect(html).toContain("text-green-600"); + expect(html).toContain("var(--status-task-icon-done)"); expect(html).toContain(">PAP-1271<"); expect(html).toContain('data-mention-kind="issue"'); expect(html).toContain("paperclip-markdown-issue-ref"); @@ -235,8 +235,8 @@ describe("MarkdownBody", () => { expect(html).toContain('href="/issues/PAP-1180"'); expect(html).toContain(">/issues/PAP-1179<"); expect(html).toContain(">/PAP/issues/pap-1180<"); - expect(html).toContain("text-red-600"); - expect(html).toContain("text-green-600"); + expect(html).toContain("var(--status-task-icon-blocked)"); + expect(html).toContain("var(--status-task-icon-done)"); }); it("does not auto-link non-issue internal route paths", () => { @@ -258,8 +258,8 @@ describe("MarkdownBody", () => { expect(html).toContain('href="/issues/PAP-1311"'); expect(html).toContain(">issue://PAP-1310<"); expect(html).toContain(">issue://:PAP-1311<"); - expect(html).toContain("text-green-600"); - expect(html).toContain("text-red-600"); + expect(html).toContain("var(--status-task-icon-done)"); + expect(html).toContain("var(--status-task-icon-blocked)"); }); it("linkifies issue identifiers inside inline code spans", () => { @@ -269,7 +269,7 @@ describe("MarkdownBody", () => { expect(html).toContain('href="/issues/PAP-1271"'); expect(html).toContain('PAP-1271'); - expect(html).toContain("text-green-600"); + expect(html).toContain("var(--status-task-icon-done)"); expect(html).toContain("paperclip-markdown-issue-ref"); }); @@ -544,6 +544,26 @@ describe("MarkdownBody", () => { expect(html).toContain('href="/issues/JIRA-2"'); }); + it("renders the inline mention status glyph at lg (20px / h-5 w-5)", () => { + const html = renderMarkdown("See PAP-1271 for context.", [ + { identifier: "PAP-1271", status: "in_progress" }, + ]); + + // Unified glyph at 20px, with the h-5 w-5 class override so the Tailwind + // sizing matches the intrinsic SVG size. + expect(html).toContain('viewBox="0 0 24 24"'); + expect(html).toContain('width="20"'); + expect(html).toContain('height="20"'); + expect(html).toContain("h-5"); + expect(html).toContain("w-5"); + // PAP-243b: the lg glyph is optically centered to the body text + // (vertical-align: middle + a 1px lift), not floating off the baseline. + expect(html).toContain("align-middle"); + expect(html).not.toContain("align-[-0.125em]"); + // Legacy h-3 w-3 sizing is gone. + expect(html).not.toContain("mr-1 h-3 w-3"); + }); + it("never gates explicit internal issue paths, even for unknown prefixes", () => { mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "PAP" }] }); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 8575af7100..bf4e0fa580 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -96,12 +96,14 @@ function MarkdownIssueLink({ {status ? ( - + ) : null} {children} diff --git a/ui/src/components/StatusBadge.test.tsx b/ui/src/components/StatusBadge.test.tsx index 6a9ef84481..c0c46e380e 100644 --- a/ui/src/components/StatusBadge.test.tsx +++ b/ui/src/components/StatusBadge.test.tsx @@ -2,12 +2,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { IssueStatusBadge, IssueStatusGlyph, StatusBadge } from "./StatusBadge"; -import { brandChipBadge, issueStatusColor, statusBadgeClassic } from "../lib/status-colors"; +import { AgentStatusBadge, IssueStatusBadge, StatusBadge } from "./StatusBadge"; +import { agentStatusVar, statusBadgeClassic, taskStatusVar } from "../lib/status-colors"; -// The brand chips ship behind the Conference Room Chat experimental flag -// (PAP-139). These suites were written against the NUX UI, so the flag is -// seeded ON; the classic-fallback suite below flips it OFF. +// The generic StatusBadge (runs/goals/approvals) keeps the PAP-75 brand palette +// behind the Conference Room Chat flag (PAP-139). Seeded ON; the suite below +// flips it OFF. The task/agent status chips no longer depend on this flag. const conferenceRoomChatFlag = vi.hoisted(() => ({ enabled: true })); vi.mock("../hooks/useConferenceRoomChatEnabled", () => ({ useConferenceRoomChatEnabled: () => ({ enabled: conferenceRoomChatFlag.enabled, loaded: true }), @@ -18,114 +18,78 @@ afterEach(() => { }); /** - * PAP-99 (PAP-95e): issue/task status chips adopt the PAP-75 brand palette and - * carry their glyph icon. These tests lock the colour mapping ("blue = - * liveness": todo → amber, in_progress → blue, in_review → violet) and assert a - * glyph is always present, against the brand `.task-chip` tokens. + * Issue/task status chips carry the unified glyph and are recolored from the + * `--status-task-*` base hue via the `.status-chip` color-mix helper. */ describe("IssueStatusBadge", () => { - it("maps each issue status to its PAP-75 brand colour token", () => { - const cases: Record = { - backlog: "gray", - todo: "amber", - in_progress: "blue", - in_review: "violet", - done: "green", - blocked: "red", - cancelled: "gray", - }; - for (const [status, color] of Object.entries(cases)) { - expect(issueStatusColor[status]).toBe(color); + it("wires each issue status to its --status-task-* base hue, with a glyph", () => { + for (const [status, cssVar] of Object.entries(taskStatusVar)) { const html = renderToStaticMarkup(); - // Brand chip carries a 1px border + the colour's light + dark classes. + expect(html).toContain("status-chip"); expect(html).toContain("border"); - expect(html).toContain(brandChipBadge[color].split(" ")[0]); // light bg hex - // Every chip carries a glyph (inline SVG). - expect(html).toContain(" { - const prog = renderToStaticMarkup(); - expect(prog).toContain("#DBEAFE"); // blue light bg - expect(prog).not.toContain("#FEF3C7"); // not amber - const todo = renderToStaticMarkup(); - expect(todo).toContain("#FEF3C7"); // amber light bg - expect(todo).not.toContain("#DBEAFE"); // not blue + it("points in_progress at the blue liveness var and todo at the amber var", () => { + expect(renderToStaticMarkup()).toContain("var(--status-task-in_progress)"); + expect(renderToStaticMarkup()).toContain("var(--status-task-todo)"); }); - it("renders in_review with the reserved violet token", () => { + it("sentence-cases the label and uses regular weight", () => { const html = renderToStaticMarkup(); - expect(html).toContain("#EDE9FE"); - expect(html).toContain("#7C3AED"); + expect(html).toContain("In review"); + expect(html).not.toContain("In Review"); // sentence case, not title case + expect(html).toContain("font-normal"); + expect(html).not.toContain("font-medium"); }); it("strikes through cancelled chips", () => { - const html = renderToStaticMarkup(); - expect(html).toContain("line-through"); + expect(renderToStaticMarkup()).toContain("line-through"); }); - it("falls back to the gray token for unknown statuses", () => { - const html = renderToStaticMarkup(); - expect(html).toContain(brandChipBadge.gray.split(" ")[0]); + it("falls back to the backlog (gray) var for unknown statuses", () => { + expect(renderToStaticMarkup()).toContain("var(--status-task-backlog)"); + }); + + it("is independent of the Conference Room Chat flag", () => { + conferenceRoomChatFlag.enabled = false; + const html = renderToStaticMarkup(); + expect(html).toContain("status-chip"); + expect(html).toContain('viewBox="0 0 24 24"'); + expect(html).toContain("Todo"); }); }); -describe("IssueStatusBadge — Conference Room Chat flag OFF (PAP-139)", () => { - it("falls back to the plain master badge (no brand chip, no glyph)", () => { - conferenceRoomChatFlag.enabled = false; - const html = renderToStaticMarkup(); - expect(html).not.toContain(")); - expect(html).toContain(statusBadgeClassic.in_progress!.split(" ")[0]); // bg-yellow-100 +/** Agent chips recolor from the `--status-agent-*` base hues. */ +describe("AgentStatusBadge", () => { + it("wires each agent status to its --status-agent-* base hue via status-chip", () => { + for (const [status, cssVar] of Object.entries(agentStatusVar)) { + const html = renderToStaticMarkup(); + expect(html).toContain("status-chip"); + expect(html).toContain(`var(${cssVar})`); + } }); - it("keeps master's blue todo / yellow in_progress palette on StatusBadge", () => { + it('renders "active" as the idle label', () => { + expect(renderToStaticMarkup()).toContain("idle"); + }); +}); + +/** The generic badge still honors the PAP-139 Conference Room Chat palette. */ +describe("StatusBadge — Conference Room Chat flag palettes (PAP-139)", () => { + it("keeps master's blue todo / yellow in_progress palette when the flag is OFF", () => { conferenceRoomChatFlag.enabled = false; expect(renderToStaticMarkup()).toContain("bg-blue-100"); expect(renderToStaticMarkup()).toContain("bg-yellow-100"); + expect(renderToStaticMarkup()).toContain( + statusBadgeClassic.in_progress!.split(" ")[0], + ); }); - it("uses the brand hues on StatusBadge when the flag is ON", () => { + it("uses the brand hues when the flag is ON", () => { expect(renderToStaticMarkup()).toContain("bg-amber-100"); expect(renderToStaticMarkup()).toContain("bg-blue-100"); }); }); - -describe("IssueStatusGlyph", () => { - it("gives in_progress a half-filled ring (liveness)", () => { - const html = renderToStaticMarkup(); - // Open ring + the right-half semicircle fill path from status-reference.html. - expect(html).toContain('d="M6 1.5 A4.5 4.5 0 0 1 6 10.5 Z"'); - }); - - it("gives in_review a ring + centre dot (not a clock)", () => { - const html = renderToStaticMarkup(); - expect(html).toContain('r="2"'); - }); - - it("gives done a filled circle with a knocked-out check", () => { - const html = renderToStaticMarkup(); - expect(html).toContain('d="M3.5 6 5.5 8 8.5 4.5"'); - expect(html).toContain("stroke-background"); - }); - - it("gives blocked a ring + bar", () => { - const html = renderToStaticMarkup(); - expect(html).toContain(" { - const html = renderToStaticMarkup(); - expect(html).toContain('stroke-dasharray="2 2"'); - }); - - it("gives cancelled a ring + slash", () => { - const html = renderToStaticMarkup(); - expect(html).toContain('d="M3 9 9 3"'); - }); -}); diff --git a/ui/src/components/StatusBadge.tsx b/ui/src/components/StatusBadge.tsx index dbe5f58d5e..47beee987c 100644 --- a/ui/src/components/StatusBadge.tsx +++ b/ui/src/components/StatusBadge.tsx @@ -1,23 +1,35 @@ +import type { CSSProperties } from "react"; import { cn } from "../lib/utils"; import { statusBadge, statusBadgeClassic, statusBadgeDefault, - agentStatusColor, - agentStatusColorDefault, - agentStatusBadge, - agentStatusCapsule, agentStatusMotion, - brandChipBadge, - issueStatusColor, - issueStatusColorDefault, + agentStatusVar, + agentStatusVarDefault, + taskStatusVar, + taskStatusVarDefault, } from "../lib/status-colors"; import { useConferenceRoomChatEnabled } from "../hooks/useConferenceRoomChatEnabled"; +import { StatusGlyph } from "./StatusGlyph"; +/** Inline `--sc` local var pointing a status helper at a base-hue CSS var. */ +function scStyle(cssVar: string): CSSProperties { + return { "--sc": `var(${cssVar})` } as CSSProperties; +} + +/** "in_review" → "In review" (sentence case). */ +function sentenceCaseStatus(status: string): string { + const s = status.replace(/_/g, " "); + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Generic status badge for runs / goals / approvals (not task status). Keeps + * the PAP-75 brand palette behind the Conference Room Chat flag (PAP-139); flag + * OFF keeps master's palette. Non-issue entries are identical in both records. + */ export function StatusBadge({ status }: { status: string }) { - // PAP-75 brand hues for issue statuses (todo/in_progress) ship behind the - // Conference Room Chat flag (PAP-139); OFF keeps master's palette. Non-issue - // entries are identical in both records. const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled(); const palette = conferenceRoomChatEnabled ? statusBadge : statusBadgeClassic; return ( @@ -33,20 +45,17 @@ export function StatusBadge({ status }: { status: string }) { } /** - * Agent status chip — brand `.task-chip` (1px border, light/dark variants). - * Distinct from the shared {@link StatusBadge} so the agents section can carry - * the brand state colours without affecting run/issue/goal badges. `active` + * Agent status chip — bordered chip recoloured from the editable + * `--status-agent-*` base hue via the `.status-chip` color-mix helper. `active` * renders as "idle" (alias for dead code). */ export function AgentStatusBadge({ status }: { status: string }) { - const color = agentStatusColor[status] ?? agentStatusColorDefault; + const cssVar = agentStatusVar[status] ?? agentStatusVarDefault; const label = status === "active" ? "idle" : status; return ( {label.replace(/_/g, " ")} @@ -54,119 +63,42 @@ export function AgentStatusBadge({ status }: { status: string }) { } /** - * Agent status indicator — brand heartbeat capsule (vertical 8x16, r4). Running - * agents pulse, broken (error) agents blink; both honor `prefers-reduced-motion`. + * Agent status indicator — heartbeat capsule (vertical 8x16, r4) filled from the + * editable `--status-agent-*` base hue. Running agents pulse, broken (error) + * agents blink; both honor `prefers-reduced-motion`. */ export function AgentStatusCapsule({ status }: { status: string }) { - const color = agentStatusColor[status] ?? agentStatusColorDefault; + const cssVar = agentStatusVar[status] ?? agentStatusVarDefault; const motion = agentStatusMotion[status] ?? ""; return ( ); } /** - * Brand status glyph (12px) for the issue/task chip — paths lifted verbatim - * from the PAP-75 `status-reference.html` guide (12px, `currentColor`). The - * `in_progress` ring is half-filled (liveness), `done` is a filled circle with - * a knocked-out check, `in_review` a ring + centre dot, `blocked` a ring + bar. - */ -export function IssueStatusGlyph({ status }: { status: string }) { - const svgProps = { - viewBox: "0 0 12 12", - className: "h-3 w-3 shrink-0", - "aria-hidden": true, - } as const; - - switch (status) { - case "todo": - return ( - - - - ); - case "in_progress": - return ( - - - - - ); - case "in_review": - return ( - - - - - ); - case "done": - return ( - - - - - ); - case "blocked": - return ( - - - - - ); - case "cancelled": - return ( - - - - - ); - case "backlog": - default: - return ( - - - - ); - } -} - -/** - * Issue/task status chip — brand `.task-chip` (1px border, 12px glyph, - * light/dark), the same surface used by projects + agents. Maps the 7 - * `ISSUE_STATUSES` onto PAP-75 brand colours (todo → amber, in_progress → blue - * "liveness", in_review → violet, done → green, blocked → red, - * backlog/cancelled → gray). Each chip carries its glyph; `cancelled` is struck - * through. Distinct from the shared {@link StatusBadge} so run/goal/approval - * badges are unaffected. + * Issue/task status chip — bordered chip recoloured from the editable + * `--status-task-*` base hue via `.status-chip`, carrying the unified + * {@link StatusGlyph} (one distinct, color-blind-safe shape per status), a + * sentence-cased label and regular weight. `cancelled` is struck through. + * Distinct from the generic {@link StatusBadge} so run/goal/approval badges are + * unaffected. */ export function IssueStatusBadge({ status }: { status: string }) { - // Conference Room Chat flag OFF (PAP-139): fall back to the plain master - // badge — same markup master rendered at this badge's call sites. - const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled(); - const color = issueStatusColor[status] ?? issueStatusColorDefault; - if (!conferenceRoomChatEnabled) { - return ; - } + const cssVar = taskStatusVar[status] ?? taskStatusVarDefault; return ( - - {status.replace(/_/g, " ")} + + {sentenceCaseStatus(status)} ); } diff --git a/ui/src/components/StatusGlyph.test.tsx b/ui/src/components/StatusGlyph.test.tsx new file mode 100644 index 0000000000..da76ccf542 --- /dev/null +++ b/ui/src/components/StatusGlyph.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment node + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { StatusGlyph } from "./StatusGlyph"; +import { taskStatusIconVar } from "../lib/status-colors"; + +/** + * PAP-238 3b: the unified status glyph renders every status from ONE + * `viewBox="0 0 24 24"` SVG at a `sm 14 / md 16 / lg 20` scale, coloured from + * the AA-tuned `--status-task-icon-*` vars. These tests lock the geometry (the + * rev-4 spec hexes/paths), the size scale, the colour wiring and `in_queue`. + */ +describe("StatusGlyph", () => { + it("renders one 24-unit viewBox for every status (proportional scaling)", () => { + for (const status of Object.keys(taskStatusIconVar)) { + const html = renderToStaticMarkup(); + expect(html).toContain('viewBox="0 0 24 24"'); + expect(html).toContain(" { + expect(renderToStaticMarkup()).toContain('width="14"'); + expect(renderToStaticMarkup()).toContain('width="16"'); + expect(renderToStaticMarkup()).toContain('width="20"'); + // Default size is md. + expect(renderToStaticMarkup()).toContain('width="16"'); + }); + + it("colours each status from its --status-task-icon-* var", () => { + for (const [status, cssVar] of Object.entries(taskStatusIconVar)) { + const html = renderToStaticMarkup(); + expect(html).toContain(`var(${cssVar})`); + } + }); + + it("falls back to the backlog icon var for unknown statuses", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("var(--status-task-icon-backlog)"); + }); + + it("gives backlog a uniform dashed ring (pathLength=100)", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('pathLength="100"'); + expect(html).toContain('stroke-dasharray="6.25 6.25"'); + }); + + it("gives todo a bare open ring (no inner shape)", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('r="8.5"'); + expect(html).not.toContain(" { + const html = renderToStaticMarkup(); + expect(html).toContain("M12 3.5 A8.5 8.5 0 0 1 12 20.5 Z"); + }); + + it("gives in_review a ring + centre dot", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('r="3.6"'); + }); + + it("gives done a filled disc with a knocked-out check in the surface colour", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('r="9.5"'); + expect(html).toContain("M7.5 12.2 10.6 15.2 16.5 8.8"); + expect(html).toContain("stroke-background"); + }); + + it("gives blocked a ring + bar", () => { + const html = renderToStaticMarkup(); + expect(html).toContain(" { + const html = renderToStaticMarkup(); + expect(html).toContain("M6.5 17.5 17.5 6.5"); + }); + + it("renders in_queue as the blocked shape recoloured blue (in_progress var)", () => { + const queue = renderToStaticMarkup(); + const blocked = renderToStaticMarkup(); + // Same geometry as blocked (ring + bar)… + expect(queue).toContain(" { + expect(renderToStaticMarkup()).toContain('aria-hidden="true"'); + const labelled = renderToStaticMarkup(); + expect(labelled).toContain('role="img"'); + expect(labelled).toContain('aria-label="Todo"'); + expect(labelled).toContain("Todo"); + }); +}); diff --git a/ui/src/components/StatusGlyph.tsx b/ui/src/components/StatusGlyph.tsx new file mode 100644 index 0000000000..92138de2ae --- /dev/null +++ b/ui/src/components/StatusGlyph.tsx @@ -0,0 +1,139 @@ +import type { CSSProperties } from "react"; +import { cn } from "../lib/utils"; +import { taskStatusIconVar, taskStatusIconVarDefault } from "../lib/status-colors"; + +/** + * Unified task status glyph (PAP-238 3b) — the single source-of-truth icon for + * every task/issue status. Rendered from ONE `viewBox="0 0 24 24"` SVG per + * status so it scales proportionally at any size (fixes "done" collapsing into + * a filled blob at small sizes). Geometry + AA hues are lifted verbatim from + * the rev-4 spec artifact. + * + * Distinct shapes: backlog dashed ring (uniform via `pathLength`), todo open + * ring, in_progress half-filled, in_review ring + dot, done disc + knockout + * check, blocked ring + bar, cancelled ring + slash, and `in_queue` = the + * blocked shape recoloured blue (replaces the bespoke teal "covered" state). + * + * Colour comes from the `--status-task-icon-*` CSS vars (AA-tuned, + * mode-aware; see `index.css`). The glyph paints in `currentColor`, and the + * component defaults `color` to the status' icon var — so it renders correctly + * standalone, but a call site (3c) can recolour it by setting `color` on the + * SVG or any ancestor (e.g. a chip pointing it at its foreground hue). + */ + +export type StatusGlyphSize = "sm" | "md" | "lg"; + +/** sm 14 / md 16 / lg 20 — the only sizes the unified glyph ships at. */ +const SIZE_PX: Record = { sm: 14, md: 16, lg: 20 }; + +/** Proportional stroke for the open-ring family (24-unit viewBox). */ +const SW = 2.4; + +export type StatusGlyphStatus = + | "backlog" + | "todo" + | "in_progress" + | "in_review" + | "done" + | "blocked" + | "cancelled" + | "in_queue"; + +interface StatusGlyphProps { + status: string; + /** sm 14 / md 16 / lg 20. Default `md`. */ + size?: StatusGlyphSize; + className?: string; + /** Accessible label; when set the SVG gets `role="img"`, else it's decorative. */ + title?: string; +} + +/** Inner geometry per status (viewBox 0 0 24 24). `in_queue` reuses `blocked`. */ +function glyphBody(status: string) { + // in_queue borrows the blocked shape; its colour var resolves to the blue. + const shape = status === "in_queue" ? "blocked" : status; + switch (shape) { + case "todo": + return ; + case "in_progress": + return ( + <> + + + + ); + case "in_review": + return ( + <> + + + + ); + case "done": + return ( + <> + + {/* Check knocked out in the surface colour so the disc reads at any size. */} + + + ); + case "blocked": + return ( + <> + + + + ); + case "cancelled": + return ( + <> + + + + ); + case "backlog": + default: + // pathLength=100 makes the dash pattern resolution-independent: 100/12.5 = + // 8 exact dashes, so the ring is uniform with no overlap at the seam. + return ( + + ); + } +} + +export function StatusGlyph({ status, size = "md", className, title }: StatusGlyphProps) { + const px = SIZE_PX[size]; + const cssVar = taskStatusIconVar[status] ?? taskStatusIconVarDefault; + const a11y = title + ? ({ role: "img", "aria-label": title } as const) + : ({ "aria-hidden": true } as const); + return ( + + {title ? {title} : null} + {glyphBody(status)} + + ); +} diff --git a/ui/src/components/StatusIcon.test.tsx b/ui/src/components/StatusIcon.test.tsx index 82739c7b16..2415b7e7d5 100644 --- a/ui/src/components/StatusIcon.test.tsx +++ b/ui/src/components/StatusIcon.test.tsx @@ -1,36 +1,28 @@ // @vitest-environment node import { renderToStaticMarkup } from "react-dom/server"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { StatusIcon } from "./StatusIcon"; -// PAP-75 brand hues ship behind the Conference Room Chat experimental flag -// (PAP-139). This suite was written against the NUX UI, so the flag is seeded -// ON; the palette suite at the bottom covers both flag states. -const conferenceRoomChatFlag = vi.hoisted(() => ({ enabled: true })); -vi.mock("../hooks/useConferenceRoomChatEnabled", () => ({ - useConferenceRoomChatEnabled: () => ({ enabled: conferenceRoomChatFlag.enabled, loaded: true }), -})); - -afterEach(() => { - conferenceRoomChatFlag.enabled = true; -}); - -describe("StatusIcon — Conference Room Chat flag palettes (PAP-139)", () => { - it("keeps master's blue todo / yellow in_progress when the flag is OFF", () => { - conferenceRoomChatFlag.enabled = false; - expect(renderToStaticMarkup()).toContain("text-blue-600"); - expect(renderToStaticMarkup()).toContain("text-yellow-600"); - }); - - it("uses PAP-75 brand hues (todo amber, in_progress blue) when the flag is ON", () => { - expect(renderToStaticMarkup()).toContain("text-amber-600"); - expect(renderToStaticMarkup()).toContain("text-blue-600"); - }); -}); - +/** + * StatusIcon renders the unified {@link StatusGlyph} (one shape per status) at + * every standalone status surface. These tests lock the glyph rendering, the + * covered-blocked → "in queue" mapping, the accessible blocked labels, and the + * size prop. + */ describe("StatusIcon", () => { - it("renders covered blocked issues with the cyan covered state visual", () => { + it("renders the unified glyph (24-unit viewBox), not a bespoke ring", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('viewBox="0 0 24 24"'); + expect(html).not.toContain("rounded-full border-2"); + }); + + it("drives the glyph colour from the status icon var", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("var(--status-task-icon-todo)"); + }); + + it("maps covered-blocked → In queue (blue in_queue var, no cyan markers)", () => { const html = renderToStaticMarkup( { reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1, - stalledBlockerCount: 0, attentionBlockerCount: 0, - sampleBlockerIdentifier: "PAP-2", + stalledBlockerCount: 0, + sampleBlockerIdentifier: "PAP-9", sampleStalledBlockerIdentifier: null, }} />, ); - - expect(html).toContain('data-blocker-attention-state="covered"'); - expect(html).toContain('aria-label="Blocked · waiting on active sub-task PAP-2"'); - expect(html).toContain('title="Blocked · waiting on active sub-task PAP-2"'); - expect(html).toContain("border-cyan-600"); - expect(html).not.toContain("border-red-600"); - expect(html).not.toContain("border-dashed"); - expect(html).toContain("-bottom-0.5"); + expect(html).toContain("var(--status-task-icon-in_queue)"); + expect(html).not.toContain("bg-cyan"); + expect(html).not.toContain("border-cyan"); + // Full blocked reason still rides on the accessible label. + expect(html).toContain("Blocked · waiting on active sub-task PAP-9"); }); - it("uses covered blocked copy for the active dependency count matrix", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('aria-label="Blocked · covered by 2 active dependencies"'); - expect(html).toContain("border-cyan-600"); - expect(html).not.toContain("border-dashed"); - }); - - it("keeps normal blocked issues on the attention-required visual", () => { + it("surfaces attention-required blocked copy and keeps the blocked glyph", () => { const html = renderToStaticMarkup( , - ); - - expect(html).not.toContain('data-blocker-attention-state="covered"'); - expect(html).toContain('data-blocker-attention-state="needs_attention"'); - expect(html).toContain('aria-label="Blocked · 1 blocker needs attention"'); - expect(html).toContain("border-red-600"); - expect(html).not.toContain("border-dashed"); - }); - - it("shows active covered work on mixed attention-required blockers", () => { - const html = renderToStaticMarkup( - { }} />, ); - - expect(html).toContain('data-blocker-attention-state="needs_attention"'); - expect(html).toContain('aria-label="Blocked · 3 blockers need attention; 2 covered by active work"'); - expect(html).toContain("border-red-600"); - expect(html).not.toContain("border-cyan-600"); - expect(html).toContain("bg-cyan-600"); + expect(html).toContain("Blocked · 3 blockers need attention; 2 covered by active work"); + // needs_attention is not "covered", so it keeps the blocked glyph (not in_queue). + expect(html).toContain("var(--status-task-icon-blocked)"); + expect(html).not.toContain("var(--status-task-icon-in_queue)"); }); - it("renders stalled review chains with amber visual and stalled-leaf copy", () => { + it("surfaces stalled-review blocked copy on the accessible label", () => { const html = renderToStaticMarkup( { }} />, ); + expect(html).toContain("Blocked · review stalled on PAP-2279"); + }); - expect(html).toContain('data-blocker-attention-state="stalled"'); - expect(html).toContain('aria-label="Blocked · review stalled on PAP-2279"'); - expect(html).toContain("border-amber-600"); - expect(html).not.toContain("border-cyan-600"); - expect(html).not.toContain("border-red-600"); + it("keeps the onChange picker working with the glyph", () => { + const html = renderToStaticMarkup( {}} />); + expect(html).toContain('viewBox="0 0 24 24"'); + }); +}); + +describe("StatusIcon — glyph size (PAP-243a)", () => { + it('forwards size="lg" as a 20px glyph', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('width="20"'); + expect(html).toContain('height="20"'); + }); + + it("defaults to a 16px (md) glyph when size is omitted", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('width="16"'); + expect(html).toContain('height="16"'); }); }); diff --git a/ui/src/components/StatusIcon.tsx b/ui/src/components/StatusIcon.tsx index 05612f25a8..b5a8dd9332 100644 --- a/ui/src/components/StatusIcon.tsx +++ b/ui/src/components/StatusIcon.tsx @@ -1,8 +1,7 @@ import { useState } from "react"; import type { IssueBlockerAttention } from "@paperclipai/shared"; import { cn } from "../lib/utils"; -import { issueStatusIcon, issueStatusIconClassic, issueStatusIconDefault } from "../lib/status-colors"; -import { useConferenceRoomChatEnabled } from "../hooks/useConferenceRoomChatEnabled"; +import { StatusGlyph, type StatusGlyphSize } from "./StatusGlyph"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; @@ -18,6 +17,8 @@ interface StatusIconProps { onChange?: (status: string) => void; className?: string; showLabel?: boolean; + /** Glyph size (PAP-243a). Default `md` (16px); lists/detail/mentions use `lg` (20px). */ + size?: StatusGlyphSize; } function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) { @@ -62,66 +63,52 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | return "Blocked"; } -export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) { +/** + * Task/issue status indicator — renders the unified, color-blind-safe + * {@link StatusGlyph} (one distinct shape per status). With `onChange` it also + * acts as a status picker (popover). This one component drives every standalone + * status surface: list, kanban, detail header, properties row + picker flyout, + * sub-task / blocked-by pills, blocked inbox, quicklook, sibling nav, filters, + * search, columns, dashboard. + * + * A "covered" blocked task (waiting on active work) maps to the `in_queue` + * glyph — the blocked shape recoloured blue — while the full blocked reason + * still rides on the accessible label. + */ +export function StatusIcon({ status, blockerAttention, onChange, className, showLabel, size = "md" }: StatusIconProps) { const [open, setOpen] = useState(false); - // PAP-75 brand hues (todo → amber, in_progress → blue) ship behind the - // Conference Room Chat flag (PAP-139); OFF keeps master's palette. - const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled(); - const statusIconPalette = conferenceRoomChatEnabled ? issueStatusIcon : issueStatusIconClassic; const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered"; - const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled"; - const isAttentionBlocked = status === "blocked" && blockerAttention?.state === "needs_attention"; - const hasCoveredBlockedWork = isAttentionBlocked && (blockerAttention?.coveredBlockerCount ?? 0) > 0; - const colorClass = isCoveredBlocked - ? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400" - : isStalledBlocked - ? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400" - : statusIconPalette[status] ?? issueStatusIconDefault; - const isDone = status === "done"; const ariaLabel = status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status); - const blockerAttentionState = isCoveredBlocked - ? "covered" - : isStalledBlocked - ? "stalled" - : isAttentionBlocked - ? "needs_attention" - : undefined; + const glyphStatus = isCoveredBlocked ? "in_queue" : status; - const circle = ( - - {isDone && ( - - )} - {isCoveredBlocked && ( - - )} - {hasCoveredBlockedWork && ( - - )} - {isStalledBlocked && ( - - )} - + /> ); - if (!onChange) return showLabel ? {circle}{statusLabel(status)} : circle; + if (!onChange) { + return showLabel ? ( + + {glyph} + {statusLabel(status)} + + ) : ( + glyph + ); + } const trigger = showLabel ? ( - ) : circle; + ) : ( + glyph + ); return ( @@ -138,7 +125,7 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show setOpen(false); }} > - + {statusLabel(s)} ))} diff --git a/ui/src/context/BreadcrumbContext.tsx b/ui/src/context/BreadcrumbContext.tsx index 16ea448cc8..5c7fc43161 100644 --- a/ui/src/context/BreadcrumbContext.tsx +++ b/ui/src/context/BreadcrumbContext.tsx @@ -3,6 +3,14 @@ import { createContext, useCallback, useContext, useEffect, useState, type React export interface Breadcrumb { label: string; href?: string; + /** Optional node rendered before the label (e.g. a status glyph). */ + leading?: ReactNode; + /** + * Stable identity for `leading` so equality/diffing works without comparing + * React nodes by reference (which always differ across renders). Set this to + * a primitive that changes only when the rendered `leading` should change. + */ + leadingKey?: string; } interface BreadcrumbContextValue { @@ -23,7 +31,11 @@ function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) { if (left === right) return true; if (left.length !== right.length) return false; for (let index = 0; index < left.length; index += 1) { - if (left[index]?.label !== right[index]?.label || left[index]?.href !== right[index]?.href) { + if ( + left[index]?.label !== right[index]?.label + || left[index]?.href !== right[index]?.href + || left[index]?.leadingKey !== right[index]?.leadingKey + ) { return false; } } diff --git a/ui/src/index.css b/ui/src/index.css index f3da45a2ce..d749022899 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -118,6 +118,43 @@ --agent-9b: #1f4ed4; --agent-10a: #f2d95f; --agent-10b: #4fbcba; + + /* Status colors — one base hue per status, consumed by the agent / task + status chips and the task status icons. Mode-independent: the `.status-chip`, + `.status-fill` and inline icon helpers derive the light vs dark + fill/text/border from these via color-mix, so a single hue covers both + modes. Values mirror the PAP-75 brand palette (gray inert, blue liveness, + amber queued, violet review, green done, red blocked). */ + --status-agent-idle: #a8aeb2; + --status-agent-running: #2563eb; + --status-agent-paused: #f59e0b; + --status-agent-error: #dc2626; + --status-task-backlog: #a8aeb2; + --status-task-todo: #f59e0b; + --status-task-in_progress: #2563eb; + --status-task-in_review: #7c3aed; + --status-task-done: #22c55e; + --status-task-blocked: #dc2626; + --status-task-cancelled: #a8aeb2; + + /* Task status ICON hues (PAP-238 3b) — AA-tuned (every glyph ≥ 3:1 against + its surface, WCAG 2.1 AA, both modes). Distinct from the + `--status-task-*` chip base hues: a status chip carries its own bg/text + tint, but a bare glyph sitting next to text on the page needs a stronger + hue to clear 3:1. Where the chip base already passes (blue liveness, red + blocked, light violet review) the icon var simply re-points at it; the + gray / amber / green statuses are pinned to the board-approved AA hexes. + `in_queue` is the BLOCKED shape recoloured to the in_progress blue + (replaces the bespoke teal "covered" state). Light values here; the + `.dark` block below overrides the four that need a mode-specific hue. */ + --status-task-icon-backlog: #52585d; /* gray darkened — 7.21:1 on paper */ + --status-task-icon-todo: #cc7a00; /* amber darkened — clears 3:1 */ + --status-task-icon-in_progress: var(--status-task-in_progress); /* #2563eb both modes */ + --status-task-icon-in_review: var(--status-task-in_review); /* #7c3aed (light) */ + --status-task-icon-done: #16a34a; /* green darkened — clears 3:1 */ + --status-task-icon-blocked: var(--status-task-blocked); /* #dc2626 both modes */ + --status-task-icon-cancelled: #52585d; + --status-task-icon-in_queue: var(--status-task-in_progress); /* blocked shape, blue */ } .dark { @@ -169,6 +206,15 @@ --paperclip-doc-annotation-highlight-focused: #ca8a04; --paperclip-doc-annotation-highlight-stale: #854d0e; --paperclip-doc-annotation-highlight-resolved: #713f12; + + /* Dark-mode AA overrides for the status-icon hues that need a mode-specific + value (PAP-238). in_progress / blocked stay identical (same blue / red both + modes) and in_queue tracks in_progress, so they need no override. */ + --status-task-icon-backlog: #9a958a; + --status-task-icon-todo: #fbbf24; + --status-task-icon-in_review: #9474f0; /* deeper than violet-400, reads purple */ + --status-task-icon-done: #34d06f; + --status-task-icon-cancelled: #9a958a; } ::highlight(paperclip-doc-annotation-open) { @@ -1263,3 +1309,24 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { [class*="_toolbarNodeKindSelectContainer_"] { z-index: 81 !important; } + +/* Status chip/fill helpers. + Each consumer sets a local `--sc` to the status' base hue (e.g. + `--sc: var(--status-task-in_progress)`); these rules derive the rendered + fill / text / border from it. `.status-chip` is the bordered chip (agent + + task chips); `.status-fill` a solid swatch (heartbeat capsule). Light mixes + the hue toward white for a soft fill; dark layers the hue at low alpha so it + reads on dark surfaces. A single hue covers both modes. */ +.status-chip { + background-color: color-mix(in srgb, var(--sc) 15%, white); + color: color-mix(in srgb, var(--sc) 82%, black); + border-color: var(--sc); +} +.dark .status-chip { + background-color: color-mix(in srgb, var(--sc) 22%, transparent); + color: color-mix(in srgb, var(--sc) 80%, white); + border-color: color-mix(in srgb, var(--sc) 48%, transparent); +} +.status-fill { + background-color: var(--sc); +} diff --git a/ui/src/lib/status-colors.ts b/ui/src/lib/status-colors.ts index 267ade6830..4f552780e2 100644 --- a/ui/src/lib/status-colors.ts +++ b/ui/src/lib/status-colors.ts @@ -189,6 +189,57 @@ export const issueStatusColor: Record = { export const issueStatusColorDefault: BrandChipColor = "gray"; +// --------------------------------------------------------------------------- +// Status → base-hue CSS variable +// +// Each status chip / icon sets a local `--sc` to the matching var below, and +// the `.status-chip` / `.status-fill` helpers (index.css) derive the rendered +// fill/text/border from it for both light and dark. Agent and task keep +// independent vars so each can be tuned without touching the other, even where +// their defaults coincide. +// --------------------------------------------------------------------------- + +/** Agent status → base-hue CSS var. `active` aliases idle (never assigned). */ +export const agentStatusVar: Record = { + idle: "--status-agent-idle", + active: "--status-agent-idle", + running: "--status-agent-running", + paused: "--status-agent-paused", + error: "--status-agent-error", +}; +export const agentStatusVarDefault = "--status-agent-idle"; + +/** Task/issue status → base-hue CSS var (drives both the chip and the icon). */ +export const taskStatusVar: Record = { + backlog: "--status-task-backlog", + todo: "--status-task-todo", + in_progress: "--status-task-in_progress", + in_review: "--status-task-in_review", + done: "--status-task-done", + blocked: "--status-task-blocked", + cancelled: "--status-task-cancelled", +}; +export const taskStatusVarDefault = "--status-task-backlog"; + +/** + * Task/issue status → AA-tuned ICON-hue CSS var (PAP-238). Drives the standalone + * {@link StatusGlyph} colour. Separate from {@link taskStatusVar} (the chip base + * hue) because a bare glyph next to text needs a stronger hue to clear WCAG 3:1; + * see the `--status-task-icon-*` block in `index.css`. `in_queue` is the blocked + * shape recoloured blue, so it maps to its own var. + */ +export const taskStatusIconVar: Record = { + backlog: "--status-task-icon-backlog", + todo: "--status-task-icon-todo", + in_progress: "--status-task-icon-in_progress", + in_review: "--status-task-icon-in_review", + done: "--status-task-icon-done", + blocked: "--status-task-icon-blocked", + cancelled: "--status-task-icon-cancelled", + in_queue: "--status-task-icon-in_queue", +}; +export const taskStatusIconVarDefault = "--status-task-icon-backlog"; + // --------------------------------------------------------------------------- // Agent status dot — solid background for small indicator dots // --------------------------------------------------------------------------- diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 34883a17e2..b59c8f9c25 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -484,7 +484,10 @@ describe("InboxIssueMetaLeading", () => { root.render(); }); - const statusIcon = container.querySelector('span[class*="border-blue-600"]'); + // The status glyph is an coloured from its --status-task-icon-* var. + const statusIcon = Array.from(container.querySelectorAll("svg")).find((svg) => + (svg.getAttribute("style") ?? "").includes("--status-task-icon"), + ); const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-blue-500/10"]'); const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find( (node) => node.textContent === "Live" && node.className.includes("text-"), @@ -492,9 +495,9 @@ describe("InboxIssueMetaLeading", () => { const liveDot = container.querySelector('span[class*="bg-blue-500"]'); const pulseRing = container.querySelector('span[class*="animate-pulse"]'); - expect(statusIcon).not.toBeNull(); - expect(statusIcon?.className).not.toContain("!border-muted-foreground"); - expect(statusIcon?.className).not.toContain("!text-muted-foreground"); + expect(statusIcon).not.toBeUndefined(); + // Status accent stays visible — not neutralized to muted. + expect(statusIcon?.getAttribute("class") ?? "").not.toContain("!text-muted-foreground"); expect(liveBadge).not.toBeNull(); expect(liveBadge?.className).toContain("bg-blue-500/10"); expect(liveBadgeLabel).not.toBeNull(); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 19774bead5..41d8593485 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1723,6 +1723,24 @@ export function IssueDetail() { [comments, optimisticComments], ); const breadcrumbTitle = issue?.title ?? issueId ?? "Task"; + const breadcrumbStatus = issue?.status; + const breadcrumbBlockerAttention = issue?.blockerAttention; + // Stable identity for the breadcrumb status glyph. The glyph's shape/colour + // depend on status (+ covered state), and its accessible label is derived + // from the blocker counts — so the key signs over the full blockerAttention, + // not just `state`, to avoid a stale label when counts change. + const breadcrumbStatusKey = breadcrumbStatus + ? `${breadcrumbStatus}|${JSON.stringify(breadcrumbBlockerAttention ?? null)}` + : undefined; + const breadcrumbStatusLeading = useMemo( + () => + breadcrumbStatus ? ( + + ) : undefined, + // `breadcrumbStatusKey` is a complete signature of the inputs below. + // eslint-disable-next-line react-hooks/exhaustive-deps + [breadcrumbStatusKey], + ); const issueCacheRefs = useMemo(() => { const refs = new Set(); if (issueId) refs.add(issueId); @@ -2808,7 +2826,13 @@ export function IssueDetail() { useEffect(() => { setBreadcrumbs([ sourceBreadcrumb, - { label: hasLiveRuns ? `🔵 ${breadcrumbTitle}` : breadcrumbTitle }, + { + label: hasLiveRuns ? `🔵 ${breadcrumbTitle}` : breadcrumbTitle, + // Prepend the task's status glyph (lg/20px) to the breadcrumb so the + // current task's state reads at a glance. + leading: breadcrumbStatusLeading, + leadingKey: breadcrumbStatusKey, + }, ]); }, [ breadcrumbTitle, @@ -2816,6 +2840,8 @@ export function IssueDetail() { setBreadcrumbs, sourceBreadcrumb.href, sourceBreadcrumb.label, + breadcrumbStatusLeading, + breadcrumbStatusKey, ]); const isFromInbox = resolvedIssueDetailState?.issueDetailSource === "inbox"; @@ -3648,6 +3674,7 @@ export function IssueDetail() {
updateIssue.mutate({ status })} />