From c9516c383bdbdd9ad905f0ca26e84b2620817e57 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:39:18 +0200 Subject: [PATCH 1/6] feat(settings): an accent colour option for the window Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 12 +++- src/renderer/src/App.tsx | 6 +- src/renderer/src/components/Settings.tsx | 49 +++++++++++++++- src/renderer/src/lib/accentPrefs.test.ts | 34 ++++++++++++ src/renderer/src/lib/accentPrefs.ts | 53 ++++++++++++++++++ src/renderer/src/lib/chromeTheme.test.ts | 26 +++++++++ src/renderer/src/lib/chromeTheme.ts | 16 +++++- tools/e2e/run.mjs | 71 +++++++++++++++++++++++- 8 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/lib/accentPrefs.test.ts create mode 100644 src/renderer/src/lib/accentPrefs.ts diff --git a/CLAUDE.md b/CLAUDE.md index 41be84e..5dfcbe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ so an update never silently changes what an existing user sees; the bridge to ma same"). `core/renderer/settings`: the field primitives, `TerminalAppearanceSettings` (theme wall and editor, font, size, acrylic, the two indicator colours) and the rows `ShellSetting` / `AgentIndicatorSetting`. Each app composes its OWN page round them (`components/Settings.tsx` here - is only the page plus this app's rows: new tabs, Explorer menu, window edges, version). Values are per app (own + is only the page plus this app's rows: new tabs, Explorer menu, window edges, accent, version). Values are per app (own userData), never shared. `settings/options.ts` lists every terminal option by id; a unit test holds the list and the sections together, and each app's e2e (`options`) asserts its page shows that list and no terminal-looking row of its own. A row outside the list is a fork. @@ -395,6 +395,16 @@ terminal theme, anything that reads or shows files. asserts what main HEARD, and how the border looks is on the hands-on list. The `edges` e2e measures real edges (a tab separator, the title bar's rule, the settings rail, a settings row), WAITING for each to arrive, since the strip's border colour transitions over 550ms. +- **THE ACCENT IS A SETTING, AND UNSET IS THE THEME'S** (owner, 2026-09-22: "add an accent colour + option which would pick the accents you see, like the blue highlight effect and tab effect"). + Settings > Appearance > Accent colour (`window-accent`, localStorage `prism.window.accent`, store + `lib/accentPrefs.ts`), a hex field and swatch showing the theme's own accent until a colour is + picked, then a "Follow theme" button that forgets it. This app's row, for the edges' reason (in + Prism the accent is the app style's). Applied ONCE, as `chromeTokens`' `chosenAccent`: every + accent-derived token (`--p-accent`, `--p-accent-hi`, `--p-sel-bg`, `--p-on-accent`) follows, and + no component knows. A chosen colour is KEPT where the ground can show it and only MOVED to the + 3:1 floor where it cannot, never swapped for another candidate the way the theme's own are, + since it was somebody's pick. The `accent` e2e measures the active tab's rule, not just the token. - **THE CLOSE QUESTION IS ONE RULE, NOT A SETTING** (owner, 2026-09-19, #15: "remove the setting but just have it on smart mode by default, so it won't ask if you're in a normal shell but if you're working with an agent it will ask"). `core/renderer/lib/agentClose.ts`, the same in Prism: a plain diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 558894c..47e3d32 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -55,6 +55,7 @@ import { import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' import { applyChrome, chromeTokens } from './lib/chromeTheme' import { onWindowEdgesChange, windowEdges } from './lib/edgesPrefs' +import { onWindowAccentChange, windowAccent } from './lib/accentPrefs' const Settings = lazy(() => import('./components/Settings')) // Loaded when it is first opened: the popup brings the whole catalogue with it, @@ -90,7 +91,8 @@ function paintChrome(): void { resolveTermTheme(id), acrylic ? termOpacity() : 100, presetAccent(id), - edges + edges, + windowAccent() ) applyChrome(tokens) window.prism.setWindowBg(tokens.vars['--p-bg-solid']) @@ -133,9 +135,11 @@ export default function App(): JSX.Element { paintChrome() const offLook = onTermLookChange(paintChrome) const offEdges = onWindowEdgesChange(paintChrome) + const offAccent = onWindowAccentChange(paintChrome) return () => { offLook() offEdges() + offAccent() } }, []) diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index aae1cd8..f5a8e6f 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -1,8 +1,12 @@ -import { useEffect, useState, type JSX, type ReactNode } from 'react' +import { useEffect, useState, useSyncExternalStore, type JSX, type ReactNode } from 'react' import { setNewTabMode, useNewTabFolder, useNewTabMode } from '../lib/newTabPrefs' import { setWindowEdges, useWindowEdges } from '../lib/edgesPrefs' import { WINDOW_EDGES, type WindowEdges } from '@shared/windowEdges' -import { Pref, ROWS, ROW_BUTTON, Segmented, Switch } from '@core/renderer/settings/fields' +import { HexSwatch, Pref, ROWS, ROW_BUTTON, Segmented, Switch } from '@core/renderer/settings/fields' +import { setWindowAccent, useWindowAccent } from '../lib/accentPrefs' +import { chromeTokens } from '../lib/chromeTheme' +import { onTermLookChange, termThemeId } from '@core/renderer/lib/termLook' +import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' import { DictationSettings } from '@core/renderer/settings/Dictation' import { HelpSetting } from '@core/renderer/settings/Help' import { TerminalAppearanceSettings } from '@core/renderer/settings/TerminalAppearance' @@ -161,11 +165,52 @@ const EDGE_OPTIONS: Array<{ id: WindowEdges; name: string }> = WINDOW_EDGES.map( * reason. No `ROWS` wrapper: the list above ends in its own bottom rule, and a * second top rule under it would be a doubled line (at "solid", a visible one). */ +/** The accent the THEME in force would give the window, which is what the + * 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. */ +const themeAccent = (): string => { + const id = termThemeId() + return chromeTokens(resolveTermTheme(id), 100, presetAccent(id)).vars['--p-accent'] +} + +/** + * The window's accent (owner, 2026-09-22: "add an accent colour option which + * would pick the accents you see, like the blue highlight effect and tab + * effect"). Follows the theme until a colour is picked; "Follow theme" puts + * it back. This app's own row, for the edges' reason: in Prism the accent is + * the app style's, which has its own picker. + */ +function AccentSetting(): JSX.Element { + const chosen = useWindowAccent() + const fromTheme = useSyncExternalStore(onTermLookChange, themeAccent) + return ( + +
+ {chosen && ( + + )} + +
+
+ ) +} + function AppearanceTab(): JSX.Element { const edges = useWindowEdges() return ( <> + { + beforeEach(() => localStorage.clear()) + + it('follows the theme until somebody chooses', () => { + expect(windowAccent()).toBeNull() + }) + + it('remembers a chosen colour, in lower case', () => { + setWindowAccent('#E07A2F') + expect(windowAccent()).toBe('#e07a2f') + }) + + it('reads anything that is not a six-digit hex as "follow the theme"', () => { + for (const junk of ['', 'orange', '#fff', '#12345g', 'null', '#1234567']) { + localStorage.setItem('prism.window.accent', junk) + expect(windowAccent(), junk).toBeNull() + } + }) + + it('forgets the choice when handed null, and tells the painter both times', () => { + const cb = vi.fn() + const off = onWindowAccentChange(cb) + setWindowAccent('#336699') + setWindowAccent(null) + expect(windowAccent()).toBeNull() + expect(cb).toHaveBeenCalledTimes(2) + off() + setWindowAccent('#336699') + expect(cb).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/lib/accentPrefs.ts b/src/renderer/src/lib/accentPrefs.ts new file mode 100644 index 0000000..046c14f --- /dev/null +++ b/src/renderer/src/lib/accentPrefs.ts @@ -0,0 +1,53 @@ +import { useSyncExternalStore } from 'react' + +// THE WINDOW'S ACCENT, chosen (owner, 2026-09-22: "add an accent colour option +// which would pick the accents you see, like the blue highlight effect and tab +// effect"). Unset, the accent is the theme's own, exactly as before: the +// preset's opinion if it has one, else its blue. Set, it is this colour, and +// every token derived from the accent - the selection fill, the ink on it, the +// active tab's rule, the brighter variant - follows it. +// +// THIS APP'S OWN setting, like the edges (edgesPrefs.ts): the accent is the +// window's chrome, which in Prism belongs to the app style and has a picker of +// its own there, so it is not one of the core's options and Prism's terminal +// page never shows it. +// +// Read defensively: anything that is not a six-digit hex is "follow the theme". + +const KEY = 'prism.window.accent' + +let listeners: Array<() => void> = [] + +const HEX = /^#[0-9a-f]{6}$/i + +/** The chosen accent, lower case, or null to follow the theme. */ +export function windowAccent(): string | null { + try { + const raw = localStorage.getItem(KEY) + return raw && HEX.test(raw) ? raw.toLowerCase() : null + } catch { + return null + } +} + +/** A hex colour to wear it, or null to follow the theme again. */ +export function setWindowAccent(hex: string | null): void { + try { + if (hex && HEX.test(hex)) localStorage.setItem(KEY, hex.toLowerCase()) + else localStorage.removeItem(KEY) + } catch { + /* a full or blocked store loses the choice, not the app */ + } + listeners.forEach((l) => l()) +} + +/** For App, which repaints the chrome: Settings writes the store and nothing + * else, the same deal the edges have. */ +export function onWindowAccentChange(cb: () => void): () => void { + listeners.push(cb) + return () => { + listeners = listeners.filter((l) => l !== cb) + } +} + +export const useWindowAccent = (): string | null => useSyncExternalStore(onWindowAccentChange, windowAccent) diff --git a/src/renderer/src/lib/chromeTheme.test.ts b/src/renderer/src/lib/chromeTheme.test.ts index 25edd0e..7543b8b 100644 --- a/src/renderer/src/lib/chromeTheme.test.ts +++ b/src/renderer/src/lib/chromeTheme.test.ts @@ -26,6 +26,32 @@ describe('chromeTokens', () => { expect(contrastRatio(vars['--p-on-accent'], vars['--p-sel-bg'])).toBeGreaterThanOrEqual(4.5) } ) + it('wears a chosen accent exactly when the ground can show it', () => { + const theme = resolveTermTheme('prism') + const { vars } = chromeTokens(theme, 100, undefined, 'hairline', '#e07a2f') + expect(vars['--p-accent']).toBe('#e07a2f') + // Everything derived from the accent follows it, and still reads. + expect(contrastRatio(vars['--p-on-accent'], vars['--p-accent'])).toBeGreaterThanOrEqual(3) + expect(contrastRatio(vars['--p-on-accent'], vars['--p-sel-bg'])).toBeGreaterThanOrEqual(4.5) + expect(vars['--p-accent']).not.toBe(chromeTokens(theme).vars['--p-accent']) + }) + it.each(TERM_PRESETS.map((p) => p.id))( + '%s: a chosen accent the ground cannot show is moved to the floor, not swapped', + (id) => { + // Near-black and near-white: one of them is invisible on every theme. + for (const pick of ['#0a0a0a', '#f7f7f7']) { + const { vars } = chromeTokens(resolveTermTheme(id), 100, undefined, 'hairline', pick) + for (const ground of [vars['--p-bg-solid'], vars['--p-side-flat']]) { + expect(contrastRatio(vars['--p-accent'], ground), `${pick} on ${ground}`).toBeGreaterThanOrEqual(3) + } + expect(contrastRatio(vars['--p-on-accent'], vars['--p-sel-bg'])).toBeGreaterThanOrEqual(4.5) + } + } + ) + it('follows the theme when nothing is chosen', () => { + const theme = resolveTermTheme('prism') + expect(chromeTokens(theme, 100, undefined, 'hairline', null).vars).toEqual(chromeTokens(theme).vars) + }) it('publishes every colour token the components read', () => { // The components were transplanted from Prism and read Prism's token names. // A name missing here is a surface painted by the stylesheet's fallback, diff --git a/src/renderer/src/lib/chromeTheme.ts b/src/renderer/src/lib/chromeTheme.ts index 5f184e6..60af120 100644 --- a/src/renderer/src/lib/chromeTheme.ts +++ b/src/renderer/src/lib/chromeTheme.ts @@ -86,7 +86,9 @@ export function chromeTokens( theme: TermTheme, opacityPct = 100, wantedAccent?: string, - edges: WindowEdges = 'hairline' + edges: WindowEdges = 'hairline', + /** The accent the USER chose (accentPrefs), or nothing to follow the theme. */ + chosenAccent?: string | null ): ChromeTokens { // Flattened before any maths: a theme may publish rgba() or #rrggbbaa, which // is fine to paint and NaN to measure, and NaN is how a hue walks to black. @@ -115,7 +117,17 @@ export function chromeTokens( .map((c) => normalizeColor(c, INDIGO)) const visible = (c: string): boolean => contrastRatio(c, bg) >= QUIET_FLOOR && contrastRatio(c, flat) >= QUIET_FLOOR - const accent = candidates.find(visible) ?? floorOn(candidates[0], QUIET_FLOOR) + // A CHOSEN accent is kept (owner, 2026-09-22: "an accent colour option"). + // The theme's own candidates above may be swapped for one another when they + // are hard to see, because none of them was anybody's pick; a colour picked + // by hand is only MOVED, as far as the floor and no further, so it stays the + // colour that was chosen on every ground that can show it. + const chosen = chosenAccent ? normalizeColor(chosenAccent, INDIGO) : null + const accent = chosen + ? visible(chosen) + ? chosen + : floorOn(chosen, QUIET_FLOOR) + : (candidates.find(visible) ?? floorOn(candidates[0], QUIET_FLOOR)) // What sits on the accent is white or near-black, whichever reads better: // the better of two, not a midpoint test, since both can be poor at a midpoint. const onAccent = diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 11af97b..3ae8532 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -1166,7 +1166,8 @@ const scenarios = { // row outside the core's list is a fork. // 'window-edges' (#27) is the window's chrome, which in Prism belongs to // the app style and has a row of its own there: this app's, not the core's. - const own = ['newtab-mode', 'explorer-verb', 'app-version', 'window-edges'] + // 'window-accent' is the same: the accent is the app style's in Prism. + const own = ['newtab-mode', 'explorer-verb', 'app-version', 'window-edges', 'window-accent'] const extra = [...shown].filter((id) => !wanted.includes(id) && !own.includes(id)) ok(extra.length === 0, `and nothing else claims to be a setting (extra: ${JSON.stringify(extra)})`) ok((await page.locator('[data-pref="confirm-close"]').count()) === 0, 'the close question is not a setting any more') @@ -1464,6 +1465,74 @@ const scenarios = { await app.close().catch(() => {}) }, + /** + * THE WINDOW'S ACCENT (owner, 2026-09-22: "add an accent colour option which + * would pick the accents you see, like the blue highlight effect and tab + * effect"). Measured on the ACTIVE TAB'S RULE, which is the tab effect the + * owner pointed at, as well as on the token: a token that moved while the + * rule stayed blue would be the setting lying. Then "Follow theme" must put + * back exactly the colour that was there before. + */ + async accent(ok) { + const w = world() + const { app, page } = await launch(w, { args: [w.alpha, w.beta] }) + ok(await until(async () => (await tabLabels(page)).length === 2), 'two tabs open, so one is the active tab') + const probe = () => + page.evaluate(() => { + const root = getComputedStyle(document.documentElement) + const colour = (css) => { + const span = document.createElement('span') + span.style.color = css + document.body.appendChild(span) + const c = getComputedStyle(span).color + span.remove() + return c + } + const rule = [...document.querySelectorAll('[data-tab] span[aria-hidden]')].find( + (s) => s.className.includes('top-0') && s.className.includes('h-0.5') + ) + return { + accent: root.getPropertyValue('--p-accent').trim().toLowerCase(), + hi: colour(root.getPropertyValue('--p-accent-hi').trim()), + rule: rule ? getComputedStyle(rule).backgroundColor : null, + stored: localStorage.getItem('prism.window.accent'), + follow: document.querySelectorAll('[data-follow-theme="accent"]').length, + field: document.querySelector('[data-pref="window-accent"] input:not([type])')?.value?.toLowerCase() ?? null + } + }) + const before = await probe() + ok(!!before.rule && before.rule === before.hi, `the active tab wears the accent rule (${before.rule})`) + await page.locator('[data-title-settings]').click() + await page.locator('[data-settings-tab="appearance"]').click() + const row = page.locator('[data-pref="window-accent"]') + await row.waitFor({ state: 'visible', timeout: 10000 }) + await row.scrollIntoViewIfNeeded() + const idle = await probe() + ok(idle.stored === null && idle.follow === 0, 'nothing is chosen at first, so there is nothing to follow back to') + ok(idle.field === before.accent, `the swatch shows the theme's own accent (${idle.field} vs ${before.accent})`) + + const field = page.locator('[data-pref="window-accent"] input:not([type])') + await field.fill('#E07A2F') + await field.press('Enter') + const picked = await until(async () => { + const p = await probe() + return p.accent === '#e07a2f' && p.rule === p.hi && p.rule !== before.rule ? p : null + }, 10000) + ok(!!picked, `a picked colour is the accent, and the tab's rule follows it (${picked?.rule})`) + ok(picked?.stored === '#e07a2f' && picked?.follow === 1, 'it is stored, and "Follow theme" is offered') + await row.scrollIntoViewIfNeeded() + await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/accent-picked.png') }).catch(() => {}) + + await page.locator('[data-follow-theme="accent"]').click() + const back = await until(async () => { + const p = await probe() + return p.accent === before.accent && p.rule === before.rule ? p : null + }, 10000) + ok(!!back, `"Follow theme" puts back exactly the theme's accent (${back?.accent})`) + ok(back?.stored === null && back?.follow === 0, 'and forgets the choice') + await app.close().catch(() => {}) + }, + /** * THE UPDATE WINDOW (#28; owner, 2026-09-19: "when you click the Update badge, * it opens like a pop window, which shows the change log or like patch notes From b0913f712719559f38f034017d9aeb96c6753c69 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:46:56 +0200 Subject: [PATCH 2/6] build: 0.10.0 Co-Authored-By: Claude Opus 5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1dc3193..a908464 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.9.1", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.9.1", + "version": "0.10.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index d5ba367..f10510e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.9.1", + "version": "0.10.0", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", From dcdfbe8b9428ad4e47deac0e2b475766e597a1e6 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:51:51 +0200 Subject: [PATCH 3/6] test(e2e): the pressed segment follows the picked accent too Co-Authored-By: Claude Opus 5 --- tools/e2e/run.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 3ae8532..e2cc5e6 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -1520,6 +1520,23 @@ const scenarios = { }, 10000) ok(!!picked, `a picked colour is the accent, and the tab's rule follows it (${picked?.rule})`) ok(picked?.stored === '#e07a2f' && picked?.follow === 1, 'it is stored, and "Follow theme" is offered') + // EVERY accent follows, not just the tab: the pressed segment of a + // control and the selected settings page wear it too. + const worn = await until( + () => + page.evaluate(() => { + const span = document.createElement('span') + span.style.color = getComputedStyle(document.documentElement).getPropertyValue('--p-accent').trim() + document.body.appendChild(span) + const accent = getComputedStyle(span).color + span.remove() + const seg = document.querySelector('[data-pref="window-edges"] [aria-pressed="true"]') + return !!seg && getComputedStyle(seg).backgroundColor === accent + }), + 5000 + ) + ok(worn, 'the pressed segment of a control wears the picked accent too') + await sleep(800) await row.scrollIntoViewIfNeeded() await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/accent-picked.png') }).catch(() => {}) From cf44c453f54d881edd7fc80d2377908b71300866 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:55:54 +0200 Subject: [PATCH 4/6] feat(settings): a background colour, both colours under Font size with a Reset word, and Ember as the default theme Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 31 ++++-- core/package.json | 2 +- core/renderer/lib/termTheme.ts | 14 +++ core/renderer/settings/TerminalAppearance.tsx | 12 +- src/renderer/src/App.tsx | 9 +- src/renderer/src/components/Settings.tsx | 103 +++++++++++++----- src/renderer/src/lib/accentPrefs.ts | 50 +-------- src/renderer/src/lib/backgroundPrefs.test.ts | 25 +++++ src/renderer/src/lib/backgroundPrefs.ts | 16 +++ src/renderer/src/lib/chromeTheme.test.ts | 11 ++ src/renderer/src/lib/colourPref.ts | 48 ++++++++ src/renderer/src/termHost.ts | 2 +- tools/e2e/run.mjs | 52 ++++++++- 13 files changed, 284 insertions(+), 91 deletions(-) create mode 100644 src/renderer/src/lib/backgroundPrefs.test.ts create mode 100644 src/renderer/src/lib/backgroundPrefs.ts create mode 100644 src/renderer/src/lib/colourPref.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5dfcbe4..a4f00cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -395,16 +395,27 @@ terminal theme, anything that reads or shows files. asserts what main HEARD, and how the border looks is on the hands-on list. The `edges` e2e measures real edges (a tab separator, the title bar's rule, the settings rail, a settings row), WAITING for each to arrive, since the strip's border colour transitions over 550ms. -- **THE ACCENT IS A SETTING, AND UNSET IS THE THEME'S** (owner, 2026-09-22: "add an accent colour - option which would pick the accents you see, like the blue highlight effect and tab effect"). - Settings > Appearance > Accent colour (`window-accent`, localStorage `prism.window.accent`, store - `lib/accentPrefs.ts`), a hex field and swatch showing the theme's own accent until a colour is - picked, then a "Follow theme" button that forgets it. This app's row, for the edges' reason (in - Prism the accent is the app style's). Applied ONCE, as `chromeTokens`' `chosenAccent`: every - accent-derived token (`--p-accent`, `--p-accent-hi`, `--p-sel-bg`, `--p-on-accent`) follows, and - no component knows. A chosen colour is KEPT where the ground can show it and only MOVED to the - 3:1 floor where it cannot, never swapped for another candidate the way the theme's own are, - since it was somebody's pick. The `accent` e2e measures the active tab's rule, not just the token. +- **THE ACCENT AND THE BACKGROUND ARE SETTINGS, AND UNSET IS THE THEME'S** (owner, 2026-09-22: + "an accent colour option which would pick the accents you see, like the blue highlight effect + and tab effect"; "let background colour be a setting ... move those settings, bg and accent, to + the top of the list right under font and font size"). Settings > Appearance > Background colour + and Accent colour (`window-background`, `window-accent`; localStorage `prism.window.background` + / `.accent`; stores `lib/backgroundPrefs.ts` / `accentPrefs.ts`, both `lib/colourPref.ts`). Each + shows the theme's own colour until one is picked, then a plain **Reset** word (the core's + `RESET_LINK`, Prism's own style) forgets it. This app's rows, for the edges' reason (in Prism the + window's colours are the app style's); they sit under Font size because the core lends the place + (`TerminalAppearanceSettings`' `afterFont`; Prism passes nothing). Applied ONCE, in `paintChrome`: + the background replaces the theme's before `chromeTokens` measures it, so the mode, every ink and + the accent's floor follow; the terminal follows too because the panel paints its ground from the + same token (`paintsGround`). Known gap: xterm's ANSI floor and the character under the block + cursor are still measured against the theme's own background, which is the core's. A chosen + accent is KEPT where the ground can show it and only MOVED to the 3:1 floor where it cannot. The + agent working indicator still follows the THEME's accent (a core change and an owner decision). + The `accent` e2e measures the active tab's rule, a pressed segment, the row order and the ground. +- **EMBER IS THE DEFAULT THEME HERE** (owner, 2026-09-22, handing over the palette he had saved as + Custom: "let this be the default theme ... for prism terminal"): Wombat's colours on #121212, the + two blacks lifted, the icon's orange `#fe8f34` as the accent. A core preset like any other, and + this host's `defaults.theme`, so anyone who never picked a theme moves to it with the update. - **THE CLOSE QUESTION IS ONE RULE, NOT A SETTING** (owner, 2026-09-19, #15: "remove the setting but just have it on smart mode by default, so it won't ask if you're in a normal shell but if you're working with an agent it will ask"). `core/renderer/lib/agentClose.ts`, the same in Prism: a plain diff --git a/core/package.json b/core/package.json index 2fc4374..85e4f71 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.7.1", + "version": "0.8.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/lib/termTheme.ts b/core/renderer/lib/termTheme.ts index 93bb1ab..3b85493 100644 --- a/core/renderer/lib/termTheme.ts +++ b/core/renderer/lib/termTheme.ts @@ -95,6 +95,20 @@ export interface TermPreset { export const TERM_PRESETS: TermPreset[] = [ // Prism's own dark look: what the terminal wore by default inside Prism. { id: 'prism', name: 'Prism', bg: '#0b0b0f', fg: '#e7e7ee', cursor: '#7c7cf0', accent: '#5b5bd6' }, + // EMBER is the owner's own palette (2026-09-22, saved as Custom and handed + // over: "let this be the default theme ... for prism terminal"): Wombat's + // colours on a darker #121212, the two blacks lifted so they read, and the + // app icon's orange as the accent. Prism Terminal's default; a preset like + // any other everywhere else. + { + id: 'ember', + name: 'Ember', + bg: '#121212', + fg: '#dedacf', + cursor: '#bbbbbb', + accent: '#fe8f34', + ansi: { black: '#646464', red: '#ff615a', green: '#b1e969', yellow: '#ebd99c', blue: '#5da9f6', magenta: '#e86aff', cyan: '#82fff7', white: '#dedacf', brightBlack: '#6b6b6b', brightRed: '#f58c80', brightGreen: '#ddf88f', brightYellow: '#eee5b2', brightBlue: '#a5c7ff', brightMagenta: '#ddaaff', brightCyan: '#b7fff9', brightWhite: '#ffffff' } + }, { id: 'bright-lights', name: 'Bright Lights', diff --git a/core/renderer/settings/TerminalAppearance.tsx b/core/renderer/settings/TerminalAppearance.tsx index 8330535..1949cea 100644 --- a/core/renderer/settings/TerminalAppearance.tsx +++ b/core/renderer/settings/TerminalAppearance.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type JSX } from 'react' +import { useEffect, useMemo, useRef, useState, type JSX, type ReactNode } from 'react' import { followsHostStyle, termHost } from '../host' import { FONT_PCTS, @@ -273,7 +273,14 @@ function pickPreset(id: string): void { if (volume !== termExtraDefaults().indicator) setAgentIndicator(volume) } -export function TerminalAppearanceSettings(): JSX.Element { +/** + * `afterFont`: an app's OWN rows, placed right under Font size (owner, + * 2026-09-22: "move those settings, bg and accent, to the top of the list + * right under font and font size"). The rows stay the app's - Prism passes + * nothing, since its window colours belong to its app style - and the core + * only lends them the place. + */ +export function TerminalAppearanceSettings({ afterFont }: { afterFont?: ReactNode } = {}): JSX.Element { const themeId = useTermThemeId() const fontPct = useTermFontPct() const fontId = useTermFontId() @@ -496,6 +503,7 @@ export function TerminalAppearanceSettings(): JSX.Element { options={FONT_PCTS.map((p) => ({ id: String(p), name: `${p}%` }))} /> + {afterFont} {/* The material does not exist before Windows 11, so there the row says why instead of offering a switch that would do nothing. */} import('./components/Settings')) // Loaded when it is first opened: the popup brings the whole catalogue with it, @@ -87,8 +88,12 @@ function paintChrome(): void { const acrylic = termAcrylic() const id = termThemeId() const edges = windowEdges() + // A picked background replaces the theme's for the whole window; the panel + // paints the terminal's ground from the same token, so it follows too. + const theme = resolveTermTheme(id) + const background = windowBackground() const tokens = chromeTokens( - resolveTermTheme(id), + background ? { ...theme, background } : theme, acrylic ? termOpacity() : 100, presetAccent(id), edges, @@ -136,7 +141,9 @@ export default function App(): JSX.Element { const offLook = onTermLookChange(paintChrome) const offEdges = onWindowEdgesChange(paintChrome) const offAccent = onWindowAccentChange(paintChrome) + const offBackground = onWindowBackgroundChange(paintChrome) return () => { + offBackground() offLook() offEdges() offAccent() diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index f5a8e6f..857295a 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -2,8 +2,14 @@ import { useEffect, useState, useSyncExternalStore, type JSX, type ReactNode } f import { setNewTabMode, useNewTabFolder, useNewTabMode } from '../lib/newTabPrefs' import { setWindowEdges, useWindowEdges } from '../lib/edgesPrefs' import { WINDOW_EDGES, type WindowEdges } from '@shared/windowEdges' -import { HexSwatch, Pref, ROWS, ROW_BUTTON, Segmented, Switch } from '@core/renderer/settings/fields' +import { HexSwatch, Pref, RESET_LINK, ROWS, ROW_BUTTON, Segmented, Switch } from '@core/renderer/settings/fields' import { setWindowAccent, useWindowAccent } from '../lib/accentPrefs' +import { + onWindowBackgroundChange, + setWindowBackground, + useWindowBackground, + windowBackground +} from '../lib/backgroundPrefs' import { chromeTokens } from '../lib/chromeTheme' import { onTermLookChange, termThemeId } from '@core/renderer/lib/termLook' import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' @@ -165,52 +171,93 @@ const EDGE_OPTIONS: Array<{ id: WindowEdges; name: string }> = WINDOW_EDGES.map( * reason. No `ROWS` wrapper: the list above ends in its own bottom rule, and a * second top rule under it would be a doubled line (at "solid", a visible one). */ -/** The accent the THEME in force would give the window, which is what the - * 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. */ -const themeAccent = (): string => { +/** 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. + * The theme's accent is measured against the PICKED background when there is + * one, since that is the ground it has to be seen on. */ +const themeColours = (): string => { const id = termThemeId() - return chromeTokens(resolveTermTheme(id), 100, presetAccent(id)).vars['--p-accent'] + const theme = resolveTermTheme(id) + const bg = windowBackground() + const vars = chromeTokens(bg ? { ...theme, background: bg } : theme, 100, presetAccent(id)).vars + const themeBg = chromeTokens(theme, 100, presetAccent(id)).vars['--p-bg-solid'] + return `${vars['--p-accent']}|${themeBg}` +} +const onColoursChange = (cb: () => void): (() => void) => { + const offs = [onTermLookChange(cb), onWindowBackgroundChange(cb)] + return () => offs.forEach((off) => off()) } /** - * The window's accent (owner, 2026-09-22: "add an accent colour option which - * would pick the accents you see, like the blue highlight effect and tab - * effect"). Follows the theme until a colour is picked; "Follow theme" puts - * it back. This app's own row, for the edges' reason: in Prism the accent is - * the app style's, which has its own picker. + * A window colour the user may pick over the theme's (owner, 2026-09-22): the + * ACCENT ("the accents you see, like the blue highlight effect and tab + * effect") and the BACKGROUND ("let background colour be a setting"). Each + * follows the theme until a colour is picked, and a plain Reset word puts it + * back, as in Prism. This app's own rows, for the edges' reason: in Prism the + * window's colours are the app style's. They sit right under Font size (the + * core lends the place, `afterFont`). */ -function AccentSetting(): JSX.Element { - const chosen = useWindowAccent() - const fromTheme = useSyncExternalStore(onTermLookChange, themeAccent) +function WindowColour({ + id, + label, + what, + chosen, + fromTheme, + onPick +}: { + id: string + label: string + what: string + chosen: string | null + fromTheme: string + onPick: (hex: string | null) => void +}): JSX.Element { return ( - +
{chosen && ( - )} - +
) } +function WindowColours(): JSX.Element { + const accent = useWindowAccent() + const background = useWindowBackground() + const [themeAccent, themeBg] = useSyncExternalStore(onColoursChange, themeColours).split('|') + return ( + <> + + + + ) +} + function AppearanceTab(): JSX.Element { const edges = useWindowEdges() return ( <> - - + } /> void> = [] - -const HEX = /^#[0-9a-f]{6}$/i - -/** The chosen accent, lower case, or null to follow the theme. */ -export function windowAccent(): string | null { - try { - const raw = localStorage.getItem(KEY) - return raw && HEX.test(raw) ? raw.toLowerCase() : null - } catch { - return null - } -} - -/** A hex colour to wear it, or null to follow the theme again. */ -export function setWindowAccent(hex: string | null): void { - try { - if (hex && HEX.test(hex)) localStorage.setItem(KEY, hex.toLowerCase()) - else localStorage.removeItem(KEY) - } catch { - /* a full or blocked store loses the choice, not the app */ - } - listeners.forEach((l) => l()) -} - -/** For App, which repaints the chrome: Settings writes the store and nothing - * else, the same deal the edges have. */ -export function onWindowAccentChange(cb: () => void): () => void { - listeners.push(cb) - return () => { - listeners = listeners.filter((l) => l !== cb) - } -} - -export const useWindowAccent = (): string | null => useSyncExternalStore(onWindowAccentChange, windowAccent) +export const windowAccent = accent.get +export const setWindowAccent = accent.set +export const onWindowAccentChange = accent.onChange +export const useWindowAccent = accent.use diff --git a/src/renderer/src/lib/backgroundPrefs.test.ts b/src/renderer/src/lib/backgroundPrefs.test.ts new file mode 100644 index 0000000..c8afbfb --- /dev/null +++ b/src/renderer/src/lib/backgroundPrefs.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { setWindowBackground, windowBackground } from './backgroundPrefs' +import { windowAccent } from './accentPrefs' + +describe('backgroundPrefs', () => { + beforeEach(() => localStorage.clear()) + + it('follows the theme until somebody chooses, and forgets on null', () => { + expect(windowBackground()).toBeNull() + setWindowBackground('#1A1B26') + expect(windowBackground()).toBe('#1a1b26') + setWindowBackground(null) + expect(windowBackground()).toBeNull() + }) + + it('is its own key: picking a background picks no accent', () => { + setWindowBackground('#101010') + expect(windowAccent()).toBeNull() + }) + + it('reads junk as "follow the theme"', () => { + localStorage.setItem('prism.window.background', 'black') + expect(windowBackground()).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/backgroundPrefs.ts b/src/renderer/src/lib/backgroundPrefs.ts new file mode 100644 index 0000000..4c08d07 --- /dev/null +++ b/src/renderer/src/lib/backgroundPrefs.ts @@ -0,0 +1,16 @@ +import { colourPref } from './colourPref' + +// THE WINDOW'S BACKGROUND, chosen (owner, 2026-09-22: "let background colour be +// a setting in the terminal UI in Prism Terminal"). Unset, it is the theme's. +// Set, it replaces the theme's background for the whole window, terminal +// included: this app's panel paints the terminal's ground from the window's +// own colour (`paintsGround`), so one colour covers both and they can never +// disagree. Every ink is measured against it again (chromeTokens), so the +// text, dim text and accent stay readable on whatever is picked. + +const background = colourPref('prism.window.background') + +export const windowBackground = background.get +export const setWindowBackground = background.set +export const onWindowBackgroundChange = background.onChange +export const useWindowBackground = background.use diff --git a/src/renderer/src/lib/chromeTheme.test.ts b/src/renderer/src/lib/chromeTheme.test.ts index 7543b8b..c039379 100644 --- a/src/renderer/src/lib/chromeTheme.test.ts +++ b/src/renderer/src/lib/chromeTheme.test.ts @@ -48,6 +48,17 @@ describe('chromeTokens', () => { } } ) + it('a picked light background makes a light window whose inks still read', () => { + // The background setting hands chromeTokens the theme with its ground + // replaced; the mode is MEASURED, so a white ground on a dark theme turns + // the window light, and every ink is floored against the new ground. + const theme = resolveTermTheme('ember') + const { vars, mode } = chromeTokens({ ...theme, background: '#f4f1ea' }, 100, '#fe8f34') + expect(mode).toBe('light') + expect(vars['--p-bg-solid']).toBe('#f4f1ea') + expect(contrastRatio(vars['--p-text'], vars['--p-bg-solid'])).toBeGreaterThanOrEqual(4.5) + expect(contrastRatio(vars['--p-accent'], vars['--p-bg-solid'])).toBeGreaterThanOrEqual(3) + }) it('follows the theme when nothing is chosen', () => { const theme = resolveTermTheme('prism') expect(chromeTokens(theme, 100, undefined, 'hairline', null).vars).toEqual(chromeTokens(theme).vars) diff --git a/src/renderer/src/lib/colourPref.ts b/src/renderer/src/lib/colourPref.ts new file mode 100644 index 0000000..8b8f8f2 --- /dev/null +++ b/src/renderer/src/lib/colourPref.ts @@ -0,0 +1,48 @@ +import { useSyncExternalStore } from 'react' + +// A WINDOW COLOUR the user may pick over the theme's own: the accent and the +// background (owner, 2026-09-22). One shape for both: unset means "follow the +// theme", set is a six-digit hex, anything else in the store reads as unset. +// This app's own settings, like the edges: in Prism the window's colours +// belong to its app style, so none of this is the core's. + +const HEX = /^#[0-9a-f]{6}$/i + +export interface ColourPref { + /** The chosen colour, lower case, or null to follow the theme. */ + get: () => string | null + /** A hex colour to wear, or null to follow the theme again. */ + set: (hex: string | null) => void + /** For App, which repaints the chrome: Settings writes the store and + * nothing else, the same deal the edges have. */ + onChange: (cb: () => void) => () => void + use: () => string | null +} + +export function colourPref(key: string): ColourPref { + let listeners: Array<() => void> = [] + const get = (): string | null => { + try { + const raw = localStorage.getItem(key) + return raw && HEX.test(raw) ? raw.toLowerCase() : null + } catch { + return null + } + } + const set = (hex: string | null): void => { + try { + if (hex && HEX.test(hex)) localStorage.setItem(key, hex.toLowerCase()) + else localStorage.removeItem(key) + } catch { + /* a full or blocked store loses the choice, not the app */ + } + listeners.forEach((l) => l()) + } + const onChange = (cb: () => void): (() => void) => { + listeners.push(cb) + return () => { + listeners = listeners.filter((l) => l !== cb) + } + } + return { get, set, onChange, use: () => useSyncExternalStore(onChange, get) } +} diff --git a/src/renderer/src/termHost.ts b/src/renderer/src/termHost.ts index 2670b4c..d8291fc 100644 --- a/src/renderer/src/termHost.ts +++ b/src/renderer/src/termHost.ts @@ -20,7 +20,7 @@ import { themeAgentColors } from './lib/agentColors' configureTermCore({ api: window.prism, defaults: { - theme: 'prism', + theme: 'ember', acrylic: false, indicator: 'minimal', // '' = follow the theme (lib/agentColors resolves what that is here). diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index e2cc5e6..39717d8 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -1167,7 +1167,7 @@ const scenarios = { // 'window-edges' (#27) is the window's chrome, which in Prism belongs to // the app style and has a row of its own there: this app's, not the core's. // 'window-accent' is the same: the accent is the app style's in Prism. - const own = ['newtab-mode', 'explorer-verb', 'app-version', 'window-edges', 'window-accent'] + const own = ['newtab-mode', 'explorer-verb', 'app-version', 'window-edges', 'window-accent', 'window-background'] const extra = [...shown].filter((id) => !wanted.includes(id) && !own.includes(id)) ok(extra.length === 0, `and nothing else claims to be a setting (extra: ${JSON.stringify(extra)})`) ok((await page.locator('[data-pref="confirm-close"]').count()) === 0, 'the close question is not a setting any more') @@ -1470,7 +1470,7 @@ const scenarios = { * would pick the accents you see, like the blue highlight effect and tab * effect"). Measured on the ACTIVE TAB'S RULE, which is the tab effect the * owner pointed at, as well as on the token: a token that moved while the - * rule stayed blue would be the setting lying. Then "Follow theme" must put + * rule stayed blue would be the setting lying. Then Reset must put * back exactly the colour that was there before. */ async accent(ok) { @@ -1519,7 +1519,7 @@ const scenarios = { return p.accent === '#e07a2f' && p.rule === p.hi && p.rule !== before.rule ? p : null }, 10000) ok(!!picked, `a picked colour is the accent, and the tab's rule follows it (${picked?.rule})`) - ok(picked?.stored === '#e07a2f' && picked?.follow === 1, 'it is stored, and "Follow theme" is offered') + ok(picked?.stored === '#e07a2f' && picked?.follow === 1, 'it is stored, and Reset is offered') // EVERY accent follows, not just the tab: the pressed segment of a // control and the selected settings page wear it too. const worn = await until( @@ -1545,8 +1545,52 @@ const scenarios = { const p = await probe() return p.accent === before.accent && p.rule === before.rule ? p : null }, 10000) - ok(!!back, `"Follow theme" puts back exactly the theme's accent (${back?.accent})`) + ok(!!back, `Reset puts back exactly the theme's accent (${back?.accent})`) ok(back?.stored === null && back?.follow === 0, 'and forgets the choice') + + // THE DEFAULT THEME IS EMBER here (owner, 2026-09-22: "let this be the + // default theme ... for prism terminal"): a fresh profile has it selected. + ok( + (await page.locator('[data-term-card="ember"]').first().getAttribute('aria-pressed')) === 'true', + 'a fresh profile wears Ember, the default theme' + ) + + // BACKGROUND AND ACCENT SIT RIGHT UNDER FONT SIZE (owner, same day). + const order = await page.evaluate(() => [...document.querySelectorAll('[data-pref]')].map((e) => e.getAttribute('data-pref'))) + const at = order.indexOf('term-font') + ok( + at >= 0 && order[at + 1] === 'window-background' && order[at + 2] === 'window-accent', + `Background and Accent come right after Font size (${order.slice(Math.max(0, at - 1), at + 4).join(' > ')})` + ) + + // THE BACKGROUND (owner: "let background colour be a setting"): the + // window's ground and the terminal's, one colour, and Reset puts it back. + const ground = () => + page.evaluate(() => ({ + bg: getComputedStyle(document.documentElement).getPropertyValue('--p-bg-solid').trim().toLowerCase(), + stored: localStorage.getItem('prism.window.background'), + reset: document.querySelectorAll('[data-follow-theme="background"]').length + })) + const themeGround = (await ground()).bg + const bgField = page.locator('[data-pref="window-background"] input:not([type])') + ok((await bgField.inputValue()).toLowerCase() === themeGround, `the background swatch shows the theme's own (${themeGround})`) + await bgField.fill('#1c2330') + await bgField.press('Enter') + const painted = await until(async () => { + const g = await ground() + return g.bg === '#1c2330' && g.stored === '#1c2330' && g.reset === 1 ? g : null + }, 8000) + ok(!!painted, 'a picked background is the window ground, stored, with Reset offered') + await sleep(900) // the strip's colour transitions over 550ms + await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/background-picked.png') }).catch(() => {}) + await page.locator('[data-follow-theme="background"]').click() + ok( + !!(await until(async () => { + const g = await ground() + return g.bg === themeGround && g.stored === null && g.reset === 0 ? g : null + }, 8000)), + "Reset puts the theme's background back" + ) await app.close().catch(() => {}) }, From 3ba548a04893955f863339f61b4483802ab447d9 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:18:37 +0200 Subject: [PATCH 5/6] feat(settings): PT Default leads the theme wall; the help popup blurs the window instead of casting a shadow Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 5 +- core/renderer/components/HelpPanel.tsx | 4 +- core/renderer/lib/termTheme.ts | 15 ++--- core/renderer/settings/TerminalAppearance.tsx | 60 +++++++++++-------- src/renderer/src/lib/chromeTheme.test.ts | 2 +- src/renderer/src/termHost.ts | 2 +- tools/e2e/run.mjs | 18 +++++- 7 files changed, 66 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4f00cd..98c13f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,6 +161,9 @@ so an update never silently changes what an existing user sees; the bridge to ma on the icon's own dark grey `#383c44` (Cinder). They were the two ORIGINALS whose names already fitted; the public schemes (Dracula, Nord, Gruvbox...) keep their real colours, since a scheme called Dracula that is not Dracula is a lie. Ids unchanged: they are saved-settings keys. +- **THE HELP POPUP BLURS THE WINDOW BEHIND IT AND CASTS NO SHADOW** (owner, 2026-09-22: "remove the + shadow behind this and make the bg blurred when it's open"): `backdrop-blur` on a lighter scrim; the + blur already lifts the panel off the page, and a shadow on top of it read as a dark halo. - **COMMAND HELP IS A POPUP THAT SHOWS AND COPIES, AND NOTHING ELSE** (#12; owner, 2026-09-19: "an easy to use panel where you can find shell commands... searchable... metadata on each command so a natural-language search finds it... optional in settings", and 2026-09-20: "a pop up with copy @@ -412,7 +415,7 @@ terminal theme, anything that reads or shows files. accent is KEPT where the ground can show it and only MOVED to the 3:1 floor where it cannot. The agent working indicator still follows the THEME's accent (a core change and an owner decision). The `accent` e2e measures the active tab's rule, a pressed segment, the row order and the ground. -- **EMBER IS THE DEFAULT THEME HERE** (owner, 2026-09-22, handing over the palette he had saved as +- **PT DEFAULT IS THE DEFAULT THEME HERE, AND FIRST IN THE WALL** (owner, 2026-09-22, handing over the palette he had saved as Custom: "let this be the default theme ... for prism terminal"): Wombat's colours on #121212, the two blacks lifted, the icon's orange `#fe8f34` as the accent. A core preset like any other, and this host's `defaults.theme`, so anyone who never picked a theme moves to it with the update. diff --git a/core/renderer/components/HelpPanel.tsx b/core/renderer/components/HelpPanel.tsx index de3548f..9666fb9 100644 --- a/core/renderer/components/HelpPanel.tsx +++ b/core/renderer/components/HelpPanel.tsx @@ -487,7 +487,7 @@ export default function HelpPanel({
@@ -499,7 +499,7 @@ export default function HelpPanel({ aria-label="Command help" onMouseDown={(e) => e.stopPropagation()} style={{ width: clampHelpWidth(width) }} - className="relative flex h-[min(82vh,680px)] max-w-full flex-col overflow-hidden rounded-[var(--p-radius)] border border-[color:var(--p-divider)] bg-[var(--p-side-flat)] shadow-[0_24px_70px_rgba(0,0,0,.6)]" + className="relative flex h-[min(82vh,680px)] max-w-full flex-col overflow-hidden rounded-[var(--p-radius)] border border-[color:var(--p-divider)] bg-[var(--p-side-flat)]" > {grip('left')} {grip('right')} diff --git a/core/renderer/lib/termTheme.ts b/core/renderer/lib/termTheme.ts index 3b85493..20ccefc 100644 --- a/core/renderer/lib/termTheme.ts +++ b/core/renderer/lib/termTheme.ts @@ -93,22 +93,23 @@ export interface TermPreset { * Prism's app styles' names. */ export const TERM_PRESETS: TermPreset[] = [ - // Prism's own dark look: what the terminal wore by default inside Prism. - { id: 'prism', name: 'Prism', bg: '#0b0b0f', fg: '#e7e7ee', cursor: '#7c7cf0', accent: '#5b5bd6' }, - // EMBER is the owner's own palette (2026-09-22, saved as Custom and handed + // PT DEFAULT is the owner's own palette (2026-09-22, saved as Custom and handed // over: "let this be the default theme ... for prism terminal"): Wombat's // colours on a darker #121212, the two blacks lifted so they read, and the - // app icon's orange as the accent. Prism Terminal's default; a preset like - // any other everywhere else. + // app icon's orange as the accent. Prism Terminal's default, and FIRST in + // the wall (owner: "it should be first in the list and be called PT + // default"); a preset like any other everywhere else. { - id: 'ember', - name: 'Ember', + id: 'pt-default', + name: 'PT Default', bg: '#121212', fg: '#dedacf', cursor: '#bbbbbb', accent: '#fe8f34', ansi: { black: '#646464', red: '#ff615a', green: '#b1e969', yellow: '#ebd99c', blue: '#5da9f6', magenta: '#e86aff', cyan: '#82fff7', white: '#dedacf', brightBlack: '#6b6b6b', brightRed: '#f58c80', brightGreen: '#ddf88f', brightYellow: '#eee5b2', brightBlue: '#a5c7ff', brightMagenta: '#ddaaff', brightCyan: '#b7fff9', brightWhite: '#ffffff' } }, + // Prism's own dark look: what the terminal wore by default inside Prism. + { id: 'prism', name: 'Prism', bg: '#0b0b0f', fg: '#e7e7ee', cursor: '#7c7cf0', accent: '#5b5bd6' }, { id: 'bright-lights', name: 'Bright Lights', diff --git a/core/renderer/settings/TerminalAppearance.tsx b/core/renderer/settings/TerminalAppearance.tsx index 1949cea..e2f92ce 100644 --- a/core/renderer/settings/TerminalAppearance.tsx +++ b/core/renderer/settings/TerminalAppearance.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type JSX, type ReactNode } from 'react' -import { followsHostStyle, termHost } from '../host' +import { followsHostStyle, hostDefaults, termHost } from '../host' import { FONT_PCTS, TERM_FONTS, @@ -324,10 +324,39 @@ export function TerminalAppearanceSettings({ afterFont }: { afterFont?: ReactNod const [lightFirst] = useState( () => luminance(normalizeColor(resolveTermTheme(termThemeId()).background, '#000000')) > 0.4 ) + // THE HOST'S OWN DEFAULT LEADS THE WALL, ahead of Custom (owner, 2026-09-22: + // "it should be first in the list"). Prism Terminal's is a preset (PT + // Default); Prism's is 'style', which is no preset, so its wall is untouched. + 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].sort((a, b) => (lightFirst ? lum(b.bg) - lum(a.bg) : lum(a.bg) - lum(b.bg))) - }, [lightFirst]) + 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]) + const presetCard = (p: (typeof TERM_PRESETS)[number]): JSX.Element => { + const t = resolveTermTheme(p.id) + return ( + pickPreset(p.id)} + onEdit={() => setEditing(paletteOf(p.id))} + /> + ) + } // "Save changes": the WHOLE look - palette of the selected theme, font, // size, agent colours, acrylic and its opacity - lands in the Custom slot, // reselectable after any theme switch. The indicator's volume is not part @@ -401,6 +430,7 @@ export function TerminalAppearanceSettings({ afterFont }: { afterFont?: ReactNod style={{ maxHeight: wallHeight }} >
+ {defaultPreset && presetCard(defaultPreset)} {followsHostStyle() && ( setEditing(paletteOf('custom'))} /> )} - {sortedPresets.map((p) => { - const t = resolveTermTheme(p.id) - return ( - pickPreset(p.id)} - onEdit={() => setEditing(paletteOf(p.id))} - /> - ) - })} + {sortedPresets.map(presetCard)}
diff --git a/src/renderer/src/lib/chromeTheme.test.ts b/src/renderer/src/lib/chromeTheme.test.ts index c039379..ebd84c6 100644 --- a/src/renderer/src/lib/chromeTheme.test.ts +++ b/src/renderer/src/lib/chromeTheme.test.ts @@ -52,7 +52,7 @@ describe('chromeTokens', () => { // The background setting hands chromeTokens the theme with its ground // replaced; the mode is MEASURED, so a white ground on a dark theme turns // the window light, and every ink is floored against the new ground. - const theme = resolveTermTheme('ember') + const theme = resolveTermTheme('pt-default') const { vars, mode } = chromeTokens({ ...theme, background: '#f4f1ea' }, 100, '#fe8f34') expect(mode).toBe('light') expect(vars['--p-bg-solid']).toBe('#f4f1ea') diff --git a/src/renderer/src/termHost.ts b/src/renderer/src/termHost.ts index d8291fc..b496805 100644 --- a/src/renderer/src/termHost.ts +++ b/src/renderer/src/termHost.ts @@ -20,7 +20,7 @@ import { themeAgentColors } from './lib/agentColors' configureTermCore({ api: window.prism, defaults: { - theme: 'ember', + theme: 'pt-default', acrylic: false, indicator: 'minimal', // '' = follow the theme (lib/agentColors resolves what that is here). diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 39717d8..83174d7 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -780,6 +780,16 @@ const scenarios = { ok((await page.locator('[data-title-help]').count()) === 1, 'the ? is in the title bar (the setting is on by default)') await page.locator('[data-title-help]').click() ok(await opened(), 'a click on it opens the popup') + // The window behind is BLURRED and the panel casts no shadow (owner, + // 2026-09-22: "remove the shadow behind this and make the bg blurred"). + const scrimLook = await page.evaluate(() => ({ + blur: getComputedStyle(document.querySelector('[data-help-scrim]')).backdropFilter, + shadow: getComputedStyle(document.querySelector('[data-help-panel]')).boxShadow + })) + ok( + /blur\(/.test(scrimLook.blur) && scrimLook.shadow === 'none', + `the window behind is blurred and the panel has no shadow (${JSON.stringify(scrimLook)})` + ) ok(await until(focusIsSearch, 3000, 50), 'and the search field has the focus') ok((await page.locator('[data-help-shell="powershell"]').getAttribute('aria-pressed')) === 'true', 'the chip is the shell of the tab in front (PowerShell)') const browse = await page.evaluate(() => ({ @@ -1551,8 +1561,12 @@ const scenarios = { // THE DEFAULT THEME IS EMBER here (owner, 2026-09-22: "let this be the // default theme ... for prism terminal"): a fresh profile has it selected. ok( - (await page.locator('[data-term-card="ember"]').first().getAttribute('aria-pressed')) === 'true', - 'a fresh profile wears Ember, the default theme' + (await page.locator('[data-term-card="pt-default"]').first().getAttribute('aria-pressed')) === 'true', + 'a fresh profile wears PT Default, the default theme' + ) + ok( + (await page.locator('[data-term-card]').first().getAttribute('data-term-card')) === 'pt-default', + 'and it is the first card in the wall' ) // BACKGROUND AND ACCENT SIT RIGHT UNDER FONT SIZE (owner, same day). From a264026762bb0c214717a35edf65ecbff958c00d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:33:29 +0200 Subject: [PATCH 6/6] test: PT Default leads the preset list; the core's fallback is still prism, by id Co-Authored-By: Claude Opus 5 --- core/renderer/lib/termTheme.test.ts | 7 +++++-- core/renderer/lib/termTheme.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/core/renderer/lib/termTheme.test.ts b/core/renderer/lib/termTheme.test.ts index 2a4e7b1..6c6cec2 100644 --- a/core/renderer/lib/termTheme.test.ts +++ b/core/renderer/lib/termTheme.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_TERM_THEME, TERM_PRESETS, resolveTermTheme } from './termTheme' describe('resolveTermTheme', () => { - it('leads the list with the prism preset, which is the default', () => { - expect(TERM_PRESETS[0]?.id).toBe('prism') + it("keeps the core's own default, prism, in the list (PT Default now leads it)", () => { + // The wall's first card is the HOST's default (Prism Terminal's is PT + // Default); the core's neutral fallback is still 'prism', found by id. + expect(TERM_PRESETS[0]?.id).toBe('pt-default') expect(DEFAULT_TERM_THEME).toBe('prism') + expect(TERM_PRESETS.some((p) => p.id === DEFAULT_TERM_THEME)).toBe(true) }) it('resolves a preset by id, with a full sixteen', () => { diff --git a/core/renderer/lib/termTheme.ts b/core/renderer/lib/termTheme.ts index 20ccefc..a05562d 100644 --- a/core/renderer/lib/termTheme.ts +++ b/core/renderer/lib/termTheme.ts @@ -433,7 +433,7 @@ export function resolveTermTheme(themeId: string): TermTheme { ...legiblePalette(c.ansi, c.bg) } } - // TERM_PRESETS[0] is 'prism'; the find cannot miss, the fallback is for the type. + // DEFAULT_TERM_THEME is in the list; that find cannot miss, the last fallback is for the type. const p = TERM_PRESETS.find((x) => x.id === themeId) ?? // An id nothing answers to: the host's own default, where that is a preset.