diff --git a/CLAUDE.md b/CLAUDE.md index f5769d7..87f2b76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,13 +27,22 @@ so an update never silently changes what an existing user sees; the bridge to ma - **THE TERMINAL'S SETTINGS ARE THE CORE'S TOO** (owner, 2026-09-19: "they shouldn't be synced in terms of personalization, but the setting names, types, how they function and so on should be the - 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, 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. + same"). `core/renderer/settings`: the field primitives and, since the GROUPED CARDS redesign + (#134, 2026-10-05; spec `docs/superpowers/specs/2026-10-05-settings-redesign-design.md`), the + whole frame (rail, Find a setting, sections, rows, icons, search, flash: `layout/`) and the + terminal's sections (`sections/`: Shell, Text, Theme, Tab marks, Claude Code, Mark colours, + Command help, the Dictation page). Each app decides which page holds a section: here + `components/settings/` is Appearance (Window, then the Theme section with Background and Accent + in its slot), Terminal (Shell, Opening terminals, Text, Command help), Agents (Tab marks with the + taskbar row, Claude Code, Mark colours), Dictation, About; this app's own rows are the closed list + `appOptions.ts`. Values are per app (own userData), never shared. The core lists + (`options.ts`, `dictationOptions.ts`, `helpOptions.ts`) name every row's section, icon, subtext + and search words as FLAT entries (Prism's gate reads them as text); a unit test holds the lists + and the sections together, and each app's e2e (`options`) asserts its page shows the lists, its + own rows and nothing else. A row outside both is a fork. Every row is in Find a setting's index + (`settingsIndex.ts`), and the `settingsSearch` e2e opens each one by its label. The old + components (`TerminalAppearanceSettings` and friends) stay exported, unchanged, until Prism has + moved (spec 3.0); do not build on them. - **THIS IS A PRODUCT FOR OTHER PEOPLE** (owner, 2026-09-19: "this isn't an app for just me. keep that in mind with all things you implement"). A feature bundles or fetches what it needs and works on a fresh Windows install: never lean on the owner's GPU, tools, caches or installed runtimes. @@ -172,8 +181,9 @@ so an update never silently changes what an existing user sees; the bridge to ma (it failed with 2 copies before the fix). - **THE TITLE BAR CAN BE HIDDEN** (#91; owner, 2026-09-28: "add a no title bar option for pt in appearance as well, not theme related", and of the shapes offered, "tabs in the top row"). - Settings > Appearance > Title bar (`title-bar`, `prism.window.titleBar`, `lib/titleBarPrefs.ts`), - above the theme wall: **Shown** (the DEFAULT, the window as it was) or **Hidden**: one row, the + Settings > Appearance > Show title bar (`title-bar`, `prism.window.titleBar`, `lib/titleBarPrefs.ts`), + above the theme wall, a SWITCH since #134 over the same stored values: on is **Shown** (the + DEFAULT, the window as it was), off is **Hidden**: one row, the tab strip (`inTitleRow`) with `TitleButtons` (chip, help, cog, window buttons) at its end, the strip's empty space the drag handle; with no tabs the row is the handle and the buttons. This app's row. The `titleBar` e2e measures both and the start screen. @@ -287,14 +297,18 @@ so an update never silently changes what an existing user sees; the bridge to ma symbols other than comma and dot, no mentioning of specific keys or tips"; "the terminal settings pages in Prism and Prism Terminal should be the same in terms of order"). Every hint, sub and note is checked by `core/shared/settingsCopy.ts` (a test in the core and one per app). - `TERMINAL_OPTIONS` is in display order and the `options` e2e reads the page top to bottom against - it. + Since #134 a subtext is ONE line of at most eight words (`subTooLong`) and a label passes the + symbol rule (`labelProblem`), both held for the new files only (the legacy ones run longer). + `TERMINAL_OPTIONS` is in display order WITHIN EACH SECTION, and the `options` e2e reads every core + section (`[data-settings-section]`) against it; which page holds a section is each app's. - **WHAT NO THEME OWNS SITS ABOVE THE THEME WALL** (owner, 2026-09-28: font and font size "should transcend" the theme's save, "so changing a theme should not reset the font and font size or if you use a minimal or full agent indicator, or edges. those options should be above the themes"). - Appearance runs: the host's `beforeTheme` (here Tab width, Edges), Font, Font size, Agent - indicator; then the theme wall and Save changes; then what a theme SETS: the host's `afterTheme` - (here Background, Accent), Acrylic, the two indicator colours. A theme switch and Save + Since #134 Appearance runs: the Window section (Tab width, Show title bar, Panel edges), then the + Theme section (the theme row with Save changes, the wall, what a theme SETS: Background, Accent, + Acrylic). The font and its size are Terminal's Text section; the indicator and the agent colours + are on Agents, where Mark colours carries the SAME Save changes (both save the whole setup and + light together, `theme/useTermSetup.ts`). A theme switch and Save as Custom leave the font and its size alone (`termExtraDefaults`, `resetTermExtras`, `applyCustomExtras`; an older Custom's `font`/`fontPct` are ignored). Font size is 50% to 200% in tens; a saved size off that list reads as the nearest step. `themeSwitch` e2e holds it all. @@ -359,7 +373,7 @@ so an update never silently changes what an existing user sees; the bridge to ma `shared/help/`, `renderer/lib/helpPrefs.ts`, `renderer/components/HelpPanel.tsx` (props only), `renderer/settings/Help.tsx` + `helpOptions.ts`, and `writeClipboard` on the bridge. This app's part is the way in: `F1`, the ? in the title bar, "Command help" in the terminal's right-click - menu, the row in Settings > General. The rules that must not regress: + menu, the row in Settings > Terminal. The rules that must not regress: - **IT NEVER INSERTS AND NEVER RUNS** (owner, 2026-09-19: "picking a command in the help panel does NOT insert it into the shell"). The component is handed the clipboard and nothing else: no session id, no `termInput`, no bridge. It is NOT a fifth exception to "the app never types into @@ -616,7 +630,7 @@ the owner's own call, #99.) output arrives while idle; a rewording in a Claude update is a change there. In Minimal, EVERY working tab draws its OWN bar (owner, 2026-10-03, reverting the 2026-09-28 shared bar across neighbours: "i want that to be one for each tab, like it was before"). The taskbar button's overlay icon shows - how many tabs carry a mark (`taskbarBadge.ts`, `window:badge`, Settings > General switch, on): + how many tabs carry a mark (`taskbarBadge.ts`, `window:badge`, Settings > Agents switch, on): a dark disc with a white number, whatever the marks (owner, 2026-09-29: "too big and it's green, it should be grey with white number"; 2026-10-01, beside ChatGPT's: "so much clearer and high res, fix that", #108). Drawn at the display's PHYSICAL size (`badgePixels`, 36 at 225%) and handed @@ -717,8 +731,10 @@ the owner's own call, #99.) pressed segment (`SEGMENT_ON`), a switch that is on (`SWITCH_ON`, track in the soft ink, knob in the ground) and dictation's buttons are greys from the theme's own tokens, in `core/` so Prism follows. Save changes and Save as Custom keep the accent. Still accented, since they are not - buttons: Reset links, the chosen theme card, the dropdown's chosen item, the rail's page, focus - rings, progress, the hotkey capture while it listens. `neutralControls.test.ts` holds the + buttons: Reset links, the chosen theme card, the dropdown's chosen item, the badges, progress, the + hotkey capture while it listens, a found row's flash. NOT the rail's chosen page any more (#134, + owner: no accent bar): a grey `--p-hover-hi` fill; and focus in the new rows is a fill and a + lighter edge, not an accent ring (Prism #272). `neutralControls.test.ts` holds the source; the `accent` e2e asserts a picked accent moves none of the three controls. - **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 diff --git a/core/README.md b/core/README.md index 9573df2..63ac446 100644 --- a/core/README.md +++ b/core/README.md @@ -139,6 +139,50 @@ host's one-time migration); `TERMINAL_OPTIONS` is the same list in every host. Spec: `docs/superpowers/specs/2026-10-03-colour-picker-alpha-design.md`. +**THE SETTINGS LAYOUT (2026-10-05, the grouped cards redesign; owner: "can you +mock up some new settings pages ... grouped cards ... both apps")** is the +fourth, under the same bar. Spec and plan: +`docs/superpowers/specs/2026-10-05-settings-redesign-design.md`. +- `renderer/settings/layout/`: `SettingsFrame` (the rail, Find a setting, the + results page, the pane and its header; props `pages`, `page`, `onPage`, + `index`, `title`, `headerAction`, `compact`), `SettingsSection` (heading + + ONE panel), `SettingRow` (icon tile, label, one line of subtext, control; + `warn`, `off`, `tap`), `SettingBlock` (a full-width part of a panel), + `icons.ts`, `search.ts` (pure), `flash.ts`. Props only. +- `renderer/settings/sections/`: `ShellSection`, `TerminalTextSection`, + `TerminalThemeSection({ afterTheme, onThemePicked })`, + `AgentMarksSection({ after })`, `ClaudeCodeSection`, `MarkColoursSection` + (carries the SAME Save changes as the theme row: both save the whole setup + and light together, `theme/useTermSetup.ts`), `HelpSection`, `DictationPage`. + WHICH PAGE holds a section is the host's; `coreSettingsIndex({ pageOf, + nvidia, help })` gives the host the core's rows for its index. +- The lists (`options.ts`, `dictationOptions.ts`, `helpOptions.ts`) carry each + row's `section`, `icon`, `sub` (its resting subtext, at most eight plain + words, which the search reads too) and hidden `keywords`, as FLAT one-line + entries with no brace inside: Prism's gate reads them as text. Their order + is the display order inside each section, and each app's e2e checks it per + section (`[data-settings-section]`). +- DOM hooks: `[data-settings-page]` on the frame, `[data-settings-tab=]` + on rail buttons (`aria-current="page"` on the chosen one, a GREY + `--p-hover-hi` fill, never the accent), `[data-settings-find]` (it wears + `data-owns-escape` only while it holds text, so an empty field lets Escape + through to the host), a row's controls in a `role="group"` named by its label, + `[data-settings-section=]`, `[data-settings-panel]`, + `[data-pref=]` + `[data-setting-row]` on rows, `[data-term-wall]` on the + theme wall's block, `[data-flash]` while a found row flashes, every + existing dictation and theme hook unchanged. +- Focus inside a row is a fill and a lighter edge, never a ring (Prism #272); + a dropdown's menu in a row blurs what is behind it and casts no shadow. +- No new token: panels are `color-mix()` of `--p-text`, edges `--p-line`, + corners `max(4px, --p-radius + 3px)`. +- **Kept for the transition, removed in PR 3:** `Pref`, `ThemeHead`, `ROWS`, + `TerminalAppearanceSettings`, `AgentIndicatorSetting`, `AttentionSettings`, + `ShellSetting`, `HelpSetting` and `DictationSettings` stay exported and draw + exactly as before, until Prism no longer imports them. They are built on the + same extracted pieces (`theme/ThemeWall.tsx`, `theme/useTermSetup.ts`, + `dictation/useDictationState.ts`, `dictation/parts.tsx`), so every rule has + one copy and, for a few days, two layouts. + ## Rules for code in here (lint-enforced in Prism Terminal's `eslint.config.js`) - **Relative imports only.** No `@shared` / `@renderer` / `@core` aliases: a diff --git a/core/package.json b/core/package.json index b17e193..c94510a 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.24.0", + "version": "0.25.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/settings/Dictation.tsx b/core/renderer/settings/Dictation.tsx index bf5bad1..eacd523 100644 --- a/core/renderer/settings/Dictation.tsx +++ b/core/renderer/settings/Dictation.tsx @@ -1,38 +1,20 @@ -import { useCallback, useEffect, useRef, useState, type JSX, type ReactNode } from 'react' -import { dictationHost } from '../host' +import type { JSX, ReactNode } from 'react' import ModelDownloadAsk from '../components/ModelDownloadAsk' +import { LANGUAGES, LIMITED_LANGUAGES_TEXT, visibleModels } from '../../shared/dictationCatalog' +import type { CatalogEntry, DownloadFailure, ItemStatus } from '../../shared/dictationTypes' import { - catalogEntry, - LANGUAGES, - LIMITED_LANGUAGES_TEXT, - limitedLanguages, - recommendedModel, - visibleModels -} from '../../shared/dictationCatalog' -import type { CatalogEntry, DownloadFailure, EngineInfo, ItemStatus } from '../../shared/dictationTypes' -import { DEFAULT_HOTKEY, formatHotkey, parseHotkeyFromEvent, usableHotkey, type Hotkey } from '../lib/dictationKey' -import { - dictationModel, setDictationEnabled, - setDictationHotkey, setDictationLanguage, - setDictationMic, setDictationMode, setDictationModel, setDictationPauseMedia, - setDictationSounds, - useDictationEnabled, - useDictationHotkey, - useDictationLanguage, - useDictationMic, - useDictationMode, - useDictationModel, - useDictationPauseMedia, - useDictationSounds + setDictationSounds } from '../lib/dictationPrefs' -import { listMics, startCapture, type Capture } from '../lib/micCapture' -import { Pref, Segmented, Select, Switch } from './fields' +import { Pref, ROWS, Segmented, Select, Switch } from './fields' import { VendorMark } from './VendorMark' +import { button, HotkeyField, LimitedMark, MicField, primary, Uninstall } from './dictation/parts' +import { FAILURES, GPU_ID, size } from './dictation/items' +import { useDictationState } from './dictation/useDictationState' /** * DICTATION'S SETTINGS PAGE, for both hosts (#13). Prism Terminal shows it as @@ -45,225 +27,11 @@ import { VendorMark } from './VendorMark' * microphone (the meter is behind a Test button: a mic that lights up because * you looked at a settings page is a privacy bug), and nothing downloads * without a click. + * + * LEGACY (2026-10-05): the grouped cards redesign draws this page from + * `sections/DictationPage.tsx`. This layout stays, unchanged, until Prism has + * moved off it; the state and the controls are the shared `dictation/` pieces. */ -const ROWS = 'border-t border-[color:var(--p-line)]' -const GPU_ID = 'gpu-pack' - -const FAILURES: Record = { - network: 'The download failed.', - checksum: 'The file did not match its checksum and was deleted.', - disk: 'It could not be written to disk.', - cancelled: '', - unpack: 'It could not be unpacked.' -} - -/** A copy of a record without one key. */ -function without(rec: Record, key: string): Record { - const next = { ...rec } - delete next[key] - return next -} - -function size(bytes: number): string { - return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)} GB` : `${Math.round(bytes / 1e6)} MB` -} - -const button = - 'rounded-md border border-[color:var(--p-divider)] bg-[var(--p-control)] px-3 py-1 text-[11.5px] font-semibold text-[var(--p-text)] transition hover:bg-[var(--p-hover)] disabled:opacity-50' -// The step that moves things along (a download, picking a model) is marked by -// a stronger grey, never the accent: settings buttons are neutral, only Save -// is accented (see ROW_BUTTON in fields). -const primary = - 'rounded-md border border-[color:var(--p-line)] bg-[color-mix(in_srgb,var(--p-text)_12%,var(--p-control))] px-3 py-1 text-[11.5px] font-semibold text-[var(--p-text)] transition hover:bg-[color-mix(in_srgb,var(--p-text)_18%,var(--p-control))] disabled:opacity-50' - -/** Click, then press the key or chord. A bare modifier (Right Alt, the default) - * is taken on its RELEASE, so that Ctrl+Shift+D is not captured as "Ctrl". */ -function HotkeyField({ value, disabled }: { value: Hotkey; disabled: boolean }): JSX.Element { - const [listening, setListening] = useState(false) - useEffect(() => { - if (!listening) return - let bare: Hotkey | null = null - const isModifier = (code: string): boolean => /^(Alt|Control|Shift|Meta)(Left|Right)$/.test(code) - const down = (e: KeyboardEvent): void => { - e.preventDefault() - e.stopPropagation() - if (e.repeat) return - if (e.code === 'Escape') return setListening(false) - const hk = parseHotkeyFromEvent(e) - if (!hk) return - if (isModifier(e.code)) bare = hk - // A key that types, or a chord the terminal owns, is not taken (#27): - // the field keeps listening for one that can be. - else if (usableHotkey(hk)) { - setDictationHotkey(hk) - setListening(false) - } - } - const up = (e: KeyboardEvent): void => { - e.preventDefault() - e.stopPropagation() - if (bare && bare.code === e.code) { - setDictationHotkey(bare) - setListening(false) - } - } - const stop = (): void => setListening(false) - // A press anywhere else ends the capture: left armed, the next key typed - // on the page became the dictation key. The button's own press is its - // click's to answer. - const away = (e: PointerEvent): void => { - if (!(e.target instanceof Element && e.target.closest('#dictation-hotkey'))) setListening(false) - } - window.addEventListener('keydown', down, true) - window.addEventListener('keyup', up, true) - window.addEventListener('blur', stop) - window.addEventListener('pointerdown', away, true) - return () => { - window.removeEventListener('keydown', down, true) - window.removeEventListener('keyup', up, true) - window.removeEventListener('blur', stop) - window.removeEventListener('pointerdown', away, true) - } - }, [listening]) - const isDefault = JSON.stringify(value) === JSON.stringify(DEFAULT_HOTKEY) - return ( -
- {!isDefault && !listening && ( - - )} - -
- ) -} - -/** The mic picker, and a meter behind a button: a dead or muted microphone is - * found here, in ten seconds, instead of after a lost dictation. */ -function MicField({ value, disabled }: { value: string; disabled: boolean }): JSX.Element { - const [mics, setMics] = useState>([]) - const [testing, setTesting] = useState(false) - const [problem, setProblem] = useState(null) - const bar = useRef(null) - useEffect(() => { - void listMics().then(setMics) - }, [testing]) - useEffect(() => { - if (!testing) return - let cap: Capture | null = null - let dead = false - let shown = 0 - void startCapture({ - deviceId: value || undefined, - onLevel: (l) => { - shown = l > shown ? l : shown * 0.85 + l * 0.15 - if (bar.current) bar.current.style.transform = `scaleX(${Math.min(1, shown)})` - } - }) - .then((c) => { - if (dead) c.stop() - else cap = c - }) - .catch((err) => { - setProblem(err === 'denied' ? 'Microphone access was refused.' : 'No working microphone was found.') - setTesting(false) - }) - const timer = window.setTimeout(() => setTesting(false), 10000) - return () => { - dead = true - clearTimeout(timer) - cap?.stop() - } - }, [testing, value]) - return ( -
- {testing ? ( - - - - ) : ( - problem && {problem} - )} - - m.id === value) ? value : ''} + onChange={setDictationMic} + options={[{ id: '', name: 'System default' }, ...mics.map((m) => ({ id: m.id, name: m.label }))]} + /> +
+ ) +} + +/** Beside the language picker while the active model picks its own language + * (#121; owner, 2026-10-04: "in the language drop down have an icon to + * indicate that it doesn't support all languages"). A globe in the dim ink, + * and the same one line as the download question on hover. */ +export function LimitedMark(): JSX.Element { + return ( + + + + + + + ) +} + +/** "X Uninstall" (owner, 2026-09-19): it frees the disk, and says so by name. + * A BUTTON, the same one Download is: a first cut drew it as bare text to keep + * the destructive control quiet, and it read as a label rather than something + * to press (owner, the same evening). */ +export function Uninstall({ onClick, what, look = button }: { onClick: () => void; what: string; look?: string }): JSX.Element { + return ( + + ) +} diff --git a/core/renderer/settings/dictation/useDictationState.ts b/core/renderer/settings/dictation/useDictationState.ts new file mode 100644 index 0000000..7e7d5aa --- /dev/null +++ b/core/renderer/settings/dictation/useDictationState.ts @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useState } from 'react' +import { dictationHost } from '../../host' +import { catalogEntry, limitedLanguages, recommendedModel } from '../../../shared/dictationCatalog' +import type { CatalogEntry, DownloadFailure, EngineInfo, ItemStatus } from '../../../shared/dictationTypes' +import { + dictationModel, + setDictationModel, + useDictationEnabled, + useDictationHotkey, + useDictationLanguage, + useDictationMic, + useDictationMode, + useDictationModel, + useDictationPauseMedia, + useDictationSounds +} from '../../lib/dictationPrefs' +import { GPU_ID, without } from './items' + +/** + * WHAT DICTATION'S PAGE KNOWS AND DOES, once for both layouts of it + * (2026-10-05; moved out of Dictation.tsx unchanged): the stored settings, the + * model files main reports, the downloads in flight and their failures, and + * the one-line question before a model that does not support every language. + * + * NOTHING HERE STARTS ANYTHING BY ITSELF: reading the status opens no + * microphone and downloads nothing; every download is a click. + */ +export function useDictationState() { + const host = dictationHost() + const enabled = useDictationEnabled() + const mode = useDictationMode() + const hotkey = useDictationHotkey() + const mic = useDictationMic() + const language = useDictationLanguage() + const pauseMedia = useDictationPauseMedia() + const sounds = useDictationSounds() + const model = useDictationModel() + + const [status, setStatus] = useState([]) + const [info, setInfo] = useState(null) + const [progress, setProgress] = useState>({}) + const [failures, setFailures] = useState>({}) + /** The model whose download waits on the one-line question, if any. */ + const [asking, setAsking] = useState(null) + + const api = host?.api + const refresh = useCallback(() => { + if (!api) return + void api.dictationStatus().then(setStatus) + void api.dictationInfo().then(setInfo) + }, [api]) + useEffect(() => { + if (!api) return + refresh() + return api.onDictationProgress((p) => setProgress((prev) => ({ ...prev, [p.id]: p.received }))) + }, [api, refresh]) + + const stateOf = (id: string): ItemStatus | undefined => status.find((s) => s.id === id) + const installed = (id: string): boolean => stateOf(id)?.state === 'installed' + /** GPU acceleration is ON exactly when its engine is on disk. */ + const gpuOn = installed(GPU_ID) + + const download = (id: string): void => { + if (!api) return + setFailures((prev) => without(prev, id)) + setStatus((prev) => [...prev.filter((s) => s.id !== id), { id, state: 'downloading', received: 0 }]) + void api.dictationDownload(id).then(async (res) => { + setProgress((prev) => without(prev, id)) + if (!res.ok) setFailures((prev) => ({ ...prev, [id]: res.reason })) + else if (catalogEntry(id)?.kind === 'model') { + // The first model anyone downloads is the one they meant to use. Read + // NOW, not from the render the click came from (code review + // 2026-09-24, #30): a second download finishing after the first had + // become the model, or after a pick with Use, replaced it unasked. + const current = dictationModel() + const on = await api.dictationStatus() + if (!on.some((s) => s.id === current && s.state === 'installed')) setDictationModel(id) + } + refresh() + }) + } + const remove = (id: string): void => { + if (!api) return + void api.dictationRemove(id).then(() => { + if (id === model) setDictationModel('') + refresh() + }) + } + const cancel = (id: string): void => { + void api?.dictationCancel(id) + } + + /** A model that does not support every language says so once, before its + * download (#121). A Retry after a failed one has already been asked. */ + const fetchModel = (id: string): void => { + const failed = failures[id] + if (catalogEntry(id)?.limitedLanguages && (!failed || failed === 'cancelled')) setAsking(id) + else download(id) + } + + return { + host, + api, + enabled, + mode, + hotkey, + mic, + language, + pauseMedia, + sounds, + model, + info, + progress, + failures, + asking, + setAsking, + stateOf, + installed, + gpuOn, + download, + remove, + cancel, + fetchModel, + /** The active model picks its own language: the picker shows Auto-detect, + * cannot be changed, and the user's own choice waits, stored, for the + * next Whisper model. */ + ownLanguage: limitedLanguages(model), + recommended: recommendedModel(gpuOn) as CatalogEntry | undefined, + noModel: !installed(model), + gpu: catalogEntry(GPU_ID) + } +} + +export type DictationState = ReturnType diff --git a/core/renderer/settings/dictationOptions.ts b/core/renderer/settings/dictationOptions.ts index 50b1068..336baa4 100644 --- a/core/renderer/settings/dictationOptions.ts +++ b/core/renderer/settings/dictationOptions.ts @@ -1,4 +1,5 @@ import { DICTATION_KEYS } from '../lib/dictationPrefs' +import type { SettingsSectionId } from './sectionIds' /** * EVERY DICTATION OPTION, BY ID (#13): the same deal as `options.ts` makes for @@ -19,19 +20,25 @@ export interface DictationOption { key: string | null /** Absent = every PC. */ onlyWhere?: 'an NVIDIA adapter is present' + /** The section that draws it, its tile, its subtext at rest and its hidden + * search words, as in `options.ts`. */ + section: SettingsSectionId + icon: string + sub: string + keywords?: string } export const DICTATION_OPTIONS: readonly DictationOption[] = [ - { id: 'dictation-enabled', label: 'Dictation', type: 'switch', key: DICTATION_KEYS.enabled }, - { id: 'dictation-mode', label: 'Trigger', type: 'choice', key: DICTATION_KEYS.mode }, - { id: 'dictation-hotkey', label: 'Key', type: 'key', key: DICTATION_KEYS.hotkey }, - { id: 'dictation-mic', label: 'Microphone', type: 'choice', key: DICTATION_KEYS.mic }, - { id: 'dictation-language', label: 'Language', type: 'choice', key: DICTATION_KEYS.language }, - { id: 'dictation-pause-media', label: 'Pause media while dictating', type: 'switch', key: DICTATION_KEYS.pauseMedia }, - { id: 'dictation-sounds', label: 'Sounds', type: 'switch', key: DICTATION_KEYS.sounds }, - { id: 'dictation-model', label: 'Models', type: 'manager', key: DICTATION_KEYS.model }, + { id: 'dictation-enabled', label: 'Use dictation', type: 'switch', key: DICTATION_KEYS.enabled, section: 'dictation', icon: 'mic', sub: 'Speech to text on this PC, never sent.', keywords: 'voice speech whisper talk dictate' }, + { id: 'dictation-mode', label: 'Key behaviour', type: 'choice', key: DICTATION_KEYS.mode, section: 'listening', icon: 'hand', sub: 'Runs while the key is held.', keywords: 'push talk hold toggle trigger' }, + { id: 'dictation-hotkey', label: 'Dictation key', type: 'key', key: DICTATION_KEYS.hotkey, section: 'listening', icon: 'key', sub: 'Text is pasted at the cursor.', keywords: 'hotkey shortcut right alt' }, + { id: 'dictation-mic', label: 'Microphone', type: 'choice', key: DICTATION_KEYS.mic, section: 'listening', icon: 'mic', sub: 'Test it to see the level.', keywords: 'input device level headset' }, + { id: 'dictation-language', label: 'Spoken language', type: 'choice', key: DICTATION_KEYS.language, section: 'listening', icon: 'globe', sub: 'The language you speak.', keywords: 'locale english norwegian auto detect' }, + { id: 'dictation-pause-media', label: 'Pause media while dictating', type: 'switch', key: DICTATION_KEYS.pauseMedia, section: 'while', icon: 'pause', sub: 'Resumes when you stop.', keywords: 'music video audio' }, + { id: 'dictation-sounds', label: 'Play start and stop sounds', type: 'switch', key: DICTATION_KEYS.sounds, section: 'while', icon: 'sound', sub: 'A short sound as the microphone opens.', keywords: 'beep chime audio' }, + { id: 'dictation-model', label: 'Speech models', type: 'manager', key: DICTATION_KEYS.model, section: 'models', icon: 'download', sub: 'Shared by both apps on this PC.', keywords: 'whisper parakeet download model' }, // The official GPU engine is NVIDIA's: the row is offered only where it can work. - { id: 'dictation-gpu', label: 'GPU acceleration', type: 'manager', key: null, onlyWhere: 'an NVIDIA adapter is present' } + { id: 'dictation-gpu', label: 'GPU acceleration', type: 'manager', key: null, onlyWhere: 'an NVIDIA adapter is present', section: 'gpu', icon: 'chip', sub: 'Large models answer in under a second.', keywords: 'cuda nvidia graphics card fast' } ] /** The option ids a PC should be showing. */ diff --git a/core/renderer/settings/helpOptions.ts b/core/renderer/settings/helpOptions.ts index d7d342d..c3d2d90 100644 --- a/core/renderer/settings/helpOptions.ts +++ b/core/renderer/settings/helpOptions.ts @@ -1,4 +1,5 @@ import { HELP_KEYS } from '../lib/helpPrefs' +import type { SettingsSectionId } from './sectionIds' /** * EVERY COMMAND-HELP OPTION, BY ID (#12): the same deal `options.ts` makes for @@ -16,8 +17,13 @@ export interface HelpOption { type: 'switch' /** The localStorage key behind it. Same key in both apps, separate stores. */ key: string + /** As in `options.ts`. */ + section: SettingsSectionId + icon: string + sub: string + keywords?: string } export const HELP_OPTIONS: readonly HelpOption[] = [ - { id: 'help-enabled', label: 'Command help', type: 'switch', key: HELP_KEYS.enabled } + { id: 'help-enabled', label: 'Command help', type: 'switch', key: HELP_KEYS.enabled, section: 'help', icon: 'search', sub: 'Find a command by describing it.', keywords: 'cheat sheet commands lookup shell' } ] diff --git a/core/renderer/settings/layout/Glyph.tsx b/core/renderer/settings/layout/Glyph.tsx new file mode 100644 index 0000000..d1e82cc --- /dev/null +++ b/core/renderer/settings/layout/Glyph.tsx @@ -0,0 +1,22 @@ +import type { JSX } from 'react' +import { isSettingIcon, SETTING_ICONS } from './icons' + +/** A settings icon: a name from `icons.ts` (or a raw path) as a stroked svg. */ +export function Glyph({ name, size = 16, stroke = 1.7 }: { name: string; size?: number; stroke?: number }): JSX.Element { + return ( + + + + ) +} diff --git a/core/renderer/settings/layout/SettingBlock.tsx b/core/renderer/settings/layout/SettingBlock.tsx new file mode 100644 index 0000000..2f3f8fa --- /dev/null +++ b/core/renderer/settings/layout/SettingBlock.tsx @@ -0,0 +1,28 @@ +import type { JSX, ReactNode } from 'react' +import { RULE } from './SettingRow' + +/** A full-width part of a panel for what is not a row: a wall of cards, a + * list of models, the About card. `full` rules it edge to edge. */ +export function SettingBlock({ + full = true, + pad = false, + children, + ...rest +}: { + full?: boolean + /** The wall's padding, 14px 16px 16px. */ + pad?: boolean + children: ReactNode +} & Record<`data-${string}`, string | boolean | undefined>): JSX.Element { + return ( +
+ {children} +
+ ) +} diff --git a/core/renderer/settings/layout/SettingRow.tsx b/core/renderer/settings/layout/SettingRow.tsx new file mode 100644 index 0000000..2e16b6e --- /dev/null +++ b/core/renderer/settings/layout/SettingRow.tsx @@ -0,0 +1,143 @@ +import { useId, useLayoutEffect, useRef, type JSX, type MouseEvent, type ReactNode } from 'react' +import { Glyph } from './Glyph' + +// ONE SETTING, AS A ROW OF A SECTION'S PANEL (2026-10-05, the grouped cards +// redesign): an icon tile, the label with ONE line of subtext under it, and +// the control at the right. A grid, so every label in a panel starts at the +// same x whatever its control is. The rows of a panel are ruled by a hairline +// that starts after the icon column; a full-width block is ruled edge to edge. + +/** The hairline above every row or block but the first. A row after a full + * block starts its rule at the panel's edge too. */ +export const RULE = + "before:pointer-events-none before:absolute before:right-0 before:top-0 before:h-px before:bg-[var(--p-line)] before:content-[''] first:before:hidden" + +/** + * KEYBOARD FOCUS IS A FILL, NEVER A RING (Prism #272; owner, 2026-10-05, Q1: + * both apps). A row holding the focus takes the hover fill, and a bordered + * control in it shows the focus as its edge a step lighter, never the accent. + * The colour field keeps its own focus look (the picker is unchanged). + * + * THE DROPDOWN'S MENU BLURS WHAT IS BEHIND IT AND CASTS NO SHADOW (owner's + * popup rule, 2026-09-22), here and not in `Select` itself, so the legacy + * rows (kept until Prism moves) look exactly as they did. + */ +export const ROW_SCOPE = + 'has-[:focus-visible]:bg-[var(--p-hover)] ' + + '[&_button:not([data-colour-swatch]):focus-visible]:border-[color:color-mix(in_srgb,var(--p-text)_22%,transparent)] ' + + '[&_[role=listbox]]:rounded-[7px] [&_[role=listbox]]:border-[color:var(--p-line)] [&_[role=listbox]]:bg-[color-mix(in_srgb,var(--p-side-flat)_80%,transparent)] [&_[role=listbox]]:shadow-none [&_[role=listbox]]:backdrop-blur-[22px] [&_[role=listbox]]:backdrop-saturate-[1.3]' + +/** A subtext's ink: the dim ink a step towards the text, so it holds 4.5:1 + * on the panel as composited over a LIGHT ground too (MEASURED in + * `settingsLook`: the bare dim ink read 3.9:1 on Paper's panel). */ +export const SUB_INK = 'text-[color-mix(in_srgb,var(--p-dim)_72%,var(--p-text))]' + +/** A warning subtext's ink: the mockup's amber on a dark ground, a deeper + * amber on a light one, so it holds 4.5:1 on both (MEASURED in + * `settingsLook`; one mix for both read 4.0:1 on Paper). `light-dark()` + * follows the colour scheme the host sets from its ground. */ +export const WARN_INK = + 'text-[light-dark(color-mix(in_srgb,#a46400_75%,var(--p-text)),color-mix(in_srgb,#e0a84b_85%,var(--p-text)))]' + +/** The icon tile, 32px, its corner a step under the panel's. */ +export function IconTile({ icon }: { icon: string }): JSX.Element { + return ( +
+ +
+ ) +} + +/** The subtext: one line, truncated, the whole text on its tooltip. */ +export function Subtext({ id, text, warn }: { id?: string; text: string; warn?: boolean }): JSX.Element { + return ( +
+ {warn && ( + + + + )} + {text} +
+ ) +} + +export function SettingRow({ + id, + icon, + label, + sub, + warn, + off, + tap, + children +}: { + /** `data-pref`, and the id of the control the label names. */ + id: string + icon: string + label: string + sub?: string + /** The subtext is a warning: amber, with a mark. */ + warn?: boolean + /** Cannot be used now: dimmed, not clickable, `aria-disabled`. */ + off?: boolean + /** The control is a switch: a press anywhere on the row flips it. */ + tap?: boolean + children: ReactNode +}): JSX.Element { + const subId = useId() + const box = useRef(null) + // Every control is DESCRIBED by the subtext, so a screen reader hears the + // live state ("Needs a speech model before it works.") with the control. + useLayoutEffect(() => { + const ctl = box.current?.querySelector('[data-row-control]') + if (!ctl) return + for (const el of ctl.querySelectorAll('button:not([role="option"]), input')) { + if (sub) el.setAttribute('aria-describedby', subId) + else el.removeAttribute('aria-describedby') + } + }) + const flip = (e: MouseEvent): void => { + if (!tap || off) return + if ((e.target as Element).closest('button, input, a, [role="listbox"], [data-row-control] *')) return + box.current?.querySelector('[role="switch"]:not(:disabled)')?.click() + } + const dim = off ? 'pointer-events-none opacity-[.45]' : '' + return ( +
+
+ +
+
+ + {sub && } +
+ {/* A group named by the row's label: a segmented control's buttons are + named by their options alone ("Dynamic"), so without this a screen + reader never hears which setting they belong to (spec 1.3). */} +
+ {children} +
+
+ ) +} diff --git a/core/renderer/settings/layout/SettingsFrame.tsx b/core/renderer/settings/layout/SettingsFrame.tsx new file mode 100644 index 0000000..07fd5be --- /dev/null +++ b/core/renderer/settings/layout/SettingsFrame.tsx @@ -0,0 +1,344 @@ +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type JSX, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode +} from 'react' +import { Glyph } from './Glyph' +import { IconTile, RULE, SUB_INK, Subtext } from './SettingRow' +import { PANEL_RADIUS } from './SettingsSection' +import { flashPref } from './flash' +import { searchSettings, type SettingsIndexEntry } from './search' + +// THE SETTINGS FRAME, for both apps (2026-10-05, the grouped cards redesign; +// owner: "can you mock up some new settings pages ... grouped cards ... both +// apps", the approved v1 and its Prism Terminal version). The rail with its +// title, Find a setting and the pages; the pane with the page's header and the +// host's sections. Props only: which pages exist, what each holds and what the +// index says are the host's. +// +// THE CHOSEN PAGE IS A GREY FILL, NOT THE ACCENT (owner, 2026-10-05: no accent +// bar on the chosen rail item): `--p-hover-hi`, bold text, a brighter icon. +// +// NARROW: below 760px of the FRAME's own width (a container query, since in +// Prism Terminal the page is a tab and in Prism the frame is zoomed) the rail +// is icons only, 56px, and Find a setting is a magnifier that opens the field +// over the pane. `compact` asks for the same at any width (Prism's toggle). + +export interface SettingsPageDef { + id: string + label: string + icon: string + /** Drawn at the bottom of the rail, after a spacer (About). */ + end?: boolean +} + +export type { SettingsIndexEntry } from './search' + +/** The settings font: the system's, whatever the theme or style wears. */ +export const SETTINGS_FONT = '"Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif' +const DISPLAY_FONT = '"Segoe UI Variable Display", "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif' + +// Every narrow rule twice, as literals Tailwind can find: once behind the +// container query, once for `compact`, which applies it at any width. +const NARROW = { + rail: '@max-[760px]:w-14 @max-[760px]:items-center @max-[760px]:px-2', + hide: '@max-[760px]:hidden', + find: '@max-[760px]:w-10', + input: + '@max-[760px]:absolute @max-[760px]:left-0 @max-[760px]:top-0 @max-[760px]:w-10 @max-[760px]:cursor-pointer @max-[760px]:pr-0 @max-[760px]:text-transparent @max-[760px]:placeholder:text-transparent @max-[760px]:focus:w-[300px] @max-[760px]:focus:cursor-text @max-[760px]:focus:bg-[var(--p-side-flat)] @max-[760px]:focus:pr-[30px] @max-[760px]:focus:text-[var(--p-text)] @max-[760px]:focus:placeholder:text-[var(--p-dim)] @max-[760px]:[&:not(:placeholder-shown)]:w-[300px] @max-[760px]:[&:not(:placeholder-shown)]:bg-[var(--p-side-flat)] @max-[760px]:[&:not(:placeholder-shown)]:pr-[30px] @max-[760px]:[&:not(:placeholder-shown)]:text-[var(--p-text)]', + glass: '@max-[760px]:left-[13px]', + clear: '@max-[760px]:left-[274px] @max-[760px]:right-auto', + nav: '@max-[760px]:w-10 @max-[760px]:justify-center @max-[760px]:px-0', + inner: '@max-[760px]:px-5 @max-[760px]:pb-12 @max-[760px]:pt-5' +} +const COMPACT = { + rail: 'w-14 items-center px-2', + hide: 'hidden', + find: 'w-10', + input: + 'absolute left-0 top-0 w-10 cursor-pointer pr-0 text-transparent placeholder:text-transparent focus:w-[300px] focus:cursor-text focus:bg-[var(--p-side-flat)] focus:pr-[30px] focus:text-[var(--p-text)] focus:placeholder:text-[var(--p-dim)] [&:not(:placeholder-shown)]:w-[300px] [&:not(:placeholder-shown)]:bg-[var(--p-side-flat)] [&:not(:placeholder-shown)]:pr-[30px] [&:not(:placeholder-shown)]:text-[var(--p-text)]', + glass: 'left-[13px]', + clear: 'left-[274px] right-auto', + nav: 'w-10 justify-center px-0', + inner: 'px-5 pb-12 pt-5' +} + +export function SettingsFrame({ + pages, + page, + onPage, + index, + title = 'Settings', + headerAction, + compact = false, + children +}: { + pages: SettingsPageDef[] + page: string + onPage: (id: string, view?: string) => void + /** Every row the host draws on this PC, in page order. */ + index: readonly SettingsIndexEntry[] + title?: string + /** At the right end of the page header (Prism's Media switch). */ + headerAction?: ReactNode + compact?: boolean + children: ReactNode +}): JSX.Element { + const n = compact ? COMPACT : NARROW + const [query, setQuery] = useState('') + const [cursor, setCursor] = useState(-1) + const [pending, setPending] = useState(null) + const field = useRef(null) + const pane = useRef(null) + const list = useRef(null) + const listId = useId() + const headId = useId() + const names = useMemo(() => Object.fromEntries(pages.map((p) => [p.id, p.label])), [pages]) + const searching = query.trim().length > 0 + const hits = useMemo(() => (searching ? searchSettings(index, query, names) : []), [searching, index, query, names]) + const active = pages.find((p) => p.id === page) ?? pages[0] + + // A new page starts at its top; a result then scrolls to its row. + useEffect(() => { + if (pane.current) pane.current.scrollTop = 0 + }, [page, searching]) + useEffect(() => { + if (!pending || !pane.current) return + // Each choice is a new object, so this runs once per choice; the wait + // for a late row is cancelled only by the next choice or by leaving. + return flashPref(pane.current, pending.id, pending.sectionId) + }, [pending]) + + const choose = (hit: SettingsIndexEntry): void => { + setQuery('') + setCursor(-1) + onPage(hit.page, hit.view) + setPending({ ...hit }) + } + const mark = (i: number): void => { + setCursor(i) + list.current?.querySelectorAll('[role="option"]')[i]?.focus() + } + const clear = (): void => { + setQuery('') + setCursor(-1) + field.current?.focus() + } + + const onFieldKey = (e: ReactKeyboardEvent): void => { + if (e.key === 'Escape' && query) { + // Only an EMPTY field lets Escape through to the host (Prism closes + // Settings on it). + e.preventDefault() + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + setQuery('') + setCursor(-1) + } else if (e.key === 'ArrowDown' && hits.length) { + e.preventDefault() + mark(0) + } else if (e.key === 'Enter' && hits.length) { + e.preventDefault() + choose(hits[0]) + } + } + const onListKey = (e: ReactKeyboardEvent): void => { + if (e.key === 'ArrowDown') { + e.preventDefault() + mark(Math.min(cursor + 1, hits.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + if (cursor <= 0) { + setCursor(-1) + field.current?.focus() + } else mark(cursor - 1) + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + if (hits[cursor]) choose(hits[cursor]) + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + clear() + } + } + const onRailKey = (e: ReactKeyboardEvent): void => { + const all = [...(e.currentTarget.querySelectorAll('[data-settings-tab]') ?? [])] + const i = all.indexOf(document.activeElement as HTMLElement) + if (i < 0) return + const j = + e.key === 'ArrowDown' ? Math.min(i + 1, all.length - 1) + : e.key === 'ArrowUp' ? Math.max(i - 1, 0) + : e.key === 'Home' ? 0 + : e.key === 'End' ? all.length - 1 + : -1 + if (j < 0) return + e.preventDefault() + all[j].focus() + } + + const railButton = (p: SettingsPageDef): JSX.Element => { + const on = p.id === active.id && !searching + return ( + + ) + } + + return ( +
+ + +
+
+
+

