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 modified apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/action.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/action@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/action.png
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.
82 changes: 66 additions & 16 deletions apps/streamdeck/scripts/render-action-icons.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
// Render the dedicated manifest action icons for `send-queue` and `open-terminal` (#1440).
// Render the dedicated manifest action icons for `send-queue`, `open-terminal` (#1440) and the
// catch-all `action` (#1444).
//
// 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.
//
// BRAND-SOURCED: the `action` icon is the exception — it renders from the plugin's own brand mark
// (`icons/plugin.svg`), not a face.ts glyph. `Codev Action` is a configurable catch-all that runs
// any verb, so #1440's terminal glyph both mislabeled it and collided with the new open-terminal
// icon; the Codev mark reads as "a generic Codev action" and needs no new artwork (#1444). The mark
// is already monochrome white on the same viewBox we reuse, so it flows through the identical
// trim → fit → composite pipeline as the glyphs.
//
// 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
Expand All @@ -26,13 +34,17 @@ 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');
const BRAND_SVG = join(PLUGIN, 'icons', 'plugin.svg');

// name → the GLYPHS key in face.ts it renders from.
export const ICONS = [
{ name: 'send-queue', glyph: 'comment' },
{ name: 'open-terminal', glyph: 'terminal' },
];

// name → rendered from the brand mark in icons/plugin.svg instead of a face.ts glyph (#1444).
export const BRAND_ICONS = [{ name: 'action' }];

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)
Expand Down Expand Up @@ -72,6 +84,25 @@ 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>`;
}

/**
* Pull the brand mark out of icons/plugin.svg: its single `<g>` group (the white handshake path)
* and the SVG's viewBox. The opaque background `<rect>` is left behind, so the mark renders
* transparent — the same raster shape renderKey/renderList expect. Throws loudly if the mark's
* shape drifts, so a silent stale-icon build can't happen. Returns the group already white; unlike
* the glyphs it carries no `${c}` placeholder, so no recolor step is needed.
*/
export function extractBrandMark(svgSrc) {
const viewBox = svgSrc.match(/viewBox="([^"]*)"/);
const group = svgSrc.match(/<g\b[^>]*>[\s\S]*?<\/g>/);
if (!viewBox || !group) throw new Error('brand mark: expected <svg viewBox> with a <g> group in plugin.svg');
return { viewBox: viewBox[1], inner: group[0] };
}

/** The brand mark on its own, high-res, transparent — same role as glyphSvg for the glyphs. */
function brandSvg({ viewBox, inner }) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${RENDER_PX}" height="${RENDER_PX}" viewBox="${viewBox}">${inner}</svg>`;
}

function tmp(tag) {
return join(tmpdir(), `sd-icon-1440-${tag}`);
}
Expand Down Expand Up @@ -126,6 +157,34 @@ function assertListCoverage(out, size, min) {
}
}

/**
* The four manifest variants a single high-res transparent source raster produces: the 72/144 key
* faces (glyph over the rounded ground) and the 20/40 transparent list icons. Shared by the glyph-
* and brand-sourced icons so both fit and center identically.
*/
function emit(name, srcPng) {
renderKey(srcPng, join(PLUGIN, 'icons', `${name}.png`), 72);
renderKey(srcPng, join(PLUGIN, 'icons', `${name}@2x.png`), 144);
renderList(srcPng, join(PLUGIN, 'icons', 'list', `${name}.png`), 20);
renderList(srcPng, join(PLUGIN, 'icons', 'list', `${name}@2x.png`), 40);
assertListCoverage(join(PLUGIN, 'icons', 'list', `${name}@2x.png`), 40, 0.8);
}

/** Rasterize a source SVG to the shared high-res transparent raster, run `emit`, then clean up. */
function build(name, svg, tag, label) {
const svgFile = tmp(`${tag}.svg`);
const srcPng = tmp(`${tag}.png`);
writeFileSync(svgFile, svg);
execFileSync('rsvg-convert', ['-w', String(RENDER_PX), '-h', String(RENDER_PX), svgFile, '-o', srcPng]);
try {
emit(name, srcPng);
} finally {
rmSync(svgFile, { force: true });
rmSync(srcPng, { force: true });
}
console.log(`rendered ${name} (${label}) → 72/144/20/40`);
}

