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:
parent
50ae8fc657
commit
bac15ebd09
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2303,6 +2303,7 @@ export function IssueProperties({
|
|||
<PropertyRow label="Status">
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
size="lg"
|
||||
blockerAttention={issue.blockerAttention}
|
||||
onChange={(status) => onUpdate({ status })}
|
||||
showLabel
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" }] });
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue