diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty.png
deleted file mode 100644
index fbbb78989..000000000
Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty.png and /dev/null differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty@2x.png
deleted file mode 100644
index 106244131..000000000
Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty@2x.png and /dev/null differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending.png
deleted file mode 100644
index ce1395dbe..000000000
Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending.png and /dev/null differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending@2x.png
deleted file mode 100644
index 273c41b08..000000000
Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending@2x.png and /dev/null differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav.png
deleted file mode 100644
index 3c64c9dc6..000000000
Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav.png and /dev/null differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav@2x.png
deleted file mode 100644
index 4830623da..000000000
Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav@2x.png and /dev/null differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal.png
new file mode 100644
index 000000000..a7358fdb0
Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal.png differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal@2x.png
new file mode 100644
index 000000000..d8e250ed4
Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal@2x.png differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue.png
new file mode 100644
index 000000000..1a6800ffa
Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue.png differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue@2x.png
new file mode 100644
index 000000000..85edfa383
Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue@2x.png differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal.png
new file mode 100644
index 000000000..5e551eaa4
Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal.png differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal@2x.png
new file mode 100644
index 000000000..75a8bce77
Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal@2x.png differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue.png
new file mode 100644
index 000000000..73f0b7902
Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue.png differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue@2x.png
new file mode 100644
index 000000000..42d16f278
Binary files /dev/null and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue@2x.png differ
diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json b/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json
index 9b9e74915..f0a6aae8f 100644
--- a/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json
+++ b/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json
@@ -52,13 +52,13 @@
"Name": "Send Feedback",
"UUID": "com.cluesmith.codev.send-queue",
"Tooltip": "Flush the selected builder's queued review feedback. Badge shows the queued count; inert when nothing is queued (or the workspace forwards immediately).",
- "Icon": "icons/list/action",
+ "Icon": "icons/list/send-queue",
"Controllers": [
"Keypad"
],
"States": [
{
- "Image": "icons/action",
+ "Image": "icons/send-queue",
"TitleAlignment": "bottom"
}
]
@@ -67,13 +67,13 @@
"Name": "Open Terminal",
"UUID": "com.cluesmith.codev.open-terminal",
"Tooltip": "Open the selected builder's terminal — the per-builder complement to Builder Action's open-artifact.",
- "Icon": "icons/list/action",
+ "Icon": "icons/list/open-terminal",
"Controllers": [
"Keypad"
],
"States": [
{
- "Image": "icons/action",
+ "Image": "icons/open-terminal",
"TitleAlignment": "bottom"
}
]
diff --git a/apps/streamdeck/scripts/render-action-icons.mjs b/apps/streamdeck/scripts/render-action-icons.mjs
new file mode 100644
index 000000000..ae839707a
--- /dev/null
+++ b/apps/streamdeck/scripts/render-action-icons.mjs
@@ -0,0 +1,159 @@
+// Render the dedicated manifest action icons for `send-queue` and `open-terminal` (#1440).
+//
+// SINGLE SOURCE: the glyph vectors are NOT re-drawn here — they are parsed out of
+// `src/face.ts`'s GLYPHS map, the same vectors the runtime key face draws via
+// `labelFaceSvg('comment'|'terminal', …)`. So the action-picker icon and the live hardware key
+// agree by construction; changing a glyph in face.ts and re-running this script keeps them aligned.
+//
+// FIT: the glyphs don't fill their authored 24×24 box (comment ≈ 18×17, terminal ≈ 20×16), and a
+// transparent list icon needs far less padding than a rounded-key image. So we render the glyph,
+// trim it to its true drawn bounding box, then scale that bbox to the SAME fill fraction the
+// existing icons use (measured: list/* ≈ 95% of frame, key images ≈ 56%). Fitting the bbox — not
+// the nominal box — is what keeps the new icons from reading small next to their siblings.
+//
+// TOOLING: system `rsvg-convert` (librsvg) rasterizes the vector; system `magick` (ImageMagick)
+// trims to the glyph bbox, fits, centers, and composites over the rounded-key ground. Both are
+// pre-installed dev tools, not npm dependencies — per the #1440 scope, a one-time asset build
+// prefers repo-available tooling over adding a dependency just to turn SVG into PNG. Re-run after a
+// glyph changes: node scripts/render-action-icons.mjs (needs: brew install librsvg imagemagick)
+
+import { execFileSync } from 'node:child_process';
+import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { tmpdir } from 'node:os';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const PLUGIN = join(HERE, '..', 'com.cluesmith.codev.sdPlugin');
+const FACE_TS = join(HERE, '..', 'src', 'face.ts');
+
+// name → the GLYPHS key in face.ts it renders from.
+export const ICONS = [
+ { name: 'send-queue', glyph: 'comment' },
+ { name: 'open-terminal', glyph: 'terminal' },
+];
+
+const GLYPH_COLOR = '#ffffff';
+const BG = '#1C2128'; // rounded-key ground, matching icons/action.png & siblings
+const CORNER_RADIUS = 12; // measured from the existing 72px key images (scales with size)
+const LIST_FILL = 0.94; // glyph bbox / frame for the transparent list icon (siblings ≈ 0.95)
+const KEY_FILL = 0.56; // glyph bbox / frame for the key image (siblings ≈ 0.56)
+const RENDER_PX = 512; // high-res glyph raster, downscaled by magick for clean antialiasing
+
+/**
+ * Reproduce face.ts's `stroked()` wrapper — line glyphs (comment/terminal) are stored as their
+ * inner paths and wrapped at draw time. Kept identical to `stroked` in src/face.ts.
+ */
+function stroked(color, paths) {
+ return `${paths}`;
+}
+
+/**
+ * Pull a glyph's inner SVG out of face.ts's GLYPHS map without importing it (GLYPHS is
+ * module-private, and face.ts is off-limits to edit while bugfix-1431 is in flight). Supports the
+ * two forms GLYPHS uses: `stroked(c, '<…>')` (line glyphs) and a raw `` `<… ${c} …>` `` template
+ * (filled glyphs), and both bare (`comment:`) and quoted (`'pull-request':`) keys. Throws loudly if
+ * the shape drifts, so a silent stale-icon build can't happen.
+ */
+export function extractGlyph(faceSrc, key, color) {
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const line = faceSrc.match(new RegExp(`\\n\\s*['"]?${k}['"]?:\\s*\\(c\\)\\s*=>\\s*([^\\n]*?),?\\s*\\n`));
+ if (!line) throw new Error(`glyph '${key}' not found in face.ts GLYPHS`);
+ const rhs = line[1].trim();
+ const strokedArg = rhs.match(/^stroked\(c,\s*'(.*)'\)$/);
+ if (strokedArg) return stroked(color, strokedArg[1]);
+ const rawArg = rhs.match(/^`(.*)`$/);
+ if (rawArg) return rawArg[1].replace(/\$\{c\}/g, color);
+ throw new Error(`glyph '${key}' has an unrecognized form: ${rhs}`);
+}
+
+/** The glyph on its own, high-res, transparent — the raster both variants trim and fit from. */
+function glyphSvg(inner) {
+ return ``;
+}
+
+function tmp(tag) {
+ return join(tmpdir(), `sd-icon-1440-${tag}`);
+}
+
+function ensureTool(bin, install) {
+ try {
+ execFileSync(bin, ['--version'], { stdio: 'ignore' });
+ } catch {
+ throw new Error(`'${bin}' not found — this one-off asset build needs it (${install}).`);
+ }
+}
+
+/** magick expression that trims the high-res glyph to its drawn bbox and scales it to `target` px. */
+function fittedGlyph(glyphPng, target) {
+ return [glyphPng, '-trim', '+repage', '-resize', `${target}x${target}`];
+}
+
+/** Transparent list icon: glyph fit to LIST_FILL of the frame, centered. */
+function renderList(glyphPng, out, size) {
+ const target = Math.round(LIST_FILL * size);
+ execFileSync('magick', [
+ ...fittedGlyph(glyphPng, target),
+ '-background', 'none', '-gravity', 'center', '-extent', `${size}x${size}`,
+ out,
+ ]);
+}
+
+/** Key image: glyph fit to KEY_FILL of the frame, centered over the rounded #1C2128 ground. */
+function renderKey(glyphPng, out, size) {
+ const target = Math.round(KEY_FILL * size);
+ const radius = Math.round((CORNER_RADIUS * size) / 72);
+ const bg = tmp(`bg-${size}.png`);
+ execFileSync('magick', [
+ '-size', `${size}x${size}`, 'xc:none', '-fill', BG,
+ '-draw', `roundrectangle 0,0,${size - 1},${size - 1},${radius},${radius}`,
+ bg,
+ ]);
+ try {
+ execFileSync('magick', [bg, '(', ...fittedGlyph(glyphPng, target), ')', '-gravity', 'center', '-composite', out]);
+ } finally {
+ rmSync(bg, { force: true });
+ }
+}
+
+/** Guard the fix: a list icon must fill the frame like its siblings, not sit small and padded. */
+function assertListCoverage(out, size, min) {
+ const dims = execFileSync('magick', [out, '-trim', '+repage', '-format', '%wx%h', 'info:'], { encoding: 'utf8' });
+ const [w, h] = dims.trim().split('x').map(Number);
+ const coverage = Math.max(w, h) / size;
+ if (coverage < min) {
+ throw new Error(`${out}: glyph fills ${(coverage * 100).toFixed(0)}% of the frame, below the ${(min * 100).toFixed(0)}% convention floor`);
+ }
+}
+
+function main() {
+ ensureTool('rsvg-convert', 'brew install librsvg');
+ ensureTool('magick', 'brew install imagemagick');
+
+ const faceSrc = readFileSync(FACE_TS, 'utf8');
+ mkdirSync(join(PLUGIN, 'icons', 'list'), { recursive: true });
+
+ for (const { name, glyph } of ICONS) {
+ const svgFile = tmp(`${glyph}.svg`);
+ const glyphPng = tmp(`${glyph}.png`);
+ writeFileSync(svgFile, glyphSvg(extractGlyph(faceSrc, glyph, GLYPH_COLOR)));
+ execFileSync('rsvg-convert', ['-w', String(RENDER_PX), '-h', String(RENDER_PX), svgFile, '-o', glyphPng]);
+ try {
+ renderKey(glyphPng, join(PLUGIN, 'icons', `${name}.png`), 72);
+ renderKey(glyphPng, join(PLUGIN, 'icons', `${name}@2x.png`), 144);
+ renderList(glyphPng, join(PLUGIN, 'icons', 'list', `${name}.png`), 20);
+ renderList(glyphPng, join(PLUGIN, 'icons', 'list', `${name}@2x.png`), 40);
+ assertListCoverage(join(PLUGIN, 'icons', 'list', `${name}@2x.png`), 40, 0.8);
+ } finally {
+ rmSync(svgFile, { force: true });
+ rmSync(glyphPng, { force: true });
+ }
+ console.log(`rendered ${name} (from GLYPHS.${glyph}) → 72/144/20/40`);
+ }
+}
+
+// Run only when invoked directly (`node scripts/render-action-icons.mjs`); importing the module
+// for tests exercises the pure helpers above without rasterizing.
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ main();
+}
diff --git a/apps/streamdeck/src/__tests__/manifest-icons.test.ts b/apps/streamdeck/src/__tests__/manifest-icons.test.ts
new file mode 100644
index 000000000..6df2abeca
--- /dev/null
+++ b/apps/streamdeck/src/__tests__/manifest-icons.test.ts
@@ -0,0 +1,98 @@
+import { describe, it, expect } from 'vitest';
+import { existsSync, readFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+/**
+ * #1440: the `send-queue` and `open-terminal` actions got dedicated icons rendered from the
+ * face.ts glyph vectors, replacing the shared `action` asset they used to borrow. These guards
+ * keep the manifest and the on-disk PNGs in agreement: a manifest that points at a missing or
+ * deleted image reverts the key to a blank in the Stream Deck app (and fails Elgato validation),
+ * so pin every referenced asset to a real file — and pin the two follow-up actions to their own.
+ */
+const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+const pluginDir = join(root, 'com.cluesmith.codev.sdPlugin');
+
+interface ManifestAction {
+ UUID: string;
+ Icon: string;
+ States: { Image: string }[];
+}
+const manifest = JSON.parse(readFileSync(join(pluginDir, 'manifest.json'), 'utf-8')) as {
+ Icon: string;
+ CategoryIcon: string;
+ Actions: ManifestAction[];
+};
+
+/** A manifest icon reference (no extension) → the @1x and @2x PNGs it must resolve to. */
+function pngVariants(ref: string): string[] {
+ return [join(pluginDir, `${ref}.png`), join(pluginDir, `${ref}@2x.png`)];
+}
+
+/** Read a PNG's pixel dimensions from its IHDR chunk (bytes 16–24, big-endian) — no image lib. */
+function pngSize(absPath: string): { w: number; h: number } {
+ const buf = readFileSync(absPath);
+ return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
+}
+
+describe('manifest icon assets exist on disk', () => {
+ const refs = new Set([manifest.Icon, manifest.CategoryIcon]);
+ for (const action of manifest.Actions) {
+ refs.add(action.Icon);
+ for (const state of action.States) refs.add(state.Image);
+ }
+
+ for (const ref of refs) {
+ it(`${ref} resolves to @1x and @2x PNGs`, () => {
+ for (const png of pngVariants(ref)) {
+ expect(existsSync(png), `missing ${png}`).toBe(true);
+ }
+ });
+ }
+});
+
+describe('#1440 dedicated action icons', () => {
+ function action(uuid: string): ManifestAction {
+ const found = manifest.Actions.find((a) => a.UUID === uuid);
+ if (!found) throw new Error(`action ${uuid} not in manifest`);
+ return found;
+ }
+
+ it('send-queue points at its own icon, not the shared action asset', () => {
+ const a = action('com.cluesmith.codev.send-queue');
+ expect(a.Icon).toBe('icons/list/send-queue');
+ expect(a.States[0].Image).toBe('icons/send-queue');
+ });
+
+ it('open-terminal points at its own icon, not the shared action asset', () => {
+ const a = action('com.cluesmith.codev.open-terminal');
+ expect(a.Icon).toBe('icons/list/open-terminal');
+ expect(a.States[0].Image).toBe('icons/open-terminal');
+ });
+
+ it('removes the verified-dead icons (approve-gate-empty / -pending / gate-nav)', () => {
+ const dead = ['icons/approve-gate-empty', 'icons/approve-gate-pending', 'icons/gate-nav'];
+ for (const ref of dead) {
+ for (const png of pngVariants(ref)) {
+ expect(existsSync(png), `${png} should have been deleted`).toBe(false);
+ }
+ }
+ });
+
+ it('keeps the still-live approve-gate assets', () => {
+ for (const ref of ['icons/approve-gate', 'icons/list/approve-gate']) {
+ for (const png of pngVariants(ref)) {
+ expect(existsSync(png), `missing ${png}`).toBe(true);
+ }
+ }
+ });
+
+ // The Stream Deck convention: key Image @1x/@2x = 72/144, list Icon @1x/@2x = 20/40. A wrongly
+ // sized asset renders blurry or gets rejected by Elgato validation — pin the committed sizes.
+ it.each(['send-queue', 'open-terminal'])('%s icons ship at the convention sizes', (name) => {
+ expect(pngSize(join(pluginDir, `icons/${name}.png`))).toEqual({ w: 72, h: 72 });
+ expect(pngSize(join(pluginDir, `icons/${name}@2x.png`))).toEqual({ w: 144, h: 144 });
+ expect(pngSize(join(pluginDir, `icons/list/${name}.png`))).toEqual({ w: 20, h: 20 });
+ expect(pngSize(join(pluginDir, `icons/list/${name}@2x.png`))).toEqual({ w: 40, h: 40 });
+ });
+});
diff --git a/apps/streamdeck/src/__tests__/render-action-icons.test.ts b/apps/streamdeck/src/__tests__/render-action-icons.test.ts
new file mode 100644
index 000000000..545ea3d20
--- /dev/null
+++ b/apps/streamdeck/src/__tests__/render-action-icons.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+// @ts-expect-error — plain ESM build script, no type declarations.
+import { ICONS, extractGlyph } from '../../scripts/render-action-icons.mjs';
+
+/**
+ * #1440: the action icons are rendered FROM face.ts's GLYPHS map, not re-drawn — the render
+ * script parses the vector out of face.ts so the picker icon and the runtime key face share one
+ * source. These guards protect that contract: if GLYPHS's declaration shape drifts, the extractor
+ * must throw (a loud build failure) rather than silently ship a stale icon.
+ */
+const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+const faceSrc = readFileSync(join(root, 'src', 'face.ts'), 'utf-8');
+
+describe('extractGlyph pulls the glyph vector out of face.ts', () => {
+ for (const { name, glyph } of ICONS) {
+ it(`${name} extracts the '${glyph}' glyph as a colored SVG group`, () => {
+ const svg = extractGlyph(faceSrc, glyph, '#ffffff');
+ expect(svg).toContain('#ffffff');
+ // comment/terminal are line glyphs: rendered through the stroked() wrapper.
+ expect(svg).toMatch(/stroke="#ffffff"/);
+ expect(svg).not.toContain('${c}'); // the color placeholder must be substituted
+ });
+ }
+
+ it('throws on an unknown glyph key rather than emitting nothing', () => {
+ expect(() => extractGlyph(faceSrc, 'no-such-glyph', '#ffffff')).toThrow(/not found/);
+ });
+
+ it('terminal glyph carries the terminal shape (rect + prompt path)', () => {
+ const svg = extractGlyph(faceSrc, 'terminal', '#ffffff');
+ expect(svg).toContain('.png` (72), `icons/@2x.png` (144),
+ `icons/list/.png` (20), `icons/list/@2x.png` (40). Repoint manifest `Icon` +
+ `States[].Image` off the shared `action` asset.
+- Delete six verified-dead PNGs: approve-gate-empty, approve-gate-pending, gate-nav (+@2x each).
+ KEEP approve-gate.* and list/approve-gate.* (live).
+- Profile work OUT. No key-face / press-behaviour change.
+- **Do NOT edit face.ts or actions.ts** — bugfix-1431 is in flight there. Diff = manifest.json
+ + icon files (+ a committed render script, justified below).
+
+## Decisions
+- **Single source honored by parsing face.ts at build time.** The render script reads
+ `src/face.ts`, extracts the `comment`/`terminal` GLYPHS path data + the `stroked()` wrapper,
+ and rasterizes — so the PNGs derive from the exact same vector the runtime key face uses, with
+ zero duplication and zero edit to face.ts.
+- **Rasterizer: system `rsvg-convert` (librsvg), no new npm dep.** Architect asked to prefer
+ repo-available tooling over adding a dependency for a one-time asset build. Script committed at
+ `apps/streamdeck/scripts/render-action-icons.mjs` for reproducibility; it shells to
+ rsvg-convert (documented in PR body).
+- Frame matches existing convention measured from live assets: key image = rounded rect rx=12
+ fill `#1C2128` + white glyph; list icon = transparent + white glyph.
+
+## Status
+- Implemented + PR #1443 opened (review in body).
+- CMAP (AIR PR): gemini=APPROVE(HIGH), claude=REQUEST_CHANGES(HIGH), codex=unavailable (external
+ OpenAI billing — "no credits remaining", not our code).
+- **Acted on Claude's blocking finding (verified against the PNGs first):** the first-pass list
+ icons filled only ~45% of the frame vs the ~95% convention, because `listSvg` reused the
+ key-frame padding and the glyphs don't fill their 24×24 box. Rewrote the render pipeline to
+ rasterize the glyph, trim to its true bbox, then fit to the convention's fill fraction
+ (list 0.94, key 0.56) via `magick` + `rsvg-convert`; added a self-check that fails the build if a
+ list icon drops below 80% coverage. Re-measured: list @2x now 38×36 / 38×30 (sibling 38×34); key
+ @2x 81×77 / 81×65 (sibling 80×68). Also addressed Claude's minors: friendly ENOENT for both
+ system tools, quoted-key-safe `extractGlyph` regex, and a zero-dep PNG-dimension test guard.
+- Deferred (architect's call, noted in PR): open-terminal's glyph resembles the still-shared
+ `icons/action` used by catch-all "Codev Action" — re-glyphing Codev Action (to `bolt`) would
+ fully resolve the picker ambiguity; the terminal→open-terminal mapping itself is baked scope.
+- Verified: check-types ✓, build ✓, `streamdeck validate` ✓, vitest 162 passed.
+- Committed + pushed (fix commit 1d19f99f2), PR #1443 body updated with CMAP round, architect
+ notified.
+- **PR gate open — WAITING for human approval.** `porch approve 1440 pr` is the human's to run
+ (via the architect). Nothing further from me until approval arrives.