feat: task status icons & colors (#8580)

## 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 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
scotttong 2026-06-24 14:42:27 -07:00 committed by GitHub
parent 50ae8fc657
commit bac15ebd09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 714 additions and 374 deletions

View File

@ -79,9 +79,16 @@ export function BreadcrumbBar() {
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
{menuButton}
<div className="min-w-0 overflow-hidden flex-1">
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
{breadcrumbs[0].label}
</h1>
{breadcrumbs[0].leading ? (
<h1 className="flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wider">
<span className="flex shrink-0 items-center">{breadcrumbs[0].leading}</span>
<span className="truncate">{breadcrumbs[0].label}</span>
</h1>
) : (
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
{breadcrumbs[0].label}
</h1>
)}
</div>
{globalToolbarSlots}
</div>
@ -102,10 +109,24 @@ export function BreadcrumbBar() {
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
{isLast || !crumb.href ? (
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
crumb.leading ? (
<BreadcrumbPage className="flex min-w-0 items-center gap-1.5">
<span className="flex shrink-0 items-center">{crumb.leading}</span>
<span className="truncate">{crumb.label}</span>
</BreadcrumbPage>
) : (
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
)
) : (
<BreadcrumbLink asChild>
<Link to={crumb.href}>{crumb.label}</Link>
{crumb.leading ? (
<Link to={crumb.href} className="flex items-center gap-1.5">
<span className="flex shrink-0 items-center">{crumb.leading}</span>
<span className="truncate">{crumb.label}</span>
</Link>
) : (
<Link to={crumb.href}>{crumb.label}</Link>
)}
</BreadcrumbLink>
)}
</BreadcrumbItem>

View File

@ -2303,6 +2303,7 @@ export function IssueProperties({
<PropertyRow label="Status">
<StatusIcon
status={issue.status}
size="lg"
blockerAttention={issue.blockerAttention}
onChange={(status) => onUpdate({ status })}
showLabel

View File

@ -84,6 +84,25 @@ describe("IssueRow", () => {
container.remove();
});
it("renders the list status glyph at lg (20px)", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue({ status: "in_progress" })} />);
});
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
// <svg> (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();

View File

@ -127,7 +127,7 @@ export function IssueRow({
)}
>
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} size="lg" className={selectedStatusClass} />}
{productivityReviewIndicator}
{parkedBlockerIndicator}
{recoveryIndicator}
@ -148,7 +148,7 @@ export function IssueRow({
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} size="lg" className={selectedStatusClass} />
{productivityReviewIndicator}
</span>
{checklistStep}

View File

@ -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
// `?? <StatusIcon size="lg">` 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(
<IssuesList
issues={[createIssue({ status: "in_progress" })]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => 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();
});
});
});

View File

