From 363afb4246a906953076e237c92d58b748e21cb9 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 13 Aug 2026 11:33:00 +1000 Subject: [PATCH 1/7] chore(porch): 1440 init air --- .../status.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml diff --git a/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml b/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml new file mode 100644 index 000000000..c0f5c2274 --- /dev/null +++ b/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml @@ -0,0 +1,14 @@ +id: '1440' +title: stream-deck-polish-follow-ups- +protocol: air +phase: implement +plan_phases: [] +current_plan_phase: null +gates: + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-08-13T01:33:00.163Z' +updated_at: '2026-08-13T01:33:00.163Z' From 83688cb9c2c12ad0944834323ff32dd7a3a2af40 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 13 Aug 2026 11:40:11 +1000 Subject: [PATCH 2/7] [Air #1440] feat: dedicated send-queue/open-terminal action icons rendered from face.ts glyphs Render the send-queue (comment glyph) and open-terminal (terminal glyph) manifest action icons from the same GLYPHS vectors the runtime key face uses, so the action-picker icon and the hardware key agree by construction. Repoint the manifest Icon + States[].Image off the shared 'action' asset. Remove six verified-dead PNGs (approve-gate-empty, approve-gate-pending, gate-nav, each +@2x). A committed one-off render script (scripts/render-action-icons.mjs) parses the glyph vector out of src/face.ts and rasterizes via system rsvg-convert (no new npm dep), keeping the PNGs derived from a single source without editing face.ts. --- .../icons/approve-gate-empty.png | Bin 1060 -> 0 bytes .../icons/approve-gate-empty@2x.png | Bin 2009 -> 0 bytes .../icons/approve-gate-pending.png | Bin 1037 -> 0 bytes .../icons/approve-gate-pending@2x.png | Bin 2070 -> 0 bytes .../icons/gate-nav.png | Bin 898 -> 0 bytes .../icons/gate-nav@2x.png | Bin 1696 -> 0 bytes .../icons/list/open-terminal.png | Bin 0 -> 261 bytes .../icons/list/open-terminal@2x.png | Bin 0 -> 430 bytes .../icons/list/send-queue.png | Bin 0 -> 233 bytes .../icons/list/send-queue@2x.png | Bin 0 -> 319 bytes .../icons/open-terminal.png | Bin 0 -> 1412 bytes .../icons/open-terminal@2x.png | Bin 0 -> 2799 bytes .../icons/send-queue.png | Bin 0 -> 1078 bytes .../icons/send-queue@2x.png | Bin 0 -> 2047 bytes .../manifest.json | 8 +- .../scripts/render-action-icons.mjs | 115 ++++++++++++++++++ .../src/__tests__/manifest-icons.test.ts | 83 +++++++++++++ .../src/__tests__/render-action-icons.test.ts | 37 ++++++ codev/state/air-1440_thread.md | 34 ++++++ 19 files changed, 273 insertions(+), 4 deletions(-) delete mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty.png delete mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-empty@2x.png delete mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending.png delete mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/approve-gate-pending@2x.png delete mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav.png delete mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/gate-nav@2x.png create mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal.png create mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/open-terminal@2x.png create mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue.png create mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/list/send-queue@2x.png create mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal.png create mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal@2x.png create mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue.png create mode 100644 apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/send-queue@2x.png create mode 100644 apps/streamdeck/scripts/render-action-icons.mjs create mode 100644 apps/streamdeck/src/__tests__/manifest-icons.test.ts create mode 100644 apps/streamdeck/src/__tests__/render-action-icons.test.ts create mode 100644 codev/state/air-1440_thread.md 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 fbbb78989860a9b0a00a5b14c0c44a39d3712cc1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1060 zcmV+<1l#+GP)^Huj)ZO7`+ge6cQ7~G@gV|iU&{N@gnMhi{Mc#@nG8>GfP|T&vsgz?oM~-OGuW^md<|6&wIPgLy!mc zc%VX$$3jGh00N>CfC_K`2*DB|Xcb|Y!yfO{()+np({s(l#G}zZ0B8YsFyT>@{#>H6 z1Xb45wjiC15FmTOMIb;wFre0@#retf*E8gRu0I7ggK_J}h53mrK~m0NJb=$8N0K2( ztqG`cJ^JCFb6gFg(@pVK(KQLv7vdx~Vt1!Rg08=MG#aMINUc5@h!Zkvcc(^uH-Yl2@;1ER(~11U2T!gIK{` z=D936T6h4ggsUKAk_QzciFMBG+j-07a2GT+J+mzVrvs;$QL+{;e3}X?XlQuUm1M{z@R$&CEJ-!AA|+WFB@2^_X$4B6cLPO|V`;Lg zF(lD8-nUE3E}Pd(B-xQd2>akr3lG50$R>n>b*ZNiB90tOws|KY>6$z+|WHqW(9AwnX!MGLN4#sI!sItD$qxx%`bRZK1$R2Rp zw4x&?qeNF$Bu!7iidWsv!dsY2ipb_VI emC1Z%0{;NpN`y$6U3{nj0000~U;`c`(g4H_iZ!8IC>eslTk(Bz0 ziRC`qKP}c8KlL*!>em`=J5$6B{W}f)G(3ZBoO`99)?+@p>Wwiw^I7v9miRb| z=^{pSoP$Z^oG2i#MChw`=9voPqO`6|h~-@7j=Y$y{mE`hS$PHuhO*@r zM+P5uDC{8ZEo>OOGWqJ2w2g6V)k6m3oCR%4r}drr~P zpmfjRlrL?nN&R29eh*dBK5(FIN*_s!u+dcRQzi~5?sS%ZF}tb2w#;v<^dx9>v0gFy#B2?rxsSQ|Ja0J{GCKXNidcgsjZUkh0NFX#L?Cc_M zW3%X{|Mc>08?MLh)tx~nUcx-Wtd0KVlX}kbeSdn}m)gg-RQtlMYP2Y$@N_uETZgWe zl2Zn{rBdUx*><_y(yho_rj_Dl;jeCXG=Pj~g}1#0t3vM?4fJWc`fG3qHrcB^+4r5Z zIR@`Rl{`v**ye3o+&Cn`Nq$LMpGR72svFXgGeV-Yr3hK!CL(FMd$!Zt2nazRX}jbKp1oW zkRO^Ot!7NiSBzc%CS~`Kq^3htQ^X^?!BBU!@cd&Oo(QHJs}h~j!Xfo`=&W;ToT|R- z>oBG-8zS=iZvG4EMqAi0vU>%Oq)=yKzf#2_b>U{G!janx0el?F`F1uxK<34xflyNuM*Vv(Kgv&2`SA}A*B!DI;7?Fb$#`J*T z5M4^~7U3{kD2u*@8;vdia?F3k%pbbGYiLcxe{=rW7&+_QnKr>DXNL1h3;YYfwSf3? znkO{EfgoQGBRD=Thqx3wp==5=JWF+Ly<*RXcVuYW941zj8B*G&bFXjg=b>CTTsM1aw`c!+vY%sffY zx5A?(z*(D>Gzu>un}B(eOmfZ5p-8Wwc&WQe$)kDc{!d3oUu7>k@IIuRM;&sR)ZEt_ zUp0Sus$$K%3)kCf%||{`NzdtT1GJGiq+Wi(lyh^aL0w(Jb~nmizD>0Crb39vm@ZK%G$o0 zGpmGA{#diFLC?$Ikp7_Fp?5>JgqzSzrx0|)hDq3F-xBXsZ%#P;j+4M+{+uroU7#m_ zCZCsOL+RMo<#kuah4LHRTT|zyD1OHaW<6r<)cE$}5z3!HKX(%>@(K^$F?{a2JO5^w uD=bC=3ci#kLeB4x(;TyS&Aa43^Y;P3EvlDMe%Fr?2ap`81cAe^SN{bRyMz4z 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 ce1395dbeedaf32d266ba9a38d78ef4ed5f31f83..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1037 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 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 3c64c9dc62f395479495bd751b7dd448cc493c97..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 898 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2VD|KMaSW-L^LEbpY+*-{|wH#gS3$He_>mS?Pn#8Ivx28RgD_65(`#UQ%AIPb9}{&* zmfdU3n9&U(m(z2GXC;=m#);K=4Ofl)AJ0e@`5lS`~3ZJ$r1^eq$* zS-pS5zQALzmg`>>3z0aNoL6?tx{cAh>c@+XJmci)q@`TsnA^?ccq zTbcIT3*!yc_NfUq8O`(wmOec*zGmC{+^nKSh0Qg^+kP!KxTvxs)Mu{3YXxSd6Ac_j z3Qas6q%&W&F8F!i^Xjr(cR@+V#5ep&70cP*Z%UB!x%a)VdVbxfo%i=`Upx67q4#3p9EcdE_lfV)K9MEbIB_ zuG!NFdB?X8a<%Wa2!GmjH1n#dqu(z1cjxktaIOwM(>rs{IvtU@CH9Y6N*(jgxJ~?X z^~F*J^|Z^U#kZt+_egJkc{ER?@m84K&-f&bW6gIpl-9J!dW0mJdoBI4FH7C?oVnwT z-bcSgSoR!HEKIgqTXVy6aasH1VEI9Yj}&sqXspc*Sc~UK#hM4?#VPf;PB>U-p`%UvNpIY|@T?t(x^U=N&EU zfA^#^9pA~-)%HxN@%b*P%=ZzGT^DRW=lSw!^C^~pjQ@6;SSXjyCUD;N;?4gZj_2?eghoeBEuZd33)p=VNwM0W#K|$|quRgxt?hgtyFKoGP{#@g8kg zc=fX2VRQ34nuc(g?~jbC8DAGdG7Y#CnbxxH{Ti+S7Eqo1y@z&|D7tuj0m493_w(kO zX(KTIW4EJm$An)pmiwYMGztcz5qkp@%kJ?U4vnefj9NYDpW{cDsY6-uaY#3$+tsl0 zG!<_8llg(@QqI8i+Q$>xQ{ffg49D48eB}Cp$75j@k9p`o#rwA1M?_{|SU{f_WB6&b zWinJlr!+Y*r(CLJYw7BAayp0+N1bngpJBIC=n&Mw%BFnP^4-H`n`J8D6NLzuUHO33w7+LY^rEz~*<6MywsLKRlsG^h_LIbhfDzr~P>Sv!Y;h zC(3#*j)kE6T{t&-5Bag{&Zq^jjWlBN+|yGV$gIh$H95s2-)^~v`9R?{gnrb$UvkOb zlW|L}P*av;1_paTp>+tv85{H;%%mh`1qD?g5U!+T{LetE7@)I3r_iOggfiJ0LkSOO zrfOqg_VP!NU>dED-w+oWGovviv&?qhZXZlwK9!+J1%`IUUc@h6P+X*eFhy=-$<2t97=^7JaCk>*km9=vGbilm6JNuby6@GiUQJ zsdnCpDPp{-?1p)dB3p>ODOEEDbaM%o)pNxcR3ia%acmWOuo0Oj(ay} zBO}XA&CnFNXj0`Co!8=SH=8sU46rG>$GuN?dktt_9v zehPxy8U*Z#r$7DT4K?w3vjvA$R;m_xw>V_4dde17Y3^Ev+?2kvLlX!kyI0lxP^j7$ z1)n$u(ts+RbeR6$*BZmCt@590mB=Yaw3$0a_R#oCA8TFC$r zCp3>F7g=4l40#yancXB^>xJ$R$$uRrIKP3trsSA>Cbq8s1yqEt`d^fx*mm(AS5@m}`~VRK%gT zGH@`+?*zn+uoOjz@9L%^;uU-oV0V%VjxP27`D5pv0$!5WbGV*pkm(qTA|eN?s(r(; z8AVY}yF`K{UR$Kf<`UBLX^C}9tX@d!6wE5&*KAaG&&Oys`9kYnd|vt6Ju@Vi2~6f+U&>J zIYF`e55Ohe?_EJr8s1??2dx-e5sRY^hwI79?Qq19G50V-Y#!S*5+NIQc0XwvgR}sR z=fl4ZpQ_rJuoEL1l>DBV9mnZx%ORqqGy@*2xC zdNzaS^9G|0^N;eY=uhx=5;aJ0a)Yn5^16O8Yf)%E5QT2o%Uw(tcuHUt-T@8GK1UOw s`YWSJmZ6vI07*u$l?Bw%iVsT@1GHq)$ 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 0000000000000000000000000000000000000000..362223ed5d771d4e8eed6a96a91b3ebe38558c5c GIT binary patch literal 261 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VTavfC3&Vd9T(EcfWT40{PZ!4! zi{7`BY`G3O2(<1O)Yh&S01|?G8m3vcrgK!V9*j989Z|_9?d_=>Rl)prORbovL(;MN zW>2!G-JSV8ExmlfqCXKA7cLPLV9a$dXP%Z;_Umc^Z%o77WC;dmWA4w5yOc8+j+gYF zeKS$}2D_T;p;^f@44J=8VvGM&XLg6lHbHBp=1SF*uem-%pW|p`6<}tcWZv&#_f-F| zwE>&i%YEEu;vYHA`h0Bp#z;olu*Ol{nuulDRX|@bc)I$ztaD0e F0sw{XVUhp< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ed46a479adfa94dc160828a3b5ab8e11e1a92d45 GIT binary patch literal 430 zcmV;f0a5;mP)z;Mo-XJ+n&1CdB15{X3q6QxPE zodc$U;lO@Rz^SCq!1}+jE!!tDa0BcIoDpCVC;$aXx6KrEr`UWT ziDgNfY0Us^k5LmGY__H=q){gc7PLKSyJS0`rBEZS++ZG<1uksQWaRUMU_nVcKoRH% zO19TBv#O(|Z&1<}unXjXZQH$>Sk;l{FaCz@E?@Ht@cNguXHfu6e5s-$Yi1%Xv+ zPh0`!C7s(|01g`RUThCZdhN7e8Q25XB|Ri-S3n8qtIN9s-a?xM3re~KmKxUXfw|B< ze|Eh`8L(*dUhkg<<8MMt8w2g6k8Wd%m%0zWza|Osq`Pg*kb$Q_?;?Apw>3 zcVvIK{}nGUeB-#m+?-I(JZ+NRR|RgigBU<(>L zD^Rfv5e@kicO;_l-kIwnaemEd?wRw?+}t|@DW#NB`b{PBusQ+`f#t?|Przj<<+ZW@ zYwFnk#*vxx!Hi3_-T+s@-hE*A!x}S*27Gs0N;!!%s4H{z(u^8s6b zGpp)mDdi48T?96!^)$|!ojUn+DjcY5>V4oOnM7trckmiG19pIIU<)_}*6B`{hbjHf zb&X-E$8|SeYCX5RNPN4#AIS)~0FH*bp|l2ktaZ?E&C~^TZ>ZOrQc5ZPBX1_J$!9|@ RS1SMj002ovPDHLkV1h45hQ0s* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..937742e8d30396c4bd598c7edd63354b1f86aab5 GIT binary patch literal 1412 zcmV-~1$+95P)9 zB}Ho^8k#mmAw^m(1*I(&O0`9+6%=WeL}Qe;P-y87t;PE-o4Cg4?#5CpZdMIsvpdrt zN!G+9JF(8rj&Yt}XU@!i=jGk+>^HM#ID$9~eQIo`)S~&oEFcST1O4KC<{{t?;0Nk~ zgQ|2?He5RA>*-?AU8mbU(nYHL2v`cFBtoTyPz9nqMblQ-*L&*kJ=w*ejI7afHKFYU z`kSH7LfjH)i+q=B4s`77lpXG@Q6&hr1B$8Atbqbz!N9b^!BFtV(e}<*2DN2QIUtiF z&pN0At)M-V1b{0$+byDwY9765)f2ZwM9cO0Y8qOzCF1x5+nkOJ&>XA-5N)Nrj=o>5 z?w6>R63LQMjm*V1r#n()G&Nr$#EfJsu_~IT&XhoQvV~X{Ax22xiDc`rFfNH*z2edT zQNJnF#WqL>+aMimgLJSB(!n-J2iqVWY=d;L4bs7psKCQAGs(@(A=QzZs38DoiL_Ad zspaa`9v3Da2*V9>yIpMjd_9w%f7a;GaXIqmUzDyY!yjlcG_G(D&((|yrYH(O9VjO! zXM|B9?~B@tUS`amM>rfdET*BqK?V7*C(RsykvZ8El@^ZIBM88MJIkA)a%`*}Zc+Lx!ZAHS7apnn53wzDqx+lb2qcOx3D#TbJ|P6}m#k#t7aNe$n)Sj|RjR78;j=F(T~&sr-F*a{S1^Yc zCOv1SlwM={*VfPJWct)8ou*YZ8s+bQuA42TS0wEJpzd(HhO%wTCdQ1uTfy!$HBnN! z8n5?~nNoU|VR&f4;2WYKs#Vd+>e4dI#B2?_J;V4|dUA}OU~ zUu6~Z-z@B8j#V)&gO)8XW=Y{JQ^y z6KJ?b*_yTH2czsGu$Xj;$PTT6op}#8L{JFu+h-?hEB0lr_ZqP zjrjoNjUR`%`jnC3d#T~9q45mOpo;xf)L!%&8S=iUslCX)$|^%+8fs8D9Hwwd5r>Z) z)xX$a4j(znTT7Q4x+Vq&FC2CxBg4(;+)+H1n%Jf1a7zmpsy+DqfrP|Qm_dC7yUgog z8>EA6kPfy%I@kv3U>l@^ZIBMOK|0t5=^%mI$rfQ@LJ~0cwa~srg9Pf5t-`WsTAc(A zC0mD85t4&am5xdvnrtx^B%;b_m2BNyNZXNYHC96iwgtRie`}d<(LM%lC0CNA2+62e z2W|I3et&HMRb7BR)c{l#7G1egeGNM7*%4~I;T@PZIEWC_=wa)wM*tOgFL2qjv%RD1 z7G1+f&Cw9MfB|MIvj8DYurN^PIoPqY%dRh>;Eft*dfInVlM3)OegAPp1#MS~D(Cv@ zJtyux8FwnVva{VXnwqZ(W}#`rY27C9Vc*K)HsD9o>V%YsRM~PM;Pv`@y7)gD4T$bD S%K^dw0000 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0c69422578c5098c3882f826ce2cf428248a7d52 GIT binary patch literal 2799 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1c8fe588996d68edfda216afbfeb27cd75e21dc9 GIT binary patch literal 2047 zcmb7F`8U)H6#tIFh(ymmWB14m<5>nh>%<7jzLtHO z#ol;4OD3jllig%UJpP0C&OLWI_n!O1=X374p9~u-Q(kT;Hvj;<=4KeXVTu8cPOOYvlMeupXXcm-4!DxlPuLLqMTxhYv~AJi=JPVj6!IAxXK-5^TOBo5=hsR{#7br8l*wact_ zec^&H9c>*GZYZmT`!p9WoUV-y#c2yB@`}X65m&Cl+d9Hc2CMvhSf!*URV=#v_J%OW zU)&U90ZA8+wyj8YHF0v{>rBXKIdd@U5A|~(2Vp-{tH}qoArV7K9S!7@p!kOi3kyKW zfK)dwu)55tv!bfXe@d5!$_D)zw($GLr10SC(7htcD_!QTBdH#va#!r!1Q~cBbW^$2 z{KI0c>o8$;Pg^yLPjF)uLN8C_AO@4dw)i;Q;(by0h|lq^Uxr?oR~Sg?VT+b`uC@T zYB7`4Au?28QB}Zy5FIuGK*^G+%KigLD7tVWBDbv`g-g`<+H3^o!s&Bd!NRRY<<4?q zWKXAKMENsRrGN28|Js8$O9ka|fo1>0KzTC`AChX+7MS!*2s@tom!!XKiLOX*UN%im zxPN7WQ?JU1(L-U#%&-n9(>Hcvh_a%}tJX4AcJPqU>166-caNO;yP9{Nik#8&+}Z@t zs-k^75W;TU>50Zufm8_?S+d+Kq?0(#F343dyeJiVQexg@fb!BEt?2>Vl;SY&b&cUz z{fkzKht#6YcfFp+yJy#u|2u;JiMeqvyDx_`&aFvXOY62aEe;0ZBd0qcG}k*ScBys7&!F+%rA!?FPQ)&_sAwZZVVP_fXk)|z82@n-eD`iT${NN*f8

