diff --git a/docs/plans/2026-07-30-deck-render-refinements.md b/docs/plans/2026-07-30-deck-render-refinements.md new file mode 100644 index 000000000..13e0a9be5 --- /dev/null +++ b/docs/plans/2026-07-30-deck-render-refinements.md @@ -0,0 +1,950 @@ +# Deck Render Refinements Implementation Plan + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Four refinements to freshell's Stream Deck rendering and layout: slightly smaller tile titles, pure-black tile interiors with a darker composited green fill, a new "reversed" key layout with a permanent top-left pager (default on 6-key decks via an Auto setting), and deck labels that exactly match the tab bar's displayed labels. + +**Architecture:** All changes are client-only, in `src/deck/` plus the shared settings layer. The canvas renderer (`tile-renderer.ts`) gets constant-level visual changes. Label parity is a single-point fix in `selectDeckModel` reusing the tab bar's existing pure function `getTabDisplayTitle`. The reversed layout is a new `DeckArrangement` concept: a pure resolver (`resolveArrangement`) maps the new persisted `streamDeck.keyLayout` setting + device key count to `'standard' | 'reversed'`; `planLayout` gains a reversed branch (pager always at key 0); a pure `arrangeTabs` orders tabs (reverse tab-bar order via a new `DeckTab.tabIndex` field). Both `buildFrame` and the controller's press-snapshot resolver consume the same pure functions, so key painting and press targeting stay mirror images. + +**Tech Stack:** TypeScript, React, Redux Toolkit, Zod (settings schema), Vitest (jsdom, recording-context canvas harness, `FakeDeckDevice` fake transport), ESLint (jsx-a11y). + +## Global Constraints + +- Client-only: no server changes; nothing under `crates/` or `server/` is touched. +- STANDARD arrangement behavior is unchanged: attention-priority sorting (gated on `tileStyle === 'status-icons'`), pager bottom-right (`keyCount - 1`) only on overflow. No changes to state classification, transports, long-press action layer, dial semantics, or idle dimming. +- The classic `terminal-previews` tile style is PINNED per repo precedent (`docs/plans/2026-07-29-deck-visual-tweaks.md`): its fonts (`11px monospace`, `16px sans-serif` banner), palette (`PREVIEW_BG '#0a0a0a'`, `PREVIEW_TEXT_COLOR`, `RING_COLORS`), and truncation stay identical. The title shrink applies to the icons-style banner only; the label-parity and arrangement changes apply to both styles (they change WHAT is shown/ordered, not the pinned HOW). +- New setting: `streamDeck.keyLayout` with values `'auto' | 'newest-first' | 'status-sorted'`, default `'auto'`. Auto resolves to the reversed arrangement when `keyCount <= 6`, standard otherwise. Persisted client-side (localStorage blob `freshell.browser-preferences.v1`), live-applied. +- Naming note (deliberate, validated): `'newest-first'` is implemented as strictly REVERSE TAB-BAR ORDER (`tabIndex` descending). Tabs are created by append (`addTab` pushes, tabsSlice.ts:322), so the last tab IS the newest — until the user reorders (drag, Ctrl+Shift+arrows, and context menu are all wired today via `reorderTabs`, tabsSlice.ts:414-422) or a cross-device `hydrateTabs` sync adopts remote order (tabsSlice.ts:375-398). After any reorder the deck mirrors the REVERSED TAB BAR, not creation recency — that is the intended, muscle-memory-stable behavior. The value name keeps the spec's user-facing vocabulary; a later rename remains possible via a normalizer alias. +- New color values (exact): `TILE_BG = '#000000'`; `TILE_FILL_GREEN = '#697d73'` (= `#d1fae5` rgb(209,250,229) composited at 50% opacity over black: `Math.round(c/2)` per channel → rgb(105,125,115)). `BAR_TOP_BORDER '#21c45d'`, `ACTIVE_COLOR '#ffffff'`, and all rings stay full-strength. +- New font size (exact): icons-style banner title `ICONS_TITLE_FONT_SIZE = 14` (down from 16); `TEXT_LETTER_SPACING = '0.4px'` and `TITLE_SIDE_PADDING = 6` from PR #585 are kept. +- Repo rules (AGENTS.md, binding): work only inside the worktree `/home/dan/code/freshell/.worktrees/deck-render-refinements`; do NOT create/open a PR without explicit user approval; NEVER restart the live Rust server on port 3002; no broad kill patterns. Focused tests: `npm run test:vitest -- run --config config/vitest/vitest.config.ts` (run from inside the worktree; the config excludes `**/.worktrees/**` so you must cd in). Lint: `npm run lint`. Typecheck: `npm run typecheck`. Full coordinated suite: `npm run check`. +- TDD Red-Green-Refactor for every task; commit after every green cycle. `docs/index.html` and README need no changes (no deck content there; verified by exploration). +- Vitest runs with `sequence.shuffle: true` — tests must be order-independent. + +--- + +### Task 1: Smaller icons-banner title font (16 → 14) + +**Files:** +- Modify: `src/deck/tile-renderer.ts` (constant near line 55, title draw near lines 305-317) +- Test: `test/unit/client/deck/tile-renderer.test.ts` + +**Interfaces:** +- Consumes: existing `TITLE_FONT_SIZE = 16` (`tile-renderer.ts:55`), `BANNER_HEIGHT = 20` (`:53`), `DECK_FONT_STACK = 'Inter, sans-serif'` (from `src/deck/deck-font.ts`), `drawCenteredText(ctx, text, w, y)` (`:190-193`), `drawIconsTab` title block (`:305-317`). +- Produces: `export const ICONS_TITLE_FONT_SIZE = 14` in `src/deck/tile-renderer.ts`. `TITLE_FONT_SIZE` stays `16` and becomes classic-preview-only. No other task depends on these names, but Task 2 edits the same file — keep the constants block tidy. + +**Why this shape:** `TITLE_FONT_SIZE` is dual-use today: the icons banner (`:313`) AND the PINNED classic-preview banner (`:216`, `` `${TITLE_FONT_SIZE}px sans-serif` ``). Shrinking the shared constant would silently shrink the pinned preview, and the existing pin test (`test:364-368`) interpolates the constant so it cannot catch that drift. So: new constant for the icons banner, literal-string guards in tests. The fit/truncation math (`truncateTitle` 10-char cap + `fitLabel` measure-driven pixel fit) needs **no code change** — `fitLabel` consumes the injected `measure`, and real Chromium `measureText` scales with `ctx.font` automatically, so a smaller font mechanically fits more text at runtime. The test harness's `measureText` stub ignores `ctx.font` (models `6px/char + letterSpacing`), so the existing fit test (`test:404-420`) keeps passing unchanged — we deliberately do NOT make the stub font-aware (it would ripple into the `'+3'` badge geometry pins at `test:486-488` for zero user-facing value); the shrink is guarded by literal font-string pins instead. + +- [ ] **Step 1: Write the failing tests** + +In `test/unit/client/deck/tile-renderer.test.ts`, add `ICONS_TITLE_FONT_SIZE` to the existing import from `@/deck/tile-renderer`, then add inside the existing `describe('title fitting')` block (near line 98): + +```ts +it('icons banner title is 14px and vertically centered in the 20px banner', () => { + // Literal pins on purpose: an interpolated pin cannot catch drift. + expect(ICONS_TITLE_FONT_SIZE).toBe(14) + const { texts } = renderTab(tabSpec({ title: 'build' })) + const title = texts.find((t) => t.text === 'build') + expect(title?.font).toBe('400 14px Inter, sans-serif') + // y = Math.round((BANNER_HEIGHT - ICONS_TITLE_FONT_SIZE) / 2) = (20 - 14) / 2 = 3 + expect(title?.y).toBe(3) +}) + +it('classic preview banner stays PINNED at literal 16px sans-serif (does not follow the icons shrink)', () => { + const { texts } = renderTab(previewSpec({ title: 'build', previewLines: ['$ ls'] })) + expect(texts.find((t) => t.text === 'build')?.font).toBe('16px sans-serif') +}) +``` + +Also update two existing tests in the same file to stop interpolating the now-split constant: +- In `'icons tile: banner title renders regular-weight (400) Inter; avatar letter keeps RepoIcon 600'` (near line 331): change the expected title font from `` `400 ${TITLE_FONT_SIZE}px ${DECK_FONT_STACK}` `` to the literal `'400 14px Inter, sans-serif'`. +- In `'classic preview tile is PINNED: monospace body, sans-serif banner'` (near line 364): change `` `${TITLE_FONT_SIZE}px sans-serif` `` to the literal `'16px sans-serif'`. + +(`tabSpec` and `previewSpec` are existing in-file spec builders — see their usage at lines 182 and 365.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/dan/code/freshell/.worktrees/deck-render-refinements && npm run test:vitest -- run test/unit/client/deck/tile-renderer.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `ICONS_TITLE_FONT_SIZE` is not exported (import error), and after a stub export, font-string mismatch `'400 16px Inter, sans-serif' !== '400 14px Inter, sans-serif'`. + +- [ ] **Step 3: Implement** + +In `src/deck/tile-renderer.ts`, directly below `export const TITLE_FONT_SIZE = 16` (line 55): + +```ts +/** Icons-style banner title. 2px smaller than the classic banner: 16px read + * too large on 72-80px keys. The classic terminal-previews banner is PINNED + * and keeps TITLE_FONT_SIZE = 16. Fit math needs no change: fitLabel is + * measure-driven and Chromium's measureText scales with ctx.font. */ +export const ICONS_TITLE_FONT_SIZE = 14 +``` + +In `drawIconsTab`'s title block (lines 310-317), change the font assignment and recenter the title vertically: + +```ts +ctx.letterSpacing = TEXT_LETTER_SPACING +// Weight rule: 400 everywhere, EXCEPT where the deck mirrors the app UI — +// the letter avatar and +N badge keep RepoIcon's 600 (see RepoIcon.tsx). +ctx.font = `400 ${ICONS_TITLE_FONT_SIZE}px ${DECK_FONT_STACK}` +ctx.textBaseline = 'top' +ctx.fillStyle = ACTIVE_COLOR +const label = fitLabel((t) => ctx.measureText(t).width, truncateTitle(spec.title), w - 2 * TITLE_SIDE_PADDING) +drawCenteredText(ctx, label, w, Math.round((BANNER_HEIGHT - ICONS_TITLE_FONT_SIZE) / 2)) +``` + +(The only two changed lines are the `ctx.font = ...` line and the final `drawCenteredText(...)` y argument, previously the literal `2`. Do NOT touch `drawPreviewTab` at `:198-231`.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/dan/code/freshell/.worktrees/deck-render-refinements && npm run test:vitest -- run test/unit/client/deck/tile-renderer.test.ts --config config/vitest/vitest.config.ts` +Expected: PASS (whole file). + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/tile-renderer.ts test/unit/client/deck/tile-renderer.test.ts +git commit -m "feat(deck): shrink icons-banner title to 14px, recentered; classic preview stays pinned at 16px" +``` + +--- + +### Task 2: Pure-black tile background + darker composited green fill + +**Files:** +- Modify: `src/deck/tile-renderer.ts` (constants at lines 90-91, token-mapping comment block at lines 63-88, comment at ~line 326's test counterpart) +- Test: `test/unit/client/deck/tile-renderer.test.ts` + +**Interfaces:** +- Consumes: `TILE_BG` (`:90`), `TILE_FILL_GREEN` (`:91`) — the single fill constant used for BOTH green states (`ctx.fillStyle = spec.fill === 'none' ? TILE_BG : TILE_FILL_GREEN` at `:234-236`; `TileFill = 'barTop' | 'green' | 'none'`). `EMPTY_BG = '#000000'` (`:100`, the #585 surround) is already pure black. +- Produces: `TILE_BG = '#000000'`, `TILE_FILL_GREEN = '#697d73'`. One shared constant deliberately keeps serving both green-fill states — they differ only by border today (barTop adds the bright `BAR_TOP_BORDER` ring) and the spec darkens both fills identically. + +- [ ] **Step 1: Write the failing tests** + +In `test/unit/client/deck/tile-renderer.test.ts`, inside `describe('palette derives from the app UI tokens (mapping block in tile-renderer.ts)')` (near line 423), update the two literal pins in `'matches the documented app-token values'`: + +```ts +expect(TILE_BG).toBe('#000000') // deck-only pure black (matches EMPTY_BG surround) +expect(TILE_FILL_GREEN).toBe('#697d73') // emerald-100 #d1fae5 @ 50% over black, precomputed +``` + +And add a derivation test in the same describe: + +```ts +it('TILE_FILL_GREEN is emerald-100 composited at 50% opacity over black (round(c/2) per channel)', () => { + // #d1fae5 = rgb(209,250,229) -> rgb(105,125,115) = #697d73. Same hue, darker. + const composite = ['d1', 'fa', 'e5'] + .map((h) => Math.round(parseInt(h, 16) / 2).toString(16).padStart(2, '0')) + .join('') + expect(TILE_FILL_GREEN).toBe(`#${composite}`) + // Borders stay full strength: the barTop BORDER is NOT darkened. + expect(BAR_TOP_BORDER).toBe('#21c45d') + expect(ACTIVE_COLOR).toBe('#ffffff') +}) +``` + +Rename three now-inaccurate test names and one comment (behavior assertions unchanged — they reference the constants symbolically): +- `:166` `'no-fill tile: near-black bg, ...'` → `'no-fill tile: pure-black bg, banner, white title, no rings, no dot, no preview text'` +- `:176` `'green fill state paints the light-green background'` → `'green fill state paints the darker composited green background'` +- `:181` `'barTop state paints light-green background + 3px green border ring'` → `'barTop state paints the darker composited green background + full-strength 3px green border ring'` +- `:326` comment `// emerald-100 green fill` → `// composited green fill (emerald-100 @ 50% over black)` + +Do NOT convert any index-based `rects[N]` assertion into a `rects.find(r => r.style === TILE_BG)` lookup — with `TILE_BG === EMPTY_BG` the surround (`rects[0]`) and interior (`rects[1]`) are now value-identical; index assertions stay unambiguous. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/dan/code/freshell/.worktrees/deck-render-refinements && npm run test:vitest -- run test/unit/client/deck/tile-renderer.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `'#09090b' !== '#000000'` and `'#d1fae5' !== '#697d73'`. + +- [ ] **Step 3: Implement** + +In `src/deck/tile-renderer.ts` change the two constants (lines 90-91): + +```ts +export const TILE_BG = '#000000' +export const TILE_FILL_GREEN = '#697d73' +``` + +Update the token-mapping comment block (lines 63-88) — replace the `TILE_BG` and `TILE_FILL_GREEN` lines with: + +```ts +// TILE_BG <- deck-only pure black (was --background dark #09090b). +// Matches EMPTY_BG, so unfilled tiles read as plain +// black keys: only banner/icons/rings carry state. #000000 +// TILE_FILL_GREEN <- bg-emerald-100 (TabItem.tsx green-filled tab) #d1fae5 +// precomposited at 50% opacity over the black tile: +// round(c/2) per channel -> rgb(105,125,115) = #697d73. +// Same hue as the tab bar's green, dark enough for the +// LCD. Both fill states (green + barTop) use it; the +// barTop BORDER stays full-strength BAR_TOP_BORDER. +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/dan/code/freshell/.worktrees/deck-render-refinements && npm run test:vitest -- run test/unit/client/deck/tile-renderer.test.ts --config config/vitest/vitest.config.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/tile-renderer.ts test/unit/client/deck/tile-renderer.test.ts +git commit -m "feat(deck): pure-black tile interiors; green fills precomposited at 50% over black (#697d73)" +``` + +--- + +### Task 3: Deck labels reuse the tab bar's `getTabDisplayTitle` + +**Files:** +- Modify: `src/deck/deck-selectors.ts` (import + line 208) +- Test: `test/unit/client/deck/deck-selectors.test.ts` + +**Interfaces:** +- Consumes: `getTabDisplayTitle(tab: Tab, layout?: PaneNode, paneTitles?: Record, extensions?: ClientExtensionEntry[]): string` from `src/lib/tab-title.ts:19-38` — the tab bar's already-extracted pure derivation (user-set title > single-pane override > non-placeholder programmatic title > derived name; discards `/^Tab \d+$/` placeholders). Call shape mirrors the tab bar and the e2e helper at `test/e2e/coding-agent-naming-flow.test.tsx:43`: `getTabDisplayTitle(tab, state.panes.layouts[tab.id], state.panes.paneTitles?.[tab.id], state.extensions?.entries)`. +- Produces: `DeckTab.title` (and therefore every `KeySpec.title` and the touch-strip `stripText` active-tab text, both of which copy `DeckTab.title`) now carries the tab bar's displayed label. No signature changes; Tasks 6-8 build on the model unchanged. + +**Parity decision (explicit):** for a layout-less tab, `getTabDisplayTitle` returns the stored title (possibly `Tab N`) — exactly what the tab bar shows in that case (`TabBar.deriveTitle` test `:405` "falls back to stored title when no pane layout exists"). We pass `state.panes.layouts[tab.id]` directly like TabBar does and do NOT synthesize a leaf layout: the requirement is parity ("never a raw placeholder **when the tab bar would show something better**"), not divergence. + +- [ ] **Step 1: Write the failing tests** + +In `test/unit/client/deck/deck-selectors.test.ts`, add imports: + +```ts +import { getTabDisplayTitle } from '@/lib/tab-title' +``` + +Add a new describe (use the file's existing fixture builders — `makeState({ tabs, ... })` and the `claudeLeaf(...)` pane helper; mirror how the existing `'freshopencode targets carry cwd'` describe at `:167` seeds a leaf pane with an `initialCwd`): + +```ts +describe('deck titles match the tab bar (getTabDisplayTitle parity)', () => { + it('replaces a "Tab N" placeholder with the tab bar derived label (cwd basename)', () => { + // Seed one tab whose stored title is the raw placeholder and whose single + // claude pane has initialCwd '/home/dan/code/freshell'. + const state = makeState({ + tabs: [{ id: 't1', title: 'Tab 1', pane: claudeLeaf('p1', 'term-1', '/home/dan/code/freshell') }], + }) + const model = selectDeckModel(state) + expect(model.tabs[0].title).toBe('freshell') + // Load-bearing parity assertion: byte-identical with the tab bar's call. + const tab = state.tabs.tabs[0] + expect(model.tabs[0].title).toBe( + getTabDisplayTitle(tab, state.panes.layouts[tab.id], state.panes.paneTitles?.[tab.id], state.extensions?.entries), + ) + }) + + it('keeps a user-set custom title verbatim', () => { + const state = makeState({ + tabs: [{ id: 't1', title: 'my custom name', titleSetByUser: true, pane: claudeLeaf('p1', 'term-1', '/tmp/x') }], + }) + expect(selectDeckModel(state).tabs[0].title).toBe('my custom name') + }) + + it('layout-less tab falls back to the stored title, exactly like the tab bar', () => { + const state = makeState({ tabs: [{ id: 't1', title: 'Tab 1' }] }) // no pane layout + const tab = state.tabs.tabs[0] + expect(selectDeckModel(state).tabs[0].title).toBe( + getTabDisplayTitle(tab, state.panes.layouts[tab.id], state.panes.paneTitles?.[tab.id], state.extensions?.entries), + ) + }) +}) +``` + +Adapt the fixture-shape details to `makeState`'s actual option shape in that file (it already builds tabs + pane layouts + settings); the three assertions above are the contract. Note the deck unit-test store does NOT register the `extensions` reducer — the implementation must use `state.extensions?.entries` optional chaining or these tests crash. + +Also check the existing pin at `:124` (`expect(model.tabs[0]).toMatchObject({ id: 't1', title: 'build', ... })`): `'build'` is a non-placeholder stored title that differs from the derived name, so `getTabDisplayTitle` keeps it and the pin should still pass. If the fixture's derived name happens to equal the stored title, update the fixture title rather than weakening the assertion. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/dan/code/freshell/.worktrees/deck-render-refinements && npm run test:vitest -- run test/unit/client/deck/deck-selectors.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `'Tab 1' !== 'freshell'` (deck currently copies the raw store title). + +- [ ] **Step 3: Implement** + +In `src/deck/deck-selectors.ts` add the import: + +```ts +import { getTabDisplayTitle } from '@/lib/tab-title' +``` + +In `selectDeckModel` (line 208), replace `title: tab.title,` with: + +```ts +// Same label the tab bar displays (custom title, else derived default such as +// the working-directory basename) — reuse the tab bar's derivation verbatim. +// extensions uses ?. because unit-test stores may omit that reducer. +title: getTabDisplayTitle(tab, state.panes.layouts[tab.id], state.panes.paneTitles?.[tab.id], state.extensions?.entries), +``` + +No changes to `frame.ts` or `tile-renderer.ts`: tiles (`frame.ts:119`/`:124`), previews, and the touch strip (`stripText` reads `active?.title` at `frame.ts:83`) all copy `DeckTab.title`. Live repaint is free — `DeckController.onStoreChange` JSON-diffs the model, so a title change repaints automatically. + +- [ ] **Step 4: Run tests to verify they pass, and run neighbors** + +Run: `cd /home/dan/code/freshell/.worktrees/deck-render-refinements && npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` +Expected: PASS. If any existing deck test seeded a placeholder-titled tab WITH a cwd-bearing pane and pinned the placeholder, update that pin to the derived label (that is the new correct behavior). (Strip/e2e parity coverage is added in Task 8.) + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/deck-selectors.ts test/unit/client/deck/deck-selectors.test.ts +git commit -m "feat(deck): tiles and strip show the tab bar's displayed label via getTabDisplayTitle" +``` + +--- + +### Task 4: `streamDeck.keyLayout` setting — shared layer + persistence + +**Files:** +- Modify: `shared/settings.ts` (value tuple + type near line 99, `LocalSettings.streamDeck` at 225-231, zod schema near 266, patch normalizer at 635-655, defaults at 902-908, legacy-seed `pickKeys` allowlist at ~1463) +- Modify: `src/store/browserPreferencesPersistence.ts` (streamDeck block at lines 145-153) +- Modify (fixture ripple, same task): `test/unit/client/components/settings/StreamDeckSettings.test.tsx` (`renderSection` default streamDeck object at ~line 24). Do NOT touch `defaultSettings()` in `test/e2e/stream-deck-flow.test.tsx` (see Step 3). +- Test: `test/unit/shared/settings.stream-deck.test.ts` + +**Interfaces:** +- Consumes: the `tileStyle` precedent end-to-end (`DECK_TILE_STYLE_VALUES`/`DeckTileStyle`/`DeckTileStyleSchema`/normalizer/defaults/persistence). +- Produces: `export const DECK_KEY_LAYOUT_VALUES = ['auto', 'newest-first', 'status-sorted'] as const`; `export type DeckKeyLayout = (typeof DECK_KEY_LAYOUT_VALUES)[number]`; `LocalSettings['streamDeck']` gains `keyLayout: DeckKeyLayout`; `defaultLocalSettings.streamDeck.keyLayout === 'auto'`. Tasks 5-8 consume `DeckKeyLayout` and the persisted value at `state.settings.settings.streamDeck.keyLayout`. + +- [ ] **Step 1: Write the failing tests** + +In `test/unit/shared/settings.stream-deck.test.ts`, mirroring the existing `describe('streamDeck.tileStyle')` block exactly (same imports and helpers), add: + +```ts +describe('streamDeck.keyLayout', () => { + it('defaults to auto', () => { + expect(defaultLocalSettings.streamDeck.keyLayout).toBe('auto') + }) + + it('round-trips newest-first through patch normalization and persistence', () => { + // Mirror the tileStyle round-trip test one-for-one, substituting + // { streamDeck: { keyLayout: 'newest-first' } } and asserting the + // resolved settings carry keyLayout 'newest-first' and + // buildLocalSettingsPatch emits streamDeck.keyLayout 'newest-first'. + }) + + it('drops invalid keyLayout values during extraction', () => { + // Mirror the tileStyle invalid-value test: extractLegacyLocalSettingsSeed + // with streamDeck.keyLayout: 'sideways' must not emit a keyLayout key. + }) + + it('produces no persisted entry at the default value', () => { + // buildLocalSettingsPatch(defaultLocalSettings) has no streamDeck.keyLayout. + }) + + it('survives the reload path: a parsed browser-preferences record preserves streamDeck.keyLayout', () => { + // Reload-path proxy (round-trip tests alone cannot catch a whitelist-on-read + // gap): boot hydration routes the localStorage blob through + // parseBrowserPreferencesRaw -> extractLegacyLocalSettingsSeed (pickKeys + // allowlist, settings.ts ~:1463) -> normalizeExtractedLocalSeed (value gate, + // ~:635-655). BOTH gates strip unknown keys, so each must learn keyLayout or + // the setting persist-then-vanishes on reload. Parse + // JSON.stringify({ settings: { streamDeck: { keyLayout: 'newest-first' } } }) + // with the real parse function from src/lib/browser-preferences (adapt the + // import to its actual export) and assert the resulting record carries + // streamDeck.keyLayout 'newest-first'. + }) +}) +``` + +Fill each body by copying the adjacent `tileStyle` test in the same file and substituting the key/value — those tests are the canonical template (they exercise `resolveLocalSettings`, `buildLocalSettingsPatch`, and `extractLegacyLocalSettingsSeed`). Also update the two full-object assertions that will now fail: `'has safe defaults'` (`:12`) and `'round-trips a patch through resolve -> buildLocalSettingsPatch'` (`:22`) gain `keyLayout: 'auto'` in their expected `streamDeck` objects. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/dan/code/freshell/.worktrees/deck-render-refinements && npm run test:vitest -- run test/unit/shared/settings.stream-deck.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `defaultLocalSettings.streamDeck.keyLayout` is `undefined`. + +- [ ] **Step 3: Implement** + +In `shared/settings.ts`, next to the `DECK_TILE_STYLE_VALUES` block: + +```ts +/** Key layout for the Stream Deck. 'auto' resolves per device: reversed + * ("newest first", pager pinned top-left) on the smallest decks + * (keyCount <= 6, e.g. the 6-key Mini), status-sorted on larger decks. + * 'newest-first' = strictly reverse tab-bar order: newest tab first while + * tabs are unreordered; after a manual reorder (or cross-device order sync) + * the deck mirrors the reversed tab bar. Deliberate — see plan naming note. */ +export const DECK_KEY_LAYOUT_VALUES = ['auto', 'newest-first', 'status-sorted'] as const +export type DeckKeyLayout = (typeof DECK_KEY_LAYOUT_VALUES)[number] +``` + +Next to `DeckTileStyleSchema` (~line 266): + +```ts +const DeckKeyLayoutSchema = z.enum(DECK_KEY_LAYOUT_VALUES) +``` + +In `LocalSettings.streamDeck` (~line 230), add `keyLayout: DeckKeyLayout`. In the patch normalizer's streamDeck block (~line 649), alongside the tileStyle guard: + +```ts +if (DeckKeyLayoutSchema.safeParse(patch.streamDeck.keyLayout).success) { + streamDeck.keyLayout = patch.streamDeck.keyLayout as DeckKeyLayout +} +``` + +In `defaultLocalSettings.streamDeck` (~line 907), add `keyLayout: 'auto'`. In `extractLegacyLocalSettingsSeed`'s `pickKeys` allowlist (~line 1463), append `'keyLayout'`: + +```ts +pickKeys(raw.streamDeck, ['enabled', 'brightness', 'idleBrightness', 'idleTimeoutSeconds', 'tileStyle', 'keyLayout']) +``` + +(Both read-path gates are individually load-bearing: boot hydration routes the localStorage blob through `extractLegacyLocalSettingsSeed`'s pickKeys allowlist AND `normalizeExtractedLocalSeed`'s value gate. Miss either one and the setting persists but silently vanishes on reload — while the patch round-trip tests still pass. The reload-path test in Step 1 guards exactly this.) + +In `src/store/browserPreferencesPersistence.ts` streamDeck block (~line 150), add: + +```ts +assignChangedScalar(streamDeck, localSettings.streamDeck, defaultLocalSettings.streamDeck, 'keyLayout') +``` + +Fixture ripple (runtime, not typecheck — test files sit outside BOTH tsconfig includes, so no test file can ever produce a typecheck error in this repo): add `keyLayout: 'auto' as const` to the `renderSection` default streamDeck object in `test/unit/client/components/settings/StreamDeckSettings.test.tsx` (~line 24) — Task 5's `value={streamDeck.keyLayout}` control needs it at runtime to render 'Auto' as pressed. Do NOT touch `defaultSettings()` in `test/e2e/stream-deck-flow.test.tsx`: that object is the controller's settings-callback shape (`DeckControllerOptions.settings`, `deck-controller.ts:37`), which never carries `keyLayout` — keyLayout reaches the deck via the store (`updateSettingsLocal` → `selectDeckModel`, Task 6) — and adding `keyLayout` to that annotated literal is an excess-property type error (TS2353) in an editor, not a fix. + +- [ ] **Step 4: Run tests + typecheck to verify green** + +Run: `cd /home/dan/code/freshell/.worktrees/deck-render-refinements && npm run test:vitest -- run test/unit/shared/settings.stream-deck.test.ts test/unit/client/components/settings/StreamDeckSettings.test.tsx test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts && npm run typecheck` +Expected: PASS + clean typecheck. Fix any remaining full-object streamDeck literal the typechecker flags (mechanical: add `keyLayout: 'auto'`) — note tsc covers only `src/` + `shared/` (tests are outside both tsconfig includes, so fixture drift never surfaces via typecheck); the only production full-object literal is `defaultLocalSettings.streamDeck`, already edited above. + +- [ ] **Step 5: Commit** + +```bash +git add shared/settings.ts src/store/browserPreferencesPersistence.ts test/unit/shared/settings.stream-deck.test.ts test/e2e/stream-deck-flow.test.tsx test/unit/client/components/settings/StreamDeckSettings.test.tsx +git commit -m "feat(settings): streamDeck.keyLayout (auto | newest-first | status-sorted), persisted client-side" +``` + +--- + +### Task 5: Settings UI — "Key layout" segmented control + +**Files:** +- Modify: `src/components/settings/StreamDeckSettings.tsx` (new `SettingsRow` after the "Tile style" row at lines 79-95) +- Test: `test/unit/client/components/settings/StreamDeckSettings.test.tsx` + +**Interfaces:** +- Consumes: `DeckKeyLayout` from Task 4; existing `SettingsRow` + `SegmentedControl` primitives (`settings-controls.tsx` — `role="group"` + per-option `