@ -1828,7 +1828,7 @@ export function IssuesList({
</button>
) : (
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} size="lg" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)
}
@ -1855,7 +1855,7 @@ export function IssuesList({
checklistStepNumber={checklistStepNumber}
statusSlot={(
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} size="lg" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)}
/>

View File

@ -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('<code style="overflow-wrap:anywhere;word-break:break-word">PAP-1271</code>');
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" }] });

View File

@ -96,12 +96,14 @@ function MarkdownIssueLink({
<Link
to={`/issues/${identifier}`}
data-mention-kind="issue"
className="paperclip-markdown-issue-ref"
// Boxless inline mention: the unified status glyph + a regular-weight
// underlined link, optically centered with the body text.
className={cn("paperclip-markdown-issue-ref", "font-normal underline")}
title={title}
aria-label={issueLabel}
>
{status ? (
<StatusIcon status={status} className="mr-1 h-3 w-3 align-[-0.125em]" />
<StatusIcon status={status} size="lg" className="relative -top-px mr-1 inline-block h-5 w-5 align-middle" />
) : null}
{children}
</Link>

View File

@ -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<string, keyof typeof brandChipBadge> = {
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(<IssueStatusBadge status={status} />);
// 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("<svg");
// Human-readable label, underscores spaced out.
expect(html).toContain(status.replace(/_/g, " "));
expect(html).toContain(`var(${cssVar})`);
expect(html).toContain('viewBox="0 0 24 24"'); // unified glyph
}
});
it("uses liveness blue for in_progress (not amber) and amber for todo (not blue)", () => {
const prog = renderToStaticMarkup(<IssueStatusBadge status="in_progress" />);
expect(prog).toContain("#DBEAFE"); // blue light bg
expect(prog).not.toContain("#FEF3C7"); // not amber
const todo = renderToStaticMarkup(<IssueStatusBadge status="todo" />);
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(<IssueStatusBadge status="in_progress" />)).toContain("var(--status-task-in_progress)");
expect(renderToStaticMarkup(<IssueStatusBadge status="todo" />)).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(<IssueStatusBadge status="in_review" />);
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(<IssueStatusBadge status="cancelled" />);
expect(html).toContain("line-through");
expect(renderToStaticMarkup(<IssueStatusBadge status="cancelled" />)).toContain("line-through");
});
it("falls back to the gray token for unknown statuses", () => {
const html = renderToStaticMarkup(<IssueStatusBadge status="mystery" />);
expect(html).toContain(brandChipBadge.gray.split(" ")[0]);
it("falls back to the backlog (gray) var for unknown statuses", () => {
expect(renderToStaticMarkup(<IssueStatusBadge status="mystery" />)).toContain("var(--status-task-backlog)");
});
it("is independent of the Conference Room Chat flag", () => {
conferenceRoomChatFlag.enabled = false;
const html = renderToStaticMarkup(<IssueStatusBadge status="todo" />);
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(<IssueStatusBadge status="in_progress" />);
expect(html).not.toContain("<svg");
expect(html).not.toContain("#DBEAFE");
// Master's StatusBadge markup with master's hues (in_progress → yellow).
expect(html).toBe(renderToStaticMarkup(<StatusBadge status="in_progress" />));
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(<AgentStatusBadge status={status} />);
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(<AgentStatusBadge status="active" />)).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(<StatusBadge status="todo" />)).toContain("bg-blue-100");
expect(renderToStaticMarkup(<StatusBadge status="in_progress" />)).toContain("bg-yellow-100");
expect(renderToStaticMarkup(<StatusBadge status="in_progress" />)).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(<StatusBadge status="todo" />)).toContain("bg-amber-100");
expect(renderToStaticMarkup(<StatusBadge status="in_progress" />)).toContain("bg-blue-100");
});
});
describe("IssueStatusGlyph", () => {
it("gives in_progress a half-filled ring (liveness)", () => {
const html = renderToStaticMarkup(<IssueStatusGlyph status="in_progress" />);
// 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(<IssueStatusGlyph status="in_review" />);
expect(html).toContain('r="2"');
});
it("gives done a filled circle with a knocked-out check", () => {
const html = renderToStaticMarkup(<IssueStatusGlyph status="done" />);
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(<IssueStatusGlyph status="blocked" />);
expect(html).toContain("<rect");
});
it("gives backlog a dashed ring", () => {
const html = renderToStaticMarkup(<IssueStatusGlyph status="backlog" />);
expect(html).toContain('stroke-dasharray="2 2"');
});
it("gives cancelled a ring + slash", () => {
const html = renderToStaticMarkup(<IssueStatusGlyph status="cancelled" />);
expect(html).toContain('d="M3 9 9 3"');
});
});

View File