function main() {
ensureTool('rsvg-convert', 'brew install librsvg');
ensureTool('magick', 'brew install imagemagick');
Expand All @@ -134,21 +193,12 @@ function main() {
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`);
build(name, glyphSvg(extractGlyph(faceSrc, glyph, GLYPH_COLOR)), glyph, `from GLYPHS.${glyph}`);
}

const brandSrc = readFileSync(BRAND_SVG, 'utf8');
for (const { name } of BRAND_ICONS) {
build(name, brandSvg(extractBrandMark(brandSrc)), 'brand', 'from icons/plugin.svg brand mark');
}
}

Expand Down
39 changes: 39 additions & 0 deletions apps/streamdeck/src/__tests__/manifest-icons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,42 @@ describe('#1440 dedicated action icons', () => {
expect(pngSize(join(pluginDir, `icons/list/${name}@2x.png`))).toEqual({ w: 40, h: 40 });
});
});

/**
* #1444: the catch-all `Codev Action` was re-glyphed off the terminal picture (which now belongs to
* the dedicated open-terminal action from #1440) onto the Codev brand mark. Its manifest references
* are unchanged — only the pixels behind `icons/action` were regenerated — so these guards pin both
* the still-shared filenames and the fix itself: the action image must no longer be the terminal.
*/
describe('#1444 re-glyphed Codev Action', () => {
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('keeps the action referencing its own icon filenames', () => {
const a = action('com.cluesmith.codev.action');
expect(a.Icon).toBe('icons/list/action');
expect(a.States[0].Image).toBe('icons/action');
});

it('action icons ship at the convention sizes', () => {
expect(pngSize(join(pluginDir, 'icons/action.png'))).toEqual({ w: 72, h: 72 });
expect(pngSize(join(pluginDir, 'icons/action@2x.png'))).toEqual({ w: 144, h: 144 });
expect(pngSize(join(pluginDir, 'icons/list/action.png'))).toEqual({ w: 20, h: 20 });
expect(pngSize(join(pluginDir, 'icons/list/action@2x.png'))).toEqual({ w: 40, h: 40 });
});

// The collision the issue reports: before the re-glyph, action and open-terminal drew the same
// terminal picture. The two key faces must no longer be byte-identical.
it.each(['icons/action.png', 'icons/action@2x.png', 'icons/list/action.png', 'icons/list/action@2x.png'])(
'%s no longer collides with the open-terminal asset',
(ref) => {
const terminalRef = ref.replace('action', 'open-terminal');
const actionBytes = readFileSync(join(pluginDir, ref));
const terminalBytes = readFileSync(join(pluginDir, terminalRef));
expect(actionBytes.equals(terminalBytes)).toBe(false);
},
);
});
38 changes: 37 additions & 1 deletion apps/streamdeck/src/__tests__/render-action-icons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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';
import { ICONS, BRAND_ICONS, extractGlyph, extractBrandMark } from '../../scripts/render-action-icons.mjs';

/**
* #1440: the action icons are rendered FROM face.ts's GLYPHS map, not re-drawn — the render
Expand All @@ -13,6 +13,10 @@ import { ICONS, extractGlyph } from '../../scripts/render-action-icons.mjs';
*/
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const faceSrc = readFileSync(join(root, 'src', 'face.ts'), 'utf-8');
const pluginSvg = readFileSync(
join(root, 'com.cluesmith.codev.sdPlugin', 'icons', 'plugin.svg'),
'utf-8',
);

describe('extractGlyph pulls the glyph vector out of face.ts', () => {
for (const { name, glyph } of ICONS) {
Expand All @@ -35,3 +39,35 @@ describe('extractGlyph pulls the glyph vector out of face.ts', () => {
expect(svg).toContain('<path');
});
});

/**
* #1444: the catch-all `action` icon renders from the Codev brand mark in icons/plugin.svg, NOT a
* face.ts glyph — a terminal glyph both mislabeled a configurable verb runner and collided with
* #1440's dedicated open-terminal icon. These guards keep the brand-mark extractor honest: it must
* pull the mark's group and viewBox, and throw loudly if plugin.svg's shape drifts.
*/
describe('extractBrandMark pulls the Codev mark out of plugin.svg', () => {
it('routes the catch-all action through the brand source, not a glyph', () => {
expect(BRAND_ICONS).toEqual([{ name: 'action' }]);
// The re-glyph must not reintroduce a glyph source for `action`.
expect(ICONS.some((i: { name: string }) => i.name === 'action')).toBe(false);
});

it('returns the mark group and the svg viewBox', () => {
const mark = extractBrandMark(pluginSvg);
expect(mark.viewBox).toMatch(/^[\d.\s]+$/);
expect(mark.inner).toMatch(/^<g\b/);
expect(mark.inner).toContain('<path');
// The mark is already white; it carries no ${c} recolor placeholder like the glyphs.
expect(mark.inner).not.toContain('${c}');
});

it('leaves the opaque background rect behind so the mark renders transparent', () => {
const mark = extractBrandMark(pluginSvg);
expect(mark.inner).not.toContain('<rect');
});

it('throws when the mark group is absent rather than emitting nothing', () => {
expect(() => extractBrandMark('<svg viewBox="0 0 24 24"></svg>')).toThrow(/brand mark/);
});
});
22 changes: 22 additions & 0 deletions codev/projects/1444-stream-deck-re-glyph-the-catch/status.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
id: '1444'
title: stream-deck-re-glyph-the-catch
protocol: air
phase: pr
plan_phases: []
current_plan_phase: null
gates:
pr:
status: approved
requested_at: '2026-08-14T00:56:52.006Z'
approved_at: '2026-08-15T01:52:51.230Z'
iteration: 1
build_complete: false
history: []
started_at: '2026-08-14T00:27:27.654Z'
updated_at: '2026-08-15T01:52:51.230Z'
pr_history:
- phase: implement
pr_number: 1453
branch: builder/air-1444
created_at: '2026-08-14T00:55:15.071Z'
pr_ready_for_human: false
36 changes: 36 additions & 0 deletions codev/state/air-1444_thread.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# air-1444 — Stream Deck: re-glyph the catch-all Codev Action

## Issue
`Codev Action` (catch-all verb runner) draws a **terminal** glyph (`icons/action.*`).
#1440 gave the new `open-terminal` action its own terminal-glyph icon. The two now look
near-identical in the Stream Deck action picker. Re-glyph the catch-all, not open-terminal.

## Verification (issue point 3) — done before coding
`CodevAction` (src/actions.ts:63-66) extends `VerbKey` and does NOT override `onWillAppear`.
Only `DevServerAction`/`BuilderAction`/gate keys composite a runtime face via `setImage`.
So the Codev Action key face shows the manifest `States[0].Image` (`icons/action`) directly.
=> The change is NOT picker-only: it fixes both the picker list icon AND the physical key face.

## Approach
- Source: the existing Codev brand mark `icons/plugin.svg` (architect's first candidate).
Prototyped the render at 72 / 20px — the handshake mark is legible and unambiguously
not-a-terminal even at 20x20. It survives; no new artwork needed.
- Extend `scripts/render-action-icons.mjs` (from #1440) rather than hand-rolling: add a
brand-mark source path alongside the glyph source, reuse the same fit fractions
(KEY_FILL 0.56, LIST_FILL 0.94) and render targets (72/144/20/40).
- No manifest change: `Codev Action` already references `icons/list/action` + `icons/action`;
only the pixels behind those filenames change.

## Status
- [x] Verify runtime render path
- [x] Prototype brand-mark legibility at 20px
- [x] Extend render script + regenerate action PNGs (brand-mark source in render-action-icons.mjs)
- [x] Tests (172 pass; check-types + build green after building @cluesmith/codev-sdk first)
- [x] PR #1453 with review in body

## Notes
- send-queue/open-terminal PNGs re-encode with different IM metadata but are pixel-identical
(AE=0) to #1440's committed versions; reverted them so the diff is action-only.
- check-types/build initially failed on missing @cluesmith/codev-sdk/controller — pre-existing,
fixed by `pnpm --filter @cluesmith/codev-sdk build` (dist wasn't built in the worktree).
- No CMAP: AIR economy, purely declarative asset regen, visually + test-guarded.
Loading