g0gENW zeF{<0QS{Nbr5IVYt7UbU?rG*Ehqu`1<*g}B5pZ`mxe6lD+jHJZh9A_Wk1_a*@YCND zTe;IWzj#ivj|nYbVE9gpSrZCP(8n94^}qeX0EdeQ%zmBOR#stv_Z5YUBd$1CLZ+u; z{um~uH}chz8em7bT~=nln6) z9~q;U2+XkR`|XW}sd?@H|Bz8Y{7dVsyMsc!(!0c;Pr|jUy~q5CHPTplRKfCY9CvGh zom4PjOI(!Jg`X#1g^O>;@wevtZZv!NJUHM53}F{1)j_PIW(cH91vo#)MEO0Yr=+-x zD0rIb%`YqZNmPxU{XC^Ks*rUCpcAYxT4FKH4=cLc@bjBX>2?c3VF9wPqMwU4agpo~ zw>%!27x#YlNEg;x(0FCrnfSzlZ9Wc?d>E;{aWghr%tp}}BXl@Dg ze!xJH<>1FrwQ*=!>i#`HsL1`5U0_0lT*-hrP{>i78Il|BwYHCY8Z%`L9Xv0kI4-rE zQt=%8PB2&<5hun$jZV<(E_jQ!mp8prQK%F=1jhM|npXG(0ByQjq6wkT(@31`!27<9 z_g@_~1InL@=|!}-`DebKW!Xdy72y_6a{>1==Ir|$oT829ya9`C14$uL<*lGBUS{+_ z`^V#_N;;*9`o!j&&Gtg$w+dTZd+v4E*J&stX9xMLxxv^zq-?R* zQJ@g;hA6&Ee!_&H3S=wQ(jHkeu`MoN>{OiZ7ai>sPY_?hb5G6it<{9 literal 0 HcmV?d00001 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..cdbe4319b --- /dev/null +++ b/apps/streamdeck/scripts/render-action-icons.mjs @@ -0,0 +1,115 @@ +// 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. +// +// RASTERIZER: system `rsvg-convert` (librsvg). This is a one-time asset build; per the #1440 +// scope we prefer repo-available tooling over adding an npm dependency just to turn SVG into PNG. +// Re-run manually when a glyph changes: node scripts/render-action-icons.mjs +// +// Frame matches the existing icon convention (measured from the live approve-gate / action +// assets): the full key Image is a rounded rect (rx=12) filled #1C2128 with a white glyph; the +// list/picker Icon is the same glyph, white, on a transparent ground. + +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 +const GLYPH_BOX = 24; // GLYPHS are authored in a 24×24 box (see face.ts) +const GLYPH_SIZE = 40; // rendered glyph size inside the 72px canvas (centered, padded) + +/** + * 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 `'<… fill="${c}" …>'` string + * (filled glyphs). Throws loudly if the shape drifts, so a silent stale-icon build can't happen. + */ +export function extractGlyph(faceSrc, key, color) { + const line = faceSrc.match(new RegExp(`\\n\\s*${key}:\\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}`); +} + +/** Center the 24×24 glyph, scaled to GLYPH_SIZE, inside the 72×72 canvas. */ +function centeredGlyph(inner) { + const scale = GLYPH_SIZE / GLYPH_BOX; + const offset = (72 - GLYPH_SIZE) / 2; + return `${inner}`; +} + +function keySvg(inner) { + return `${centeredGlyph(inner)}`; +} + +function listSvg(inner) { + return `${centeredGlyph(inner)}`; +} + +function rasterize(svg, outPath, size) { + const tmp = join(tmpdir(), `sd-icon-${size}-${Math.abs(hash(outPath))}.svg`); + writeFileSync(tmp, svg); + try { + execFileSync('rsvg-convert', ['-w', String(size), '-h', String(size), tmp, '-o', outPath]); + } finally { + rmSync(tmp, { force: true }); + } +} + +// Stable per-path suffix for the temp filename (Math.random is unavailable in some sandboxes). +function hash(s) { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return h; +} + +function main() { + const faceSrc = readFileSync(FACE_TS, 'utf8'); + mkdirSync(join(PLUGIN, 'icons', 'list'), { recursive: true }); + + for (const { name, glyph } of ICONS) { + const inner = extractGlyph(faceSrc, glyph, GLYPH_COLOR); + const key = keySvg(inner); + const list = listSvg(inner); + rasterize(key, join(PLUGIN, 'icons', `${name}.png`), 72); + rasterize(key, join(PLUGIN, 'icons', `${name}@2x.png`), 144); + rasterize(list, join(PLUGIN, 'icons', 'list', `${name}.png`), 20); + rasterize(list, join(PLUGIN, 'icons', 'list', `${name}@2x.png`), 40); + 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..198c5a250 --- /dev/null +++ b/apps/streamdeck/src/__tests__/manifest-icons.test.ts @@ -0,0 +1,83 @@ +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`)]; +} + +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); + } + } + }); +}); 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. 8 PNGs rendered (send-queue←comment, open-terminal←terminal), manifest repointed, + 6 dead PNGs removed. Render script committed. Two vitest files added. +- Verified: check-types ✓, esbuild build ✓ (after building @cluesmith/codev-sdk dist — the + pre-existing "cannot find codev-sdk" errors are a fresh-worktree artifact, not from this change), + `streamdeck validate` ✓, vitest 160 passed. Glyphs visually confirmed (comment bubble + terminal + window); key-image bg/corner pixels byte-match the existing action.png convention. +- Next: commit, open PR with review in body (no review file — AIR). From e37f0a5f990089442209483a7713c6877453bdb8 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 13 Aug 2026 11:41:03 +1000 Subject: [PATCH 3/7] chore(porch): 1440 pr phase-transition --- .../projects/1440-stream-deck-polish-follow-ups-/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml b/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml index c0f5c2274..4dc218d8f 100644 --- a/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml +++ b/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml @@ -1,7 +1,7 @@ id: '1440' title: stream-deck-polish-follow-ups- protocol: air -phase: implement +phase: pr plan_phases: [] current_plan_phase: null gates: @@ -11,4 +11,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-13T01:33:00.163Z' -updated_at: '2026-08-13T01:33:00.163Z' +updated_at: '2026-08-13T01:41:03.395Z' From 1d19f99f249c55074676acdd9e19866c7c2caa46 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 13 Aug 2026 11:50:57 +1000 Subject: [PATCH 4/7] [Air #1440] fix: size action glyphs to the icon-fill convention (CMAP) Claude's PR review (verified against the PNGs) caught the first-pass list icons filling ~45% of the frame vs the ~95% every other list/* asset uses: listSvg reused the key-frame padding, and the glyphs don't fill their authored 24x24 box. Rewrite the render pipeline to rasterize the glyph, trim to its true bbox, then fit it to the convention's fill fraction (list 0.94, key 0.56) via rsvg-convert + magick, with a build-time self-check that fails below 80% list coverage. Re-measured against siblings: list @2x 38x36 / 38x30 (sibling 38x34), key @2x 81x77 / 81x65 (sibling 80x68). Also from the review: friendly ENOENT for both system tools, quoted-key-safe extractGlyph regex, and a zero-dep PNG-dimension test guard. --- .../icons/list/open-terminal.png | Bin 261 -> 557 bytes .../icons/list/open-terminal@2x.png | Bin 430 -> 804 bytes .../icons/list/send-queue.png | Bin 233 -> 488 bytes .../icons/list/send-queue@2x.png | Bin 319 -> 620 bytes .../icons/open-terminal.png | Bin 1412 -> 1161 bytes .../icons/open-terminal@2x.png | Bin 2799 -> 2135 bytes .../icons/send-queue.png | Bin 1078 -> 990 bytes .../icons/send-queue@2x.png | Bin 2047 -> 1588 bytes .../scripts/render-action-icons.mjs | 120 ++++++++++++------ .../src/__tests__/manifest-icons.test.ts | 15 +++ codev/state/air-1440_thread.md | 23 +++- 11 files changed, 113 insertions(+), 45 deletions(-) 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 index 362223ed5d771d4e8eed6a96a91b3ebe38558c5c..a7358fdb025b4ab627c742dcacba15fc692f2607 100644 GIT binary patch literal 557 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc0wmQNuC@Uwg=CK)Uj~LMH3o);76yi2K%s^g z3=E|P3=FRl7#OT(FffQ0%-I!a1C(G&@^*J&U|7|wYy{-7mw5WRvcKZsWi-_AwmzcH zz`!Wz>EaloaenQjjb6-w0tV>dYeH41rGOaFKJ!>a8V ztWnpLklf)Ru+hMygR#40*RiirdqjCtng!m69F~4Jv(os(`ykiS-muu$3A>h^vCZ%F zlk_slIr-(Fvb0U|;l?vXei!E)=BQja{lyCoLl4HbiD^?S|0nHN^848O!Lu_tYfa@X zgQZE6#ng3*Ttue_tn%=E+RF6yb?o|ELd+AxgD-AZc9`YaW0!czXWv>)z8lMq%B8>A z)WcAC>Zn4M8+X;EsbMXfc1;n{oK*6oxvat9-Hj#Q(l0hTEB@<_cBnIJ=QwfkIb*U^ z1K(Q-bH`~ij7-OQQ?r!IZQUQQzaA|4-0t`_@ptz(w`x@#+z$*d)e_f;l9a@fRIB8o zR3OD*WME{bYha;kXdGf-Xk}t)Wn!RhU|?lnaAwoRLKF?T`6-!cmAEzReV=(2s6hj6 gLrG?CYH>+oZUJsRuYP$50rfC=y85}Sb4q9e07p^9sQ>@~ delta 244 zcmVAnlh uU*bWU^TwyKSpglV;FV--&fZNc%FGie#VveTrfYft0000T 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 index ed46a479adfa94dc160828a3b5ab8e11e1a92d45..d8e250ed40abd0381a5a92c5944144986a19f477 100644 GIT binary patch literal 804 zcmeAS@N?(olHy`uVBq!ia0vp^8X(NU0wmSG7d!(}3dtTpz6=aiY77hwEes65fIP;uzv_{Oz=j{=tDF$LhD&3JFCAc+M7(5Sh#-uBfN>LH@;~0}R5k6%1$>sB^w6!Hj2RxlJy5*A|SNK#y;pKI6tJ?hQt?AkahgMxRT-c^6E`(E?B z`uQE^=Vi~%G$=Su<6QSRV$qZzr@Z3|QW&OOnA2socIT{MCI;WF41SZkW_{AAPhima z+!@=$nmF;1TH%Amk|qn{RT_EUZOY}maXLDWf%Wo3hC&9un@6{bq_#VMnBZ4pDxo9$ z0KN3u2B85ea| zEokEZ89ccwk~yf+MCy7Y|4-lAi;T&mswn36cAgMfy(^F*f9;oirCa zX2Q<0V7~gDkMpnZIL~z9oUY#WHCmhf?7B`}-x8xc^YbZ=g)`(({RuU|F#yS(% z1ui?Iv{S?IPj<+Ze6yx0{x&=(*K1g=IS|LL!F<6}>iQ=>kIOIH8QE{7Fr4`COyqh7 zTZ3=@y-VHmrfpSnT`#)Q#9KHe`kKz%+mV~?jhwf)Z3t1n0!${VC9V-ADTyViR>?)F zK#IZ0z{pJ3z(Uv1IK;rv%EZ#j#6a7?z{nC}Q!>*kackK7KJzS4g9hA& flFZ!H;*!MN0^E9D{qhh3>S6G7^>bP0l+XkK(sng~ delta 415 zcmV;Q0bu^52Cf5;8Gi-<004~sxNQIc00v@9M??Vs0RI60puMM)0004FNklj#M0wHO3Hf=v*M8x|~n zfV_siVOrhkFqzZnVQ4<_&wx%nj zQ6~u&v^{CNWILawP$R9}U>=wSE^Nw%0PVs-vZEP|_B#3*>-p z+r61s)sf~e{(pw;E?@Ht@cNguXHfu6e5s-$Yi1%Xv+Ph0`!C7s(|01g`RUThCZ zdhN7e8Q25XB|Ri-S3n8qtIN9s-a?xM3re~KmKxUXfw|Bs7;*HlV|Gg=roCJIMJcD z(v{nLgTe~DWM4fU9PA- delta 216 zcmV;}04M+G1L*;f8Gi-<0051N9Sr~g00v@9M??Vs0RI60puMM)0001uoi6e0I9rzM-j9UuVCj%>^y_l@Dg?wb}2*yW0OK+zAh3FB(L0=dF-;# z@&Bml(Dn$}1A}091D295^&krdV_*WTgM3$Yl7#?;q+?THyHSWny*I+IXk8dR*+6c4 z0i1w&EzhgY3(+rUzy??WeP9b*pDK6YAgN5gy6u_mv+Zf}=z~zUhposue#-+s3@t2_ Sg2JLw(On`jwcMKf8ub-!9rZn^ z*unMa!o{Wi0jz@T;Va|n_&VQi?Va(&s^o;d^Sh#x>i>V=pLk$RJEQD{B;HDI{VhJX z8>-g$Ywk@CHdnZR=Z=Z*)t8Zrn*1D|ZntFFZ}BYCVuG?xv`nBhyRNJpSK5!xxhxBr zmNlr|sbpm>{$=d7fyH|ROYmZ=TfP6;j%EICU`!CuuuGlm{N$eTAJ0guJr|$P^4>T3 zqOH`K|Fc&`q^kcrA;p|wVz))RB_R0yDans5Z%+IcyTP*HBM?u=vuncXrfcY@xD zaz1voe9~qW`t7C%n-sHOONnvbI=S$Z#!f4i-CiQlUnMK@)TdkN_rs>*U9(qwth76K zlW*mN25kY&gxk&DjDL5q?3pjc?$qd~>Nzp|t&BkK>ec)7wmdp}w&V}Pt#WsRytUUR z0E1h##5JNMC9x#cD!C{XNHG{07@6rBSm+uWhZq=InOItx7-$<9SQ!|c*>tfGMMG|W uN@iLmZVh|iXPyOW(16=el9`)YT#}eufLqV2Umik0Jq(_%elF{r5}E)%!{a^x delta 303 zcmV+~0nq;J1iu218Gi-<004~sxNQIc00v@9M??Vs0RI60puMM)0002=NklI|JP9HeAZitbMx#@RL|*kOjg?q|j!j?-8agXbu?!In`4o2~qVV3C>mqS} z&1vqL^UvJeI|C`Dlv4UlCGxO30uF)Y#(7V`Whv#gvHxr8*nj@Uk(u+sj7zoN09V1@ zePH*)8Z(Ioe0N()If*o=D|7)v=AsQhEd%zI0sH#%0b74FtLkPcZOrQc5ZPBX1_J$!9|@S1SMj002ovPDHLkV1lP# BhY0`x diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal.png b/apps/streamdeck/com.cluesmith.codev.sdPlugin/icons/open-terminal.png index 937742e8d30396c4bd598c7edd63354b1f86aab5..5e551eaa4988395754e119a132826396aed8f3c4 100644 GIT binary patch literal 1161 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=IjczVPIek4)6(a1uBqH)WDCV71R|}wKH=IPo6sS`SX`w zzkZ)QeI_fnNM1#|rM>I(=gqo;pKA$5>0>?83!M|Ni}3w_%fsrM;w_>dTj}j-NQ?<`obTop|HMtvh$_#wMgX zx%nJAeDuwmx48wSl5%PWrq*lMZ@PBh!gtR%FJQ28mjw9* zGsvfIdS!8qYvBT6!Hlivfx?VQ-tI01HyCnCfE@M`PhVH|R~)>Ih8o`1N7RARKRjI= zLn02py>UH=$xwvt!BUUY2fJL>bS&a9dz18jZ_@E5-rf8D&ppYcZ1uP;=Uk8Yr+9t& z*}G0&+!CV`HhZqevAoSuTi$CHpHTW~af(Y~`DvZQkISCP_Lt1A+j3!YwY+P)=gsn> z)mAM#zSM=4b3C-otJ=f$IN>wnYdRTLPG?*KAyQQ^B|Q%|tfED^8gkOs}L(2*{pr zsZ8;R99MaX8_%Vif@$?(B3DEWrCxYkmv>~a5O7J}A5bJv^8L%VN=M12e-=xbo1Fzy zGb&on?z*_~V@uIxCgB=QA3s$QMW)NfF|+0}yLPBN6W_6A#}clIPp5|JtzFw}WYWf@ zYG(FzS?~!% zsPvrr6Rp`33NHNKdNgi{Ls@6cCe@;&(>ab@iVvuX5K=0roxE@FWovQ1_s0BHM&kXO ze3R|#tIp*s3)G%`%IU*Xe=skl>PMj2|8I}%+}AsCyk+kEcJkZaz2b!(D@5g$4t!U% zxfU`-yl{=-Y@sjv6}NQD-wG9ZThvn)>#nWvV5*79(no=2d3QGPM=5sgogBn@^63%% zYl&0%?HS)z8Dw7lv)K@sEL2NeBT7;dOH!?pi&B9UgOP!enXZ9_uAy;=fuWU&rIm?+ zwt<0_fx($g7Yk7|@La^``&; delta 1404 zcmV-?1%vvD34{xf8Gi-<0033(vqt~`00v@9M??Vs0RI60puMM)000F!Nkl1v7vgy$E7BE8VZepy7##)B(;rFJ#D63^vChtpah_ji&dh%2 z<=yY>H?wCrf;bF)YHX&|qWQoqAPaB<{o;M*A>a<+2kL=?s&rH~Tsr6L>0;4cr`tW! zMXLM=SPG;hLZyXJ1)@Af(^l8ld+P5!*~OrYtkH8dq3s0vo1xA^+!APue3xqubnNVu z9qz1AB?z_yihrrntbqbz!N9b^!BFtV(e}<*2DN2QIUtiF&pN0At)M-V1b{0$+byDw zY9765)f2ZwM9cO0Y8qOzCF1x5+nkOJ&>XA-5N)Nrj=o>5?w6>R63LQMjm*V1r#n() zG&Nr$#EfJsu_~IT&XhoQvV~X{Ax22xiDc`rFfNH*y?^4-|53jw)WtSP2iqVWY=d;L z4bs6jNC(>>9c+Viunp3|k*L7KGBe4|%^}s1ny4WFXo<8??WyJJ)gBin9tgt?a=Tq@ z{Cqu=o`2Tp&~Z8P=UL>qnn8mG4UDyY{y@Ub0cdV+=Bm#hJ5Ea*WN3W5%|MF|>*y((nEt|ihw4@jzx_yt!5}0Ptou(#=R+g~o z+pV!aAq4N2tYqUC8<5hP^}|VR&f4;2WYKs#Vd+>e4dI#B2?_J;FN*TUK41zM%kLR<_PK~ z*1S8j5QZAm*cghnGc(*MO5$JGR}_U|nHjO;V6f59_=ZlgQ>V|c@QwKZPu(;jE$Y49%d5{Z-Un^corRzNo3a$iB)dLt`3hP&gc>a7ht|j~vy%*kBGHIm%m0 zmm9h!1_m!2b|fRi&FI`wJeHc+rRQ)<3m2+A`2B%|#7~$(eFeMB>tGwCgKdxwwm~}B z2I*iMq=Rjc4z@u$*aqn!f!oOzVSiyl5-|3)(7r{31nQEl!m?;uodgagTZdH;Eft*dfInVlM3)OegAPp1#MS~D(Cv@Jtyux8FwnV zva{VXnwqZ(W}#`rY27C9Vc*K)HsD9o>V%YsRM~PM;Pv`@y7)gD4T$bD%K^dw0000< KMNUMnLSTY``Ld({ 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 index 0c69422578c5098c3882f826ce2cf428248a7d52..75a8bce779e1005081f8ae4851770647ad117997 100644 GIT binary patch literal 2135 zcmb7^c{tQv8^?b_WX)2^He(wzA){m&R5Zg(jO<%v9ovuxV<(eTc!W@->|sdBl4Vfd zh_sZ|Lb)WC|y3c(+*SY_=uajzJX(lWnEdT(3Fb0jZ z;ds`c&Ckuz!F=&Fj)A?=Hdp|Ng#Z9C83499M?@+BM8E-n<^ceRd;mE1*CVpEApjgG zx3aK9aW2RzK>x2eF0TSnJLTc+UsYZEZESpHWtGX?;4D&2t*1|bi~>{!tZHTF(%RO+ zU@-Uh_ZduP8@a>U-bDrsl~+1Jh>BZSpe@j7gsAwih*+$(!!IiJ*DtCvOtYb>xuLOH z8LH{z>Pe@s(dl%j%bt&lN`L?Uy|KZ1SXBD))4=2uB`hMw%GTM+&UtEz^5Nsa+}!-` z?rubM{83q@W3oyS(Fru#B8$zocfLYfTx7A>(jZl75Cp8E?(F7O@Tj<^w(eHy9U~KT zT6)&__X#&oUrzhWD=TpcNo5sPo12^W@*l!ZYJ-&3np??xdwb6tnsgAxc@GNT_P{yK6-|C|z3Gx>Jf+WI=>$F#Gnmxh*Jap~jj?QIJydxP_) zt>lguEp7T|ky3KX(qPp~woY4HTWWA!b-1pp2QD%uAv!M6$1g}*@2r<^;LPkS@y2aM z)stuoo1Qm)n_F8uJ3C)TM%}&q%*?H4W@h1M4Box}n0vn7Fh%*{c-f=Au^DUSU}5FJU@*cXW2p5vn)5-2ljSJbeecX8$9}tYshO9nV4N1t%+v(7LGAEv)CI)gpmorw} zC!5&VKQMYb`~k5GJR#|wbbx#B9qh;w_D=Q>n@{+@xr7WAi0TWw0-?U_&mwvXrE`Xc zAZf2vm^Wl$Iwt(ML8jx*{c@hM6chf@%oiEy6zL&Wxk_%@9xi=q^Z?qt$$wU?bnUs1 z1ufoqoU81EBF|4vzeANZyv|+$sOyi#dU=lCNXZ=Pa)m1>8{R@z-0 zuXYKv-e%ta&{_K$hrd&exze6q;pXbgE_mT3M(oj({93MzuuML9=*1$KfwqYc&Rc$a zgml#?SsrtdBq|su*p!9yuLBWfT z@B016E)p@mi$WD81Scz*ibu`M6Vkqgrv9T2Fp{u3snf=i&)c zw@hMBtz}_5Vfkk@KM4Ay9mh4MlpWQ{VhEtAWAC@yqKefsk(+^}I&wO7G?A$4E~7Kp zWjuZnPtPiqCm05SL#3QDL_k{EhG_XS(kZ>^M<#>OQhlYNw~A4-jjFA%*ZktDmIuln z_1jM1$DPb*)nABE?`@%$0-cezpr5tRk>?AH`U(-R4cgv?|3U#nzg=~a`F?R)n$IAI zHJqqq-0YQWDPC151T=I_d@B3|6N52_|t%MDJai~=m7H9N5JrL}_QIL^r zygnYWc-^nt^mC`hR8x!~T{%R>NaWD>X6}q8?<%s;;m9$+`KxurIU_;9FXZ#tifePb zd?NA31uj$w_j#QTB;WU-2b{?oms3#hVf*XYm(whs}b=;=c^~gy?fk zj!28Wq~M~$YKuoHoqkf+=5|D7V+95R@1+P!+R_IVW1I9Sk^O6%soVo!{7|uSGm9+V zsG?WVTY)W;#q*AFMx=F_haCy*m;8w>n$MK359FACI(queb?4>>T{4xg_G$YUgpm3v zUMxgRuiYT zBa@2ihmeaXm#(`YH&CL4*$?F z6#73vaEMQUZ_K{~ovQ&KIRNy(FAxHPap45d;J^Nzp>3nZIShb7St6gAcqIJ;4fWmJ literal 2799 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 delta 1068 zcmV+{1k?N82et^18Gi-<0033(vqt~`00v@9M??Vs0RI60puMM)000B)NklX0q1FVzpo-@<^B#`3`Ut z2)I$Dk5B?}A)xi!iNr)=<-saJJ399CXrV`dO^v9|N6ZU!Z}QgUC1>!i*%9g3^(KNZ zflxzB^9Lai2YG<$=3D@zC*}DECD1RIXOaL2N23u@434w@mQ^k0 zMM3XQCMPqc*-{9;hn3@Gvkr0wKrFZNIQM_I`mYpqpwJAe?Dx77 zzoNA|Ac3gYh4>aB9+toZUf1Dc!V=rP)?(9ozbG_f1%DZ^f(%$e2CN_hR*(TJ$bc1O zzzQ-DbR%gf6yoV8pP;q1)s6I385$gD$bXpkiYrUZ`BtO3yUwtz|DxG0^I?18K{Y@nY8qmd>9eXckzzQ;81sSk{3|K)1tRMqckO3>mfCO%P zU4)OxN+9EP6+R{-fw5*YKk4!MWE<)F+QZAWbv{rZ7!t0_PQFs zAq3~ru~@3q=I`itfq8FB@)cQG6lY-hI)5mY`YVl62eC^HKq=^*ni~HH9D8MgoDcfF($=Yh?Qtjq^wwcwR>d}7oYtlIS@J3BMk zwypgOsbvUwocsTFM1ejZP_iePnD}kw!8MmsI2w(}VoSFcbfI-8rELP6eJhLGKqU&T m<3h?YCGT8H$6~2kC;tmVmM%FHwU8A60000<=K159H`UeIPFz$$6aWBm z5)n`4d)D6}e2lO3La8*qsRa_rP5^LA3jh*R0AQbgmB;~rn=k-ay$As4LI9A9D(Y|p z0YIS4)zO2%f1sqU^S_RQs+OUtdB)@1xjEMM_RbEE#}6Yox}mB0;Nals=m-YKN4G`x5(15tQ$BlIMU%~5LV-4B{8CAS%Uul! z4tH?zaCG%_aQO~nMNre!-`(Bi@p#oW^*DkqlO=HyRLOO?++nG;i! zQ_}_}C`C1GI|n!B#3Tx|9%oLlmzGf$HjMF!$GHUrk_%&;X=q|zTh~Y+Inx=;+WJOa zBeUBHNj<&&6O+@EQ#0@0&F$~+ij0}uX`Y5h*^}-_i zLSX2~D6OTf!vtaR{{2F0d#4G~qOF5sinMI!E6TE?lM3f6r3-Fu)k*C(Gq<8pyQy70 z)b3sc8rRj+hs5C6OH18O(MpNs=*SXLsOR@W%5go?Q4Uw9y+d)^91UT5?6$LL&RPYN(49STASrrx+ zFxGLv3}^$uF;@~E=MndIZ4Tnc#L7c9>6LU7Y1=$o-MpvdVcP*xvgk9E_sOUQmYCdn zYKbrTNp=f1Q;|A7xOXJ9eS1B-^K9&%uDx>_=s)RIBjViXvU@8nmjc~_;N9m$J~^~3 z=jO|jUlccu*ZSERfP)zw1iidB3p3F96n8f$tvoDGcD7iz#4)q-&*~;n=xz(bLCwZP zz5Q->#L~Li^S#U*dr4XWh8t3gZy886RUs)zM3+~th|Oj^s-c~cTB)xkpIANF6gW>v zm=%uw9InO};>!W4XP zAVz;StVV52r622BhAkVi%*R_(a89EJwY;#fYm4Gn!Z%=CCWG}_QD$uf!-O#n5mQB>u%UuFJ`g24h4`TRnEpAn)WWSMMxb`UP&Ph zzOKrW3hei>CtNDu<_8pFK ztb{b@H}^x8Q}h=f1i^1g<%|e%99(_=4{=qv`oI9NxfHpU1m>2uZs6lN*bT3g_#vDs z0#vn1-H}Z899Q*x{V^LW0Ug>eN?88X#}j`eR^fBOy*N^Oo^WdjXO*C3(VZX`QKEcx zY_hOL#5^#$x)EVUS+2fv2lv5t?#3RfSx>J zq8DCpY^KWE054eQAw~&6Yz`kJ>ODMOA0$J0=DRtZA)r4}nW%TZ;HswgtAp&veutYz z&^J9IXN4c?^1qvA48c1l=u*sObl{E4d;?5iCI|x<%D~vv17?hdo1@_{Js1oPgN-yX yuKx>&hztq~j{iD9-3S}y13LdqhzX0h9368hBI@hR>ON75&j2KXGrrFHV$xsL!nQO3 literal 2047 zcmb7F`8U)H6#tIFh(ymmWB14m<5>nh>%<7jzLtHO z#ol;4OD3jllig%UJpP0C&OLWI_n!O1=X374p9~u-Q(kT;Hvj;<=4KeXVTu8cPOOYvlMeupXXcm-4!DxlPuLLqMTxhYv~AJi=JPVj6!IAxXK-5^TOBo5=hsR{#7br8l*wact_ zec^&H9c>*GZYZmT`!p9WoUV-y#c2yB@`}X65m&Cl+d9Hc2CMvhSf!*URV=#v_J%OW zU)&U90ZA8+wyj8YHF0v{>rBXKIdd@U5A|~(2Vp-{tH}qoArV7K9S!7@p!kOi3kyKW zfK)dwu)55tv!bfXe@d5!$_D)zw($GLr10SC(7htcD_!QTBdH#va#!r!1Q~cBbW^$2 z{KI0c>o8$;Pg^yLPjF)uLN8C_AO@4dw)i;Q;(by0h|lq^Uxr?oR~Sg?VT+b`uC@T zYB7`4Au?28QB}Zy5FIuGK*^G+%KigLD7tVWBDbv`g-g`<+H3^o!s&Bd!NRRY<<4?q zWKXAKMENsRrGN28|Js8$O9ka|fo1>0KzTC`AChX+7MS!*2s@tom!!XKiLOX*UN%im zxPN7WQ?JU1(L-U#%&-n9(>Hcvh_a%}tJX4AcJPqU>166-caNO;yP9{Nik#8&+}Z@t zs-k^75W;TU>50Zufm8_?S+d+Kq?0(#F343dyeJiVQexg@fb!BEt?2>Vl;SY&b&cUz z{fkzKht#6YcfFp+yJy#u|2u;JiMeqvyDx_`&aFvXOY62aEe;0ZBd0qcG}k*ScBys7&!F+%rA!?FPQ)&_sAwZZVVP_fXk)|z82@n-eD`iT${NN*f8

g0gENW zeF{<0QS{Nbr5IVYt7UbU?rG*Ehqu`1<*g}B5pZ`mxe6lD+jHJZh9A_Wk1_a*@YCND zTe;IWzj#ivj|nYbVE9gpSrZCP(8n94^}qeX0EdeQ%zmBOR#stv_Z5YUBd$1CLZ+u; z{um~uH}chz8em7bT~=nln6) z9~q;U2+XkR`|XW}sd?@H|Bz8Y{7dVsyMsc!(!0c;Pr|jUy~q5CHPTplRKfCY9CvGh zom4PjOI(!Jg`X#1g^O>;@wevtZZv!NJUHM53}F{1)j_PIW(cH91vo#)MEO0Yr=+-x zD0rIb%`YqZNmPxU{XC^Ks*rUCpcAYxT4FKH4=cLc@bjBX>2?c3VF9wPqMwU4agpo~ zw>%!27x#YlNEg;x(0FCrnfSzlZ9Wc?d>E;{aWghr%tp}}BXl@Dg ze!xJH<>1FrwQ*=!>i#`HsL1`5U0_0lT*-hrP{>i78Il|BwYHCY8Z%`L9Xv0kI4-rE zQt=%8PB2&<5hun$jZV<(E_jQ!mp8prQK%F=1jhM|npXG(0ByQjq6wkT(@31`!27<9 z_g@_~1InL@=|!}-`DebKW!Xdy72y_6a{>1==Ir|$oT829ya9`C14$uL<*lGBUS{+_ z`^V#_N;;*9`o!j&&Gtg$w+dTZd+v4E*J&stX9xMLxxv^zq-?R* zQJ@g;hA6&Ee!_&H3S=wQ(jHkeu`MoN>{OiZ7ai>sPY_?hb5G6it<{9 diff --git a/apps/streamdeck/scripts/render-action-icons.mjs b/apps/streamdeck/scripts/render-action-icons.mjs index cdbe4319b..ae839707a 100644 --- a/apps/streamdeck/scripts/render-action-icons.mjs +++ b/apps/streamdeck/scripts/render-action-icons.mjs @@ -5,13 +5,17 @@ // `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. // -// RASTERIZER: system `rsvg-convert` (librsvg). This is a one-time asset build; per the #1440 -// scope we prefer repo-available tooling over adding an npm dependency just to turn SVG into PNG. -// Re-run manually when a glyph changes: node scripts/render-action-icons.mjs +// 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. // -// Frame matches the existing icon convention (measured from the live approve-gate / action -// assets): the full key Image is a rounded rect (rx=12) filled #1C2128 with a white glyph; the -// list/picker Icon is the same glyph, white, on a transparent ground. +// 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'; @@ -31,9 +35,10 @@ export const ICONS = [ 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 -const GLYPH_BOX = 24; // GLYPHS are authored in a 24×24 box (see face.ts) -const GLYPH_SIZE = 40; // rendered glyph size inside the 72px canvas (centered, padded) +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 @@ -46,11 +51,13 @@ function stroked(color, 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 `'<… fill="${c}" …>'` string - * (filled glyphs). Throws loudly if the shape drifts, so a silent stale-icon build can't happen. + * 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 line = faceSrc.match(new RegExp(`\\n\\s*${key}:\\s*\\(c\\)\\s*=>\\s*([^\\n]*?),?\\s*\\n`)); + 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*'(.*)'\)$/); @@ -60,50 +67,87 @@ export function extractGlyph(faceSrc, key, color) { throw new Error(`glyph '${key}' has an unrecognized form: ${rhs}`); } -/** Center the 24×24 glyph, scaled to GLYPH_SIZE, inside the 72×72 canvas. */ -function centeredGlyph(inner) { - const scale = GLYPH_SIZE / GLYPH_BOX; - const offset = (72 - GLYPH_SIZE) / 2; - return `${inner}`; +/** The glyph on its own, high-res, transparent — the raster both variants trim and fit from. */ +function glyphSvg(inner) { + return `${inner}`; } -function keySvg(inner) { - return `${centeredGlyph(inner)}`; +function tmp(tag) { + return join(tmpdir(), `sd-icon-1440-${tag}`); } -function listSvg(inner) { - return `${centeredGlyph(inner)}`; +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}`]; } -function rasterize(svg, outPath, size) { - const tmp = join(tmpdir(), `sd-icon-${size}-${Math.abs(hash(outPath))}.svg`); - writeFileSync(tmp, svg); +/** 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('rsvg-convert', ['-w', String(size), '-h', String(size), tmp, '-o', outPath]); + execFileSync('magick', [bg, '(', ...fittedGlyph(glyphPng, target), ')', '-gravity', 'center', '-composite', out]); } finally { - rmSync(tmp, { force: true }); + rmSync(bg, { force: true }); } } -// Stable per-path suffix for the temp filename (Math.random is unavailable in some sandboxes). -function hash(s) { - let h = 0; - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; - return h; +/** 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 inner = extractGlyph(faceSrc, glyph, GLYPH_COLOR); - const key = keySvg(inner); - const list = listSvg(inner); - rasterize(key, join(PLUGIN, 'icons', `${name}.png`), 72); - rasterize(key, join(PLUGIN, 'icons', `${name}@2x.png`), 144); - rasterize(list, join(PLUGIN, 'icons', 'list', `${name}.png`), 20); - rasterize(list, join(PLUGIN, 'icons', 'list', `${name}@2x.png`), 40); + 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`); } } diff --git a/apps/streamdeck/src/__tests__/manifest-icons.test.ts b/apps/streamdeck/src/__tests__/manifest-icons.test.ts index 198c5a250..6df2abeca 100644 --- a/apps/streamdeck/src/__tests__/manifest-icons.test.ts +++ b/apps/streamdeck/src/__tests__/manifest-icons.test.ts @@ -29,6 +29,12 @@ 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) { @@ -80,4 +86,13 @@ describe('#1440 dedicated action icons', () => { } } }); + + // 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/codev/state/air-1440_thread.md b/codev/state/air-1440_thread.md index 454dd7715..4bbbc29bf 100644 --- a/codev/state/air-1440_thread.md +++ b/codev/state/air-1440_thread.md @@ -25,10 +25,19 @@ fill `#1C2128` + white glyph; list icon = transparent + white glyph. ## Status -- Implemented. 8 PNGs rendered (send-queue←comment, open-terminal←terminal), manifest repointed, - 6 dead PNGs removed. Render script committed. Two vitest files added. -- Verified: check-types ✓, esbuild build ✓ (after building @cluesmith/codev-sdk dist — the - pre-existing "cannot find codev-sdk" errors are a fresh-worktree artifact, not from this change), - `streamdeck validate` ✓, vitest 160 passed. Glyphs visually confirmed (comment bubble + terminal - window); key-image bg/corner pixels byte-match the existing action.png convention. -- Next: commit, open PR with review in body (no review file — AIR). +- 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. +- Next: commit fix, push, update PR body, notify architect, porch done. From e3dca80d798cdb6a16bfc0ce5e5df56f68d04108 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 13 Aug 2026 11:51:46 +1000 Subject: [PATCH 5/7] chore(porch): 1440 pr gate-requested --- .../projects/1440-stream-deck-polish-follow-ups-/status.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml b/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml index 4dc218d8f..dfe111410 100644 --- a/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml +++ b/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml @@ -7,8 +7,10 @@ current_plan_phase: null gates: pr: status: pending + requested_at: '2026-08-13T01:51:46.079Z' iteration: 1 build_complete: false history: [] started_at: '2026-08-13T01:33:00.163Z' -updated_at: '2026-08-13T01:41:03.395Z' +updated_at: '2026-08-13T01:51:46.080Z' +pr_ready_for_human: true From 326fbb3d7509b3d74d9d0c8bd95cfd13acc577ce Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 13 Aug 2026 11:52:07 +1000 Subject: [PATCH 6/7] =?UTF-8?q?[Air=20#1440]=20chore:=20update=20thread=20?= =?UTF-8?q?=E2=80=94=20PR=20gate=20open,=20awaiting=20approval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- codev/state/air-1440_thread.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/codev/state/air-1440_thread.md b/codev/state/air-1440_thread.md index 4bbbc29bf..427d017dd 100644 --- a/codev/state/air-1440_thread.md +++ b/codev/state/air-1440_thread.md @@ -40,4 +40,7 @@ `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. -- Next: commit fix, push, update PR body, notify architect, porch done. +- 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. From b507e1001931a86278f963dc6b3168e6f0713a47 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 13 Aug 2026 17:25:03 +1000 Subject: [PATCH 7/7] chore(porch): 1440 pr gate-approved --- .../1440-stream-deck-polish-follow-ups-/status.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml b/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml index dfe111410..f571367bb 100644 --- a/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml +++ b/codev/projects/1440-stream-deck-polish-follow-ups-/status.yaml @@ -6,11 +6,12 @@ plan_phases: [] current_plan_phase: null gates: pr: - status: pending + 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-13T01:51:46.080Z' -pr_ready_for_human: true +updated_at: '2026-08-13T07:25:03.413Z' +pr_ready_for_human: false