feat(ui): use Lucide icons for task status glyphs (#9395)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Operators scan task state constantly, so the task **status**
vocabulary (backlog / todo / in progress / in review / done / blocked /
cancelled) has to read instantly
> - Those statuses render through one shared component, `StatusGlyph`,
whose icons were hand-rolled SVG geometry lifted from an internal spec
> - Hand-rolled glyphs are harder to reason about, drift from the rest
of the UI (which uses Lucide everywhere else), and mix fill/stroke
styles across statuses
> - This pull request swaps the hand-rolled geometry for named Lucide
icons — one clean, consistent icon family — with no change to colours,
sizing, or accessibility
> - The benefit is a status icon set that is consistent with the rest of
the app's iconography, trivially adjustable (change a mapping, not SVG
path math), and simpler to maintain

## Linked Issues or Issue Description

No existing public GitHub issue. Describing the change in-PR
(feature/polish):

**Problem / motivation.** The task status icons in `StatusGlyph` were
bespoke inline SVGs (a half-filled disc for *in progress*, a filled disc
+ knockout check for *done*, ring+bar for *blocked*, ring+slash for
*cancelled*, etc.). The rest of the UI uses [Lucide](https://lucide.dev)
icons, so the status set was the odd one out — and its mixed fill/stroke
shapes were harder to scan and to tweak.

**Proposed solution.** Map each status to a Lucide icon and render that
instead:

| Status | Lucide icon |
| --- | --- |
| backlog | `circle-dashed` |
| todo | `circle` |
| in_progress | `rotate-cw` |
| in_review | `circle-dot` |
| done | `circle-check` |
| blocked | `circle-minus` |
| cancelled | `ban` |
| in_queue (covered-blocked) | `circle-minus`, recoloured blue |

Colours (the `--status-task-icon-*` tokens), the `sm/md/lg` size scale,
`currentColor` recolouring, and the `role="img"` / `aria-label`
behaviour are all unchanged — only the shapes change.

**Alternatives considered.** Keeping the bespoke geometry (rejected:
inconsistent with the app and harder to maintain).

**Related PRs** (linked for reviewer context, not dependencies):
- Refs #8580 — the merged PR that established the current hand-rolled
status glyphs this PR restyles.
- Refs #8838 — open PR forwarding Radix trigger props through
`StatusGlyph`; touches the same component (no overlap with this change).
- Refs #1760 — open proposal to redesign the *cancelled* status icon
specifically; this PR moves cancelled to Lucide `ban`.

## What Changed

- `ui/src/components/StatusGlyph.tsx`: replaced the per-status
hand-rolled SVG `glyphBody()` geometry with a `status → Lucide icon` map
(`circle-dashed`, `circle`, `rotate-cw`, `circle-dot`, `circle-check`,
`circle-minus`, `ban`). Kept the token-driven colour wiring, size scale,
`currentColor` recolouring, a11y label handling, and the `in_queue` =
blocked-icon-recoloured-blue behaviour.
- `ui/src/components/StatusGlyph.test.tsx`: updated to lock the new icon
mapping (per-status Lucide class, size scale, colour var, `in_queue`,
a11y) instead of the old geometry.

Net: two files, +74 / −138 (the component got smaller). Because every
status surface (list, board, detail header, status picker,
sub-task/blocked-by pills, chips) routes through `StatusGlyph`, this
single-component edit covers them all.

## Verification

- `pnpm check:token-gates` → **3/3 clean** (no hardcoded
colour/spacing/font values introduced).
- `pnpm typecheck` → clean across all packages.
- `cd ui && pnpm vitest run` → **2509/2509 passing**, including the
updated `StatusGlyph` test.
- Manual: ran the worktree dev server and confirmed the new icons render
everywhere (task list, task detail, related-task chips, and the status
picker showing all seven).

**Storybook visual-regression note:** this is an intentional visual
change, so the status-icon stories will diff against the published
baseline. The baseline snapshots need to be regenerated and republished
by a maintainer (`pnpm test:storybook-visual:update` from a trusted
environment) as part of accepting this change — the visual-regression CI
check is expected to be red until then. No baseline is published in the
environment this PR was authored in, so that step is left to a
maintainer.

## Risks

- **Low risk / cosmetic.** No logic, data, or API changes — only the
rendered icon shapes. Colours, sizes, and accessibility labels are
unchanged.
- The most noticeable shifts are *in progress* (half-disc → rotating
arrow), *done* (solid disc+check → outline circle+check), and
*cancelled* (ring+slash → ban). These are deliberate.
- The only CI check expected to fail is the Storybook visual-regression
job, pending a maintainer baseline update (see Verification).

## Model Used

Claude Opus 4.8 (`claude-opus-4-8`), run in Claude Code with extended
thinking and tool use (file edits, local test runs, browser-driven
visual verification).

## 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 (e.g. `docs/...`, `fix/...`)
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
- [ ] All Paperclip CI gates are green
- [ ] 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>
This commit is contained in:
scotttongandClaude Opus 4.8 authored and GitHub committed 2026-07-10 20:18:33 -05:00
1 parent e0f1905222
commit 7fd321d622
2 files changed
+74 -138

No files matched your search

+33 -46
View File
@@ -6,11 +6,24 @@ 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`.
* The unified status glyph renders every status from ONE Lucide icon per status
* (all on a shared `viewBox="0 0 24 24"`, at a `sm 14 / md 16 / lg 20` scale),
* coloured from the AA-tuned `--status-task-icon-*` vars. These tests lock the
* icon mapping, the size scale, the colour wiring and `in_queue`.
*/
/** Status → the Lucide class token its icon renders (`lucide-<kebab-name>`). */
const STATUS_ICON_CLASS: Record<string, string> = {
backlog: "lucide-circle-dashed",
todo: "lucide-circle",
in_progress: "lucide-rotate-cw",
in_review: "lucide-circle-dot",
done: "lucide-circle-check",
blocked: "lucide-circle-minus",
cancelled: "lucide-ban",
in_queue: "lucide-circle-minus",
};
describe("StatusGlyph", () => {
it("renders one 24-unit viewBox for every status (proportional scaling)", () => {
for (const status of Object.keys(taskStatusIconVar)) {
@@ -35,59 +48,33 @@ describe("StatusGlyph", () => {
}
});
it("falls back to the backlog icon var for unknown statuses", () => {
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)");
expect(html).toContain("lucide-circle-dashed");
});
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("maps each status to its Lucide icon", () => {
for (const [status, iconClass] of Object.entries(STATUS_ICON_CLASS)) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html).toContain(iconClass);
}
});
it("gives todo a bare open ring (no inner shape)", () => {
it("gives todo the plain circle (not a compound circle icon)", () => {
const html = renderToStaticMarkup(<StatusGlyph status="todo" />);
expect(html).toContain('r="8.5"');
expect(html).not.toContain("<path");
expect(html).not.toContain("<rect");
expect(html).toContain("lucide-circle");
for (const compound of ["circle-dashed", "circle-dot", "circle-check", "circle-minus"]) {
expect(html).not.toContain(compound);
}
});
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)", () => {
it("renders in_queue as the blocked icon recoloured blue (in_queue 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.
// Same icon as blocked (circle-minus)…
expect(queue).toContain("lucide-circle-minus");
// …but coloured from the in_queue (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)");
+41 -92
View File
@@ -1,24 +1,32 @@
import type { CSSProperties } from "react";
import {
Ban,
Circle,
CircleCheck,
CircleDashed,
CircleDot,
CircleMinus,
RotateCw,
type LucideIcon,
} from "lucide-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.
* Unified task status glyph — the single source-of-truth icon for every
* task/issue status. Each status maps to one Lucide icon (all drawn on the same
* `viewBox="0 0 24 24"` so they scale proportionally at any size), so the whole
* set reads as one consistent icon family:
*
* 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).
* backlog → circle-dashed · todo → circle · in_progress → rotate-cw ·
* in_review → circle-dot · done → circle-check · blocked → circle-minus ·
* cancelled → ban · in_queue → circle-minus (blocked recoloured blue).
*
* 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).
* 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 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";
@@ -26,9 +34,6 @@ 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"
@@ -39,6 +44,21 @@ export type StatusGlyphStatus =
| "cancelled"
| "in_queue";
/** Status → Lucide icon. `in_queue` borrows the blocked icon; its colour var resolves to blue. */
const STATUS_ICON: Record<string, LucideIcon> = {
backlog: CircleDashed,
todo: Circle,
in_progress: RotateCw,
in_review: CircleDot,
done: CircleCheck,
blocked: CircleMinus,
cancelled: Ban,
in_queue: CircleMinus,
};
/** Unknown statuses fall back to the backlog icon (matches the colour-var fallback). */
const STATUS_ICON_DEFAULT = CircleDashed;
interface StatusGlyphProps {
status: string;
/** sm 14 / md 16 / lg 20. Default `md`. */
@@ -48,92 +68,21 @@ interface StatusGlyphProps {
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 Icon = STATUS_ICON[status] ?? STATUS_ICON_DEFAULT;
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"
<Icon
size={px}
className={cn("inline-block shrink-0 align-middle", className)}
style={{ color: `var(${cssVar})` } as CSSProperties}
{...a11y}
>
{title ? <title>{title}</title> : null}
{glyphBody(status)}
</svg>
</Icon>
);
}