Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
dfdb80b
docs: add implementation plan for deck-icons-polish
danshapiro Jul 29, 2026
0670ca8
docs(plan): harden deck-icons-polish plan from load-bearing validation
danshapiro Jul 29, 2026
f8058d9
docs(plan): fix +N overflow badge geometry in Task 10 (fresheyes review)
danshapiro Jul 29, 2026
121742c
docs(plan): cast PROVIDER_ICONS index in Task 7 to satisfy strict typ…
danshapiro Jul 29, 2026
35cfa8d
refactor(icons): export shared repoAvatarColor + letter font ratio fr…
danshapiro Jul 29, 2026
92f187e
feat(deck): letter avatar is a circle matching RepoIcon exactly (shar…
danshapiro Jul 29, 2026
86530b7
feat(deck): bundle Inter locally and add guarded deck font loader
danshapiro Jul 29, 2026
f8dbf7e
feat(deck): render all deck text in Inter (previews pinned to sans-se…
danshapiro Jul 29, 2026
7206b32
feat(deck): force full repaint once Inter loads (injectable fontReady…
danshapiro Jul 29, 2026
1060ad9
feat(deck): re-derive tile palette from the app's UI tokens with docu…
danshapiro Jul 29, 2026
e3a805a
feat(deck): serialize tab-bar agent icons to tinted standalone SVG da…
danshapiro Jul 29, 2026
936e472
feat(deck): derive per-tab agent pane icons with tab-bar tint rules
danshapiro Jul 29, 2026
91dac8c
feat(deck): replace the icons-tile status dot with readiness-stamped …
danshapiro Jul 29, 2026
b8b6f6f
feat(deck): draw tab-bar-style tinted agent icons beside the repo ico…
danshapiro Jul 29, 2026
87cac7f
test(deck): e2e proof of tinted paneIcons pipeline + final gates for …
danshapiro Jul 30, 2026
4eecf7b
revert(deck): restore DeckTab.dot per plan constraint (selector-level…
danshapiro Jul 30, 2026
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
1,598 changes: 1,598 additions & 0 deletions docs/plans/2026-07-29-deck-icons-polish.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@elgato-stream-deck/webhid": "^7.6.3",
"@fontsource/inter": "^5.3.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.6.0",
"@reduxjs/toolkit": "^2.3.0",
Expand Down
3 changes: 3 additions & 0 deletions src/components/VirtualDeckPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ function noopCtx(width: number, height: number): Ctx2D {
fillRect: () => {},
fillText: () => {},
drawImage: () => {},
beginPath: () => {},
arc: () => {},
fill: () => {},
measureText: () => ({ width: 0 }) as TextMetrics,
getImageData: () => ({ data: new Uint8ClampedArray(width * height * 4) }) as ImageData,
}
Expand Down
18 changes: 14 additions & 4 deletions src/components/icons/RepoIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ export function hueFromString(input: string): number {
return Math.abs(hash) % 360
}

/**
* Canonical letter-avatar fill. 60% saturation / 42% lightness keeps white
* text readable in both themes. Shared with the deck's canvas replica
* (src/deck/tile-renderer.ts) — change it here and both surfaces follow.
*/
export function repoAvatarColor(hue: number): string {
return `hsl(${hue}, 60%, 42%)`
}

/** Letter font-size as a fraction of the avatar diameter (SVG: 9 units / 16-unit viewBox). */
export const REPO_AVATAR_FONT_RATIO = 9 / 16

/**
* Decorative repo identity icon: the repo's own icon via the server when
* available, else a letter avatar (uppercase first letter on a circle with a
Expand All @@ -44,18 +56,16 @@ export default function RepoIcon({ info, className }: RepoIconProps) {
)
}
const letter = (info.repoName.trim()[0] || '?').toUpperCase()
// 60% saturation / 42% lightness keeps white text readable on the circle
// in both light and dark themes.
const hue = hueFromString(info.repoName)
return (
<svg viewBox="0 0 16 16" aria-hidden="true" className={cn('shrink-0', className)}>
<circle cx="8" cy="8" r="8" fill={`hsl(${hue}, 60%, 42%)`} />
<circle cx="8" cy="8" r="8" fill={repoAvatarColor(hue)} />
<text
x="8"
y="8.5"
textAnchor="middle"
dominantBaseline="central"
fontSize="9"
fontSize={16 * REPO_AVATAR_FONT_RATIO}
fontWeight="600"
fill="white"
>
Expand Down
16 changes: 16 additions & 0 deletions src/deck/deck-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { fetchRepoIconMeta } from '@/store/repoIconsSlice'
import { resolvePaneRepoCwd } from '@/lib/repo-icon'
import { IconImageCache, getIconImageCache } from './icon-image-cache'
import { defaultCtxFactory, renderKey as canvasRenderKey, renderStrip as canvasRenderStrip } from './tile-renderer'
import { whenDeckFontReady } from './deck-font'

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

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

private readonly fontReady: (onReady: () => void) => () => void
private cancelFontWait: (() => void) | null = null

constructor(options: DeckControllerOptions) {
this.store = options.store
this.device = options.device
Expand All @@ -85,6 +91,7 @@ export class DeckController {
this.renderStripFn = options.renderStrip ?? ((text, width, height) => canvasRenderStrip(text, width, height, defaultCtxFactory))
this.settings = options.settings
this.now = options.now ?? (() => Date.now())
this.fontReady = options.fontReady ?? whenDeckFontReady
}

start(): void {
Expand All @@ -93,6 +100,13 @@ export class DeckController {
this.repaint()
this.probeRepoIcons()
this.unsubscribeIcons = this.iconCache.subscribe(() => this.repaint())
this.cancelFontWait = this.fontReady(() => {
// A font load changes no KeySpec, so the JSON diff (repaint(), line ~148)
// would paint nothing: invalidate the caches to force a real repaint in Inter.
this.lastPaintedSpecs = []
this.lastStripText = null
this.repaint()
})
this.unsubscribeStore = this.store.subscribe(() => this.onStoreChange())
this.unsubscribeInput = this.device.onInput((event) => this.handleInput(event))
this.intervalId = setInterval(() => this.tick(), TICK_MS)
Expand All @@ -110,6 +124,8 @@ export class DeckController {
this.unsubscribeInput = null
this.unsubscribeIcons?.()
this.unsubscribeIcons = null
this.cancelFontWait?.()
this.cancelFontWait = null
if (this.intervalId !== null) {
clearInterval(this.intervalId)
this.intervalId = null
Expand Down
40 changes: 40 additions & 0 deletions src/deck/deck-font.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Deck tile typeface: Inter, bundled locally via @fontsource (src/index.css
// imports weights 400/600 — no CDN fetch). Canvas ctx.font does NOT trigger
// webfont loading, so the deck controller waits for the FontFace load and
// forces a repaint; until then every deck font string falls back to
// sans-serif (DECK_FONT_STACK lists it second) without breaking.
// jsdom has no document.fonts: every path here degrades to a silent no-op
// (console.error is fatal in tests; a missing font is expected, not
// exceptional — same rule as icon-image-cache.ts).
// Two verified FontFaceSet facts shape this module: (1) fonts.load('400 16px
// "Inter"') uses load()'s default sample text (a single space), so it loads
// only the latin-subset face — non-Latin deck text stays in the sans-serif
// fallback (accepted as fine for v1); (2) load() REJECTS on a broken src, so
// the .catch below is MANDATORY (without it a failed load is an unhandled
// rejection, which is fatal under the test rules).

export const DECK_FONT_FAMILY = 'Inter'
/** Family list for ctx.font strings: Inter once loaded, sans-serif before. */
export const DECK_FONT_STACK = `${DECK_FONT_FAMILY}, sans-serif`

/**
* Invoke onReady once the deck's font weights (400 + 600) are loaded so the
* caller can repaint with Inter. Returns a cancel function — after cancel a
* late load is ignored (the controller calls it from stop()).
*/
export function whenDeckFontReady(onReady: () => void): () => void {
let cancelled = false
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
if (!fonts?.load) return () => { cancelled = true }
void Promise.all([
fonts.load(`400 16px "${DECK_FONT_FAMILY}"`),
fonts.load(`600 16px "${DECK_FONT_FAMILY}"`),
])
.then(() => {
if (!cancelled) onReady()
})
.catch(() => {
// Font failure -> keep the sans-serif fallback, silently.
})
return () => { cancelled = true }
}
48 changes: 48 additions & 0 deletions src/deck/deck-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { buildRepoIconUrl, pathBasename, resolvePaneRepoCwd } from '@/lib/repo-i
import { hueFromString } from '@/components/icons/RepoIcon'
import { makeFreshAgentSessionKey } from '@shared/fresh-agent'
import type { DeckTileStyle } from '@shared/settings'
import { isNonShellMode } from '@/lib/coding-cli-utils'

export type TilePaneTint = 'blue' | 'green' | 'amber' | 'red' | 'mutedDim' | 'muted'
export type TilePaneIcon = { provider: string; tint: TilePaneTint }

export type DeckTab = {
id: string
Expand All @@ -22,6 +26,7 @@ export type DeckTab = {
dot: TileDot
priority: number
repoIcons: TileRepoIcon[]
paneIcons: TilePaneIcon[]
}
export type DeckModel = { tabs: DeckTab[]; activeTabId: string | null; tileStyle: DeckTileStyle }

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

/** Mirrors getTerminalStatusIconClassName (src/lib/terminal-status-indicator.ts). */
function paneStatusTint(status: string): TilePaneTint {
switch (status) {
case 'running': return 'green' // text-success
case 'recovering': return 'amber' // text-warning
case 'exited': return 'mutedDim' // text-muted-foreground/40
case 'error': return 'red' // text-destructive
default: return 'muted' // creating etc. -> text-muted-foreground
}
}

/**
* Agent pane icons for a tab, in layout order, UNCAPPED (the renderer caps
* drawn icons and folds the rest into a +N badge — TabItem's MAX_PANE_ICONS
* overflow rule adapted to key size). Agent panes are non-shell terminals
* (provider = mode) and fresh-agent panes (provider = sessionType);
* shell/browser/editor/picker/extension panes draw no agent icon on a key
* this small. Tint mirrors TabItem.tsx renderIcons: busy -> blue (wins),
* else the pane's effective status (non-terminal kinds count as 'running').
*/
export function getTabPaneIcons(state: RootState, tab: Tab): TilePaneIcon[] {
const busyIds = getBusyPaneIdsForTab({
tab,
paneLayouts: state.panes.layouts as Record<string, PaneNode | undefined>,
...activityInputs(state),
})
const icons: TilePaneIcon[] = []
for (const { paneId, content } of panesForTab(state, tab)) {
let provider: string | null = null
let status = 'running'
if (content.kind === 'terminal' && isNonShellMode(content.mode)) {
provider = content.mode
status = content.status
} else if (content.kind === 'fresh-agent') {
provider = content.sessionType
}
if (!provider) continue
icons.push({ provider, tint: busyIds.includes(paneId) ? 'blue' : paneStatusTint(status) })
}
return icons
}

/**
* Per-tab status flags, derived from the SAME conditions the tab bar uses:
* - busy: any pane busy (getBusyPaneIdsForTab, TabBar.tsx:329-338)
Expand Down Expand Up @@ -167,6 +214,7 @@ export function selectDeckModel(state: RootState): DeckModel {
dot: tileDot(flags),
priority: tilePriority(active, flags),
repoIcons: getTabRepoIcons(state, tab),
paneIcons: getTabPaneIcons(state, tab),
}
})
if (tileStyle === 'status-icons') {
Expand Down
22 changes: 18 additions & 4 deletions src/deck/frame.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import type { DeckCapabilities } from './deck-device'
import type { DeckModel } from './deck-selectors'
import type { TileFill, TileDot } from './tile-state'
import type { DeckModel, TilePaneIcon } from './deck-selectors'
import type { TileFill } from './tile-state'
import { PANE_TINT_COLORS } from './pane-tint-colors'
import { providerIconDataUrl } from './provider-icon-svg'

export type RingColor = 'amber' | 'green' | 'blue' | null
export type DeckAction = 'back' | 'approve' | 'stop'
export type TileIcon = { url: string | null; letter: string; hue: number; ready: boolean }
export type TilePaneIconSpec = TilePaneIcon & { ready: boolean }
export type KeySpec =
| { kind: 'empty' }
| { kind: 'tab'; style: 'icons'; tabId: string; title: string; active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] }
| { kind: 'tab'; style: 'icons'; tabId: string; title: string; active: boolean; fill: TileFill; paneIcons: TilePaneIconSpec[]; icons: TileIcon[] }
| { kind: 'tab'; style: 'preview'; tabId: string; title: string; active: boolean; previewLines: string[]; ring: RingColor }
| { kind: 'pager'; page: number; pageCount: number }
| { kind: 'action'; action: DeckAction; enabled: boolean }
Expand Down Expand Up @@ -119,7 +122,18 @@ export function buildFrame({ model, caps, page, actionLayer, iconReady, previewF
}
: {
kind: 'tab', style: 'icons', tabId: tab.id, title: tab.title, active: tab.active,
fill: tab.fill, dot: tab.dot,
fill: tab.fill,
// Readiness must live IN the spec: the controller's repaint() skips
// keys whose JSON is unchanged, so the decode completing has to flip
// a spec field to trigger the repaint — same mechanism as the repo
// icons below. iconReady (bitmapFor in production) also STARTS the
// async load on first miss, so the first frame kicks off the fetch.
// The URL is recomputed, never stored: providerIconDataUrl is
// memoized, and the spec stays small (no multi-KB data URLs).
paneIcons: tab.paneIcons.map((icon) => ({
...icon,
ready: iconReady(providerIconDataUrl(icon.provider, PANE_TINT_COLORS[icon.tint])),
})),
icons: tab.repoIcons.map((icon) => ({
...icon,
ready: icon.url !== null && iconReady(icon.url),
Expand Down
36 changes: 36 additions & 0 deletions src/deck/pane-tint-colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// ============================================================================
// PANE-ICON TINT COLORS — TabItem.tsx's icon tint classes projected to canvas
// hex, derived from freshell's own UI tokens. KEEP IN SYNC: when an app token
// changes, update the deck constant to match (same rule as tile-renderer.ts's
// palette block). These live in their own leaf module because BOTH frame.ts
// (which stamps per-icon readiness by computing the tinted data URL at
// frame-build time) and tile-renderer.ts (which draws) need them — a shared
// leaf module keeps the deck import graph free of runtime cycles.
//
// deck constant <- app source token value
// STATUS_GREEN <- text-success (TabItem pane running tint) hsl(142 71% 45%) = #21c45d
// STATUS_BLUE <- text-blue-500 (TabItem pane busy tint) #3b82f6
// STATUS_AMBER <- --warning / text-warning hsl(38 92% 50%) = #f59f0a
// STATUS_RED <- --destructive light / text-destructive hsl(0 72% 51%) = #dc2828
// STATUS_MUTED <- text-muted-foreground dark hsl(240 5% 65%) = #a1a1aa
// STATUS_MUTED_DIM <- text-muted-foreground/40 dark rgba(161,161,170,0.4)
// ============================================================================

import type { TilePaneTint } from './deck-selectors'

export const STATUS_GREEN = '#21c45d'
export const STATUS_BLUE = '#3b82f6'
export const STATUS_AMBER = '#f59f0a'
export const STATUS_RED = '#dc2828'
export const STATUS_MUTED = '#a1a1aa'
export const STATUS_MUTED_DIM = 'rgba(161,161,170,0.4)'

/** TabItem.tsx pane-icon tint classes -> canvas colors (keyed by Task 8's TilePaneTint). */
export const PANE_TINT_COLORS: Record<TilePaneTint, string> = {
blue: STATUS_BLUE,
green: STATUS_GREEN,
amber: STATUS_AMBER,
red: STATUS_RED,
muted: STATUS_MUTED,
mutedDim: STATUS_MUTED_DIM,
}
45 changes: 45 additions & 0 deletions src/deck/provider-icon-svg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Canvas-side bridge to the tab bar's coding-agent icons. The icons exist
// ONLY as React SVG components drawing with currentColor
// (provider-icons.tsx + fresh-agent-registry.ts), so we serialize them with
// renderToStaticMarkup and inject the tint via a color attribute on the root
// <svg> — inside an <img>-loaded SVG, currentColor resolves through the
// inherited `color` property, which tints solid fills AND strokes (KilroyIcon
// is stroke-based). The data URL feeds the existing IconImageCache: same
// async load, same drawn-empty probe, same silent failure -> no-icon path.
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { DefaultProviderIcon, PROVIDER_ICONS } from '@/components/icons/provider-icons'
import { resolveFreshAgentType } from '@/lib/fresh-agent-registry'

const markupCache = new Map<string, string>()

/**
* Standalone tinted SVG markup for a provider. `provider` is a terminal
* mode ('claude', 'codex', ...) or a fresh-agent sessionType ('freshclaude',
* ...); anything unknown gets DefaultProviderIcon (same rule as PaneIcon).
*/
export function providerIconSvg(provider: string, colorHex: string): string {
const key = `${provider}\u0000${colorHex}`
const hit = markupCache.get(key)
if (hit) return hit
const Icon =
resolveFreshAgentType(provider)?.icon ??
// `provider` is an open string; PROVIDER_ICONS is Record<CodingCliProviderName, ...>,
// so a plain string index is TS7053 under strict. Same cast as session-type-utils.ts.
PROVIDER_ICONS[provider as keyof typeof PROVIDER_ICONS] ??
DefaultProviderIcon
const raw = renderToStaticMarkup(createElement(Icon))
let svg = raw.replace('<svg', `<svg color="${colorHex}"`)
// Standalone SVG (loaded via <img src="data:...\">) requires xmlns; React
// components may or may not declare it.
if (!svg.includes('xmlns=')) {
svg = svg.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"')
}
markupCache.set(key, svg)
return svg
}

/** Stable per-(provider, tint) data URL for IconImageCache and KeySpec diffing. */
export function providerIconDataUrl(provider: string, colorHex: string): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(providerIconSvg(provider, colorHex))}`
}
Loading
Loading