Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand All @@ -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"
}
]
Expand Down
159 changes: 159 additions & 0 deletions apps/streamdeck/scripts/render-action-icons.mjs
Original file line number Diff line number Diff line change
@@ -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 `<g fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</g>`;
}

/**
* 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 `<svg xmlns="http://www.w3.org/2000/svg" width="${RENDER_PX}" height="${RENDER_PX}" viewBox="0 0 24 24">${inner}</svg>`;
}

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();
}
98 changes: 98 additions & 0 deletions apps/streamdeck/src/__tests__/manifest-icons.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>([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 });
});
});
37 changes: 37 additions & 0 deletions apps/streamdeck/src/__tests__/render-action-icons.test.ts
Original file line number Diff line number Diff line change
@@ -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('<rect');
expect(svg).toContain('<path');
});
});
17 changes: 17 additions & 0 deletions codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
id: '1440'
title: stream-deck-polish-follow-ups-
protocol: air
phase: pr
plan_phases: []
current_plan_phase: null
gates:
pr:
status: approved
requested_at: '2026-08-13T01:51:46.079Z'
approved_at: '2026-08-13T07:25:03.413Z'
iteration: 1
build_complete: false
history: []
started_at: '2026-08-13T01:33:00.163Z'
updated_at: '2026-08-13T07:25:03.413Z'
pr_ready_for_human: false
46 changes: 46 additions & 0 deletions codev/state/air-1440_thread.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# air-1440 — Stream Deck polish follow-ups (dedicated action icons)

## Scope (architect-authoritative, issue comment 5274852386)
- AIR, mechanical. RENDER two action icons from the glyph vectors already in
`apps/streamdeck/src/face.ts` GLYPHS — `comment` → `send-queue`, `terminal` → `open-terminal`.
- Outputs per icon: `icons/<name>.png` (72), `icons/<name>@2x.png` (144),
`icons/list/<name>.png` (20), `icons/list/<name>@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.
Loading