+ {searching ? 'Results' : active.label} +

+ {searching ? ( + + {hits.length ? `${hits.length} ${hits.length === 1 ? 'result' : 'results'}` : 'No results'} + + ) : ( + headerAction &&
{headerAction}
+ )} +
+ {searching ? ( +
+ {hits.length ? ( +
+ {hits.map((h, i) => ( +
choose(h)} + onFocus={() => setCursor(i)} + className={`relative grid min-h-[58px] cursor-pointer grid-cols-[32px_minmax(0,1fr)_auto] items-center gap-x-3.5 py-2.5 pl-3.5 pr-4 outline-none first:rounded-t-[inherit] last:rounded-b-[inherit] ${RULE} before:left-[60px] hover:bg-[color-mix(in_srgb,var(--p-text)_2.5%,transparent)] aria-selected:bg-[var(--p-hover)]`} + > + +
+
{h.label}
+ {h.sub && } +
+ + {[names[h.page] ?? h.page, h.section].filter(Boolean).join(', ')} + +
+ ))} +
+ ) : ( +
+ Nothing matches {query.trim()}. Try a shorter word. +
+ )} +
+ ) : ( + children + )} +
+
+
+ ) +} diff --git a/core/renderer/settings/layout/SettingsSection.tsx b/core/renderer/settings/layout/SettingsSection.tsx new file mode 100644 index 0000000..0945dd5 --- /dev/null +++ b/core/renderer/settings/layout/SettingsSection.tsx @@ -0,0 +1,64 @@ +import { useId, type JSX, type ReactNode } from 'react' + +// A SECTION OF A SETTINGS PAGE (2026-10-05, the grouped cards redesign): a +// heading in sentence case, then ONE panel. The panel is one thin coat of the +// text's own ink over the ground (3.6%), so it reads on glass and on every +// theme without ever becoming a solid slab, and its edge is `--p-line`, which +// the window's Edges setting already drives. Its corners follow the host's +// own roundness, at least 4px. + +/** The panel's corner: the host's radius plus 3px, never under 4px. */ +export const PANEL_RADIUS = 'rounded-[max(4px,calc(var(--p-radius)_+_3px))]' + +export function SettingsSection({ + id, + title, + action, + children +}: { + /** `data-settings-section`: what the search and the e2e find it by. */ + id: string + /** The heading. A section with none (the first one of Dictation) has a + * label for assistive technology only. */ + title?: string + /** At the heading's right end: a tag, or Save changes. */ + action?: ReactNode + children: ReactNode +}): JSX.Element { + const h = useId() + return ( +
+ {(title || action) && ( +
+ {title && ( +

+ {title} +

+ )} + {action &&
{action}
} +
+ )} +
+ {children} +
+
+ ) +} + +/** A tag for a section heading ("Shared by both apps"): words, not a button. */ +export function SectionTag({ children }: { children: ReactNode }): JSX.Element { + return ( + + {children} + + ) +} diff --git a/core/renderer/settings/layout/flash.ts b/core/renderer/settings/layout/flash.ts new file mode 100644 index 0000000..d425f4d --- /dev/null +++ b/core/renderer/settings/layout/flash.ts @@ -0,0 +1,66 @@ +/** + * TAKE THE EYE TO A ROW (2026-10-05, Find a setting): scroll it to the middle + * of the pane, flash it, and put the keyboard on its first control. The flash + * is a MARK, not a button, so it may wear the accent: a 16% fill of + * `--p-accent-hi` fading over 1.4s. Nothing moves under reduced motion. + * + * A row may be drawn LATE (Default shell renders nothing until main has listed + * the shells), so the row is looked for until it exists, for up to 3 s, and + * the section it belongs to stands in if it never comes. + */ +const WAIT_MS = 3000 + +/** The first control a keyboard can use in a row, else the row itself. */ +function focusIn(row: HTMLElement): void { + const ctl = row.querySelector( + 'button:not(:disabled):not([role="option"]), input:not(:disabled), [tabindex="0"]' + ) + if (ctl) ctl.focus({ preventScroll: true }) + else { + if (!row.hasAttribute('tabindex')) row.setAttribute('tabindex', '-1') + row.focus({ preventScroll: true }) + } +} + +export function flashRow(row: HTMLElement): void { + row.scrollIntoView({ block: 'center' }) + const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + // `data-flash` while it runs: the e2e reads it, a screen reader does not. + row.setAttribute('data-flash', '') + if (!still && typeof row.animate === 'function') { + const lit = 'color-mix(in srgb, var(--p-accent-hi) 16%, transparent)' + row.animate( + [ + { backgroundColor: lit, offset: 0 }, + { backgroundColor: lit, offset: 0.3 }, + { backgroundColor: 'transparent', offset: 1 } + ], + { duration: 1400, easing: 'ease-out' } + ) + } + window.setTimeout(() => row.removeAttribute('data-flash'), 1400) + focusIn(row) +} + +/** Find `[data-pref=]` inside `root` (waiting for a late row), then flash + * it; `section` names the fallback. Returns a cancel. */ +export function flashPref(root: ParentNode, id: string, section?: string): () => void { + const started = performance.now() + let timer = 0 + let frame = 0 + const look = (): void => { + const row = root.querySelector(`[data-pref="${CSS.escape(id)}"]`) + if (row) return flashRow(row) + if (performance.now() - started < WAIT_MS) { + timer = window.setTimeout(() => (frame = requestAnimationFrame(look)), 60) + return + } + const fallback = section ? root.querySelector(`[data-settings-section="${CSS.escape(section)}"]`) : null + if (fallback) flashRow(fallback) + } + frame = requestAnimationFrame(look) + return () => { + clearTimeout(timer) + cancelAnimationFrame(frame) + } +} diff --git a/core/renderer/settings/layout/icons.ts b/core/renderer/settings/layout/icons.ts new file mode 100644 index 0000000..43b8436 --- /dev/null +++ b/core/renderer/settings/layout/icons.ts @@ -0,0 +1,55 @@ +/** + * THE SETTINGS ICONS, by name (2026-10-05, the grouped cards redesign). One + * set for both apps, so a row's tile is the same picture in each: every one a + * 24 unit stroke path drawn at 16px with a 1.7 stroke, the rail's at 17px. + * Drawn for the approved mockup; a list entry names its icon here and a unit + * test holds every name a list uses to this record. + */ +export const SETTING_ICONS = { + // Pages. + appearance: 'M12 3a9 9 0 1 0 0 18 3 3 0 0 0 0-6 3 3 0 0 1 0-6h3a6 6 0 0 0-3-6ZM7.5 10.5h.01M10 7h.01M14 7h.01', + terminal: 'M4 5h16v14H4zM7.5 9.5l3 2.5-3 2.5M13 15h4', + agents: 'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9zM18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z', + dictation: 'M12 3a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21', + about: 'M12 8h.01M11 12h1v4h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z', + // Rows. + brush: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16zM13.5 6.5l4 4', + viewer: 'M4 5h16v14H4zM8 15l3-3 2 2 3-3 2 2', + accent: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z', + font: 'M4 19l5.5-14h1L16 19M6.3 14h7.4M17 19v-6a2.5 2.5 0 0 1 4 0v6', + size: 'M3 8V6h10v2M8 6v12M6 18h4M14 13v-1h7v1M17.5 12v6M16 18h3', + titlebar: 'M4 5h16v14H4zM4 9h16', + tabs: 'M3 10h18v9H3zM4 10V7h6v3M12 10V7h7v3', + edges: 'M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5', + newtab: 'M3 6.5A1.5 1.5 0 0 1 4.5 5H9l2 2h8.5A1.5 1.5 0 0 1 21 8.5v9a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5zM12 10v6M9 13h6', + menu: 'M5 4h14v16H5zM8 8h8M8 12h8M8 16h5', + shell: 'M5 7l5 5-5 5M12 17h7', + glass: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 3v18M12 7.5h6.5M12 12h9M12 16.5h6.5', + help: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .8-1 1.5v.4M12 16.5h.01', + search: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4', + working: 'M3 12h4l3-7 4 14 3-7h4', + done: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM8 12.5l3 3 5-6', + ask: 'M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-4.9A8 8 0 1 1 21 12zM10 10a2 2 0 1 1 2.8 1.8c-.5.2-.8.7-.8 1.2M12 15.5h.01', + fail: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7.5v5.5M12 16.5h.01', + taskbar: 'M3 15h18v5H3zM6.5 17.5h.01M10 17.5h4M17 4.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z', + hook: 'M8 3v6a4 4 0 0 0 8 0V3M12 13v3a5 5 0 0 1-5 5', + hand: 'M8 13V5.5a1.5 1.5 0 0 1 3 0V12M11 11.5V4.5a1.5 1.5 0 0 1 3 0V12M14 11.5V6.5a1.5 1.5 0 0 1 3 0V14a6 6 0 0 1-6 6h-1a5 5 0 0 1-4.2-2.3L4.5 14a1.5 1.5 0 0 1 2.5-1.6L8 14', + key: 'M3 6h18v12H3zM7 10h.01M11 10h.01M15 10h.01M7 14h10', + mic: 'M12 3a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21', + globe: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c2.5 2.7 3.8 5.7 3.8 9s-1.3 6.3-3.8 9c-2.5-2.7-3.8-5.7-3.8-9S9.5 5.7 12 3z', + pause: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM10 9v6M14 9v6', + sound: 'M4 9.5v5h4l5 4v-13l-5 4zM16.5 9a4 4 0 0 1 0 6M19 6.5a7.5 7.5 0 0 1 0 11', + download: 'M12 4v11M7 10.5l5 5 5-5M5 20h14', + chip: 'M7 7h10v10H7zM10 3v4M14 3v4M10 17v4M14 17v4M3 10h4M3 14h4M17 10h4M17 14h4', + version: 'M20 12l-8 8-9-9V3h8zM7.5 7.5h.01', + // Marks inside a row. + warn: 'M12 4l9 16H3zM12 10v4M12 17h.01', + x: 'M6 6l12 12M18 6L6 18' +} as const + +export type SettingIconName = keyof typeof SETTING_ICONS + +/** Whether a name is in the set: lists name their icons as plain strings. */ +export function isSettingIcon(name: string): name is SettingIconName { + return Object.prototype.hasOwnProperty.call(SETTING_ICONS, name) +} diff --git a/core/renderer/settings/layout/search.test.ts b/core/renderer/settings/layout/search.test.ts new file mode 100644 index 0000000..f3fc02b --- /dev/null +++ b/core/renderer/settings/layout/search.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { fold, searchSettings, type SettingsIndexEntry } from './search' + +const e = (id: string, label: string, sub = '', extra: Partial = {}): SettingsIndexEntry => ({ + id, + page: 'appearance', + section: 'Theme', + label, + sub, + icon: 'brush', + ...extra +}) + +const INDEX = [ + e('term-theme', 'Terminal theme', 'Colours of the terminal and the window.', { keywords: 'palette scheme' }), + e('window-background', 'Background colour', 'Behind the text in window and terminal.'), + e('window-accent', 'Accent colour', 'Highlights, the active tab and selection.'), + e('term-font', 'Terminal text size', 'Text size for every terminal.', { page: 'terminal', section: 'Text' }), + e('agent-color', 'Agent working colour', 'Follows the accent.', { page: 'agents', section: 'Mark colours' }), + e('dictation-language', 'Spoken language', 'The language you speak.', { page: 'dictation', section: 'Listening', keywords: 'norwegian' }) +] +const ids = (q: string): string[] => searchSettings(INDEX, q, { appearance: 'Appearance', agents: 'Agents' }).map((r) => r.id) + +describe('find a setting', () => { + it('finds nothing for an empty or blank query', () => { + expect(ids('')).toEqual([]) + expect(ids(' ')).toEqual([]) + }) + + it('needs every word to match', () => { + expect(ids('accent colour')).toEqual(['window-accent', 'agent-color']) + expect(ids('accent spoken')).toEqual([]) + }) + + it('matches the start of a word, not its middle', () => { + expect(ids('col')).toContain('window-background') + expect(ids('olour')).toEqual([]) + }) + + it('folds case and accents', () => { + expect(fold('Àccént')).toBe('accent') + expect(ids('ACCÉNT')).toEqual(['window-accent', 'agent-color']) + }) + + it('puts a match in the label first, an exact label first of all', () => { + // "terminal" is in the theme's label and in the background's subtext. + expect(ids('terminal')).toEqual(['term-theme', 'term-font', 'window-background']) + expect(ids('terminal text size')[0]).toBe('term-font') + expect(ids('Agent working colour')[0]).toBe('agent-color') + }) + + it('reads the hidden keywords, the section and the page', () => { + expect(ids('palette')).toEqual(['term-theme']) + expect(ids('norwegian')).toEqual(['dictation-language']) + expect(ids('mark colours')).toEqual(['agent-color']) + expect(ids('agents')).toEqual(['agent-color']) + }) + + it('answers nothing found with an empty list', () => { + expect(ids('zebra')).toEqual([]) + }) +}) diff --git a/core/renderer/settings/layout/search.ts b/core/renderer/settings/layout/search.ts new file mode 100644 index 0000000..696cee7 --- /dev/null +++ b/core/renderer/settings/layout/search.ts @@ -0,0 +1,52 @@ +/** + * FIND A SETTING (2026-10-05, the grouped cards redesign). Pure: the frame + * hands in an index (one entry per row the host DRAWS on this PC), this + * answers which match a query and in what order. No model, no network. + * + * Every word of the query must match the START of some word of the entry's + * label, subtext, section, page or hidden keywords, case and accents folded. + * Entries whose LABEL holds every word come first, an exact label first of + * all; otherwise the index's own order (the page order) stands. + */ +export interface SettingsIndexEntry { + /** The row's `data-pref`. */ + id: string + /** The page that holds it. */ + page: string + /** A view inside that page, for a host whose page has two (Prism's Media). */ + view?: string + /** The section's title, as the result says where the row lives. */ + section: string + /** The section's `data-settings-section`, flashed if the row never comes. */ + sectionId?: string + label: string + sub: string + icon: string + /** Words nobody sees that should still find the row. */ + keywords?: string +} + +/** Lower case, accents gone. */ +export function fold(text: string): string { + return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() +} + +const words = (text: string): string[] => fold(text).split(/[^a-z0-9]+/).filter(Boolean) + +/** The page's NAME, which the index entry knows only by id. */ +export type PageNames = Record + +export function searchSettings(index: readonly SettingsIndexEntry[], query: string, pages: PageNames = {}): SettingsIndexEntry[] { + const q = words(query) + if (!q.length) return [] + const scored: Array<{ e: SettingsIndexEntry; rank: number; i: number }> = [] + index.forEach((e, i) => { + const hay = words([e.label, e.sub, e.section, pages[e.page] ?? e.page, e.keywords ?? ''].join(' ')) + if (!q.every((w) => hay.some((h) => h.startsWith(w)))) return + const label = words(e.label) + const inLabel = q.every((w) => label.some((h) => h.startsWith(w))) + const exact = fold(e.label).trim() === fold(query).trim().replace(/\s+/g, ' ') + scored.push({ e, rank: exact ? 0 : inLabel ? 1 : 2, i }) + }) + return scored.sort((a, b) => a.rank - b.rank || a.i - b.i).map((s) => s.e) +} diff --git a/core/renderer/settings/neutralControls.test.ts b/core/renderer/settings/neutralControls.test.ts index ef49093..f5c7eb8 100644 --- a/core/renderer/settings/neutralControls.test.ts +++ b/core/renderer/settings/neutralControls.test.ts @@ -1,5 +1,5 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { ROW_BUTTON, SEGMENT_ON, SWITCH_KNOB_ON, SWITCH_ON } from './fields' @@ -8,6 +8,8 @@ import { ROW_BUTTON, SEGMENT_ON, SWITCH_KNOB_ON, SWITCH_ON } from './fields' // an accent slipped back into a control fails before anything is built. const ACCENT = /--p-(accent|accent-hi|on-accent|sel-bg)\b/ +const code = (src: string): string[] => src.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*\*)/.test(l)) + describe('settings controls', () => { it('row button, pressed segment and switch carry no accent token', () => { for (const cls of [ROW_BUTTON, SEGMENT_ON, SWITCH_ON, SWITCH_KNOB_ON]) { @@ -17,7 +19,7 @@ describe('settings controls', () => { }) it("dictation's buttons carry no accent", () => { - const src = readFileSync(resolve(__dirname, 'Dictation.tsx'), 'utf8') + const src = readFileSync(resolve(__dirname, 'dictation', 'parts.tsx'), 'utf8') for (const name of ['button', 'primary']) { const m = src.match(new RegExp(`const ${name} =\\s*'([^']*)'`)) expect(m, name).not.toBeNull() @@ -30,8 +32,7 @@ describe('settings controls', () => { // only ever a focus ring. it("the colour picker's controls carry no accent", () => { const src = readFileSync(resolve(__dirname, 'ColourPicker.tsx'), 'utf8') - const lines = src.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*\*)/.test(l)) - for (const l of lines) expect(l.replace(/focus-visible:\S+/g, '').replace(/const FOCUS =.*/, ''), l).not.toMatch(ACCENT) + for (const l of code(src)) expect(l.replace(/focus-visible:\S+/g, '').replace(/const FOCUS =.*/, ''), l).not.toMatch(ACCENT) expect(src).toMatch(/\$\{ROW_BUTTON\}/) }) @@ -40,4 +41,34 @@ describe('settings controls', () => { const save = src.slice(src.indexOf('export function SaveButton'), src.indexOf('export function ThemeHead')) expect(save).toMatch(/bg-\[var\(--p-accent\)\]/) }) + + // THE GROUPED CARDS (2026-10-05). The frame, the section, the row and the + // block wear no accent at all: the chosen rail page is a GREY fill (owner, + // no accent bar), focus is a fill or a lighter edge (Q1). Only the flash, + // a mark and not a button, may (flash.ts). + it('the layout primitives carry no accent, the flash aside', () => { + const dir = join(__dirname, 'layout') + const files = readdirSync(dir).filter((f) => /\.tsx?$/.test(f) && !f.endsWith('.test.ts') && f !== 'flash.ts') + expect(files.length).toBeGreaterThanOrEqual(6) + for (const f of files) + for (const l of code(readFileSync(join(dir, f), 'utf8'))) expect(l, `${f}: ${l}`).not.toMatch(ACCENT) + expect(readFileSync(join(dir, 'flash.ts'), 'utf8')).toMatch(/--p-accent-hi/) + }) + + // The sections' buttons are the core's neutral ones (ROW_BUTTON, Save, + // Reset, the colour field). In Dictation the accent is only on what is not + // a button: the Recommended and Active badges and the download's progress. + it("the sections' controls carry no accent; dictation's badges and progress may", () => { + const dir = join(__dirname, 'sections') + for (const f of readdirSync(dir).filter((x) => x.endsWith('.tsx'))) { + const lines = code(readFileSync(join(dir, f), 'utf8')) + lines.forEach((l, i) => { + if (!ACCENT.test(l)) return + expect(f, l).toBe('DictationPage.tsx') + // The element the class belongs to: the nearest tag opened above it. + const tag = lines.slice(0, i + 1).reverse().find((x) => /<[a-zA-Z]/.test(x)) ?? '' + expect(tag, l).toMatch(/ { + const p = join(dir, f) + return statSync(p).isDirectory() ? walk(p) : [p] + }) +} +const files = walk(__dirname).filter((f) => /\.tsx?$/.test(f) && !/\.test\.tsx?$/.test(f)) +const sections = files .filter((f) => f.endsWith('.tsx')) - .map((f) => readFileSync(join(__dirname, f), 'utf8')) + .map((f) => readFileSync(f, 'utf8')) .join('\n') -/** Every row id a shared section renders: ` m[1] ?? m[2]) + [...sections.matchAll(/<(?:Pref|SettingRow)\s+id="([a-z-]+)"|data-pref="([a-z-]+)"/g)].map((m) => m[1] ?? m[2]) ) +const ALL = [...TERMINAL_OPTIONS, ...DICTATION_OPTIONS, ...HELP_OPTIONS] +const LIST_FILES = ['options.ts', 'dictationOptions.ts', 'helpOptions.ts'] + describe('the terminal options list', () => { it('names every row the shared sections render, and nothing they do not', () => { - expect([...rendered].sort()).toEqual( - [...TERMINAL_OPTIONS, ...DICTATION_OPTIONS, ...HELP_OPTIONS].map((o) => o.id).sort() - ) + expect([...rendered].sort()).toEqual(ALL.map((o) => o.id).sort()) }) it('has one storage key per option, all under prism.term', () => { @@ -60,4 +75,86 @@ describe('the terminal options list', () => { const without = dictationOptionIds({ nvidia: false }) expect(all.filter((id) => !without.includes(id))).toEqual(['dictation-gpu']) }) + + // NO STORAGE KEY CHANGES (2026-10-05, spec 1.1): a key is a saved setting. + it('keeps every storage key it has always had', () => { + expect(ALL.map((o) => `${o.id}=${o.key}`)).toMatchInlineSnapshot(` + [ + "term-shell=prism.term.shell", + "term-font-family=prism.term.font", + "term-font=prism.term.fontPct", + "agent-indicator=prism.term.agentIndicator", + "agent-done-on=prism.term.agentDoneOn", + "agent-question-on=prism.term.agentQuestionOn", + "agent-failed-on=prism.term.agentFailedOn", + "agent-hooks=prism.term.agentHooks", + "term-theme=prism.term.theme", + "term-acrylic=prism.term.acrylic", + "agent-color=prism.term.agentColor", + "agent-done-color=prism.term.agentDoneColor", + "agent-question-color=prism.term.agentQuestionColor", + "dictation-enabled=prism.dictation.enabled", + "dictation-mode=prism.dictation.mode", + "dictation-hotkey=prism.dictation.hotkey", + "dictation-mic=prism.dictation.mic", + "dictation-language=prism.dictation.language", + "dictation-pause-media=prism.dictation.pauseMedia", + "dictation-sounds=prism.dictation.sounds", + "dictation-model=prism.dictation.model", + "dictation-gpu=null", + "help-enabled=prism.help.enabled", + ] + `) + }) +}) + +describe('the grouped cards fields of the lists (2026-10-05)', () => { + it('gives every entry a known section, a known icon and a subtext', () => { + for (const o of ALL) { + expect(Object.keys(SETTINGS_SECTIONS), o.id).toContain(o.section) + expect(isSettingIcon(o.icon), `${o.id}: ${o.icon}`).toBe(true) + expect(o.sub.length, o.id).toBeGreaterThan(0) + } + }) + + it('keeps the list order inside every section, which is the order the sections draw', () => { + // The order did not change with the redesign: within each section it is + // already the display order, which keeps Prism's current gate green. + const grouped = files + .filter((f) => f.includes(`${join('settings', 'sections')}`) && f.endsWith('.tsx')) + .map((f) => readFileSync(f, 'utf8')) + .join('\n') + const drawn = [...grouped.matchAll(/ m[1] ?? m[2]) + for (const s of Object.keys(SETTINGS_SECTIONS)) { + const ids = ALL.filter((o) => o.section === s).map((o) => o.id) + expect(drawn.filter((id) => ids.includes(id)), s).toEqual(ids) + } + }) + + it('words every label and subtext in plain words, each subtext at most eight', () => { + for (const o of ALL) { + expect(labelProblem(o.label), o.label).toBeNull() + expect(copyProblem(o.sub), o.sub).toBeNull() + expect(subTooLong(o.sub), o.sub).toBe(false) + } + }) + + // PRISM'S GATE READS THESE FILES AS TEXT: an entry is `{ id: '...'` up to + // its first closing brace, and the GPU row is skipped by finding the word + // onlyWhere inside that text. So no entry may hold a brace of its own, and + // the word appears only as the field's name. + it('keeps every entry flat, on one line, with no brace inside it', () => { + for (const f of LIST_FILES) { + const src = readFileSync(join(__dirname, f), 'utf8') + const entries = [...src.matchAll(/\{\s*id: '([a-z-]+)'[^}]*\}/g)] + const ids = entries.map((m) => m[1]) + const list = f === 'options.ts' ? TERMINAL_OPTIONS : f === 'dictationOptions.ts' ? DICTATION_OPTIONS : HELP_OPTIONS + expect(ids, f).toEqual(list.map((o) => o.id)) + for (const m of entries) { + expect(m[0].includes('\n'), m[1]).toBe(false) + expect(m[0].split('{').length - 1, m[1]).toBe(1) + expect((m[0].match(/onlyWhere/g) ?? []).length, m[1]).toBe(m[0].includes('onlyWhere:') ? 1 : 0) + } + } + }) }) diff --git a/core/renderer/settings/options.ts b/core/renderer/settings/options.ts index 4615145..a59a159 100644 --- a/core/renderer/settings/options.ts +++ b/core/renderer/settings/options.ts @@ -1,3 +1,5 @@ +import type { SettingsSectionId } from './sectionIds' + /** * EVERY TERMINAL OPTION, BY ID (#15). The owner's rule: the two apps' terminal * settings are the same settings ("the setting names, types, how they @@ -17,30 +19,44 @@ export interface TerminalOption { /** Absent = every host. No row uses it since the Opacity slider went * (#114); kept, since each host's parity check reads it. */ onlyWhere?: 'the terminal owns the window acrylic' + /** The section that draws it (`SETTINGS_SECTIONS`). */ + section: SettingsSectionId + /** Its tile (`layout/icons.ts`). */ + icon: string + /** Its subtext at rest, which is also what Find a setting reads. At most + * eight plain words; the row may say a live state in its place. */ + sub: string + /** Words nobody sees that should still find it. */ + keywords?: string } /** In DISPLAY ORDER (owner, 2026-09-22: the two apps' terminal settings "the - * same in terms of order"). Each app's e2e reads the page top to bottom and - * asserts these ids appear in this order, whatever of its own sits between. */ + * same in terms of order"). Since the grouped cards redesign (2026-10-05) + * the order is checked PER SECTION: each app's e2e reads every core section + * top to bottom against this list; which page holds a section is the host's. + * + * FLAT ENTRIES, ONE LINE EACH, NO BRACE INSIDE ONE: Prism's gate reads this + * file as text, an entry being `{ id: '...' ... }` up to its first closing + * brace (a core test holds it). */ export const TERMINAL_OPTIONS: readonly TerminalOption[] = [ - { id: 'term-shell', label: 'Shell', type: 'choice', key: 'prism.term.shell' }, - { id: 'term-font-family', label: 'Font', type: 'choice', key: 'prism.term.font' }, - { id: 'term-font', label: 'Font size', type: 'choice', key: 'prism.term.fontPct' }, - { id: 'agent-indicator', label: 'Agent indicator', type: 'choice', key: 'prism.term.agentIndicator' }, - { id: 'agent-done-on', label: 'Finished indicator', type: 'switch', key: 'prism.term.agentDoneOn' }, - { id: 'agent-question-on', label: 'Question indicator', type: 'switch', key: 'prism.term.agentQuestionOn' }, + { id: 'term-shell', label: 'Default shell', type: 'choice', key: 'prism.term.shell', section: 'shell', icon: 'shell', sub: 'New terminals start with this shell.', keywords: 'powershell pwsh cmd command prompt bash wsl' }, + { id: 'term-font-family', label: 'Terminal font', type: 'choice', key: 'prism.term.font', section: 'text', icon: 'font', sub: 'The typeface inside every terminal.', keywords: 'typeface face monospace cascadia consolas' }, + { id: 'term-font', label: 'Terminal text size', type: 'choice', key: 'prism.term.fontPct', section: 'text', icon: 'size', sub: 'Text size for every terminal.', keywords: 'font zoom bigger smaller scale' }, + { id: 'agent-indicator', label: 'Agent working indicator', type: 'choice', key: 'prism.term.agentIndicator', section: 'marks', icon: 'working', sub: 'A line under the tab while it works.', keywords: 'claude codex busy minimal full mark' }, + { id: 'agent-done-on', label: 'Mark tabs when an agent finishes', type: 'switch', key: 'prism.term.agentDoneOn', section: 'marks', icon: 'done', sub: 'Stays until you open the tab.', keywords: 'finished complete done indicator' }, + { id: 'agent-question-on', label: 'Mark tabs when an agent asks', type: 'switch', key: 'prism.term.agentQuestionOn', section: 'marks', icon: 'ask', sub: 'Stays until you answer or open it.', keywords: 'question waiting answer indicator' }, // Claude Code's own word through its hooks (#131): a Failed mark, and the // switch that hands new shells the plugin which tells it. - { id: 'agent-failed-on', label: 'Failed indicator', type: 'switch', key: 'prism.term.agentFailedOn' }, - { id: 'agent-hooks', label: 'Exact status from Claude Code', type: 'switch', key: 'prism.term.agentHooks' }, + { id: 'agent-failed-on', label: 'Mark tabs when an agent fails', type: 'switch', key: 'prism.term.agentFailedOn', section: 'marks', icon: 'fail', sub: 'When it stops on an error.', keywords: 'failed error crash stopped indicator' }, + { id: 'agent-hooks', label: 'Exact status from Claude Code', type: 'switch', key: 'prism.term.agentHooks', section: 'claude', icon: 'hook', sub: 'Applies to terminals opened after a change.', keywords: 'hooks plugin status anthropic' }, // The theme wall, and under it only what a theme sets (2026-09-28). - { id: 'term-theme', label: 'Theme', type: 'theme', key: 'prism.term.theme' }, - { id: 'term-acrylic', label: 'Acrylic background', type: 'switch', key: 'prism.term.acrylic' }, + { id: 'term-theme', label: 'Terminal theme', type: 'theme', key: 'prism.term.theme', section: 'theme', icon: 'brush', sub: 'Colours of the terminal and the window.', keywords: 'colors palette scheme dark light preset custom' }, + { id: 'term-acrylic', label: 'Acrylic terminal background', type: 'switch', key: 'prism.term.acrylic', section: 'theme', icon: 'glass', sub: 'The desktop shows through the window.', keywords: 'transparent glass blur mica see through' }, // No Opacity row (#114): the theme Background's alpha is the window's // see-through where the terminal owns the window acrylic. - { id: 'agent-color', label: 'Working colour', type: 'colour', key: 'prism.term.agentColor' }, - { id: 'agent-done-color', label: 'Finished colour', type: 'colour', key: 'prism.term.agentDoneColor' }, - { id: 'agent-question-color', label: 'Question colour', type: 'colour', key: 'prism.term.agentQuestionColor' } + { id: 'agent-color', label: 'Agent working colour', type: 'colour', key: 'prism.term.agentColor', section: 'colours', icon: 'working', sub: 'Follows the accent.', keywords: 'color indicator busy' }, + { id: 'agent-done-color', label: 'Agent finished colour', type: 'colour', key: 'prism.term.agentDoneColor', section: 'colours', icon: 'done', sub: 'Follows the theme green.', keywords: 'color indicator complete' }, + { id: 'agent-question-color', label: 'Agent question colour', type: 'colour', key: 'prism.term.agentQuestionColor', section: 'colours', icon: 'ask', sub: 'Follows the theme.', keywords: 'color indicator waiting' } ] /** The option ids a host should be showing. */ diff --git a/core/renderer/settings/sectionIds.ts b/core/renderer/settings/sectionIds.ts new file mode 100644 index 0000000..8bd7fcb --- /dev/null +++ b/core/renderer/settings/sectionIds.ts @@ -0,0 +1,26 @@ +/** + * THE CORE'S SETTINGS SECTIONS, by id, with the heading each wears (2026-10-05, + * the grouped cards redesign). A section is drawn the same in both apps; which + * PAGE holds it is each host's (Prism Terminal puts the theme on Appearance, + * Prism on Terminal). Every option list names its section here, and a test + * holds every name to this record. + * + * `dictation` has no heading: it is the first section of the Dictation page, + * and the page's own title says what it is. + */ +export const SETTINGS_SECTIONS = { + shell: 'Shell', + text: 'Text', + theme: 'Theme', + help: 'Command help', + marks: 'Tab marks', + claude: 'Claude Code', + colours: 'Mark colours', + dictation: '', + listening: 'Listening', + while: 'While dictating', + models: 'Speech models', + gpu: 'GPU acceleration' +} as const + +export type SettingsSectionId = keyof typeof SETTINGS_SECTIONS diff --git a/core/renderer/settings/sections/AgentMarksSection.tsx b/core/renderer/settings/sections/AgentMarksSection.tsx new file mode 100644 index 0000000..5ae0d4b --- /dev/null +++ b/core/renderer/settings/sections/AgentMarksSection.tsx @@ -0,0 +1,66 @@ +import type { JSX, ReactNode } from 'react' +import { + setAgentDoneOn, + setAgentFailedOn, + setAgentIndicator, + setAgentQuestionOn, + useAgentDoneOn, + useAgentFailedOn, + useAgentIndicator, + useAgentQuestionOn, + type AgentIndicator +} from '../../lib/termLook' +import { Segmented, Switch } from '../fields' +import { SettingRow } from '../layout/SettingRow' +import { SettingsSection } from '../layout/SettingsSection' +import { opt, sectionTitle } from './opts' + +const INDICATOR_SUB: Record = { + off: 'No mark while an agent works.', + minimal: 'A line under the tab while it works.', + full: 'The whole tab fills while it works.' +} + +/** + * HOW A TAB MARKS ITS AGENT (2026-10-05, the grouped cards redesign): off, a + * line under the tab, or the whole tab filled while it works, and the three + * marks a tab keeps while you are not looking (2026-09-28; #131 for Failed). + * The indicator's style belongs to no theme: a theme pick never resets it. + * `after`: the host's own rows about the same marks (Prism Terminal: the + * taskbar count). + */ +export function AgentMarksSection({ after }: { after?: ReactNode } = {}): JSX.Element { + const volume = useAgentIndicator() + const doneOn = useAgentDoneOn() + const questionOn = useAgentQuestionOn() + const failedOn = useAgentFailedOn() + const ind = opt('agent-indicator') + const done = opt('agent-done-on') + const question = opt('agent-question-on') + const failed = opt('agent-failed-on') + return ( + + + setAgentIndicator(v as AgentIndicator)} + options={[ + { id: 'off', name: 'Off' }, + { id: 'minimal', name: 'Minimal' }, + { id: 'full', name: 'Full' } + ]} + /> + + + + + + + + + + + {after} + + ) +} diff --git a/core/renderer/settings/sections/ClaudeCodeSection.tsx b/core/renderer/settings/sections/ClaudeCodeSection.tsx new file mode 100644 index 0000000..841120b --- /dev/null +++ b/core/renderer/settings/sections/ClaudeCodeSection.tsx @@ -0,0 +1,20 @@ +import type { JSX } from 'react' +import { setAgentHooksOn, useAgentHooksOn } from '../../lib/termLook' +import { Switch } from '../fields' +import { SettingRow } from '../layout/SettingRow' +import { SettingsSection } from '../layout/SettingsSection' +import { opt, sectionTitle } from './opts' + +/** Claude Code tells the tab when it works, waits, finishes or fails (#131); + * off, a new shell is not handed the plugin that tells it. */ +export function ClaudeCodeSection(): JSX.Element { + const hooksOn = useAgentHooksOn() + const o = opt('agent-hooks') + return ( + + + + + + ) +} diff --git a/core/renderer/settings/sections/DictationPage.tsx b/core/renderer/settings/sections/DictationPage.tsx new file mode 100644 index 0000000..0f48b82 --- /dev/null +++ b/core/renderer/settings/sections/DictationPage.tsx @@ -0,0 +1,287 @@ +import { useState, type JSX, type ReactNode } from 'react' +import ModelDownloadAsk from '../../components/ModelDownloadAsk' +import { LANGUAGES, LIMITED_LANGUAGES_TEXT, visibleModels } from '../../../shared/dictationCatalog' +import type { CatalogEntry, DownloadFailure, ItemStatus } from '../../../shared/dictationTypes' +import { + setDictationEnabled, + setDictationLanguage, + setDictationMode, + setDictationModel, + setDictationPauseMedia, + setDictationSounds +} from '../../lib/dictationPrefs' +import { ROW_BUTTON, Segmented, Select, Switch } from '../fields' +import { VendorMark } from '../VendorMark' +import { HotkeyField, LimitedMark, MicField, Uninstall } from '../dictation/parts' +import { FAILURES, GPU_ID, size } from '../dictation/items' +import { useDictationState } from '../dictation/useDictationState' +import { ROW_SCOPE, SettingRow, Subtext } from '../layout/SettingRow' +import { SectionTag, SettingsSection } from '../layout/SettingsSection' +import { opt, sectionTitle } from './opts' + +/** + * DICTATION'S PAGE, on the grouped cards (2026-10-05); the same in both apps. + * Values are per app; the model files it manages are shared by both apps, + * which is why a model downloaded in one shows as installed in the other. + * + * NOTHING HERE STARTS ANYTHING BY ITSELF. Opening the page does not open the + * microphone (the meter is behind a Test button: a mic that lights up because + * you looked at a settings page is a privacy bug), and nothing downloads + * without a click. Off means off: every row under the switch is dimmed and + * disabled while dictation is off. + * + * Live state is said in the row's SUBTEXT, so a sighted user and a screen + * reader get the same words: no model yet, a refused microphone, a model that + * picks its own language, a failed download, a large model with no GPU. + */ + +const MODE_SUB = { hold: 'Runs while the key is held.', toggle: 'One press starts, the next stops.' } as const +const SLOW = 'Slow without GPU acceleration.' + +/** One downloadable thing: a model, or the GPU engine. The vendor's mark + * leads, then the full name, its size, the badges, and its note; what is in + * use is marked by a badge and a faint fill, so the list reads as a state. */ +function ModelRow({ + entry, + status, + progress, + failure, + badge, + recommended, + warn, + getLabel, + onDownload, + onCancel, + installed, + note +}: { + entry: CatalogEntry + status: ItemStatus | undefined + progress: number | undefined + failure: DownloadFailure | undefined + badge: string | null + recommended: boolean + warn: boolean + getLabel: string + onDownload: () => void + onCancel: () => void + installed: ReactNode + /** The subtext in place of the catalogue's note (the GPU fall-back). */ + note?: string +}): JSX.Element { + const state = status?.state ?? 'absent' + const received = progress ?? status?.received ?? 0 + const pct = Math.min(100, Math.round((received / entry.bytes) * 100)) + const failed = failure ? FAILURES[failure] : '' + const sub = failed || (warn ? SLOW : (note ?? entry.note)) + return ( +
+ +
+
+ + {entry.label} + + {size(entry.bytes)} + {recommended && ( + + Recommended + + )} + {badge && ( + + {badge} + + )} +
+ +
+
+ {state === 'downloading' ? ( + <> + + + + + {pct}% + + + + ) : state === 'installed' ? ( + installed + ) : ( + + )} +
+
+ ) +} + +export function DictationPage(): JSX.Element | null { + const d = useDictationState() + const [micProblem, setMicProblem] = useState(null) + if (!d.host || !d.api) return null + const off = !d.enabled + const use = opt('dictation-enabled') + const mode = opt('dictation-mode') + const key = opt('dictation-hotkey') + const mic = opt('dictation-mic') + const lang = opt('dictation-language') + const pause = opt('dictation-pause-media') + const sounds = opt('dictation-sounds') + const noModelYet = d.enabled && d.noModel + return ( +
+ + + + + + + + + + + + + + + + + + {d.ownLanguage && } + { + setChoice(v) + saveShellId(v) + }} + options={shells} + /> + + + ) +} diff --git a/core/renderer/settings/sections/TerminalTextSection.tsx b/core/renderer/settings/sections/TerminalTextSection.tsx new file mode 100644 index 0000000..bf77d0f --- /dev/null +++ b/core/renderer/settings/sections/TerminalTextSection.tsx @@ -0,0 +1,36 @@ +import type { JSX } from 'react' +import { FONT_PCTS, TERM_FONTS, setTermFontId, setTermFontPct, useTermFontId, useTermFontPct } from '../../lib/termLook' +import { Select } from '../fields' +import { SettingRow } from '../layout/SettingRow' +import { SettingsSection } from '../layout/SettingsSection' +import { opt, sectionTitle } from './opts' + +/** The terminal's typeface and its size. Neither belongs to a theme + * (2026-09-28): a theme switch and Save changes leave both alone, which is + * why they can live apart from the theme wall. */ +export function TerminalTextSection(): JSX.Element { + const fontPct = useTermFontPct() + const fontId = useTermFontId() + const family = opt('term-font-family') + const size = opt('term-font') + return ( + + + setTermFontPct(Number(v))} + options={FONT_PCTS.map((p) => ({ id: String(p), name: `${p}%` }))} + /> + + + ) +} diff --git a/core/renderer/settings/sections/TerminalThemeSection.tsx b/core/renderer/settings/sections/TerminalThemeSection.tsx new file mode 100644 index 0000000..e8d453e --- /dev/null +++ b/core/renderer/settings/sections/TerminalThemeSection.tsx @@ -0,0 +1,57 @@ +import type { JSX, ReactNode } from 'react' +import { setTermAcrylic, useTermAcrylic } from '../../lib/termLook' +import { SaveButton, Switch } from '../fields' +import { SettingBlock } from '../layout/SettingBlock' +import { SettingRow } from '../layout/SettingRow' +import { SettingsSection } from '../layout/SettingsSection' +import { ThemeWall } from '../theme/ThemeWall' +import { useNoAcrylic, useTermSetup } from '../theme/useTermSetup' +import { acrylicSub, opt, sectionTitle, themeSub } from './opts' + +/** What the Save changes button says it does, in both places it is drawn. */ +export const SAVE_SETUP_TITLE = 'Saves the theme, agent colours and acrylic as Custom' + +/** + * THE THEME, as one section (2026-10-05): the theme row with Save changes, the + * wall of cards, the host's own rows that a theme sets (Prism Terminal: + * Background and Accent), and acrylic. Under the wall only what a theme DOES + * set (2026-09-28); the font and the indicator live in their own sections. + */ +export function TerminalThemeSection({ + afterTheme, + onThemePicked +}: { + /** The host's own rows that a theme sets, right under the wall. */ + afterTheme?: ReactNode + /** A card was picked (Custom included), after the pick landed. */ + onThemePicked?: () => void +} = {}): JSX.Element { + const acrylicOn = useTermAcrylic() + const { dirty, save } = useTermSetup() + const noAcrylic = useNoAcrylic() + const theme = opt('term-theme') + const acrylic = opt('term-acrylic') + return ( + + + + + + + + {afterTheme} + {/* The material does not exist before Windows 11, so there the row says + why instead of offering a switch that would do nothing. */} + + + + + ) +} diff --git a/core/renderer/settings/sections/opts.ts b/core/renderer/settings/sections/opts.ts new file mode 100644 index 0000000..8416594 --- /dev/null +++ b/core/renderer/settings/sections/opts.ts @@ -0,0 +1,36 @@ +import { followsHostStyle, hostOwnsWindowAcrylic } from '../../host' +import { DICTATION_OPTIONS, type DictationOption } from '../dictationOptions' +import { HELP_OPTIONS, type HelpOption } from '../helpOptions' +import { TERMINAL_OPTIONS, type TerminalOption } from '../options' +import { SETTINGS_SECTIONS, type SettingsSectionId } from '../sectionIds' + +// The lists, by id, for the sections that draw them: a row's label, icon and +// resting subtext are read from its list entry, so the page and Find a setting +// can never word a row two ways. + +type AnyOption = TerminalOption | DictationOption | HelpOption + +const byId = new Map( + [...TERMINAL_OPTIONS, ...DICTATION_OPTIONS, ...HELP_OPTIONS].map((o) => [o.id, o]) +) + +/** A core option by id. Throws on a typo, which the unit suite then finds. */ +export function opt(id: string): AnyOption { + const o = byId.get(id) + if (!o) throw new Error(`no settings option ${id}`) + return o +} + +/** The heading a core section wears. */ +export const sectionTitle = (id: SettingsSectionId): string => SETTINGS_SECTIONS[id] + +/** The theme row's subtext: where the host has styles of its own the window + * wears THOSE, and only a host with none (Prism Terminal) dresses its window + * in the terminal theme. */ +export const themeSub = (): string => + followsHostStyle() ? 'Colours of the terminal text and ground.' : opt('term-theme').sub + +/** The acrylic row's subtext, by what acrylic means in this host: the + * window's own material, or the app style's let through the terminal. */ +export const acrylicSub = (): string => + hostOwnsWindowAcrylic() ? opt('term-acrylic').sub : 'The desktop shows through the terminal.' diff --git a/core/renderer/settings/theme/ThemeWall.tsx b/core/renderer/settings/theme/ThemeWall.tsx new file mode 100644 index 0000000..3a0507e --- /dev/null +++ b/core/renderer/settings/theme/ThemeWall.tsx @@ -0,0 +1,465 @@ +import { useEffect, useMemo, useRef, useState, type JSX, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import { followsHostStyle, hostDefaults, hostOwnsWindowAcrylic } from '../../host' +import { + applyCustomExtras, + customTermTheme, + saveCustomTermTheme, + setTermThemeId, + termThemeId, + useCustomTermTheme, + useTermAcrylic, + useTermThemeId, + withGroundAlpha, + type CustomTermTheme +} from '../../lib/termLook' +import { resolveCustomTheme, resolveTermTheme, watchTermTheme, TERM_PRESETS } from '../../lib/termTheme' +import { luminance, normalizeColor } from '../../lib/termAnsi' +import type { AlphaRange } from '../../lib/colour' +import { ColourField } from '../ColourPicker' +import ThemeSwitchAsk from '../../components/ThemeSwitchAsk' +import { ANSI_KEYS, cardAnsi, paletteOf, pickPreset, presetLook } from './palette' +import { useNoAcrylic, useTermSetup } from './useTermSetup' + +// THE THEME WALL, once for both layouts of the theme section (2026-10-05): +// the cards, Show all, the switch question and the colour editor. Extracted +// from TerminalAppearance.tsx unchanged; the legacy section and the grouped +// cards one both draw this. + +/** A theme as a miniature terminal: prompt, coloured ls output, cursor. The + * point is the SYNTAX colours - a swatch row says nothing about how a real + * session will read. Every card paints itself with its OWN palette, never the + * chrome's tokens: the chrome is whichever theme is selected, and a wall of + * cards wearing that would be one theme drawn forty times. */ +export function TermThemeCard({ + id, + name, + on, + bg, + fg, + cursor, + ansi, + onPick, + onEdit +}: { + id: string + name: string + on: boolean + bg: string + fg: string + cursor: string + ansi: { green: string; yellow: string; blue: string; cyan: string; red: string } + onPick: () => void + /** Rendered as a pencil on the SELECTED card only. */ + onEdit?: () => void +}): JSX.Element { + // The pencil is a real button BESIDE the card's, laid over its label row + // (code review 2026-09-24, #28): nested inside the card's button it was + // flattened into the card's name, and could not be reached as a control. + return ( +
+ + {/* Only the SELECTED theme wears the pencil: editing starts from what + you are using, and saving lands in the Custom slot. */} + {on && onEdit && ( + + )} +
+ ) +} + +/** The Tabby-style editor: every colour of a theme, individually, with the + * live preview beside them. Save lands in the single Custom slot. */ +function TermThemeEditor({ + seed, + bgAlpha, + onSave, + onCancel +}: { + seed: CustomTermTheme + /** What the Background's alpha may be: the window's see-through where the + * terminal owns the window acrylic, nothing where the style does (#114). */ + bgAlpha: AlphaRange & { alphaDisabled?: boolean } + onSave: (t: CustomTermTheme) => void + onCancel: () => void +}): JSX.Element { + const [draft, setDraft] = useState(seed) + // A MODAL THAT HOLDS THE KEYBOARD (code review 2026-09-24, #28). Opened from + // the pencil by keyboard, the focus stayed behind the backdrop: Escape did + // nothing (it was heard only inside) and Tab walked the cards under it, where + // Enter picked a theme beneath the open editor. So the first field takes the + // focus, Escape is heard from the window as ThemeSwitchAsk hears it, Tab + // stays inside, and the focus goes back to where it came from on close. + const panel = useRef(null) + const cancel = useRef(onCancel) + useEffect(() => { + cancel.current = onCancel + }) + useEffect(() => { + const back = document.activeElement as HTMLElement | null + panel.current?.querySelector('input, button')?.focus() + const onKey = (e: KeyboardEvent): void => { + if (e.key !== 'Escape') return + // An Escape aimed at a colour picker inside the editor is the picker's + // (#112): this listener is native and on the window, so it hears the key + // before the picker does, and would close the editor behind it. + if ((e.target as Element | null)?.closest?.('[data-colour-popover]')) return + e.preventDefault() + e.stopPropagation() + cancel.current() + } + window.addEventListener('keydown', onKey, true) + return () => { + window.removeEventListener('keydown', onKey, true) + back?.focus?.() + } + }, []) + const trapTab = (e: ReactKeyboardEvent): void => { + if (e.key !== 'Tab' || e.ctrlKey || e.altKey || e.metaKey) return + if ((e.target as Element | null)?.closest?.('[data-colour-popover]')) return + const all = [...(panel.current?.querySelectorAll('input, button, [tabindex="0"]') ?? [])].filter( + (el) => !el.hasAttribute('disabled') + ) + if (!all.length) return + const i = all.indexOf(document.activeElement as HTMLElement) + const next = e.shiftKey ? (i <= 0 ? all.length - 1 : i - 1) : i === -1 || i === all.length - 1 ? 0 : i + 1 + e.preventDefault() + all[next].focus() + } + const set = (k: string, v: string | undefined): void => + setDraft((d) => + k === 'bg' || k === 'fg' || k === 'cursor' || k === 'selection' + ? { ...d, [k]: v } + : { ...d, ansi: { ...d.ansi, [k]: v ?? '#888888' } } + ) + // EVERY COLOUR CARRIES AN ALPHA (#112). The Background's IS the window's + // see-through where the terminal owns the window acrylic (#114, in place of + // the Opacity slider), at least 30% as the slider was, and inert while + // acrylic is off; where the style owns the glass (Prism) it has none. + // Escape in a picker puts the draft back (no onRevert: the well's opening + // value is written back). + const well = ( + label: string, + key: string, + value: string, + more: AlphaRange & { alphaDisabled?: boolean; onRevert?: () => void } = {} + ): JSX.Element => ( +
+ {label} + set(key, v)} {...more} /> +
+ ) + // The selection shows what is drawn until one is chosen: the cursor AS + // DRAWN (composited and floored, which a see-through cursor is) at 55, the + // terminal's own derivation, so a nudge starts from what was on screen. + // Its alpha stops at 254/255, since xterm draws an OPAQUE selection at 0.3 + // (ThemeService, issue 2737) and what is picked must be what is drawn. + // Escape on a selection never chosen leaves it unchosen. The preview card + // draws the same resolution: raw alphas would show a 30% foreground the + // terminal floors to 4.5:1. + const chosenSelection = draft.selection + const drawn = useMemo(() => resolveCustomTheme(draft), [draft]) + return ( + // A popup, not an inline section: below the card grid the editor sat out + // of view. data-owns-escape keeps App's window Escape away; the backdrop + // and Escape both cancel. +
{ + if (e.target === e.currentTarget) onCancel() + }} + onKeyDown={trapTab} + role="dialog" + aria-modal="true" + aria-label="Edit terminal colours" + > +
+
Edit colours
+
+
+ {well('Background', 'bg', draft.bg, bgAlpha)} + {well('Foreground', 'fg', draft.fg)} + {well('Cursor', 'cursor', draft.cursor)} + {well('Selection', 'selection', chosenSelection ?? drawn.selectionBackground, { + alphaMax: 254 / 255, + onRevert: () => set('selection', chosenSelection) + })} + {ANSI_KEYS.map((k) => well(k, k, draft.ansi[k] ?? '#888888'))} +
+ )} + onPick={() => {}} + /> +
+
+ + +
+
+
+ ) +} + +/** Collapsed, the wall shows two rows of cards: every theme as one wall + * buried what came after it. */ +const TWO_ROWS = 268 + +/** + * The wall itself: Custom, the host's default, Follow style where the host + * has styles, then every preset by brightness; the pencil on the chosen card; + * Show all; the question when a pick would drop unsaved changes; the editor. + * `onThemePicked`: a pick landed (Custom included), for a host whose own rows + * follow the theme (Prism Terminal forgets its picked background and accent). + */ +export function ThemeWall({ onThemePicked, className = '' }: { onThemePicked?: () => void; className?: string }): JSX.Element { + const themeId = useTermThemeId() + const acrylicOn = useTermAcrylic() + const custom = useCustomTermTheme() + const { dirty, save, extras } = useTermSetup() + const noAcrylic = useNoAcrylic() + const windowAcrylic = hostOwnsWindowAcrylic() + // The Custom card draws what the terminal draws (#113 review), not the raw + // alphas; resolved once per saved Custom, which the store already caches. + const customDrawn = useMemo(() => (custom ? resolveCustomTheme(custom) : null), [custom]) + // What the HOST's style looks like right now, for its card; re-read when the + // style repaints :root. Only a host WITH styles has one (Prism). + const [styleTheme, setStyleTheme] = useState(() => resolveTermTheme(followsHostStyle() ? 'style' : termThemeId())) + useEffect(() => (followsHostStyle() ? watchTermTheme(setStyleTheme) : undefined), []) + // Presets ordered by brightness, the themes nearest your own look leading: + // on a light theme the wall runs light to dark, on a dark one dark to light. + // The direction is MEASURED off the theme worn when the page opened, and + // read once: the chrome follows the theme now, so re-sorting on every pick + // would shuffle the wall under the pointer that just clicked a card. + const [lightFirst] = useState( + () => luminance(normalizeColor(resolveTermTheme(termThemeId()).background, '#000000')) > 0.4 + ) + // CUSTOM LEADS THE WALL, then THE HOST'S OWN DEFAULT (owner, 2026-09-22: "it + // should be first in the list"; then 2026-09-23: "custom should come before + // default"). Prism Terminal's default is a preset (PT Default); Prism's is + // 'style', which is no preset, so there Custom leads Follow style. + const defaultPreset = TERM_PRESETS.find((p) => p.id === hostDefaults().theme) + const sortedPresets = useMemo(() => { + const lum = (bg: string): number => luminance(normalizeColor(bg, '#000000')) + return TERM_PRESETS.filter((p) => p !== defaultPreset).sort((a, b) => + lightFirst ? lum(b.bg) - lum(a.bg) : lum(a.bg) - lum(b.bg) + ) + }, [lightFirst, defaultPreset]) + // The editor popup, seeded from the SELECTED theme. Presets never change - + // editing always lands in the Custom slot. + const [editing, setEditing] = useState(null) + // A THEME PICK, Custom included. It lands at once when nothing is unsaved; + // with Save changes lit it asks first (ThemeSwitchAsk), since landing puts + // the agent colours and acrylic back to the theme's own. Once + // it lands the host hears of it (`onThemePicked`), for its own rows that + // follow the theme. + const [asking, setAsking] = useState(null) + const land = (id: string): void => { + const saved = customTermTheme() + if (id === 'custom' && saved) { + setTermThemeId('custom') + // The saved setup is more than the palette: font, agent colours, + // acrylic come back with it when the save captured them. + applyCustomExtras(saved) + } else pickPreset(id) + onThemePicked?.() + } + const pick = (id: string): void => { + if (dirty) setAsking(id) + else land(id) + } + const presetCard = (p: (typeof TERM_PRESETS)[number]): JSX.Element => { + const t = presetLook(p.id) + return ( + pick(p.id)} + onEdit={() => setEditing(withGroundAlpha(paletteOf(p.id)))} + /> + ) + } + // Measured at click time, so the expand can ANIMATE: max-height can't + // tween to 'none', only to a number, and the content's real height is the + // honest one. + const [wallHeight, setWallHeight] = useState(TWO_ROWS) + const allThemes = wallHeight !== TWO_ROWS + const themeWall = useRef(null) + const toggleWall = (): void => setWallHeight(allThemes ? TWO_ROWS : (themeWall.current?.scrollHeight ?? 2400)) + return ( + <> +
+
+ {custom && ( + )} + onPick={() => pick('custom')} + onEdit={() => setEditing(withGroundAlpha(paletteOf('custom')))} + /> + )} + {defaultPreset && presetCard(defaultPreset)} + {followsHostStyle() && ( + )} + onPick={() => pick('style')} + onEdit={() => setEditing(paletteOf('style'))} + /> + )} + {sortedPresets.map(presetCard)} +
+ {asking !== null && ( + { + const to = asking + setAsking(null) + save() + land(to) + }} + onDiscard={() => { + const to = asking + setAsking(null) + land(to) + }} + onCancel={() => setAsking(null)} + /> + )} +
+
+ +
+ {editing && ( + { + // The Custom slot is the WHOLE setup (code review 2026-09-24, + // #8): saved as a bare palette, the agent colours and + // acrylic it held were gone for good. And it is a theme pick like + // a card's (#29), so the host forgets its own window colours and + // the edited background is the one that shows. + saveCustomTermTheme({ ...t, ...extras }) + setTermThemeId('custom') + setEditing(null) + onThemePicked?.() + }} + onCancel={() => setEditing(null)} + /> + )} + + ) +} diff --git a/core/renderer/settings/theme/palette.ts b/core/renderer/settings/theme/palette.ts new file mode 100644 index 0000000..23fe93a --- /dev/null +++ b/core/renderer/settings/theme/palette.ts @@ -0,0 +1,91 @@ +import { + agentIndicator, + customTermTheme, + resetTermExtras, + setAgentIndicator, + setTermThemeId, + termExtraDefaults, + type CustomTermTheme +} from '../../lib/termLook' +import { resolveTermTheme } from '../../lib/termTheme' +import { normalizeColor } from '../../lib/termAnsi' + +// What a theme card and the editor read off a theme, once for both layouts +// of the theme wall (the legacy section and the grouped cards one). + +export const ANSI_KEYS = [ + 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', + 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite' +] as const + +export type CardAnsi = { green: string; yellow: string; blue: string; cyan: string; red: string } + +/** The five colours a card's miniature session reads, with the fallbacks a + * custom palette missing one of them gets. */ +export const cardAnsi = (a: Record): CardAnsi => ({ + green: a.green ?? '#8cc265', + yellow: a.yellow ?? '#d1a54b', + blue: a.blue ?? '#4aa5f0', + cyan: a.cyan ?? '#42b3c2', + red: a.red ?? '#e05561' +}) + +/** + * A PRESET'S LOOK, worked out once (2026-09-22, owner: the Appearance page + * "takes a second to load"). A preset never changes, and resolving one runs + * the legibility floors over its sixteen colours, so the forty cards cost the + * same forty resolves on every change to any setting on the page. Custom and + * Follow style are live, and are never read through this. + */ +const presetLooks = new Map>() +export function presetLook(id: string): ReturnType { + let look = presetLooks.get(id) + if (!look) presetLooks.set(id, (look = resolveTermTheme(id))) + return look +} + +/** The selected theme's palette as the editor and the Custom slot hold it. + * Normalised: a theme may publish rgba() or #rrggbbaa, and a colour input + * handed either silently renders black. */ +export function paletteOf(id: string): Pick { + // THE EDITOR EDITS WHAT IS STORED (#112). A Custom is handed over raw, its + // alphas and chosen selection included: resolved, it would be the floored + // composites, and Save changes would turn every see-through colour into an + // opaque one. Presets and the host's style keep normalising (Prism's + // e2e holds the follow-style Background to six digits). + const raw = id === 'custom' ? customTermTheme() : null + if (raw) { + const out: Pick = { + bg: raw.bg, + fg: raw.fg, + cursor: raw.cursor, + ansi: { ...raw.ansi } + } + if (raw.selection) out.selection = raw.selection + return out + } + const t = resolveTermTheme(id) + const ansi: Record = {} + for (const k of ANSI_KEYS) { + const v = t[k] + if (typeof v === 'string') ansi[k] = v + } + return { + bg: normalizeColor(t.background, '#0b0b0f'), + fg: normalizeColor(t.foreground, '#e7e7ee'), + cursor: normalizeColor(t.cursor, '#5b5bd6'), + ansi + } +} + +/** Picking a theme returns the LOOK to its defaults: the theme is the whole + * setup. The indicator's volume is carried across, because it is a General + * setting here (how loudly a tab speaks, not what the terminal looks like) + * and a behaviour that reset itself on a theme click would be a setting that + * does not hold. */ +export function pickPreset(id: string): void { + const volume = agentIndicator() + setTermThemeId(id) + resetTermExtras() + if (volume !== termExtraDefaults().indicator) setAgentIndicator(volume) +} diff --git a/core/renderer/settings/theme/useTermSetup.ts b/core/renderer/settings/theme/useTermSetup.ts new file mode 100644 index 0000000..79b6816 --- /dev/null +++ b/core/renderer/settings/theme/useTermSetup.ts @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react' +import { hostOwnsWindowAcrylic, termHost } from '../../host' +import { + saveCustomTermTheme, + setTermThemeId, + termExtraDefaults, + termThemeId, + useAgentColorChoice, + useAgentDoneColorChoice, + useAgentQuestionColorChoice, + useCustomTermTheme, + useTermAcrylic, + useTermGroundAlpha, + useTermThemeId, + withGroundAlpha +} from '../../lib/termLook' +import { alphaOf } from '../../lib/colour' +import { paletteOf } from './palette' + +/** + * THE TERMINAL'S SETUP AND ITS SAVE, once (2026-10-05). The theme wall's Save + * changes and, since the grouped cards redesign, a second one over the agent + * colours (owner, Q3) both save the WHOLE setup as Custom and light together, + * because both read this. + * + * "Save changes": the theme's whole look - palette of the selected theme, + * agent colours, acrylic and how see-through the ground is - lands in the + * Custom slot, reselectable after any theme switch. The font, its size and the + * indicator's style belong to no theme and are not part of it (2026-09-28). + * The see-through rides on the palette's own `bg` (#114). + */ +export interface TermSetup { + /** The settings deviate from the selected theme's stock. */ + dirty: boolean + /** Save the whole setup as Custom, and select it. */ + save: () => void + /** What a save carries besides the palette (the editor's save adds it). */ + extras: { indicatorColor: string; doneColor: string; questionColor: string; acrylic: boolean } +} + +export function useTermSetup(): TermSetup { + const themeId = useTermThemeId() + const acrylicOn = useTermAcrylic() + // The window's see-through (#114): the alpha of the ground in force, the + // picked Background's first. A byte, so the comparison below is exact. + const groundByte = Math.round(useTermGroundAlpha() * 255) + // The CHOICES ('' = follow the theme) are what is saved and compared. + const agentCol = useAgentColorChoice() + const doneCol = useAgentDoneColorChoice() + const questionCol = useAgentQuestionColorChoice() + const custom = useCustomTermTheme() + const extras = { + indicatorColor: agentCol, + doneColor: doneCol, + questionColor: questionCol, + acrylic: acrylicOn + } + // Dirty = the SETTINGS deviate from the selected theme's stock: any theme + // arrives with the defaults, a Custom arrives with what it saved. Comparing + // whole palettes kept the button lit forever - the palette IS the selection. + // Built field by field in the same order as `extras`, since the comparison + // is by JSON and key order is part of that. + const src = themeId === 'custom' && custom ? custom : null + const baseline = { + indicatorColor: src?.indicatorColor ?? termExtraDefaults().indicatorColor, + doneColor: src?.doneColor ?? termExtraDefaults().doneColor, + questionColor: src?.questionColor ?? termExtraDefaults().questionColor, + acrylic: src?.acrylic ?? termExtraDefaults().acrylic + } + // THE UNSAVED-CHANGES QUESTION SURVIVES THE SLIDER (#114, #60). Opacity was + // one of the extras, so a changed one lit Save changes and a theme pick + // asked before forgetting it. Its place is taken by the ground's alpha in + // force against the theme's own (a preset is opaque, a Custom has its bg's): + // a see-through picked Background lights Save changes in the same way. + // Only where that alpha is the window's (Prism has no such alpha). + const ownByte = src ? Math.round(alphaOf(src.bg) * 255) : 255 + const dirty = + JSON.stringify(extras) !== JSON.stringify(baseline) || (hostOwnsWindowAcrylic() && groundByte !== ownByte) + const save = (): void => { + saveCustomTermTheme({ ...withGroundAlpha(paletteOf(termThemeId())), ...extras }) + setTermThemeId('custom') + } + return { dirty, save, extras } +} + +/** + * Whether the acrylic material is missing on this PC, where the terminal owns + * the window's (Prism Terminal). The material is Windows 11's; null while main + * has not answered reads as supported, so a row does not flash disabled on + * every open. + */ +export function useNoAcrylic(): boolean { + const acrylic = termHost().acrylic + const [ok, setOk] = useState(null) + useEffect(() => { + if (acrylic.kind !== 'window') return + let live = true + void acrylic.supported().then((v) => { + if (live) setOk(v) + }) + return () => { + live = false + } + }, [acrylic]) + return hostOwnsWindowAcrylic() && ok === false +} diff --git a/core/shared/dictationCatalog.ts b/core/shared/dictationCatalog.ts index e12dd83..cea179a 100644 --- a/core/shared/dictationCatalog.ts +++ b/core/shared/dictationCatalog.ts @@ -116,7 +116,9 @@ export const ENGINE = { * * The notes say who a model is FOR and leave "Recommended" and "needs the GPU * pack" to the badges the manager draws from `recommended` and `needsGpu`: a - * note repeating a badge is the same thing said twice on one row. + * note repeating a badge is the same thing said twice on one row. Each note is + * a row's subtext, so at most eight plain words (the grouped cards redesign, + * 2026-10-05, which also dropped "CUDA 12.4": a row says what it does). */ export const CATALOG: readonly CatalogEntry[] = [ { @@ -134,7 +136,7 @@ export const CATALOG: readonly CatalogEntry[] = [ { id: 'small', label: 'Whisper Small', - note: 'More accurate without a GPU, and about three times slower than Base.', + note: 'More accurate, about three times slower.', url: model('ggml-small.bin'), bytes: 487601967, sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b', @@ -150,7 +152,7 @@ export const CATALOG: readonly CatalogEntry[] = [ // detects which by itself, so the picker has nothing to tell it. id: 'parakeet-v3', label: 'Parakeet v3', - note: 'Fast and accurate on any PC, in fewer languages.', + note: 'Fast and accurate, in fewer languages.', url: parakeet('ggml-parakeet-tdt-0.6b-v3-q8_0.bin'), bytes: 668757119, sha256: '4d64e9e96c2792186d072fde0034df0ad670cf680a2f53069052ead827fd600e', @@ -161,7 +163,7 @@ export const CATALOG: readonly CatalogEntry[] = [ { id: 'large-v3-turbo', label: 'Whisper Large v3 Turbo', - note: 'Close to Large v3 at about half the download.', + note: 'Close to Large v3 at half the download.', url: model('ggml-large-v3-turbo.bin'), bytes: 1624555275, sha256: '1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69', @@ -184,7 +186,7 @@ export const CATALOG: readonly CatalogEntry[] = [ { id: 'tiny', label: 'Whisper Tiny', - note: 'For the automated tests, small enough to fetch once and run anywhere.', + note: 'For the automated tests only.', url: model('ggml-tiny.bin'), bytes: 77691713, sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21', @@ -196,7 +198,7 @@ export const CATALOG: readonly CatalogEntry[] = [ // Parakeet engine on a 356 MB fetch (cached once) rather than 669 MB. id: 'parakeet-v3-q4', label: 'Parakeet v3 (4 bit)', - note: 'For the automated tests, the smallest Parakeet v3 file.', + note: 'For the automated tests only.', url: parakeet('ggml-parakeet-tdt-0.6b-v3-q4_0.bin'), bytes: 355615679, sha256: 'aa7fe2f5fb47d863ca23e8b1d490632d63a2599f515268b6d6bd656158dad45e', @@ -208,7 +210,7 @@ export const CATALOG: readonly CatalogEntry[] = [ { id: 'gpu-pack', label: 'NVIDIA GPU acceleration', - note: 'The official CUDA 12.4 build, which runs the large models in under a second.', + note: 'Large models answer in under a second.', // ONE pack for every NVIDIA card (measured: it runs on an RTX 5090, a card // newer than CUDA 12.4, after a one-time 9.1 s kernel compile), which is // why the 273 MB CUDA 11.8 pack of the same release is not catalogued. diff --git a/core/shared/settingsCopy.test.ts b/core/shared/settingsCopy.test.ts index add4b03..77e5636 100644 --- a/core/shared/settingsCopy.test.ts +++ b/core/shared/settingsCopy.test.ts @@ -1,7 +1,7 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' import { describe, expect, it } from 'vitest' -import { copyProblem, settingsDescriptions } from './settingsCopy' +import { copyProblem, labelProblem, settingsDescriptions, settingsListCopy, subTooLong } from './settingsCopy' describe('copyProblem', () => { it('passes plain words with commas and full stops', () => { @@ -27,13 +27,37 @@ describe('settingsDescriptions', () => { }) }) +describe('the grouped cards rules (2026-10-05)', () => { + it('holds a subtext to eight words', () => { + expect(subTooLong('Lines between panels and around the window.')).toBe(false) + expect(subTooLong('Speech to text on this PC, never sent.')).toBe(false) + expect(subTooLong('The colour of the chosen page, buttons, progress bar and visualizer.')).toBe(true) + }) + it('checks a label for symbols only, never for key names', () => { + expect(labelProblem('Tab width')).toBeNull() + expect(labelProblem('Exact status from Claude Code')).toBeNull() + expect(labelProblem("The theme's colour")).not.toBeNull() + }) + it('reads the label and sub fields of a list, and nothing else', () => { + const src = `{ id: 'a', label: 'Tab width', sub: 'Sized to the name.', keywords: 'x y' }\n{ id: 'b', label: "Two", sub: \`Three.\` }` + expect(settingsListCopy(src)).toEqual({ labels: ['Tab width', 'Two'], subs: ['Sized to the name.', 'Three.'] }) + }) +}) + +/** Every file under a folder, subfolders included. */ +function walk(dir: string): string[] { + return readdirSync(dir).flatMap((f) => { + const p = join(dir, f) + return statSync(p).isDirectory() ? walk(p) : [p] + }) +} + describe("the core's own settings", () => { + const dir = join(__dirname, '..', 'renderer', 'settings') + const source = walk(dir).filter((f) => /\.tsx?$/.test(f) && !/\.test\.tsx?$/.test(f)) + it('describe every setting in plain words', () => { - const dir = join(__dirname, '..', 'renderer', 'settings') - const files = [ - ...readdirSync(dir).filter((f) => f.endsWith('.tsx')).map((f) => join(dir, f)), - join(__dirname, 'dictationCatalog.ts') - ] + const files = [...source, join(__dirname, 'dictationCatalog.ts')] const problems = files.flatMap((f) => settingsDescriptions(readFileSync(f, 'utf8')) .map((text) => ({ f, text, problem: copyProblem(text) })) @@ -41,4 +65,31 @@ describe("the core's own settings", () => { ) expect(problems).toEqual([]) }) + + // THE WORD LIMIT IS FOR THE NEW FILES ONLY: the lists, `layout/`, + // `sections/` and the catalogue's notes, never the legacy components kept + // for the transition, whose hints run longer. + it('keep every new label plain and every new subtext to eight words', () => { + const fresh = source.filter((f) => { + const r = relative(dir, f).replace(/\\/g, '/') + return /^(layout|sections)\//.test(r) || ['options.ts', 'dictationOptions.ts', 'helpOptions.ts', 'coreIndex.ts'].includes(r) + }) + expect(fresh.length).toBeGreaterThan(10) + const long: string[] = [] + const bad: string[] = [] + for (const f of fresh) { + const src = readFileSync(f, 'utf8') + const { labels, subs } = settingsListCopy(src) + for (const t of [...subs, ...settingsDescriptions(src)]) { + if (subTooLong(t)) long.push(t) + if (copyProblem(t)) bad.push(t) + } + for (const t of labels) if (labelProblem(t)) bad.push(t) + } + // The model notes are each a row's subtext on the new page. + for (const t of settingsDescriptions(readFileSync(join(__dirname, 'dictationCatalog.ts'), 'utf8'))) + if (subTooLong(t)) long.push(t) + expect(long).toEqual([]) + expect(bad).toEqual([]) + }) }) diff --git a/core/shared/settingsCopy.ts b/core/shared/settingsCopy.ts index 6a3a3e2..ec40515 100644 --- a/core/shared/settingsCopy.ts +++ b/core/shared/settingsCopy.ts @@ -64,3 +64,41 @@ function literals(expr: string): string[] { } return out } + +// THE GROUPED CARDS REDESIGN (2026-10-05) adds rules BESIDE the ones above, +// never inside them: Prism's unit suite calls `copyProblem` and +// `settingsDescriptions` over its current pages, whose hints run longer, and +// the core's legacy rows are as long. So both keep today's behaviour exactly, +// and the new files (the option lists, `layout/`, `sections/`, each app's +// `appOptions.ts`) are held to these as well. + +/** A subtext is ONE short line: at most this many words. */ +export const SUB_MAX_WORDS = 8 + +/** Whether a subtext runs past the limit. */ +export function subTooLong(text: string): boolean { + return text.trim().split(/\s+/).filter(Boolean).length > SUB_MAX_WORDS +} + +/** What is wrong with a row LABEL, or null: the symbol rule only. "Tab + * width" names a tab, not the Tab key, so a label is not checked for key + * names. */ +export function labelProblem(text: string): string | null { + if (ALLOWED.test(text)) return null + const bad = [...new Set([...text].filter((c) => !/[A-Za-z0-9 ,.]/.test(c)))].join(' ') + return `uses symbols other than comma and full stop: ${bad}` +} + +/** + * The `label:` and `sub:` string fields of an option list or an app's + * `appOptions.ts`. Separate from `settingsDescriptions`, which Prism points at + * files full of `label:` fields that are not setting labels. + */ +export function settingsListCopy(source: string): { labels: string[]; subs: string[] } { + const LABEL = /\blabel:\s*(['"`])((?:\\.|(?!\1).)*)\1/g + const SUB = /\bsub:\s*(['"`])((?:\\.|(?!\1).)*)\1/g + return { + labels: [...source.matchAll(LABEL)].map((m) => m[2]), + subs: [...source.matchAll(SUB)].map((m) => m[2]) + } +} diff --git a/docs/superpowers/specs/2026-10-05-settings-redesign-design.md b/docs/superpowers/specs/2026-10-05-settings-redesign-design.md new file mode 100644 index 0000000..8ab6a3c --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-settings-redesign-design.md @@ -0,0 +1,725 @@ +# Settings redesign, both apps: spec and plan (2026-10-05) + +One document for one approval: the design (part 1 and 2), the plan (part 3), and what is left +to decide (part 4). + +**Approved look.** Version 1, "Grouped cards": `v1-grouped-cards/settings.html`, `notes.md` and +`shots/` in this folder, with the owner's one change: **no accent bar on the chosen rail item**. +The chosen page is a grey fill, bold text and a brighter icon. Labels come from `renames.md` as +v1 uses them. + +**Read for this document (2026-10-05):** PrismTerminal `main` at `6266aa4` (app 0.30.0, core +`0.24.0`): `core/README.md`, `core/renderer/host.ts`, `core/renderer/settings/*`, +`core/shared/settingsCopy.ts`, `core/shared/dictationCatalog.ts`, +`src/renderer/src/components/Settings.tsx`, `tools/e2e/run.mjs`, both CLAUDE.md files. Prism +`origin/main` at `47ece4e` (0.87.0, pinned to `core-v0.24.0`; the local checkout is 7 commits +behind): `src/renderer/src/components/Settings.tsx`, `WinEShortcutSetting.tsx`, +`settingsCopy.test.ts`, `tools/e2e/run.mjs` (`termOptions`, `termColourPicker`, `dictationPage`, +`noCommandHelp`), its CLAUDE.md (#202 neutral controls, #272 no focus boxes, the terminal gate). +Plus this folder's `inventory.md`, `renames.md` and the v1 mockup's CSS and script. + +**What changed since the mockup was drawn.** The mockup read core `0.23.0`. Core `0.24.0` (#131) +added two terminal rows the mockup does not show: `agent-failed-on` ("Failed indicator") and +`agent-hooks` ("Exact status from Claude Code"). Both are placed below (Agents page). Nothing +else in either app's settings changed. + +--- + +## Part 1. Spec + +### 1.1 What stays the same + +- **Every behaviour.** Each control writes the store it writes today, with the same values. + Nothing is added, removed or merged as a setting. The theme switch question, Save changes as + Custom, the colour picker, the model manager, the Explorer verb read back from the registry, + the Win+E helper status: all unchanged. +- **Every storage key and every stored value.** No `localStorage` key, settings file field or + registry value changes name or form. Where a control changes TYPE, it maps onto the old values: + "Show title bar" is a switch that writes `shown` / `hidden` to `prism.window.titleBar` (PT) and + Prism's own title bar store, exactly as the segmented control did. A snapshot test per app + freezes the list of keys (section 3, tasks 1.4 and 2.2). +- **Every row id.** `data-pref=""` stays on every row that has one today, and the core's + lists (`TERMINAL_OPTIONS`, `DICTATION_OPTIONS`, `HELP_OPTIONS`) keep their ids. Prism's own rows, + which carry no `data-pref` today, get one (ids in 1.4.2; DOM only, not storage). +- **The DOM contracts both apps' e2e read:** `[data-settings-page]`, `[data-term-card]`, + `[data-save-term]`, `[data-theme-wall-toggle]`, `[data-theme-switch-ask]`, `[data-theme-editor]`, + `[data-follow-theme]`, the colour picker's contract (`core/README.md`), `[data-dictation-settings]`, + `[data-dictation-item]`, `[data-item-name]`, `[data-item-badge]`, `[data-gpu-toggle]`, + `[data-vendor]`, `[data-language-limited]`, `[data-seg]`, `[data-app-version]`, and also + `[data-settings-tab=]` on every rail button (PT's e2e uses it about 40 times), + `[data-use-home]`, `[data-choose-folder]`, `[data-edit-theme]`, `[data-save-custom]`, + `[data-hotkey-capture]`, `[data-mic-meter]`, `[data-uninstall]`, `[data-state]`, `[data-active]`, + `[data-owns-escape]`. New hooks: `[data-settings-section=]` on every section and + `[data-term-wall]` on the theme wall's block (neither exists today). +- **Neutral controls; only Save is accented** (PT #42, Prism #202). Still accented, since they are + not buttons: Reset links, the chosen theme or style card, a dropdown's chosen item, a chosen + swatch's ring, the Active, Enabled and Recommended badges, download progress, the hotkey capture while it + listens, Prism's Win+E switch (its existing exception). +- **What changes about the accent:** the rail's chosen page. It was `--p-sel-bg` (PT) and + `--p-sel-solid` (Prism, owner 2026-10-03 "more saturated"); the approved v1 replaces both with a + grey fill. Both CLAUDE.md files are updated where they say the rail is accented. +- **Off means off** for dictation (rows dimmed and disabled while it is off) and command help (no + button, no key, no row in Prism). +- **Settings remembers its page as today**, with the new page ids: in PT, App holds it while the + Settings tab stays open (#123; in memory, nothing saved, so no old `'general'` value needs + mapping); in Prism, the always-mounted `Settings` overlay holds it in its own state across + open and close. + +### 1.2 The look + +All measurements are the v1 mockup's, translated onto the `--p-*` tokens both apps already define. +**No new CSS variable** is introduced (the core's README: a new token must be added to both apps). +Derived colours are `color-mix()` inside the core's classes. + +**Frame.** +- Rail 244px wide, padding 18px 12px 12px, the chrome's own ground (`--p-side`), a 1px + `--p-divider` rule on its right. +- Rail title "Settings": Segoe UI Variable Display, 15px, bold, letter-spacing -0.01em, 12px + under it. +- Pane: the ground (`--p-bg`), scrolls with the hidden scrollbar of `.p-scroll`. Inner column + max-width 960px, padding 26px 44px 64px. +- Page header: h2, Segoe UI Variable Display 24px bold, letter-spacing -0.02em, line-height 1.15. + An optional control at its right end (Prism's Media switch). +- The settings font stays the system stack whatever the theme or style says (both apps' existing + rule). + +**Rail items.** Height 36px, padding 0 10px, radius 6px, gap 11px, 13px weight 500, text +`--p-text-soft`, icon 17px in `--p-dim`. Hover: `--p-hover` fill, `--p-text`. **Chosen:** +`--p-hover-hi` fill, weight 600, text and icon `--p-text`, `aria-current="page"`. No bar, no +accent. About sits at the bottom, after a flexible spacer. PT's old "Prism Terminal" footer word +goes (About says it). + +**Find a setting.** At the top of the rail, under the title, 14px above the first item. Height +32px, radius 6px, the section panel's fill (below; there is no `--p-panel` token) and a `--p-line` edge, a 14px magnifier at 10px from the left, +placeholder "Find a setting" in `--p-dim`, a clear button (20px, an X) at the right while there is +text. Focus: the edge goes a step lighter (`color-mix(--p-text 22%)`), the field rule of Prism +#272, never a ring. + +**Section.** A heading, then one panel. +- Heading: h3, 13px weight 600, `--p-text-soft`, sentence case (no uppercase eyebrows), 8px above + the panel, an optional action at its right (a tag, or Save changes). +- Sections are 26px apart; the first sits 18px under the page header. +- Panel: fill `color-mix(in srgb, var(--p-text) 3.6%, transparent)` over the ground, so it is one + thin coat that works on glass and never a solid slab; edge 1px `--p-line`; radius + `max(4px, calc(var(--p-radius) + 3px))`, so Prism's Corner roundness rounds the panels (Onyx 2px + gives 5px, Ruby 14px gives 17px) and PT, whose `--p-radius` is 2px, gets 5px. +- Every edge reads `--p-line`, so PT's Edges setting (None, Faint, Hairline, Solid) reaches the + panels too, as its rule for every edge in the window requires. + +**Row.** A grid: 32px icon column, the text, the control. +- `grid-template-columns: 32px minmax(0,1fr) auto`, column gap 14px, min-height 58px, padding + 10px 16px 10px 14px. +- Between two rows, a 1px `--p-line` hairline that starts after the icon column (60px in) and + runs to the right edge; a full-width block (a wall, a swatch grid, a model list) is ruled the + full width. +- Icon tile 32 x 32, radius `max(4px, calc(var(--p-radius) - 1px))`, fill + `color-mix(var(--p-text) 6%, transparent)`, a 16px stroke icon (stroke 1.7) in `--p-text-soft`. +- Label: 13px weight 600 `--p-text`, line-height 1.3. +- Subtext: 11.5px `--p-dim`, 2px under the label, ONE line, truncated with an ellipsis, its full + text on the title attribute. A warning subtext is amber + (`color-mix(#e0a84b 85%, var(--p-text))`) and leads with an 11px warning glyph. +- Control slot: right aligned, items 10px apart. +- `off` (a row that cannot be used now): icon, text and control at 45% and not clickable, + `aria-disabled`. +- A row whose control is a switch is clickable as a whole (hover + `color-mix(var(--p-text) 2.5%, transparent)`); the switch stays the one focusable control. + +**Block.** A full-width part of a panel for what is not a row: the theme and style walls +(padding 14px 16px 16px), swatch grids, the model and GPU lists, the About card. + +**Controls.** The core's existing ones, unchanged in size and colour: `Switch`, `Segmented`, +`Select`, `ROW_BUTTON`, `SaveButton`, `ColourField`, `RESET_LINK`. Two changes from the mockup, +both on the new rows only (task 1.2): the dropdown's menu blurs what is behind it and casts no +shadow (owner's popup rule, 2026-09-22), and focus is shown as in Prism #272 (Q1). + +**Search results.** While the field holds text, the pane shows a page titled "Results": one panel +of result rows. Each has the row's icon, label and subtext, and at its right, in `--p-dim`, where +it lives ("Terminal, Text"). Words must all match (prefix match, case and accent folded) against +the label, subtext, section, page and the row's hidden keywords; label matches rank first. No +match: "Nothing matches . Try a shorter word." in the panel. The rail shows no chosen page +while results are up. Choosing a result clears the field, opens the page (and Prism's Media view), +scrolls the row to the centre, flashes it (1.4s fade of a 16% `--p-accent-hi` fill; the flash is a +mark, not a button), and puts the keyboard on the row's first control. + +**Motion.** The wall's expand (240ms) stays; the flash; nothing else moves. Under +`prefers-reduced-motion` neither runs. + +### 1.3 Accessibility + +- The rail is `