diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 7b07368e..302b8fbd 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -8908,3 +8908,66 @@ PATH / tool-resolution dependency (2026-06-28): the d2 compile step shells out t DONE (2026-06-29): d2 diagram template landed + fast suite green. Decision RESOLVED: shell out to the d2 binary (supporter-tool pattern, D-3/D-5) — not vendored. d2TemplateHandler compiles doc.source -> SVG via ''d2 - -'', resolved through the D-104 path layer (T-495); painted by the T-320 SVG renderer. CLI infers type from .d2 (and .svg renders directly). Template handlers now return DrawResult so an unresolved d2 or a compile failure surface as honest userError with an install hint. The d2 source folds into a collapsed ''view source'' disclosure (D-78). ACCEPTANCE VERIFIED LIVE: resolver found /home/linuxbrew/.linuxbrew/bin/d2 and compiled a -> b to a 13.5KB SVG end-to-end (the brew-on-linux case). Commits 64d77ec5 (core) + 5f8fa6af (disclosure).', NULL, '2026-06-29 10:23:55', '2026-06-29 10:23:55.745', '2026-06-29 10:23:55.745', NULL, '1ab9a75ee23a5332edc9e3459a92b6cd', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGX26B0NAC9WRJMVB0QE6CV8', 'status', 'backlog', 'done', NULL, '2026-06-29 10:23:55', '2026-06-29 10:23:55.781', '2026-06-29 10:23:55.781', NULL, 'ff09222d1e3a77f0a082caf302aedf07', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 10:41:42', '2026-06-29 10:41:42.262', '2026-06-29 10:41:42.262', NULL, '6ecda6d328b1cc8ade1d80e5f2de08f1', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'description', 'Give `clide image show` the same structured-metadata plumbing the icon card gets (T-313), so an image driven into the conversation can carry ANNOTATIONS — not just a single one-line caption. + +## Why + +Today image.show (lib/src/daemon/image_commands.dart) takes a `path` positional plus an optional `--caption` string, and ImageMessage (lib/builtin/claude/src/transcript_reader.dart:160) holds only `path` + optional one-line `caption`. When showing a screenshot/wireframe to discuss, one line is thin — we want to attach a title/label, a longer description, and potentially captioned markers (callouts on specific spots). This mirrors the per-entry label+description the icon card introduces. + +## Deliverable + +Extend image.show to accept a JSON metadata payload (via the new stdin plumbing T-315 and/or a `--file ` flag, same as T-313''s icon entries) describing the image''s annotations, e.g.: + + { "path": "docs/shot.png", "label": "HUD v3", "description": "note the cramped status row", "caption": "before the fix" } + +Extend ImageMessage + the image card to render the richer metadata. Keep the existing `clide image show --caption "…"` form working unchanged (back-compat); the JSON payload is additive. + +## Open decision — text vs visual annotation + +''Annotated'' could mean (a) richer TEXT metadata shown around the image (label + description + caption), or (b) VISUAL overlays drawn ON the image (markers/arrows/numbered callouts at coordinates). Start with (a) — straightforward extension of the current card. (b) is a bigger, clide-owned CustomPaint job (markers:[{x,y,label}] painted over the image); feasible since the image card is our own rendering, but scope it as a follow-up unless we decide we need it now. Flag which we want before building markers. + +## Dependencies + +- Pairs with T-315 (stdin JSON plumbing) for the piped form; can land with `--file` alone if T-315 isn''t ready. +- Parallel to T-313 (icon card) — same --file/--stdin metadata pattern, same Flutter-free handler + MessageBus publish path. + +## Acceptance + +- `clide image show` accepts a JSON metadata payload (via --file, and via --stdin once T-315 lands) carrying at least label + description alongside the existing caption. +- ImageMessage + the image card render the added metadata. +- The existing `image show [--caption]` form is unchanged. +- Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation. +- Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision.', 'Give `clide image show` the same structured-metadata plumbing the icon card gets (T-313), so an image driven into the conversation can carry ANNOTATIONS — not just a single one-line caption. + +## Why + +Today image.show (lib/src/daemon/image_commands.dart) takes a `path` positional plus an optional `--caption` string, and ImageMessage (lib/builtin/claude/src/transcript_reader.dart:160) holds only `path` + optional one-line `caption`. When showing a screenshot/wireframe to discuss, one line is thin — we want to attach a title/label, a longer description, and potentially captioned markers (callouts on specific spots). This mirrors the per-entry label+description the icon card introduces. + +## Deliverable + +Extend image.show to accept a JSON metadata payload (via the new stdin plumbing T-315 and/or a `--file ` flag, same as T-313''s icon entries) describing the image''s annotations, e.g.: + + { "path": "docs/shot.png", "label": "HUD v3", "description": "note the cramped status row", "caption": "before the fix" } + +Extend ImageMessage + the image card to render the richer metadata. Keep the existing `clide image show --caption "…"` form working unchanged (back-compat); the JSON payload is additive. + +## Open decision — text vs visual annotation + +''Annotated'' could mean (a) richer TEXT metadata shown around the image (label + description + caption), or (b) VISUAL overlays drawn ON the image (markers/arrows/numbered callouts at coordinates). Start with (a) — straightforward extension of the current card. (b) is a bigger, clide-owned CustomPaint job (markers:[{x,y,label}] painted over the image); feasible since the image card is our own rendering, but scope it as a follow-up unless we decide we need it now. Flag which we want before building markers. + +## Dependencies + +- Pairs with T-315 (stdin JSON plumbing) for the piped form; can land with `--file` alone if T-315 isn''t ready. +- Parallel to T-313 (icon card) — same --file/--stdin metadata pattern, same Flutter-free handler + MessageBus publish path. + +## Acceptance + +- `clide image show` accepts a JSON metadata payload (via --file, and via --stdin once T-315 lands) carrying at least label + description alongside the existing caption. +- ImageMessage + the image card render the added metadata. +- The existing `image show [--caption]` form is unchanged. +- Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation. +- Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision. + +DONE (2026-06-29): text-annotation payload landed (option a). image.show --file {path,label,description,caption}; ImageMessage + card render label (title) + description; bare positional/--caption form unchanged; honest userError on malformed/missing payload. Tests: command (--file, malformed, missing) + widget (label/description render). Commit f0fb5a51. Visual marker overlays (option b) split to a follow-up.', NULL, '2026-06-29 10:58:54', '2026-06-29 10:58:54.871', '2026-06-29 10:58:54.871', NULL, '441e112bc4d899f4bde04d27b4a04f09', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'status', 'in_progress', 'done', NULL, '2026-06-29 10:58:54', '2026-06-29 10:58:54.909', '2026-06-29 10:58:54.909', NULL, 'd75063a149400a6fd1a418dd5bc0da2f', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 10:59:39', '2026-06-29 10:59:39.390', '2026-06-29 10:59:39.390', NULL, '107169be09c5a9a31a7c19a905cecce9', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 2d367fba..c2073f09 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -10730,3 +10730,121 @@ Extend ImageMessage + the image card to render the richer metadata. Keep the exi - The existing `image show [--caption]` form is unchanged. - Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation. - Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:36', '2026-06-29 10:41:42.262', NULL, 'b2f5d954720a3a837142c31aaac94257', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide image show: JSON metadata payload so images can be annotated (label/description/markers)', 'Give `clide image show` the same structured-metadata plumbing the icon card gets (T-313), so an image driven into the conversation can carry ANNOTATIONS — not just a single one-line caption. + +## Why + +Today image.show (lib/src/daemon/image_commands.dart) takes a `path` positional plus an optional `--caption` string, and ImageMessage (lib/builtin/claude/src/transcript_reader.dart:160) holds only `path` + optional one-line `caption`. When showing a screenshot/wireframe to discuss, one line is thin — we want to attach a title/label, a longer description, and potentially captioned markers (callouts on specific spots). This mirrors the per-entry label+description the icon card introduces. + +## Deliverable + +Extend image.show to accept a JSON metadata payload (via the new stdin plumbing T-315 and/or a `--file ` flag, same as T-313''s icon entries) describing the image''s annotations, e.g.: + + { "path": "docs/shot.png", "label": "HUD v3", "description": "note the cramped status row", "caption": "before the fix" } + +Extend ImageMessage + the image card to render the richer metadata. Keep the existing `clide image show --caption "…"` form working unchanged (back-compat); the JSON payload is additive. + +## Open decision — text vs visual annotation + +''Annotated'' could mean (a) richer TEXT metadata shown around the image (label + description + caption), or (b) VISUAL overlays drawn ON the image (markers/arrows/numbered callouts at coordinates). Start with (a) — straightforward extension of the current card. (b) is a bigger, clide-owned CustomPaint job (markers:[{x,y,label}] painted over the image); feasible since the image card is our own rendering, but scope it as a follow-up unless we decide we need it now. Flag which we want before building markers. + +## Dependencies + +- Pairs with T-315 (stdin JSON plumbing) for the piped form; can land with `--file` alone if T-315 isn''t ready. +- Parallel to T-313 (icon card) — same --file/--stdin metadata pattern, same Flutter-free handler + MessageBus publish path. + +## Acceptance + +- `clide image show` accepts a JSON metadata payload (via --file, and via --stdin once T-315 lands) carrying at least label + description alongside the existing caption. +- ImageMessage + the image card render the added metadata. +- The existing `image show [--caption]` form is unchanged. +- Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation. +- Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision. + +DONE (2026-06-29): text-annotation payload landed (option a). image.show --file {path,label,description,caption}; ImageMessage + card render label (title) + description; bare positional/--caption form unchanged; honest userError on malformed/missing payload. Tests: command (--file, malformed, missing) + widget (label/description render). Commit f0fb5a51. Visual marker overlays (option b) split to a follow-up.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:36', '2026-06-29 10:58:54.871', NULL, '1cf8a5bb479f2b42d84cef6af3990a1f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide image show: JSON metadata payload so images can be annotated (label/description/markers)', 'Give `clide image show` the same structured-metadata plumbing the icon card gets (T-313), so an image driven into the conversation can carry ANNOTATIONS — not just a single one-line caption. + +## Why + +Today image.show (lib/src/daemon/image_commands.dart) takes a `path` positional plus an optional `--caption` string, and ImageMessage (lib/builtin/claude/src/transcript_reader.dart:160) holds only `path` + optional one-line `caption`. When showing a screenshot/wireframe to discuss, one line is thin — we want to attach a title/label, a longer description, and potentially captioned markers (callouts on specific spots). This mirrors the per-entry label+description the icon card introduces. + +## Deliverable + +Extend image.show to accept a JSON metadata payload (via the new stdin plumbing T-315 and/or a `--file ` flag, same as T-313''s icon entries) describing the image''s annotations, e.g.: + + { "path": "docs/shot.png", "label": "HUD v3", "description": "note the cramped status row", "caption": "before the fix" } + +Extend ImageMessage + the image card to render the richer metadata. Keep the existing `clide image show --caption "…"` form working unchanged (back-compat); the JSON payload is additive. + +## Open decision — text vs visual annotation + +''Annotated'' could mean (a) richer TEXT metadata shown around the image (label + description + caption), or (b) VISUAL overlays drawn ON the image (markers/arrows/numbered callouts at coordinates). Start with (a) — straightforward extension of the current card. (b) is a bigger, clide-owned CustomPaint job (markers:[{x,y,label}] painted over the image); feasible since the image card is our own rendering, but scope it as a follow-up unless we decide we need it now. Flag which we want before building markers. + +## Dependencies + +- Pairs with T-315 (stdin JSON plumbing) for the piped form; can land with `--file` alone if T-315 isn''t ready. +- Parallel to T-313 (icon card) — same --file/--stdin metadata pattern, same Flutter-free handler + MessageBus publish path. + +## Acceptance + +- `clide image show` accepts a JSON metadata payload (via --file, and via --stdin once T-315 lands) carrying at least label + description alongside the existing caption. +- ImageMessage + the image card render the added metadata. +- The existing `image show [--caption]` form is unchanged. +- Malformed payload / unknown fields fail with a clear userError, mirroring image.show''s current validation. +- Visual marker overlays are explicitly noted as a separate follow-up unless pulled in by decision. + +DONE (2026-06-29): text-annotation payload landed (option a). image.show --file {path,label,description,caption}; ImageMessage + card render label (title) + description; bare positional/--caption form unchanged; honest userError on malformed/missing payload. Tests: command (--file, malformed, missing) + widget (label/description render). Commit f0fb5a51. Visual marker overlays (option b) split to a follow-up.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:36', '2026-06-29 10:58:54.909', NULL, '1c3430eabe5faa80af0ab7d6b151eb39', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FH621XBYJBGE1XPSFFK4YZGM', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Image card: visual marker/callout overlays (markers:[{x,y,label}]) painted over the image', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-29 10:58:54.943', '2026-06-29 10:58:54.943', NULL, '3484b2faac0b864f67a21f0c6d90792a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Native Phosphor glyph card — multi-icon list with optional labels/descriptions (previews + choice offers)', 'A conversation-pane card that renders one OR MANY Phosphor glyphs by name/codepoint, each with an optional label and an optional description line, so icons can be previewed and compared in the live pane — and so a set of candidate icons can be offered as a labelled choice list (e.g. picking settings-scope icons, T-302). + +## Why + +Picking icons needs to SEE real glyphs side by side with what each one means. Frame0 can''t render Phosphor (private-use codepoints, no font) and goldens render the font as Ahem boxes, so preview only works where the app has the font — a native card is the vehicle. Beyond a bare grid, real icon decisions are ''which of these N icons, for these N meanings'' — so each entry wants an optional label (what we''d call it) and an optional description (what it represents), turning the card into an offer/choice list. + +## Deliverable + +A conversation-pane card (peer of the image card, T-249/T-252) that renders a list/grid of entries, each entry = glyph + optional label + optional description, driven by the clide CLI (D-6 parity). Uses the bundled Phosphor.ttf via PhosphorIconPainter. + +### Multi-size rendering (per entry) + +Each icon is shown at SEVERAL sizes, not one: (1) a large hero rendering so the glyph''s detail is clearly legible, and (2) a sample at each font-size token the app actually uses inline, so you can judge how the glyph reads at real UI sizes. The relevant inline scale is in lib/widgets/src/typography.dart: clideFontBadge (11), clideFontSmall (12), clideFontMeta (13), clideFontCaption/clideFontMono (14), clideFontBody (15). Drive the inline samples off those tokens (not bare numbers) so the row tracks the scale if it changes. For the hero, REUSE the existing clideFontWelcomeBanner (52) token rather than adding a new constant — it''s already the app''s named oversized size; no new token needed. Lay the size samples out in a single row/strip per entry, smallest to largest, labelled with the token/px so a reviewer sees exactly where each size lands. + +### CLI shape + IPC wiring (mirror image.show exactly) + +Follow the image-card template (lib/src/daemon/image_commands.dart) end to end — it is the proven D-6 parity pattern: + +- REGISTRATION: a dotted `icon.show` command on DaemonDispatcher (invoked as `clide icon show`), declared with a CommandSchema — positional + per-arg ArgSpec — exactly like image.show''s `{positional: [''path''], args: {...}}`. Handler stays Flutter-free so it runs under `dart test`. +- BARE PREVIEW (variadic): one or more icons as positionals via ArgType.stringList — `clide icon show gear folder gauge` — each a kebab-case name (resolved by PhosphorIcons.byName) or a 0xNNNN codepoint. stringList is already supported by the schema (lib/src/ipc/command_schema.dart) and the argv parser, so no new CLI plumbing. +- LABELLED/DESCRIBED entries: a `--file ` flag whose value is a JSON array of `{"icon": "gear", "label": "Settings", "description": "global scope"}` (label, description optional); the handler reads and parses the file. NOTE: do NOT spec `--stdin` — clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals/flags/passthrough and has no stdin path, so a `--file` flag (or repeated flags) is the grounded choice unless we deliberately add stdin support as separate work. +- RENDER PATH: validate + resolve icon names in the handler (inject a resolver the way image.show injects ImagePathResolver, so headless/dart-test stays filesystem-free), then publish on a dedicated MessageBus channel — e.g. `iconShowChannel = ''icon''`, peer of `imageShowChannel = ''image''` — captured post-boot in main.dart; the Claude extension subscribes to that literal and injects the card into the primary session''s conversation log. Honest failure (IpcError userError/notFound) on an unknown glyph name or a malformed/missing --file, and on no live UI bus (headless), mirroring image.show. +- One card per invocation; entries render as rows (or a grid when label/description are absent). + +## Display-only card + interaction-zone selection (D-78 — decided) + +DECIDED: the display card is display-only; the SELECTION happens in the convo box (interaction zone), not on the card. The card renders the labelled icon options for the user to SEE; when a pick is needed, Claude offers a matching choice list in the interaction zone (AskUserQuestion-style options that replace the composer), and the user selects there. This keeps conversation widgets display-only per D-78 and the interaction-zone rule. + +The LABEL is the bridge between the two surfaces: Claude attaches a label to each icon on the display card, then offers the SAME labels as the options in the interaction-zone choice list — so ''I pick Settings'' in the convo box maps unambiguously back to the glyph the user saw on the card. That''s why per-entry labels are first-class here: they exist to facilitate this show-then-pick flow, with the description giving the extra context that doesn''t fit a one-word option. The card may still copy a codepoint/name on click (a convenience), but it never resolves the choice itself. + +## Notes / scope + +- Name->codepoint resolution ALREADY EXISTS: lib/widgets/src/icons/phosphor_glyphs.g.dart (generated, 1512 glyphs) + PhosphorIcons.byName. The earlier ''OPTIONAL: generate the full set'' caveat is resolved — every named glyph is already resolvable; the 49 curated consts in phosphor.dart remain curated sugar. +- Every glyph already renders via PhosphorIconPainter(0xNNNN). +- label and description are both optional per entry; an entry with neither degrades to the bare-preview look. +- Surfaced 2026-06-10 while choosing settings scope icons (T-302); refined 2026-06-10 to cover multi-icon labelled offer/choice lists. + +## Acceptance + +- `icon.show` is registered on DaemonDispatcher with a CommandSchema and invoked as `clide icon show`, mirroring image.show; the handler is Flutter-free and publishes on an `icon` MessageBus channel injected by the Claude extension. +- `clide icon show [ ...]` accepts multiple icons (variadic stringList positionals) in one call and renders them in a single conversation card. +- A `--file ` payload lets each icon carry an optional label and optional description, both rendered alongside the glyph (no --stdin — not supported by the CLI parser). +- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes. +- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card. +- Unknown/invalid icon names fail with a clear user error, not a blank glyph. + +FOLLOW-UPS: the piped-JSON (--stdin) variant is split out as T-315 (generic CLI stdin plumbing); image.show gets the same metadata/annotation treatment in T-316. T-313 ships with --file regardless of T-315. + +DESIGN REFINEMENT (2026-06-28, wireframe review): broaden the per-entry size-sample strip beyond clide''s inline typography tokens. clide icon show is a general-purpose glyph previewer, not bound to clide''s own UI, so the strip renders BOTH clide inline tokens (11–15: badge/small/meta/caption/body) AND common icon sizes (18, 20, 24, 32, 48). Hero stays 52 (clideFontWelcomeBanner). Update the acceptance bullet accordingly: ''a sample at each inline token (11–15) PLUS common sizes 18/20/24/32/48'', driven off named tokens where they exist and explicit px otherwise. Wireframe: docs/design/wireframes/conversation/icon-glyph-card.png. + +Size set FINALIZED (2026-06-28): one continuous sample strip — 10, 11, 12, 13, 14, 15, 18, 20, 24, 32, 48 px — no clide-vs-common visual split. Hero stays 52. Supersedes the grouped framing in the prior note. + +COLOR (2026-06-28, folded into payload spec): add an OPTIONAL per-entry ''color'' to the --file JSON, e.g. {"icon":"gear","label":"Settings","color":"#e2b714"}. Value is an ARBITRARY color — hex #rrggbb / #rrggbbaa (CSS-style names acceptable) — passed straight to PhosphorIconPainter. NOT a clide SurfaceTokens theme token: the glyph is content for whatever project we''re working on (its own palette), and the D-7 token discipline governs clide''s own chrome, not rendered content. Optional card-level default ''color''; a glyph with none falls back to the card''s default foreground. Unknown/malformed color → the same honest userError as a bad glyph name. Mirrors how arbitrary colors already flow through the image/svg cards.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-29 10:59:39.390', NULL, '0e6bef5261f0c792bbdc376c019101f0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/src/daemon/icon_commands.dart b/lib/src/daemon/icon_commands.dart new file mode 100644 index 00000000..7dd1a599 --- /dev/null +++ b/lib/src/daemon/icon_commands.dart @@ -0,0 +1,130 @@ +/// Registers `icon.show` — drive a Phosphor glyph card into the Claude +/// conversation from the CLI (T-313, D-6 parity). +/// +/// clide icon show gear folder gauge +/// clide icon show --file icons.json # entries with label/description/color +/// +/// Mirrors `image.show` end to end: a Flutter-free handler validates + resolves +/// each glyph (via an injected [IconResolver], so dart-test needs no font), +/// validates any colors (reusing [parseSvgColor] — hex or CSS name), then +/// publishes on the `icon` MessageBus channel. The Claude extension injects the +/// matching card. Honest userError on an unknown glyph, a malformed/missing +/// `--file`, or an unparseable color; toolError when there is no live UI. +/// +/// Flutter-free: pure Dart, runs under `dart test`. +library; + +import 'dart:convert'; + +import '../ipc/command_schema.dart'; +import '../ipc/envelope.dart'; +import '../ipc/schema_v1.dart'; +import '../svg/svg_color.dart' show parseSvgColor; +import 'dispatcher.dart'; +import 'ui_command.dart' show MessagePublisher; + +/// Resolves a Phosphor glyph name to its codepoint, or null if unknown. Injected +/// (wired to `kPhosphorGlyphs` in main.dart) so this file stays Flutter-free. +typedef IconResolver = int? Function(String name); + +/// Reads a metadata JSON file's contents, or null if unreadable. Injected for +/// testability. +typedef IconFileReader = Future Function(String path); + +/// The MessageBus channel `icon.show` publishes on; the Claude extension +/// subscribes to the same literal to inject the card. +const iconShowChannel = 'icon'; + +void registerIconCommands(DaemonDispatcher d, MessagePublisher? Function() publisher, {required IconResolver resolve, IconFileReader? readFile}) { + d.register( + 'icon.show', + (req) async => _show(req, publisher, resolve, readFile), + schema: const CommandSchema( + positional: ['icons'], + args: { + 'icons': ArgSpec(type: ArgType.stringList, rejectLeadingDash: true), + 'file': ArgSpec(rejectLeadingDash: true), + 'color': ArgSpec(), + }, + ), + ); +} + +IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err( + id: id, + error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint), +); + +Future _show(IpcRequest req, MessagePublisher? Function() publisherSource, IconResolver resolve, IconFileReader? readFile) async { + final defaultColor = _str(req.args['color']); + if (defaultColor != null && parseSvgColor(defaultColor) == null) { + return _userErr(req.id, 'invalid color: $defaultColor', hint: '#rrggbb, #rrggbbaa, or a CSS color name'); + } + + final entries = >[]; + final file = _str(req.args['file']); + + if (file != null) { + final raw = readFile == null ? null : await readFile(file); + if (raw == null) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.notFound, + kind: IpcErrorKind.notFound, + message: 'no such file: $file', + hint: 'path is resolved relative to the workspace root', + ), + ); + } + Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException catch (e) { + return _userErr(req.id, 'invalid JSON in $file: ${e.message}'); + } + if (decoded is! List) return _userErr(req.id, 'icon metadata must be a JSON array of entries'); + for (final item in decoded) { + if (item is! Map) return _userErr(req.id, 'each icon entry must be a JSON object'); + final token = _str(item['icon']); + if (token == null) return _userErr(req.id, 'each entry needs an "icon" name or 0x codepoint'); + final cp = _resolveIcon(token, resolve); + if (cp == null) return _userErr(req.id, 'unknown icon: $token', hint: 'a kebab-case Phosphor name (e.g. gear) or a 0xNNNN codepoint'); + final color = _str(item['color']); + if (color != null && parseSvgColor(color) == null) return _userErr(req.id, 'invalid color: $color', hint: '#rrggbb, #rrggbbaa, or a CSS color name'); + entries.add({'codepoint': cp, 'name': token, 'label': ?_str(item['label']), 'description': ?_str(item['description']), 'color': ?color}); + } + } else { + final icons = (req.args['icons'] as List?)?.whereType() ?? const []; + for (final token in icons) { + final cp = _resolveIcon(token, resolve); + if (cp == null) return _userErr(req.id, 'unknown icon: $token', hint: 'a kebab-case Phosphor name (e.g. gear) or a 0xNNNN codepoint'); + entries.add({'codepoint': cp, 'name': token}); + } + } + + if (entries.isEmpty) { + return _userErr(req.id, 'at least one icon is required (e.g. `icon show gear folder` or `icon show --file icons.json`)'); + } + + final publish = publisherSource(); + if (publish == null) { + return IpcResponse.err( + id: req.id, + error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'), + ); + } + publish('cli', iconShowChannel, {'entries': entries, 'color': ?defaultColor}); + return IpcResponse.ok(id: req.id, data: {'shown': true, 'count': entries.length}); +} + +/// Resolve an icon [token] — a `0xNNNN` codepoint or a glyph name — to a +/// codepoint, or null if it doesn't parse / resolve. +int? _resolveIcon(String token, IconResolver resolve) { + final t = token.trim(); + if (t.startsWith('0x') || t.startsWith('0X')) return int.tryParse(t.substring(2), radix: 16); + return resolve(t); +} + +/// Trimmed non-empty string, or null — for tolerant arg/JSON reads. +String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null; diff --git a/lib/src/ipc/command_schema.dart b/lib/src/ipc/command_schema.dart index dab04f5d..e852e1b2 100644 --- a/lib/src/ipc/command_schema.dart +++ b/lib/src/ipc/command_schema.dart @@ -156,8 +156,15 @@ class CommandSchema { final out = {}; final pos = raw['positional']; if (pos is List) { - for (var i = 0; i < pos.length && i < positional.length; i++) { - out[positional[i]] = pos[i]; + for (var i = 0; i < positional.length; i++) { + final name = positional[i]; + // A trailing stringList positional is variadic: it absorbs ALL the + // remaining tokens (e.g. `icon show gear folder gauge`), not just one. + if (i == positional.length - 1 && args[name]?.type == ArgType.stringList) { + out[name] = i < pos.length ? pos.sublist(i) : const []; + break; + } + if (i < pos.length) out[name] = pos[i]; } } final flags = raw['flags']; diff --git a/test/daemon/icon_commands_test.dart b/test/daemon/icon_commands_test.dart new file mode 100644 index 00000000..ae217242 --- /dev/null +++ b/test/daemon/icon_commands_test.dart @@ -0,0 +1,103 @@ +/// Tests for `icon.show` — the CLI that drives a Phosphor glyph card into the +/// Claude conversation (T-313, D-6 parity). Verifies glyph resolution (name + +/// 0xNNNN), the --file metadata payload (label/description/color), the published +/// `icon` payload, and honest failure on unknown glyphs / colors / no UI. +library; + +import 'package:clide/clide.dart'; +import 'package:clide/src/daemon/icon_commands.dart'; +import 'package:test/test.dart'; + +void main() { + late List<({String publisher, String channel, Map data})> published; + late DaemonDispatcher d; + + const glyphs = {'gear': 0xe2a4, 'folder': 0xe24a, 'gauge': 0xe1d0}; + + void wire({bool liveUi = true, Map files = const {}}) { + published = []; + d = DaemonDispatcher(); + registerIconCommands( + d, + () { + if (!liveUi) return null; + return (p, c, data) => published.add((publisher: p, channel: c, data: data)); + }, + resolve: (name) => glyphs[name], + readFile: (path) async => files[path], + ); + } + + Future show(List positional, {Map? flags}) => + d.dispatch(IpcRequest(id: '1', cmd: 'icon.show', args: {'positional': positional, 'flags': ?flags})); + + test('variadic positionals resolve to codepoint entries', () async { + wire(); + final r = await show(['gear', 'folder']); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data['count'], 2); + expect(published.single.channel, 'icon'); + expect(published.single.data['entries'], [ + {'codepoint': 0xe2a4, 'name': 'gear'}, + {'codepoint': 0xe24a, 'name': 'folder'}, + ]); + }); + + test('a 0xNNNN codepoint is accepted directly', () async { + wire(); + final r = await show(['0xe2a4']); + expect(r.ok, isTrue, reason: r.error?.message); + expect((published.single.data['entries'] as List).single, {'codepoint': 0xe2a4, 'name': '0xe2a4'}); + }); + + test('--file entries carry label, description, and color', () async { + wire(files: {'i.json': '[{"icon":"gear","label":"Settings","description":"global","color":"#e2b714"}]'}); + final r = await show([], flags: {'file': 'i.json'}); + expect(r.ok, isTrue, reason: r.error?.message); + expect((published.single.data['entries'] as List).single, { + 'codepoint': 0xe2a4, + 'name': 'gear', + 'label': 'Settings', + 'description': 'global', + 'color': '#e2b714', + }); + }); + + test('a card-level --color rides along', () async { + wire(); + final r = await show(['gear'], flags: {'color': 'red'}); + expect(r.ok, isTrue, reason: r.error?.message); + expect(published.single.data['color'], 'red'); + }); + + test('an unknown glyph name is an honest userError', () async { + wire(); + final r = await show(['notaglyph']); + expect(r.error?.kind, IpcErrorKind.userError); + expect(published, isEmpty); + }); + + test('an invalid color is an honest userError', () async { + wire(files: {'i.json': '[{"icon":"gear","color":"notacolor"}]'}); + final r = await show([], flags: {'file': 'i.json'}); + expect(r.error?.kind, IpcErrorKind.userError); + }); + + test('a malformed --file is a userError', () async { + wire(files: {'i.json': 'not json'}); + final r = await show([], flags: {'file': 'i.json'}); + expect(r.error?.kind, IpcErrorKind.userError); + }); + + test('no icons at all is a userError', () async { + wire(); + final r = await show([]); + expect(r.error?.kind, IpcErrorKind.userError); + }); + + test('no live UI is a toolError, not a hang', () async { + wire(liveUi: false); + final r = await show(['gear']); + expect(r.error?.kind, IpcErrorKind.toolError); + }); +} diff --git a/test/ipc/command_schema_test.dart b/test/ipc/command_schema_test.dart index 1ee323e5..532dc811 100644 --- a/test/ipc/command_schema_test.dart +++ b/test/ipc/command_schema_test.dart @@ -124,6 +124,29 @@ void main() { expect(out['to'], '300'); }); + test('a trailing stringList positional absorbs all remaining tokens (T-313)', () { + const variadic = CommandSchema( + positional: ['head', 'rest'], + args: { + 'head': ArgSpec(), + 'rest': ArgSpec(type: ArgType.stringList), + }, + ); + final out = variadic.normalize(const { + 'positional': ['a', 'b', 'c', 'd'], + }); + expect(out['head'], 'a'); + expect(out['rest'], ['b', 'c', 'd']); + }); + + test('a variadic positional with no tokens normalizes to an empty list', () { + const variadic = CommandSchema( + positional: ['rest'], + args: {'rest': ArgSpec(type: ArgType.stringList)}, + ); + expect(variadic.normalize(const {'positional': []})['rest'], const []); + }); + test('passthrough is carried over', () { final out = schema.normalize(const { 'positional': ['x'],