Skip to content

Commit 0ec1bfe

Browse files
authored
Merge pull request #580 from danshapiro/feat/deck-icons-polish
feat(deck): visual polish — circular avatars, Inter font, token-derived palette, tinted agent icons
2 parents 4c04dc9 + 4eecf7b commit 0ec1bfe

20 files changed

Lines changed: 2404 additions & 85 deletions

docs/plans/2026-07-29-deck-icons-polish.md

Lines changed: 1598 additions & 0 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"@dnd-kit/sortable": "^10.0.0",
8989
"@dnd-kit/utilities": "^3.2.2",
9090
"@elgato-stream-deck/webhid": "^7.6.3",
91+
"@fontsource/inter": "^5.3.0",
9192
"@modelcontextprotocol/sdk": "^1.27.1",
9293
"@monaco-editor/react": "^4.6.0",
9394
"@reduxjs/toolkit": "^2.3.0",

src/components/VirtualDeckPanel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ function noopCtx(width: number, height: number): Ctx2D {
2727
fillRect: () => {},
2828
fillText: () => {},
2929
drawImage: () => {},
30+
beginPath: () => {},
31+
arc: () => {},
32+
fill: () => {},
3033
measureText: () => ({ width: 0 }) as TextMetrics,
3134
getImageData: () => ({ data: new Uint8ClampedArray(width * height * 4) }) as ImageData,
3235
}

src/components/icons/RepoIcon.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ export function hueFromString(input: string): number {
2424
return Math.abs(hash) % 360
2525
}
2626

27+
/**
28+
* Canonical letter-avatar fill. 60% saturation / 42% lightness keeps white
29+
* text readable in both themes. Shared with the deck's canvas replica
30+
* (src/deck/tile-renderer.ts) — change it here and both surfaces follow.
31+
*/
32+
export function repoAvatarColor(hue: number): string {
33+
return `hsl(${hue}, 60%, 42%)`
34+
}
35+
36+
/** Letter font-size as a fraction of the avatar diameter (SVG: 9 units / 16-unit viewBox). */
37+
export const REPO_AVATAR_FONT_RATIO = 9 / 16
38+
2739
/**
2840
* Decorative repo identity icon: the repo's own icon via the server when
2941
* available, else a letter avatar (uppercase first letter on a circle with a
@@ -44,18 +56,16 @@ export default function RepoIcon({ info, className }: RepoIconProps) {
4456
)
4557
}
4658
const letter = (info.repoName.trim()[0] || '?').toUpperCase()
47-
// 60% saturation / 42% lightness keeps white text readable on the circle
48-
// in both light and dark themes.
4959
const hue = hueFromString(info.repoName)
5060
return (
5161
<svg viewBox="0 0 16 16" aria-hidden="true" className={cn('shrink-0', className)}>
52-
<circle cx="8" cy="8" r="8" fill={`hsl(${hue}, 60%, 42%)`} />
62+
<circle cx="8" cy="8" r="8" fill={repoAvatarColor(hue)} />
5363
<text
5464
x="8"
5565
y="8.5"
5666
textAnchor="middle"
5767
dominantBaseline="central"
58-
fontSize="9"
68+
fontSize={16 * REPO_AVATAR_FONT_RATIO}
5969
fontWeight="600"
6070
fill="white"
6171
>

src/deck/deck-controller.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { fetchRepoIconMeta } from '@/store/repoIconsSlice'
2727
import { resolvePaneRepoCwd } from '@/lib/repo-icon'
2828
import { IconImageCache, getIconImageCache } from './icon-image-cache'
2929
import { defaultCtxFactory, renderKey as canvasRenderKey, renderStrip as canvasRenderStrip } from './tile-renderer'
30+
import { whenDeckFontReady } from './deck-font'
3031

3132
export type DeckControllerOptions = {
3233
store: DeckStore & { subscribe(cb: () => void): () => void }
@@ -36,6 +37,8 @@ export type DeckControllerOptions = {
3637
settings: () => { brightness: number; idleBrightness: number; idleTimeoutSeconds: number; tileStyle: DeckTileStyle }
3738
now?: () => number
3839
iconCache?: IconImageCache
40+
/** Injectable font-ready hook (defaults to whenDeckFontReady); tests drive it directly. */
41+
fontReady?: (onReady: () => void) => () => void
3942
}
4043