@ -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 (
<span
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium leading-none whitespace-nowrap shrink-0",
agentStatusBadge[color]
)}
className="status-chip inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium leading-none whitespace-nowrap shrink-0"
style={scStyle(cssVar)}
>
{label.replace(/_/g, " ")}
</span>
@ -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 (
<span
aria-hidden
className={cn("inline-block h-4 w-2 rounded-[4px] shrink-0", agentStatusCapsule[color], motion)}
className={cn("status-fill inline-block h-4 w-2 rounded-[4px] shrink-0", motion)}
style={scStyle(cssVar)}
/>
);
}
/**
* 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 (
<svg {...svgProps}>
<circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
</svg>
);
case "in_progress":
return (
<svg {...svgProps}>
<circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
<path d="M6 1.5 A4.5 4.5 0 0 1 6 10.5 Z" fill="currentColor" />
</svg>
);
case "in_review":
return (
<svg {...svgProps}>
<circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
<circle cx="6" cy="6" r="2" fill="currentColor" />
</svg>
);
case "done":
return (
<svg {...svgProps}>
<circle cx="6" cy="6" r="5" fill="currentColor" />
<path
d="M3.5 6 5.5 8 8.5 4.5"
fill="none"
className="stroke-background"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
case "blocked":
return (
<svg {...svgProps}>
<circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
<rect x="3.5" y="5.2" width="5" height="1.6" rx="0.4" fill="currentColor" />
</svg>
);
case "cancelled":
return (
<svg {...svgProps}>
<circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
<path d="M3 9 9 3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
);
case "backlog":
default:
return (
<svg {...svgProps}>
<circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.2" strokeDasharray="2 2" />
</svg>
);
}
}
/**
* 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 <StatusBadge status={status} />;
}
const cssVar = taskStatusVar[status] ?? taskStatusVarDefault;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium leading-none whitespace-nowrap shrink-0",
brandChipBadge[color],
"status-chip inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-normal leading-none whitespace-nowrap shrink-0",
status === "cancelled" && "line-through"
)}
style={scStyle(cssVar)}
>
<IssueStatusGlyph status={status} />
{status.replace(/_/g, " ")}
<StatusGlyph status={status} size="sm" />
{sentenceCaseStatus(status)}
</span>
);
}

View File

@ -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(<StatusGlyph status={status} />);
expect(html).toContain('viewBox="0 0 24 24"');
expect(html).toContain("<svg");
}
});
it("maps sm/md/lg to 14/16/20 px", () => {
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="sm" />)).toContain('width="14"');
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="md" />)).toContain('width="16"');
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="lg" />)).toContain('width="20"');
// Default size is md.
expect(renderToStaticMarkup(<StatusGlyph status="todo" />)).toContain('width="16"');
});
it("colours each status from its --status-task-icon-* var", () => {
for (const [status, cssVar] of Object.entries(taskStatusIconVar)) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html).toContain(`var(${cssVar})`);
}
});
it("falls back to the backlog icon var for unknown statuses", () => {
const html = renderToStaticMarkup(<StatusGlyph status="mystery" />);
expect(html).toContain("var(--status-task-icon-backlog)");
});
it("gives backlog a uniform dashed ring (pathLength=100)", () => {
const html = renderToStaticMarkup(<StatusGlyph status="backlog" />);
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(<StatusGlyph status="todo" />);
expect(html).toContain('r="8.5"');
expect(html).not.toContain("<path");
expect(html).not.toContain("<rect");
});
it("gives in_progress a half-filled ring (liveness)", () => {
const html = renderToStaticMarkup(<StatusGlyph status="in_progress" />);
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(<StatusGlyph status="in_review" />);
expect(html).toContain('r="3.6"');
});
it("gives done a filled disc with a knocked-out check in the surface colour", () => {
const html = renderToStaticMarkup(<StatusGlyph status="done" />);
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(<StatusGlyph status="blocked" />);
expect(html).toContain("<rect");
expect(html).toContain('width="10"');
});
it("gives cancelled a ring + slash", () => {
const html = renderToStaticMarkup(<StatusGlyph status="cancelled" />);
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(<StatusGlyph status="in_queue" />);
const blocked = renderToStaticMarkup(<StatusGlyph status="blocked" />);
// Same geometry as blocked (ring + bar)…
expect(queue).toContain("<rect");
expect(queue).toContain('width="10"');
// …but coloured from the in_progress (blue) icon var, not blocked's red.
expect(queue).toContain("var(--status-task-icon-in_queue)");
expect(queue).not.toContain("var(--status-task-icon-blocked)");
expect(blocked).toContain("var(--status-task-icon-blocked)");
});
it("is decorative by default and labelled when given a title", () => {
expect(renderToStaticMarkup(<StatusGlyph status="todo" />)).toContain('aria-hidden="true"');
const labelled = renderToStaticMarkup(<StatusGlyph status="todo" title="Todo" />);
expect(labelled).toContain('role="img"');
expect(labelled).toContain('aria-label="Todo"');
expect(labelled).toContain("<title>Todo</title>");
});
});

View File

@ -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<StatusGlyphSize, number> = { 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 <circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" strokeWidth={SW} />;
case "in_progress":
return (
<>
<circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" strokeWidth={SW} />
<path d="M12 3.5 A8.5 8.5 0 0 1 12 20.5 Z" fill="currentColor" />
</>
);
case "in_review":
return (
<>
<circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" strokeWidth={SW} />
<circle cx="12" cy="12" r="3.6" fill="currentColor" />
</>
);
case "done":
return (
<>
<circle cx="12" cy="12" r="9.5" fill="currentColor" />
{/* Check knocked out in the surface colour so the disc reads at any size. */}
<path
d="M7.5 12.2 10.6 15.2 16.5 8.8"
fill="none"
className="stroke-background"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
);
case "blocked":
return (
<>
<circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" strokeWidth={SW} />
<rect x="7" y="10.7" width="10" height="2.6" rx="1" fill="currentColor" />
</>
);
case "cancelled":
return (
<>
<circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" strokeWidth={SW} />
<path d="M6.5 17.5 17.5 6.5" stroke="currentColor" strokeWidth={SW} strokeLinecap="round" />
</>
);
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 (
<circle
cx="12"
cy="12"
r="8.5"
fill="none"
stroke="currentColor"
strokeWidth={SW}
pathLength={100}
strokeDasharray="6.25 6.25"
/>
);
}
}
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 (
<svg
width={px}
height={px}
viewBox="0 0 24 24"
className={cn("inline-block shrink-0 align-middle", className)}
style={{ color: `var(${cssVar})` } as CSSProperties}
{...a11y}
>
{title ? <title>{title}</title> : null}
{glyphBody(status)}
</svg>
);
}

