diff --git a/CLAUDE.md b/CLAUDE.md
index 41be84e..98c13f7 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.
@@ -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
@@ -395,6 +398,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 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.
+- **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.
- **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/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.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 93bb1ab..a05562d 100644
--- a/core/renderer/lib/termTheme.ts
+++ b/core/renderer/lib/termTheme.ts
@@ -93,6 +93,21 @@ export interface TermPreset {
* Prism's app styles' names.
*/
export const TERM_PRESETS: TermPreset[] = [
+ // 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, 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: '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' },
{
@@ -418,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.
diff --git a/core/renderer/settings/TerminalAppearance.tsx b/core/renderer/settings/TerminalAppearance.tsx
index 8330535..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 } from 'react'
-import { followsHostStyle, termHost } from '../host'
+import { useEffect, useMemo, useRef, useState, type JSX, type ReactNode } from 'react'
+import { followsHostStyle, hostDefaults, termHost } from '../host'
import {
FONT_PCTS,
TERM_FONTS,
@@ -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()
@@ -317,10 +324,39 @@ export function TerminalAppearanceSettings(): JSX.Element {
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
@@ -394,6 +430,7 @@ export function TerminalAppearanceSettings(): JSX.Element {
style={{ maxHeight: wallHeight }}
>
@@ -496,6 +511,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,
@@ -86,11 +88,16 @@ 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
+ edges,
+ windowAccent()
)
applyChrome(tokens)
window.prism.setWindowBg(tokens.vars['--p-bg-solid'])
@@ -133,9 +140,13 @@ export default function App(): JSX.Element {
paintChrome()
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 aae1cd8..857295a 100644
--- a/src/renderer/src/components/Settings.tsx
+++ b/src/renderer/src/components/Settings.tsx
@@ -1,8 +1,18 @@
-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, 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'
import { DictationSettings } from '@core/renderer/settings/Dictation'
import { HelpSetting } from '@core/renderer/settings/Help'
import { TerminalAppearanceSettings } from '@core/renderer/settings/TerminalAppearance'
@@ -161,11 +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).
*/
+/** 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()
+ 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())
+}
+
+/**
+ * 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 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 (
<>
-
+ } />
{
+ 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..c2d751d
--- /dev/null
+++ b/src/renderer/src/lib/accentPrefs.ts
@@ -0,0 +1,15 @@
+import { colourPref } from './colourPref'
+
+// 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.
+
+const accent = colourPref('prism.window.accent')
+
+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 25edd0e..ebd84c6 100644
--- a/src/renderer/src/lib/chromeTheme.test.ts
+++ b/src/renderer/src/lib/chromeTheme.test.ts
@@ -26,6 +26,43 @@ 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('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('pt-default')
+ 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)
+ })
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/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..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: 'prism',
+ 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 11af97b..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(() => ({
@@ -1166,7 +1176,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', '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')
@@ -1464,6 +1475,139 @@ 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 Reset 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 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(
+ () =>
+ 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(() => {})
+
+ 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, `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="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).
+ 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(() => {})
+ },
+
/**
* 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