diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/action.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/action.png index 617c8a270..a42e03bca 100644 Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/action.png and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/action.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/action@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/action@2x.png index b72dee216..0617970b8 100644 Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/action@2x.png and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/action@2x.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/action.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/action.png index 771443dcc..eb73006f5 100644 Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/action.png and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/action.png differ diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/action@2x.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/action@2x.png index 899b08e53..1b0f77946 100644 Binary files a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/action@2x.png and b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/action@2x.png differ diff --git a/apps/streamdeck/scripts/render-action-icons.mjs b/apps/streamdeck/scripts/render-action-icons.mjs index ae839707a..365c370dd 100644 --- a/apps/streamdeck/scripts/render-action-icons.mjs +++ b/apps/streamdeck/scripts/render-action-icons.mjs @@ -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 @@ -26,6 +34,7 @@ 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 = [ @@ -33,6 +42,9 @@ export const ICONS = [ { 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) @@ -72,6 +84,25 @@ function glyphSvg(inner) { return `${inner}`; } +/** + * Pull the brand mark out of icons/plugin.svg: its single `` group (the white handshake path) + * and the SVG's viewBox. The opaque background `` 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(/]*>[\s\S]*?<\/g>/); + if (!viewBox || !group) throw new Error('brand mark: expected with a 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 `${inner}`; +} + function tmp(tag) { return join(tmpdir(), `sd-icon-1440-${tag}`); } @@ -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'); @@ -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'); } } diff --git a/apps/streamdeck/src/__tests__/manifest-icons.test.ts b/apps/streamdeck/src/__tests__/manifest-icons.test.ts index 6df2abeca..082bebe75 100644 --- a/apps/streamdeck/src/__tests__/manifest-icons.test.ts +++ b/apps/streamdeck/src/__tests__/manifest-icons.test.ts @@ -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); + }, + ); +}); diff --git a/apps/streamdeck/src/__tests__/render-action-icons.test.ts b/apps/streamdeck/src/__tests__/render-action-icons.test.ts index 545ea3d20..7d0222dfe 100644 --- a/apps/streamdeck/src/__tests__/render-action-icons.test.ts +++ b/apps/streamdeck/src/__tests__/render-action-icons.test.ts @@ -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 @@ -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) { @@ -35,3 +39,35 @@ describe('extractGlyph pulls the glyph vector out of face.ts', () => { expect(svg).toContain(' { + 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(/^ { + const mark = extractBrandMark(pluginSvg); + expect(mark.inner).not.toContain(' { + expect(() => extractBrandMark('')).toThrow(/brand mark/); + }); +}); diff --git a/codev/projects/1444-stream-deck-re-glyph-the-catch/status.yaml b/codev/projects/1444-stream-deck-re-glyph-the-catch/status.yaml new file mode 100644 index 000000000..249eac78c --- /dev/null +++ b/codev/projects/1444-stream-deck-re-glyph-the-catch/status.yaml @@ -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 diff --git a/codev/state/air-1444_thread.md b/codev/state/air-1444_thread.md new file mode 100644 index 000000000..b96a4e58c --- /dev/null +++ b/codev/state/air-1444_thread.md @@ -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.