View File

@ -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(<StatusIcon status="todo" />)).toContain("text-blue-600");
expect(renderToStaticMarkup(<StatusIcon status="in_progress" />)).toContain("text-yellow-600");
});
it("uses PAP-75 brand hues (todo amber, in_progress blue) when the flag is ON", () => {
expect(renderToStaticMarkup(<StatusIcon status="todo" />)).toContain("text-amber-600");
expect(renderToStaticMarkup(<StatusIcon status="in_progress" />)).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(<StatusIcon status="in_progress" />);
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(<StatusIcon status="todo" />);
expect(html).toContain("var(--status-task-icon-todo)");
});
it("maps covered-blocked → In queue (blue in_queue var, no cyan markers)", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
@ -39,77 +31,28 @@ describe("StatusIcon", () => {
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(
<StatusIcon
status="blocked"
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
sampleStalledBlockerIdentifier: null,
}}
/>,
);
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(
<StatusIcon
status="blocked"
blockerAttention={{
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
}}
/>,
);
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(
<StatusIcon
status="blocked"
blockerAttention={{
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 5,
unresolvedBlockerCount: 3,
coveredBlockerCount: 2,
stalledBlockerCount: 0,
attentionBlockerCount: 3,
@ -118,15 +61,13 @@ describe("StatusIcon", () => {
}}
/>,
);
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(
<StatusIcon
status="blocked"
@ -142,11 +83,25 @@ describe("StatusIcon", () => {
}}
/>,
);
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(<StatusIcon status="todo" onChange={() => {}} />);
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(<StatusIcon status="todo" size="lg" />);
expect(html).toContain('width="20"');
expect(html).toContain('height="20"');
});
it("defaults to a 16px (md) glyph when size is omitted", () => {
const html = renderToStaticMarkup(<StatusIcon status="todo" />);
expect(html).toContain('width="16"');
expect(html).toContain('height="16"');
});
});

View File

@ -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 = (
<span
className={cn(
"relative inline-flex h-4 w-4 rounded-full border-2 shrink-0",
colorClass,
onChange && !showLabel && "cursor-pointer",
className
)}
data-blocker-attention-state={blockerAttentionState}
aria-label={ariaLabel}
const glyph = (
<StatusGlyph
status={glyphStatus}
size={size}
className={cn(onChange && !showLabel && "cursor-pointer", className)}
title={ariaLabel}
>
{isDone && (
<span className="absolute inset-0 m-auto h-2 w-2 rounded-full bg-current" />
)}
{isCoveredBlocked && (
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
)}
{hasCoveredBlockedWork && (
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-cyan-600 dark:bg-cyan-400" />
)}
{isStalledBlocked && (
<span className="absolute inset-0 m-auto h-1.5 w-1.5 rounded-full bg-current" />
)}
</span>
/>
);
if (!onChange) return showLabel ? <span className="inline-flex items-center gap-1.5">{circle}<span className="text-sm">{statusLabel(status)}</span></span> : circle;
if (!onChange) {
return showLabel ? (
<span className="inline-flex items-center gap-1.5">
{glyph}
<span className="text-sm">{statusLabel(status)}</span>
</span>
) : (
glyph
);
}
const trigger = showLabel ? (
<button className="inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors">
{circle}
{glyph}
<span className="text-sm">{statusLabel(status)}</span>
</button>
) : circle;
) : (
glyph
);
return (
<Popover open={open} onOpenChange={setOpen}>
@ -138,7 +125,7 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
setOpen(false);
}}
>
<StatusIcon status={s} />
<StatusIcon status={s} size="lg" />
{statusLabel(s)}
</Button>
))}

View File

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

View File

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

View File

@ -189,6 +189,57 @@ export const issueStatusColor: Record<string, BrandChipColor> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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
// ---------------------------------------------------------------------------

View File

@ -484,7 +484,10 @@ describe("InboxIssueMetaLeading", () => {
root.render(<InboxIssueMetaLeading issue={createIssue()} isLive />);
});
const statusIcon = container.querySelector('span[class*="border-blue-600"]');
// The status glyph is an <svg> 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();

View File

@ -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 ? (
<StatusIcon status={breadcrumbStatus} size="lg" blockerAttention={breadcrumbBlockerAttention} />
) : 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<string>();
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() {
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<StatusIcon
status={issue.status}
size="lg"
blockerAttention={issue.blockerAttention}
onChange={(status) => updateIssue.mutate({ status })}
/>