From 37a0bc5e395456f92d86af7e884f9d7a921bffdf Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:27:36 +0200 Subject: [PATCH 1/8] docs: spec and plan for the see-through window, theme order and Most recent (#156, #157, #158) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- .../plans/2026-10-10-look-and-mru.md | 129 +++++++++ .../specs/2026-10-10-look-and-mru-design.md | 268 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-10-look-and-mru.md create mode 100644 docs/superpowers/specs/2026-10-10-look-and-mru-design.md diff --git a/docs/superpowers/plans/2026-10-10-look-and-mru.md b/docs/superpowers/plans/2026-10-10-look-and-mru.md new file mode 100644 index 0000000..7835abb --- /dev/null +++ b/docs/superpowers/plans/2026-10-10-look-and-mru.md @@ -0,0 +1,129 @@ +# Plan: see-through window, theme order, Most recent tab switching (#156, #157, #158) + +Spec: `docs/superpowers/specs/2026-10-10-look-and-mru-design.md`. Worktree +`.claude/worktrees/look-mru`, branch `feat/look-mru` (stacked on #155). TDD: each pure function's +test first. One commit per task group. No em-dashes. No push, no PR, no merge from this plan's +build agents; e2e only in the Gate task, one run at a time. + +## Task 0: versions + +- `core/package.json` 0.30.0 to 0.31.0; `package.json` 0.36.0 to 0.37.0 (and `package-lock.json`'s + root version fields). + +## Task 1: theme order (#157), core + +1. `core/renderer/lib/themeOrder.test.ts` (red): permutation; neutrals before coloured; L + non-decreasing per group; neutral set = {pitch, high-contrast, volt, prism, campbell, pt-default, + graphite, monokai, gruvbox-dark, mist, catppuccin-latte, paper}; no ground within 0.001 of + `NEUTRAL_CHROMA`; stable ties (pitch before high-contrast); `toMatchSnapshot()` of the id order. +2. `core/renderer/lib/themeOrder.ts`: `oklch(hex)`, `NEUTRAL_CHROMA = 0.0125`, + `orderTermThemes(list: readonly T[]): T[]`. Comment the measurement. +3. `core/renderer/settings/theme/ThemeWall.tsx`: drop `lightFirst` and the luminance sort; + `sortedPresets = orderTermThemes(TERM_PRESETS.filter((p) => p !== defaultPreset))`. Leading + cards unchanged (Custom, host default, Follow style). Update the component's doc comment. + +## Task 2: see-through default, core + +1. `core/renderer/lib/seeThrough.test.ts` (red), then `core/renderer/lib/seeThrough.ts`: + `SEE_THROUGH_ALPHA` (0xb9/255 dark, 0xd1/255 light; comment the Prism levels 70/49 and the + paintedAlpha formula), `SEE_THROUGH_MAX = 0xf2 / 255`, `seeThroughAlpha(ground)` (luminance of + the opaque colour > 0.4 is light; `luminance`/`normalizeColor` from `termAnsi.ts`, `opaque` + from `colour.ts`). +2. `core/renderer/lib/termLook.ts`: + - `termAcrylicInForce()`: `termAcrylic()` and not (`hostOwnsWindowAcrylic()` and + `liveThemeId(termThemeId()) === 'high-contrast'`); `useTermAcrylicInForce()`. + - `termGroundAlpha()`: window host, `termAcrylicInForce()`, raw alpha 1 (>= 255/255 by byte): + `seeThroughAlpha(groundInForce())`; else as today. `groundInForce()` = `hostGround()` ?? + custom `bg` ?? `resolveTermTheme(termThemeId()).background` (call-time import of + `./termTheme`; comment why the cycle is safe). + - Export a pure `paintsAlpha(rawAlpha, ground, acrylicOn)` used by both `termGroundAlpha` and + `useTermSetup`'s baseline, so the dirty check and the window share one rule. +3. Tests in `core/renderer/host.test.ts` (or a new `termLook.seeThrough.test.ts`): the cases in the + spec's Tests list, with a window host and with a style host. +4. `core/renderer/settings/theme/useTermSetup.ts`: `ownByte` through `paintsAlpha` with the saved + setup's own `acrylic` and ground. +5. `core/renderer/settings/theme/ThemeWall.tsx:448`: editor `bgAlpha` = + `{ alphaMin: 0.3, alphaMax: SEE_THROUGH_MAX, alphaDisabled: !acrylicInForce || noAcrylic }`. + +## Task 3: the row, core + +1. `core/renderer/settings/sections/opts.ts`: `acrylicLabel()` ("See-through window" where the + terminal owns the window acrylic, else `opt('term-acrylic').label`); `acrylicSub()` gives + "The desktop shows behind every surface." there, and "High contrast stays solid." when High + Contrast blocks it. Test both host kinds (new `opts.test.ts` or the existing options test). +2. `TerminalThemeSection.tsx`: the switch row BEFORE `{afterTheme}`; label `acrylicLabel()`; + `off`/`disabled` also when High Contrast blocks it; Switch `on={acrylicInForce && !noAcrylic}`. + Keep `id="term-acrylic"`. +3. `TerminalAppearance.tsx` (legacy) label through `acrylicLabel()`; `coreIndex.ts` label through + `acrylicLabel()`. +4. `options.ts` entry unchanged (Prism's gate reads it as text). Keywords may gain nothing: "see + through" is already there. + +## Task 4: PT's window, src + +1. `src/renderer/src/App.tsx` `paintChrome`: `const acrylic = termAcrylicInForce()` (material and + alpha both). +2. `src/renderer/src/components/settings/AppearancePage.tsx`: `themeColours()` shows + `termGroundAlpha()` while the switch is in force and nothing is picked; Background `range` + `{ alphaMin: 0.3, alphaMax: SEE_THROUGH_MAX, alphaDisabled: !acrylic }`; `acrylic` from + `useTermAcrylicInForce()`. +3. `settingsIndex.ts` `ROW_ORDER`: `'term-theme', 'term-acrylic', 'window-background', + 'window-accent'`. +4. `src/renderer/src/lib/chromeTheme.test.ts`: each preset at `seeThroughAlpha(its bg)`, text fills + flattened and 4.5:1. +5. Run `npm test`, `npm run typecheck`, `npm run lint`; update snapshots only where the change is + the intended one (say which in the commit). + +## Task 5: Most recent (#158), src + +1. `src/renderer/src/lib/tabSwitchPrefs.ts` (+ test): key `prism.window.tabSwitch`, + `'order' | 'recent'`, default `'order'`, `useTabSwitch`, `onTabSwitchChange`. +2. `src/renderer/src/lib/tabMru.test.ts` (red), then `tabMru.ts`: `touchMru(mru, id)`, + `syncMru(mru, tabIds, activeId)` (drop closed, add unknown ids after the known ones in strip + order, active to the front when the list was empty), `startWalk(mru)`, `stepWalk(walk, dir)`, + `walkTarget(walk)`. Cases from the spec. +3. `App.tsx`: + - refs `mru` (seeded from the boot state: active, then strip order) and `walk`. + - effect on `[state.tabs, activeId]`: `mru = syncMru(...)`; when no walk, `touchMru(activeId)`. + - Ctrl+Tab branch: In order unchanged. Most recent: `hit()`; start a walk if none (from the + synced list), step by `shiftKey ? -1 : 1`, `setState(pickTab(s, walkTarget))`; a list under 2 + does nothing. + - Any other Ctrl chord handled in `onKey` first commits a running walk. + - `keyup` (capture) on window: `e.key === 'Control'` commits; `blur` on window commits. Commit = + `walk = null; mru = touchMru(mru, activeId)`. Remove both listeners in the cleanup. +4. `appOptions.ts`: `{ id: 'tab-switch', label: 'Tab switching', sub: 'Where Ctrl+Tab goes next.', + section: 'window', page: 'appearance', icon: 'key', keywords: 'ctrl tab mru recent order cycle + switch next previous last used', store: ['prism.window.tabSwitch'] }`, after `tab-style`. +5. `AppearancePage.tsx` Window section: a `SettingRow id="tab-switch"` with + `Segmented` options `[{ id: 'order', name: 'In order' }, { id: 'recent', name: 'Most recent' }]` + after Tab style; live sub per value (spec). +6. `settingsIndex.ts` `ROW_ORDER`: `'tab-width', 'tab-style', 'tab-switch', 'title-bar', ...`. +7. Unit: `appOptions.test.ts`, `settingsCopy.test.ts` (eight plain words), the ROW_ORDER test. + +## Task 6: e2e scenarios (written, NOT run here) + +In `tools/e2e/run.mjs`: +- `PREF_PAGE` (line ~170): `'tab-switch': 'appearance'`. +- New `seeThrough` and `tabSwitch` scenarios (spec). End idle; close through `closeApp`. +- `themeCards`: wall DOM order equals `[pt-default, ...orderTermThemes(rest)]`. The order is + computed in the scenario from the same bg values read off the cards, or hardcoded from the + unit snapshot (prefer reading `data-term-card` ids and comparing to a list exported for the test). +- `settingsLook` `want` (line ~3572): `['tab-width', 'tab-style', 'tab-switch', 'title-bar', + 'window-edges', 'term-theme', 'term-acrylic', 'window-background', 'window-accent']`. +- Check `options`, `opacityAlpha`, `accent`, `pickedGround` for assumptions the default see-through + breaks (an acrylic-on window asserted opaque); fix the assertion to the new rule, never the rule. + +## Task 7: docs + +- `README.md` features and keys rows; `docs/two-apps.md` (theme order, see-through default, + Appearance order); `core/README.md` (acrylic contract, `orderTermThemes`); + `docs/regression-rules.md` new entry "Most recent commits on Ctrl release" with its tests. + +## Task 8: Gate (one agent, alone) + +1. `npm test`, `npm run typecheck`, `npm run lint` green. +2. `npm run e2e -- ` one at a time for: seeThrough, tabSwitch, themeCards, themeSwitch, + settingsLook, settingsSearch, settingsKeys, options, opacityAlpha, accent, pickedGround, + colourPicker, theme, reviewKeys, tabWidth, tabStyle. Then the full `npm run e2e` once. +3. LOOK at `.e2e-shots/settings-appearance-*.png`, `see-through.png`, `theme-wall.png`. +4. Commit fixes with the PR-ready message trailer. diff --git a/docs/superpowers/specs/2026-10-10-look-and-mru-design.md b/docs/superpowers/specs/2026-10-10-look-and-mru-design.md new file mode 100644 index 0000000..93a2612 --- /dev/null +++ b/docs/superpowers/specs/2026-10-10-look-and-mru-design.md @@ -0,0 +1,268 @@ +# See-through window, theme order and Most recent tab switching (#156, #157, #158) + +Date: 2026-10-10. Branch `feat/look-mru`, stacked on `feat/154-full-style` (PR #155). +Versions: app `package.json` 0.36.0 to **0.37.0**; `core/package.json` 0.30.0 to **0.31.0** (core +changes, so this ships to Prism too). + +## The owner's words (2026-10-10, verbatim) + +> also add support for the see through winmdow setting in prism terminal if its not here already, +> its in prism in style settings i wnat it here too.also sort all the terminal themes so therye +> ordered form black to white with colroed ones in between depending on where they fall on teh black +> or white scale not sure whta thats called but the tint of their colro determins weather their +> darker or brighter then teh next. but dark colros shoudl be before all colred, amybe acutally +> colroed themes last first black to white then colroed. also add a new tab switching mode called +> most recent. so you can either switch chronologically or by most recently used. createa plan for +> all this then build, ill be gone so youll have to do all these thigns and ill test once im back. + +The owner is away; every decision below is made by the implementer and flagged for the owner's +review in the PR. The owner is visually impaired and works zoomed in: contrast and clarity matter. + +--- + +## 1. See-through window (#156) + +### What Prism has + +Prism, Settings > Appearance, section "This theme" +(`Prism/src/renderer/src/components/settings/AppearancePage.tsx:99-141`, `appOptions.ts:74`): + +- Row `see-through`, label **"See-through window"**, sub **"The desktop shows behind every + surface."**, icon `glass`, a Switch. It sits right under the theme wall, before the theme's colours. +- The switch is the SAME value as Primary's alpha: on means the painted alpha is below 1 + (`paintedAlpha(shown) < 1`). Turning it on over a SOLID theme writes a see-through level of + `SEE_THROUGH_LEVEL = { dark: 70, light: 49 }` (the levels Glacier and Orchid paint); turning it + off over a glassy theme writes 0. Either is an edit of the theme, so it lights Save changes. +- Hidden on the high contrast themes: "their contrast is measured on a solid ground, and glass + would put an unmeasurable desktop under the text." +- What a level paints (`theme.ts:1271-1286`): `glass = 0.85 - level/100 * 0.55`, painted alpha + `1 - (1 - glass * 0.75)^3`. Level 70 (dark) paints **0.724 = byte 0xb9**; level 49 (light) + paints **0.820 = byte 0xd1**. + +### What Prism Terminal has today + +PT already has the switch, under another name and with a gap that makes it look broken: + +- Row `term-acrylic`, label "Acrylic terminal background", sub "The desktop shows through the + window.", `core/renderer/settings/options.ts:54`; drawn by + `core/renderer/settings/sections/TerminalThemeSection.tsx:45-54` in Settings > Appearance > + Theme, AFTER the wall and after PT's own Background colour and Accent colour rows + (`src/renderer/src/components/settings/AppearancePage.tsx:197-208`, `ROW_ORDER` in + `settingsIndex.ts:41`). Store `prism.term.acrylic`, default off (`src/renderer/src/termHost.ts:27`). +- It switches the window's acrylic material (`App.tsx:100-123`, `paintChrome`), and the window + paints at `termGroundAlpha()` (`core/renderer/lib/termLook.ts:423-437`): the picked Background's + alpha, else a Custom's `bg` alpha, else **1 for every preset**. +- **THE GAP**: on any preset with no picked Background, switching it on changes the material but + the ground is painted opaque, so the desktop does NOT show. It shows only after the user also + lowers the Alpha slider of the Background colour picker (the #114 design: "the Background's alpha + is the window's see-through"). That is why it reads as missing. Prism's switch, by contrast, + makes the window see-through by itself. +- Windows 10: the row says "Needs Windows 11." and is off (`useNoAcrylic`). + +### Decisions + +1. **No second switch.** The existing `term-acrylic` row IS the see-through window. Same id, same + store key, same `TERMINAL_OPTIONS` entry (so neither app's parity test and no saved setting + changes). +2. **Name and words like Prism's, in PT only.** In a host whose terminal owns the window acrylic + (`hostOwnsWindowAcrylic()`, i.e. PT) the row reads **"See-through window"** / + **"The desktop shows behind every surface."** In Prism the terminal row keeps "Acrylic terminal + background" and its own sub: there it is a TERMINAL setting under Prism's own app-level + "See-through window", and two rows with one name would be wrong. Done the way the sub already + differs per host (`sections/opts.ts:37` `acrylicSub`): a new `acrylicLabel()` beside it, used by + `TerminalThemeSection`, the legacy `TerminalAppearance.tsx` and `coreIndex.ts` (Find a setting). + `options.ts` keeps Prism's label (Prism's gate reads that file as text). No new `TermHostConfig` + field: it derives from `acrylic.kind`, which is declared. +3. **On means see-through, by itself (Prism's behaviour).** Where the terminal owns the window + acrylic and the switch is on, an OPAQUE ground in force (alpha 1: every preset, a picked + Background with no alpha, an opaque Custom) paints at the **default see-through**: + `0xb9/255` on a dark ground, `0xd1/255` on a light one, Prism's own two levels as they paint. + Light or dark is MEASURED from the ground in force (relative luminance > 0.4, the same test + `chromeTheme.ts:105` uses for `data-mode`), never read off a name. A ground that already carries + an alpha (30% to 95%) keeps it: the Background picker's Alpha slider still tunes how much. + - New pure `core/renderer/lib/seeThrough.ts`: `SEE_THROUGH_ALPHA = { dark: 0xb9 / 255, light: + 0xd1 / 255 }`, `SEE_THROUGH_MAX = 0xf2 / 255`, `seeThroughAlpha(groundHex): number`. + - `termGroundAlpha()` (`termLook.ts:423`): where `hostOwnsWindowAcrylic()` and `termAcrylic()` + and the raw alpha is 1, return `seeThroughAlpha(opaque ground in force)`; otherwise as today + (floored at 30%). The opaque ground in force is `hostGround()`, else the Custom's `bg`, else + the preset's background, read through `resolveTermTheme` (a call-time import; `termTheme.ts` + already imports `termLook` the same way, and neither reads the other at module load). + - So Prism, where `acrylic.kind` is 'style', is untouched. +4. **Opaque is the switch's off.** While the switch is on, the Background colour picker (PT's + `window-background` row) and the theme editor's Background (`ThemeWall.tsx:448`) cap Alpha at + `SEE_THROUGH_MAX` (95%), `alphaMin` stays 0.3: an Alpha of 100 would read back as the default + and the slider would jump. Same rule as Prism: the switch and the alpha always agree. + PT's Background row shows the alpha IN FORCE while nothing is picked (`themeColours()` in + `AppearancePage.tsx:65-76` uses `termGroundAlpha()` under the switch instead of 1). +5. **The row moves up, right under the wall**, before Background colour and Accent colour, as in + Prism (see-through first, then the theme's colours). `TerminalThemeSection` renders the switch + before `afterTheme`. Prism passes no `afterTheme`, so its Terminal page does not change. PT's + `ROW_ORDER` becomes `... 'term-theme', 'term-acrylic', 'window-background', 'window-accent' ...`. +6. **High Contrast stays solid** (Prism's rule, and this owner's eyes). In a window-acrylic host, + when the theme in force is `high-contrast`, the row is drawn off and disabled with the sub + "High contrast stays solid.", and the window paints solid whatever is stored + (`termAcrylicInForce()` in `termLook.ts` = `termAcrylic()` and not that case; `paintChrome`, + the Background row and the editor read it). The stored choice is kept, so leaving High Contrast + gives it back. +7. **Save changes** keeps working: the switch is a theme extra already (`termExtraDefaults().acrylic`), + and Save as Custom stores `withGroundAlpha(...)`, which now carries the default alpha, so a saved + Custom stays see-through. `useTermSetup`'s dirty check compares the ground alpha in force with + the alpha the saved setup paints, computed by the SAME rule (an older Custom saved with + `acrylic: true` and an opaque `bg` paints the default, so it is not dirty on pick). +8. **Contrast.** Chrome text fills are already flattened under a see-through ground + (`chromeTheme.ts`, #114). Unit-test every preset at the two default alphas (text 4.5:1 on the + composite, as the existing `under a see-through ground` test does at its alpha). + +**Known, accepted change on update**: anyone who had the switch ON with an opaque ground (it looked +solid) now sees a see-through window. That is the bug being fixed; the owner's Stable copy is one +such profile if he ever turned it on. Said in the PR. + +--- + +## 2. Theme order (#157) + +### Today + +`core/renderer/settings/theme/ThemeWall.tsx:299-317`: Custom first, then the host's default preset +(PT Default in PT; none in Prism, whose default is 'style'), then "Follow style" (Prism only), then +every other preset sorted by the WCAG luminance of its background, DARK TO LIGHT or LIGHT TO DARK +depending on whether the theme worn when the page opened is light (`lightFirst`). No neutral vs +coloured grouping. The wall shows two rows until "Show all". + +### Decision + +**Neutral themes first, black to white; then coloured themes, black to white.** Computed, not typed: + +- New pure `core/renderer/lib/themeOrder.ts`: `oklch(hex) -> { L, C }` (OKLab, Björn Ottosson's + matrices) and `orderTermThemes(presets)`. Lightness is OKLab **L** (perceptual lightness, "the + black or white scale"); a theme is **neutral** when the OKLab chroma **C** of its ground is below + `NEUTRAL_CHROMA = 0.0125`. Each group sorts by L ascending; ties keep `TERM_PRESETS` order (stable + sort), so Pitch stays before High Contrast. +- **The threshold is measured** (2026-10-10, the forty grounds): sorted by C, the grounds run 0 + (six pure greys), 0.0027 Mist, 0.0055 Volt, 0.0058 Catppuccin Latte, 0.0082 Paper, 0.0085 Prism, + 0.0109 Monokai, then **0.0147** Sage, 0.0149 Cinder and Blossom, 0.0157 Horizon and up to 0.1316 + Retro. 0.0109 to 0.0147 is the widest gap below 0.02, and 0.0125 sits in it. A unit test holds + that no preset ground is within 0.001 of the threshold, so a new theme cannot land on the fence + unnoticed. +- Neutral (12): Pitch, High Contrast, Volt, Prism, Campbell, PT Default, Graphite, Monokai, Gruvbox + Dark, Mist, Catppuccin Latte, Paper. Coloured (28): the rest, from Phosphor (L 0.147) to Butter + (0.967). Cinder (`#383c44`, a blue slate, C 0.0149) lands with the coloured: it is measurably as + tinted as Blossom, and the rule is measured, not named. +- The fixed direction replaces `lightFirst` (the owner asked for one order). The leading cards stay + as the owner set them (2026-09-22/23): **Custom, then the host's default (PT Default), then Follow + style (Prism)**, then the computed order. In Prism, PT Default is not the default, so it takes its + place among the neutrals. +- **Prism** gets the same order on its Terminal page's wall: the rule is about the terminal themes, + which are the same forty in both apps, so it is right there too. Said in the PR. +- `docs/two-apps.md` (FORTY THEMES paragraph) gets the order rule. + +--- + +## 3. Tab switching: Most recent (#158) + +### Today + +`src/renderer/src/App.tsx:538-540`: Ctrl+Tab / Ctrl+Shift+Tab call `stepTab(s, +-1)` +(`src/renderer/src/lib/tabs.ts:45-51`), strip order, wrapping; the Settings tab is a tab like any +other. `termHost.ts:70` claims Ctrl+Tab from xterm. There is no other next/previous chord +(no Ctrl+PageUp/PageDown). App shell only: nothing in `core/`, Prism unaffected. + +### Decision + +- Setting **"Tab switching"** (`tab-switch`), a Segmented control, **"In order"** (default) / + **"Most recent"**, in Settings > Appearance > Window, right after Tab style (the strip's other + rows). Sub at rest "Where Ctrl+Tab goes next."; live sub "Ctrl+Tab follows the strip." / + "Ctrl+Tab goes to the tab you used last." Icon `key`. Store `prism.window.tabSwitch` + (`'order' | 'recent'`, anything else reads 'order'), in a new `src/renderer/src/lib/tabSwitchPrefs.ts` + shaped like `tabWidthPrefs.ts`. Never touched: In order, so nobody's keys change on update. +- In **Most recent**, Ctrl+Tab and Ctrl+Shift+Tab walk the most-recently-used list, as browsers and + VS Code do: + - The first Ctrl+Tab of a hold goes to the tab used before this one; each further Tab while Ctrl + is held goes one further back; Ctrl+Shift+Tab goes the other way (from the far end on a fresh + hold). Wraps. + - The list is SNAPSHOT at the first press and only reordered when **Ctrl is released** (keyup of + Control, or the window losing focus): then the tab landed on moves to the front. So a single + Ctrl+Tab flips between the last two tabs, and repeated presses walk instead of ping-ponging. + - Any other activation (a click, Ctrl+1..9, Ctrl+T, Ctrl+, , a close handing over) moves the tab + in front to the head of the list at once. Any other Ctrl chord pressed during a walk ends the + walk first (commit), then acts. + - Closed tabs leave the list; new tabs enter it (at the front, as they are activated). At launch + the list is the active tab, then the rest in strip order (not persisted: history across launches + is not a thing the owner asked for). The Settings tab takes part, as in In order. + - Under a close question or the update window the chords still do nothing (rule from #10). +- Pure `src/renderer/src/lib/tabMru.ts`: `touchMru`, `syncMru(mru, tabIds, activeId)`, + `startWalk`, `stepWalk`, `walkTarget`. App holds the list and the walk in refs and listens to + keyup (capture) and window blur. In order keeps `stepTab` exactly. +- Regression rule 10 is untouched: this listens to the Control keyup only, never to `onData`. + +--- + +## What changes where + +**core/ (ships to Prism; bump 0.31.0):** +- `lib/seeThrough.ts` (new), `lib/termLook.ts` (`termGroundAlpha` default, `termAcrylicInForce`), + `lib/themeOrder.ts` (new). +- `settings/sections/opts.ts` (`acrylicLabel`, `acrylicSub` for High Contrast), + `settings/sections/TerminalThemeSection.tsx` (label, row above `afterTheme`, High Contrast off), + `settings/TerminalAppearance.tsx` (label; legacy, Prism-only in practice), `settings/coreIndex.ts` + (label), `settings/theme/ThemeWall.tsx` (order; editor alpha cap and in-force acrylic), + `settings/theme/useTermSetup.ts` (dirty check by the same rule). +- Prism-visible effect: only the terminal theme wall's order. Everything else is gated on + `acrylic.kind === 'window'`, or is a host's own rows. + +**src/ (PT only; app 0.37.0):** `App.tsx` (paintChrome reads `termAcrylicInForce`; MRU), +`lib/tabMru.ts`, `lib/tabSwitchPrefs.ts` (new), `components/settings/AppearancePage.tsx` +(Background alpha in force and cap; Tab switching row), `appOptions.ts` (`tab-switch`), +`settingsIndex.ts` (`ROW_ORDER`). + +## Tests + +Unit (vitest): +- `core/renderer/lib/seeThrough.test.ts`: dark and light bytes, the 0.4 luminance edge, a + see-through hex reads its own opaque colour. +- `core/renderer/lib/termLook` / `host.test.ts`: window host + switch on + preset = default alpha + (dark preset 0xb9, light preset 0xd1); picked Background with alpha keeps it; switch off = 1; + style host (Prism) unchanged; High Contrast in force = acrylic not in force. +- `core/renderer/lib/themeOrder.test.ts`: permutation of the input; every neutral before every + coloured; L non-decreasing in each group; the measured neutral list exactly; no ground within + 0.001 of the threshold; the full id order as a snapshot; stable on ties. +- `core/renderer/settings/sections/opts` test: label and sub per host kind. +- `src/renderer/src/lib/chromeTheme.test.ts`: every preset at its default see-through alpha, text + 4.5:1 on the composite. +- `src/renderer/src/lib/tabMru.test.ts`: touch, sync (closed leave, new enter), single flip, walk + three back, Shift walks the other way, wrap, a walk over a list of one does nothing. +- `src/renderer/src/lib/tabSwitchPrefs.test.ts`: default, validation, notify. +- `appOptions.test.ts` / `settingsCopy.test.ts` / settingsIndex order test: the new row. + +e2e (`tools/e2e/run.mjs`): +- New **`seeThrough`**: on a dark preset, the switch alone paints `--p-bg` at alpha `b9`; on Paper + at `d1`; the Background picker's Alpha reads 73 / 82 and is capped at 95; a picked alpha of 60 + wins; off is solid; High Contrast shows the row disabled and the window solid; the row's label is + "See-through window" and it sits right under the wall; screenshot `.e2e-shots/see-through.png`. +- New **`tabSwitch`**: default In order still steps the strip; set Most recent; visit A, B, C; + Ctrl+Tab lands on B, again on C (no ping-pong); hold Ctrl, Tab, Tab lands on A, release; then + Ctrl+Tab goes to C; Ctrl+Shift+Tab walks the other way; closing a tab removes it; a new tab enters. +- **`themeCards`**: the wall's DOM order equals `[pt-default, ...orderTermThemes(rest)]` (Custom + absent in a fresh profile); keeps the `theme-wall.png` screenshot. +- **`settingsLook`** (line ~3572 `want`): Appearance rows gain `tab-switch` and `term-acrylic` moves + up; the acrylic contrast check now runs over real glass. +- **`options`**: picks up the new app row from `APP_OPTIONS`; adjust if it hardcodes the theme + section's slot order. + +Gate scenarios: `seeThrough tabSwitch themeCards themeSwitch settingsLook settingsSearch +settingsKeys options opacityAlpha accent pickedGround colourPicker theme reviewKeys tabWidth +tabStyle`, then the full `npm run e2e` once, alone. LOOK at `.e2e-shots/settings-appearance-*.png`, +`see-through.png` and `theme-wall.png`. + +## Docs + +- `README.md`: features row "Themes that dress the window" says see-through window; the keys table + `Ctrl+Tab` row says "next / previous tab, in strip order or most recently used". +- `docs/two-apps.md`: the theme order rule (FORTY THEMES paragraph); the #114 paragraph gets "on + means see-through: an opaque ground under the switch paints Prism's levels"; the Appearance order + line (the see-through row now under the wall). +- `core/README.md`: the acrylic contract paragraph (default see-through, `seeThroughAlpha`, + `termAcrylicInForce`, `acrylicLabel`), and `orderTermThemes`. +- `docs/regression-rules.md`: new entry **"Most recent commits on Ctrl release"** (the walk is a + snapshot; the order updates only when Ctrl is released or the window loses focus; held by + `tabMru.test.ts` and the `tabSwitch` e2e). From c6081892e256010578b7caec6c86e33ff970f6a2 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:39:32 +0200 Subject: [PATCH 2/8] feat(core): the see-through window, like Prism's (#156) The acrylic row is the see-through window. Where the terminal owns the window acrylic (Prism Terminal) it reads "See-through window" / "The desktop shows behind every surface.", sits right under the theme wall, and the switch alone makes the window see-through: an opaque ground under it paints Prism's own levels (0xb9 dark, 0xd1 light, light by luminance > 0.4). Before, it changed the material and left every preset opaque, so it looked broken. - core/renderer/lib/seeThrough.ts: SEE_THROUGH_ALPHA, SEE_THROUGH_MAX (95%), seeThroughAlpha. - termLook: paintsAlpha (one rule for the window and the dirty check), termAcrylicInForce / seeThroughBlocked (High Contrast stays solid). - The Background picker and the theme editor cap Alpha at 95% under the switch and show the alpha in force. - Prism: unchanged (gated on acrylic.kind 'window'; its row keeps its label). - opacityMigration tests: three asserted an opaque window under acrylic on; that is the bug fixed here, so they now expect the default see-through. - Versions: core 0.31.0, app 0.37.0. - e2e: new seeThrough scenario, settingsLook row order (not run here). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- README.md | 2 +- core/README.md | 8 ++ core/package.json | 2 +- core/renderer/host.test.ts | 73 +++++++++++++- core/renderer/lib/seeThrough.test.ts | 28 ++++++ core/renderer/lib/seeThrough.ts | 34 +++++++ core/renderer/lib/termLook.ts | 62 +++++++++++- core/renderer/settings/TerminalAppearance.tsx | 8 +- core/renderer/settings/coreIndex.ts | 5 +- .../sections/TerminalThemeSection.tsx | 35 ++++--- core/renderer/settings/sections/opts.test.ts | 61 ++++++++++++ core/renderer/settings/sections/opts.ts | 23 ++++- core/renderer/settings/theme/ThemeWall.tsx | 19 +++- core/renderer/settings/theme/useTermSetup.ts | 17 +++- docs/two-apps.md | 18 +++- package-lock.json | 4 +- package.json | 2 +- src/renderer/src/App.tsx | 7 +- .../components/settings/AppearancePage.tsx | 37 ++++++-- .../src/components/settings/settingsIndex.ts | 2 +- src/renderer/src/lib/chromeTheme.test.ts | 15 ++- src/renderer/src/lib/opacityMigration.test.ts | 14 ++- tools/e2e/run.mjs | 95 ++++++++++++++++++- 23 files changed, 516 insertions(+), 55 deletions(-) create mode 100644 core/renderer/lib/seeThrough.test.ts create mode 100644 core/renderer/lib/seeThrough.ts create mode 100644 core/renderer/settings/sections/opts.test.ts diff --git a/README.md b/README.md index 316acd9..5bdf628 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ session comes back when you reopen the app. | **Sessions that come back** | Close the app and reopen it: every tab returns in its folder, and agent conversations resume on their own. | | **Local dictation** | Hold `Right Alt` and speak. [whisper.cpp](https://github.com/ggml-org/whisper.cpp) runs on your PC, offline, and never presses Enter. | | **Command help** | Press `F1`, describe a task in plain words and copy the command. Works offline. | -| **Themes that dress the window** | About forty themes, a theme editor, fifteen fonts and acrylic. The whole window follows the theme. | +| **Themes that dress the window** | About forty themes, a theme editor, fifteen fonts and a see-through window. The whole window follows the theme. | | **Made for AI CLIs** | Images paste into Claude Code, dropped files type their path, and `Shift+Enter` is a newline. | | **Asks before it interrupts** | Closing a tab with a running agent asks first. A plain shell just closes. | | **Open terminal here** | Right-click any folder in Explorer to open it in a new tab. | diff --git a/core/README.md b/core/README.md index 83d1b6e..f2606ee 100644 --- a/core/README.md +++ b/core/README.md @@ -137,6 +137,14 @@ there is no Opacity row and no `termOpacity`/`setTermOpacity`: where `acrylic.kind` is 'window' the theme Background's alpha is the window's see-through (`termGroundAlpha`, `withGroundAlpha`, `legacyTermOpacity` for a host's one-time migration); `TERMINAL_OPTIONS` is the same list in every host. +Since 0.31.0 (#156) the switch alone makes such a window see-through: an +opaque ground in force under it paints Prism's default levels +(`seeThroughAlpha`, 0xb9 dark / 0xd1 light, `lib/seeThrough.ts`) through +`paintsAlpha`, the one rule `termGroundAlpha` and Save changes' dirty check +share; `termAcrylicInForce` / `useTermAcrylicInForce` is the switch with High +Contrast held solid (`seeThroughBlocked`), which is what a host paints by; the +row is named by `acrylicLabel()` ("See-through window" there, the list's label +in Prism) and drawn right under the wall, before `afterTheme`. Spec: `docs/superpowers/specs/2026-10-03-colour-picker-alpha-design.md`. diff --git a/core/package.json b/core/package.json index 342a3ba..4b7eac8 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.30.0", + "version": "0.31.0", "description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.", "license": "MIT", "private": true, diff --git a/core/renderer/host.test.ts b/core/renderer/host.test.ts index acfd279..7c90fb8 100644 --- a/core/renderer/host.test.ts +++ b/core/renderer/host.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { configureTermCore, resetTermCore, termHost, type TermApi, type TermHostConfig } from './host' -import { agentColorChoice, agentIndicator, saveCustomTermTheme, setTermThemeId, termAcrylic, termExtraDefaults, termGroundAlpha, termThemeId, withGroundAlpha } from './lib/termLook' +import { agentColorChoice, agentIndicator, paintsAlpha, saveCustomTermTheme, setTermAcrylic, setTermThemeId, termAcrylic, termAcrylicInForce, termExtraDefaults, termGroundAlpha, termThemeId, withGroundAlpha } from './lib/termLook' import { CH } from '../shared/channels' // The seam is what lets ONE terminal serve two apps. These tests are the two @@ -117,3 +117,74 @@ describe('the ground alpha a saved setup carries', () => { expect(withGroundAlpha(palette)).toEqual(palette) }) }) + +// #156: the switch alone makes the window see-through where the terminal owns +// the window acrylic, as Prism's "See-through window" does. An opaque ground +// in force paints Prism's default, measured light or dark; a ground that +// carries its own alpha keeps it; High Contrast stays solid. +describe('the see-through window (#156)', () => { + afterEach(() => resetTermCore()) + beforeEach(() => localStorage.clear()) + + it('switch on over a preset: the default, by the ground light or dark', () => { + configureTermCore(PRISM_TERMINAL) + setTermThemeId('dracula') + expect(termGroundAlpha()).toBe(1) + setTermAcrylic(true) + expect(termGroundAlpha()).toBe(0xb9 / 255) + setTermThemeId('paper') + setTermAcrylic(true) + expect(termGroundAlpha()).toBe(0xd1 / 255) + }) + it('an opaque picked Background is measured, not the theme under it', () => { + let picked: string | null = '#ffffff' + configureTermCore({ ...PRISM_TERMINAL, terminalGround: () => picked }) + setTermThemeId('dracula') + setTermAcrylic(true) + expect(termGroundAlpha()).toBe(0xd1 / 255) + // A picked alpha wins over the default: the Alpha slider still tunes it. + picked = '#ffffff99' + expect(termGroundAlpha()).toBe(0x99 / 255) + }) + it('an opaque Custom paints the default; one with an alpha keeps it', () => { + configureTermCore(PRISM_TERMINAL) + saveCustomTermTheme({ bg: '#111111', fg: '#eeeeee', cursor: '#ff0000', ansi: {} }) + setTermThemeId('custom') + setTermAcrylic(true) + expect(termGroundAlpha()).toBe(0xb9 / 255) + saveCustomTermTheme({ bg: '#11111180', fg: '#eeeeee', cursor: '#ff0000', ansi: {} }) + expect(termGroundAlpha()).toBe(0x80 / 255) + }) + it('High Contrast stays solid, and the stored choice comes back after it', () => { + configureTermCore(PRISM_TERMINAL) + setTermThemeId('high-contrast') + setTermAcrylic(true) + expect(termAcrylic()).toBe(true) + expect(termAcrylicInForce()).toBe(false) + expect(termGroundAlpha()).toBe(1) + setTermThemeId('dracula') + expect(termAcrylicInForce()).toBe(true) + }) + it('Save as Custom carries the default, so a saved setup stays see-through', () => { + configureTermCore(PRISM_TERMINAL) + setTermThemeId('dracula') + setTermAcrylic(true) + expect(withGroundAlpha({ bg: '#1e1f29' }).bg).toBe('#1e1f29b9') + }) + it('in Prism, where the style owns the glass, nothing changes', () => { + configureTermCore(PRISM) + setTermThemeId('dracula') + setTermAcrylic(true) + expect(termGroundAlpha()).toBe(1) + setTermThemeId('high-contrast') + expect(termAcrylicInForce()).toBe(true) + }) + it('paintsAlpha: the one rule the window and the dirty check share', () => { + expect(paintsAlpha(1, '#000000', false)).toBe(1) + expect(paintsAlpha(1, '#000000', true)).toBe(0xb9 / 255) + expect(paintsAlpha(1, '#ffffff', true)).toBe(0xd1 / 255) + expect(paintsAlpha(0x80 / 255, '#ffffff', true)).toBe(0x80 / 255) + // The 30% floor, on or off. + expect(paintsAlpha(0x05 / 255, '#000000', true)).toBe(0x4d / 255) + }) +}) diff --git a/core/renderer/lib/seeThrough.test.ts b/core/renderer/lib/seeThrough.test.ts new file mode 100644 index 0000000..f6d365f --- /dev/null +++ b/core/renderer/lib/seeThrough.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { SEE_THROUGH_ALPHA, SEE_THROUGH_MAX, seeThroughAlpha } from './seeThrough' + +describe('the default see-through (#156)', () => { + it("is Prism's two levels as they paint: 0xb9 on dark, 0xd1 on light", () => { + expect(Math.round(SEE_THROUGH_ALPHA.dark * 255)).toBe(0xb9) + expect(Math.round(SEE_THROUGH_ALPHA.light * 255)).toBe(0xd1) + expect(seeThroughAlpha('#0b0b0f')).toBe(SEE_THROUGH_ALPHA.dark) + expect(seeThroughAlpha('#ffffff')).toBe(SEE_THROUGH_ALPHA.light) + }) + it('light is measured: relative luminance above 0.4', () => { + // #aaaaaa is 0.402, #a9a9a9 is 0.397: either side of the edge. + expect(seeThroughAlpha('#aaaaaa')).toBe(SEE_THROUGH_ALPHA.light) + expect(seeThroughAlpha('#a9a9a9')).toBe(SEE_THROUGH_ALPHA.dark) + }) + it('a see-through colour reads by its own opaque colour, not its composite', () => { + expect(seeThroughAlpha('#ffffff20')).toBe(SEE_THROUGH_ALPHA.light) + expect(seeThroughAlpha('#00000020')).toBe(SEE_THROUGH_ALPHA.dark) + }) + it('an unparseable ground reads as dark', () => { + expect(seeThroughAlpha('soup')).toBe(SEE_THROUGH_ALPHA.dark) + }) + it("the cap sits below opaque and above both defaults, so Alpha and the switch agree", () => { + expect(Math.round(SEE_THROUGH_MAX * 255)).toBe(0xf2) + expect(SEE_THROUGH_MAX).toBeGreaterThan(SEE_THROUGH_ALPHA.light) + expect(SEE_THROUGH_MAX).toBeLessThan(1) + }) +}) diff --git a/core/renderer/lib/seeThrough.ts b/core/renderer/lib/seeThrough.ts new file mode 100644 index 0000000..aa36e3f --- /dev/null +++ b/core/renderer/lib/seeThrough.ts @@ -0,0 +1,34 @@ +import { opaque } from './colour' +import { luminance } from './termAnsi' + +/** + * THE SEE-THROUGH WINDOW'S DEFAULT (#156; owner, 2026-10-10: "add support for + * the see through window setting ... its in prism in style settings i want it + * here too"). Where the terminal owns the window acrylic (Prism Terminal), the + * switch alone makes the window see-through, as Prism's does: an OPAQUE ground + * under it paints at Prism's own levels, as they paint. + * + * Prism's `SEE_THROUGH_LEVEL` is 70 on a dark style and 49 on a light one + * (the levels Glacier and Orchid paint). A level paints + * `glass = 0.85 - level / 100 * 0.55`, alpha `1 - (1 - glass * 0.75) ^ 3`: + * level 70 is 0.724, the byte 0xb9; level 49 is 0.820, the byte 0xd1. Stored + * as those bytes so the window and the Alpha field name the same value. + */ +export const SEE_THROUGH_ALPHA = { dark: 0xb9 / 255, light: 0xd1 / 255 } as const + +/** + * The highest Alpha a ground may carry while the switch is on: 95%, the byte + * 0xf2. Opaque is the switch's OFF (Prism's rule: the switch and the alpha + * always agree), and an Alpha of 100 under the switch would read back as the + * default and make the slider jump. + */ +export const SEE_THROUGH_MAX = 0xf2 / 255 + +/** + * The default see-through for a ground: light or dark MEASURED from its own + * opaque colour (relative luminance above 0.4, the test chromeTheme uses for + * `data-mode`), never read off a theme's name. Unparseable reads as dark. + */ +export function seeThroughAlpha(ground: string): number { + return luminance(opaque(ground)) > 0.4 ? SEE_THROUGH_ALPHA.light : SEE_THROUGH_ALPHA.dark +} diff --git a/core/renderer/lib/termLook.ts b/core/renderer/lib/termLook.ts index 487f345..5d2f30f 100644 --- a/core/renderer/lib/termLook.ts +++ b/core/renderer/lib/termLook.ts @@ -9,7 +9,12 @@ import { type AgentIndicator } from '../host' import { liveThemeId } from './termThemeRetired' -import { alphaOf, parseColour, toStored, withAlpha } from './colour' +import { alphaOf, opaque, parseColour, toStored, withAlpha } from './colour' +import { seeThroughAlpha } from './seeThrough' +// A CYCLE, and a safe one: termTheme imports this module too, and neither reads +// the other at module load (only inside functions, at call time), so either may +// be evaluated first. +import { resolveTermTheme } from './termTheme' export type { AgentIndicator } @@ -419,13 +424,59 @@ function stored(c: unknown): string | null { * they write, but a stored or hand-edited `#12121205` must not paint an all * but invisible window. The floor is the byte 30% gives (0x4d), so every * value a picker or the migration writes reads unchanged. + * + * And there, with the see-through switch in force, an OPAQUE ground paints + * the default see-through (#156, `paintsAlpha`): a preset is no longer 1. */ export function termGroundAlpha(): number { const a = rawGroundAlpha() - return hostOwnsWindowAcrylic() ? Math.max(GROUND_ALPHA_MIN, a) : a + if (!hostOwnsWindowAcrylic()) return a + // The ground is only looked up when the default can apply: resolving a + // theme on every read of an alpha that needs none would be waste. + const inForce = termAcrylicInForce() + return paintsAlpha(a, inForce && isOpaque(a) ? groundInForce() : '#000000', inForce) } /** The lowest window see-through: Opacity 30's byte, round(0.3 * 255). */ export const GROUND_ALPHA_MIN = 0x4d / 255 + +/** + * THE ONE RULE for what a ground paints where the terminal owns the window + * acrylic (#156), shared by the window (`termGroundAlpha`) and Save changes' + * dirty check (`useTermSetup`), so the two can never disagree about a saved + * setup. With the switch on, an OPAQUE ground paints the default see-through + * (Prism's levels, by the ground light or dark): on means see-through by + * itself, as Prism's switch does, where before #156 a preset stayed solid and + * the switch looked broken. A ground that carries an alpha keeps it, floored + * at 30% as the Opacity slider was. + */ +export function paintsAlpha(raw: number, ground: string, acrylicOn: boolean): number { + if (acrylicOn && isOpaque(raw)) return seeThroughAlpha(ground) + return Math.max(GROUND_ALPHA_MIN, raw) +} +/** Opaque BY THE BYTE: an alpha that stores as ff is opaque. */ +const isOpaque = (a: number): boolean => Math.round(a * 255) >= 255 + +/** The ground in force, opaque, in the order the window paints it: the + * host's picked one, else the theme's own (a Custom's `bg` included). */ +function groundInForce(): string { + return opaque(hostGround() ?? resolveTermTheme(termThemeId()).background) +} + +/** + * Whether the window is see-through RIGHT NOW: the switch, except that High + * Contrast stays solid where the terminal owns the window acrylic (#156, + * Prism's rule: its contrast is measured on a solid ground, and glass would + * put an unmeasurable desktop under the text; this owner works zoomed in and + * relies on it). The stored choice is kept, so leaving High Contrast gives it + * back. In Prism the style owns the glass and the switch is read as it is. + */ +export function termAcrylicInForce(): boolean { + return termAcrylic() && !seeThroughBlocked() +} +/** The theme in force keeps the window solid whatever the switch says. */ +export function seeThroughBlocked(): boolean { + return hostOwnsWindowAcrylic() && termThemeId() === 'high-contrast' +} function rawGroundAlpha(): number { const picked = hostGround() if (picked) return alphaOf(picked) @@ -474,6 +525,13 @@ export function useTermFontId(): string { export function useTermAcrylic(): boolean { return useSyncExternalStore(sub, termAcrylic) } +/** The see-through in force (High Contrast stays solid, #156). */ +export function useTermAcrylicInForce(): boolean { + return useSyncExternalStore(sub, termAcrylicInForce) +} +export function useSeeThroughBlocked(): boolean { + return useSyncExternalStore(sub, seeThroughBlocked) +} /** The ground's alpha, re-read when the look OR the host's window colours * change (a picked background is the host's, not this store's). */ export function useTermGroundAlpha(): number { diff --git a/core/renderer/settings/TerminalAppearance.tsx b/core/renderer/settings/TerminalAppearance.tsx index 82f5953..c0252ac 100644 --- a/core/renderer/settings/TerminalAppearance.tsx +++ b/core/renderer/settings/TerminalAppearance.tsx @@ -22,6 +22,7 @@ import { ColourField } from './ColourPicker' import { AgentIndicatorSetting, AttentionSettings } from './TerminalBehaviour' import { ThemeWall } from './theme/ThemeWall' import { useNoAcrylic, useTermSetup } from './theme/useTermSetup' +import { acrylicLabel } from './sections/opts' // THE TERMINAL'S LOOK, as one settings section for both hosts (#15): the theme // wall and its editor, font, size, acrylic, and the two agent indicator @@ -76,6 +77,9 @@ export function TerminalAppearanceSettings({ // The theme Background's alpha is the window's see-through here (#114). const windowAcrylic = hostOwnsWindowAcrylic() const noAcrylic = useNoAcrylic() + // Where the terminal owns the window acrylic, the see-through window (#156); + // in Prism the name this row always had. + const acrylicName = windowAcrylic ? acrylicLabel() : 'Acrylic background' return (
{/* WHAT NO THEME OWNS COMES FIRST (owner, 2026-09-28: font and font size @@ -133,7 +137,7 @@ export function TerminalAppearanceSettings({ why instead of offering a switch that would do nothing. */} diff --git a/core/renderer/settings/coreIndex.ts b/core/renderer/settings/coreIndex.ts index 2bdbb2d..5027e41 100644 --- a/core/renderer/settings/coreIndex.ts +++ b/core/renderer/settings/coreIndex.ts @@ -5,7 +5,7 @@ import { MARK_OPTIONS } from './markOptions' import { terminalOptionIds } from './options' import type { SettingsIndexEntry } from './layout/search' import { SETTINGS_SECTIONS, type SettingsSectionId } from './sectionIds' -import { acrylicSub, opt, themeSub } from './sections/opts' +import { acrylicLabel, acrylicSub, opt, themeSub } from './sections/opts' /** * THE CORE'S ROWS FOR FIND A SETTING (2026-10-05): one entry per terminal, @@ -48,7 +48,8 @@ export function coreSettingsIndex({ page: pageOf(section), section: SETTINGS_SECTIONS[section] || '', sectionId: section, - label: o.label, + // The see-through row is named per host (#156), as the page names it. + label: id === 'term-acrylic' ? acrylicLabel() : o.label, sub, icon: o.icon, keywords: o.keywords diff --git a/core/renderer/settings/sections/TerminalThemeSection.tsx b/core/renderer/settings/sections/TerminalThemeSection.tsx index e8d453e..7450881 100644 --- a/core/renderer/settings/sections/TerminalThemeSection.tsx +++ b/core/renderer/settings/sections/TerminalThemeSection.tsx @@ -1,27 +1,29 @@ import type { JSX, ReactNode } from 'react' -import { setTermAcrylic, useTermAcrylic } from '../../lib/termLook' +import { setTermAcrylic, useSeeThroughBlocked, useTermAcrylic } from '../../lib/termLook' import { SaveButton, Switch } from '../fields' import { SettingBlock } from '../layout/SettingBlock' import { SettingRow } from '../layout/SettingRow' import { SettingsSection } from '../layout/SettingsSection' import { ThemeWall } from '../theme/ThemeWall' import { useNoAcrylic, useTermSetup } from '../theme/useTermSetup' -import { acrylicSub, opt, sectionTitle, themeSub } from './opts' +import { acrylicLabel, acrylicSub, opt, sectionTitle, themeSub } from './opts' /** What the Save changes button says it does, in both places it is drawn. */ export const SAVE_SETUP_TITLE = 'Saves the theme, agent colours and acrylic as Custom' /** * THE THEME, as one section (2026-10-05): the theme row with Save changes, the - * wall of cards, the host's own rows that a theme sets (Prism Terminal: - * Background and Accent), and acrylic. Under the wall only what a theme DOES - * set (2026-09-28); the font and the indicator live in their own sections. + * wall of cards, acrylic, and the host's own rows that a theme sets (Prism + * Terminal: Background and Accent). Under the wall only what a theme DOES set + * (2026-09-28); the font and the indicator live in their own sections. + * Acrylic comes FIRST under the wall (#156), as Prism's "See-through window" + * does: see-through first, then the theme's colours. */ export function TerminalThemeSection({ afterTheme, onThemePicked }: { - /** The host's own rows that a theme sets, right under the wall. */ + /** The host's own rows that a theme sets, under the wall and acrylic. */ afterTheme?: ReactNode /** A card was picked (Custom included), after the pick landed. */ onThemePicked?: () => void @@ -31,6 +33,8 @@ export function TerminalThemeSection({ const noAcrylic = useNoAcrylic() const theme = opt('term-theme') const acrylic = opt('term-acrylic') + const label = acrylicLabel() + const blocked = useSeeThroughBlocked() return ( @@ -39,19 +43,26 @@ export function TerminalThemeSection({ - {afterTheme} {/* The material does not exist before Windows 11, so there the row says - why instead of offering a switch that would do nothing. */} + why instead of offering a switch that would do nothing. High Contrast + keeps the window solid (#156), and says so the same way; the stored + choice is kept for the next theme. */} - + + {afterTheme} ) } diff --git a/core/renderer/settings/sections/opts.test.ts b/core/renderer/settings/sections/opts.test.ts new file mode 100644 index 0000000..138818c --- /dev/null +++ b/core/renderer/settings/sections/opts.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { configureTermCore, resetTermCore, type TermHostConfig } from '../../host' +import { setTermAcrylic, setTermThemeId, seeThroughBlocked } from '../../lib/termLook' +import { coreSettingsIndex } from '../coreIndex' +import { acrylicLabel, acrylicSub, opt } from './opts' + +// #156: where the terminal owns the window acrylic (Prism Terminal), the +// acrylic row IS the see-through window and is worded as Prism words its own; +// in Prism it stays the terminal's row under the app's see-through. + +const base = { + api: {} as TermHostConfig['api'], + themedAgentColors: () => ({ working: '#5b5bd6', finished: '#22c55e' }), + ownsKey: () => false +} +const WINDOW: TermHostConfig = { + ...base, + defaults: { theme: 'prism', acrylic: false, indicator: 'minimal', agentColor: '', agentDoneColor: '' }, + followsHostStyle: false, + paintsGround: true, + acrylic: { kind: 'window', supported: async () => true } +} +const STYLE: TermHostConfig = { + ...base, + defaults: { theme: 'style', acrylic: true, indicator: 'full', agentColor: '#f97316', agentDoneColor: '#22c55e' }, + followsHostStyle: true, + paintsGround: false, + acrylic: { kind: 'style' } +} + +describe('the acrylic row, per host (#156)', () => { + beforeEach(() => localStorage.clear()) + afterEach(() => resetTermCore()) + + it("Prism Terminal: Prism's own name and words", () => { + configureTermCore(WINDOW) + expect(acrylicLabel()).toBe('See-through window') + expect(acrylicSub()).toBe('The desktop shows behind every surface.') + }) + it('Prism: the terminal row keeps its name and words', () => { + configureTermCore(STYLE) + expect(acrylicLabel()).toBe(opt('term-acrylic').label) + expect(acrylicSub()).toBe('The desktop shows through the terminal.') + }) + it('High Contrast says why it stays solid, only where it does', () => { + configureTermCore(WINDOW) + setTermAcrylic(true) + setTermThemeId('high-contrast') + expect(seeThroughBlocked()).toBe(true) + expect(acrylicSub(true)).toBe('High contrast stays solid.') + configureTermCore(STYLE) + expect(seeThroughBlocked()).toBe(false) + }) + it('Find a setting words the row as the page does', () => { + configureTermCore(WINDOW) + const row = coreSettingsIndex({ pageOf: () => 'appearance', nvidia: false }).find((e) => e.id === 'term-acrylic') + expect(row).toMatchObject({ label: 'See-through window', sub: 'The desktop shows behind every surface.' }) + // "see through" still finds it by its keywords in Prism's wording too. + expect(row?.keywords).toContain('see through') + }) +}) diff --git a/core/renderer/settings/sections/opts.ts b/core/renderer/settings/sections/opts.ts index 98840fa..7fd7bf9 100644 --- a/core/renderer/settings/sections/opts.ts +++ b/core/renderer/settings/sections/opts.ts @@ -32,7 +32,24 @@ export const sectionTitle = (id: SettingsSectionId): string => SETTINGS_SECTIONS export const themeSub = (): string => followsHostStyle() ? 'Colours of the terminal text and ground.' : opt('term-theme').sub +/** + * THE SEE-THROUGH WINDOW (#156; owner, 2026-10-10: "its in prism in style + * settings i want it here too"). Where the terminal owns the window acrylic + * (Prism Terminal) this row IS the see-through window, so it wears the name + * and words of Prism's own row. In Prism it stays the terminal's acrylic, a + * row under the app's "See-through window": two rows of one name would be + * wrong there. `options.ts` keeps Prism's label, which Prism's gate reads. + */ +export const acrylicLabel = (): string => + hostOwnsWindowAcrylic() ? 'See-through window' : opt('term-acrylic').label + /** The acrylic row's subtext, by what acrylic means in this host: the - * window's own material, or the app style's let through the terminal. */ -export const acrylicSub = (): string => - hostOwnsWindowAcrylic() ? opt('term-acrylic').sub : 'The desktop shows through the terminal.' + * window's own material, or the app style's let through the terminal. + * `blocked`: the theme in force keeps the window solid (High Contrast, + * `seeThroughBlocked`), which the row says rather than look broken. */ +export const acrylicSub = (blocked = false): string => + !hostOwnsWindowAcrylic() + ? 'The desktop shows through the terminal.' + : blocked + ? 'High contrast stays solid.' + : 'The desktop shows behind every surface.' diff --git a/core/renderer/settings/theme/ThemeWall.tsx b/core/renderer/settings/theme/ThemeWall.tsx index 3a0507e..2248f8e 100644 --- a/core/renderer/settings/theme/ThemeWall.tsx +++ b/core/renderer/settings/theme/ThemeWall.tsx @@ -7,7 +7,7 @@ import { setTermThemeId, termThemeId, useCustomTermTheme, - useTermAcrylic, + useTermAcrylicInForce, useTermThemeId, withGroundAlpha, type CustomTermTheme @@ -15,6 +15,7 @@ import { import { resolveCustomTheme, resolveTermTheme, watchTermTheme, TERM_PRESETS } from '../../lib/termTheme' import { luminance, normalizeColor } from '../../lib/termAnsi' import type { AlphaRange } from '../../lib/colour' +import { SEE_THROUGH_MAX } from '../../lib/seeThrough' import { ColourField } from '../ColourPicker' import ThemeSwitchAsk from '../../components/ThemeSwitchAsk' import { ANSI_KEYS, cardAnsi, paletteOf, pickPreset, presetLook } from './palette' @@ -284,7 +285,8 @@ const TWO_ROWS = 268 */ export function ThemeWall({ onThemePicked, className = '' }: { onThemePicked?: () => void; className?: string }): JSX.Element { const themeId = useTermThemeId() - const acrylicOn = useTermAcrylic() + // In force, not stored: High Contrast stays solid (#156), so its Alpha is inert. + const acrylicOn = useTermAcrylicInForce() const custom = useCustomTermTheme() const { dirty, save, extras } = useTermSetup() const noAcrylic = useNoAcrylic() @@ -445,7 +447,18 @@ export function ThemeWall({ onThemePicked, className = '' }: { onThemePicked?: ( {editing && ( { // The Custom slot is the WHOLE setup (code review 2026-09-24, // #8): saved as a bare palette, the agent colours and diff --git a/core/renderer/settings/theme/useTermSetup.ts b/core/renderer/settings/theme/useTermSetup.ts index 79b6816..e40ccff 100644 --- a/core/renderer/settings/theme/useTermSetup.ts +++ b/core/renderer/settings/theme/useTermSetup.ts @@ -3,6 +3,7 @@ import { hostOwnsWindowAcrylic, termHost } from '../../host' import { saveCustomTermTheme, setTermThemeId, + paintsAlpha, termExtraDefaults, termThemeId, useAgentColorChoice, @@ -15,6 +16,7 @@ import { withGroundAlpha } from '../../lib/termLook' import { alphaOf } from '../../lib/colour' +import { resolveTermTheme } from '../../lib/termTheme' import { paletteOf } from './palette' /** @@ -70,12 +72,17 @@ export function useTermSetup(): TermSetup { // THE UNSAVED-CHANGES QUESTION SURVIVES THE SLIDER (#114, #60). Opacity was // one of the extras, so a changed one lit Save changes and a theme pick // asked before forgetting it. Its place is taken by the ground's alpha in - // force against the theme's own (a preset is opaque, a Custom has its bg's): - // a see-through picked Background lights Save changes in the same way. - // Only where that alpha is the window's (Prism has no such alpha). - const ownByte = src ? Math.round(alphaOf(src.bg) * 255) : 255 + // force against what the theme's own setup paints: a see-through picked + // Background lights Save changes in the same way. Only where that alpha is + // the window's (Prism has no such alpha, and is not asked to resolve one). + // The theme's own is computed by the rule the window paints by (#156, + // `paintsAlpha`): an older Custom saved with acrylic on and an opaque `bg` + // paints the default see-through, so it is not dirty on pick. A preset's own + // setup has the host's default acrylic (off in Prism Terminal). + const ownByte = (): number => + Math.round(paintsAlpha(src ? alphaOf(src.bg) : 1, src?.bg ?? resolveTermTheme(themeId).background, baseline.acrylic) * 255) const dirty = - JSON.stringify(extras) !== JSON.stringify(baseline) || (hostOwnsWindowAcrylic() && groundByte !== ownByte) + JSON.stringify(extras) !== JSON.stringify(baseline) || (hostOwnsWindowAcrylic() && groundByte !== ownByte()) const save = (): void => { saveCustomTermTheme({ ...withGroundAlpha(paletteOf(termThemeId())), ...extras }) setTermThemeId('custom') diff --git a/docs/two-apps.md b/docs/two-apps.md index bc09c5b..b71c62d 100644 --- a/docs/two-apps.md +++ b/docs/two-apps.md @@ -315,8 +315,8 @@ so an update never silently changes what an existing user sees; the bridge to ma transcend" the theme's save, "so changing a theme should not reset the font and font size or if you use a minimal or full agent indicator, or edges. those options should be above the themes"). Since #134 Appearance runs: the Window section (Tab width, Show title bar, Panel edges), then the - Theme section (the theme row with Save changes, the wall, what a theme SETS: Background, Accent, - Acrylic). The font and its size are Terminal's Text section; the indicator and the agent colours + Theme section (the theme row with Save changes, the wall, what a theme SETS: See-through window, + Background, Accent; the see-through row right under the wall since #156, as in Prism). The font and its size are Terminal's Text section; the indicator and the agent colours are on Agents, where Mark colours carries the SAME Save changes (both save the whole setup and light together, `theme/useTermSetup.ts`). A theme switch and Save as Custom leave the font and its size alone (`termExtraDefaults`, `resetTermExtras`, @@ -368,7 +368,19 @@ so an update never silently changes what an existing user sees; the bridge to ma paint and maps a saved Opacity N to byte round(N / 100 * 255), which is exactly what N painted (snapshot `chromeTheme.opacity.test.ts`): the saved `Custom.opacity` folds into `Custom.bg`, the live value onto the picked Background. The ground alpha takes Opacity's place in Save changes' - dirty check, so a theme pick still asks first (#60). The Accent's alpha is for FILLS; + dirty check, so a theme pick still asks first (#60). + **ON MEANS SEE-THROUGH, BY ITSELF** (#156; owner, 2026-10-10, of Prism's "See-through window": + "i want it here too"). Before it the switch changed the material and left every preset's ground + opaque, so it looked broken. Now where `acrylic.kind` is 'window' an OPAQUE ground under the + switch paints Prism's own levels as they paint (level 70 dark = byte 0xb9, level 49 light = + 0xd1, light measured as luminance > 0.4; `lib/seeThrough.ts`, and `paintsAlpha` in `termLook.ts`, + shared by the window and Save changes' dirty check). A ground with an alpha keeps it; under the + switch the Alpha stops at 95% (`SEE_THROUGH_MAX`: opaque is the switch's off). High Contrast + stays solid (`termAcrylicInForce`; the row is drawn off with "High contrast stays solid."). + In this app the row reads "See-through window" / "The desktop shows behind every surface." + (`acrylicLabel`, `acrylicSub`); Prism keeps "Acrylic terminal background", a terminal row under + its own app-level See-through window, and nothing there changes. The `seeThrough` e2e holds it. + The Accent's alpha is for FILLS; `--p-accent-solid` is the line (rules, spinner, progress, rings, an unpicked working colour), `--p-on-accent` is chosen 4.5:1 on the composite (`selectionFor`), and under a see-through ground the text fills are flattened over `--p-bg-solid`. The `opacityAlpha` and `accent` e2e diff --git a/package-lock.json b/package-lock.json index 1485dc8..4098a73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.36.0", + "version": "0.37.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.36.0", + "version": "0.37.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index ba54f16..844be52 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.36.0", + "version": "0.37.0", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3bcf21c..cd93a5c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -58,7 +58,7 @@ import { } from '@core/renderer/lib/agentClose' import { onTermLookChange, - termAcrylic, + termAcrylicInForce, termFontStack, termGroundAlpha, termThemeId @@ -98,7 +98,9 @@ const nextId = (): string => 't' + Date.now().toString(36) + '-' + String((seq + * edges setting (#27) rides the same paint: it changes two of the tokens, and * main is told so the DWM border round the window follows the lines in it. */ function paintChrome(): void { - const acrylic = termAcrylic() + // IN FORCE, material and alpha both: High Contrast keeps the window solid + // whatever the switch says (#156). + const acrylic = termAcrylicInForce() const id = termThemeId() const edges = windowEdges() // A picked background replaces the theme's for the whole window; the panel @@ -109,6 +111,7 @@ function paintChrome(): void { // Opacity slider): the picked Background's, else the theme's own (a Custom // may carry one), the same order the core reads. Passed as the byte's // fraction, unrounded, so the field and the window name the same alpha. + // An opaque ground under the switch paints the default see-through (#156). const tokens = chromeTokens( background ? { ...theme, background } : theme, acrylic ? termGroundAlpha() : 1, diff --git a/src/renderer/src/components/settings/AppearancePage.tsx b/src/renderer/src/components/settings/AppearancePage.tsx index fcd15bd..bfa1dd4 100644 --- a/src/renderer/src/components/settings/AppearancePage.tsx +++ b/src/renderer/src/components/settings/AppearancePage.tsx @@ -6,7 +6,16 @@ import { SettingRow } from '@core/renderer/settings/layout/SettingRow' import { SettingsSection } from '@core/renderer/settings/layout/SettingsSection' import { TerminalThemeSection } from '@core/renderer/settings/sections/TerminalThemeSection' import { alphaOf, withAlpha, type AlphaRange } from '@core/renderer/lib/colour' -import { customTermTheme, onTermLookChange, termThemeId, useTermAcrylic } from '@core/renderer/lib/termLook' +import { + customTermTheme, + onTermLookChange, + termAcrylicInForce, + termGroundAlpha, + termThemeId, + useTermAcrylicInForce, + useTermGroundAlpha +} from '@core/renderer/lib/termLook' +import { SEE_THROUGH_MAX } from '@core/renderer/lib/seeThrough' import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' import { setWindowEdges, useWindowEdges } from '../../lib/edgesPrefs' import { setTabWidth, useTabWidth, type TabWidth } from '../../lib/tabWidthPrefs' @@ -70,9 +79,12 @@ const themeColours = (): string => { const themeBg = chromeTokens(theme, 1, presetAccent(id)).vars['--p-bg-solid'] // The theme's ground AS THE WINDOW PAINTS IT (#114): its solid colour at the // theme's own alpha (a Custom may carry one), so the row shows the - // see-through that is in force while nothing is picked. + // see-through that is in force while nothing is picked. Under the switch + // that is what the window paints, an opaque theme's default included (#156), + // so the Alpha reads 73 on a dark preset rather than 100. const own = id === 'custom' ? customTermTheme() : null - return `${vars['--p-accent']}|${withAlpha(themeBg, own ? alphaOf(own.bg) : 1)}` + const alpha = termAcrylicInForce() ? termGroundAlpha() : own ? alphaOf(own.bg) : 1 + return `${vars['--p-accent']}|${withAlpha(themeBg, alpha)}` } const onColoursChange = (cb: () => void): (() => void) => { const offs = [onTermLookChange(cb), onWindowBackgroundChange(cb)] @@ -92,6 +104,7 @@ function WindowColour({ id, sub, chosen, + shown = chosen, fromTheme, onPick, range @@ -99,6 +112,9 @@ function WindowColour({ id: 'window-background' | 'window-accent' sub: string chosen: string | null + /** What the field shows of the choice, where it paints otherwise than it + * is stored (an opaque Background under the see-through switch, #156). */ + shown?: string | null fromTheme: string onPick: (hex: string | null) => void /** The alpha this colour may carry (#114). */ @@ -114,7 +130,7 @@ function WindowColour({ )} {/* Escape in the picker puts back what was chosen when it opened, a row that followed the theme included (#112). */} - onPick(chosen)} {...range} /> + onPick(chosen)} {...range} /> ) } @@ -123,7 +139,10 @@ function WindowColours(): JSX.Element { const accent = useWindowAccent() const background = useWindowBackground() const [themeAccent, themeBg] = useSyncExternalStore(onColoursChange, themeColours).split('|') - const acrylicOn = useTermAcrylic() + // In force: High Contrast keeps the window solid (#156), so there the + // Alpha is as inert as with the switch off. + const acrylicOn = useTermAcrylicInForce() + const groundAlpha = useTermGroundAlpha() // The material is Windows 11's (1809 has none): where main says it cannot // be had, the window never shows the desktop, so the alpha is as inert as // with the switch off, the rule the theme editor's Background follows. @@ -144,14 +163,18 @@ function WindowColours(): JSX.Element { {/* THE BACKGROUND'S ALPHA IS THE WINDOW'S SEE-THROUGH (#114; owner, 2026-10-03: alpha "should be built into the colour pickers ... it should not be a separate opacity setting"). At least 30%, and inert - while acrylic is off, when the desktop does not show through. */} + while acrylic is off, when the desktop does not show through. + OPAQUE IS THE SWITCH'S OFF (#156, Prism's rule): under it an opaque + pick paints the default see-through and the field shows that, and + the Alpha stops at 95%, so the field and the window always agree. */} {/* The accent's alpha is for its FILLS; its lines stay solid. */} = { /** Every row in the order the pages draw them, which is the order Find a * setting lists matches in. A test holds this to the lists. */ export const ROW_ORDER = [ - 'tab-width', 'tab-style', 'title-bar', 'window-edges', 'term-theme', 'window-background', 'window-accent', 'term-acrylic', + 'tab-width', 'tab-style', 'title-bar', 'window-edges', 'term-theme', 'term-acrylic', 'window-background', 'window-accent', 'term-shell', 'newtab-mode', 'explorer-verb', 'term-font-family', 'term-font', 'help-enabled', 'agent-indicator', 'agent-done-on', 'agent-rainbow', 'agent-question-on', 'agent-failed-on', 'taskbar-badge', 'agent-hooks', 'agent-color', 'agent-done-color', 'agent-question-color', diff --git a/src/renderer/src/lib/chromeTheme.test.ts b/src/renderer/src/lib/chromeTheme.test.ts index 8e7b098..6e11a27 100644 --- a/src/renderer/src/lib/chromeTheme.test.ts +++ b/src/renderer/src/lib/chromeTheme.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { CHROME_COLOUR_TOKENS, chromeTokens } from './chromeTheme' -import { TERM_PRESETS, resolveTermTheme, type TermTheme } from '@core/renderer/lib/termTheme' +import { TERM_PRESETS, presetAccent, resolveTermTheme, type TermTheme } from '@core/renderer/lib/termTheme' +import { SEE_THROUGH_ALPHA, seeThroughAlpha } from '@core/renderer/lib/seeThrough' import { contrastRatio } from '@core/renderer/lib/termAnsi' import { composite } from '@core/renderer/lib/colour' import { WINDOW_EDGES } from '@shared/windowEdges' @@ -236,6 +237,18 @@ describe('a see-through accent', () => { const open = chromeTokens(resolveTermTheme(id), 1, undefined, 'hairline', '#e07a2f80').vars expect(vars['--p-sel-bg']).toBe(composite(open['--p-sel-bg'], vars['--p-bg-solid'])) }) + // #156: the switch alone paints every preset at Prism's default see-through, + // so every preset is held to its text there, on the theme's own accent. + it.each(TERM_PRESETS.map((p) => [p.id, p.bg]))('%s: legible at its default see-through', (id, bg) => { + const a = seeThroughAlpha(bg) + const { vars } = chromeTokens(resolveTermTheme(id), a, presetAccent(id)) + expect(vars['--p-bg']).toMatch(a === SEE_THROUGH_ALPHA.dark ? /^#[0-9a-f]{6}b9$/ : /^#[0-9a-f]{6}d1$/) + for (const t of ['--p-text', '--p-dim', '--p-on-accent', '--p-accent', '--p-sel-bg']) + expect(vars[t], t).toMatch(/^#[0-9a-f]{6}$/) + expect(contrastRatio(vars['--p-text'], vars['--p-bg-solid'])).toBeGreaterThanOrEqual(4.5) + expect(contrastRatio(vars['--p-on-accent'], vars['--p-accent'])).toBeGreaterThanOrEqual(4.5) + expect(contrastRatio(vars['--p-on-accent'], vars['--p-sel-bg'])).toBeGreaterThanOrEqual(4.5) + }) it('--p-bg-solid is always flat, whatever the ground alpha (main is only ever sent it)', () => { for (const a of [0.3, 0.5, 153 / 255, 1]) { expect(chromeTokens(resolveTermTheme('prism'), a).vars['--p-bg-solid']).toMatch(/^#[0-9a-f]{6}$/) diff --git a/src/renderer/src/lib/opacityMigration.test.ts b/src/renderer/src/lib/opacityMigration.test.ts index 93c4661..638731c 100644 --- a/src/renderer/src/lib/opacityMigration.test.ts +++ b/src/renderer/src/lib/opacityMigration.test.ts @@ -23,6 +23,10 @@ const custom = (extra: Record): string => /** Dracula's own ground, as the window paints it. */ const DRACULA = resolveTermTheme('dracula').background.slice(0, 7).toLowerCase() const saved = (): Record => JSON.parse(localStorage.getItem(CUSTOM) ?? 'null') +/** What an opaque dark ground paints with the switch on since #156: the + * migration leaves the ground opaque, and the switch alone makes it Prism's + * default see-through (an opaque window under acrylic on was the bug #156 fixes). */ +const DEFAULT_DARK = 0xb9 / 255 beforeEach(() => { localStorage.clear() @@ -89,25 +93,25 @@ describe('migrateOpacity', () => { // Review of #115: step 1 gave Custom.bg the saved alpha, and a live 100 was // skipped as "nothing to do", so an opaque window turned see-through. - it('a Custom saved at 60, left at a live 100 with acrylic on, stays opaque', () => { + it('a Custom saved at 60, left at a live 100 with acrylic on, keeps an opaque ground (not the saved 60)', () => { localStorage.setItem(THEME, 'custom') localStorage.setItem(ACRYLIC, '1') localStorage.setItem(LIVE, '100') localStorage.setItem(CUSTOM, custom({ acrylic: true, opacity: 60 })) migrateOpacity() - expect(termGroundAlpha()).toBe(1) + expect(termGroundAlpha()).toBe(DEFAULT_DARK) expect(windowBackground()).toBe('#1d1f21') // Picking Custom again still restores the SAVED see-through. expect(customTermTheme()?.bg).toBe('#1d1f21' + byte(60)) expect(localStorage.getItem(LIVE)).toBeNull() }) - it('a Custom saved at 60 with no live value stored (read as 100) stays opaque', () => { + it('a Custom saved at 60 with no live value stored (read as 100) keeps an opaque ground', () => { localStorage.setItem(THEME, 'custom') localStorage.setItem(ACRYLIC, '1') localStorage.setItem(CUSTOM, custom({ acrylic: true, opacity: 60 })) migrateOpacity() - expect(termGroundAlpha()).toBe(1) + expect(termGroundAlpha()).toBe(DEFAULT_DARK) const once = JSON.stringify({ ...localStorage }) migrateOpacity() expect(JSON.stringify({ ...localStorage })).toBe(once) @@ -119,7 +123,7 @@ describe('migrateOpacity', () => { localStorage.setItem(CUSTOM, custom({ acrylic: true, opacity: 60 })) migrateOpacity() expect(windowBackground()).toBeNull() - expect(termGroundAlpha()).toBe(1) + expect(termGroundAlpha()).toBe(DEFAULT_DARK) }) it('a Custom with a picked Background: the picked one takes the live value', () => { diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 2ec5ad7..630860f 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -3566,7 +3566,8 @@ const scenarios = { // and the agent rows have pages of their own; Appearance keeps the // window's rows above the theme and what a theme sets under it. const rows = await page.evaluate(() => [...document.querySelectorAll('[data-pref]')].map((e) => e.getAttribute('data-pref'))) - const want = ['tab-width', 'tab-style', 'title-bar', 'window-edges', 'term-theme', 'window-background', 'window-accent', 'term-acrylic'] + // The see-through row sits right under the wall since #156, as Prism's. + const want = ['tab-width', 'tab-style', 'title-bar', 'window-edges', 'term-theme', 'term-acrylic', 'window-background', 'window-accent'] ok(JSON.stringify(rows) === JSON.stringify(want), `Appearance runs ${want.join(' > ')} (${rows.join(' > ')})`) // Font size is 50% to 200% in tens. await gotoPref(page, 'term-font') @@ -4247,6 +4248,98 @@ const scenarios = { } }, + /** + * THE SEE-THROUGH WINDOW (#156; owner, 2026-10-10: "add support for the see + * through window setting ... its in prism in style settings i want it here + * too"). The acrylic row IS it: named and worded as Prism's, right under the + * wall, and the switch ALONE makes the window see-through at Prism's own + * levels (0xb9 on a dark ground, 0xd1 on a light one), where before it + * changed the material and left a preset's ground opaque. The Background's + * Alpha shows what is in force and stops at 95 (opaque is the switch's off); + * a picked alpha wins; off is solid; High Contrast stays solid. + */ + async seeThrough(ok) { + const w = world() + const { app, page } = await launch(w, { args: [w.alpha] }) + const sheet = () => page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--p-bg').trim().toLowerCase()) + const sw = page.locator('[data-pref="term-acrylic"] [role="switch"]') + const sub = () => page.locator('[data-pref="term-acrylic"] [title]').first().getAttribute('title') + const bgRow = page.locator('[data-pref="window-background"]') + const pop = page.locator('[data-colour-popover][role="dialog"]') + /** The Background picker's Alpha, read and closed again. */ + const alphaReads = async () => { + await bgRow.scrollIntoViewIfNeeded() + await bgRow.locator('[data-colour-swatch]').click() + await pop.waitFor({ timeout: 3000 }) + const a = pop.locator('[role="slider"][aria-label="Alpha"]') + const v = { now: await a.getAttribute('aria-valuenow'), max: await a.getAttribute('aria-valuemax') } + await page.keyboard.press('Escape') + await until(async () => (await pop.count()) === 0, 3000, 50) + return v + } + /** A theme pick, answering the unsaved-changes question if it asks. */ + const pick = async (id) => { + await page.locator(`[data-term-card="${id}"]`).first().click() + const ask = page.locator('[data-theme-switch-ask]') + if (await until(async () => (await ask.count()) === 1, 1500, 50)) await page.locator('[data-ask-discard]').click() + await until(async () => (await page.locator(`[data-term-card="${id}"]`).first().getAttribute('aria-pressed')) === 'true', 4000, 50) + } + try { + await gotoPref(page, 'term-acrylic') + const more = page.locator('button[aria-label^="Show all"]') + if ((await more.count()) === 1) await more.click() + // Named and placed as Prism's: right under the wall, before the colours. + const rows = await page.evaluate(() => [...document.querySelectorAll('[data-pref]')].map((e) => e.getAttribute('data-pref'))) + const at = rows.indexOf('term-theme') + ok(rows[at + 1] === 'term-acrylic' && rows[at + 2] === 'window-background', `the row sits right under the wall (${rows.join(' > ')})`) + ok((await sw.getAttribute('aria-label')) === 'See-through window', `it is called See-through window (${await sw.getAttribute('aria-label')})`) + ok((await sub()) === 'The desktop shows behind every surface.', `with Prism's words (${await sub()})`) + + // A dark preset: the switch alone paints 0xb9. + await pick('dracula') + ok(/^#[0-9a-f]{6}$/.test(await sheet()), `off, the window is solid (${await sheet()})`) + await sw.click() + ok(!!(await until(async () => /^#[0-9a-f]{6}b9$/.test(await sheet()), 4000, 50)), `on a dark preset the switch alone paints alpha b9 (${await sheet()})`) + const dark = await alphaReads() + ok(dark.now === '73' && dark.max === '95', `the Background's Alpha reads 73 and stops at 95 (${dark.now}, max ${dark.max})`) + + // A picked alpha wins; Reset gives the default back. + const field = bgRow.locator('input:not([type])') + await field.fill('#1c233099') + await field.press('Enter') + ok(!!(await until(async () => (await sheet()) === '#1c233099', 4000, 50)), `a picked 60 wins (${await sheet()})`) + await page.locator('[data-follow-theme="background"]').click() + ok(!!(await until(async () => /^#[0-9a-f]{6}b9$/.test(await sheet()), 4000, 50)), `Reset gives the default back (${await sheet()})`) + + // Off is solid. + await sw.click() + ok(!!(await until(async () => /^#[0-9a-f]{6}$/.test(await sheet()), 4000, 50)), `switched off, the window is solid (${await sheet()})`) + + // A light preset: 0xd1. A theme pick resets the switch, as every extra. + await pick('paper') + await sw.click() + ok(!!(await until(async () => /^#[0-9a-f]{6}d1$/.test(await sheet()), 4000, 50)), `on Paper the switch paints alpha d1 (${await sheet()})`) + const light = await alphaReads() + ok(light.now === '82', `the Background's Alpha reads 82 (${light.now})`) + await sw.scrollIntoViewIfNeeded() + await sleep(400) + await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/see-through.png') }).catch(() => {}) + + // High Contrast stays solid, and the row says why. + await pick('high-contrast') + ok((await sw.isDisabled()) && (await sw.getAttribute('aria-checked')) === 'false', 'on High Contrast the switch is off and disabled') + ok((await sub()) === 'High contrast stays solid.', `and says why (${await sub()})`) + ok(/^#[0-9a-f]{6}$/.test(await sheet()), `and the window is solid (${await sheet()})`) + await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/see-through-high-contrast.png') }).catch(() => {}) + + // Leave on a quiet preset, the switch off: nothing holds the close. + await pick('dracula') + ok(!(await sw.isDisabled()), 'off High Contrast the switch is offered again') + } finally { + await closeApp(app) + } + }, + async themeCards(ok) { // PICKING A THEME MOVES NOTHING (owner, 2026-09-22: "when I click a theme // ... the ui shifts a bit, it's not every theme but some"). Only the From 568024af2e2533ec3ace3b2f4214cf461f9915ed Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:43:54 +0200 Subject: [PATCH 3/8] feat(core): the theme wall runs neutral first, black to white (#157) The owner, 2026-10-10: "first black to white then coloured". After the leading cards (Custom, the host's default, Follow style in Prism) the presets now come in one fixed, computed order: the neutral grounds black to white, then the coloured ones black to white, by OKLab L. It replaces the light-first flip, which ran the wall either way by the theme worn. - core/renderer/lib/themeOrder.ts: oklch, NEUTRAL_CHROMA (0.0125, measured in the widest gap of the forty grounds: Monokai 0.0109, Sage 0.0147), orderTermThemes (stable on ties). - themeOrder.test.ts: permutation, neutrals first, L non-decreasing per group, the twelve measured neutrals, no ground within 0.001 of the threshold, ties, and the whole order as a snapshot. - Prism: its terminal theme wall takes the same order (the same forty themes); nothing else changes there. - e2e: themeCards checks the wall's DOM order against the unit snapshot (not run here). - Docs: two-apps.md (FORTY THEMES), core/README.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/README.md | 3 + .../lib/__snapshots__/themeOrder.test.ts.snap | 46 +++++++++++++++ core/renderer/lib/themeOrder.test.ts | 59 +++++++++++++++++++ core/renderer/lib/themeOrder.ts | 56 ++++++++++++++++++ core/renderer/settings/theme/ThemeWall.tsx | 26 ++++---- docs/two-apps.md | 8 +++ tools/e2e/run.mjs | 12 ++++ 7 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 core/renderer/lib/__snapshots__/themeOrder.test.ts.snap create mode 100644 core/renderer/lib/themeOrder.test.ts create mode 100644 core/renderer/lib/themeOrder.ts diff --git a/core/README.md b/core/README.md index f2606ee..2372658 100644 --- a/core/README.md +++ b/core/README.md @@ -145,6 +145,9 @@ share; `termAcrylicInForce` / `useTermAcrylicInForce` is the switch with High Contrast held solid (`seeThroughBlocked`), which is what a host paints by; the row is named by `acrylicLabel()` ("See-through window" there, the list's label in Prism) and drawn right under the wall, before `afterTheme`. +Since 0.31.0 (#157) the wall orders the presets after its leading cards with +`orderTermThemes` (`lib/themeOrder.ts`): neutral grounds first, then coloured, +each black to white by OKLab L; neutral is chroma below `NEUTRAL_CHROMA`. Spec: `docs/superpowers/specs/2026-10-03-colour-picker-alpha-design.md`. diff --git a/core/renderer/lib/__snapshots__/themeOrder.test.ts.snap b/core/renderer/lib/__snapshots__/themeOrder.test.ts.snap new file mode 100644 index 0000000..dd885a5 --- /dev/null +++ b/core/renderer/lib/__snapshots__/themeOrder.test.ts.snap @@ -0,0 +1,46 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`orderTermThemes (#157) > holds the whole order 1`] = ` +[ + "pitch", + "high-contrast", + "volt", + "prism", + "campbell", + "pt-default", + "graphite", + "monokai", + "gruvbox-dark", + "mist", + "catppuccin-latte", + "paper", + "phosphor", + "amber", + "garnet", + "plum", + "moss", + "rosewood", + "tokyonight", + "horizon", + "dracula", + "catppuccin-mocha", + "kanagawa", + "ocean", + "umber", + "solarized-dark", + "synthwave", + "marine", + "cobalt", + "nord", + "retro", + "cinder", + "fawn", + "sage", + "lavender", + "peach", + "mint", + "blossom", + "sky", + "butter", +] +`; diff --git a/core/renderer/lib/themeOrder.test.ts b/core/renderer/lib/themeOrder.test.ts new file mode 100644 index 0000000..9e3dea8 --- /dev/null +++ b/core/renderer/lib/themeOrder.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { TERM_PRESETS } from './termTheme' +import { NEUTRAL_CHROMA, oklch, orderTermThemes } from './themeOrder' + +const NEUTRALS = [ + 'pitch', 'high-contrast', 'volt', 'prism', 'campbell', 'pt-default', + 'graphite', 'monokai', 'gruvbox-dark', 'mist', 'catppuccin-latte', 'paper' +] + +describe('oklch', () => { + it('measures black, white and a grey as achromatic, black to white', () => { + expect(oklch('#000000').L).toBeCloseTo(0, 3) + expect(oklch('#ffffff').L).toBeCloseTo(1, 3) + expect(oklch('#808080').C).toBeLessThan(1e-4) + expect(oklch('#ff0000').C).toBeGreaterThan(0.2) + }) +}) + +describe('orderTermThemes (#157)', () => { + const ordered = orderTermThemes(TERM_PRESETS) + const neutral = (bg: string): boolean => oklch(bg).C < NEUTRAL_CHROMA + + it('is a permutation of the input and leaves the input alone', () => { + expect(ordered).toHaveLength(TERM_PRESETS.length) + expect(new Set(ordered)).toEqual(new Set(TERM_PRESETS)) + expect(orderTermThemes(TERM_PRESETS).map((p) => p.id)).toEqual(ordered.map((p) => p.id)) + }) + + it('puts every neutral theme before every coloured one', () => { + const firstColoured = ordered.findIndex((p) => !neutral(p.bg)) + expect(ordered.slice(firstColoured).every((p) => !neutral(p.bg))).toBe(true) + }) + + it('runs black to white inside each group', () => { + for (const group of [ordered.filter((p) => neutral(p.bg)), ordered.filter((p) => !neutral(p.bg))]) { + for (let i = 1; i < group.length; i++) expect(oklch(group[i].bg).L).toBeGreaterThanOrEqual(oklch(group[i - 1].bg).L) + } + }) + + it('measures the twelve neutral grounds', () => { + expect(ordered.filter((p) => neutral(p.bg)).map((p) => p.id).sort()).toEqual([...NEUTRALS].sort()) + }) + + it('has no preset ground on the fence, so a new theme cannot land there unnoticed', () => { + for (const p of TERM_PRESETS) expect(Math.abs(oklch(p.bg).C - NEUTRAL_CHROMA), p.id).toBeGreaterThan(0.001) + }) + + it('keeps the list order on a tie (Pitch before High Contrast)', () => { + const ids = ordered.map((p) => p.id) + expect(ids.indexOf('pitch')).toBeLessThan(ids.indexOf('high-contrast')) + const a = { id: 'a', bg: '#101010' } + const b = { id: 'b', bg: '#101010' } + expect(orderTermThemes([b, a]).map((p) => p.id)).toEqual(['b', 'a']) + }) + + it('holds the whole order', () => { + expect(ordered.map((p) => p.id)).toMatchSnapshot() + }) +}) diff --git a/core/renderer/lib/themeOrder.ts b/core/renderer/lib/themeOrder.ts new file mode 100644 index 0000000..2a293d2 --- /dev/null +++ b/core/renderer/lib/themeOrder.ts @@ -0,0 +1,56 @@ +/** + * The order of the terminal theme wall (#157). The owner, 2026-10-10: "ordered + * from black to white ... dark colours should be before all coloured, maybe + * actually coloured themes last, first black to white then coloured". So: + * the neutral themes first, black to white, then the coloured ones, black to + * white. Computed off each ground, never typed, so a new theme files itself. + */ +import { normalizeColor } from './termAnsi' + +/** sRGB channel (0..1) to linear light. */ +function linear(c: number): number { + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 +} + +/** + * OKLab lightness L (0 black .. 1 white, perceptual: "the black or white + * scale") and chroma C (how coloured) of a colour; Bjorn Ottosson's matrices. + * Perceptual on purpose: WCAG luminance puts a saturated blue far darker than + * it looks, which would file it among the blacks. + */ +export function oklch(colour: string): { L: number; C: number } { + const hex = normalizeColor(colour, '#000000') + const r = linear(parseInt(hex.slice(1, 3), 16) / 255) + const g = linear(parseInt(hex.slice(3, 5), 16) / 255) + const b = linear(parseInt(hex.slice(5, 7), 16) / 255) + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s + const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + return { L, C: Math.hypot(A, B) } +} + +/** + * Below this OKLab chroma a ground reads as neutral. MEASURED (2026-10-10, the + * forty preset grounds sorted by C): 0 (six pure greys), 0.0027 Mist, 0.0055 + * Volt, 0.0058 Catppuccin Latte, 0.0082 Paper, 0.0085 Prism, 0.0109 Monokai, + * then 0.0147 Sage, 0.0149 Cinder and Blossom, 0.0157 Horizon and up to 0.1316 + * Retro. 0.0109 to 0.0147 is the widest gap below 0.02, and this sits in it; + * a unit test keeps every preset at least 0.001 away from it. + */ +export const NEUTRAL_CHROMA = 0.0125 + +/** + * Neutral grounds first, then coloured, each group black to white by OKLab L. + * A copy; ties keep the input order (Array.prototype.sort is stable). + */ +export function orderTermThemes(list: readonly T[]): T[] { + const keyed = list.map((item) => { + const { L, C } = oklch(item.bg) + return { item, L, coloured: C >= NEUTRAL_CHROMA ? 1 : 0 } + }) + keyed.sort((a, b) => a.coloured - b.coloured || a.L - b.L) + return keyed.map((k) => k.item) +} diff --git a/core/renderer/settings/theme/ThemeWall.tsx b/core/renderer/settings/theme/ThemeWall.tsx index 2248f8e..33ee5c9 100644 --- a/core/renderer/settings/theme/ThemeWall.tsx +++ b/core/renderer/settings/theme/ThemeWall.tsx @@ -13,7 +13,7 @@ import { type CustomTermTheme } from '../../lib/termLook' import { resolveCustomTheme, resolveTermTheme, watchTermTheme, TERM_PRESETS } from '../../lib/termTheme' -import { luminance, normalizeColor } from '../../lib/termAnsi' +import { orderTermThemes } from '../../lib/themeOrder' import type { AlphaRange } from '../../lib/colour' import { SEE_THROUGH_MAX } from '../../lib/seeThrough' import { ColourField } from '../ColourPicker' @@ -278,7 +278,7 @@ const TWO_ROWS = 268 /** * The wall itself: Custom, the host's default, Follow style where the host - * has styles, then every preset by brightness; the pencil on the chosen card; + * has styles, then every preset neutral-first, black to white (#157); the pencil on the chosen card; * Show all; the question when a pick would drop unsaved changes; the editor. * `onThemePicked`: a pick landed (Custom included), for a host whose own rows * follow the theme (Prism Terminal forgets its picked background and accent). @@ -298,25 +298,19 @@ export function ThemeWall({ onThemePicked, className = '' }: { onThemePicked?: ( // style repaints :root. Only a host WITH styles has one (Prism). const [styleTheme, setStyleTheme] = useState(() => resolveTermTheme(followsHostStyle() ? 'style' : termThemeId())) useEffect(() => (followsHostStyle() ? watchTermTheme(setStyleTheme) : undefined), []) - // Presets ordered by brightness, the themes nearest your own look leading: - // on a light theme the wall runs light to dark, on a dark one dark to light. - // The direction is MEASURED off the theme worn when the page opened, and - // read once: the chrome follows the theme now, so re-sorting on every pick - // would shuffle the wall under the pointer that just clicked a card. - const [lightFirst] = useState( - () => luminance(normalizeColor(resolveTermTheme(termThemeId()).background, '#000000')) > 0.4 - ) // CUSTOM LEADS THE WALL, then THE HOST'S OWN DEFAULT (owner, 2026-09-22: "it // should be first in the list"; then 2026-09-23: "custom should come before // default"). Prism Terminal's default is a preset (PT Default); Prism's is // 'style', which is no preset, so there Custom leads Follow style. const defaultPreset = TERM_PRESETS.find((p) => p.id === hostDefaults().theme) - const sortedPresets = useMemo(() => { - const lum = (bg: string): number => luminance(normalizeColor(bg, '#000000')) - return TERM_PRESETS.filter((p) => p !== defaultPreset).sort((a, b) => - lightFirst ? lum(b.bg) - lum(a.bg) : lum(a.bg) - lum(b.bg) - ) - }, [lightFirst, defaultPreset]) + // Then every other preset in ONE fixed order (#157, owner 2026-10-10): the + // neutral grounds black to white, then the coloured ones black to white, + // measured in lib/themeOrder. Fixed, so a pick never reshuffles the wall + // (the old light-first flip read the worn theme once for that reason). + const sortedPresets = useMemo( + () => orderTermThemes(TERM_PRESETS.filter((p) => p !== defaultPreset)), + [defaultPreset] + ) // The editor popup, seeded from the SELECTED theme. Presets never change - // editing always lands in the Custom slot. const [editing, setEditing] = useState(null) diff --git a/docs/two-apps.md b/docs/two-apps.md index b71c62d..6126c58 100644 --- a/docs/two-apps.md +++ b/docs/two-apps.md @@ -234,6 +234,14 @@ so an update never silently changes what an existing user sees; the bridge to ma Horizon). NO INVISIBLE TEXT is a test (`termTheme.legible.test.ts`): text 4.5:1, cursor and accent 3:1, all sixteen 3:1 against the theme's own ground. It caught Catppuccin Latte's own cursor at 2.3:1, which is its mauve here. Original themes give base colours only. + **THE WALL'S ORDER IS MEASURED** (#157; owner, 2026-10-10: "first black to white then + coloured"): after the leading cards (Custom, the host's default, Follow style in Prism), core + `orderTermThemes` (`lib/themeOrder.ts`) puts the neutral grounds first, then the coloured ones, + each black to white by OKLab L. Neutral is OKLab chroma below `NEUTRAL_CHROMA` 0.0125, measured + in the widest gap of the forty grounds (Monokai 0.0109, Sage 0.0147), so Cinder, a blue slate, + files with the coloured. One fixed order in both apps (it replaced the light-first flip); + `themeOrder.test.ts` snapshots it and keeps every ground 0.001 off the threshold, and the + `themeCards` e2e reads that snapshot against the wall. **VOLT TOOK INK'S PLACE** (#93; owner, 2026-09-28, with a screenshot: "make one of the black themes this colour scheme with black and that yellow greenish colour, kind of cyberpunk"): `#d8ff26` on `#050706`; Ink, the blue-black next to Prism and Tokyo Night, retired to `prism`. diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 630860f..c67904a 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -4370,6 +4370,18 @@ const scenarios = { return { cards, below: row ? Math.round(y(row) * 10) / 10 : -1 } }) const first = await measure() + // THE ORDER (#157, owner 2026-10-10): PT Default leads, then the neutral + // grounds black to white, then the coloured ones. The expected list is the + // unit test's snapshot of orderTermThemes, read here so the two never + // disagree (this runner cannot import the TypeScript). + const snap = readFileSync(resolve(process.cwd(), 'core/renderer/lib/__snapshots__/themeOrder.test.ts.snap'), 'utf8') + const computed = [...snap.matchAll(/^\s+"([a-z0-9-]+)",?$/gm)].map((m) => m[1]) + const wantOrder = ['pt-default', ...computed.filter((id) => id !== 'pt-default')] + const wallOrder = first.cards.map((c) => c.id).filter((id) => id !== 'custom') + ok( + computed.length >= 40 && wallOrder.join(',') === wantOrder.join(','), + `the wall runs PT Default, neutrals black to white, then coloured (${wallOrder.slice(0, 14).join(' ')} ...)` + ) const rowTops = [...new Set(first.cards.map((c) => c.top))] ok(rowTops.length >= 2, `the wall has two rows to pick across (${rowTops.length})`) const inRow = (top) => first.cards.find((c) => c.top === top)?.id From ebaab4f2ffc5ceb30274fe802bf57bbbff9d5595 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:50:32 +0200 Subject: [PATCH 4/8] feat: Most recent tab switching (#158) A Tab switching setting in Settings > Appearance > Window, after Tab style: In order (the default, Ctrl+Tab along the strip as before) or Most recent. In Most recent, Ctrl+Tab walks a snapshot of the most-recently-used list taken at the hold's first press; the order changes only when Ctrl is released, the window loses focus or another Ctrl chord is pressed, so one press flips to the last tab and repeated presses walk further back instead of ping-ponging. Closed tabs leave the list, new tabs enter at the front. - lib/tabMru.ts (pure, tested) and lib/tabSwitchPrefs.ts (prism.window.tabSwitch) - App.tsx holds the list and the walk in refs; keyup of Control and blur commit - appOptions/settingsIndex/AppearancePage: the tab-switch row - e2e: new tabSwitch scenario, PREF_PAGE and settingsLook rows (not run here) - README keys row, docs/regression-rules.md "Most recent commits on Ctrl release" Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- README.md | 4 +- docs/regression-rules.md | 12 ++ src/renderer/src/App.tsx | 68 +++++++++++- .../components/settings/AppearancePage.tsx | 20 ++++ .../components/settings/appOptions.test.ts | 10 ++ .../src/components/settings/appOptions.ts | 1 + .../src/components/settings/settingsIndex.ts | 2 +- src/renderer/src/lib/tabMru.test.ts | 88 +++++++++++++++ src/renderer/src/lib/tabMru.ts | 59 ++++++++++ src/renderer/src/lib/tabSwitchPrefs.test.ts | 35 ++++++ src/renderer/src/lib/tabSwitchPrefs.ts | 37 +++++++ tools/e2e/run.mjs | 103 +++++++++++++++++- 12 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/lib/tabMru.test.ts create mode 100644 src/renderer/src/lib/tabMru.ts create mode 100644 src/renderer/src/lib/tabSwitchPrefs.test.ts create mode 100644 src/renderer/src/lib/tabSwitchPrefs.ts diff --git a/README.md b/README.md index 5bdf628..5837f09 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ session comes back when you reopen the app. | | | |---|---| | **Agent indicator on every tab** | A tab lights the moment Claude Code or Codex starts working and shows when the answer has landed, a question is waiting, or a turn failed. Pick Minimal, Ring or Full; a finished tab wears the app icon's rainbow, flowing. | -| **Two tab styles** | Classic flat tabs, or Powerline: chevron segments whose arrow edge is the agent's mark. | +| **Two tab styles** | Classic flat tabs, or Powerline: chevron segments whose arrow edge is the agent's mark. `Ctrl+Tab` goes along the strip or back to the tab you used last. | | **Sessions that come back** | Close the app and reopen it: every tab returns in its folder, and agent conversations resume on their own. | | **Local dictation** | Hold `Right Alt` and speak. [whisper.cpp](https://github.com/ggml-org/whisper.cpp) runs on your PC, offline, and never presses Enter. | | **Command help** | Press `F1`, describe a task in plain words and copy the command. Works offline. | @@ -52,7 +52,7 @@ Works with PowerShell 7, Windows PowerShell, Command Prompt and any installed WS | Key | Action | |---|---| | `Ctrl+T` / `Ctrl+W` | New tab / close tab | -| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / previous tab | +| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / previous tab, in strip order or most recently used (Settings > Appearance > Tab switching) | | `Ctrl+1` to `Ctrl+9` | Jump to a tab | | `Ctrl+F` | Find in the scrollback (`Ctrl+Shift+F` too; in vim or less, `Ctrl+F` stays page down) | | `Ctrl+,` | Settings | diff --git a/docs/regression-rules.md b/docs/regression-rules.md index a30e949..aa93536 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -174,6 +174,18 @@ one-liner in `CLAUDE.md` only if it is among the most important. **Ctrl+W closes a tab, in BOTH apps** (owner, 2026-09-19, reversing the first build's Ctrl+Shift+W, which still works). Known cost, accepted: the shell loses delete-word on that chord; Ctrl+Backspace does the same job. +- **MOST RECENT COMMITS ON CTRL RELEASE** (#158; owner, 2026-10-10: "add a + new tab switching mode called most recent. so you can either switch chronologically or by most + recently used"). Settings > Appearance > Window > Tab switching, `prism.window.tabSwitch`: + `order` (In order, the DEFAULT, `stepTab` exactly as before) or `recent`. In Most recent, + Ctrl+Tab walks a SNAPSHOT of the used list taken at the hold's first press (`lib/tabMru.ts`): + one press flips to the tab used before, more presses while Ctrl is held go further back, + Shift goes the other way. The list is reordered ONLY when the hold ends: Control's keyup + (window, capture), the window's blur, or any other Ctrl chord. Reordering on each press makes + the second Tab flip straight back (a ping-pong) and the oldest tabs unreachable. Any other + activation (a click, Ctrl+1..9, a new tab, a close handing over) is a use at once; closed tabs + leave the list. Not persisted. Listens to Control's keyup only, never `onData` (rule 10). Held by + `tabMru.test.ts`, `tabSwitchPrefs.test.ts` and the `tabSwitch` e2e. - **The window's edge is a faint hairline that follows the theme** (owner, same day; `windowEdge.ts` + Prism's `dwmHelper.ts`). DWM's border is always one physical pixel, so it cannot be thinner; what reads as thickness is contrast, so it is drawn a small step off the theme's own diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cd93a5c..44407fe 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -13,6 +13,8 @@ import TermFind from '@core/renderer/components/TermFind' import { TabStrip } from './components/TabStrip' import TitleBar, { TitleButtons } from './components/TitleBar' import { useTitleBarMode } from './lib/titleBarPrefs' +import { tabSwitch } from './lib/tabSwitchPrefs' +import { startWalk, stepWalk, syncMru, touchMru, walkTarget, type Mru, type Walk } from './lib/tabMru' import EmptyState from './components/EmptyState' import type { SettingsPage } from './components/settings/Settings' import { Dialog } from './components/Dialog' @@ -208,6 +210,48 @@ export default function App(): JSX.Element { // The latest of everything, for listeners registered once. const live = useRef({ state, workingIds, agentIds, blocked: false, front: '' }) + // MOST RECENT (#158): the tabs by last use, and the walk of a Ctrl hold. + // Refs, not state: nothing is drawn from them, and the key listener is + // registered once. Not persisted: at launch the list is the tab in front, + // then the strip. + const mru = useRef(syncMru([], boot.state.tabs.map((t) => t.id), boot.state.activeId)) + const walk = useRef(null) + /** The hold ended (Ctrl released, the window left, another chord): only + * now does the tab landed on move to the front, so repeated presses walk + * instead of flipping back and forth. */ + const commitWalk = useCallback((): void => { + if (!walk.current) return + walk.current = null + const id = live.current.state.activeId + if (id) mru.current = touchMru(mru.current, id) + }, []) + useEffect(() => { + mru.current = syncMru( + mru.current, + tabs.map((t) => t.id), + activeId + ) + // A tab closed under a walk could be its next stop: the hold ends there. + if (walk.current && walk.current.list.some((id) => !tabs.some((t) => t.id === id))) walk.current = null + // Any activation that is not the walk's (a click, Ctrl+1..9, a new tab, a + // close handing over) is a use at once. + if (!walk.current && activeId) mru.current = touchMru(mru.current, activeId) + }, [tabs, activeId]) + useEffect(() => { + // Capture, on the window: the shell's xterm never gets to swallow it. + // Control's own keyup only; rule 10 is untouched, nothing here reads what + // reaches the pty. + const onUp = (e: KeyboardEvent): void => { + if (e.key === 'Control') commitWalk() + } + window.addEventListener('keyup', onUp, true) + window.addEventListener('blur', commitWalk) + return () => { + window.removeEventListener('keyup', onUp, true) + window.removeEventListener('blur', commitWalk) + } + }, [commitWalk]) + useEffect(() => { paintChrome() const offLook = onTermLookChange(paintChrome) @@ -529,6 +573,10 @@ export default function App(): JSX.Element { e.preventDefault() e.stopPropagation() } + // Any other Ctrl chord ends a Most recent walk first (#158), then acts: + // the tab the walk landed on counts as used. Control and Shift are the + // hold itself (Shift walks the other way), and Control repeats while held. + if (e.key !== 'Tab' && e.key !== 'Control' && e.key !== 'Shift') commitWalk() // ONE QUESTION AT A TIME, for the tab chords too (code review // 2026-09-24, #10): under a close question or the update window, Ctrl+Tab // or Ctrl+T moved the keyboard into a shell behind it, and the Enter @@ -540,7 +588,23 @@ export default function App(): JSX.Element { } if (e.key === 'Tab') { hit() - setState((s) => stepTab(s, e.shiftKey ? -1 : 1)) + const dir = e.shiftKey ? -1 : 1 + if (tabSwitch() === 'recent') { + // The walk is a snapshot taken at the hold's first press, of the + // list as it stands with the tab in front first (tabMru). + const { state: st } = live.current + if (!walk.current) { + const ids = st.tabs.map((t) => t.id) + const now = syncMru(mru.current, ids, st.activeId) + walk.current = startWalk(st.activeId ? touchMru(now, st.activeId) : now) + } + if (!walk.current) return + walk.current = stepWalk(walk.current, dir) + const id = walkTarget(walk.current) + setState((s) => pickTab(s, id)) + } else { + setState((s) => stepTab(s, dir)) + } } else if (!e.shiftKey && /^[1-9]$/.test(e.key)) { const t = live.current.state.tabs[Number(e.key) - 1] if (t) { @@ -572,7 +636,7 @@ export default function App(): JSX.Element { } window.addEventListener('keydown', onKey, true) return () => window.removeEventListener('keydown', onKey, true) - }, [newTab, requestClose, toggleHelp]) + }, [newTab, requestClose, toggleHelp, commitWalk]) const openRecent = useCallback( (path: string) => { diff --git a/src/renderer/src/components/settings/AppearancePage.tsx b/src/renderer/src/components/settings/AppearancePage.tsx index bfa1dd4..5514953 100644 --- a/src/renderer/src/components/settings/AppearancePage.tsx +++ b/src/renderer/src/components/settings/AppearancePage.tsx @@ -20,6 +20,7 @@ import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' import { setWindowEdges, useWindowEdges } from '../../lib/edgesPrefs' import { setTabWidth, useTabWidth, type TabWidth } from '../../lib/tabWidthPrefs' import { setTabStyle, useTabStyle, type TabStyle } from '../../lib/tabStylePrefs' +import { setTabSwitch, useTabSwitch, type TabSwitch } from '../../lib/tabSwitchPrefs' import { setTitleBarMode, useTitleBarMode } from '../../lib/titleBarPrefs' import { setWindowAccent, useWindowAccent } from '../../lib/accentPrefs' import { onWindowBackgroundChange, setWindowBackground, useWindowBackground, windowBackground } from '../../lib/backgroundPrefs' @@ -66,6 +67,12 @@ const TAB_STYLE_OPTIONS: Array<{ id: TabStyle; name: string }> = [ { id: 'prompt', name: 'Powerline' } ] +// In order first: it is the default, Ctrl+Tab as it always was (#158). +const TAB_SWITCH_OPTIONS: Array<{ id: TabSwitch; name: string }> = [ + { id: 'order', name: 'In order' }, + { id: 'recent', name: 'Most recent' } +] + /** What the THEME in force would give the window, which is what a swatch * shows while nothing is chosen: computed the way the window computes it * (chromeTokens), not read back off the page, so it cannot lag a repaint. @@ -196,6 +203,8 @@ export function AppearancePage(): JSX.Element { const titleBar = useTitleBarMode() const tab = appOpt('tab-width') const shape = appOpt('tab-style') + const switching = useTabSwitch() + const swap = appOpt('tab-switch') const bar = appOpt('title-bar') const edge = appOpt('window-edges') return ( @@ -208,6 +217,17 @@ export function AppearancePage(): JSX.Element { + {/* Where Ctrl+Tab goes (#158): the strip, or the tab used last. The + sub says what the choice in force does, so the row answers it + without trying the keys; settings copy names no key (settingsCopy). */} + + + {/* A switch over the same store the segmented control wrote: on is `shown`, the default, the window as it always was (#91). */} diff --git a/src/renderer/src/components/settings/appOptions.test.ts b/src/renderer/src/components/settings/appOptions.test.ts index 84949c8..289f709 100644 --- a/src/renderer/src/components/settings/appOptions.test.ts +++ b/src/renderer/src/components/settings/appOptions.test.ts @@ -34,6 +34,7 @@ describe("this app's own settings rows", () => { [ "tab-width=prism.window.tabWidth", "tab-style=prism.window.tabStyle", + "tab-switch=prism.window.tabSwitch", "title-bar=prism.window.titleBar", "window-edges=prism.window.edges", "window-background=prism.window.background", @@ -87,4 +88,13 @@ describe('Find a setting', () => { const style = settingsIndex(true).find((e) => e.id === 'tab-style') expect(style?.keywords).toContain('chevron') }) + + it('finds Tab switching by what it does, right after Tab style, in the Window section (#158)', () => { + const index = settingsIndex(true) + const ids = index.map((e) => e.id) + expect(ids.indexOf('tab-switch')).toBe(ids.indexOf('tab-style') + 1) + const row = index.find((e) => e.id === 'tab-switch') + expect(`${row?.page}/${row?.section}`).toBe('appearance/Window') + for (const word of ['recent', 'ctrl', 'mru', 'order']) expect(row?.keywords).toContain(word) + }) }) diff --git a/src/renderer/src/components/settings/appOptions.ts b/src/renderer/src/components/settings/appOptions.ts index fbe48e3..b0c063e 100644 --- a/src/renderer/src/components/settings/appOptions.ts +++ b/src/renderer/src/components/settings/appOptions.ts @@ -28,6 +28,7 @@ export interface AppOption { export const APP_OPTIONS: readonly AppOption[] = [ { id: 'tab-width', label: 'Tab width', sub: 'Sized to the name, or all equal.', section: 'window', page: 'appearance', icon: 'tabs', keywords: 'size wide narrow equal fixed dynamic', store: ['prism.window.tabWidth'] }, { id: 'tab-style', label: 'Tab style', sub: 'Flat tabs, or arrows like a prompt.', section: 'window', page: 'appearance', icon: 'prompt', keywords: 'chevron arrow segment shape powerline classic', store: ['prism.window.tabStyle'] }, + { id: 'tab-switch', label: 'Tab switching', sub: 'Which tab comes next when you switch.', section: 'window', page: 'appearance', icon: 'key', keywords: 'ctrl tab mru recent order cycle switch next previous last used', store: ['prism.window.tabSwitch'] }, { id: 'title-bar', label: 'Show title bar', sub: 'When off, tabs share the top row.', section: 'window', page: 'appearance', icon: 'titlebar', keywords: 'caption top frame hide hidden', store: ['prism.window.titleBar'] }, { id: 'window-edges', label: 'Panel edges', sub: 'Lines between panels and around the window.', section: 'window', page: 'appearance', icon: 'edges', keywords: 'border lines hairline outline faint solid', store: ['prism.window.edges'] }, { id: 'window-background', label: 'Background colour', sub: 'Behind the text in window and terminal.', section: 'theme', page: 'appearance', icon: 'viewer', keywords: 'ground backdrop color transparent alpha opacity', store: ['prism.window.background'] }, diff --git a/src/renderer/src/components/settings/settingsIndex.ts b/src/renderer/src/components/settings/settingsIndex.ts index d2f8458..76748cd 100644 --- a/src/renderer/src/components/settings/settingsIndex.ts +++ b/src/renderer/src/components/settings/settingsIndex.ts @@ -38,7 +38,7 @@ const PAGE_OF: Record = { /** Every row in the order the pages draw them, which is the order Find a * setting lists matches in. A test holds this to the lists. */ export const ROW_ORDER = [ - 'tab-width', 'tab-style', 'title-bar', 'window-edges', 'term-theme', 'term-acrylic', 'window-background', 'window-accent', + 'tab-width', 'tab-style', 'tab-switch', 'title-bar', 'window-edges', 'term-theme', 'term-acrylic', 'window-background', 'window-accent', 'term-shell', 'newtab-mode', 'explorer-verb', 'term-font-family', 'term-font', 'help-enabled', 'agent-indicator', 'agent-done-on', 'agent-rainbow', 'agent-question-on', 'agent-failed-on', 'taskbar-badge', 'agent-hooks', 'agent-color', 'agent-done-color', 'agent-question-color', diff --git a/src/renderer/src/lib/tabMru.test.ts b/src/renderer/src/lib/tabMru.test.ts new file mode 100644 index 0000000..41e98f7 --- /dev/null +++ b/src/renderer/src/lib/tabMru.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { startWalk, stepWalk, syncMru, touchMru, walkTarget, type Walk } from './tabMru' + +/** A hold of Ctrl: the walk started from `mru`, then each Tab (1) or + * Shift+Tab (-1) pressed while it is held. Returns the tab landed on. */ +const hold = (mru: readonly string[], presses: Array<1 | -1>): string | null => { + let walk: Walk | null = startWalk(mru) + if (!walk) return null + for (const dir of presses) walk = stepWalk(walk, dir) + return walkTarget(walk) +} + +describe('touchMru', () => { + it('puts the tab in front, once', () => { + expect(touchMru(['a', 'b', 'c'], 'c')).toEqual(['c', 'a', 'b']) + expect(touchMru(['a', 'b'], 'a')).toEqual(['a', 'b']) + expect(touchMru([], 'a')).toEqual(['a']) + }) + + it('returns the same list when the tab is already in front, so nothing re-renders', () => { + const mru = ['a', 'b'] + expect(touchMru(mru, 'a')).toBe(mru) + }) +}) + +describe('syncMru', () => { + it('drops closed tabs and keeps the order of the rest', () => { + expect(syncMru(['c', 'a', 'b'], ['a', 'b'], 'a')).toEqual(['a', 'b']) + }) + + it('adds new tabs after the known ones, in strip order', () => { + expect(syncMru(['b', 'a'], ['a', 'b', 'x', 'y'], 'b')).toEqual(['b', 'a', 'x', 'y']) + }) + + it('starts an empty list from the tab in front, then the strip (a launch, a restore)', () => { + expect(syncMru([], ['a', 'b', 'c'], 'b')).toEqual(['b', 'a', 'c']) + expect(syncMru(['gone'], ['a', 'b', 'c'], 'c')).toEqual(['c', 'a', 'b']) + expect(syncMru([], [], null)).toEqual([]) + }) +}) + +describe('the walk (Most recent, #158)', () => { + const mru = ['c', 'b', 'a'] // c in front, b used before it, a before that + + it('a single Ctrl+Tab goes to the tab used before this one', () => { + expect(hold(mru, [1])).toBe('b') + }) + + it('each further Tab while Ctrl is held goes one further back, and wraps', () => { + expect(hold(mru, [1, 1])).toBe('a') + expect(hold(mru, [1, 1, 1])).toBe('c') + }) + + it('Ctrl+Shift+Tab walks the other way, from the far end on a fresh hold', () => { + expect(hold(mru, [-1])).toBe('a') + expect(hold(mru, [-1, -1])).toBe('b') + expect(hold(mru, [1, 1, -1])).toBe('b') + }) + + it('does not ping-pong: the order changes only when the hold ends', () => { + // Visit a, b, c by hand; then one Ctrl+Tab per hold. + let list: readonly string[] = [] + for (const id of ['a', 'b', 'c']) list = touchMru(list, id) + const first = hold(list, [1]) + expect(first).toBe('b') + list = touchMru(list, first!) // Ctrl released on b + const second = hold(list, [1]) + expect(second).toBe('c') // a flip back, the two last used + list = touchMru(list, second!) + // A long hold reaches the oldest, and releasing there brings it to the front. + const third = hold(list, [1, 1]) + expect(third).toBe('a') + list = touchMru(list, third!) + expect(list).toEqual(['a', 'c', 'b']) + }) + + it('the walk is a snapshot: the list it came from is not changed', () => { + const list = ['c', 'b', 'a'] + const walk = stepWalk(startWalk(list)!, 1) + expect(list).toEqual(['c', 'b', 'a']) + expect(walkTarget(walk)).toBe('b') + }) + + it('over one tab or none there is nothing to walk', () => { + expect(startWalk(['a'])).toBeNull() + expect(startWalk([])).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/tabMru.ts b/src/renderer/src/lib/tabMru.ts new file mode 100644 index 0000000..674f5f1 --- /dev/null +++ b/src/renderer/src/lib/tabMru.ts @@ -0,0 +1,59 @@ +/** + * MOST RECENT TAB SWITCHING (#158), as pure data: the most-recently-used list + * of tab ids (front = the tab in front) and the walk Ctrl+Tab takes over it. + * + * The walk is a SNAPSHOT of the list taken at the first Tab of a Ctrl hold, and + * the list is only reordered when the hold ends (Ctrl released, or the window + * losing focus), the way browsers and VS Code do it. Reordering on every press + * would make the second Tab flip straight back to where it started (a + * ping-pong), so a long hold could never reach an older tab. + */ + +/** Tab ids, the one in front first, then the one used before it, and so on. */ +export type Mru = readonly string[] + +/** One Ctrl hold: the list as it was at its first press, and where it is. */ +export interface Walk { + readonly list: Mru + readonly at: number +} + +/** The tab came to the front. The same list back when it already was. */ +export function touchMru(mru: Mru, id: string): Mru { + if (mru[0] === id) return mru + return [id, ...mru.filter((t) => t !== id)] +} + +/** + * Follow the strip: closed tabs leave, tabs the list has not seen join after + * the known ones in strip order (the effect that calls this then touches the + * one in front, so a new tab, which is activated, lands first). An empty list + * (a launch, or a restore that settled every placeholder under a new id) + * starts from the tab in front, then the strip. + */ +export function syncMru(mru: Mru, tabIds: readonly string[], activeId: string | null): Mru { + const open = new Set(tabIds) + const kept = mru.filter((id) => open.has(id)) + if (kept.length === 0) { + return activeId && open.has(activeId) ? [activeId, ...tabIds.filter((id) => id !== activeId)] : [...tabIds] + } + const known = new Set(kept) + const added = tabIds.filter((id) => !known.has(id)) + return added.length === 0 && kept.length === mru.length ? mru : [...kept, ...added] +} + +/** A walk needs somewhere to go: with one tab or none there is none. */ +export function startWalk(mru: Mru): Walk | null { + return mru.length < 2 ? null : { list: [...mru], at: 0 } +} + +/** Tab goes one further back (1), Shift+Tab the other way (-1). Wraps, so a + * fresh Shift+Tab lands on the oldest. */ +export function stepWalk(walk: Walk, dir: 1 | -1): Walk { + const n = walk.list.length + return { list: walk.list, at: (walk.at + dir + n) % n } +} + +export function walkTarget(walk: Walk): string { + return walk.list[walk.at] +} diff --git a/src/renderer/src/lib/tabSwitchPrefs.test.ts b/src/renderer/src/lib/tabSwitchPrefs.test.ts new file mode 100644 index 0000000..aa249b4 --- /dev/null +++ b/src/renderer/src/lib/tabSwitchPrefs.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onTabSwitchChange, setTabSwitch, tabSwitch } from './tabSwitchPrefs' + +describe('tabSwitchPrefs', () => { + beforeEach(() => localStorage.clear()) + + it("is In order until somebody chooses, so nobody's keys change on update (#158)", () => { + expect(tabSwitch()).toBe('order') + }) + + it('remembers both choices under its own key', () => { + for (const m of ['recent', 'order'] as const) { + setTabSwitch(m) + expect(tabSwitch()).toBe(m) + expect(localStorage.getItem('prism.window.tabSwitch')).toBe(m) + } + }) + + it('reads a word it does not know as In order, and never stores one', () => { + localStorage.setItem('prism.window.tabSwitch', 'mru') + expect(tabSwitch()).toBe('order') + setTabSwitch('random' as never) + expect(localStorage.getItem('prism.window.tabSwitch')).toBe('order') + }) + + it('tells its listeners on a change, and stops when asked', () => { + const heard = vi.fn() + const off = onTabSwitchChange(heard) + setTabSwitch('recent') + expect(heard).toHaveBeenCalledTimes(1) + off() + setTabSwitch('order') + expect(heard).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/tabSwitchPrefs.ts b/src/renderer/src/lib/tabSwitchPrefs.ts new file mode 100644 index 0000000..c45b661 --- /dev/null +++ b/src/renderer/src/lib/tabSwitchPrefs.ts @@ -0,0 +1,37 @@ +import { useSyncExternalStore } from 'react' + +// WHERE CTRL+TAB GOES (#158; owner, 2026-10-10: "add a new tab switching mode +// called most recent. so you can either switch chronologically or by most +// recently used"). `order` is the strip, left to right, as it always was, and +// the DEFAULT, so nobody's keys change with the update; `recent` walks the +// most-recently-used list (lib/tabMru). THIS APP'S OWN setting: the strip is +// the app's shell, so its key is not `prism.term.*`. Read defensively: +// anything unknown is In order. + +export type TabSwitch = 'order' | 'recent' + +const KEY = 'prism.window.tabSwitch' + +let listeners: Array<() => void> = [] + +export function validTabSwitch(v: unknown): TabSwitch { + return v === 'recent' ? 'recent' : 'order' +} + +export function tabSwitch(): TabSwitch { + return validTabSwitch(localStorage.getItem(KEY)) +} + +export function setTabSwitch(mode: TabSwitch): void { + localStorage.setItem(KEY, validTabSwitch(mode)) + listeners.forEach((l) => l()) +} + +export function onTabSwitchChange(cb: () => void): () => void { + listeners.push(cb) + return () => { + listeners = listeners.filter((l) => l !== cb) + } +} + +export const useTabSwitch = (): TabSwitch => useSyncExternalStore(onTabSwitchChange, tabSwitch) diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index c67904a..2e7a65e 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -166,7 +166,7 @@ const tabLabels = (page) => * so a row moving between pages is one line here. */ const PREF_PAGE = { - 'tab-width': 'appearance', 'tab-style': 'appearance', 'title-bar': 'appearance', 'window-edges': 'appearance', 'term-theme': 'appearance', + 'tab-width': 'appearance', 'tab-style': 'appearance', 'tab-switch': 'appearance', 'title-bar': 'appearance', 'window-edges': 'appearance', 'term-theme': 'appearance', 'window-background': 'appearance', 'window-accent': 'appearance', 'term-acrylic': 'appearance', 'term-shell': 'terminal', 'newtab-mode': 'terminal', 'explorer-verb': 'terminal', 'term-font-family': 'terminal', 'term-font': 'terminal', 'help-enabled': 'terminal', @@ -1641,6 +1641,105 @@ const scenarios = { } }, + /** + * TAB SWITCHING, MOST RECENT (#158; owner, 2026-10-10: "so you can either + * switch chronologically or by most recently used"). In order stays the + * default and steps the strip. Most recent walks the used list as browsers + * do: one Ctrl+Tab flips to the tab used before, Tab again while Ctrl is held + * goes further back, and the order changes only when Ctrl is let go, so + * repeated presses never ping-pong. A closed tab leaves the list, a new one + * enters it at the front. + */ + async tabSwitch(ok) { + const w = world() + const gamma = join(dirname(w.alpha), 'gamma') + const delta = join(dirname(w.alpha), 'delta') + mkdirSync(gamma) + mkdirSync(delta) + const { app, page } = await launch(w, { args: [w.alpha, w.beta, gamma], pick: delta }) + try { + ok(await until(async () => (await tabLabels(page)).length === 3), 'three tabs open') + const front = () => + page.evaluate(() => { + const t = [...document.querySelectorAll('[data-tab]')].find( + (el) => el.getAttribute('aria-selected') === 'true' || el.querySelector('[aria-selected="true"]') + ) + return (t?.textContent ?? '').trim() + }) + const lands = async (name, said) => ok(!!(await until(async () => (await front()).startsWith(name), 4000, 50)), `${said} (${await front()})`) + const visit = async (i) => { + await page.locator('[data-tab]').nth(i).click() + await sleep(150) + } + // A hold: Ctrl down, Tab (or Shift+Tab) per step, Ctrl up. + const hold = async (steps) => { + await page.keyboard.down('Control') + for (const s of steps) { + if (s < 0) await page.keyboard.down('Shift') + await page.keyboard.press('Tab') + if (s < 0) await page.keyboard.up('Shift') + await sleep(120) + } + await page.keyboard.up('Control') + await sleep(150) + } + + // IN ORDER, the default: the strip, left to right, wrapping. + await visit(0) + await hold([1]) + await lands('beta', 'In order: Ctrl+Tab steps to the next tab in the strip') + await hold([-1]) + await lands('alpha', 'and Ctrl+Shift+Tab back') + + // The row: right after Tab style, In order by default. + const row = await gotoPref(page, 'tab-switch') + const order = await page.evaluate(() => [...document.querySelectorAll('[data-pref]')].map((e) => e.getAttribute('data-pref'))) + ok(order.indexOf('tab-switch') === order.indexOf('tab-style') + 1, `Tab switching is right after Tab style (${order.slice(0, 4).join(' > ')})`) + ok((await row.locator('[aria-pressed="true"]').getAttribute('data-seg')) === 'order', 'In order is the default') + ok((await row.locator('[data-seg]').allTextContents()).join('|') === 'In order|Most recent', 'the choices are In order and Most recent') + await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/settings-tab-switch.png') }).catch(() => {}) + await row.locator('[data-seg="recent"]').click() + ok((await page.evaluate(() => localStorage.getItem('prism.window.tabSwitch'))) === 'recent', 'Most recent is stored') + await backToFirst(page) + + // MOST RECENT: used alpha, beta, gamma, in that order. + for (const i of [0, 1, 2]) await visit(i) + await hold([1]) + await lands('beta', 'one Ctrl+Tab goes to the tab used before this one') + await hold([1]) + await lands('gamma', 'and the next flips back: the last two used') + await hold([1, 1]) + await lands('alpha', 'Tab twice in one hold goes two back, no ping-pong') + await hold([1]) + await lands('gamma', 'released there, alpha is now the latest, so the next flip is gamma') + await hold([-1]) + await lands('beta', 'Ctrl+Shift+Tab on a fresh hold goes to the oldest') + + // A CLOSED TAB LEAVES THE LIST: close gamma (idle, so nothing asks). + await visit(2) + await lands('gamma', 'gamma in front') + await page.keyboard.press('Control+w') + ok(await until(async () => (await tabLabels(page)).length === 2), 'Ctrl+W closes it') + const after = await front() + await hold([1]) + const flip = await front() + ok(!flip.startsWith('gamma') && flip !== after && flip.length > 0, `Ctrl+Tab goes to the other open tab, never the closed one (${after} > ${flip})`) + + // A NEW TAB ENTERS AT THE FRONT: opened (asked, the chooser answers delta), + // one flip goes to the tab before it, the next comes back to it. + await page.evaluate(() => localStorage.setItem('prism.newtab.mode', 'ask')) + await page.locator('[aria-label="New tab"]').click() + ok(await until(async () => (await tabLabels(page)).length === 3), 'a new tab opens') + await lands('delta', 'and is in front') + await hold([1]) + await lands(flip, 'one Ctrl+Tab goes to the tab used before the new one') + await hold([1]) + await lands('delta', 'and the next comes back to the new tab') + } finally { + await closeApp(app) + } + }, + /** * THE INDICATOR STYLES (#143): Minimal, Ring and Full, on Volt and Paper, in * both tab styles. Measured: Ring spins by a working tab's name and leaves @@ -3567,7 +3666,7 @@ const scenarios = { // window's rows above the theme and what a theme sets under it. const rows = await page.evaluate(() => [...document.querySelectorAll('[data-pref]')].map((e) => e.getAttribute('data-pref'))) // The see-through row sits right under the wall since #156, as Prism's. - const want = ['tab-width', 'tab-style', 'title-bar', 'window-edges', 'term-theme', 'term-acrylic', 'window-background', 'window-accent'] + const want = ['tab-width', 'tab-style', 'tab-switch', 'title-bar', 'window-edges', 'term-theme', 'term-acrylic', 'window-background', 'window-accent'] ok(JSON.stringify(rows) === JSON.stringify(want), `Appearance runs ${want.join(' > ')} (${rows.join(' > ')})`) // Font size is 50% to 200% in tens. await gotoPref(page, 'term-font') From c77ef4051b49f8c901d0abbd12a6355b77202d7d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:59:38 +0200 Subject: [PATCH 5/8] fix: review of the see-through window and Most recent (#156, #158) - Save changes judges and saves the see-through switch IN FORCE: under High Contrast a stored on (from before #156) no longer lights it, and a save stays solid instead of escaping into a see-through Custom. The colour editor's save goes through the same saveAsCustom. - The dirty check measures the default level from the ground the window measures from, so an opaque picked Background does not light Save changes by its lightness; withGroundAlpha measures from the palette's own ground, so a save keeps the Custom's own level. - Most recent: a click on another tab, or a tab opened or closed, while Ctrl is still held ends the walk (walkHolds), so it counts as a use. - Save changes' tooltip and the theme switch question say "see-through window" in Prism Terminal, not "acrylic". - The spec and comments no longer claim the stored switch survives a theme pick (a pick resets it, "the theme is the whole setup"); the spec's Tab switching wording matches what was built. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/components/ThemeSwitchAsk.tsx | 3 +- core/renderer/lib/termLook.ts | 15 +++- core/renderer/settings/TerminalAppearance.tsx | 4 +- .../settings/sections/MarkColoursSection.tsx | 5 +- .../sections/TerminalThemeSection.tsx | 13 ++- core/renderer/settings/sections/opts.test.ts | 10 ++- core/renderer/settings/sections/opts.ts | 8 ++ core/renderer/settings/theme/ThemeWall.tsx | 8 +- .../settings/theme/useTermSetup.test.ts | 77 +++++++++++++++++ core/renderer/settings/theme/useTermSetup.ts | 85 ++++++++++++++----- docs/regression-rules.md | 5 +- .../plans/2026-10-10-look-and-mru.md | 2 +- .../specs/2026-10-10-look-and-mru-design.md | 20 +++-- docs/two-apps.md | 3 +- src/renderer/src/App.tsx | 11 +-- src/renderer/src/lib/tabMru.test.ts | 14 ++- src/renderer/src/lib/tabMru.ts | 13 +++ 17 files changed, 236 insertions(+), 60 deletions(-) create mode 100644 core/renderer/settings/theme/useTermSetup.test.ts diff --git a/core/renderer/components/ThemeSwitchAsk.tsx b/core/renderer/components/ThemeSwitchAsk.tsx index ed04b35..91db59c 100644 --- a/core/renderer/components/ThemeSwitchAsk.tsx +++ b/core/renderer/components/ThemeSwitchAsk.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, type JSX } from 'react' import { ROW_BUTTON } from '../settings/fields' +import { acrylicWord } from '../settings/sections/opts' /** * UNSAVED CHANGES ASK BEFORE A THEME SWITCH (owner, 2026-09-23: "if you have @@ -64,7 +65,7 @@ export default function ThemeSwitchAsk({ Save your changes first?

- Switching theme puts the agent colours and acrylic back to the new theme's own. + Switching theme puts the agent colours and {acrylicWord()} back to the new theme's own. Your changes are not saved yet.

diff --git a/core/renderer/lib/termLook.ts b/core/renderer/lib/termLook.ts index 5d2f30f..5589c56 100644 --- a/core/renderer/lib/termLook.ts +++ b/core/renderer/lib/termLook.ts @@ -467,8 +467,11 @@ function groundInForce(): string { * Contrast stays solid where the terminal owns the window acrylic (#156, * Prism's rule: its contrast is measured on a solid ground, and glass would * put an unmeasurable desktop under the text; this owner works zoomed in and - * relies on it). The stored choice is kept, so leaving High Contrast gives it - * back. In Prism the style owns the glass and the switch is read as it is. + * relies on it). A theme pick resets the switch with the rest of the setup + * (`resetTermExtras`, "the theme is the whole setup"), so a stored on under + * High Contrast only comes from before #156; Save changes judges and saves + * this, not the stored value (`termSetupState`). In Prism the style owns the + * glass and the switch is read as it is. */ export function termAcrylicInForce(): boolean { return termAcrylic() && !seeThroughBlocked() @@ -493,10 +496,16 @@ function rawGroundAlpha(): number { * the colour editor opens on, so a see-through window stays see-through * through a save (the Opacity slider was carried in the setup the same way). * Where the host's style owns the glass (Prism), the palette as it is. + * + * The default see-through is measured from the PALETTE's own ground, not the + * picked Background (review of #156): a dark Custom saved under an opaque + * white pick keeps the dark level, the one it paints by itself, and the one + * Save changes' dirty check expects. Without an opaque pick the two are the + * same ground, so this is `termGroundAlpha()`. */ export function withGroundAlpha(palette: T): T { if (!hostOwnsWindowAcrylic()) return palette - return { ...palette, bg: withAlpha(palette.bg, termGroundAlpha()) } + return { ...palette, bg: withAlpha(palette.bg, paintsAlpha(rawGroundAlpha(), opaque(palette.bg), termAcrylicInForce())) } } export function saveCustomTermTheme(theme: CustomTermTheme): void { diff --git a/core/renderer/settings/TerminalAppearance.tsx b/core/renderer/settings/TerminalAppearance.tsx index c0252ac..fd676bb 100644 --- a/core/renderer/settings/TerminalAppearance.tsx +++ b/core/renderer/settings/TerminalAppearance.tsx @@ -22,7 +22,7 @@ import { ColourField } from './ColourPicker' import { AgentIndicatorSetting, AttentionSettings } from './TerminalBehaviour' import { ThemeWall } from './theme/ThemeWall' import { useNoAcrylic, useTermSetup } from './theme/useTermSetup' -import { acrylicLabel } from './sections/opts' +import { acrylicLabel, saveSetupTitle } from './sections/opts' // THE TERMINAL'S LOOK, as one settings section for both hosts (#15): the theme // wall and its editor, font, size, acrylic, and the two agent indicator @@ -126,7 +126,7 @@ export function TerminalAppearanceSettings({ } /> diff --git a/core/renderer/settings/sections/MarkColoursSection.tsx b/core/renderer/settings/sections/MarkColoursSection.tsx index 8d762e8..f55e9c5 100644 --- a/core/renderer/settings/sections/MarkColoursSection.tsx +++ b/core/renderer/settings/sections/MarkColoursSection.tsx @@ -13,8 +13,7 @@ import { RESET_LINK, SaveButton } from '../fields' import { SettingRow } from '../layout/SettingRow' import { SettingsSection } from '../layout/SettingsSection' import { useTermSetup } from '../theme/useTermSetup' -import { opt, sectionTitle } from './opts' -import { SAVE_SETUP_TITLE } from './TerminalThemeSection' +import { opt, saveSetupTitle, sectionTitle } from './opts' const OWN = 'Your own colour.' @@ -37,7 +36,7 @@ export function MarkColoursSection(): JSX.Element { } + action={} > {agentCol && ( diff --git a/core/renderer/settings/sections/TerminalThemeSection.tsx b/core/renderer/settings/sections/TerminalThemeSection.tsx index 7450881..19f1bd4 100644 --- a/core/renderer/settings/sections/TerminalThemeSection.tsx +++ b/core/renderer/settings/sections/TerminalThemeSection.tsx @@ -6,10 +6,7 @@ import { SettingRow } from '../layout/SettingRow' import { SettingsSection } from '../layout/SettingsSection' import { ThemeWall } from '../theme/ThemeWall' import { useNoAcrylic, useTermSetup } from '../theme/useTermSetup' -import { acrylicLabel, acrylicSub, opt, sectionTitle, themeSub } from './opts' - -/** What the Save changes button says it does, in both places it is drawn. */ -export const SAVE_SETUP_TITLE = 'Saves the theme, agent colours and acrylic as Custom' +import { acrylicLabel, acrylicSub, opt, saveSetupTitle, sectionTitle, themeSub } from './opts' /** * THE THEME, as one section (2026-10-05): the theme row with Save changes, the @@ -38,15 +35,17 @@ export function TerminalThemeSection({ return ( - + {/* The material does not exist before Windows 11, so there the row says why instead of offering a switch that would do nothing. High Contrast - keeps the window solid (#156), and says so the same way; the stored - choice is kept for the next theme. */} + keeps the window solid (#156), and says so the same way. A theme + pick resets the switch with the rest of the setup, so a stored on + here is only one from before #156, and Save changes ignores it + (`termSetupState`). */} { // "see through" still finds it by its keywords in Prism's wording too. expect(row?.keywords).toContain('see through') }) -}) + it('Save changes names the setting as the page does, per host', () => { + configureTermCore(WINDOW) + expect(saveSetupTitle()).toBe('Saves the theme, agent colours and see-through window as Custom') + configureTermCore(STYLE) + expect(saveSetupTitle()).toBe('Saves the theme, agent colours and acrylic as Custom') + }) +}) \ No newline at end of file diff --git a/core/renderer/settings/sections/opts.ts b/core/renderer/settings/sections/opts.ts index 7fd7bf9..d5276ad 100644 --- a/core/renderer/settings/sections/opts.ts +++ b/core/renderer/settings/sections/opts.ts @@ -43,6 +43,14 @@ export const themeSub = (): string => export const acrylicLabel = (): string => hostOwnsWindowAcrylic() ? 'See-through window' : opt('term-acrylic').label +/** The setting as the rest of the page names it in a sentence (review of + * #156): Save changes' tooltip and the theme switch question said "acrylic" + * in Prism Terminal, where no row on the page is called that any more. */ +export const acrylicWord = (): string => (hostOwnsWindowAcrylic() ? 'see-through window' : 'acrylic') + +/** What Save changes says it does, in every place it is drawn. */ +export const saveSetupTitle = (): string => `Saves the theme, agent colours and ${acrylicWord()} as Custom` + /** The acrylic row's subtext, by what acrylic means in this host: the * window's own material, or the app style's let through the terminal. * `blocked`: the theme in force keeps the window solid (High Contrast, diff --git a/core/renderer/settings/theme/ThemeWall.tsx b/core/renderer/settings/theme/ThemeWall.tsx index 33ee5c9..ee115ed 100644 --- a/core/renderer/settings/theme/ThemeWall.tsx +++ b/core/renderer/settings/theme/ThemeWall.tsx @@ -3,7 +3,6 @@ import { followsHostStyle, hostDefaults, hostOwnsWindowAcrylic } from '../../hos import { applyCustomExtras, customTermTheme, - saveCustomTermTheme, setTermThemeId, termThemeId, useCustomTermTheme, @@ -19,7 +18,7 @@ import { SEE_THROUGH_MAX } from '../../lib/seeThrough' import { ColourField } from '../ColourPicker' import ThemeSwitchAsk from '../../components/ThemeSwitchAsk' import { ANSI_KEYS, cardAnsi, paletteOf, pickPreset, presetLook } from './palette' -import { useNoAcrylic, useTermSetup } from './useTermSetup' +import { saveAsCustom, useNoAcrylic, useTermSetup } from './useTermSetup' // THE THEME WALL, once for both layouts of the theme section (2026-10-05): // the cards, Show all, the switch question and the colour editor. Extracted @@ -288,7 +287,7 @@ export function ThemeWall({ onThemePicked, className = '' }: { onThemePicked?: ( // In force, not stored: High Contrast stays solid (#156), so its Alpha is inert. const acrylicOn = useTermAcrylicInForce() const custom = useCustomTermTheme() - const { dirty, save, extras } = useTermSetup() + const { dirty, save } = useTermSetup() const noAcrylic = useNoAcrylic() const windowAcrylic = hostOwnsWindowAcrylic() // The Custom card draws what the terminal draws (#113 review), not the raw @@ -459,8 +458,7 @@ export function ThemeWall({ onThemePicked, className = '' }: { onThemePicked?: ( // acrylic it held were gone for good. And it is a theme pick like // a card's (#29), so the host forgets its own window colours and // the edited background is the one that shows. - saveCustomTermTheme({ ...t, ...extras }) - setTermThemeId('custom') + saveAsCustom(t) setEditing(null) onThemePicked?.() }} diff --git a/core/renderer/settings/theme/useTermSetup.test.ts b/core/renderer/settings/theme/useTermSetup.test.ts new file mode 100644 index 0000000..ff367cd --- /dev/null +++ b/core/renderer/settings/theme/useTermSetup.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { configureTermCore, resetTermCore, type TermHostConfig } from '../../host' +import { + customTermTheme, + saveCustomTermTheme, + setTermAcrylic, + setTermThemeId, + termAcrylicInForce, + termGroundAlpha, + termThemeId +} from '../../lib/termLook' +import { saveTermSetup, termSetupState } from './useTermSetup' + +// Review of #156: Save changes must judge, and save, the setup IN FORCE, so a +// switch the user cannot see (High Contrast holds the window solid) neither +// lights it nor rides out on it, and a picked opaque Background does not light +// it by its lightness. + +let picked: string | null = null +const PRISM_TERMINAL: TermHostConfig = { + api: {} as TermHostConfig['api'], + defaults: { theme: 'prism', acrylic: false, indicator: 'minimal', agentColor: '', agentDoneColor: '' }, + followsHostStyle: false, + paintsGround: true, + themedAgentColors: () => ({ working: '#5b5bd6', finished: '#22c55e' }), + acrylic: { kind: 'window', supported: async () => true }, + ownsKey: () => false, + terminalGround: () => picked +} + +describe('the setup Save changes judges (#156 review)', () => { + beforeEach(() => { + localStorage.clear() + picked = null + configureTermCore(PRISM_TERMINAL) + }) + afterEach(() => resetTermCore()) + + it('High Contrast with the switch stored on is not dirty: the switch is not in force', () => { + setTermThemeId('high-contrast') + setTermAcrylic(true) + expect(termSetupState().dirty).toBe(false) + }) + + it('saving High Contrast keeps it solid: the Custom carries the switch in force', () => { + setTermThemeId('high-contrast') + setTermAcrylic(true) + saveTermSetup() + expect(termThemeId()).toBe('custom') + expect(customTermTheme()?.acrylic).toBe(false) + expect(termAcrylicInForce()).toBe(false) + expect(termGroundAlpha()).toBe(1) + }) + + it('an opaque picked Background does not light Save changes, light or dark', () => { + saveCustomTermTheme({ bg: '#111111', fg: '#eeeeee', cursor: '#ff0000', ansi: {}, acrylic: true }) + setTermThemeId('custom') + setTermAcrylic(true) + expect(termSetupState().dirty).toBe(false) + picked = '#ffffff' + expect(termSetupState().dirty).toBe(false) + picked = '#000000' + expect(termSetupState().dirty).toBe(false) + // A see-through pick still does. + picked = '#ffffff99' + expect(termSetupState().dirty).toBe(true) + }) + + it('a save under an opaque light pick keeps the Custom at its own level', () => { + saveCustomTermTheme({ bg: '#111111', fg: '#eeeeee', cursor: '#ff0000', ansi: {}, acrylic: true }) + setTermThemeId('custom') + setTermAcrylic(true) + picked = '#ffffff' + saveTermSetup() + expect(customTermTheme()?.bg).toBe('#111111b9') + }) +}) diff --git a/core/renderer/settings/theme/useTermSetup.ts b/core/renderer/settings/theme/useTermSetup.ts index e40ccff..eed0b46 100644 --- a/core/renderer/settings/theme/useTermSetup.ts +++ b/core/renderer/settings/theme/useTermSetup.ts @@ -1,10 +1,19 @@ import { useEffect, useState } from 'react' -import { hostOwnsWindowAcrylic, termHost } from '../../host' +import { hostGround, hostOwnsWindowAcrylic, termHost } from '../../host' import { + agentColorChoice, + agentDoneColorChoice, + agentQuestionColorChoice, + customTermTheme, saveCustomTermTheme, + seeThroughBlocked, + setTermAcrylic, setTermThemeId, paintsAlpha, + termAcrylic, + termAcrylicInForce, termExtraDefaults, + termGroundAlpha, termThemeId, useAgentColorChoice, useAgentDoneColorChoice, @@ -15,7 +24,7 @@ import { useTermThemeId, withGroundAlpha } from '../../lib/termLook' -import { alphaOf } from '../../lib/colour' +import { alphaOf, opaque } from '../../lib/colour' import { resolveTermTheme } from '../../lib/termTheme' import { paletteOf } from './palette' @@ -41,20 +50,36 @@ export interface TermSetup { } export function useTermSetup(): TermSetup { - const themeId = useTermThemeId() - const acrylicOn = useTermAcrylic() + // Subscribed for the re-render only; the state is read in one place below. + useTermThemeId() + useTermAcrylic() + useTermGroundAlpha() + useAgentColorChoice() + useAgentDoneColorChoice() + useAgentQuestionColorChoice() + useCustomTermTheme() + const { dirty, extras } = termSetupState() + return { dirty, save: saveTermSetup, extras } +} + +/** Dirty and the extras, from the stores as they are (tested). */ +export function termSetupState(): Pick { + const themeId = termThemeId() + // THE SWITCH IN FORCE, not the stored one (review of #156): under High + // Contrast the row shows the switch off and cannot be clicked, so a stored + // on (from before #156) must neither light Save changes for a change the + // user cannot see, nor ride out on a save into a Custom that is no longer + // held solid. In Prism the two are the same. + const acrylicOn = termAcrylicInForce() // The window's see-through (#114): the alpha of the ground in force, the // picked Background's first. A byte, so the comparison below is exact. - const groundByte = Math.round(useTermGroundAlpha() * 255) + const groundByte = Math.round(termGroundAlpha() * 255) + const custom = customTermTheme() // The CHOICES ('' = follow the theme) are what is saved and compared. - const agentCol = useAgentColorChoice() - const doneCol = useAgentDoneColorChoice() - const questionCol = useAgentQuestionColorChoice() - const custom = useCustomTermTheme() const extras = { - indicatorColor: agentCol, - doneColor: doneCol, - questionColor: questionCol, + indicatorColor: agentColorChoice(), + doneColor: agentDoneColorChoice(), + questionColor: agentQuestionColorChoice(), acrylic: acrylicOn } // Dirty = the SETTINGS deviate from the selected theme's stock: any theme @@ -67,7 +92,7 @@ export function useTermSetup(): TermSetup { indicatorColor: src?.indicatorColor ?? termExtraDefaults().indicatorColor, doneColor: src?.doneColor ?? termExtraDefaults().doneColor, questionColor: src?.questionColor ?? termExtraDefaults().questionColor, - acrylic: src?.acrylic ?? termExtraDefaults().acrylic + acrylic: (src?.acrylic ?? termExtraDefaults().acrylic) && !seeThroughBlocked() } // THE UNSAVED-CHANGES QUESTION SURVIVES THE SLIDER (#114, #60). Opacity was // one of the extras, so a changed one lit Save changes and a theme pick @@ -79,15 +104,35 @@ export function useTermSetup(): TermSetup { // `paintsAlpha`): an older Custom saved with acrylic on and an opaque `bg` // paints the default see-through, so it is not dirty on pick. A preset's own // setup has the host's default acrylic (off in Prism Terminal). - const ownByte = (): number => - Math.round(paintsAlpha(src ? alphaOf(src.bg) : 1, src?.bg ?? resolveTermTheme(themeId).background, baseline.acrylic) * 255) + // Its default level is measured from the ground the WINDOW measures from, + // the picked Background first (review of #156): an opaque pick changes no + // setting, so it must not light Save changes by being lighter or darker + // than the theme's own ground. + const ownByte = (): number => { + const picked = hostGround() + const ground = picked ? opaque(picked) : (src?.bg ?? resolveTermTheme(themeId).background) + return Math.round(paintsAlpha(src ? alphaOf(src.bg) : 1, ground, baseline.acrylic) * 255) + } const dirty = JSON.stringify(extras) !== JSON.stringify(baseline) || (hostOwnsWindowAcrylic() && groundByte !== ownByte()) - const save = (): void => { - saveCustomTermTheme({ ...withGroundAlpha(paletteOf(termThemeId())), ...extras }) - setTermThemeId('custom') - } - return { dirty, save, extras } + return { dirty, extras } +} + +/** Save the whole setup in force as Custom, and select it. */ +export function saveTermSetup(): void { + saveAsCustom(withGroundAlpha(paletteOf(termThemeId()))) +} + +/** Save a palette with the setup in force as Custom, and select it: Save + * changes, and the colour editor's save. */ +export function saveAsCustom(palette: ReturnType): void { + // Read before the switch to Custom: what is in force depends on the theme. + const { extras } = termSetupState() + saveCustomTermTheme({ ...palette, ...extras }) + setTermThemeId('custom') + // A Custom is never held solid, so the stored switch must say what the + // window showed: a save under High Contrast stays solid. + if (termAcrylic() !== extras.acrylic) setTermAcrylic(extras.acrylic) } /** diff --git a/docs/regression-rules.md b/docs/regression-rules.md index aa93536..f377d9d 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -183,8 +183,9 @@ one-liner in `CLAUDE.md` only if it is among the most important. Shift goes the other way. The list is reordered ONLY when the hold ends: Control's keyup (window, capture), the window's blur, or any other Ctrl chord. Reordering on each press makes the second Tab flip straight back (a ping-pong) and the oldest tabs unreachable. Any other - activation (a click, Ctrl+1..9, a new tab, a close handing over) is a use at once; closed tabs - leave the list. Not persisted. Listens to Control's keyup only, never `onData` (rule 10). Held by + activation (a click, Ctrl+1..9, a new tab, a close handing over) is a use at once, and one during + a hold (a click with Ctrl still down, a tab opened or closed) ends the hold (`walkHolds`); closed + tabs leave the list. Not persisted. Listens to Control's keyup only, never `onData` (rule 10). Held by `tabMru.test.ts`, `tabSwitchPrefs.test.ts` and the `tabSwitch` e2e. - **The window's edge is a faint hairline that follows the theme** (owner, same day; `windowEdge.ts` + Prism's `dwmHelper.ts`). DWM's border is always one physical pixel, so it cannot diff --git a/docs/superpowers/plans/2026-10-10-look-and-mru.md b/docs/superpowers/plans/2026-10-10-look-and-mru.md index 7835abb..4a4f000 100644 --- a/docs/superpowers/plans/2026-10-10-look-and-mru.md +++ b/docs/superpowers/plans/2026-10-10-look-and-mru.md @@ -91,7 +91,7 @@ build agents; e2e only in the Gate task, one run at a time. - Any other Ctrl chord handled in `onKey` first commits a running walk. - `keyup` (capture) on window: `e.key === 'Control'` commits; `blur` on window commits. Commit = `walk = null; mru = touchMru(mru, activeId)`. Remove both listeners in the cleanup. -4. `appOptions.ts`: `{ id: 'tab-switch', label: 'Tab switching', sub: 'Where Ctrl+Tab goes next.', +4. `appOptions.ts`: `{ id: 'tab-switch', label: 'Tab switching', sub: 'Which tab comes next when you switch.', section: 'window', page: 'appearance', icon: 'key', keywords: 'ctrl tab mru recent order cycle switch next previous last used', store: ['prism.window.tabSwitch'] }`, after `tab-style`. 5. `AppearancePage.tsx` Window section: a `SettingRow id="tab-switch"` with diff --git a/docs/superpowers/specs/2026-10-10-look-and-mru-design.md b/docs/superpowers/specs/2026-10-10-look-and-mru-design.md index 93a2612..eb94736 100644 --- a/docs/superpowers/specs/2026-10-10-look-and-mru-design.md +++ b/docs/superpowers/specs/2026-10-10-look-and-mru-design.md @@ -102,13 +102,18 @@ PT already has the switch, under another name and with a gap that makes it look when the theme in force is `high-contrast`, the row is drawn off and disabled with the sub "High contrast stays solid.", and the window paints solid whatever is stored (`termAcrylicInForce()` in `termLook.ts` = `termAcrylic()` and not that case; `paintChrome`, - the Background row and the editor read it). The stored choice is kept, so leaving High Contrast - gives it back. + the Background row and the editor read it). A theme pick resets the switch with the rest of the + setup (`resetTermExtras`), so a stored on under High Contrast only comes from before #156; Save + changes judges and saves the switch IN FORCE (review: a hidden stored on lit it, and a save + carried it into a Custom that was no longer held solid). 7. **Save changes** keeps working: the switch is a theme extra already (`termExtraDefaults().acrylic`), and Save as Custom stores `withGroundAlpha(...)`, which now carries the default alpha, so a saved Custom stays see-through. `useTermSetup`'s dirty check compares the ground alpha in force with the alpha the saved setup paints, computed by the SAME rule (an older Custom saved with - `acrylic: true` and an opaque `bg` paints the default, so it is not dirty on pick). + `acrylic: true` and an opaque `bg` paints the default, so it is not dirty on pick). The default + level is measured from the ground the window measures from, the picked Background first, so an + opaque pick does not light Save changes by its lightness; `withGroundAlpha` measures from the + palette's own ground, so a save keeps the Custom's own level. 8. **Contrast.** Chrome text fills are already flattened under a see-through ground (`chromeTheme.ts`, #114). Unit-test every preset at the two default alphas (text 4.5:1 on the composite, as the existing `under a see-through ground` test does at its alpha). @@ -171,8 +176,8 @@ other. `termHost.ts:70` claims Ctrl+Tab from xterm. There is no other next/previ - Setting **"Tab switching"** (`tab-switch`), a Segmented control, **"In order"** (default) / **"Most recent"**, in Settings > Appearance > Window, right after Tab style (the strip's other - rows). Sub at rest "Where Ctrl+Tab goes next."; live sub "Ctrl+Tab follows the strip." / - "Ctrl+Tab goes to the tab you used last." Icon `key`. Store `prism.window.tabSwitch` + rows). Sub at rest "Which tab comes next when you switch."; live sub "The next tab along the + strip." / "Back to the tab you used last." (as built: settings text names no keys). Icon `key`. Store `prism.window.tabSwitch` (`'order' | 'recent'`, anything else reads 'order'), in a new `src/renderer/src/lib/tabSwitchPrefs.ts` shaped like `tabWidthPrefs.ts`. Never touched: In order, so nobody's keys change on update. - In **Most recent**, Ctrl+Tab and Ctrl+Shift+Tab walk the most-recently-used list, as browsers and @@ -185,13 +190,14 @@ other. `termHost.ts:70` claims Ctrl+Tab from xterm. There is no other next/previ Ctrl+Tab flips between the last two tabs, and repeated presses walk instead of ping-ponging. - Any other activation (a click, Ctrl+1..9, Ctrl+T, Ctrl+, , a close handing over) moves the tab in front to the head of the list at once. Any other Ctrl chord pressed during a walk ends the - walk first (commit), then acts. + walk first (commit), then acts. A click on another tab, or a tab opened or closed, while Ctrl is + still held ends the walk too (`walkHolds`): that activation is a use at once. - Closed tabs leave the list; new tabs enter it (at the front, as they are activated). At launch the list is the active tab, then the rest in strip order (not persisted: history across launches is not a thing the owner asked for). The Settings tab takes part, as in In order. - Under a close question or the update window the chords still do nothing (rule from #10). - Pure `src/renderer/src/lib/tabMru.ts`: `touchMru`, `syncMru(mru, tabIds, activeId)`, - `startWalk`, `stepWalk`, `walkTarget`. App holds the list and the walk in refs and listens to + `startWalk`, `stepWalk`, `walkTarget`, `walkHolds`. App holds the list and the walk in refs and listens to keyup (capture) and window blur. In order keeps `stepTab` exactly. - Regression rule 10 is untouched: this listens to the Control keyup only, never to `onData`. diff --git a/docs/two-apps.md b/docs/two-apps.md index 6126c58..a8236ae 100644 --- a/docs/two-apps.md +++ b/docs/two-apps.md @@ -384,7 +384,8 @@ so an update never silently changes what an existing user sees; the bridge to ma 0xd1, light measured as luminance > 0.4; `lib/seeThrough.ts`, and `paintsAlpha` in `termLook.ts`, shared by the window and Save changes' dirty check). A ground with an alpha keeps it; under the switch the Alpha stops at 95% (`SEE_THROUGH_MAX`: opaque is the switch's off). High Contrast - stays solid (`termAcrylicInForce`; the row is drawn off with "High contrast stays solid."). + stays solid (`termAcrylicInForce`; the row is drawn off with "High contrast stays solid."), and Save + changes judges and saves the switch in force there, not the stored one (`termSetupState`). In this app the row reads "See-through window" / "The desktop shows behind every surface." (`acrylicLabel`, `acrylicSub`); Prism keeps "Acrylic terminal background", a terminal row under its own app-level See-through window, and nothing there changes. The `seeThrough` e2e holds it. diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 44407fe..2f0acdf 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,7 +14,7 @@ import { TabStrip } from './components/TabStrip' import TitleBar, { TitleButtons } from './components/TitleBar' import { useTitleBarMode } from './lib/titleBarPrefs' import { tabSwitch } from './lib/tabSwitchPrefs' -import { startWalk, stepWalk, syncMru, touchMru, walkTarget, type Mru, type Walk } from './lib/tabMru' +import { startWalk, stepWalk, syncMru, touchMru, walkHolds, walkTarget, type Mru, type Walk } from './lib/tabMru' import EmptyState from './components/EmptyState' import type { SettingsPage } from './components/settings/Settings' import { Dialog } from './components/Dialog' @@ -231,10 +231,11 @@ export default function App(): JSX.Element { tabs.map((t) => t.id), activeId ) - // A tab closed under a walk could be its next stop: the hold ends there. - if (walk.current && walk.current.list.some((id) => !tabs.some((t) => t.id === id))) walk.current = null - // Any activation that is not the walk's (a click, Ctrl+1..9, a new tab, a - // close handing over) is a use at once. + // The hold ends on anything that is not its own step (walkHolds): a click + // on another tab while Ctrl is still down, or a tab opened or closed under + // it. Then the activation is a use at once, as it is outside a hold (a + // click, Ctrl+1..9, a new tab, a close handing over). + if (walk.current && !walkHolds(walk.current, tabs.map((t) => t.id), activeId)) walk.current = null if (!walk.current && activeId) mru.current = touchMru(mru.current, activeId) }, [tabs, activeId]) useEffect(() => { diff --git a/src/renderer/src/lib/tabMru.test.ts b/src/renderer/src/lib/tabMru.test.ts index 41e98f7..53ac192 100644 --- a/src/renderer/src/lib/tabMru.test.ts +++ b/src/renderer/src/lib/tabMru.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { startWalk, stepWalk, syncMru, touchMru, walkTarget, type Walk } from './tabMru' +import { startWalk, stepWalk, syncMru, touchMru, walkHolds, walkTarget, type Walk } from './tabMru' /** A hold of Ctrl: the walk started from `mru`, then each Tab (1) or * Shift+Tab (-1) pressed while it is held. Returns the tab landed on. */ @@ -81,6 +81,18 @@ describe('the walk (Most recent, #158)', () => { expect(walkTarget(walk)).toBe('b') }) + it('the walk holds only while the tab in front is its own and the strip is its snapshot', () => { + const walk = stepWalk(startWalk(['d', 'c', 'b', 'a'])!, 1) // landed on c + expect(walkHolds(walk, ['a', 'b', 'c', 'd'], 'c')).toBe(true) + // A click on another tab mid-hold is a use, not a step: the hold ends. + expect(walkHolds(walk, ['a', 'b', 'c', 'd'], 'a')).toBe(false) + // A tab opened without Ctrl (a folder from Explorer) is not in the snapshot. + expect(walkHolds(walk, ['a', 'b', 'c', 'd', 'x'], 'x')).toBe(false) + expect(walkHolds(walk, ['a', 'b', 'c', 'd', 'x'], 'c')).toBe(false) + // A closed tab could be its next stop. + expect(walkHolds(walk, ['b', 'c', 'd'], 'c')).toBe(false) + }) + it('over one tab or none there is nothing to walk', () => { expect(startWalk(['a'])).toBeNull() expect(startWalk([])).toBeNull() diff --git a/src/renderer/src/lib/tabMru.ts b/src/renderer/src/lib/tabMru.ts index 674f5f1..ecaaee9 100644 --- a/src/renderer/src/lib/tabMru.ts +++ b/src/renderer/src/lib/tabMru.ts @@ -57,3 +57,16 @@ export function stepWalk(walk: Walk, dir: 1 | -1): Walk { export function walkTarget(walk: Walk): string { return walk.list[walk.at] } + +/** + * Whether a hold still walks the strip as it is (review of #158). Only while + * the tab in front is the one the walk landed on and the strip holds exactly + * the snapshot's tabs: a click on another tab mid-hold is a use like any + * other, and a tab opened or closed without a Ctrl key (a folder from + * Explorer) is one the next Tab press would otherwise walk away from, or to. + */ +export function walkHolds(walk: Walk, tabIds: readonly string[], activeId: string | null): boolean { + if (activeId !== walkTarget(walk) || tabIds.length !== walk.list.length) return false + const open = new Set(tabIds) + return walk.list.every((id) => open.has(id)) +} From aae2255656056ef883dfe638bfa8760d0c3ea6a4 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:07:35 +0200 Subject: [PATCH 6/8] test(e2e): the gate follows the see-through row and the accent on switches (#156) - accent: the see-through window sits between the theme wall and Background/Accent since #156 moved it there. - settingsSearch: Prism Terminal names the row "See-through window" (acrylicLabel), so the gate searches by that; "acrylic" joins the row's keywords so the old name still finds it. - settingsLook: an on switch wears the accent on purpose since #138/#139; the check failed on the base (#154) too, now it allows on switches and still catches any other accent-filled button. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/settings/options.ts | 2 +- tools/e2e/run.mjs | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/core/renderer/settings/options.ts b/core/renderer/settings/options.ts index 72eee45..19419a3 100644 --- a/core/renderer/settings/options.ts +++ b/core/renderer/settings/options.ts @@ -51,7 +51,7 @@ export const TERMINAL_OPTIONS: readonly TerminalOption[] = [ { id: 'agent-hooks', label: 'Exact status from Claude Code', type: 'switch', key: 'prism.term.agentHooks', section: 'claude', icon: 'hook', sub: 'Applies to terminals opened after a change.', keywords: 'hooks plugin status anthropic' }, // The theme wall, and under it only what a theme sets (2026-09-28). { id: 'term-theme', label: 'Terminal theme', type: 'theme', key: 'prism.term.theme', section: 'theme', icon: 'brush', sub: 'Colours of the terminal and the window.', keywords: 'colors palette scheme dark light preset custom' }, - { id: 'term-acrylic', label: 'Acrylic terminal background', type: 'switch', key: 'prism.term.acrylic', section: 'theme', icon: 'glass', sub: 'The desktop shows through the window.', keywords: 'transparent glass blur mica see through' }, + { id: 'term-acrylic', label: 'Acrylic terminal background', type: 'switch', key: 'prism.term.acrylic', section: 'theme', icon: 'glass', sub: 'The desktop shows through the window.', keywords: 'acrylic transparent glass blur mica see through' }, // No Opacity row (#114): the theme Background's alpha is the window's // see-through where the terminal owns the window acrylic. { id: 'agent-color', label: 'Agent working colour', type: 'colour', key: 'prism.term.agentColor', section: 'colours', icon: 'working', sub: 'Follows the accent.', keywords: 'color indicator busy' }, diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 2e7a65e..6aa36c7 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -3395,7 +3395,11 @@ const scenarios = { chosen: chosen ? parse(getComputedStyle(chosen).backgroundColor) : null, hoverHi: parse(root.getPropertyValue('--p-hover-hi').trim()), accent: parse(root.getPropertyValue('--p-accent').trim()), - accentButtons: accentFilled.map((b) => (b.hasAttribute('data-save-term') ? 'save' : b.textContent.trim())), + // An ON switch wears the accent too, on purpose (#138, #139), so it + // is named and allowed; any other accent-filled button is a miss. + accentButtons: accentFilled.map((b) => + b.hasAttribute('data-save-term') ? 'save' : b.getAttribute('role') === 'switch' && b.getAttribute('aria-checked') === 'true' ? 'switch' : b.textContent.trim() + ), sideways: document.querySelector('[data-settings-page]').scrollWidth > document.querySelector('[data-settings-page]').clientWidth + 1 } }) @@ -3417,7 +3421,7 @@ const scenarios = { if (m.warn !== null) ok(m.warn >= 4.5, `${scheme} ${p}: a warning subtext reads 4.5:1 (${m.warn.toFixed(1)}:1)`) ok(!!m.chosen && m.chosen.rgb.join() === m.hoverHi.rgb.join() && Math.abs(m.chosen.a - m.hoverHi.a) < 0.02, `${scheme} ${p}: the chosen rail page is the grey fill (${JSON.stringify(m.chosen)})`) ok(!!m.chosen && m.chosen.rgb.join() !== m.accent.rgb.join(), `${scheme} ${p}: and not the accent`) - ok(m.accentButtons.every((b) => b === 'save'), `${scheme} ${p}: the only accent-filled buttons are Save changes (${JSON.stringify(m.accentButtons)})`) + ok(m.accentButtons.every((b) => b === 'save' || b === 'switch'), `${scheme} ${p}: the only accent-filled buttons are Save changes and on switches (${JSON.stringify(m.accentButtons)})`) ok(!m.sideways, `${scheme} ${p}: nothing scrolls sideways at 1600px`) await page.screenshot({ path: resolve(process.cwd(), `.e2e-shots/settings-${p}-${scheme}.png`) }).catch(() => {}) } @@ -3486,6 +3490,9 @@ const scenarios = { for (const file of ['core/renderer/settings/options.ts', 'core/renderer/settings/markOptions.ts', 'core/renderer/settings/dictationOptions.ts', 'core/renderer/settings/helpOptions.ts', 'src/renderer/src/components/settings/appOptions.ts']) for (const m of readFileSync(resolve(process.cwd(), file), 'utf8').matchAll(/\{\s*id: '([a-z-]+)'[^}]*\}/g)) if (!m[0].includes('onlyWhere')) labelOf[m[1]] = (m[0].match(/label: '([^']+)'/) ?? [])[1] + // options.ts keeps Prism's name for this row; Prism Terminal draws and + // indexes it as the see-through window (#156, `acrylicLabel`). + labelOf['term-acrylic'] = 'See-through window' const order = [...readFileSync(resolve(process.cwd(), 'src/renderer/src/components/settings/settingsIndex.ts'), 'utf8').matchAll(/'([a-z]+(?:-[a-z]+)+|[a-z]+-[a-z]+)'/g)].map((m) => m[1]) const ids = [...new Set(order.filter((id) => labelOf[id]))] ok(ids.length >= 30, `the index covers every row drawn on this PC (${ids.length})`) @@ -4972,12 +4979,13 @@ const scenarios = { ) // BACKGROUND AND ACCENT SIT RIGHT UNDER THE THEME WALL (2026-09-28: a - // theme sets them, so they follow it; they sat under Font size before). + // theme sets them, so they follow it; they sat under Font size before), + // after the see-through window, which moved up to the wall in #156. const order = await page.evaluate(() => [...document.querySelectorAll('[data-pref]')].map((e) => e.getAttribute('data-pref'))) const at = order.indexOf('term-theme') ok( - at >= 0 && order[at + 1] === 'window-background' && order[at + 2] === 'window-accent', - `Background and Accent come right after the theme wall (${order.slice(Math.max(0, at - 1), at + 4).join(' > ')})` + at >= 0 && order[at + 1] === 'term-acrylic' && order[at + 2] === 'window-background' && order[at + 3] === 'window-accent', + `See-through, Background and Accent come right after the theme wall (${order.slice(Math.max(0, at - 1), at + 5).join(' > ')})` ) // THE BACKGROUND (owner: "let background colour be a setting"): the From bd68bc374035712b494ec14dc3cd5ac00ae3e4e3 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:02:56 +0200 Subject: [PATCH 7/8] fix(core): Save changes saves the see-through the window paints Under an opaque picked Background, Save changes stored the default see-through measured from the theme's own ground (b9 for a dark Custom) while the window and the dirty check measured from the pick (d1 for white), so the setup was dirty again right after every save. withGroundAlpha now carries termGroundAlpha(), the window's alpha. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/lib/termLook.ts | 13 +++++++------ core/renderer/settings/theme/useTermSetup.test.ts | 7 +++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/core/renderer/lib/termLook.ts b/core/renderer/lib/termLook.ts index 5589c56..fd83f21 100644 --- a/core/renderer/lib/termLook.ts +++ b/core/renderer/lib/termLook.ts @@ -497,15 +497,16 @@ function rawGroundAlpha(): number { * through a save (the Opacity slider was carried in the setup the same way). * Where the host's style owns the glass (Prism), the palette as it is. * - * The default see-through is measured from the PALETTE's own ground, not the - * picked Background (review of #156): a dark Custom saved under an opaque - * white pick keeps the dark level, the one it paints by itself, and the one - * Save changes' dirty check expects. Without an opaque pick the two are the - * same ground, so this is `termGroundAlpha()`. + * It is the alpha the WINDOW paints (`termGroundAlpha`), the default measured + * from the picked Background where one is in force: what the user saw is what + * is saved, and what Save changes' dirty check reads back. Measuring from the + * palette's own ground instead (review of #156) saved a dark Custom under an + * opaque white pick at b9 while the window and the check stayed at d1, so + * Save changes stayed lit after every save. */ export function withGroundAlpha(palette: T): T { if (!hostOwnsWindowAcrylic()) return palette - return { ...palette, bg: withAlpha(palette.bg, paintsAlpha(rawGroundAlpha(), opaque(palette.bg), termAcrylicInForce())) } + return { ...palette, bg: withAlpha(palette.bg, termGroundAlpha()) } } export function saveCustomTermTheme(theme: CustomTermTheme): void { diff --git a/core/renderer/settings/theme/useTermSetup.test.ts b/core/renderer/settings/theme/useTermSetup.test.ts index ff367cd..4a3c461 100644 --- a/core/renderer/settings/theme/useTermSetup.test.ts +++ b/core/renderer/settings/theme/useTermSetup.test.ts @@ -66,12 +66,15 @@ describe('the setup Save changes judges (#156 review)', () => { expect(termSetupState().dirty).toBe(true) }) - it('a save under an opaque light pick keeps the Custom at its own level', () => { + it('a save under an opaque light pick is clean afterwards', () => { saveCustomTermTheme({ bg: '#111111', fg: '#eeeeee', cursor: '#ff0000', ansi: {}, acrylic: true }) setTermThemeId('custom') setTermAcrylic(true) picked = '#ffffff' saveTermSetup() - expect(customTermTheme()?.bg).toBe('#111111b9') + // The level the window painted (white's d1), not the Custom's own b9: + // saving b9 left Save changes lit after every save. + expect(customTermTheme()?.bg).toBe('#111111d1') + expect(termSetupState().dirty).toBe(false) }) }) From b0b77ac7e0070444be194f0968df0cf3cea82517 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:21:37 +0200 Subject: [PATCH 8/8] fix(core): the colour editor opens a preset at its own see-through withGroundAlpha measured the default see-through from the window's ground, which without a pick is the ACTIVE theme's. Every preset card has an edit button, so a light preset edited under a dark active theme opened, and saved as Custom, at b9 instead of d1. It now measures from the pick when there is one (the window's alpha, as the last fix wants) and otherwise from the palette's own ground. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/lib/termLook.ts | 8 +++++++- core/renderer/settings/theme/useTermSetup.test.ts | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/core/renderer/lib/termLook.ts b/core/renderer/lib/termLook.ts index fd83f21..f7fe728 100644 --- a/core/renderer/lib/termLook.ts +++ b/core/renderer/lib/termLook.ts @@ -503,10 +503,16 @@ function rawGroundAlpha(): number { * palette's own ground instead (review of #156) saved a dark Custom under an * opaque white pick at b9 while the window and the check stayed at d1, so * Save changes stayed lit after every save. + * + * Without a pick the ground is the PALETTE's own, not the active theme's: + * every preset card has an edit button, and a light preset edited under a + * dark active theme opened, and saved, at the dark level (review of the fix + * above). For the active theme the two are the same ground. */ export function withGroundAlpha(palette: T): T { if (!hostOwnsWindowAcrylic()) return palette - return { ...palette, bg: withAlpha(palette.bg, termGroundAlpha()) } + const ground = opaque(hostGround() ?? palette.bg) + return { ...palette, bg: withAlpha(palette.bg, paintsAlpha(rawGroundAlpha(), ground, termAcrylicInForce())) } } export function saveCustomTermTheme(theme: CustomTermTheme): void { diff --git a/core/renderer/settings/theme/useTermSetup.test.ts b/core/renderer/settings/theme/useTermSetup.test.ts index 4a3c461..6997b86 100644 --- a/core/renderer/settings/theme/useTermSetup.test.ts +++ b/core/renderer/settings/theme/useTermSetup.test.ts @@ -7,8 +7,10 @@ import { setTermThemeId, termAcrylicInForce, termGroundAlpha, - termThemeId + termThemeId, + withGroundAlpha } from '../../lib/termLook' +import { paletteOf } from './palette' import { saveTermSetup, termSetupState } from './useTermSetup' // Review of #156: Save changes must judge, and save, the setup IN FORCE, so a @@ -77,4 +79,15 @@ describe('the setup Save changes judges (#156 review)', () => { expect(customTermTheme()?.bg).toBe('#111111d1') expect(termSetupState().dirty).toBe(false) }) + + it('editing a light preset that is not the active theme opens at its own level', () => { + // Every preset card has an edit button: with no pick, the window's ground + // is the ACTIVE (dark) theme's, which says nothing about this palette. + setTermThemeId('prism') + setTermAcrylic(true) + expect(withGroundAlpha(paletteOf('catppuccin-latte')).bg.slice(-2)).toBe('d1') + // Under an opaque pick the pick is what paints, for any palette. + picked = '#111111' + expect(withGroundAlpha(paletteOf('catppuccin-latte')).bg.slice(-2)).toBe('b9') + }) })