4144
/** What a key displayed at press-down - snapshotted so re-sorts can't retarget a press. */
@@ -76,6 +79,9 @@ export class DeckController {
7679
private intervalId: ReturnType<typeof setInterval> | null = null
7780
private onVisibilityChange: (() => void) | null = null
7881

82+
private readonly fontReady: (onReady: () => void) => () => void
83+
private cancelFontWait: (() => void) | null = null
84+
7985
constructor(options: DeckControllerOptions) {
8086
this.store = options.store
8187
this.device = options.device
@@ -85,6 +91,7 @@ export class DeckController {
8591
this.renderStripFn = options.renderStrip ?? ((text, width, height) => canvasRenderStrip(text, width, height, defaultCtxFactory))
8692
this.settings = options.settings
8793
this.now = options.now ?? (() => Date.now())
94+
this.fontReady = options.fontReady ?? whenDeckFontReady
8895
}
8996

9097
start(): void {
@@ -93,6 +100,13 @@ export class DeckController {
93100
this.repaint()
94101
this.probeRepoIcons()
95102
this.unsubscribeIcons = this.iconCache.subscribe(() => this.repaint())
103+
this.cancelFontWait = this.fontReady(() => {
104+
// A font load changes no KeySpec, so the JSON diff (repaint(), line ~148)
105+
// would paint nothing: invalidate the caches to force a real repaint in Inter.
106+
this.lastPaintedSpecs = []
107+
this.lastStripText = null
108+
this.repaint()
109+
})
96110
this.unsubscribeStore = this.store.subscribe(() => this.onStoreChange())
97111
this.unsubscribeInput = this.device.onInput((event) => this.handleInput(event))
98112
this.intervalId = setInterval(() => this.tick(), TICK_MS)
@@ -110,6 +124,8 @@ export class DeckController {
110124
this.unsubscribeInput = null
111125
this.unsubscribeIcons?.()
112126
this.unsubscribeIcons = null
127+
this.cancelFontWait?.()
128+
this.cancelFontWait = null
113129
if (this.intervalId !== null) {
114130
clearInterval(this.intervalId)
115131
this.intervalId = null

src/deck/deck-font.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Deck tile typeface: Inter, bundled locally via @fontsource (src/index.css
2+
// imports weights 400/600 — no CDN fetch). Canvas ctx.font does NOT trigger
3+
// webfont loading, so the deck controller waits for the FontFace load and
4+
// forces a repaint; until then every deck font string falls back to
5+
// sans-serif (DECK_FONT_STACK lists it second) without breaking.
6+
// jsdom has no document.fonts: every path here degrades to a silent no-op
7+
// (console.error is fatal in tests; a missing font is expected, not
8+
// exceptional — same rule as icon-image-cache.ts).
9+
// Two verified FontFaceSet facts shape this module: (1) fonts.load('400 16px
10+
// "Inter"') uses load()'s default sample text (a single space), so it loads
11+
// only the latin-subset face — non-Latin deck text stays in the sans-serif
12+
// fallback (accepted as fine for v1); (2) load() REJECTS on a broken src, so
13+
// the .catch below is MANDATORY (without it a failed load is an unhandled
14+
// rejection, which is fatal under the test rules).
15+
16+
export const DECK_FONT_FAMILY = 'Inter'
17+
/** Family list for ctx.font strings: Inter once loaded, sans-serif before. */
18+
export const DECK_FONT_STACK = `${DECK_FONT_FAMILY}, sans-serif`
19+
20+
/**
21+
* Invoke onReady once the deck's font weights (400 + 600) are loaded so the
22+
* caller can repaint with Inter. Returns a cancel function — after cancel a
23+
* late load is ignored (the controller calls it from stop()).
24+
*/
25+
export function whenDeckFontReady(onReady: () => void): () => void {
26+
let cancelled = false
27+
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
28+
if (!fonts?.load) return () => { cancelled = true }
29+
void Promise.all([
30+
fonts.load(`400 16px "${DECK_FONT_FAMILY}"`),
31+
fonts.load(`600 16px "${DECK_FONT_FAMILY}"`),
32+
])
33+
.then(() => {
34+
if (!cancelled) onReady()
35+
})
36+
.catch(() => {
37+
// Font failure -> keep the sans-serif fallback, silently.
38+
})
39+
return () => { cancelled = true }
40+
}

src/deck/deck-selectors.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { buildRepoIconUrl, pathBasename, resolvePaneRepoCwd } from '@/lib/repo-i
1010
import { hueFromString } from '@/components/icons/RepoIcon'
1111
import { makeFreshAgentSessionKey } from '@shared/fresh-agent'
1212
import type { DeckTileStyle } from '@shared/settings'
13+
import { isNonShellMode } from '@/lib/coding-cli-utils'
14+
15+
export type TilePaneTint = 'blue' | 'green' | 'amber' | 'red' | 'mutedDim' | 'muted'
16+
export type TilePaneIcon = { provider: string; tint: TilePaneTint }
1317

1418
export type DeckTab = {
1519
id: string
@@ -22,6 +26,7 @@ export type DeckTab = {
2226
dot: TileDot
2327
priority: number
2428
repoIcons: TileRepoIcon[]
29+
paneIcons: TilePaneIcon[]
2530
}
2631
export type DeckModel = { tabs: DeckTab[]; activeTabId: string | null; tileStyle: DeckTileStyle }
2732

@@ -122,6 +127,48 @@ export function getTabRepoIcons(state: RootState, tab: Tab): TileRepoIcon[] {
122127
return icons
123128
}
124129

130+
/** Mirrors getTerminalStatusIconClassName (src/lib/terminal-status-indicator.ts). */
131+
function paneStatusTint(status: string): TilePaneTint {
132+
switch (status) {
133+
case 'running': return 'green' // text-success
134+
case 'recovering': return 'amber' // text-warning
135+
case 'exited': return 'mutedDim' // text-muted-foreground/40
136+
case 'error': return 'red' // text-destructive
137+
default: return 'muted' // creating etc. -> text-muted-foreground
138+
}
139+
}
140+
141+
/**
142+
* Agent pane icons for a tab, in layout order, UNCAPPED (the renderer caps
143+
* drawn icons and folds the rest into a +N badge — TabItem's MAX_PANE_ICONS
144+
* overflow rule adapted to key size). Agent panes are non-shell terminals
145+
* (provider = mode) and fresh-agent panes (provider = sessionType);
146+
* shell/browser/editor/picker/extension panes draw no agent icon on a key
147+
* this small. Tint mirrors TabItem.tsx renderIcons: busy -> blue (wins),
148+
* else the pane's effective status (non-terminal kinds count as 'running').
149+
*/
150+
export function getTabPaneIcons(state: RootState, tab: Tab): TilePaneIcon[] {
151+
const busyIds = getBusyPaneIdsForTab({
152+
tab,
153+
paneLayouts: state.panes.layouts as Record<string, PaneNode | undefined>,
154+
...activityInputs(state),
155+
})
156+
const icons: TilePaneIcon[] = []
157+
for (const { paneId, content } of panesForTab(state, tab)) {
158+
let provider: string | null = null
159+
let status = 'running'
160+
if (content.kind === 'terminal' && isNonShellMode(content.mode)) {
161+
provider = content.mode
162+
status = content.status
163+
} else if (content.kind === 'fresh-agent') {
164+
provider = content.sessionType
165+
}
166+
if (!provider) continue
167+
icons.push({ provider, tint: busyIds.includes(paneId) ? 'blue' : paneStatusTint(status) })
168+
}
169+
return icons
170+
}
171+
125172
/**
126173
* Per-tab status flags, derived from the SAME conditions the tab bar uses:
127174
* - busy: any pane busy (getBusyPaneIdsForTab, TabBar.tsx:329-338)
@@ -167,6 +214,7 @@ export function selectDeckModel(state: RootState): DeckModel {
167214
dot: tileDot(flags),
168215
priority: tilePriority(active, flags),
169216
repoIcons: getTabRepoIcons(state, tab),
217+
paneIcons: getTabPaneIcons(state, tab),
170218
}
171219
})
172220
if (tileStyle === 'status-icons') {

src/deck/frame.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import type { DeckCapabilities } from './deck-device'
2-
import type { DeckModel } from './deck-selectors'
3-
import type { TileFill, TileDot } from './tile-state'
2+
import type { DeckModel, TilePaneIcon } from './deck-selectors'
3+
import type { TileFill } from './tile-state'
4+
import { PANE_TINT_COLORS } from './pane-tint-colors'
5+
import { providerIconDataUrl } from './provider-icon-svg'
46

57
export type RingColor = 'amber' | 'green' | 'blue' | null
68
export type DeckAction = 'back' | 'approve' | 'stop'
79
export type TileIcon = { url: string | null; letter: string; hue: number; ready: boolean }
10+
export type TilePaneIconSpec = TilePaneIcon & { ready: boolean }
811
export type KeySpec =
912
| { kind: 'empty' }
10-
| { kind: 'tab'; style: 'icons'; tabId: string; title: string; active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] }
13+
| { kind: 'tab'; style: 'icons'; tabId: string; title: string; active: boolean; fill: TileFill; paneIcons: TilePaneIconSpec[]; icons: TileIcon[] }
1114
| { kind: 'tab'; style: 'preview'; tabId: string; title: string; active: boolean; previewLines: string[]; ring: RingColor }
1215
| { kind: 'pager'; page: number; pageCount: number }
1316
| { kind: 'action'; action: DeckAction; enabled: boolean }
@@ -119,7 +122,18 @@ export function buildFrame({ model, caps, page, actionLayer, iconReady, previewF
119122
}
120123
: {
121124
kind: 'tab', style: 'icons', tabId: tab.id, title: tab.title, active: tab.active,
122-
fill: tab.fill, dot: tab.dot,
125+
fill: tab.fill,
126+
// Readiness must live IN the spec: the controller's repaint() skips
127+
// keys whose JSON is unchanged, so the decode completing has to flip
128+
// a spec field to trigger the repaint — same mechanism as the repo
129+
// icons below. iconReady (bitmapFor in production) also STARTS the
130+
// async load on first miss, so the first frame kicks off the fetch.
131+
// The URL is recomputed, never stored: providerIconDataUrl is
132+
// memoized, and the spec stays small (no multi-KB data URLs).
133+
paneIcons: tab.paneIcons.map((icon) => ({
134+
...icon,
135+
ready: iconReady(providerIconDataUrl(icon.provider, PANE_TINT_COLORS[icon.tint])),
136+
})),
123137
icons: tab.repoIcons.map((icon) => ({
124138
...icon,
125139
ready: icon.url !== null && iconReady(icon.url),

src/deck/pane-tint-colors.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// ============================================================================
2+
// PANE-ICON TINT COLORS — TabItem.tsx's icon tint classes projected to canvas
3+
// hex, derived from freshell's own UI tokens. KEEP IN SYNC: when an app token
4+
// changes, update the deck constant to match (same rule as tile-renderer.ts's
5+
// palette block). These live in their own leaf module because BOTH frame.ts
6+
// (which stamps per-icon readiness by computing the tinted data URL at
7+
// frame-build time) and tile-renderer.ts (which draws) need them — a shared
8+
// leaf module keeps the deck import graph free of runtime cycles.
9+
//
10+
// deck constant <- app source token value
11+
// STATUS_GREEN <- text-success (TabItem pane running tint) hsl(142 71% 45%) = #21c45d
12+
// STATUS_BLUE <- text-blue-500 (TabItem pane busy tint) #3b82f6
13+
// STATUS_AMBER <- --warning / text-warning hsl(38 92% 50%) = #f59f0a
14+
// STATUS_RED <- --destructive light / text-destructive hsl(0 72% 51%) = #dc2828
15+
// STATUS_MUTED <- text-muted-foreground dark hsl(240 5% 65%) = #a1a1aa
16+
// STATUS_MUTED_DIM <- text-muted-foreground/40 dark rgba(161,161,170,0.4)
17+
// ============================================================================
18+
19+
import type { TilePaneTint } from './deck-selectors'
20+
21+
export const STATUS_GREEN = '#21c45d'
22+
export const STATUS_BLUE = '#3b82f6'
23+
export const STATUS_AMBER = '#f59f0a'
24+
export const STATUS_RED = '#dc2828'
25+
export const STATUS_MUTED = '#a1a1aa'
26+
export const STATUS_MUTED_DIM = 'rgba(161,161,170,0.4)'
27+
28+
/** TabItem.tsx pane-icon tint classes -> canvas colors (keyed by Task 8's TilePaneTint). */
29+
export const PANE_TINT_COLORS: Record<TilePaneTint, string> = {
30+
blue: STATUS_BLUE,
31+
green: STATUS_GREEN,
32+
amber: STATUS_AMBER,
33+
red: STATUS_RED,
34+
muted: STATUS_MUTED,
35+
mutedDim: STATUS_MUTED_DIM,
36+
}

0 commit comments

Comments
 (0)