From dff0ecb0ca92f49953496971063372636fce1b2f Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:45:33 +0200 Subject: [PATCH 1/5] feat(settings): grouped cards, short subtexts and Find a setting (#292) Rebuild Settings on prism-term-core's grouped cards frame (core-v0.25.0): Appearance, Explorer, Terminal, Agents, Dictation, Media, About, with Find a setting. Prism's own rows are a closed list (appOptions.ts) with one short subtext each and unchanged storage keys; the core's sections draw the terminal, agent and dictation rows. The chosen rail page is a grey fill. New settingsLook (in e2e:terminal) and settingsSearch e2e. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 75 +- electron.vite.config.ts | 19 +- package-lock.json | 4 +- package.json | 4 +- src/renderer/src/App.tsx | 6 +- src/renderer/src/components/Settings.tsx | 1769 ----------------- .../src/components/noFocusRings.test.ts | 2 +- .../src/components/settings/AboutPage.tsx | 58 + .../src/components/settings/AgentsPage.tsx | 17 + .../components/settings/AppearancePage.tsx | 146 ++ .../src/components/settings/ColourSchemes.tsx | 133 ++ .../src/components/settings/ExplorerPage.tsx | 205 ++ .../src/components/settings/MediaPage.tsx | 188 ++ .../src/components/settings/Settings.tsx | 167 ++ .../src/components/settings/StyleColours.tsx | 171 ++ .../src/components/settings/StyleWall.tsx | 60 + .../src/components/settings/TerminalPage.tsx | 24 + .../src/components/settings/TransportMini.tsx | 142 ++ .../WinEShortcutRow.tsx} | 66 +- .../components/settings/appOptions.test.ts | 122 ++ .../src/components/settings/appOptions.ts | 99 + .../src/components/settings/cards.tsx | 64 + src/renderer/src/components/settings/icons.ts | 50 + .../settings/settingsControls.test.ts | 42 + .../components/settings/settingsCopy.test.ts | 36 + .../src/components/settings/settingsIndex.ts | 80 + .../src/components/settingsControls.test.ts | 32 - .../src/components/settingsCopy.test.ts | 15 - src/renderer/src/index.css | 30 +- src/renderer/src/lib/theme.selection.test.ts | 21 +- .../src/lib/theme.selectionColour.test.ts | 4 +- src/renderer/src/lib/theme.ts | 6 - tools/e2e/run.mjs | 636 ++++-- tsconfig.web.json | 5 +- vitest.config.ts | 14 +- 35 files changed, 2485 insertions(+), 2027 deletions(-) delete mode 100644 src/renderer/src/components/Settings.tsx create mode 100644 src/renderer/src/components/settings/AboutPage.tsx create mode 100644 src/renderer/src/components/settings/AgentsPage.tsx create mode 100644 src/renderer/src/components/settings/AppearancePage.tsx create mode 100644 src/renderer/src/components/settings/ColourSchemes.tsx create mode 100644 src/renderer/src/components/settings/ExplorerPage.tsx create mode 100644 src/renderer/src/components/settings/MediaPage.tsx create mode 100644 src/renderer/src/components/settings/Settings.tsx create mode 100644 src/renderer/src/components/settings/StyleColours.tsx create mode 100644 src/renderer/src/components/settings/StyleWall.tsx create mode 100644 src/renderer/src/components/settings/TerminalPage.tsx create mode 100644 src/renderer/src/components/settings/TransportMini.tsx rename src/renderer/src/components/{WinEShortcutSetting.tsx => settings/WinEShortcutRow.tsx} (59%) create mode 100644 src/renderer/src/components/settings/appOptions.test.ts create mode 100644 src/renderer/src/components/settings/appOptions.ts create mode 100644 src/renderer/src/components/settings/cards.tsx create mode 100644 src/renderer/src/components/settings/icons.ts create mode 100644 src/renderer/src/components/settings/settingsControls.test.ts create mode 100644 src/renderer/src/components/settings/settingsCopy.test.ts create mode 100644 src/renderer/src/components/settings/settingsIndex.ts delete mode 100644 src/renderer/src/components/settingsControls.test.ts delete mode 100644 src/renderer/src/components/settingsCopy.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c3397aec..1bc06be6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -529,11 +529,11 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: and a shut pane stays shut. A plain click and the arrows preview and play as before. The `marqueeQuiet` e2e holds it with four films. **EXPLORER SIZE IS A SETTING** (owner, 2026-10-03: "let the current be medium the old be - big, and make a slightly smaller version too"). Settings > Style > Explorer size + big, and make a slightly smaller version too"). Settings > Explorer > Explorer row size (`lib/explorerSize.ts`, `prism.explorer.size`, unknown reads Medium): Small 22px / 11.5px / 12px icon, Medium (DEFAULT) 26 / 12.5 / 14, Large the pre-#257 Explorer, 40 / 15 / 18 with its old padding and columns (`[data-row-size='large']` in browse.css). The Explorer's list - ALONE: the tree keeps General's Font size. `explorerSize` e2e. + ALONE: the tree keeps Interface text size. `explorerSize` e2e. **THE COLUMN HEADER IS FILE EXPLORER'S** (#274; owner, 2026-10-04: the hover "should be inside the whole box"; Size's name "aligned to the left"; the arrow "shows only when you hover over them while the currently sorted item has an arrow at all times"). The header keeps the rows' @@ -601,8 +601,8 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: where a name would fall under 4.5:1 or a quiet column under 3.2:1) with a faint `--p-sel-line` edge round the BLOCK (`lib/markedLook.ts`, `data-join-up/down`), and keep their own text and icon colours (no on-accent ink, no monochrome icon fallback). The - Settings rail and the chosen card use `--p-sel-solid` / `--p-accent-solid`. Neither moves - with the accent's alpha. Quick access's current place wears the tint too. `theme.selection.test.ts`, + chosen card uses `--p-accent-solid`, which does not move with the accent's alpha; the + Settings rail's chosen page is a GREY fill since #292 (`--p-sel-solid` went with it). Quick access's current place wears the tint too. `theme.selection.test.ts`, `markTint` e2e. The menu's grey row is unchanged (its icon's knockouts are its own grey). THE TINT IS NOT FLATTENED UNDER GLASS, on purpose: it carries no label of its own, only the row's ordinary text, which sits @@ -615,7 +615,7 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: six or eight hex, `cleanColour` on load, kept by a saved style). UNSET is `hi` at 22% byte for byte and the picker shows that; nothing is stored until a pick, and picking the shown value back is no edit. A pick past the floors (names 4.5:1, quiet columns 3.2:1) keeps its - hue and loses strength (`selectionTintAlpha`'s `start`). The Accent keeps the rail, buttons, + hue and loses strength (`selectionTintAlpha`'s `start`). The Accent keeps the buttons and progress. The edge is SOFTER: `TINT_LINE` 0.28 (was 0.5; "the border contrast is slightly too much"), scaled with a picked strength up to 0.5 (`tintLineAlpha`). The SWEEP BAND (the Explorer's and the archive's drag box) wears it too, as Windows' does: `--p-sel-hue` / @@ -653,10 +653,10 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: want settings buttons to be affected by the accent colour... grey based on the bg colour ... same colours as the drop down menus"; "the only ones to keep accented are the save buttons"). The Switch (on: `--p-text-soft` track, `--p-bg` knob), the pressed Segmented option and the row - buttons (Default apps) use `ROW_BUTTON` / `SEGMENT_ON` / `SWITCH_ON` in `Settings.tsx`, the same - classes as the terminal core's `fields.tsx` (PrismTerminal #42), so the terminal half of the page - matches after the core bump. Selection marks (style cards, swatches, the rail) and Reset links - are not buttons and keep the accent. `settingsControls.test.ts` holds it. + buttons (Default apps, Clear) are the terminal core's own `fields.tsx` since #292 (PrismTerminal + #42 holds them there), and no page keeps a copy. Selection marks (style cards, swatches) and + Reset links are not buttons and keep the accent, as does the Win+E switch; the chosen rail page + is grey since #292. `settings/settingsControls.test.ts` holds it. - **NOTHING WEARS A FOCUS BOX; FOCUS IS THE HOVER'S FILL** (#272; owner, 2026-10-04, of a white box round the title bar's menu button: "remove the focus effect. go through the ui and remove focus effects like this"). `index.css`'s base-layer rule takes Chromium's ring off every focused @@ -683,7 +683,8 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: - **TAB WIDTH IS A SETTING** (#216; owner, 2026-09-23, asked in Prism Terminal (#56 there) and agreed for Prism: "a setting for tab width, where the user can pick fixed size or dynamic"). Then: "put the option closer to the top of appearance, and call it dynamic ... have dynamic be - the default". So it is the FIRST row of Settings > Style (Prism's appearance page), above Mode + the default". It was the FIRST row of Settings > Style; since the grouped cards (#292, the + owner's approved v1) it is in Appearance > Window, after Show title bar (`lib/tabWidthPrefs.ts`, `prism.window.tabWidth`, the same key and words as Prism Terminal's): **Dynamic** (the DEFAULT, by the owner's word, so the strip fixed since 2026-09-21 goes back to dynamic with this update: each tab as wide as its name, capped at 14rem) or **Fixed** (every tab @@ -1513,7 +1514,7 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: (`ViewerFile.size`/`mtimeMs` absent means unknown, never 0); the restore sends Explorer tabs FIRST with their cached or names-only listing and the rest in parallel, back in saved order (`restoreOrder`); the listing cache on disk (`listingCache.ts`, local fixed drives only, - Settings > General > Remember folders, on by default, Clear) is what the first frame after a + Settings > Explorer > Remember recent folders, on by default, Clear) is what the first frame after a reboot paints; the renderer's snapshots are app-wide, so a tab switch is a hit; a navigation keeps the old rows and shows only a 2 px bar past 300 ms (`usePendingHint`); no "Loading" text anywhere in the Explorer and none in the boot shell, which is a silent outline. Read @@ -1591,7 +1592,7 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: when nothing is playing; the visualizer's frame loop stops once a paused picture has settled; and a cross-volume move copies with `fs/promises` rather than `cpSync`, which blocked every window for as long as it took. -- **The band behind the transport** is a slider (2026-08-25, Settings > Player): +- **The band behind the transport** is a slider (2026-08-25, Settings > Media > Progress bar, Control band opacity): 0-100%, opaque by default, which is the bar exactly as it always looked. Below 55% the controls carry their own drop shadow, because at that point they are sitting on the film rather than on a band. The edge, outline and island styles @@ -1756,7 +1757,7 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: Explorer forward, then opens the file with the Explorer's own `openFile` - the call a Quick access pin makes, which grants the folder to the tab and walks there, so Back returns to where the Explorer was. Two steps because `openFile` acts on the tab in - FRONT. Settings > General > "Files from Windows open in" (`prism.open.external`): + FRONT. Settings > Explorer > "View for files from Windows" (`prism.open.external`): PREVIEW (default) is the list with the preview pane FORCED ON showing the file; FULL VIEW is the file filling the Explorer. A project tab already open on that folder is NOT used (one rule), the pinned Explorer moves rather than a tab per file, and several files @@ -1869,7 +1870,7 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: the setting's one queue so an explicit off cannot race it. It compares against the CURRENT label rather than a list of old ones, so the next rewording needs nothing new.) ON by default since - 2026-08-31 (owner decision), switched in Settings > General. THE ONE FACT STORED IS THE NO + 2026-08-31 (owner decision), switched in Settings > Explorer > Add to the Explorer menu. THE ONE FACT STORED IS THE NO (`shell-verb-off` in userData, since 2026-09-03, #93; corrected here 2026-09-20, this passage still described the first design): a default that reapplied itself over a NO would be a setting that lies - turn the verb off and it would be back tomorrow. The @@ -2408,11 +2409,12 @@ Filesmith's conventions. preload, `registerTermIpc` in main, which takes Prism's WALL as three small answers), the indicator's rules (`useAgentIndicator`, which was lifted out of this App), its colours (`useAgentColors`), the close rule (`agentClose`) and **the terminal's SETTINGS** all come from - the core. Settings > Terminal is the core's `ShellSetting`, `AgentIndicatorSetting` and - `TerminalAppearanceSettings` and nothing else: "the setting names, types, how they function" + the core. Settings > Terminal and Settings > Agents are the core's sections (`ShellSection`, + `TerminalTextSection`, `TerminalThemeSection`; `AgentMarksSection`, `ClaudeCodeSection`, + `MarkColoursSection`) and nothing else (#292): "the setting names, types, how they function" are shared, the VALUES are this app's own. A terminal row written here instead of there is a - fork, and the e2e `termOptions` goes red on it (it compares the page with the core's - `settings/options.ts`, as Prism Terminal's `options` does). + fork, and the e2e `termOptions` goes red on it (it compares the two pages with the core's + `settings/options.ts`, order checked per section, as Prism Terminal's `options` does). **BUMPING THE PIN HAS ITS OWN GATE: `npm run e2e:terminal`** (owner, 2026-09-19: "we need to run some automated tests that confirm that the terminal in Prism still works, since it has more failure points due to its larger footprint"). It builds and runs every scenario the terminal @@ -2451,7 +2453,7 @@ Filesmith's conventions. - **A media viewer first.** App arms the core with the shell that is SHOWING, or with null, so Right Alt over a film, a PDF or the tree does nothing (`useDictationArm` in App.tsx; the e2e holds the key with no terminal up and asserts no pill and no process). - - **Settings > Dictation**, its own page under Behaviour, is the core's `DictationSettings` whole. + - **Settings > Dictation**, its own page, is the core's `DictationPage` whole. Values are this app's; the model files are shared with Prism Terminal in `%LOCALAPPDATA%\PrismDictation`, so a model downloaded there is installed here. - **The engine ships in the installer**: `npm run fetch:whisper` runs the CORE's script @@ -2470,7 +2472,7 @@ Filesmith's conventions. Prism mounts no `HelpPanel`, its terminal menu has no row, Settings has no switch, and F1 is the shell's (termHost's `ownsKey` no longer claims it). `noCommandHelp` in the e2e proves all three. - **REMEMBER TABS IS A SETTING** (2026-09-22, owner: "Prism should also have the option to not - remember tabs"). Settings > General, on by default (how Prism always started), key + remember tabs"). Settings > Explorer > Reopen tabs at start, on by default (how Prism always started), key `prism.tabs.remember` (`lib/tabRestorePrefs.ts`). Off, a COLD start opens only the Explorer tab and whatever Prism was opened with: main reads the key from the window preferences store in `restoreWhenListening`, once per process (`coldRestoreDone`), so a reload of the window keeps its @@ -2490,14 +2492,39 @@ Filesmith's conventions. helper and the app ship together; an old helper would read "started" as a wrong answer, which is why they must. `npm run test:win-e` holds the helper's three new cases. - **THE TERMINAL ROWS COME IN ONE ORDER, THE CORE'S** (2026-09-22, owner: the two apps' terminal - settings "the same in terms of order"). Prism no longer places the Agent indicator itself; the - core's appearance list draws it above its two colours when the host asks (`withIndicator`), which - is opt-in so a host that still places it never shows it twice. `termOptions` reads the page top to - bottom against `TERMINAL_OPTIONS`, as Prism Terminal's `options` does. The restore's claude lookup + settings "the same in terms of order"). The core's sections draw them; `termOptions` reads each + core section (`[data-settings-section]`) top to bottom against `TERMINAL_OPTIONS`, as Prism + Terminal's `options` does. The restore's claude lookup is the core's async one now (`claudeSessionsAsync`), which is Prism's half of the launch freeze. - **SETTINGS DESCRIPTIONS ARE PLAIN WORDS** (2026-09-22, owner: "no symbols other than comma and dot, no mentioning of specific keys or tips, just a simple text description of what it does"). Every hint on Prism's own pages was rewritten to that rule, the core's rows likewise in the core. + Since #292 a subtext is also at most EIGHT words (`subTooLong`), and labels pass `labelProblem`: + `settings/settingsCopy.test.ts` reads every file of `components/settings/`. +- **THE SETTINGS PAGE IS GROUPED CARDS, ON THE CORE'S FRAME** (#292; owner, 2026-10-05, approved + v1 "Grouped cards" with no accent bar on the chosen rail item; spec and plan: PrismTerminal + `docs/superpowers/specs/2026-10-05-settings-redesign-design.md`, PT side PrismTerminal#135). + Rail: Find a setting, Appearance, Explorer, Terminal, Agents, Dictation, Media, (spacer) About; + Media's Visualizer | Progress bar switch is in its header. The frame, sections, rows, controls, + search and flash are prism-term-core's (`renderer/settings/layout`, `sections`, `fields`); Prism's + part is `components/settings/`: the pages, `appOptions.ts` (a CLOSED list of Prism's own rows, + their subtexts and storage keys, the keys a snapshot in `appOptions.test.ts`: none changed), + `settingsIndex.ts` (page order and what Find a setting indexes: the core's rows drawn here, no + command help, the GPU row only with an NVIDIA card) and `icons.ts` (Prism's own row icons beside + the core's). Every row carries `data-pref`; live state (Win+E's status, the Explorer menu + check, the band opacity, a chosen folder) is the row's SUBTEXT. The chosen rail page is a GREY + fill (`--p-hover-hi`), never the accent. Settings' Escape yields while Find a setting holds text + (`[data-settings-find][data-owns-escape]`), and App gives Settings its plain keys (Up and Down + walk the rail, not the folder behind). `colorScheme` on the overlay follows the style's mode, + since `:root` says dark and the core's warning ink is `light-dark()`. Two STOPGAPS for the + core, each to go when the core fixes it: the core's `exports` reach no plain `.ts` settings + module, so `coreIndex`, `sectionIds` and `layout/icons` are resolved by exact specifier in + `electron.vite.config.ts` (`CORE_TS`), `vitest.config.ts` and `tsconfig.web.json`; and the + frame's `compact` classes lose in the cascade to its base ones, so `index.css` gives + `[data-settings-compact]` the rail width. `settingsLook` (in `e2e:terminal`, runner-safe: + contrast, grey rail, Save the only accent button, row and tile size, panel corners from the + style's roundness, Large text, narrow and compact rail, screenshots of every page in both + schemes) and `settingsSearch` (every indexed row found by its label and opened) hold it. - **The viewer lives here for now.** The plan is a shared package, **`prism-core`**, which diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 00ae3d5f..cd13f1ce 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -5,6 +5,16 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import type { Plugin } from 'vite' +/** + * THE CORE'S PLAIN .ts SETTINGS MODULES THE PACKAGE DOES NOT EXPORT (#292). + * prism-term-core's `exports` maps `./renderer/settings/*` to `.tsx` only, so + * `coreIndex`, `sectionIds` and `layout/icons` (Find a setting's index, the + * section ids, the icon names) cannot be imported by package path. Each is + * resolved to its file here, by its exact specifier, until the core exports + * them; the same list is in `vitest.config.ts` and `tsconfig.web.json`. + */ +const CORE_TS = ['renderer/settings/coreIndex', 'renderer/settings/sectionIds', 'renderer/settings/layout/icons'] + // pdf.js side data (character maps, the fourteen standard fonts, wasm image // decoders, ICC profiles), served next to the bundle as /pdf//. // Hand-rolled: vite-plugin-static-copy rebases files from outside the Vite @@ -66,10 +76,11 @@ export default defineConfig({ renderer: { root: 'src/renderer', resolve: { - alias: { - '@renderer': resolve('src/renderer/src'), - '@shared': resolve('src/shared') - }, + alias: [ + { find: '@renderer', replacement: resolve('src/renderer/src') }, + { find: '@shared', replacement: resolve('src/shared') }, + ...CORE_TS.map((m) => ({ find: new RegExp(`^prism-term-core/${m}$`), replacement: resolve(`node_modules/prism-term-core/${m}.ts`) })) + ], // THE TERMINAL COMES FROM prism-term-core (the `core/` of PrismTerminal, #154). // It ships TypeScript SOURCE and is a DEV dependency on purpose, so // electron-vite compiles it in and nothing extra is packaged. A linked diff --git a/package-lock.json b/package-lock.json index a8af225e..f77e8de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.88.0", + "version": "0.89.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.88.0", + "version": "0.89.0", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index 3c7d0f78..811d12e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.88.0", + "version": "0.89.0", "description": "A fast, universal media viewer for Windows.", "author": "Max", "license": "MIT", @@ -14,7 +14,7 @@ "typecheck": "npm run typecheck:node && npm run typecheck:web", "test": "vitest run --passWithNoTests", "e2e": "npm run fetch:bin && npm run build && node tools/e2e/run.mjs", - "e2e:terminal": "npm run fetch:bin && npm run build && node tools/e2e/run.mjs =terminal =termOptions =termColourPicker =helpPanel =dictation =dictationPage =termCwd =agentTitle =handoffOverTerm =promptLayout =termMenuCopy =tabs =sort =pinRecent =updateWindow =updateGuard =updateQuiet", + "e2e:terminal": "npm run fetch:bin && npm run build && node tools/e2e/run.mjs =terminal =termOptions =termColourPicker =settingsLook =helpPanel =dictation =dictationPage =termCwd =agentTitle =handoffOverTerm =promptLayout =termMenuCopy =tabs =sort =pinRecent =updateWindow =updateGuard =updateQuiet", "test:watch": "vitest", "lint": "eslint .", "format": "prettier --write .", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8764d1e3..45cd1b5f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -123,7 +123,7 @@ const DocView = lazy(() => import('./components/DocView').then((m) => ({ default const ComicView = lazy(() => import('./components/ComicView').then((m) => ({ default: m.ComicView })) ) -import { Settings } from './components/Settings' +import { Settings } from './components/settings/Settings' import { Sidebar } from './components/Sidebar' import { TabStrip } from './components/TabStrip' import { PanelToggle } from './components/PanelToggle' @@ -3383,6 +3383,10 @@ export default function App(): JSX.Element { // the sidebar's tree too, whose row then took the focus out of the // menu (measured in the moreMenu e2e). The chords stay the app's. if (el?.closest('[role="menu"]') && !e.ctrlKey && !e.altKey && !e.metaKey) return + // SETTINGS OWNS ITS PLAIN KEYS (#292): Up and Down walk its rail and + // Find a setting's results, and the same press used to page the folder + // behind it too. The chords and the function keys stay the app's. + if (el?.closest('[data-settings-page]') && !e.ctrlKey && !e.altKey && !e.metaKey && !/^F\d+$/.test(e.key) && e.key !== 'Escape') return const inBrowser = !!el?.closest('.folder-browser') if (inBrowser && e.altKey) return if (inBrowser && !e.ctrlKey && !e.altKey && e.key !== 'F11') return diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx deleted file mode 100644 index a4c2eec7..00000000 --- a/src/renderer/src/components/Settings.tsx +++ /dev/null @@ -1,1769 +0,0 @@ -import { WinEShortcutSetting } from './WinEShortcutSetting' -import { DictationSettings } from 'prism-term-core/renderer/settings/Dictation' -import { TerminalAppearanceSettings } from 'prism-term-core/renderer/settings/TerminalAppearance' -import { ShellSetting } from 'prism-term-core/renderer/settings/TerminalBehaviour' -import { useEffect, useRef, useState, type JSX, type ReactNode } from 'react' -import { TRANSPORT_STYLES, TRANSPORT_GROUPS, type TransportStyle } from '../lib/transport' -import { ACCENT_THEME_ID, DEFAULT_THEME_ID } from '../lib/viz/styles' -import type { VizTheme } from '../lib/viz/core' -import { - useViz, - visibleThemes, - applyPreset, - setTheme, - setGlow, - setCycle, - setMove, - setBarTheme, - setBarGlow, - setBarCycle, - setBarMove, - type Preset, - type VizState -} from '../lib/vizStore' -import { VizPreview } from './VizPreview' -import { StyleMini } from './StyleMini' -import { - setNewTabMode, - setNewTabShow, - useNewTabFolder, - useNewTabMode, - useNewTabShow, - type NewTabShow -} from '../lib/newTabPrefs' -import { setOpenMode, useOpenMode, type OpenMode } from '../lib/openPrefs' -import { setRememberTabs, useRememberTabs } from '../lib/tabRestorePrefs' -import { setRememberFolders, useRememberFolders } from '../lib/listingCachePrefs' -import { visitedDirectories } from '../lib/visitedDirectories' -import { setTabWidth, useTabWidth } from '../lib/tabWidthPrefs' -import { EXPLORER_SIZES, setExplorerSize, useExplorerSize } from '../lib/explorerSize' -import { setTitleBarMode, useTitleBarMode } from '../lib/titleBarPrefs' -import { - setAutoScroll, - setTreeSide, - setTreeSize, - TREE_SIDES, - TREE_SIZES, - useAutoScroll, - useTreeSide, - useTreeSize, - type TreeSide, - type TreeSize -} from '../lib/treePrefs' -import { - FONTS, - deletePreset, - isEdited, - paletteOf, - folderIconOf, - primaryValue, - PRIMARY_ALPHA_MIN, - resolveVizTheme, - resetAccent, - restoreOverrides, - savePreset, - secondaryValue, - selectionValue, - TINT_MIN, - setAccentColour, - setAcrylic, - setMode, - setOverride, - setPrimary, - setSecondary, - setSelection, - setStyle, - snapPrimaryAlpha, - useMode, - useOverrides, - useSelectedId, - useStyle, - useStyles, - type FontId, - type Mode, - type Style -} from '../lib/theme' -import { ALPHA_MIN, accentAlphaOf } from '../lib/accentAlpha' -import { ColourField } from 'prism-term-core/renderer/settings/ColourPicker' -import { withAlpha } from 'prism-term-core/renderer/lib/colour' - -// The app-wide Settings window: a large pop-up with a left tab rail and a content -// pane, so it reads like a real settings page. It and the in-canvas gear panel are -// two views over the same vizStore — a change in one shows in the other live. - -/** A small schematic of each transport style, so the picker previews the shape - * without spinning up a real player. */ -function Mini({ id }: { id: TransportStyle }): JSX.Element { - const acc = 'var(--p-accent-hi)' - const box = - 'relative h-11 w-full overflow-hidden rounded-md border border-[color:var(--p-divider)] bg-[var(--p-preview)]' - const dot = - const bars = (n: number, h: number, gap: string, bold = false): JSX.Element => ( -
- {Array.from({ length: n }).map((_, i) => ( - - ))} -
- ) - const line = (h: number, glow = false): JSX.Element => ( -
-
-
- ) - - switch (id) { - case 'edge': - return ( -
-
- {dot} - controls -
-
{line(2, true)}
-
- ) - case 'pill': - return ( -
- {line(7)} -
- {dot} - {dot} -
-
- ) - case 'inline': - return ( -
- {dot} -
{line(3)}
- {dot} -
- ) - case 'island': - return ( -
-
- {dot} -
{line(3)}
-
-
- ) - case 'wave': - return ( -
-
{bars(52, 16, 'gap-[1.5px]')}
-
- {dot} - {dot} -
-
- ) - case 'outline': - return ( -
-
{line(2, true)}
-
- - -
-
- ) - case 'bold': - return ( -
-
{line(4)}
-
- - 0:41 -
-
- ) - case 'segments': - return ( -
-
- {Array.from({ length: 16 }).map((_, i) => ( - - ))} -
-
- {dot} - {dot} -
-
- ) - case 'wavebold': - return ( -
-
{bars(40, 20, 'gap-[2px]', true)}
-
- - 0:41 -
-
- ) - case 'slim': - default: - return ( -
-
{line(3)}
-
- {dot} - {dot} -
-
- ) - } -} - -/* ---------- shared bits ---------- */ - -/** A titled block within a tab. One label, no explanation: if a section needs a - * sentence to justify itself, it is in the wrong place. */ -function Section({ - title, - action, - children -}: { - title: string - action?: ReactNode - children: ReactNode -}): JSX.Element { - return ( -
-
- - {title} - - {action} -
- {children} -
- ) -} - -/** - * SETTINGS CONTROLS ARE NEUTRAL, AND ONLY SAVE WEARS THE ACCENT (owner, - * 2026-09-23: "i dont want settings buttons to be affected by the accent - * colour... grey based on the bg colour and then a slight contrast so that it - * can easily be seen. same colours as the drop down menus"; "the only ones to - * keep accented are the save buttons"). The same classes as the terminal - * core's `fields.tsx`, so both halves of this page match. Every grey is a - * token the style derives from its own ground. FOCUS IS THE HOVER'S LOOK, not - * an accent edge (#272; owner, 2026-10-04: "remove the focus effect. go - * through the ui and remove focus effects like this"). - */ -const ROW_BUTTON = - 'h-8 rounded-[var(--p-radius-sm)] border border-[color:var(--p-divider)] bg-[var(--p-control)] px-3 text-[12px] font-semibold text-[var(--p-text)] transition-colors hover:border-[color:var(--p-line)] hover:bg-[var(--p-hover)] focus-visible:border-[color:var(--p-line)] focus-visible:bg-[var(--p-hover)] focus-visible:outline-none disabled:opacity-50' -const SEGMENT_ON = 'bg-[color-mix(in_srgb,var(--p-text)_16%,var(--p-control))] text-[var(--p-text)]' -const SWITCH_ON = 'bg-[var(--p-text-soft)]' -const SWITCH_KNOB_ON = 'bg-[var(--p-bg)]' - -/** A real on/off switch: one control, one state, no pair of buttons to compare. */ -function Switch({ - on, - onChange, - label -}: { - on: boolean - onChange: (b: boolean) => void - label: string -}): JSX.Element { - return ( - - ) -} - -/** A label with its switch, sized to sit beside others on one line. */ -/** Whether a cold start reopens last time's tabs (owner, 2026-09-22). */ -function RememberTabsSetting(): JSX.Element { - const on = useRememberTabs() - return ( - - - - ) -} - -/** - * Whether the Explorer keeps a list of what is in the folders it opens, so - * they show at once, even after a restart (#271; owner-approved, 2026-10-04: - * on by default, a switch and a Clear button). Off deletes the list at once. - * Clear deletes it and keeps the switch as it is. - */ -function RememberFoldersSetting(): JSX.Element { - const on = useRememberFolders() - const [cleared, setCleared] = useState(false) - return ( - -
- - { - setCleared(false) - setRememberFolders(next) - }} - label="Remember folders" - /> -
-
- ) -} - -/** How wide a tab is (#216; owner, 2026-09-23: "fixed size or dynamic ... the - * user can pick"), the same row as Prism Terminal's. */ -function TabWidthSetting(): JSX.Element { - const width = useTabWidth() - return ( - - - - ) -} - -/** Whether the window keeps its title bar (#250; owner, 2026-10-02: "normal - * prism should also have no titlebar option"), Prism Terminal's row (#91). - * A window setting, not the style's: a style switch leaves it alone, so it - * sits up here with Tab width rather than with Edges under "This style". */ -function TitleBarSetting(): JSX.Element { - const mode = useTitleBarMode() - return ( - - - - ) -} - -/** How big the Explorer's rows are (owner, 2026-10-03: "size options for - * explorer in the appearance menu, let the current be medium the old be big, - * and make a slightly smaller version too"). The Explorer's alone: the tree - * and the rest of the app keep General's Font size. */ -function ExplorerSizeSetting(): JSX.Element { - const size = useExplorerSize() - return ( - - ({ id, name }))} - /> - - ) -} - -function SwitchItem({ - label, - on, - onChange -}: { - label: string - on: boolean - onChange: (b: boolean) => void -}): JSX.Element { - return ( - - ) -} - -/** - * A colour row's control: the core's picker (ONE COLOUR PICKER, owner - * 2026-10-03: "the colour pickers should be the same for both apps, i need an - * input field for a color code and an alpha per colour on every colour - * setting"), with this page's Reset beside it. The code field takes HEX, RGBA - * or HSLA and commits only a typed colour that differs from the one shown, so - * tabbing through a row writes nothing (the guard the old local well lacked); - * the swatch opens the picker, live, and Escape there puts back the row as it - * was when it opened (`onRevert`), an unset row included. - */ -function StyleColour({ - id, - label, - value, - custom, - onChange, - onReset, - onRevert, - alphaMin, - snapAlpha -}: { - id: string - label: string - value: string - custom: boolean - onChange: (stored: string) => void - onReset: () => void - onRevert: () => void - alphaMin?: number - snapAlpha?: (a: number) => number -}): JSX.Element { - return ( -
- {custom && ( - - )} - -
- ) -} - -/** Two or three exclusive choices as one control rather than a row of buttons. */ -function Segmented({ - value, - onChange, - options -}: { - value: T - onChange: (v: T) => void - options: Array<{ id: T; name: string }> -}): JSX.Element { - return ( -
- {options.map((o) => { - const on = o.id === value - return ( - - ) - })} -
- ) -} - -// Picker grids are bounded, not stretched: a plain fractional grid pulled three -// cards across a wide window and squeezed six into slivers, taking the schematic -// inside each card with it. So the track is free to flex but the card is capped: -// it fills what it is given up to its full size, shrinks by at most a quarter as -// the window narrows, and only then does the row drop a column. -// -// The cap has to live on the card, not the track: with `minmax(min, max)` and -// two definite lengths, auto-fill counts columns using the *max*, so the cards -// never shrink at all - which is what the first attempt at this did. -const GRID = 'grid grid-cols-[repeat(auto-fill,minmax(201px,1fr))] gap-2.5 [&>*]:max-w-[268px]' -const GRID_SM = 'grid grid-cols-[repeat(auto-fill,minmax(168px,1fr))] gap-2.5 [&>*]:max-w-[224px]' - -/** A selectable tile — the shell every picker card shares. A div rather than a - * button so a card can carry its own controls (a preset's delete). */ -/** The one save button, worn identically by the style and terminal tabs: - * accent while there is something to save, quietly grey when there is not. */ -function SaveButton({ - dirty, - onClick, - title -}: { - dirty: boolean - onClick: () => void - title: string -}): JSX.Element { - return ( - - ) -} - -/** A shared section head: title, one line under it, and the save button in the - * top-right corner - the style and terminal tabs read the same. */ -function ThemeHead({ sub, save }: { sub: string; save: ReactNode }): JSX.Element { - return ( -
-
-
Theme
-

{sub}

-
- {save} -
- ) -} - -function Tile({ - on, - onClick, - children -}: { - on: boolean - onClick: () => void - children: ReactNode -}): JSX.Element { - return ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onClick() - } - }} - // The chosen card's wash is from the accent as picked, so the accent's - // alpha (a choice about fills) never fades the mark that says "this one". - className={`group relative flex cursor-pointer flex-col gap-1.5 rounded-[var(--p-radius)] border p-2 text-left transition ${ - on - ? 'border-[var(--p-accent-solid)] bg-[var(--p-accent-solid)]/12 shadow-[0_0_0_2px_var(--p-accent-solid)]' - : 'border-[color:var(--p-divider)] bg-[var(--p-hover)] hover:border-[color:var(--p-dim2)]' - }`} - > - {children} -
- ) -} - -// The name, and nothing else: the ring already says which card is chosen, so a -// "Selected" caption is the same fact written twice. -function TileFooter({ name, on }: { name: string; on: boolean }): JSX.Element { - return ( - - {name} - - ) -} - -/** Whether preset `p` matches the current live settings exactly. */ -function isActivePreset(p: Preset, v: VizState): boolean { - return ( - p.style === v.style && - p.height === v.height && - p.pos === v.pos && - p.width === v.width && - p.logo === v.logo && - (p.theme ?? DEFAULT_THEME_ID) === v.theme - ) -} - -/* ---------- tab bodies ---------- */ - -function PlayerTab({ - transportStyle, - onPickTransport, - transportBg, - onPickTransportBg -}: { - transportStyle: TransportStyle - onPickTransport: (s: TransportStyle) => void - /** How solid the band behind the controls is, 0-100%. */ - transportBg: number - onPickTransportBg: (pct: number) => void -}): JSX.Element { - const v = useViz() - return ( -
- {TRANSPORT_GROUPS.map((g) => { - const items = TRANSPORT_STYLES.filter((s) => s.group === g) - if (!items.length) return null - return ( -
-
- {items.map((s) => { - const on = s.id === transportStyle - return ( - onPickTransport(s.id)}> - - - - ) - })} -
-
- ) - })} -
- {/* Opaque is the bar as it has always been; all the way down, the - picture runs to the bottom of the frame and the controls carry - their own shadow. The edge, outline and island styles have no band - to fade - they are their own shape - and the hint says so. */} - -
- - {transportBg}% - - onPickTransportBg(Number(e.target.value))} - className="h-1.5 w-[180px] cursor-pointer appearance-none rounded-full bg-[var(--p-track)]" - style={{ accentColor: 'var(--p-accent-solid)' }} - /> -
-
-
-
- -
-
- ) -} - -/* ---------- style ---------- */ - -const CORNER_OPTIONS: Array<{ id: Style['corners']; name: string }> = [ - { id: '2', name: 'Square' }, - { id: '8', name: 'Soft' }, - { id: '14', name: 'Round' } -] - -const EDGE_OPTIONS: Array<{ id: Style['borders']; name: string }> = [ - { id: 'none', name: 'None' }, - { id: 'faint', name: 'Faint' }, - { id: 'hairline', name: 'Hairline' }, - { id: 'strong', name: 'Strong' } -] - -// Each option set in its own face, so the picker previews what it names. -const FONT_OPTIONS: Array<{ id: FontId; name: string; style: React.CSSProperties }> = ( - Object.keys(FONTS) as FontId[] -).map((id) => ({ id, name: FONTS[id].name, style: { fontFamily: FONTS[id].stack } })) - -const MODE_OPTIONS: Array<{ id: Mode; name: string }> = [ - { id: 'dark', name: 'Dark' }, - { id: 'light', name: 'Light' } -] - -function StyleTab(): JSX.Element { - const style = useStyle() - const mode = useMode() - const edits = useOverrides() - const accentAlpha = accentAlphaOf(style.accentAlpha) - // What a picker's Escape puts back: the draft as it is at this render, which - // is the one a popover opening now captures. - const putBack = (keys: Array) => () => restoreOverrides(edits, keys) - const selected = useSelectedId() - const list = useStyles(mode) - // Ask the store rather than re-deriving it here: this list had already fallen - // behind twice, and a Save button that misses an edit loses it. - const dirty = isEdited() - void edits // re-render when an edit lands, so `dirty` is read again - return ( -
- {/* Mode is a setting like any other, so it gets a row of its own rather - than a control tucked into the page header. */} -
- {/* Tab width opens the look page (owner, 2026-09-23: "put the option - closer to the top of appearance"); Style is Prism's appearance. */} - - - - - - -
- - } - /> - - {/* Once a colour is changed nothing here is selected: what is on screen is - no longer any of these. Clicking one is how you go back to it. */} -
- {list.map((st) => { - // The CURRENT style's card is live: it renders the edited style, so - // turning Void white turns its card white with it. It also stays - // selected through an edit - the user reads it as "my theme", and a - // wall with nothing selected read as a bug. Clicking it while edited - // does nothing (setStyle would silently revert the edits); every - // other card still shows its saved self and gives what it shows. - const live = st.id === style.id - const on = st.id === (selected ?? style.id) - return ( - { - if (live && selected === null) return - setStyle(st.id) - }} - > - -
- - {st.custom && ( - - )} -
-
- ) - })} -
- - {/* The save button lives in the ThemeHead above, where the terminal tab - also keeps its own - the two tabs read the same. */} -
-
- - setTreeSize(v as TreeSize)} - options={TREE_SIZES} - /> - - - - - - setNewTabShow(v as NewTabShow)} - options={[ - { id: 'file', name: 'First file in the folder' }, - { id: 'terminal', name: 'A terminal' }, - { id: 'none', name: 'Folder browser' } - ]} - /> - - {/* Owner, 2026-09-22: a file from outside opens in the Explorer tab, - "maximized or as previews ... default should be preview". */} - - setOpenMode(v as OpenMode)} - options={[ - { id: 'preview', name: 'Preview' }, - { id: 'full', name: 'Full view' } - ]} - /> - - - - - setTreeSide(v as TreeSide)} options={TREE_SIDES} /> - - - {/* Explorer's own menu. Windows 11 hides classic verbs behind "Show more - options", and saying so is better than the user hunting for it. */} - - - - {/* Setup offers this once; this is where you find it afterwards. Windows - owns the choice, so all we can do is open the page it lives on. */} - - - -
- ) -} - -function VisualizerTab(): JSX.Element { - const v = useViz() - // Style shows a simple schematic mockup of each shape (min-size cards that - // reflow), with the colour scheme in its own subsection below. - return ( -
-
-
- {v.presets.map((p) => { - const on = isActivePreset(p, v) - return ( - applyPreset(p)}> -
- -
- -
- ) - })} -
-
-
- -
-
- ) -} - -// Base schemes are just Solid or Gradient (simple -> complex); glow / cycle / move -// are separate effect toggles rather than their own categories. -function colourCategory(t: VizTheme): string { - return t.palette.length <= 1 ? 'Solid' : 'Gradient' -} -const COLOUR_ORDER = ['Solid', 'Gradient'] - -// The colour effects, applied on top of any scheme (they all combine). Reused for -// the visualizer and the progress bar, each with its own values + setters. -function EffectToggles({ - glow, - cycle, - move, - onGlow, - onCycle, - onMove -}: { - glow: boolean - cycle: boolean - move: boolean - onGlow: (b: boolean) => void - onCycle: (b: boolean) => void - onMove: (b: boolean) => void -}): JSX.Element { - const toggles: Array<{ label: string; on: boolean; set: (b: boolean) => void }> = [ - { label: 'Glow', on: glow, set: onGlow }, - { label: 'Cycle', on: cycle, set: onCycle }, - { label: 'Move', on: move, set: onMove } - ] - return ( -
- {toggles.map((tg) => ( - - ))} -
- ) -} - -// A grid of plain filled colour swatches (no labels; name on hover) - used for -// both the visualizer scheme and the progress-bar colour so they match. -function Swatches({ - items, - selectedId, - onPick -}: { - items: Array<{ id: string; name: string; fill: string }> - selectedId: string - onPick: (id: string) => void -}): JSX.Element { - return ( -
- {items.map((it) => { - const on = it.id === selectedId - return ( -
- ) -} - -// A colour-scheme picker (Solid / Gradient), used for both the visualizer and the -// progress bar with their own selection. -function SchemePicker({ - selectedId, - onPick -}: { - selectedId: string - onPick: (id: string) => void -}): JSX.Element { - // The accent-following scheme leads the list, drawn in the colour it is - // actually following rather than the placeholder it carries. - const themes = [resolveVizTheme(ACCENT_THEME_ID), ...visibleThemes()] - return ( -
- {COLOUR_ORDER.map((cat) => { - const items = themes.filter((t) => colourCategory(t) === cat) - if (!items.length) return null - return ( -
-
- {cat} -
- ({ - id: t.id, - name: t.name, - fill: - t.palette.length > 1 - ? `linear-gradient(90deg, ${t.palette.join(', ')})` - : t.palette[0] - }))} - selectedId={selectedId} - onPick={onPick} - /> -
- ) - })} -
- ) -} - -// A colour scheme + effect toggles block, shared by the two Colour subsections. -function ColourControls({ - selectedId, - onPick, - glow, - cycle, - move, - onGlow, - onCycle, - onMove -}: { - selectedId: string - onPick: (id: string) => void - glow: boolean - cycle: boolean - move: boolean - onGlow: (b: boolean) => void - onCycle: (b: boolean) => void - onMove: (b: boolean) => void -}): JSX.Element { - return ( -
- -
-
- Effects -
- -
-
- ) -} - -/* ---------- tabs shell ---------- */ - -type TabId = 'style' | 'general' | 'terminal' | 'dictation' | 'player' | 'visualizer' | 'about' - -const Ico = ({ d }: { d: string }): JSX.Element => ( - - - -) - -const TABS: Array<{ id: TabId; label: string; title: string; icon: ReactNode }> = [ - { - id: 'style', - label: 'Style', - title: 'Style', - icon: ( - - ) - }, - { - id: 'general', - label: 'General', - title: 'General', - icon: ( - - ) - }, - { - id: 'terminal', - label: 'Terminal', - title: 'Terminal', - icon: - }, - // The terminal's dictation (#162): a page of its own, as in Prism Terminal - // (owner, 2026-09-19), because a model manager does not fit under Terminal. - { - id: 'dictation', - label: 'Dictation', - title: 'Dictation', - icon: - }, - { - id: 'player', - label: 'Progress bar', - title: 'Progress bar', - icon: - }, - { - id: 'visualizer', - label: 'Visualizer', - title: 'Visualizer', - icon: - }, - { - id: 'about', - label: 'About', - title: 'About Prism', - icon: - } -] - -// The rail is grouped rather than one flat list: five entries split two ways -// says more about where a setting lives than five in a row does. -const RAIL_GROUPS: Array<{ name: string; tabs: TabId[] }> = [ - { name: 'Behaviour', tabs: ['general', 'terminal', 'dictation'] }, - { name: 'Look', tabs: ['style', 'visualizer', 'player'] }, - { name: '', tabs: ['about'] } -] - -export function Settings({ - open, - onClose, - compactRail, - onShowSetup, - transportStyle, - onPickTransport, - transportBg, - onPickTransportBg -}: { - open: boolean - onClose: () => void - /** The rail collapsed to its icons, from the title-bar button. */ - compactRail: boolean - /** Run the first-run setup again. */ - onShowSetup: () => void - transportStyle: TransportStyle - onPickTransport: (s: TransportStyle) => void - transportBg: number - onPickTransportBg: (pct: number) => void -}): JSX.Element | null { - const [tab, setTab] = useState('style') - const size = useTreeSize() - - useEffect(() => { - if (!open) return - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') { - // A MODAL QUESTION OVER THIS PAGE OWNS ESCAPE (2026-09-20, #168; found - // by the updateWindow e2e). The update window can be opened from the - // title bar while Settings is up, and one Escape closed BOTH: every - // Escape listener here sits on the window in the capture phase, where - // stopPropagation does not silence a sibling listener, and this one - // was registered first. It yields by inspection, as App's does. The - // test is the modal dialog itself and NOT `data-owns-escape`: that - // attribute is also worn by things UNDER this page (an editor whose - // caret is in the file, a player's open menu), and yielding to those - // would leave Settings with no way to be closed from the keyboard. - if (document.querySelector('[role="dialog"][aria-modal="true"]')) return - // AN OPEN COLOUR PICKER OWNS ESCAPE TOO (the core's ColourPopover, on - // the Style and Terminal pages): there it undoes the picker's writes. - // This listener is native and runs first, and stopping the event here - // would close the whole page and never let the picker hear it. - if ((e.target as Element | null)?.closest?.('[data-colour-popover]')) return - e.stopPropagation() - onClose() - } - } - window.addEventListener('keydown', onKey, true) - return () => window.removeEventListener('keydown', onKey, true) - }, [open, onClose]) - // The page starts where the chrome ends: under the title bar AND the tab - // row (68px), or under the one row when the title bar is hidden (36px, #250; - // owner, 2026-10-03: "the settings page doesnt move up to cover the gap"). - const titleBar = useTitleBarMode() - - if (!open) return null - const active = TABS.find((t) => t.id === tab) ?? TABS[0] - - return ( - // A full-window settings page under the chrome, not a popup. - // Settings keeps the system font whatever the style says. A style's - // typeface belongs to the app you're looking at; letting it set the type in - // here means picking a mono or a serif style resizes the settings page - // itself, and the cards you're choosing between move as you read them. -
-
- {/* tab rail, grouped: how Prism behaves, then what it looks like. The same - title-bar button that hides the file tree collapses this to its glyphs. */} - - - {/* content */} -
- {/* No close button: the cog that opened this closes it, and Escape works - too. One control, one place. */} -
-

- {active.title} -

-
- -
- {tab === 'style' ? ( - - ) : tab === 'general' ? ( - - ) : tab === 'terminal' ? ( - - ) : tab === 'dictation' ? ( - // THE CORE'S PAGE, whole: the same settings Prism Terminal shows, - // with this app's own values (the model files are shared). - - ) : tab === 'player' ? ( - - ) : tab === 'visualizer' ? ( - - ) : ( -
-

- A quick viewer for images, video, audio and documents. -

- -
- )} -
-
-
-
- ) -} diff --git a/src/renderer/src/components/noFocusRings.test.ts b/src/renderer/src/components/noFocusRings.test.ts index a5bc5156..0319043c 100644 --- a/src/renderer/src/components/noFocusRings.test.ts +++ b/src/renderer/src/components/noFocusRings.test.ts @@ -96,6 +96,6 @@ describe('focus still shows where the ring went', () => { expect(src('components/Onboarding.tsx')).toMatch( /aria-pressed=\{mode === m\}[\s\S]{0,900}style=\{\{ backgroundColor: 'var\(--p-hover\)' \}\}/ ) - expect(src('components/Settings.tsx')).toMatch(/hover:ring-white\/30 focus-visible:ring-white\/30/) + expect(src('components/settings/ColourSchemes.tsx')).toMatch(/hover:ring-white\/30 focus-visible:ring-white\/30/) }) }) diff --git a/src/renderer/src/components/settings/AboutPage.tsx b/src/renderer/src/components/settings/AboutPage.tsx new file mode 100644 index 00000000..8a29e3a0 --- /dev/null +++ b/src/renderer/src/components/settings/AboutPage.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState, type JSX } from 'react' +import { ROW_BUTTON } from 'prism-term-core/renderer/settings/fields' +import { SettingBlock } from 'prism-term-core/renderer/settings/layout/SettingBlock' +import { SettingRow } from 'prism-term-core/renderer/settings/layout/SettingRow' +import { SettingsSection } from 'prism-term-core/renderer/settings/layout/SettingsSection' +import appIcon from '../../assets/icon.png' +import { appOpt } from './appOptions' +import { iconPath } from './icons' + +const DISPLAY_FONT = '"Segoe UI Variable Display", "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif' + +/** ABOUT: the app's own icon and one line, the version it runs (new with the + * grouped cards, 2026-10-05), and the first run guide again. */ +export function AboutPage({ onShowSetup }: { onShowSetup: () => void }): JSX.Element { + const [version, setVersion] = useState('') + useEffect(() => { + let live = true + void window.prism.appVersion().then((v) => { + if (live) setVersion(v) + }) + return () => { + live = false + } + }, []) + const ver = appOpt('app-version') + const setup = appOpt('show-setup') + return ( + + +
+ +
+

+ Prism +

+

+ A quick viewer for images, video, audio and documents. +

+
+
+
+ + + {version} + + + + + +
+ ) +} diff --git a/src/renderer/src/components/settings/AgentsPage.tsx b/src/renderer/src/components/settings/AgentsPage.tsx new file mode 100644 index 00000000..e6d51288 --- /dev/null +++ b/src/renderer/src/components/settings/AgentsPage.tsx @@ -0,0 +1,17 @@ +import type { JSX } from 'react' +import { AgentMarksSection } from 'prism-term-core/renderer/settings/sections/AgentMarksSection' +import { ClaudeCodeSection } from 'prism-term-core/renderer/settings/sections/ClaudeCodeSection' +import { MarkColoursSection } from 'prism-term-core/renderer/settings/sections/MarkColoursSection' + +/** AGENTS: how a tab marks its agent, Claude Code's own word, and the marks' + * colours, all the core's (2026-10-05). Prism adds no row of its own: it has + * no taskbar count. `data-agent-settings` is what the gate reads. */ +export function AgentsPage(): JSX.Element { + return ( +
+ + + +
+ ) +} diff --git a/src/renderer/src/components/settings/AppearancePage.tsx b/src/renderer/src/components/settings/AppearancePage.tsx new file mode 100644 index 00000000..3c31e8ee --- /dev/null +++ b/src/renderer/src/components/settings/AppearancePage.tsx @@ -0,0 +1,146 @@ +import type { CSSProperties, JSX } from 'react' +import { SaveButton, Segmented, Select, Switch } from 'prism-term-core/renderer/settings/fields' +import { SettingBlock } from 'prism-term-core/renderer/settings/layout/SettingBlock' +import { SettingRow } from 'prism-term-core/renderer/settings/layout/SettingRow' +import { SettingsSection } from 'prism-term-core/renderer/settings/layout/SettingsSection' +import { setTabWidth, useTabWidth } from '../../lib/tabWidthPrefs' +import { setTitleBarMode, useTitleBarMode } from '../../lib/titleBarPrefs' +import { setTreeSize, TREE_SIZES, useTreeSize, type TreeSize } from '../../lib/treePrefs' +import { FONTS, isEdited, savePreset, setMode, setOverride, useMode, useOverrides, useStyle, type FontId, type Mode, type Style } from '../../lib/theme' +import { APP_SECTIONS, appOpt } from './appOptions' +import { iconPath } from './icons' +import { StyleColoursSection } from './StyleColours' +import { StyleWall } from './StyleWall' + +// APPEARANCE (2026-10-05, the grouped cards redesign; it was Style): the +// style and its wall, the style's colours, the text, and the window. Rows +// marked as the style's (colours, App font, Panel edges, Corner roundness) +// are edits of the chosen style and light Save changes; the rest are this +// app's own and a style switch leaves them alone. +// +// THE FILE ICONS SWITCH IS GONE FROM HERE (owner, 2026-09-01: "hide that color +// setting for now ... we might come back to it"), and theme.ts forces the +// scheme to monochrome. Everything behind it is intact (IconScheme, +// iconSchemeOf, the iconScheme override, ICON_COLOURS, ICON_FULL_COLOUR), so +// bringing it back is a row with a Segmented of Monochrome / Coloured wired to +// setOverride('iconScheme'), plus flipping ICON_SCHEME_SHOWN. + +const MODE_OPTIONS: Array<{ id: Mode; name: string }> = [ + { id: 'dark', name: 'Dark' }, + { id: 'light', name: 'Light' } +] + +// Each option set in its own face, so the picker previews what it names. +const FONT_OPTIONS: Array<{ id: FontId; name: string; style: CSSProperties }> = (Object.keys(FONTS) as FontId[]).map((id) => ({ + id, + name: FONTS[id].name, + style: { fontFamily: FONTS[id].stack } +})) + +// Weakest to strongest, a scale read in order. +const EDGE_OPTIONS: Array<{ id: Style['borders']; name: string }> = [ + { id: 'none', name: 'None' }, + { id: 'faint', name: 'Faint' }, + { id: 'hairline', name: 'Hairline' }, + { id: 'strong', name: 'Strong' } +] + +const CORNER_OPTIONS: Array<{ id: Style['corners']; name: string }> = [ + { id: '2', name: 'Square' }, + { id: '8', name: 'Soft' }, + { id: '14', name: 'Round' } +] + +// Dynamic first: it is the default (owner, 2026-09-23: "call it dynamic ... +// have dynamic be the default"). +const TAB_WIDTH_OPTIONS: Array<{ id: 'dynamic' | 'fixed'; name: string }> = [ + { id: 'dynamic', name: 'Dynamic' }, + { id: 'fixed', name: 'Fixed' } +] + +/** Mode, the style and its wall. Mode is a setting like any other, so it is + * a row of its own rather than a control tucked into the page header. */ +function ThemeSection(): JSX.Element { + const mode = useMode() + const edits = useOverrides() + // Ask the store rather than re-deriving it here: this list had already + // fallen behind twice, and a Save button that misses an edit loses it. + const dirty = isEdited() + void edits // re-render when an edit lands, so `dirty` is read again + const modeOpt = appOpt('mode') + const theme = appOpt('style-theme') + return ( + + + + + + + + + + + + ) +} + +/** The app's typeface (the style's) and the interface's text size (the + * app's: it zooms the sidebar and this page, never the Explorer's rows). */ +function TextSection(): JSX.Element { + const style = useStyle() + const size = useTreeSize() + const font = appOpt('c-font') + const ui = appOpt('tree-size') + return ( + + + setTreeSize(v as TreeSize)} options={TREE_SIZES} /> + + + ) +} + +/** The window. Title bar and Tab width are window settings, not the style's + * (#250, #216): a style switch leaves them alone. Edges and corners are the + * style's own. */ +function WindowSection(): JSX.Element { + const style = useStyle() + const titleBar = useTitleBarMode() + const width = useTabWidth() + const bar = appOpt('title-bar') + const tab = appOpt('tab-width') + const edges = appOpt('c-edges') + const corners = appOpt('c-corners') + return ( + + {/* A switch over the same store the segmented control wrote: on is + `shown`, the default, the window as it always was (#250). */} + + setTitleBarMode(on ? 'shown' : 'hidden')} label={bar.label} /> + + + + + + setOverride('borders', v)} options={EDGE_OPTIONS} /> + + + setOverride('corners', v)} options={CORNER_OPTIONS} /> + + + ) +} + +export function AppearancePage(): JSX.Element { + return ( + <> + + + + + + ) +} diff --git a/src/renderer/src/components/settings/ColourSchemes.tsx b/src/renderer/src/components/settings/ColourSchemes.tsx new file mode 100644 index 00000000..a30867c9 --- /dev/null +++ b/src/renderer/src/components/settings/ColourSchemes.tsx @@ -0,0 +1,133 @@ +import type { JSX } from 'react' +import { Switch } from 'prism-term-core/renderer/settings/fields' +import { SettingBlock } from 'prism-term-core/renderer/settings/layout/SettingBlock' +import { SettingRow } from 'prism-term-core/renderer/settings/layout/SettingRow' +import { SettingsSection } from 'prism-term-core/renderer/settings/layout/SettingsSection' +import type { VizTheme } from '../../lib/viz/core' +import { ACCENT_THEME_ID } from '../../lib/viz/styles' +import { resolveVizTheme } from '../../lib/theme' +import { visibleThemes } from '../../lib/vizStore' +import { APP_SECTIONS, appOpt } from './appOptions' +import { BlockLabel } from './cards' +import { iconPath } from './icons' + +// A COLOUR SCHEME AND ITS THREE EFFECTS, one section, worn by the visualizer +// and by the progress bar with their own values (2026-10-05: Media's two +// halves had identical Colour blocks, so they share this one). Base schemes +// are just Solid or Gradient (simple to complex); Glow, Cycle and Move are +// effects on top of any scheme, and they all combine. + +function colourCategory(t: VizTheme): string { + return t.palette.length <= 1 ? 'Solid' : 'Gradient' +} +const COLOUR_ORDER = ['Solid', 'Gradient'] + +/** A grid of plain filled swatches (no labels; the name on hover). */ +function Swatches({ + items, + selectedId, + onPick +}: { + items: Array<{ id: string; name: string; fill: string }> + selectedId: string + onPick: (id: string) => void +}): JSX.Element { + return ( +
+ {items.map((it) => { + const on = it.id === selectedId + return ( +
+ ) +} + +/** The schemes, Solid then Gradient. The accent-following scheme leads, drawn + * in the colour it is actually following rather than its placeholder. */ +function SchemePicker({ selectedId, onPick }: { selectedId: string; onPick: (id: string) => void }): JSX.Element { + const themes = [resolveVizTheme(ACCENT_THEME_ID), ...visibleThemes()] + return ( +
+ {COLOUR_ORDER.map((cat) => { + const items = themes.filter((t) => colourCategory(t) === cat) + if (!items.length) return null + return ( +
+ {cat} + ({ + id: t.id, + name: t.name, + fill: t.palette.length > 1 ? `linear-gradient(90deg, ${t.palette.join(', ')})` : t.palette[0] + }))} + selectedId={selectedId} + onPick={onPick} + /> +
+ ) + })} +
+ ) +} + +export interface SchemeValues { + selectedId: string + onPick: (id: string) => void + glow: boolean + cycle: boolean + move: boolean + onGlow: (b: boolean) => void + onCycle: (b: boolean) => void + onMove: (b: boolean) => void +} + +/** The row ids of one half's colour section, written where it is drawn. */ +export interface SchemeIds { + block: string + glow: string + cycle: string + move: string +} + +/** The colour section of one half of Media. */ +export function ColourSection({ ids, v }: { ids: SchemeIds; v: SchemeValues }): JSX.Element { + const block = appOpt(ids.block) + const effects: Array<[string, boolean, (b: boolean) => void]> = [ + [ids.glow, v.glow, v.onGlow], + [ids.cycle, v.cycle, v.onCycle], + [ids.move, v.move, v.onMove] + ] + return ( + + + + + {effects.map(([id, on, set]) => { + const o = appOpt(id) + return ( + + + + ) + })} + + ) +} diff --git a/src/renderer/src/components/settings/ExplorerPage.tsx b/src/renderer/src/components/settings/ExplorerPage.tsx new file mode 100644 index 00000000..46ac4afa --- /dev/null +++ b/src/renderer/src/components/settings/ExplorerPage.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState, type JSX } from 'react' +import { ROW_BUTTON, Segmented, Select, Switch } from 'prism-term-core/renderer/settings/fields' +import { SettingRow } from 'prism-term-core/renderer/settings/layout/SettingRow' +import { SettingsSection } from 'prism-term-core/renderer/settings/layout/SettingsSection' +import { EXPLORER_SIZES, setExplorerSize, useExplorerSize } from '../../lib/explorerSize' +import { setRememberFolders, useRememberFolders } from '../../lib/listingCachePrefs' +import { setNewTabMode, setNewTabShow, useNewTabFolder, useNewTabMode, useNewTabShow, type NewTabShow } from '../../lib/newTabPrefs' +import { setOpenMode, useOpenMode, type OpenMode } from '../../lib/openPrefs' +import { setRememberTabs, useRememberTabs } from '../../lib/tabRestorePrefs' +import { setAutoScroll, setTreeSide, TREE_SIDES, useAutoScroll, useTreeSide, type TreeSide } from '../../lib/treePrefs' +import { visitedDirectories } from '../../lib/visitedDirectories' +import { APP_SECTIONS, appOpt } from './appOptions' +import { iconPath } from './icons' +import { WinEShortcutRow } from './WinEShortcutRow' + +// EXPLORER (2026-10-05, the grouped cards redesign): what was General, the +// tree and the Explorer's rows, sorted by what they set up: the layout, how +// things open, what comes back at a start, and how Prism sits in Windows. + +/** One of this page's rows, its icon, label and resting subtext read from + * `appOptions.ts` so the page and Find a setting say the same words. */ +const row = (id: string): { icon: string; label: string; sub: string } => { + const o = appOpt(id) + return { icon: iconPath(o.icon), label: o.label, sub: o.sub } +} + +function LayoutSection(): JSX.Element { + const side = useTreeSide() + const size = useExplorerSize() + const follow = useAutoScroll() + return ( + + + setTreeSide(v as TreeSide)} options={TREE_SIDES} /> + + {/* The Explorer's rows alone (owner, 2026-10-03): the tree and the rest + of the app keep Interface text size. */} + + ({ id, name }))} /> + + + + + + ) +} + +function OpeningSection(): JSX.Element { + const tabMode = useNewTabMode() + const tabFolder = useNewTabFolder() + const tabShow = useNewTabShow() + const openAs = useOpenMode() + // Picking "A chosen folder" opens the chooser right away; cancelling keeps + // whatever was set before rather than leaving a mode with no folder. + const pickTabMode = (v: string): void => { + if (v === 'folder') { + void window.prism.pickFolder().then((dir) => { + if (dir) setNewTabMode('folder', dir) + }) + } else setNewTabMode(v as 'home' | 'ask') + } + const chosen = tabMode === 'folder' && !!tabFolder + return ( + + {/* The folder itself is a path, which a subtext does not hold (plain + words only), so it rides on the control's tooltip. */} + +
+ setNewTabShow(v as NewTabShow)} + options={[ + { id: 'file', name: 'First file in the folder' }, + { id: 'terminal', name: 'A terminal' }, + { id: 'none', name: 'Folder browser' } + ]} + /> + + {/* Owner, 2026-09-22: a file from outside opens in the Explorer tab, + "maximized or as previews ... default should be preview". */} + + setOpenMode(v as OpenMode)} + options={[ + { id: 'preview', name: 'Preview' }, + { id: 'full', name: 'Full view' } + ]} + /> + + + ) +} + +/** + * What comes back at a start. Reopen tabs (owner, 2026-09-22): off, a cold + * start opens only the Explorer tab and what Prism was opened with. Remember + * recent folders (#271; owner-approved, 2026-10-04: on by default, a switch + * and a Clear button): off deletes the list at once; Clear deletes it and + * keeps the switch as it is. + */ +function StartsSection(): JSX.Element { + const tabsOn = useRememberTabs() + const foldersOn = useRememberFolders() + const [cleared, setCleared] = useState(false) + const folders = appOpt('remember-folders') + return ( + + + + + + + { + setCleared(false) + setRememberFolders(next) + }} + label={folders.label} + /> + + + ) +} + +/** How Prism sits in Windows: Win+E, the Explorer menu, the default apps. + * Windows owns all three, so each reports what Windows says. */ +function WindowsSection(): JSX.Element { + // Explorer's context-menu verb lives in the registry, not in settings.json: + // the switch reports what Windows actually has. + const [verb, setVerbState] = useState(false) + const [verbBusy, setVerbBusy] = useState(true) + useEffect(() => { + let live = true + void window.prism.shellVerbStatus().then((on) => { + if (live) { + setVerbState(on) + setVerbBusy(false) + } + }) + return () => { + live = false + } + }, []) + const setVerb = (on: boolean): void => { + setVerbBusy(true) + void window.prism.setShellVerb(on).then(async () => { + // Read it back rather than trusting the write: this is the registry. + setVerbState(await window.prism.shellVerbStatus()) + setVerbBusy(false) + }) + } + const menu = appOpt('explorer-verb') + return ( + + + + + + {/* Setup offers this once; this is where you find it afterwards. Windows + owns the choice, so all we can do is open the page it lives on. */} + + + + + ) +} + +export function ExplorerPage(): JSX.Element { + return ( + <> + + + + + + ) +} diff --git a/src/renderer/src/components/settings/MediaPage.tsx b/src/renderer/src/components/settings/MediaPage.tsx new file mode 100644 index 00000000..fe475db7 --- /dev/null +++ b/src/renderer/src/components/settings/MediaPage.tsx @@ -0,0 +1,188 @@ +import type { JSX } from 'react' +import { SettingBlock } from 'prism-term-core/renderer/settings/layout/SettingBlock' +import { SettingRow } from 'prism-term-core/renderer/settings/layout/SettingRow' +import { SettingsSection } from 'prism-term-core/renderer/settings/layout/SettingsSection' +import { TRANSPORT_GROUPS, TRANSPORT_STYLES, type TransportStyle } from '../../lib/transport' +import { DEFAULT_THEME_ID } from '../../lib/viz/styles' +import { + applyPreset, + setBarCycle, + setBarGlow, + setBarMove, + setBarTheme, + setCycle, + setGlow, + setMove, + setTheme, + useViz, + type Preset, + type VizState +} from '../../lib/vizStore' +import { VizPreview } from '../VizPreview' +import { APP_SECTIONS, appOpt, type MediaView } from './appOptions' +import { BlockLabel, CARD_GRID, Tile, TileFooter } from './cards' +import { ColourSection } from './ColourSchemes' +import { iconPath } from './icons' +import { TransportMini } from './TransportMini' + +// MEDIA (2026-10-05, the grouped cards redesign): the Visualizer and the +// Progress bar were two pages with identical colour blocks, so they are one +// page now with a switch in its header, each half short. Which half shows is +// the Settings page's to remember while it is open. + +/** Whether preset `p` matches the current live settings exactly. */ +function isActivePreset(p: Preset, v: VizState): boolean { + return ( + p.style === v.style && + p.height === v.height && + p.pos === v.pos && + p.width === v.width && + p.logo === v.logo && + (p.theme ?? DEFAULT_THEME_ID) === v.theme + ) +} + +function VisualizerView(): JSX.Element { + const v = useViz() + const style = appOpt('viz-style') + return ( + <> + + +
+ {v.presets.map((p) => { + const on = isActivePreset(p, v) + return ( + applyPreset(p)}> +
+ +
+ +
+ ) + })} +
+
+
+ + + ) +} + +function ProgressView({ + transportStyle, + onPickTransport, + transportBg, + onPickTransportBg +}: { + transportStyle: TransportStyle + onPickTransport: (s: TransportStyle) => void + transportBg: number + onPickTransportBg: (pct: number) => void +}): JSX.Element { + const v = useViz() + const style = appOpt('transport-style') + const band = appOpt('transport-bg') + return ( + <> + + +
+ {TRANSPORT_GROUPS.map((g) => { + const items = TRANSPORT_STYLES.filter((s) => s.group === g) + if (!items.length) return null + return ( +
+ {g} +
+ {items.map((s) => { + const on = s.id === transportStyle + return ( + onPickTransport(s.id)}> + + + + ) + })} +
+
+ ) + })} +
+
+
+ + {/* Opaque is the bar as it has always been; all the way down, the + picture runs to the bottom of the frame and the controls carry + their own shadow. The subtext says which, as the slider moves. */} + + {transportBg}% + onPickTransportBg(Number(e.target.value))} + className="h-1.5 w-[180px] cursor-pointer appearance-none rounded-full bg-[var(--p-track)]" + style={{ accentColor: 'var(--p-accent-solid)' }} + /> + + + + + ) +} + +export function MediaPage({ + view, + transportStyle, + onPickTransport, + transportBg, + onPickTransportBg +}: { + view: MediaView + transportStyle: TransportStyle + onPickTransport: (s: TransportStyle) => void + transportBg: number + onPickTransportBg: (pct: number) => void +}): JSX.Element { + return view === 'visualizer' ? ( + + ) : ( + + ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx new file mode 100644 index 00000000..0aad97a1 --- /dev/null +++ b/src/renderer/src/components/settings/Settings.tsx @@ -0,0 +1,167 @@ +import { useEffect, useMemo, useState, type JSX } from 'react' +import { dictationHost } from 'prism-term-core/renderer/host' +import { Segmented } from 'prism-term-core/renderer/settings/fields' +import { SettingsFrame } from 'prism-term-core/renderer/settings/layout/SettingsFrame' +import { DictationPage } from 'prism-term-core/renderer/settings/sections/DictationPage' +import { useTitleBarMode } from '../../lib/titleBarPrefs' +import type { TransportStyle } from '../../lib/transport' +import { useTreeSize } from '../../lib/treePrefs' +import { useStyle } from '../../lib/theme' +import { AboutPage } from './AboutPage' +import { AgentsPage } from './AgentsPage' +import { AppearancePage } from './AppearancePage' +import type { AppPageId, MediaView } from './appOptions' +import { ExplorerPage } from './ExplorerPage' +import { MediaPage } from './MediaPage' +import { SETTINGS_PAGES, settingsIndex } from './settingsIndex' +import { TerminalPage } from './TerminalPage' + +// THE SETTINGS PAGE IS PRISM'S; ITS FRAME, SECTIONS AND ROWS ARE THE CORE'S +// (2026-10-05, the grouped cards redesign, #292; the owner approved v1, +// "Grouped cards", with no accent bar on the chosen rail item). What is +// Prism's: which pages exist and in what order, which section sits on which +// page, its own rows (`appOptions.ts`), the page and Media view memory, the +// compact rail toggle and the zoom. The terminal's and dictation's rows are +// the core's, the same code as Prism Terminal's (#154). + +const MEDIA_VIEWS: Array<{ id: MediaView; name: string }> = [ + { id: 'visualizer', name: 'Visualizer' }, + { id: 'progress', name: 'Progress bar' } +] + +export function Settings({ + open, + onClose, + compactRail, + onShowSetup, + transportStyle, + onPickTransport, + transportBg, + onPickTransportBg +}: { + open: boolean + onClose: () => void + /** The rail collapsed to its icons, from the title-bar button. */ + compactRail: boolean + /** Run the first-run setup again. */ + onShowSetup: () => void + transportStyle: TransportStyle + onPickTransport: (s: TransportStyle) => void + transportBg: number + onPickTransportBg: (pct: number) => void +}): JSX.Element | null { + // ALWAYS MOUNTED, so the page and the Media view are remembered across a + // close and an open (nothing is saved: a new launch opens on Appearance). + const [page, setPage] = useState('appearance') + const [view, setView] = useState('visualizer') + const size = useTreeSize() + const style = useStyle() + // The GPU row is drawn only where an NVIDIA card is found, so it is in the + // index only there too. + const [nvidia, setNvidia] = useState(false) + useEffect(() => { + if (!open) return + let live = true + void dictationHost() + ?.api.dictationInfo() + .then((info) => { + if (live) setNvidia(!!info?.nvidia) + }) + .catch(() => {}) + return () => { + live = false + } + }, [open]) + const index = useMemo(() => settingsIndex(nvidia, style.name), [nvidia, style.name]) + + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + // A MODAL QUESTION OVER THIS PAGE OWNS ESCAPE (2026-09-20, #168; found + // by the updateWindow e2e). The update window can be opened from the + // title bar while Settings is up, and one Escape closed BOTH: every + // Escape listener here sits on the window in the capture phase, where + // stopPropagation does not silence a sibling listener, and this one + // was registered first. It yields by inspection, as App's does. The + // test is the modal dialog itself and NOT `data-owns-escape` at large: + // that attribute is also worn by things UNDER this page (an editor + // whose caret is in the file, a player's open menu), and yielding to + // those would leave Settings with no way to be closed from the keyboard. + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return + // AN OPEN COLOUR PICKER OWNS ESCAPE TOO (the core's ColourPopover): + // there it undoes the picker's writes. This listener is native and + // runs first, and stopping the event here would close the whole page + // and never let the picker hear it. + if ((e.target as Element | null)?.closest?.('[data-colour-popover]')) return + // FIND A SETTING HOLDING TEXT owns Escape (spec 1.3): it clears the + // field, and only an empty field lets Escape close Settings. The core + // marks the field `data-owns-escape` only while it holds text. + if (document.querySelector('[data-settings-find][data-owns-escape]')) return + e.stopPropagation() + onClose() + } + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [open, onClose]) + // The page starts where the chrome ends: under the title bar AND the tab + // row (68px), or under the one row when the title bar is hidden (36px, #250; + // owner, 2026-10-03: "the settings page doesnt move up to cover the gap"). + const titleBar = useTitleBarMode() + + if (!open) return null + return ( + // A full-window settings page under the chrome, not a popup. The frame + // sets the system font whatever the style says: a style's typeface + // belongs to the app you are looking at, and letting it set the type in + // here resizes the page the cards are chosen on. `colorScheme` follows the + // style's mode, so the core's `light-dark()` inks (a warning subtext) + // pick the light branch on a light style: Prism's :root says dark. +
+
+ { + setPage(id as AppPageId) + if (v === 'visualizer' || v === 'progress') setView(v) + }} + index={index} + compact={compactRail} + headerAction={page === 'media' ? : undefined} + > + {page === 'appearance' ? ( + + ) : page === 'explorer' ? ( + + ) : page === 'terminal' ? ( + + ) : page === 'agents' ? ( + + ) : page === 'dictation' ? ( + // THE CORE'S PAGE, whole: the same settings Prism Terminal shows, + // with this app's own values (the model files are shared). + + ) : page === 'media' ? ( + + ) : ( + + )} + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/StyleColours.tsx b/src/renderer/src/components/settings/StyleColours.tsx new file mode 100644 index 00000000..1f5fb058 --- /dev/null +++ b/src/renderer/src/components/settings/StyleColours.tsx @@ -0,0 +1,171 @@ +import type { JSX } from 'react' +import { ColourField } from 'prism-term-core/renderer/settings/ColourPicker' +import { RESET_LINK } from 'prism-term-core/renderer/settings/fields' +import { SettingRow } from 'prism-term-core/renderer/settings/layout/SettingRow' +import { SettingsSection } from 'prism-term-core/renderer/settings/layout/SettingsSection' +import { withAlpha } from 'prism-term-core/renderer/lib/colour' +import { ALPHA_MIN, accentAlphaOf } from '../../lib/accentAlpha' +import { + folderIconOf, + paletteOf, + primaryValue, + PRIMARY_ALPHA_MIN, + resetAccent, + restoreOverrides, + secondaryValue, + selectionValue, + setAccentColour, + setAcrylic, + setOverride, + setPrimary, + setSecondary, + setSelection, + snapPrimaryAlpha, + TINT_MIN, + useOverrides, + useStyle +} from '../../lib/theme' +import { appOpt } from './appOptions' +import { iconPath } from './icons' +import { coloursTitle } from './settingsIndex' + +/** + * One colour of the style, as a row: the core's picker (ONE COLOUR PICKER, + * owner 2026-10-03: "the colour pickers should be the same for both apps, i + * need an input field for a color code and an alpha per colour on every + * colour setting"), with a Reset word beside it while the colour is the + * user's. The code field commits only a typed colour that differs from the one + * shown, so tabbing through a row writes nothing; Escape in the picker puts + * the row back as it was when it opened (`onRevert`), an unset row included. + */ +function StyleColour({ + id, + value, + custom, + onChange, + onReset, + onRevert, + alphaMin, + snapAlpha +}: { + id: string + value: string + custom: boolean + onChange: (stored: string) => void + onReset: () => void + onRevert: () => void + alphaMin?: number + snapAlpha?: (a: number) => number +}): JSX.Element { + const o = appOpt(id) + return ( + +
+ {custom && ( + + )} + +
+
+ ) +} + +/** + * THE STYLE'S COLOURS, BY IMPORTANCE (owner, 2026-10-03: "make the most + * important colours appear first ... primary and secondary first then + * accent"): the viewer's ground, the panels round it, the accent, the + * selection that came out of it, the text, the folder icons. Headed by the + * style they edit ("Colours of Aurora"), since every edit here is an edit of + * that style until it is saved as a preset. + */ +export function StyleColoursSection(): JSX.Element { + const style = useStyle() + const edits = useOverrides() + const accentAlpha = accentAlphaOf(style.accentAlpha) + // What a picker's Escape puts back: the draft as it is at this render, which + // is the one a popover opening now captures. + const putBack = (keys: Array) => () => restoreOverrides(edits, keys) + return ( + + {/* PRIMARY'S ALPHA IS THE OLD ACRYLIC SLIDER (owner, 2026-10-03, + decision 1): below 100 the window is glass at the level that alpha + paints (a mica style stays mica), at 100 it is solid. A hue edit + never touches the glass. */} + { + setOverride('bg', null) + setAcrylic(null) + }} + onRevert={putBack(['bg', 'acrylic'])} + alphaMin={PRIMARY_ALPHA_MIN} + snapAlpha={snapPrimaryAlpha} + /> + {/* ONE PICK for the sidebar, the title bar and the tab bar (owner, + 2026-09-03). Its alpha follows Primary's until it is moved, and is + its own after (owner, 2026-10-03, decision 2). */} + setOverride('chrome', null)} + onRevert={putBack(['side', 'title', 'tabs'])} + /> + {/* The accent is a colour you choose, not a scheme you browse; its + alpha is for fills (#249), so an alpha of its own is an edit of the + accent too, and the one Reset gives back both. */} + + {/* THE SELECTION IS ITS OWN ROW (#257): its colour and alpha ARE the + tint of marked files and the current place. Unset it shows the + accent's tint, and nothing is stored until a pick; below a tenth a + mark stops reading as one, hence the floor. */} + setOverride('selection', null)} + onRevert={putBack(['selection'])} + alphaMin={TINT_MIN} + /> + setOverride('text', v)} + onReset={() => setOverride('text', null)} + onRevert={putBack(['text'])} + /> + setOverride('folderIcon', v)} + onReset={() => setOverride('folderIcon', null)} + onRevert={putBack(['folderIcon'])} + /> + + ) +} diff --git a/src/renderer/src/components/settings/StyleWall.tsx b/src/renderer/src/components/settings/StyleWall.tsx new file mode 100644 index 00000000..e678551f --- /dev/null +++ b/src/renderer/src/components/settings/StyleWall.tsx @@ -0,0 +1,60 @@ +import type { JSX } from 'react' +import { Glyph } from 'prism-term-core/renderer/settings/layout/Glyph' +import { deletePreset, setStyle, useMode, useSelectedId, useStyle, useStyles } from '../../lib/theme' +import { StyleMini } from '../StyleMini' +import { CARD_GRID, Tile, TileFooter } from './cards' + +/** + * THE STYLE WALL: the styles of the current mode, then the saved presets. + * Once a colour is changed nothing here is selected but the card being + * edited: what is on screen is no longer any of these, and clicking one is + * how you go back to it. + */ +export function StyleWall(): JSX.Element { + const style = useStyle() + const mode = useMode() + const selected = useSelectedId() + const list = useStyles(mode) + return ( +
+ {list.map((st) => { + // The CURRENT style's card is live: it renders the edited style, so + // turning Void white turns its card white with it. It also stays + // selected through an edit - the user reads it as "my theme", and a + // wall with nothing selected read as a bug. Clicking it while edited + // does nothing (setStyle would silently revert the edits); every + // other card still shows its saved self and gives what it shows. + const live = st.id === style.id + const on = st.id === (selected ?? style.id) + return ( + { + if (live && selected === null) return + setStyle(st.id) + }} + > + +
+ + {st.custom && ( + + )} +
+
+ ) + })} +
+ ) +} diff --git a/src/renderer/src/components/settings/TerminalPage.tsx b/src/renderer/src/components/settings/TerminalPage.tsx new file mode 100644 index 00000000..43e7fe83 --- /dev/null +++ b/src/renderer/src/components/settings/TerminalPage.tsx @@ -0,0 +1,24 @@ +import type { JSX } from 'react' +import { ShellSection } from 'prism-term-core/renderer/settings/sections/ShellSection' +import { TerminalTextSection } from 'prism-term-core/renderer/settings/sections/TerminalTextSection' +import { TerminalThemeSection } from 'prism-term-core/renderer/settings/sections/TerminalThemeSection' + +/** + * TERMINAL: THE CORE'S SECTIONS, AND NOTHING ELSE (#154; 2026-10-05, the + * grouped cards redesign). Every option, its name, type and behaviour, is the + * same code here and in Prism Terminal (owner, 2026-09-19: "the setting names, + * types, how they function and so on should be the same"); only the personal + * VALUES differ. Prism's window wears its own style, so the terminal theme is + * this page's (Q2), with no rows of the window's under the wall. No command + * help (owner, 2026-09-22: it is Prism Terminal's). `data-terminal-settings` + * is what the gate's `termOptions` reads. + */ +export function TerminalPage(): JSX.Element { + return ( +
+ + + +
+ ) +} diff --git a/src/renderer/src/components/settings/TransportMini.tsx b/src/renderer/src/components/settings/TransportMini.tsx new file mode 100644 index 00000000..19400251 --- /dev/null +++ b/src/renderer/src/components/settings/TransportMini.tsx @@ -0,0 +1,142 @@ +import type { JSX } from 'react' +import type { TransportStyle } from '../../lib/transport' + +/** A small schematic of each transport style, so the picker previews the shape + * without spinning up a real player. */ +export function TransportMini({ id }: { id: TransportStyle }): JSX.Element { + const acc = 'var(--p-accent-hi)' + const box = + 'relative h-11 w-full overflow-hidden rounded-md border border-[color:var(--p-divider)] bg-[var(--p-preview)]' + const dot = + const bars = (n: number, h: number, gap: string, bold = false): JSX.Element => ( +
+ {Array.from({ length: n }).map((_, i) => ( + + ))} +
+ ) + const line = (h: number, glow = false): JSX.Element => ( +
+
+
+ ) + + switch (id) { + case 'edge': + return ( +
+
+ {dot} + controls +
+
{line(2, true)}
+
+ ) + case 'pill': + return ( +
+ {line(7)} +
+ {dot} + {dot} +
+
+ ) + case 'inline': + return ( +
+ {dot} +
{line(3)}
+ {dot} +
+ ) + case 'island': + return ( +
+
+ {dot} +
{line(3)}
+
+
+ ) + case 'wave': + return ( +
+
{bars(52, 16, 'gap-[1.5px]')}
+
+ {dot} + {dot} +
+
+ ) + case 'outline': + return ( +
+
{line(2, true)}
+
+ + +
+
+ ) + case 'bold': + return ( +
+
{line(4)}
+
+ + 0:41 +
+
+ ) + case 'segments': + return ( +
+
+ {Array.from({ length: 16 }).map((_, i) => ( + + ))} +
+
+ {dot} + {dot} +
+
+ ) + case 'wavebold': + return ( +
+
{bars(40, 20, 'gap-[2px]', true)}
+
+ + 0:41 +
+
+ ) + case 'slim': + default: + return ( +
+
{line(3)}
+
+ {dot} + {dot} +
+
+ ) + } +} diff --git a/src/renderer/src/components/WinEShortcutSetting.tsx b/src/renderer/src/components/settings/WinEShortcutRow.tsx similarity index 59% rename from src/renderer/src/components/WinEShortcutSetting.tsx rename to src/renderer/src/components/settings/WinEShortcutRow.tsx index 74676681..a5adb0c0 100644 --- a/src/renderer/src/components/WinEShortcutSetting.tsx +++ b/src/renderer/src/components/settings/WinEShortcutRow.tsx @@ -1,8 +1,20 @@ import type { WinEShortcutStatus } from '@shared/winEShortcut' import { useEffect, useRef, useState, type JSX } from 'react' +import { SettingRow } from 'prism-term-core/renderer/settings/layout/SettingRow' +import { appOpt } from './appOptions' +import { iconPath } from './icons' -/** Windows owns this preference; never show an optimistic or locally cached value. */ -export function WinEShortcutSetting(): JSX.Element { +/** + * OPEN IN PLACE OF FILE EXPLORER (Win+E), as a row. Windows owns this + * preference; never show an optimistic or locally cached value. What Windows + * says about it is the row's SUBTEXT (2026-10-05, the grouped cards redesign: + * live state lives in the subtext, so a sighted user and a screen reader get + * the same words), and a live region carries it only while there is one. + * + * The switch keeps the ACCENT when on, the one switch in Settings that does + * (#202's existing exception): it hands a Windows shortcut to Prism. + */ +export function WinEShortcutRow(): JSX.Element { const [status, setStatus] = useState({ enabled: false, running: false, @@ -52,35 +64,33 @@ export function WinEShortcutSetting(): JSX.Element { }) } + const said = busy + ? 'Checking with Windows.' + : status.error || + (status.conflict + ? 'Another Prism installation or profile controls this shortcut.' + : status.enabled && !status.running + ? 'The shortcut helper is not running.' + : '') + const o = appOpt('win-e-shortcut') return ( -
-
- -

- Opens Prism in place of File Explorer from the Windows shortcut. A small helper - starts with Windows to do this. -

-

- {busy - ? 'Checking with Windows.' - : status.error || - (status.conflict - ? 'Another Prism installation or profile controls this shortcut.' - : status.enabled && !status.running - ? 'The shortcut helper is not running.' - : '')} -

-
+ + {said && ( + + {said} + + )} -
+ ) } diff --git a/src/renderer/src/components/settings/appOptions.test.ts b/src/renderer/src/components/settings/appOptions.test.ts new file mode 100644 index 00000000..65d86d6c --- /dev/null +++ b/src/renderer/src/components/settings/appOptions.test.ts @@ -0,0 +1,122 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { DICTATION_OPTIONS } from 'prism-term-core/renderer/settings/dictationOptions' +import { TERMINAL_OPTIONS } from 'prism-term-core/renderer/settings/options' +import { APP_OPTIONS, APP_SECTIONS } from './appOptions' +import { isIconName } from './icons' +import { ROW_ORDER, SETTINGS_PAGES, settingsIndex } from './settingsIndex' + +// Prism's command help is Prism Terminal's alone (owner, 2026-09-22), so the +// core's help list is not Prism's to show. +const CORE = [...TERMINAL_OPTIONS, ...DICTATION_OPTIONS] + +describe("Prism's own settings rows", () => { + it('have unique ids, none of them a core row', () => { + const ids = APP_OPTIONS.map((o) => o.id) + expect(new Set(ids).size).toBe(ids.length) + for (const id of ids) expect(CORE.some((c) => c.id === id), id).toBe(false) + }) + + it('name a known icon, page and section', () => { + const pages = SETTINGS_PAGES.map((p) => p.id) + for (const o of APP_OPTIONS) { + expect(isIconName(o.icon), o.icon).toBe(true) + expect(pages, o.id).toContain(o.page) + expect(Object.keys(APP_SECTIONS), o.id).toContain(o.section) + expect(!!o.view, `${o.id} has a Media view exactly when it is on Media`).toBe(o.page === 'media') + } + }) + + it('carry a subtext, but for a wall or a grid', () => { + for (const o of APP_OPTIONS) { + const block = ['viz-style', 'viz-colour', 'transport-style', 'transport-colour'].includes(o.id) + expect(o.sub === '', o.id).toBe(block) + } + }) + + // NO STORAGE KEY CHANGES (2026-10-05, spec 1.1): a key is a saved setting. + it('keep every storage key they have always had', () => { + expect(APP_OPTIONS.map((o) => `${o.id}=${Array.isArray(o.store) ? o.store.join('+') : String(o.store)}`)).toMatchInlineSnapshot(` + [ + "mode=prism.mode", + "style-theme=prism.style+prism.style.presets", + "c-bg=prism.style.draft", + "c-chrome=prism.style.draft", + "c-accent=prism.style.draft", + "c-selection=prism.style.draft", + "c-text=prism.style.draft", + "c-folder-icon=prism.style.draft", + "c-font=prism.style.draft", + "tree-size=prism.tree.size", + "title-bar=prism.window.titleBar", + "tab-width=prism.window.tabWidth", + "c-edges=prism.style.draft", + "c-corners=prism.style.draft", + "tree-side=prism.tree.side", + "explorer-size=prism.explorer.size", + "auto-scroll=prism.tree.autoscroll", + "newtab-mode=prism.newtab.mode+prism.newtab.folder", + "newtab-show=prism.newtab.show", + "open-external=prism.open.external", + "remember-tabs=prism.tabs.remember", + "remember-folders=prism.explorer.rememberFolders", + "win-e-shortcut=windows", + "explorer-verb=windows", + "default-apps=windows", + "viz-style=prism.viz.style+prism.viz.presets", + "viz-colour=prism.viz.theme", + "viz-glow=prism.viz.glow", + "viz-cycle=prism.viz.cycle", + "viz-move=prism.viz.move", + "transport-style=prism.transport.style", + "transport-bg=prism.transport.bg", + "transport-colour=prism.viz.barTheme", + "transport-glow=prism.viz.barGlow", + "transport-cycle=prism.viz.barCycle", + "transport-move=prism.viz.barMove", + "app-version=null", + "show-setup=null", + ] + `) + }) + + it('are each drawn by a page, by a literal row id', () => { + const pages = readdirSync(__dirname) + .filter((f) => /\.tsx$/.test(f) && !/\.test\.tsx$/.test(f)) + .map((f) => readFileSync(join(__dirname, f), 'utf8')) + .join('\n') + const drawn = new Set( + [...pages.matchAll(/ m[1] ?? m[2] ?? m[3] ?? m[4] + ) + ) + expect([...drawn].filter((id) => APP_OPTIONS.some((o) => o.id === id)).sort()).toEqual(APP_OPTIONS.map((o) => o.id).sort()) + }) +}) + +describe('Find a setting', () => { + it('orders every row once, core and own', () => { + expect([...ROW_ORDER].sort()).toEqual([...CORE, ...APP_OPTIONS].map((o) => o.id).sort()) + }) + + it('indexes what is drawn on this PC: the GPU row only with an NVIDIA card, never command help', () => { + expect(settingsIndex(true, 'Aurora').map((e) => e.id)).toEqual([...ROW_ORDER]) + expect(settingsIndex(false, 'Aurora').map((e) => e.id)).toEqual(ROW_ORDER.filter((id) => id !== 'dictation-gpu')) + expect(settingsIndex(true, 'Aurora').some((e) => e.id === 'help-enabled')).toBe(false) + }) + + it('says where each row lives, the style colours by the style', () => { + const at = Object.fromEntries(settingsIndex(true, 'Ruby').map((e) => [e.id, `${e.page}/${e.section}${e.view ? `/${e.view}` : ''}`])) + expect(at['c-accent']).toBe('appearance/Colours of Ruby') + expect(at['term-theme']).toBe('terminal/Theme') + expect(at['term-shell']).toBe('terminal/Shell') + expect(at['agent-hooks']).toBe('agents/Claude Code') + expect(at['agent-color']).toBe('agents/Mark colours') + expect(at['win-e-shortcut']).toBe('explorer/Windows') + expect(at['transport-bg']).toBe('media/Behind the controls/progress') + expect(at['viz-glow']).toBe('media/Visualizer colour/visualizer') + expect(at['dictation-enabled']).toBe('dictation/') + expect(at['app-version']).toBe('about/') + }) +}) diff --git a/src/renderer/src/components/settings/appOptions.ts b/src/renderer/src/components/settings/appOptions.ts new file mode 100644 index 00000000..4b3bc58b --- /dev/null +++ b/src/renderer/src/components/settings/appOptions.ts @@ -0,0 +1,99 @@ +/** + * PRISM'S OWN SETTINGS ROWS, a closed list (2026-10-05, the grouped cards + * redesign, #292). The terminal's and dictation's rows are prism-term-core's + * lists; these are the rows about Prism itself: its style, the Explorer, the + * player, About. The `termOptions` e2e asserts the Terminal and Agents pages + * show the core's lists and nothing else, and `settingsSearch` opens every + * row here by its label: a row in neither list is a fork. + * + * `section` is where the row is drawn (`APP_SECTIONS`), `view` the Media + * page's half that holds it, and `store` where the value lives: the + * localStorage keys, `windows` (Windows keeps it: the Explorer menu, the Win+E + * helper, the default apps), or null for a row that stores nothing. The keys + * are a SNAPSHOT in `appOptions.test.ts`: a key is a saved setting, and this + * redesign changes none. One line per entry, as in the core's lists; a block + * (a wall of cards, a grid of swatches) has no subtext of its own. + */ +export type AppPageId = 'appearance' | 'explorer' | 'terminal' | 'agents' | 'dictation' | 'media' | 'about' +export type MediaView = 'visualizer' | 'progress' + +export interface AppOption { + id: string + label: string + sub: string + section: keyof typeof APP_SECTIONS + page: AppPageId + view?: MediaView + icon: string + keywords?: string + store: readonly string[] | 'windows' | null +} + +/** This app's own section headings, by `data-settings-section`. The style's + * colours are headed by the style's name on the page ("Colours of Aurora"). */ +export const APP_SECTIONS = { + 'style-theme': 'Theme', + 'style-colours': 'Colours', + 'app-text': 'Text', + window: 'Window', + layout: 'Layout', + opening: 'Opening things', + starts: 'When Prism starts', + windows: 'Windows', + 'viz-style': 'Visualizer style', + 'viz-colour': 'Visualizer colour', + 'transport-style': 'Progress bar style', + behind: 'Behind the controls', + 'transport-colour': 'Progress bar colour', + about: '' +} as const + +const DRAFT = ['prism.style.draft'] as const + +export const APP_OPTIONS: readonly AppOption[] = [ + { id: 'mode', label: 'Colour mode', sub: 'Dark and light each keep their own style.', section: 'style-theme', page: 'appearance', icon: 'mode', keywords: 'dark light night day scheme', store: ['prism.mode'] }, + { id: 'style-theme', label: 'App theme', sub: 'Edits below change the chosen style.', section: 'style-theme', page: 'appearance', icon: 'brush', keywords: 'style preset look aurora onyx void ruby save', store: ['prism.style', 'prism.style.presets'] }, + { id: 'c-bg', label: 'Viewer background', sub: 'Behind the file you are viewing.', section: 'style-colours', page: 'appearance', icon: 'viewer', keywords: 'primary ground glass acrylic transparent alpha color', store: DRAFT }, + { id: 'c-chrome', label: 'Sidebar and tab bar colour', sub: 'Also used for the title bar.', section: 'style-colours', page: 'appearance', icon: 'sidebar', keywords: 'secondary panel chrome color', store: DRAFT }, + { id: 'c-accent', label: 'Accent colour', sub: 'Buttons, progress, visualizer and chosen cards.', section: 'style-colours', page: 'appearance', icon: 'accent', keywords: 'highlight color alpha', store: DRAFT }, + { id: 'c-selection', label: 'Selection colour', sub: 'Tint of selected files and places.', section: 'style-colours', page: 'appearance', icon: 'select', keywords: 'marked highlight tint color', store: DRAFT }, + { id: 'c-text', label: 'Text colour', sub: 'File names, labels and readouts.', section: 'style-colours', page: 'appearance', icon: 'text', keywords: 'ink foreground color', store: DRAFT }, + { id: 'c-folder-icon', label: 'Folder icon colour', sub: 'Folder icons in the file tree.', section: 'style-colours', page: 'appearance', icon: 'folder', keywords: 'folders zip color', store: DRAFT }, + { id: 'c-font', label: 'App font', sub: 'The typeface used across the app.', section: 'app-text', page: 'appearance', icon: 'font', keywords: 'typeface face family', store: DRAFT }, + { id: 'tree-size', label: 'Interface text size', sub: 'Sidebar and settings text.', section: 'app-text', page: 'appearance', icon: 'size', keywords: 'font zoom bigger smaller scale', store: ['prism.tree.size'] }, + { id: 'title-bar', label: 'Show title bar', sub: 'When off, tabs share the top row.', section: 'window', page: 'appearance', icon: 'titlebar', keywords: 'caption top frame hide hidden', store: ['prism.window.titleBar'] }, + { id: 'tab-width', label: 'Tab width', sub: 'Sized to the name, or all equal.', section: 'window', page: 'appearance', icon: 'tabs', keywords: 'size wide narrow equal fixed dynamic', store: ['prism.window.tabWidth'] }, + { id: 'c-edges', label: 'Panel edges', sub: 'Lines between panels and around the window.', section: 'window', page: 'appearance', icon: 'edges', keywords: 'border lines hairline outline faint strong', store: DRAFT }, + { id: 'c-corners', label: 'Corner roundness', sub: 'How round the larger surfaces are.', section: 'window', page: 'appearance', icon: 'corners', keywords: 'radius square soft round', store: DRAFT }, + { id: 'tree-side', label: 'Sidebar position', sub: 'The side the file tree sits on.', section: 'layout', page: 'explorer', icon: 'sidebar', keywords: 'left right tree panel', store: ['prism.tree.side'] }, + { id: 'explorer-size', label: 'Explorer row size', sub: 'Row height, with text and icons.', section: 'layout', page: 'explorer', icon: 'rows', keywords: 'small medium large density compact', store: ['prism.explorer.size'] }, + { id: 'auto-scroll', label: 'Scroll to the open file', sub: 'The tree follows the file you view.', section: 'layout', page: 'explorer', icon: 'follow', keywords: 'auto scroll follow reveal', store: ['prism.tree.autoscroll'] }, + { id: 'newtab-mode', label: 'Folder for new tabs', sub: 'Where a new tab starts.', section: 'opening', page: 'explorer', icon: 'newtab', keywords: 'home directory start ask chosen', store: ['prism.newtab.mode', 'prism.newtab.folder'] }, + { id: 'newtab-show', label: 'First view of a new project', sub: 'What a folder opened as a project shows.', section: 'opening', page: 'explorer', icon: 'project', keywords: 'project terminal browser first file', store: ['prism.newtab.show'] }, + { id: 'open-external', label: 'View for files from Windows', sub: 'How files opened from Windows appear.', section: 'opening', page: 'explorer', icon: 'file', keywords: 'preview full view double click open with', store: ['prism.open.external'] }, + { id: 'remember-tabs', label: 'Reopen tabs at start', sub: 'Brings back the tabs from last time.', section: 'starts', page: 'explorer', icon: 'history', keywords: 'restore remember session startup', store: ['prism.tabs.remember'] }, + { id: 'remember-folders', label: 'Remember recent folders', sub: 'Kept only on this PC.', section: 'starts', page: 'explorer', icon: 'clock', keywords: 'cache listing clear history', store: ['prism.explorer.rememberFolders'] }, + { id: 'win-e-shortcut', label: 'Open in place of File Explorer', sub: 'A small helper starts with Windows.', section: 'windows', page: 'explorer', icon: 'win', keywords: 'shortcut replace file explorer hotkey', store: 'windows' }, + { id: 'explorer-verb', label: 'Add to the Explorer menu', sub: 'Open files and folders in Prism.', section: 'windows', page: 'explorer', icon: 'menu', keywords: 'context menu right click open with', store: 'windows' }, + { id: 'default-apps', label: 'Default app for file types', sub: 'Windows keeps this choice.', section: 'windows', page: 'explorer', icon: 'filecheck', keywords: 'associations default viewer open with', store: 'windows' }, + { id: 'viz-style', label: 'Visualizer style', sub: '', section: 'viz-style', page: 'media', view: 'visualizer', icon: 'media', keywords: 'music audio shape bars halo preset', store: ['prism.viz.style', 'prism.viz.presets'] }, + { id: 'viz-colour', label: 'Visualizer colour', sub: '', section: 'viz-colour', page: 'media', view: 'visualizer', icon: 'droplet', keywords: 'color solid gradient palette', store: ['prism.viz.theme'] }, + { id: 'viz-glow', label: 'Glow', sub: 'A soft glow around the shapes.', section: 'viz-colour', page: 'media', view: 'visualizer', icon: 'glow', keywords: 'visualizer effect shine', store: ['prism.viz.glow'] }, + { id: 'viz-cycle', label: 'Cycle', sub: 'The colours shift hue over time.', section: 'viz-colour', page: 'media', view: 'visualizer', icon: 'cycle', keywords: 'visualizer effect hue rotate', store: ['prism.viz.cycle'] }, + { id: 'viz-move', label: 'Move', sub: 'The colours slide across over time.', section: 'viz-colour', page: 'media', view: 'visualizer', icon: 'move', keywords: 'visualizer effect scroll slide', store: ['prism.viz.move'] }, + { id: 'transport-style', label: 'Progress bar style', sub: '', section: 'transport-style', page: 'media', view: 'progress', icon: 'progress', keywords: 'seek bar player controls transport', store: ['prism.transport.style'] }, + { id: 'transport-bg', label: 'Control band opacity', sub: 'How solid the band behind the controls is.', section: 'behind', page: 'media', view: 'progress', icon: 'band', keywords: 'background transparent player video', store: ['prism.transport.bg'] }, + { id: 'transport-colour', label: 'Progress bar colour', sub: '', section: 'transport-colour', page: 'media', view: 'progress', icon: 'droplet', keywords: 'color solid gradient seek bar', store: ['prism.viz.barTheme'] }, + { id: 'transport-glow', label: 'Glow', sub: 'A soft glow around the shapes.', section: 'transport-colour', page: 'media', view: 'progress', icon: 'glow', keywords: 'progress bar effect shine', store: ['prism.viz.barGlow'] }, + { id: 'transport-cycle', label: 'Cycle', sub: 'The colours shift hue over time.', section: 'transport-colour', page: 'media', view: 'progress', icon: 'cycle', keywords: 'progress bar effect hue rotate', store: ['prism.viz.barCycle'] }, + { id: 'transport-move', label: 'Move', sub: 'The colours slide across over time.', section: 'transport-colour', page: 'media', view: 'progress', icon: 'move', keywords: 'progress bar effect scroll slide', store: ['prism.viz.barMove'] }, + { id: 'app-version', label: 'Version', sub: 'The version you are running.', section: 'about', page: 'about', icon: 'version', keywords: 'update release number', store: null }, + { id: 'show-setup', label: 'Setup guide', sub: 'The first run steps, from the start.', section: 'about', page: 'about', icon: 'setup', keywords: 'onboarding welcome first run again', store: null } +] + +/** One of this app's rows by id. Throws on a typo, which the tests find. */ +export function appOpt(id: string): AppOption { + const o = APP_OPTIONS.find((a) => a.id === id) + if (!o) throw new Error(`no app settings option ${id}`) + return o +} diff --git a/src/renderer/src/components/settings/cards.tsx b/src/renderer/src/components/settings/cards.tsx new file mode 100644 index 00000000..410b5bb4 --- /dev/null +++ b/src/renderer/src/components/settings/cards.tsx @@ -0,0 +1,64 @@ +import type { JSX, ReactNode } from 'react' + +// THE PICKER CARDS of Prism's own walls: the styles, the visualizer's presets, +// the progress bar's styles. A card is a selection mark, not a button, so the +// chosen one keeps the accent (#202: "selection marks ... keep the accent"). + +/** A wall of cards, as the v1 mockup draws it (2026-10-05): the track flexes + * from 124px and a row drops a column only when it runs out of room. The + * cap on the CARD keeps three cards from stretching across a wide window + * (with two definite lengths auto-fill counts columns by the max, so the + * cap cannot live on the track). */ +export const CARD_GRID = 'grid grid-cols-[repeat(auto-fill,minmax(124px,1fr))] gap-2.5 [&>*]:max-w-[220px]' + +/** A sub-heading inside a block ("Solid", "Line"): words, not a section. */ +export function BlockLabel({ children }: { children: ReactNode }): JSX.Element { + return
{children}
+} + +/** A selectable card. A div rather than a button so a card can carry its own + * controls (a preset's delete). */ +export function Tile({ + on, + onClick, + children +}: { + on: boolean + onClick: () => void + children: ReactNode +}): JSX.Element { + return ( +
{ + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + }} + // The chosen card's wash is from the accent as picked, so the accent's + // alpha (a choice about fills) never fades the mark that says "this one". + className={`group relative flex cursor-pointer flex-col gap-[7px] rounded-[var(--p-radius)] border p-[7px] text-left transition ${ + on + ? 'border-[var(--p-accent-solid)] bg-[var(--p-accent-solid)]/12 shadow-[0_0_0_2px_var(--p-accent-solid)]' + : 'border-[color:var(--p-divider)] bg-[var(--p-hover)] hover:border-[color:var(--p-dim2)]' + }`} + > + {children} +
+ ) +} + +/** The card's name, and nothing else: the ring already says which card is + * chosen, so a "Selected" caption is the same fact written twice. */ +export function TileFooter({ name, on }: { name: string; on: boolean }): JSX.Element { + return ( + + {name} + + ) +} diff --git a/src/renderer/src/components/settings/icons.ts b/src/renderer/src/components/settings/icons.ts new file mode 100644 index 00000000..0c5b0a35 --- /dev/null +++ b/src/renderer/src/components/settings/icons.ts @@ -0,0 +1,50 @@ +import { isSettingIcon } from 'prism-term-core/renderer/settings/layout/icons' + +/** + * PRISM'S OWN SETTINGS ICONS (2026-10-05, the grouped cards redesign). The + * core's set (`layout/icons.ts`) draws every row both apps share; these are + * the pictures only Prism's rows need, from the approved v1 mockup's set, in + * the same grammar: one 24 unit stroke path, drawn at 16px with a 1.7 stroke. + * A row names its icon; `iconPath` hands the core's `Glyph` either the core's + * name or one of these paths. + */ +export const PRISM_ICONS = { + explorer: '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.5z', + media: 'M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2', + mode: 'M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z', + droplet: 'M12 3.5s6 6.2 6 10.5a6 6 0 0 1-12 0c0-4.3 6-10.5 6-10.5z', + sidebar: 'M4 5h16v14H4zM9 5v14', + select: 'M4 4h7v7H4zM13 13h7v7h-7zM13 4h7v7h-7z', + text: 'M5 7V5h14v2M12 5v14M9 19h6', + folder: '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.5z', + corners: 'M4 20V11a7 7 0 0 1 7-7h9', + rows: 'M4 6h16M4 12h16M4 18h16', + follow: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z', + project: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', + file: 'M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5', + history: 'M4 12a8 8 0 1 0 2.3-5.6M4 4v4h4M12 8v4l3 2', + clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7v5l3 2', + win: 'M4 5.5l7-1v7H4zM13 4.2l7-1.2v8.5h-7zM4 13h7v6.5l-7-1zM13 13h7v8l-7-1.2z', + filecheck: 'M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5M9 14l2 2 4-4', + band: 'M4 5h16v14H4zM4 15h16', + glow: 'M12 4l1.8 4.6L18 10l-4.2 1.4L12 16l-1.8-4.6L6 10l4.2-1.4z', + cycle: 'M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3M18 3v4h-4M6 21v-4h4', + move: 'M4 12h16M16 8l4 4-4 4', + setup: 'M4 12a8 8 0 1 1 2.3 5.7M4 18v-4h4', + progress: 'M4 12h16M8 12a2 2 0 1 0 4 0 2 2 0 1 0-4 0' +} as const + +export type PrismIconName = keyof typeof PRISM_ICONS + +const own = (name: string): name is PrismIconName => Object.prototype.hasOwnProperty.call(PRISM_ICONS, name) + +/** Whether a name is drawable: the core's set or Prism's. */ +export const isIconName = (name: string): boolean => isSettingIcon(name) || own(name) + +/** What `Glyph` and the search are handed: a core icon by name, else Prism's + * path. An unknown name throws, which the unit suite then finds. */ +export function iconPath(name: string): string { + if (isSettingIcon(name)) return name + if (own(name)) return PRISM_ICONS[name] + throw new Error(`no settings icon ${name}`) +} diff --git a/src/renderer/src/components/settings/settingsControls.test.ts b/src/renderer/src/components/settings/settingsControls.test.ts new file mode 100644 index 00000000..7f0e9744 --- /dev/null +++ b/src/renderer/src/components/settings/settingsControls.test.ts @@ -0,0 +1,42 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +// SETTINGS CONTROLS ARE NEUTRAL AND ONLY SAVE WEARS THE ACCENT (owner, +// 2026-09-23, #202). Since the grouped cards redesign (2026-10-05, #292) the +// controls are prism-term-core's (`renderer/settings/fields.tsx`, held there by +// its own `neutralControls.test.ts`), so this holds Prism's pages to USING +// them: no local copy of a control, and the accent only on what is a mark, +// not a button. Read as source. +const files = readdirSync(__dirname) + .filter((f) => /\.tsx?$/.test(f) && !/\.test\.tsx?$/.test(f)) + .map((f) => ({ f, src: readFileSync(join(__dirname, f), 'utf8').replace(/\r\n/g, '\n') })) +const ACCENT = /--p-(accent|accent-hi|accent-solid|on-accent|sel-bg|sel-tint|sel-line)\b/ + +describe('settings controls', () => { + it("are the core's: no page keeps a copy of its own", () => { + const local = files.flatMap(({ f, src }) => + [...src.matchAll(/\bfunction (Switch|Segmented|Select|SaveButton|Pref|Section|ThemeHead)\b|\bconst (ROW_BUTTON|SEGMENT_ON|SWITCH_ON|SWITCH_KNOB_ON|ROWS)\b/g)].map( + (m) => `${f}: ${m[0]}` + ) + ) + expect(local).toEqual([]) + expect(files.some(({ src }) => /from 'prism-term-core\/renderer\/settings\/fields'/.test(src))).toBe(true) + }) + + // Still accented, since they are not buttons: the chosen card's ring, the + // chosen swatch's ring, the band slider's thumb, the progress bars drawn + // inside a style's preview card, and the Win+E switch (its existing + // exception: it hands a Windows shortcut to Prism). + it('the accent is only on marks, never on a button', () => { + const allowed = ['cards.tsx', 'ColourSchemes.tsx', 'MediaPage.tsx', 'TransportMini.tsx', 'WinEShortcutRow.tsx'] + const worn = files.filter(({ src }) => ACCENT.test(src)).map(({ f }) => f) + expect(worn.filter((f) => !allowed.includes(f))).toEqual([]) + }) + + it('Default apps and Clear are row buttons', () => { + const explorer = files.find(({ f }) => f === 'ExplorerPage.tsx')!.src + expect(explorer).toMatch(/id="default-apps"[\s\S]{0,120}className=\{ROW_BUTTON\}/) + expect(explorer).toMatch(/id="remember-folders-clear"\s+className=\{ROW_BUTTON\}/) + }) +}) diff --git a/src/renderer/src/components/settings/settingsCopy.test.ts b/src/renderer/src/components/settings/settingsCopy.test.ts new file mode 100644 index 00000000..a6dedce6 --- /dev/null +++ b/src/renderer/src/components/settings/settingsCopy.test.ts @@ -0,0 +1,36 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + copyProblem, + labelProblem, + settingsDescriptions, + settingsListCopy, + subTooLong +} from 'prism-term-core/shared/settingsCopy' + +// Settings descriptions are plain words (owner, 2026-09-22). The core holds the +// rule and checks its own rows; this holds Prism's own pages to the same one. +// Every file of this folder is the grouped cards page (2026-10-05, #292), so +// the eight word limit holds for all of it. +const files = readdirSync(__dirname) + .filter((f) => /\.tsx?$/.test(f) && !/\.test\.tsx?$/.test(f)) + .map((f) => ({ f, src: readFileSync(join(__dirname, f), 'utf8') })) + +describe("Prism's settings", () => { + it('describe every setting in plain words', () => { + const found = files.flatMap(({ f, src }) => settingsDescriptions(src).map((text) => ({ f, text }))) + expect(found.length).toBeGreaterThan(3) + expect(found.map((d) => ({ ...d, problem: copyProblem(d.text) })).filter((d) => d.problem)).toEqual([]) + }) + + it('keep each subtext to one short line, and each label plain', () => { + const subs = files.flatMap(({ src }) => [...settingsDescriptions(src), ...settingsListCopy(src).subs]).filter(Boolean) + const labels = files.flatMap(({ src }) => settingsListCopy(src).labels) + expect(subs.length).toBeGreaterThan(30) + expect(labels.length).toBeGreaterThan(30) + expect(subs.filter(subTooLong)).toEqual([]) + expect(subs.filter((t) => copyProblem(t))).toEqual([]) + expect(labels.filter((t) => labelProblem(t))).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/settings/settingsIndex.ts b/src/renderer/src/components/settings/settingsIndex.ts new file mode 100644 index 00000000..4e4d47a5 --- /dev/null +++ b/src/renderer/src/components/settings/settingsIndex.ts @@ -0,0 +1,80 @@ +import { coreSettingsIndex } from 'prism-term-core/renderer/settings/coreIndex' +import type { SettingsIndexEntry, SettingsPageDef } from 'prism-term-core/renderer/settings/layout/SettingsFrame' +import type { SettingsSectionId } from 'prism-term-core/renderer/settings/sectionIds' +import { APP_OPTIONS, APP_SECTIONS, type AppPageId } from './appOptions' +import { iconPath } from './icons' + +// Prism's pages, where the core's sections sit on them, and the index Find a +// setting reads (2026-10-05, the grouped cards redesign, #292). + +/** The pages, in the rail's order (spec 1.4.2); About sits at the bottom. */ +export const SETTINGS_PAGES: Array = [ + { id: 'appearance', label: 'Appearance', icon: 'appearance' }, + { id: 'explorer', label: 'Explorer', icon: iconPath('explorer') }, + { id: 'terminal', label: 'Terminal', icon: 'terminal' }, + { id: 'agents', label: 'Agents', icon: 'agents' }, + { id: 'dictation', label: 'Dictation', icon: 'dictation' }, + { id: 'media', label: 'Media', icon: iconPath('media') }, + { id: 'about', label: 'About', icon: 'about', end: true } +] + +/** Which page holds each of the core's sections HERE. Prism's window wears + * its own style, so the terminal theme is the Terminal page's (Q2); command + * help is Prism Terminal's alone (owner, 2026-09-22) and is never drawn. */ +const PAGE_OF: Record = { + shell: 'terminal', + text: 'terminal', + theme: 'terminal', + help: 'terminal', + marks: 'agents', + claude: 'agents', + colours: 'agents', + dictation: 'dictation', + listening: 'dictation', + while: 'dictation', + models: 'dictation', + gpu: 'dictation' +} + +/** Every row in the order the pages draw them, which is the order Find a + * setting lists matches in. A test holds this to the lists. */ +export const ROW_ORDER = [ + 'mode', 'style-theme', 'c-bg', 'c-chrome', 'c-accent', 'c-selection', 'c-text', 'c-folder-icon', + 'c-font', 'tree-size', 'title-bar', 'tab-width', 'c-edges', 'c-corners', + 'tree-side', 'explorer-size', 'auto-scroll', 'newtab-mode', 'newtab-show', 'open-external', + 'remember-tabs', 'remember-folders', 'win-e-shortcut', 'explorer-verb', 'default-apps', + 'term-shell', 'term-font-family', 'term-font', 'term-theme', 'term-acrylic', + 'agent-indicator', 'agent-done-on', 'agent-question-on', 'agent-failed-on', 'agent-hooks', + 'agent-color', 'agent-done-color', 'agent-question-color', + 'dictation-enabled', 'dictation-mode', 'dictation-hotkey', 'dictation-mic', 'dictation-language', + 'dictation-pause-media', 'dictation-sounds', 'dictation-model', 'dictation-gpu', + 'viz-style', 'viz-colour', 'viz-glow', 'viz-cycle', 'viz-move', + 'transport-style', 'transport-bg', 'transport-colour', 'transport-glow', 'transport-cycle', 'transport-move', + 'app-version', 'show-setup' +] as const + +/** The heading of the style's colours, as the page draws it. */ +export const coloursTitle = (styleName: string): string => `Colours of ${styleName}` + +/** The index Find a setting reads: the core's rows drawn here (no command + * help), and this app's own, in page order. `styleName` words the colours' + * section as the page heads it. */ +export function settingsIndex(nvidia: boolean, styleName: string): SettingsIndexEntry[] { + const core = coreSettingsIndex({ pageOf: (s) => PAGE_OF[s], nvidia, help: false }) + const own: SettingsIndexEntry[] = APP_OPTIONS.map((o) => ({ + id: o.id, + page: o.page, + view: o.view, + section: o.section === 'style-colours' ? coloursTitle(styleName) : APP_SECTIONS[o.section], + sectionId: o.section, + label: o.label, + sub: o.sub, + icon: iconPath(o.icon), + keywords: o.keywords + })) + const all = new Map([...core, ...own].map((e) => [e.id, e])) + return ROW_ORDER.flatMap((id) => { + const e = all.get(id) + return e ? [e] : [] + }) +} diff --git a/src/renderer/src/components/settingsControls.test.ts b/src/renderer/src/components/settingsControls.test.ts deleted file mode 100644 index 181bb48d..00000000 --- a/src/renderer/src/components/settingsControls.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Settings controls are neutral and only Save wears the accent (owner, -// 2026-09-23). Read as source: the Switch, the Segmented and the Default apps -// button must carry no accent token, and the save button still does. -const src = readFileSync(resolve(__dirname, 'Settings.tsx'), 'utf8').replace(/\r\n/g, '\n') -const ACCENT = /--p-(accent|accent-hi|on-accent|sel-bg|sel-solid|on-sel-solid|sel-tint|sel-line)\b/ -const between = (from: string, to: string): string => src.slice(src.indexOf(from), src.indexOf(to, src.indexOf(from))) - -describe('settings controls', () => { - // Focus included since #272 (owner, 2026-10-04: "remove the focus effect"): - // a focused control wears its hover look, which is grey too. - it('the neutral classes carry no accent, not even on focus', () => { - for (const name of ['ROW_BUTTON', 'SEGMENT_ON', 'SWITCH_ON', 'SWITCH_KNOB_ON']) { - const m = src.match(new RegExp(`const ${name} =\\s*'([^']*)'`)) - expect(m, name).not.toBeNull() - expect(m![1]).not.toMatch(ACCENT) - } - }) - - it('Switch, Segmented and Default apps use them', () => { - expect(between('function Switch(', 'function SwitchItem(')).not.toMatch(ACCENT) - expect(between('function Segmented<', '\n}\n')).not.toMatch(ACCENT) - expect(between('id="default-apps"\n', 'Choose in Windows')).toContain('ROW_BUTTON') - }) - - it('the save button still wears the accent', () => { - expect(between('Nothing to save yet', 'Save changes')).toMatch(/bg-\[var\(--p-accent\)\]/) - }) -}) diff --git a/src/renderer/src/components/settingsCopy.test.ts b/src/renderer/src/components/settingsCopy.test.ts deleted file mode 100644 index c2a1df96..00000000 --- a/src/renderer/src/components/settingsCopy.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { copyProblem, settingsDescriptions } from 'prism-term-core/shared/settingsCopy' - -// Settings descriptions are plain words (owner, 2026-09-22). The core holds the -// rule and checks its own rows; this holds Prism's own pages to the same one. -describe("Prism's settings", () => { - it('describe every setting in plain words', () => { - const files = ['Settings.tsx', 'WinEShortcutSetting.tsx'].map((f) => join(__dirname, f)) - const found = files.flatMap((f) => settingsDescriptions(readFileSync(f, 'utf8')).map((text) => ({ f, text }))) - expect(found.length).toBeGreaterThan(15) - expect(found.map((d) => ({ ...d, problem: copyProblem(d.text) })).filter((d) => d.problem)).toEqual([]) - }) -}) diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index e660bfdf..98627e11 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -166,8 +166,6 @@ video::cue { --p-sel-tint-side: #272e35; --p-sel-hue: #3f5570; --p-sel-hue-hi: #6a7f99; - --p-sel-solid: #3f5570; - --p-on-sel-solid: #ffffff; --p-icon: #868d96; --p-hover: rgba(255, 255, 255, 0.06); --p-hover-hi: rgba(255, 255, 255, 0.11); @@ -338,6 +336,34 @@ body { background-image: var(--p-wash); background-attachment: fixed; } +/* Settings' frame is prism-term-core's since #292 and knows no wash, so the + rail and the pane are given the style's light here, as their own `p-wash` + class gave it before. */ +[data-settings-page], +[data-settings-page] > nav { + background-image: var(--p-wash); + background-attachment: fixed; +} +/* THE COMPACT RAIL (the title bar's toggle over Settings) is the core frame's + `compact`, the same icons the frame shows under 760px. Its compact classes + lose to the frame's own base classes in the cascade (`w-14` comes before + `w-[244px]` in the sheet, MEASURED: the rail stayed 244px), while the narrow + ones win because they are variants. Until the core gives `compact` the + variant's weight, the three that lose are asked for here; everything else + of `compact` (the hidden names, the magnifier) already applies. */ +[data-settings-compact] [data-settings-page] > nav { + width: 56px; + padding-left: 8px; + padding-right: 8px; +} +[data-settings-compact] [data-settings-tab] { + padding-left: 0; + padding-right: 0; +} +[data-settings-compact] [data-settings-find]:not(:focus):placeholder-shown { + padding-right: 0; + color: transparent; +} /* ---- first-run setup ---- */ /* Everything here runs once, on arrival: nothing loops, and a click is what diff --git a/src/renderer/src/lib/theme.selection.test.ts b/src/renderer/src/lib/theme.selection.test.ts index 2f1097f6..2bf94f43 100644 --- a/src/renderer/src/lib/theme.selection.test.ts +++ b/src/renderer/src/lib/theme.selection.test.ts @@ -6,8 +6,9 @@ import { derive, sideGround, STYLES, TINT_ALPHA, TINT_MIN, selectionTintAlpha, t // look good both in the settings highlighting for the selected tab which i want // more saturated and the explorer which i want to be more transparent like // selecting files in file explorer"). A marked FILE is a tint the row's own -// text still reads on; a chosen PAGE is the accent solid. Neither moves with -// the accent's alpha, which stays a choice about fills. +// text still reads on. The chosen settings PAGE was the accent solid until the +// grouped cards redesign (#292; owner, 2026-10-05: no accent on the chosen +// rail item), which made it a grey fill, so `--p-sel-solid` went with it. const lum = (hex: string): number => { const n = parseInt(hex.slice(1, 7), 16) @@ -85,19 +86,3 @@ describe('the tint gives way, never the ink', () => { }) }) -describe('a chosen page is the accent, solid', () => { - for (const s of STYLES) { - it(`${s.id}: the rail's fill is opaque at every accent alpha, and its label reads`, () => { - const solid = at(s, 1) - // At 100% it is exactly what the rail has always worn. - expect(solid['--p-sel-solid']).toBe(solid['--p-sel-bg']) - expect(solid['--p-on-sel-solid']).toBe(solid['--p-on-accent']) - for (const a of ALPHAS) { - const t = at(s, a) - expect(t['--p-sel-solid'], `${s.id} at ${a}`).toMatch(/^#[0-9a-f]{6}$/) - expect(t['--p-sel-solid']).toBe(solid['--p-sel-solid']) - expect(contrast(t['--p-on-sel-solid'], t['--p-sel-solid'])).toBeGreaterThanOrEqual(4.5) - } - }) - } -}) diff --git a/src/renderer/src/lib/theme.selectionColour.test.ts b/src/renderer/src/lib/theme.selectionColour.test.ts index 43cd46e8..72098952 100644 --- a/src/renderer/src/lib/theme.selectionColour.test.ts +++ b/src/renderer/src/lib/theme.selectionColour.test.ts @@ -134,10 +134,10 @@ describe('a picked Selection is the tint', () => { } }) - it('does not move the accent, the rail or the selection fill', () => { + it('does not move the accent or the selection fill', () => { const plain = derive(dark) const picked = derive({ ...dark, selection: '#2ecc7180' }) - for (const k of ['--p-accent', '--p-accent-hi', '--p-sel-bg', '--p-sel-solid', '--p-on-sel-solid']) + for (const k of ['--p-accent', '--p-accent-hi', '--p-sel-bg']) expect(picked[k], k).toBe(plain[k]) }) diff --git a/src/renderer/src/lib/theme.ts b/src/renderer/src/lib/theme.ts index 92386114..ab615748 100644 --- a/src/renderer/src/lib/theme.ts +++ b/src/renderer/src/lib/theme.ts @@ -667,12 +667,6 @@ export function derive(input: Style): Record { // The sweep band: its fill's colour and its edge's (see `bandHi`). '--p-sel-hue': picked ? tintHue : accentFill, '--p-sel-hue-hi': picked ? bandHi : hi, - // A chosen PAGE (the settings rail, a chosen card): the accent solid and - // whole whatever its alpha (owner, 2026-10-03: "the selected tab which i - // want more saturated"), nudged only as far as its label needs for 4.5:1. - // At 100% it is exactly the --p-sel-bg the rail always wore. - '--p-sel-solid': selectionBg(accent), - '--p-on-sel-solid': readableOn(selectionBg(accent)), // A FILL: carries the opacity (#249), and is the plain hex at 100%. // Below 100% it is the SELECTION's fill, not the raw accent: buttons and // chips print --p-on-accent on it, and that ink was chosen so the diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index db2cdffc..686881ac 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -810,7 +810,7 @@ async function sortScenario(fixtures) { await win.locator('[data-tab-role]:not([data-pinned]) [role="tab"]:has-text("Settings")').isVisible().catch(() => false), 'the cog opens Settings as a tab on the strip' ) - await win.click('button:has-text("Terminal")') + await settingsPage(win, 'terminal') await sleep(300) ok( (await win.locator('[data-term-card]').count()) >= 30 && (await win.locator('#term-font').count()) === 1, @@ -907,38 +907,64 @@ async function termOptionsScenario(fixtures) { const wanted = rows.filter((m) => !m[0].includes('onlyWhere')).map((m) => m[1]).sort() const windowOnly = rows.filter((m) => m[0].includes('onlyWhere')).map((m) => m[1]) ok(wanted.length >= 8, `the core lists the terminal options (${wanted.length} of ${rows.length} apply here)`) - await win.click('[aria-label="Settings"]') - await sleep(400) - await win.click('button:has-text("Terminal")') + // Since the grouped cards (#292) the core's rows sit on TWO pages here: + // Terminal (shell, text, theme) and Agents (marks, Claude Code, colours). + // Which page holds a section is the host's; the rows and their order + // inside each section are the core's. + await settingsPage(win, 'terminal') await win.waitForSelector('[data-terminal-settings]', { timeout: 5000 }) // The shell row appears once main has answered with the shells it found. await win.waitForSelector('[data-pref="term-shell"]', { timeout: 8000 }).catch(() => {}) - // No command help in Prism (2026-09-22): the page is the terminal list and - // nothing else, the help row included. - const onPage = (await win.evaluate(() => - [...document.querySelectorAll('[data-terminal-settings] [data-pref]')].map((e) => e.getAttribute('data-pref')) - )).sort() - ok(JSON.stringify(onPage) === JSON.stringify(wanted), `the Terminal page shows exactly that list (shown: ${JSON.stringify(onPage)})`) + const readPage = () => + win.evaluate(() => { + const root = document.querySelector('[data-terminal-settings], [data-agent-settings]') + return { + prefs: [...(root?.querySelectorAll('[data-pref]') ?? [])].map((e) => e.getAttribute('data-pref')), + sections: [...(root?.querySelectorAll('[data-settings-section]') ?? [])].map((sec) => ({ + id: sec.getAttribute('data-settings-section'), + panels: sec.querySelectorAll(':scope > [data-settings-panel]').length, + prefs: [...sec.querySelectorAll('[data-pref]')].map((e) => e.getAttribute('data-pref')) + })) + } + }) + const terminalPage = await readPage() + await win.screenshot({ path: join(SHOTS, 'terminal-settings.png') }) + await settingsPage(win, 'agents') + await win.waitForSelector('[data-agent-settings]', { timeout: 5000 }) + const agentsPage = await readPage() + await win.screenshot({ path: join(SHOTS, 'agent-settings.png') }) + // No command help in Prism (2026-09-22): the two pages are the terminal + // list and nothing else, the help row included. + const onPage = [...terminalPage.prefs, ...agentsPage.prefs].sort() + ok(JSON.stringify(onPage) === JSON.stringify(wanted), `the Terminal and Agents pages show exactly that list (shown: ${JSON.stringify(onPage)})`) ok(!onPage.includes('help-enabled'), 'with no command help row, which is Prism Terminal only') - // ONE ORDER IN BOTH APPS (owner, 2026-09-22): read top to bottom, the - // core's terminal rows come in the list's own order. Prism Terminal's own - // `options` scenario asserts the same against the same file. - const inOrder = rows.filter((m) => !m[0].includes('onlyWhere')).map((m) => m[1]) - const pageOrder = await win.evaluate(() => - [...document.querySelectorAll('[data-terminal-settings] [data-pref]')].map((e) => e.getAttribute('data-pref')) - ) ok( - JSON.stringify(pageOrder.filter((id) => inOrder.includes(id))) === JSON.stringify(inOrder.filter((id) => pageOrder.includes(id))), - `and in the shared order (${pageOrder.join(' > ')})` + terminalPage.prefs.every((id) => ['term-shell', 'term-font-family', 'term-font', 'term-theme', 'term-acrylic'].includes(id)) && + agentsPage.prefs.every((id) => id.startsWith('agent-')), + `the terminal's rows on Terminal, the agents' on Agents (${terminalPage.prefs.join(', ')} | ${agentsPage.prefs.join(', ')})` ) + // ONE ORDER IN BOTH APPS (owner, 2026-09-22), read PER SECTION since the + // grouped cards: inside each of the core's sections the rows come in the + // list's own order, and each section is ONE panel. Prism Terminal's own + // `options` scenario asserts the same against the same file. + const inOrder = rows.filter((m) => !m[0].includes('onlyWhere')).map((m) => m[1]) + const sections = [...terminalPage.sections, ...agentsPage.sections] + ok(sections.length >= 6, `the core's sections are drawn (${sections.map((x) => x.id).join(', ')})`) + for (const sec of sections) { + const listed = sec.prefs.filter((id) => inOrder.includes(id)) + ok( + JSON.stringify(listed) === JSON.stringify(inOrder.filter((id) => listed.includes(id))), + `${sec.id}: in the shared order (${listed.join(' > ')})` + ) + ok(sec.panels === 1, `${sec.id}: one panel`) + } ok((await win.locator('[data-pref="term-opacity"]').count()) === 0, 'with no opacity slider: the style owns the glass') const shownWindowOnly = [] - for (const id of windowOnly) if ((await win.locator(`[data-pref="${id}"]`).count()) > 0) shownWindowOnly.push(id) + for (const id of windowOnly) if (onPage.includes(id)) shownWindowOnly.push(id) ok( shownWindowOnly.length === 0, `and no row the core keeps for a window-acrylic host (${windowOnly.length ? windowOnly.join(', ') : 'none listed'}; shown: ${JSON.stringify(shownWindowOnly)})` ) - await win.screenshot({ path: join(SHOTS, 'terminal-settings.png') }) // Untouched, the indicator is MINIMAL and its colours follow the accent. ok( (await win.evaluate(() => localStorage.getItem('prism.term.agentIndicator'))) === null && @@ -947,8 +973,8 @@ async function termOptionsScenario(fixtures) { ) // The close question is one rule and no setting, on every page. let closeRows = 0 - for (const name of ['General', 'Terminal']) { - await win.click(`button:has-text("${name}")`) + for (const name of ['explorer', 'terminal', 'agents']) { + await settingsPage(win, name) await sleep(250) closeRows += await win.locator('text=/Ask before closing/i').count() } @@ -981,10 +1007,15 @@ async function noCommandHelpScenario(fixtures) { "the terminal's menu offers no command help" ) await win.keyboard.press('Escape') - await win.click('[aria-label="Settings"]') - await win.click('button:has-text("Terminal")') + await settingsPage(win, 'terminal') await win.waitForSelector('[data-terminal-settings]', { timeout: 8000 }) ok((await win.locator('[data-pref="help-enabled"]').count()) === 0, 'and Settings has no command help switch') + await win.locator('[data-settings-find]').fill('command help') + ok( + await until(async () => ((await win.locator('[data-settings-page] [role="status"]').textContent().catch(() => '')) ?? '') === 'No results', 3000, 50), + 'nor does Find a setting know of one' + ) + await win.locator('[data-settings-find]').fill('') } finally { await app.close() } @@ -1064,11 +1095,12 @@ async function termColourPickerScenario(fixtures) { await say('25D0', 'Claude Code') // ◐, mid-answer ok(await until(async () => (await workingTab.count()) === 1, 8000, 50), 'a working stand-in agent is on the strip') - await win.click('[aria-label="Settings"]') - await sleep(400) - await win.click('button:has-text("Terminal")') + await settingsPage(win, 'terminal') await win.waitForSelector('[data-terminal-settings]', { timeout: 8000 }) const themeBefore = await win.locator('[data-term-card][aria-pressed="true"]').first().getAttribute('data-term-card') + // The agent rows are the Agents page's since the grouped cards (#292). + await settingsPage(win, 'agents') + await win.waitForSelector('[data-agent-settings]', { timeout: 8000 }) await win.locator('[data-pref="agent-indicator"] button:has-text("Full")').click() ok(await until(async () => (await workingTab.getAttribute('data-agent')) === 'full', 4000, 50), 'the indicator is Full, the tab filled') @@ -1084,10 +1116,10 @@ async function termColourPickerScenario(fixtures) { await popover.locator('[role="slider"]').first().focus() await win.keyboard.press('Escape') } - ok((await swatch.getAttribute('aria-label')) === 'Pick Working colour', 'the row has a swatch named for it') + ok((await swatch.getAttribute('aria-label')) === 'Pick Agent working colour', 'the row has a swatch named for it') await swatch.click() ok(await until(async () => (await popover.count()) === 1, 4000, 50), 'the swatch opens the picker') - ok((await popover.getAttribute('aria-label')) === 'Working colour', "the picker is named for the row's colour") + ok((await popover.getAttribute('aria-label')) === 'Agent working colour', "the picker is named for the row's colour") // The spec does not say the focus moves into the popover as it opens, and // an Escape left on the swatch is not the popover's: focus inside first. await escapeFromPicker() @@ -1145,7 +1177,10 @@ async function termColourPickerScenario(fixtures) { // 8-10. The theme editor: no alpha on the Background in Prism (the style // owns see-through), alpha on a palette colour, and no accent on a control. - const showAll = win.locator('button[aria-expanded="false"][aria-label^="Show all"]') + // The theme is the Terminal page's (#292). + await settingsPage(win, 'terminal') + await win.waitForSelector('[data-terminal-settings]', { timeout: 8000 }) + const showAll =win.locator('button[aria-expanded="false"][aria-label^="Show all"]') if ((await showAll.count()) === 1) await showAll.click() await win.locator('[data-term-card="pitch"]').click() await win.locator('[data-edit-theme="pitch"]').click() @@ -1280,9 +1315,7 @@ async function dictationScenario(fixtures) { localStorage.setItem('prism.dictation.sounds', '0') }) // The page's own switch arms it (a bare localStorage write notifies nobody). - await win.click('[aria-label="Settings"]') - await sleep(400) - await win.click('button:has-text("Dictation")') + await settingsPage(win, 'dictation') await win.waitForSelector('[data-dictation-settings]', { timeout: 8000 }) ok((await win.locator('[data-pref="dictation-enabled"] [role="switch"]').getAttribute('aria-checked')) === 'true', 'Settings has a Dictation page of its own, and it reads the setting') await win.locator('[data-pref="dictation-enabled"] [role="switch"]').click() @@ -1380,9 +1413,7 @@ async function dictationPageScenario(fixtures) { app = started.app const win = started.win await win.evaluate(() => localStorage.setItem('prism.dictation.model', 'base')) - await win.click('[aria-label="Settings"]') - await sleep(400) - await win.click('button:has-text("Dictation")') + await settingsPage(win, 'dictation') await win.waitForSelector('[data-dictation-item="gpu-pack"][data-state="installed"]', { timeout: 8000 }) const src = readFileSync(join(ROOT, 'node_modules/prism-term-core/renderer/settings/dictationOptions.ts'), 'utf8') const wanted = [...src.matchAll(/\{\s*id: '([a-z-]+)'/g)].map((m) => m[1]).sort() @@ -2046,15 +2077,15 @@ async function iconSchemeScenario(fixtures) { // THE SETTINGS SWITCH IS GONE. await win.click('[aria-label="Settings"]') await win.waitForSelector('[data-tab-role]:not([data-pinned]) [role="tab"]:has-text("Settings")', { timeout: 10000 }) - await win.locator('button:has-text("Style")').first().click() + await settingsPage(win, 'appearance') await sleep(400) ok( (await win.locator('label:text-is("File icons")').count()) === 0, 'the File icons switch is hidden' ) ok( - (await win.locator('label:text-is("Folder icons")').count()) === 1, - 'while the Folder icons picker is untouched beside it' + (await win.locator('label:text-is("Folder icon colour")').count()) === 1, + 'while the Folder icon colour picker is untouched beside it' ) } finally { await app.close() @@ -4737,10 +4768,7 @@ async function tabsScenario(fixtures) { // here, so Ctrl+W puts it away again. Prism's rows are marked by their // label (`for="tab-width"`), and the two segment names are unique there. const pickTabWidth = async (name) => { - await win.click('[aria-label="Settings"]') - await win.click('button:has-text("Style")') - await win.locator('label[for="tab-width"]').waitFor({ timeout: 8000 }) - const seg = win.getByRole('button', { name, exact: true }) + const seg = (await gotoPref(win, 'tab-width')).getByRole('button', { name, exact: true }) await seg.scrollIntoViewIfNeeded() await seg.click() await win.keyboard.press('Control+w') @@ -4931,17 +4959,16 @@ async function tabsScenario(fixtures) { ok((await win.locator(`${strip} [data-pinned] [role="tab"]`).count()) === 1, 'beside the Explorer tab') const saved = JSON.parse(readFileSync(join(PROFILE, 'tabs.json'), 'utf8')) ok(saved.tabs.length >= 2, `and last time's tabs are still saved (${saved.tabs.length})`) - await win.click('[aria-label="Settings"]') - await win.click('button:has-text("General")') - await win.waitForSelector('[role="switch"][aria-label="Remember tabs"]', { timeout: 8000 }) + await settingsPage(win, 'explorer') + await win.waitForSelector('[role="switch"][aria-label="Reopen tabs at start"]', { timeout: 8000 }) ok( - (await win.locator('[role="switch"][aria-label="Remember tabs"]').getAttribute('aria-checked')) === 'false', - 'Settings > General shows Remember tabs switched off' + (await win.locator('[role="switch"][aria-label="Reopen tabs at start"]').getAttribute('aria-checked')) === 'false', + 'Settings > Explorer shows Reopen tabs at start switched off' ) // Back on, the way a user would, so the scenarios after this one restore // as they always did: the window's own store mirrors every prism.* key back // into main's at the next launch, so deleting the file alone undoes nothing. - await win.locator('[role="switch"][aria-label="Remember tabs"]').click() + await win.locator('[role="switch"][aria-label="Reopen tabs at start"]').click() ok( await until(async () => { try { @@ -5046,20 +5073,62 @@ async function tabsScenario(fixtures) { * The pin on the + menu (#99): a pinned folder climbs above the recents, the * pin fills, the menu stays up while you do it, and the pin outlives history. */ -/** Pick a Style-page segment the way a user would: the cog, Style, the row - * named by its label's `for`, the segment by name; then the cog again puts - * Settings away (clicking it while Settings is in front closes the tab). */ +/** + * SETTINGS SINCE THE GROUPED CARDS (#292): pages by `data-settings-tab`, rows by + * `data-pref`. Which page holds a row is Prism's (`settings/appOptions.ts` and + * the core's lists); these are the rows the scenarios reach. + */ +const SETTINGS_PAGE_OF = { + mode: 'appearance', 'style-theme': 'appearance', 'c-bg': 'appearance', 'c-accent': 'appearance', 'c-font': 'appearance', + 'tree-size': 'appearance', 'title-bar': 'appearance', 'tab-width': 'appearance', 'c-edges': 'appearance', 'c-corners': 'appearance', + 'tree-side': 'explorer', 'explorer-size': 'explorer', 'auto-scroll': 'explorer', 'newtab-mode': 'explorer', 'newtab-show': 'explorer', + 'open-external': 'explorer', 'remember-tabs': 'explorer', 'remember-folders': 'explorer', 'explorer-verb': 'explorer', 'default-apps': 'explorer', + 'term-shell': 'terminal', 'term-theme': 'terminal', 'agent-indicator': 'agents', 'agent-color': 'agents', + 'dictation-enabled': 'dictation', 'transport-bg': 'media', 'app-version': 'about' +} + +/** Open Settings if it is not up, and go to one of its pages. */ +async function settingsPage(win, page) { + if ((await win.locator('[data-settings-page]').count()) === 0) await win.click('[aria-label="Settings"]') + const tab = win.locator(`[data-settings-tab="${page}"]`) + await tab.waitFor({ timeout: 10000 }) + await tab.click() +} + +/** Open Settings at the page that holds a row, and wait for the row (Media's + * rows on its Progress bar half are switched to). Returns its locator. */ +async function gotoPref(win, id) { + const page = SETTINGS_PAGE_OF[id] + if (!page) throw new Error(`gotoPref: no page known for ${id}`) + await settingsPage(win, page) + if (id === 'transport-bg') await win.locator('[data-seg="progress"]').click() + const row = win.locator(`[data-pref="${id}"]`).first() + await row.waitFor({ timeout: 10000 }) + return row +} + +/** Pick a segment of a settings row the way a user would: the cog, the row's + * page, the segment by name; then the cog again puts Settings away (clicking + * it while Settings is in front closes the tab). */ async function pickStyleSegment(win, rowId, name) { - await win.click('[aria-label="Settings"]') - await win.click('button:has-text("Style")') - await win.locator(`label[for="${rowId}"]`).waitFor({ timeout: 8000 }) - const seg = win.getByRole('button', { name, exact: true }) + const row = await gotoPref(win, rowId) + const seg = row.getByRole('button', { name, exact: true }) await seg.scrollIntoViewIfNeeded() await seg.click() await win.click('[aria-label="Settings"]') await sleep(400) } +/** Show title bar, a switch since #292 over the same store (`shown` / `hidden`). */ +async function setTitleBar(win, mode) { + const row = await gotoPref(win, 'title-bar') + const sw = row.locator('[role="switch"]') + await sw.scrollIntoViewIfNeeded() + if ((await sw.getAttribute('aria-checked')) !== String(mode === 'shown')) await sw.click() + await win.click('[aria-label="Settings"]') + await sleep(400) +} + /** Which app region a point of the window is: the nearest element at or above * it that says, as Chromium resolves drag over no-drag. */ const regionAt = (win, x, y) => @@ -5102,7 +5171,7 @@ async function titleBarScenario(fixtures) { 'the panel toggle is in the bar' ) - await pickStyleSegment(win, 'title-bar', 'Hidden') + await setTitleBar(win, 'hidden') ok( (await win.evaluate(() => localStorage.getItem('prism.window.titleBar'))) === 'hidden', 'Hidden is remembered under prism.window.titleBar' @@ -5196,7 +5265,7 @@ async function titleBarScenario(fixtures) { } // SHOWN again is the window exactly as before. - await pickStyleSegment(win, 'title-bar', 'Shown') + await setTitleBar(win, 'shown') ok(await until(async () => (await win.locator(row).count()) === 0, 5000), 'Shown takes the one row away') ok((await win.locator('[data-title-bar] [data-wordmark]').count()) === 1, 'and the bar and its wordmark come back') const backWork = await box('.browse-workspace') @@ -5367,10 +5436,10 @@ async function moreMenuScenario(fixtures) { // A SETTINGS CONTROL too: a segment and a dropdown. await win.click('[data-title-bar] [aria-label="Settings"]') - await win.click('button:has-text("Style")') - await win.locator('label[for="title-bar"]').waitFor({ timeout: 8000 }) + await settingsPage(win, 'appearance') + await win.locator('label[for="tab-width"]').waitFor({ timeout: 8000 }) await win.evaluate(() => { - const seg = [...document.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Hidden') + const seg = document.querySelector('[data-pref="tab-width"] [data-seg="fixed"]') seg?.setAttribute('data-e2e-seg', '') document.querySelector('[data-settings-page] [aria-haspopup="listbox"]')?.setAttribute('data-e2e-select', '') }) @@ -5400,7 +5469,7 @@ async function moreMenuScenario(fixtures) { ) await win.evaluate(() => document.querySelector('[data-e2e-field]')?.remove()) // A SLIDER (Progress bar > Behind the controls): its track takes the fill. - await win.click('button:has-text("Progress bar")') + await gotoPref(win, 'transport-bg') await win.locator('input#transport-bg').waitFor({ timeout: 8000 }) // A colour swatch (Progress bar > Colour), painted by an inline background: its hairline edge // brightens, as on hover. @@ -5426,7 +5495,7 @@ async function moreMenuScenario(fixtures) { // WITH THE TITLE BAR HIDDEN the button lives at the end of the tab row, // and toggles there too. - await pickStyleSegment(win, 'title-bar', 'Hidden') + await setTitleBar(win, 'hidden') const rowButton = '[data-title-bar="tabs"] [data-more-button]' ok(await until(async () => (await win.locator(rowButton).count()) === 1, 5000), 'Hidden: More is in the tab row') await win.click(rowButton) @@ -5434,7 +5503,7 @@ async function moreMenuScenario(fixtures) { await win.click(rowButton) await sleep(400) ok((await menus()) === 0, 'and a second click there closes it') - await pickStyleSegment(win, 'title-bar', 'Shown') + await setTitleBar(win, 'shown') } finally { await app.close() } @@ -7371,19 +7440,17 @@ async function folderArgScenario(fixtures) { // answered, so this WAITS for the settled text rather than reading once. // Nothing here writes the registry: under --e2e the setting is not // `automatic`, so it only ever reports what Windows says. - await win.click('[aria-label="Settings"]') - // Settings opens on Style; the Explorer menu row lives on General. - await win.click('button:has-text("General")') + // The Explorer menu row lives on Explorer, under Windows (#292), and its + // SUBTEXT says what it does, or what Windows is being asked. + await gotoPref(win, 'explorer-verb') const verbRow = win.locator('label[for="explorer-verb"]') await verbRow.waitFor({ timeout: 10000 }) await verbRow.scrollIntoViewIfNeeded() const hint = await win .waitForFunction( () => { - const text = - document.querySelector('label[for="explorer-verb"]')?.parentElement?.querySelector('p') - ?.textContent ?? '' - return /Explorer menu/.test(text) ? text : false + const text = document.querySelector('label[for="explorer-verb"]')?.nextElementSibling?.textContent ?? '' + return text && text !== 'Checking with Windows.' ? text : false }, null, { timeout: 10000 } @@ -7391,33 +7458,24 @@ async function folderArgScenario(fixtures) { .then((h) => h.jsonValue()) .catch(() => '') // Plain words since 2026-09-22 (owner: descriptions say what a setting - // does, with no symbols but commas and full stops, no keys, no tips), so - // the entries are described rather than quoted. - ok( - hint === 'Adds entries to the Explorer menu for opening files and folders in Prism.', - `Settings says what the Explorer menu row does, in plain words (${hint})` - ) - // A Pref's hint is ONE line and TRUNCATES (`truncate`), and the new words - // made this one a sixth longer, with the part a Windows 11 user needs most, - // "(Shift+F10)", at the very end where an ellipsis eats first. MEASURED - // rather than eyeballed: at the fresh profile's default window the text - // must fit its box. The hint is settled by now, so this reads once. + // does, with no symbols but commas and full stops, no keys, no tips), and + // eight words at most since the grouped cards. + ok(hint === 'Open files and folders in Prism.', `Settings says what the Explorer menu row does, in plain words (${hint})`) + // A subtext is ONE line and TRUNCATES. MEASURED rather than eyeballed: at + // the fresh profile's default window the text must fit its box. const clipped = await win.evaluate(() => { - const p = document.querySelector('label[for="explorer-verb"]')?.parentElement?.querySelector('p') + const p = document.querySelector('label[for="explorer-verb"]')?.nextElementSibling return p ? { text: p.scrollWidth, box: p.clientWidth } : null }) - ok( - !!clipped && clipped.text <= clipped.box, - `and the whole hint fits on its line (${clipped?.text}px in ${clipped?.box}px)` - ) - // The switch settles in the same render as the hint and then ANIMATES + ok(!!clipped && clipped.text <= clipped.box, `and the whole subtext fits on its line (${clipped?.text}px in ${clipped?.box}px)`) + // The switch settles in the same render as the subtext and then ANIMATES // there; a screenshot taken on the first frame showed a switch that read // as off on a machine where the verb is on. Wait for its transitions to // end. Which way it points is this machine's registry and is not asserted. await win .waitForFunction( () => { - const sw = document.querySelector('[role="switch"][aria-label="Prism in the Explorer menu"]') + const sw = document.querySelector('[role="switch"][aria-label="Add to the Explorer menu"]') return !!sw && sw.getAnimations({ subtree: true }).length === 0 }, null, @@ -8143,16 +8201,19 @@ async function markTintScenario(fixtures) { }, draftBefore) await sleep(300) ok(alphaOf(await token('--p-sel-bg')) < 1, `the accent's alpha is down: its fills are see-through (${await token('--p-sel-bg')})`) - await win.click('[aria-label="Settings"]') - const rail = win.locator('aside button[aria-label="Style"]') - await rail.waitFor({ timeout: 8000 }) - await rail.click() + // THE CHOSEN SETTINGS PAGE IS A GREY FILL (#292; owner, 2026-10-05: no + // accent bar on the chosen rail item): `--p-hover-hi`, whatever the + // accent or its alpha, never the accent. + const rail = win.locator('[data-settings-tab="appearance"]') + await settingsPage(win, 'appearance') await win.mouse.move(5, 5) await sleep(300) const railBg = await rail.evaluate((el) => getComputedStyle(el).backgroundColor) - const solid = await token('--p-sel-solid') - ok(alphaOf(railBg) === 1, `the chosen Settings page is solid all the same (${railBg})`) - ok(railBg === solid, `in the accent, whole (${railBg} and ${solid})`) + const grey = await token('--p-hover-hi') + const solid = await token('--p-accent-solid') + ok((await rail.getAttribute('aria-current')) === 'page', 'the chosen Settings page says so') + ok(railBg === grey, `and wears the grey fill (${railBg} and ${grey})`) + ok(railBg !== solid && railBg !== (await token('--p-accent')), `not the accent (${railBg}, accent ${solid})`) await win.screenshot({ path: join(SHOTS, 'marktint-settings-rail.png') }) // Explorer marks still a tint at this alpha, the same one. await win.locator('[role="tablist"] [data-pinned] [role="tab"]').click() @@ -8170,7 +8231,7 @@ async function markTintScenario(fixtures) { const green = await explorerLook() ok(green !== null && /^rgba\(46, 204, 113, 0\.2\d*\)$/.test(green.bg), `a picked Selection is the marked row's tint (${green?.bg})`) ok(green !== null && green.name[0] === green.name[1], 'and the row keeps its own text colour') - ok((await token('--p-sel-solid')) === solid, `the accent's solid fill is untouched (${await token('--p-sel-solid')})`) + ok((await token('--p-accent-solid')) === solid, `the accent is untouched (${await token('--p-accent-solid')})`) await win.screenshot({ path: join(SHOTS, 'marktint-selection-picked.png') }) // Quick access's "you are here" wears the selection's tint (owner, // 2026-10-03: "i want that colour for the sidebar on the explorer page too"). @@ -8190,14 +8251,12 @@ async function markTintScenario(fixtures) { ok(/^rgba\(46, 204, 113,/.test(place.bg), `in the picked Selection colour (${place.bg})`) ok(place.ink !== place.plain || place.ink.length > 0, `and its text keeps a text colour (${place.ink})`) } - // The Settings rail still wears the accent, solid, as before the pick. - await win.click('[aria-label="Settings"]') - await rail.waitFor({ timeout: 8000 }) - await rail.click() + // The Settings rail keeps its grey, as before the pick. + await settingsPage(win, 'appearance') await win.mouse.move(5, 5) await sleep(300) const railAfter = await rail.evaluate((el) => getComputedStyle(el).backgroundColor) - ok(railAfter === railBg, `the Settings rail keeps the accent (${railAfter}, was ${railBg})`) + ok(railAfter === railBg, `the Settings rail keeps its grey (${railAfter}, was ${railBg})`) } finally { await win .evaluate((d) => { @@ -9775,8 +9834,7 @@ async function accentOpacityScenario(fixtures) { try { await win.waitForSelector('[role="treeitem"]', { timeout: 10000 }) draftBefore = await win.evaluate(() => localStorage.getItem('prism.style.draft')) - await win.click('[aria-label="Settings"]') - await win.locator('button:has-text("Style")').first().click() + await settingsPage(win, 'appearance') const row = win.locator('[data-colour-row="c-accent"]') const field = row.locator('input:not([type])') await field.waitFor({ timeout: 8000 }) @@ -9789,7 +9847,7 @@ async function accentOpacityScenario(fixtures) { await row.locator('[data-colour-swatch]').click() const pop = win.locator('[data-colour-popover][role="dialog"]') await pop.waitFor({ timeout: 5000 }) - ok((await pop.getAttribute('aria-label')) === 'Accent', 'the popover is named by its row') + ok((await pop.getAttribute('aria-label')) === 'Accent colour', 'the popover is named by its row') const alpha = pop.locator('[role="slider"][aria-label="Alpha"]') ok((await alpha.getAttribute('aria-valuemin')) === '10', 'its Alpha runs down to 10%') await alpha.focus() @@ -9910,8 +9968,7 @@ async function accentOpacityScenario(fixtures) { await win.screenshot({ path: join(SHOTS, 'accent-opacity-row.png') }) // Reset gives back the colour AND the alpha. - await win.click('[aria-label="Settings"]') - await win.locator('button:has-text("Style")').first().click() + await settingsPage(win, 'appearance') await reset.waitFor({ timeout: 8000 }) await reset.click() await sleep(250) @@ -9976,8 +10033,7 @@ async function styleColoursScenario(fixtures) { await win.waitForSelector('[role="treeitem"]', { timeout: 10000 }) before = await win.evaluate(() => localStorage.getItem('prism.style.draft')) await setDraft(null) - await win.click('[aria-label="Settings"]') - await win.locator('button:has-text("Style")').first().click() + await settingsPage(win, 'appearance') const rowOf = (id) => win.locator(`[data-colour-row="${id}"]`) const fieldOf = (id) => rowOf(id).locator('input:not([type])') const pop = win.locator('[data-colour-popover][role="dialog"]') @@ -10020,7 +10076,7 @@ async function styleColoursScenario(fixtures) { await slider('Hue').press('Escape') await sleep(200) ok((await pop.count()) === 0 && (await draft()).selection === undefined, 'Escape puts the unset Selection back') - const accentBefore = await token('--p-sel-solid') + const accentBefore = await token('--p-accent-solid') await rowOf('c-selection').locator('[data-colour-swatch]').click() await pop.waitFor({ timeout: 5000 }) await slider('Hue').focus() @@ -10043,7 +10099,7 @@ async function styleColoursScenario(fixtures) { // selection colour), so a box dragged over green marks is not blue. const hue = await token('--p-sel-hue') ok(near(hue.slice(0, 3), rgbOf(kept)), `the sweep band's hue is the pick (${hue.join(',')} for ${kept})`) - ok(JSON.stringify(await token('--p-sel-solid')) === JSON.stringify(accentBefore), 'the accent fill does not move') + ok(JSON.stringify(await token('--p-accent-solid')) === JSON.stringify(accentBefore), 'the accent does not move') await rowOf('c-selection').scrollIntoViewIfNeeded() await win.screenshot({ path: join(SHOTS, 'style-colours-selection.png') }) const reset = rowOf('c-selection').locator('button', { hasText: 'Reset' }) @@ -12985,13 +13041,12 @@ async function rememberFoldersScenario() { try { ok(await landed(win, f.cached, 15000), 'the Explorer shows a folder') ok(await until(() => files() > 0, 5000), `and it is kept on disk (${files()} file(s))`) - await win.click('[aria-label="Settings"]') - await win.click('button:has-text("General")') + await settingsPage(win, 'explorer') const row = win.locator('#remember-folders-clear') - ok(await until(async () => (await row.count()) === 1, 5000), 'Settings > General has Remember folders') + ok(await until(async () => (await row.count()) === 1, 5000), 'Settings > Explorer has Remember recent folders') await row.click() ok(await until(() => files() === 0, 5000), 'Clear deletes what was kept') - const sw = win.locator('button[role="switch"][aria-label="Remember folders"]') + const sw = win.locator('button[role="switch"][aria-label="Remember recent folders"]') ok((await sw.getAttribute('aria-checked')) === 'true', 'the switch is on by default') await sw.click() ok(await until(() => !existsSync(cacheDir), 5000), 'off deletes the folder') @@ -13003,6 +13058,353 @@ async function rememberFoldersScenario() { } } +/** What a settings page looks like, measured in the page (#292). Every colour + * is read through a probe, since `color-mix()` computes to `color(srgb ...)` + * in 0..1; a see-through fill is laid over what is under it before a ratio. */ +const settingsLookOf = (win) => + win.evaluate(() => { + const parse = (c) => { + const span = document.createElement('span') + span.style.color = c + document.body.appendChild(span) + const v = getComputedStyle(span).color + span.remove() + const n = (v.replace(/^color\(srgb/, '').match(/[\d.]+/g) ?? []).map(Number) + const unit = v.startsWith('color(') ? 255 : 1 + return { rgb: n.slice(0, 3).map((x) => x * unit), a: n.length > 3 ? n[3] : 1 } + } + const over = (top, under) => top.rgb.map((v, i) => under[i] + (v - under[i]) * top.a) + const lin = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4) + const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) + const ratio = (x, y) => { + const [a, b] = [lum(x), lum(y)] + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) + } + const root = getComputedStyle(document.documentElement) + const frame = document.querySelector('[data-settings-page]') + // The frame paints the style's ground; under glass it is composited over + // the flat sidebar colour, the nearest thing to what is behind it. + const flat = parse(root.getPropertyValue('--p-side-flat').trim()).rgb + const ground = over(parse(getComputedStyle(frame).backgroundColor), flat) + const panelEl = document.querySelector('[data-settings-panel]') + const panelGround = panelEl ? over(parse(getComputedStyle(panelEl).backgroundColor), ground) : ground + const row = document.querySelector('[data-setting-row]') + const label = row?.querySelector('label') + const sub = row?.querySelector('label + [title]') + const tile = row?.firstElementChild?.firstElementChild + const warnEl = document.querySelector('[data-setting-row] [title] svg')?.closest('[title]') + const ink = (el) => over(parse(getComputedStyle(el).color), panelGround) + const chosen = document.querySelector('[data-settings-tab][aria-current="page"]') + const accent = parse(root.getPropertyValue('--p-accent').trim()) + // A swatch IS its colour (its fill is inline), and the accent-following + // scheme's swatch is the accent: a mark, not a button that wears it. + const accentButtons = [...frame.querySelectorAll('button')].filter((b) => { + if (b.style.background) return false + const mine = parse(getComputedStyle(b).backgroundColor) + return mine.a > 0.3 && mine.rgb.map(Math.round).join() === accent.rgb.map(Math.round).join() + }) + const rows = [...frame.querySelectorAll('[data-setting-row]')] + const radius = parseFloat(root.getPropertyValue('--p-radius')) || 0 + const r = (n) => Math.round(n * 10) / 10 + // Two controls of one row overlapping is a layout fault the eye can miss. + const overlap = rows.some((rw) => { + const boxes = [...rw.querySelectorAll('[data-row-control] > *')].map((c) => c.getBoundingClientRect()).filter((b) => b.width > 0) + return boxes.some((a, i) => boxes.some((b, j) => j > i && a.left < b.right - 1 && b.left < a.right - 1 && a.top < b.bottom - 1 && b.top < a.bottom - 1)) + }) + return { + label: label ? ratio(ink(label), panelGround) : 0, + sub: sub ? ratio(ink(sub), panelGround) : 0, + icon: tile ? ratio(ink(tile), over(parse(getComputedStyle(tile).backgroundColor), panelGround)) : 0, + warn: warnEl ? ratio(ink(warnEl), panelGround) : null, + chosen: chosen ? parse(getComputedStyle(chosen).backgroundColor) : null, + hoverHi: parse(root.getPropertyValue('--p-hover-hi').trim()), + accent, + accentButtons: accentButtons.map((b) => b.textContent.trim()), + sideways: frame.scrollWidth > frame.clientWidth + 1 || document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + rowMin: rows.length ? Math.min(...rows.map((x) => x.getBoundingClientRect().height)) : 0, + tile: tile ? r(tile.getBoundingClientRect().width) : 0, + panelRadius: panelEl ? parseFloat(getComputedStyle(panelEl).borderTopLeftRadius) : 0, + wantRadius: Math.max(4, radius + 3), + overlap, + rail: Math.round(frame.querySelector('nav[aria-label="Settings pages"]').getBoundingClientRect().width) + } + }) + +/** + * THE SETTINGS LOOK (#292; owner, 2026-10-05: the approved v1 "Grouped cards", + * with no accent bar on the chosen rail item). Every page in a dark and a + * light style: label and subtext 4.5:1 on the panel as composited, the icon + * 3:1 on its tile, a warning subtext 4.5:1, the chosen rail page the GREY fill + * and never the accent, Save changes the only accent-filled buttons, rows at + * least 58px with a 32px tile, the panel's corner the style's roundness plus + * 3px (Onyx 2px gives 5px, Ruby 14px gives 17px), nothing sideways at 1600 + * and 900px or with Interface text size Large, the icon rail under 760px and + * from the title bar's toggle. A screenshot of every page in both schemes, + * LOOKED AT before a change is called done (#20 in Prism Terminal). + * + * In `e2e:terminal`, RUNNER-SAFE: the frame, sections and rows are the core's, + * so a core bump that breaks Prism's settings layout is held here. Nothing + * reads a window material or assumes this machine. + */ +async function settingsLookScenario(fixtures) { + console.log('settings look') + const root = join(tmpdir(), `${PROFILE_NAME}-settings-look`) + rmSync(root, { recursive: true, force: true }) + mkdirSync(root, { recursive: true }) + EXTRA_ENV = { PRISM_DICTATION_ROOT: root, PRISM_E2E_NVIDIA: '0' } + let app + let win + let styleBefore = null + try { + ;({ app, win } = await launch(join(fixtures, 'README.md'))) + const setSize = (w, h) => app.evaluate(({ BrowserWindow }, s) => BrowserWindow.getAllWindows()[0].setSize(s[0], s[1]), [w, h]) + await setSize(1600, 1000) + await win.waitForSelector('[role="treeitem"]', { timeout: 15000 }) + styleBefore = await switchStyle(win, 'aurora', 'dark') + await settingsPage(win, 'appearance') + ok((await win.locator('[data-settings-tab="appearance"]').getAttribute('aria-current')) === 'page', 'Settings opens on Appearance') + const rail = await win.evaluate(() => [...document.querySelectorAll('[data-settings-tab]')].map((b) => b.getAttribute('data-settings-tab'))) + ok( + JSON.stringify(rail) === JSON.stringify(['appearance', 'explorer', 'terminal', 'agents', 'dictation', 'media', 'about']), + `the rail runs Appearance, Explorer, Terminal, Agents, Dictation, Media, About (${rail.join(', ')})` + ) + const pages = [ + ['appearance'], + ['explorer'], + ['terminal'], + ['agents'], + ['dictation'], + ['media', 'visualizer'], + ['media', 'progress'], + ['about'] + ] + for (const [scheme, style] of [['dark', 'aurora'], ['light', 'paper']]) { + await switchStyle(win, style, scheme) + ok(await until(() => win.evaluate((m) => document.documentElement.dataset.mode === m, scheme), 6000, 50), `in a ${scheme} style (${style})`) + for (const [page, view] of pages) { + await settingsPage(win, page) + if (view) await win.locator(`[data-seg="${view}"]`).click() + // The dictation page's switch, on with no model, is the one live + // warning a fresh profile can show: measure it while it is up. + if (page === 'dictation') { + const sw = win.locator('[data-pref="dictation-enabled"] [role="switch"]') + if ((await sw.getAttribute('aria-checked')) !== 'true') await sw.click() + } + await win.mouse.move(5, 5) + await sleep(450) + const name = view ? `${page}-${view}` : page + const m = await settingsLookOf(win) + ok(m.label >= 4.5 && m.sub >= 4.5, `${scheme} ${name}: label and subtext read on the panel (${m.label.toFixed(1)}:1, ${m.sub.toFixed(1)}:1)`) + ok(m.icon >= 3, `${scheme} ${name}: the icon reads 3:1 on its tile (${m.icon.toFixed(1)}:1)`) + if (page === 'dictation') ok(m.warn !== null && m.warn >= 4.5, `${scheme} dictation: a warning subtext reads 4.5:1 (${m.warn?.toFixed(1)}:1)`) + ok( + !!m.chosen && m.chosen.rgb.map(Math.round).join() === m.hoverHi.rgb.map(Math.round).join() && Math.abs(m.chosen.a - m.hoverHi.a) < 0.02, + `${scheme} ${name}: the chosen rail page is the grey fill (${JSON.stringify(m.chosen)})` + ) + ok(!!m.chosen && m.chosen.rgb.map(Math.round).join() !== m.accent.rgb.map(Math.round).join(), `${scheme} ${name}: and not the accent`) + ok(m.accentButtons.every((b) => b === 'Save changes'), `${scheme} ${name}: the only accent-filled buttons are Save changes (${JSON.stringify(m.accentButtons)})`) + ok(!m.sideways, `${scheme} ${name}: nothing scrolls sideways at 1600px`) + ok(m.rowMin >= 57.5 && m.tile === 32, `${scheme} ${name}: rows at least 58px, a 32px icon tile (${m.rowMin}, ${m.tile})`) + ok(m.panelRadius === m.wantRadius, `${scheme} ${name}: the panel's corner is the style's plus 3px (${m.panelRadius} of ${m.wantRadius})`) + ok(!m.overlap, `${scheme} ${name}: no two controls of a row overlap`) + await win.screenshot({ path: join(SHOTS, `settings-${name}-${scheme}.png`) }) + if (page === 'dictation') await win.locator('[data-pref="dictation-enabled"] [role="switch"]').click() + } + } + // The style's ROUNDNESS rounds the panels: Onyx (2px) and Ruby (14px). + for (const [style, want] of [['default', 5], ['acrylic-red', 17]]) { + await switchStyle(win, style, 'dark') + await settingsPage(win, 'appearance') + ok( + await until(async () => (await settingsLookOf(win)).panelRadius === want, 3000, 50), + `${style}: the panels' corners are ${want}px (${(await settingsLookOf(win)).panelRadius})` + ) + await win.screenshot({ path: join(SHOTS, `settings-appearance-${style}.png`) }) + } + await switchStyle(win, 'aurora', 'dark') + // Interface text size Large zooms the page by 1.12: nothing overflows. + const size = await gotoPref(win, 'tree-size') + await size.locator('#tree-size').click() + await win.locator('[data-pref="tree-size"] [role="option"]:has-text("Large")').click() + ok(await until(() => win.evaluate(() => localStorage.getItem('prism.tree.size') === 'large'), 3000, 50), 'Interface text size is Large') + for (const [page, view] of pages) { + await settingsPage(win, page) + if (view) await win.locator(`[data-seg="${view}"]`).click() + await sleep(250) + ok(!(await settingsLookOf(win)).sideways, `Large: ${view ?? page} scrolls nothing sideways`) + } + await win.screenshot({ path: join(SHOTS, 'settings-large.png') }) + await (await gotoPref(win, 'tree-size')).locator('#tree-size').click() + await win.locator('[data-pref="tree-size"] [role="option"]:has-text("Default")').click() + // 900px: the full rail, nothing sideways. Under 760px of the FRAME the rail + // is icons and Find a setting a magnifier. + await settingsPage(win, 'appearance') + await setSize(900, 800) + await sleep(500) + ok(!(await settingsLookOf(win)).sideways, 'nothing scrolls sideways at 900px') + ok((await settingsLookOf(win)).rail >= 200, `at 900px the rail has its names (${(await settingsLookOf(win)).rail}px)`) + await setSize(700, 700) + ok(await until(async () => (await settingsLookOf(win)).rail <= 60, 3000, 50), `under 760px the rail is icons (${(await settingsLookOf(win)).rail}px)`) + ok(!(await win.locator('[data-settings-tab="appearance"] span').last().isVisible()), 'with the page names hidden') + ok(!(await settingsLookOf(win)).sideways, 'and nothing scrolls sideways') + const find = win.locator('[data-settings-find]') + await find.click() + await win.keyboard.type('font') + ok(await until(async () => (await find.evaluate((el) => el.getBoundingClientRect().width)) > 200, 3000, 50), 'the magnifier opens the field over the pane') + await win.screenshot({ path: join(SHOTS, 'settings-narrow-search.png') }) + await win.keyboard.press('Escape') + await setSize(1600, 1000) + await sleep(300) + // Prism's own compact rail, from the title bar's toggle, is the same icons. + await win.locator('[data-title-bar] [data-panel-toggle]').click() + ok(await until(async () => (await settingsLookOf(win)).rail <= 60, 3000, 50), `the title bar's toggle collapses the rail to icons (${(await settingsLookOf(win)).rail}px)`) + await win.screenshot({ path: join(SHOTS, 'settings-compact.png') }) + await win.locator('[data-title-bar] [data-panel-toggle]').click() + ok(await until(async () => (await settingsLookOf(win)).rail >= 200, 3000, 50), 'and back') + } finally { + EXTRA_ENV = {} + if (styleBefore && win) await switchStyle(win, styleBefore[0], styleBefore[1]).catch(() => {}) + await win?.evaluate(() => localStorage.removeItem('prism.tree.size')).catch(() => {}) + await app?.close().catch(() => {}) + } +} + +/** + * FIND A SETTING (#292; spec 1.2, 1.3, 1.6): every row in Prism's index is + * found by its own label and opened, landing on screen, flashed and holding + * the keyboard (the Media half that holds it switched to); by keyboard alone + * from the field to a control; Escape clears the field and only an EMPTY field + * lets Escape close Settings; no status line without a query; a word that + * matches nothing says so. The index is read as text from the app's own + * `settingsIndex.ts` and the lists, as the gate reads `options.ts`. + */ +async function settingsSearchScenario(fixtures) { + console.log('settings search') + EXTRA_ENV = { PRISM_E2E_NVIDIA: '0' } + let app + try { + const started = await launch(join(fixtures, 'README.md')) + app = started.app + const win = started.win + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1600, 1000)) + const labelOf = {} + for (const file of [ + 'node_modules/prism-term-core/renderer/settings/options.ts', + 'node_modules/prism-term-core/renderer/settings/dictationOptions.ts', + 'src/renderer/src/components/settings/appOptions.ts' + ]) + for (const m of readFileSync(join(ROOT, file), 'utf8').matchAll(/\{\s*id: '([a-z-]+)'[^}]*\}/g)) + if (!m[0].includes('onlyWhere')) labelOf[m[1]] = (m[0].match(/label: '([^']+)'/) ?? [])[1] + const order = readFileSync(join(ROOT, 'src/renderer/src/components/settings/settingsIndex.ts'), 'utf8') + const rowOrder = order.slice(order.indexOf('ROW_ORDER'), order.indexOf('] as const')) + const ids = [...rowOrder.matchAll(/'([a-z-]+)'/g)].map((m) => m[1]).filter((id) => labelOf[id] && id !== 'dictation-gpu') + ok(ids.length >= 50, `the index covers every row drawn on this PC (${ids.length})`) + const find = win.locator('[data-settings-find]') + const status = win.locator('[data-settings-page] [role="status"]') + await win.click('[aria-label="Settings"]') + await find.waitFor({ timeout: 8000 }) + ok((await status.count()) === 0, 'with nothing typed there is no status line') + ok((await find.getAttribute('data-owns-escape')) === null, 'an empty field does not claim Escape') + await find.fill('font') + ok((await find.getAttribute('data-owns-escape')) !== null, 'a field holding text does') + // ESCAPE: the field's first, then Settings'. Prism's own capture-phase + // Escape closes Settings, and must stand down while the field holds text. + await find.press('Escape') + ok((await find.inputValue()) === '' && (await win.locator('[data-settings-page]').count()) === 1, 'Escape with text clears the field and leaves Settings open') + await find.press('Escape') + ok(await until(async () => (await win.locator('[data-settings-page]').count()) === 0, 3000, 50), 'Escape on an empty field closes Settings') + await win.click('[aria-label="Settings"]') + await find.waitFor({ timeout: 8000 }) + // Every row's controls are a group named by the row's label. + await gotoPref(win, 'tab-width') + ok((await win.locator('[data-pref="tab-width"] [role="group"]').getAttribute('aria-label')) === labelOf['tab-width'], 'a row names its controls by its label') + // EVERY ROW, BY ITS OWN LABEL (spec 1.6). Some labels are shared (the + // Glow, Cycle and Move of each Media half), so the row is looked for + // among the results rather than as the first. + const misses = [] + for (const id of ids) { + await find.fill(labelOf[id]) + const hit = win.locator(`[data-settings-page] [role="option"][data-hit="${id}"]`) + if (!(await until(async () => (await hit.count()) === 1, 3000, 30))) { + misses.push(`${id}: not found`) + continue + } + const first = await win.locator('[data-settings-page] [role="option"]').first().getAttribute('data-hit') + if (labelOf[first] !== labelOf[id]) misses.push(`${id}: first result is ${first}`) + await hit.click() + const landed = await until( + () => + win.evaluate((pref) => { + const row = document.querySelector(`[data-pref="${pref}"]`) + if (!row) return null + const r = row.getBoundingClientRect() + const onScreen = r.bottom > 0 && r.top < innerHeight + return onScreen && row.hasAttribute('data-flash') && row.contains(document.activeElement) ? true : null + }, id), + 4000, + 30 + ) + if (!landed) misses.push(`${id}: not on screen, flashed and focused`) + if ((await find.inputValue()) !== '') misses.push(`${id}: the field kept its text`) + } + ok(misses.length === 0, `every row is found by its label and opened (${JSON.stringify(misses)})`) + // KEYBOARD ONLY: the field, Down, Enter, and the control has the focus. + await find.focus() + await win.keyboard.type('explorer menu') + ok(await until(async () => ((await status.textContent().catch(() => '')) ?? '').includes('result'), 3000, 50), `a status line says how many (${await status.textContent().catch(() => '')})`) + await win.keyboard.press('ArrowDown') + ok(await win.evaluate(() => document.activeElement?.getAttribute('role') === 'option'), 'Down moves to the first result') + await win.screenshot({ path: join(SHOTS, 'settings-search.png') }) + await win.keyboard.press('Enter') + ok( + await until(() => win.evaluate(() => !!document.activeElement?.closest('[data-pref="explorer-verb"]')), 4000, 30), + "Enter opens it with the keyboard on the row's control" + ) + ok((await win.locator('[data-settings-tab="explorer"]').getAttribute('aria-current')) === 'page', 'on the page that holds it') + // A Media row opens its own half of the page. + await find.fill('control band') + await win.locator('[data-settings-page] [role="option"]').first().click() + ok( + await until(async () => (await win.locator('[data-seg="progress"][aria-pressed="true"]').count()) === 1 && (await win.locator('[data-pref="transport-bg"]').count()) === 1, 3000, 50), + 'a Progress bar row opens Media on its Progress bar half' + ) + // While results are up the rail chooses nothing; Escape clears. + await find.focus() + await win.keyboard.type('colour') + await until(async () => (await status.count()) === 1, 3000, 50) + ok((await win.locator('[data-settings-tab][aria-current="page"]').count()) === 0, 'while results are up no page is chosen in the rail') + await win.keyboard.press('Escape') + ok((await find.inputValue()) === '' && (await status.count()) === 0, 'Escape clears the field and the status line goes') + ok((await win.locator('[data-settings-page]').count()) === 1, 'and Settings stays open') + // Nothing found. + await find.fill('zebra') + ok(await until(async () => ((await status.textContent().catch(() => '')) ?? '') === 'No results', 3000, 50), 'a word that matches nothing says No results') + ok(((await win.locator('[data-settings-nothing]').textContent()) ?? '').includes('Nothing matches zebra'), 'and the pane says what was not found') + await win.screenshot({ path: join(SHOTS, 'settings-search-empty.png') }) + await find.fill('') + // THE RAIL BY KEYBOARD (spec 1.3): Tab from the field lands on the rail, + // Up and Down walk it and Home and End jump, and Settings keeps them: the + // folder behind does not page. + await find.focus() + await win.keyboard.press('Tab') + const at = () => win.evaluate(() => document.activeElement?.getAttribute('data-settings-tab') ?? document.activeElement?.tagName ?? null) + ok((await at()) === 'appearance', `Tab from the field lands on the rail's first page (${await at()})`) + await win.keyboard.press('ArrowDown') + ok((await at()) === 'explorer', `Down walks the rail (${await at()})`) + await win.keyboard.press('End') + ok((await at()) === 'about', `End jumps to the last (${await at()})`) + await win.keyboard.press('Home') + ok((await at()) === 'appearance', `Home to the first (${await at()})`) + await win.keyboard.press('ArrowDown') + await win.keyboard.press('ArrowDown') + await win.keyboard.press('Enter') + ok((await win.locator('[data-settings-tab="terminal"]').getAttribute('aria-current')) === 'page', 'Enter opens it, and the rail says it is the page') + } finally { + EXTRA_ENV = {} + await app?.close().catch(() => {}) + } +} + async function run(fn, gap = 900) { const name = fn.name.replace(/Scenario$/, '') if (!chosen(name)) return @@ -13054,6 +13456,8 @@ await run(terminalScenario) await run(termOptionsScenario) await run(noCommandHelpScenario) await run(termColourPickerScenario) +await run(settingsLookScenario) +await run(settingsSearchScenario) await run(dictationScenario) await run(dictationPageScenario) await run(pinRecentScenario) diff --git a/tsconfig.web.json b/tsconfig.web.json index bbf0732c..6c82de66 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -15,7 +15,10 @@ "baseUrl": ".", "paths": { "@renderer/*": ["src/renderer/src/*"], - "@shared/*": ["src/shared/*"] + "@shared/*": ["src/shared/*"], + "prism-term-core/renderer/settings/coreIndex": ["node_modules/prism-term-core/renderer/settings/coreIndex.ts"], + "prism-term-core/renderer/settings/sectionIds": ["node_modules/prism-term-core/renderer/settings/sectionIds.ts"], + "prism-term-core/renderer/settings/layout/icons": ["node_modules/prism-term-core/renderer/settings/layout/icons.ts"] } }, "include": [ diff --git a/vitest.config.ts b/vitest.config.ts index f6bad430..fd6ef34d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,10 +8,16 @@ const root = fileURLToPath(new URL('.', import.meta.url)) // electron.vite.config.ts so test imports match app imports. export default defineConfig({ resolve: { - alias: { - '@shared': resolve(root, 'src/shared'), - '@renderer': resolve(root, 'src/renderer/src') - } + alias: [ + { find: '@shared', replacement: resolve(root, 'src/shared') }, + { find: '@renderer', replacement: resolve(root, 'src/renderer/src') }, + // The core's plain .ts settings modules its package does not export + // (#292): see CORE_TS in electron.vite.config.ts. + ...['renderer/settings/coreIndex', 'renderer/settings/sectionIds', 'renderer/settings/layout/icons'].map((m) => ({ + find: new RegExp(`^prism-term-core/${m}$`), + replacement: resolve(root, `node_modules/prism-term-core/${m}.ts`) + })) + ] }, test: { include: ['src/**/*.test.ts'], From c4398359568963faf611d13abcfea7030b146bc4 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:05:46 +0200 Subject: [PATCH 2/5] test(settings): the settings scenarios put the window size back (#292) The window's size is saved in the shared profile, and columnHeaders and panelsAlign, which ran after them, started at 1600x1000. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- tools/e2e/run.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 686881ac..af813b7b 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -13155,8 +13155,12 @@ async function settingsLookScenario(fixtures) { let app let win let styleBefore = null + // The window's size is SAVED in the shared profile: the scenarios after + // this one must start at the size they always did. + let sizeBefore = null try { ;({ app, win } = await launch(join(fixtures, 'README.md'))) + sizeBefore = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getSize()) const setSize = (w, h) => app.evaluate(({ BrowserWindow }, s) => BrowserWindow.getAllWindows()[0].setSize(s[0], s[1]), [w, h]) await setSize(1600, 1000) await win.waitForSelector('[role="treeitem"]', { timeout: 15000 }) @@ -13265,6 +13269,7 @@ async function settingsLookScenario(fixtures) { EXTRA_ENV = {} if (styleBefore && win) await switchStyle(win, styleBefore[0], styleBefore[1]).catch(() => {}) await win?.evaluate(() => localStorage.removeItem('prism.tree.size')).catch(() => {}) + if (sizeBefore) await app?.evaluate(({ BrowserWindow }, s) => BrowserWindow.getAllWindows()[0].setSize(s[0], s[1]), sizeBefore).catch(() => {}) await app?.close().catch(() => {}) } } @@ -13282,10 +13287,12 @@ async function settingsSearchScenario(fixtures) { console.log('settings search') EXTRA_ENV = { PRISM_E2E_NVIDIA: '0' } let app + let sizeBefore = null try { const started = await launch(join(fixtures, 'README.md')) app = started.app const win = started.win + sizeBefore = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getSize()) await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1600, 1000)) const labelOf = {} for (const file of [ @@ -13401,6 +13408,7 @@ async function settingsSearchScenario(fixtures) { ok((await win.locator('[data-settings-tab="terminal"]').getAttribute('aria-current')) === 'page', 'Enter opens it, and the rail says it is the page') } finally { EXTRA_ENV = {} + if (sizeBefore) await app?.evaluate(({ BrowserWindow }, s) => BrowserWindow.getAllWindows()[0].setSize(s[0], s[1]), sizeBefore).catch(() => {}) await app?.close().catch(() => {}) } } From 9cbc7e9c1421dea7db808a06e146f25d26153e5f Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:56:30 +0200 Subject: [PATCH 3/5] fix(settings): review of #292: compact rail tiles, Escape away from Find, one Win+E toggle - The compact rail (title bar's toggle) drew each page as an 18px sliver: the item column is centred, so the core's w-full was the icon's width. Each page is a 40px tile now, as in the narrow layout; settingsLook measures it. - With text in Find a setting and the keyboard elsewhere (the rail, the pane), Escape did nothing at all. It now takes the keyboard back to the field, and the next Escape clears it; settingsSearch holds both steps. - Win+E's label names its switch by id and the row also flips on a press, so one press could reach toggle() twice before a render disabled it. A ref makes it one change per press. - e2e: fullscreenBlack left the shared profile's window maximized, so the narrow-window checks of columnHeaders and panelsAlign (which run later) measured a full screen. It unmaximizes on the way out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- .../src/components/settings/Settings.tsx | 14 +++++++++++- .../components/settings/WinEShortcutRow.tsx | 7 ++++++ src/renderer/src/index.css | 4 ++++ tools/e2e/run.mjs | 22 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 0aad97a1..6028059b 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -97,7 +97,19 @@ export function Settings({ // FIND A SETTING HOLDING TEXT owns Escape (spec 1.3): it clears the // field, and only an empty field lets Escape close Settings. The core // marks the field `data-owns-escape` only while it holds text. - if (document.querySelector('[data-settings-find][data-owns-escape]')) return + // Typed text with the keyboard ELSEWHERE (a press on the empty pane, a + // Tab to the rail) made Escape do nothing at all: this yielded, and + // the field, which clears, never heard it. So the keyboard goes back + // to the field, and the next Escape clears it there. + const find = document.querySelector('[data-settings-find][data-owns-escape]') + if (find) { + const t = e.target as Element | null + if (!t?.closest?.('[data-settings-find], [data-settings-page] [role="listbox"]')) { + e.stopPropagation() + find.focus() + } + return + } e.stopPropagation() onClose() } diff --git a/src/renderer/src/components/settings/WinEShortcutRow.tsx b/src/renderer/src/components/settings/WinEShortcutRow.tsx index a5adb0c0..0273c496 100644 --- a/src/renderer/src/components/settings/WinEShortcutRow.tsx +++ b/src/renderer/src/components/settings/WinEShortcutRow.tsx @@ -45,7 +45,13 @@ export function WinEShortcutRow(): JSX.Element { } }, []) + // ONE PRESS, ONE CHANGE: the row flips its switch on a press anywhere, and + // its label also names this switch by id, so one press on the label can + // reach it twice before a render disables it. A ref, read at once. + const pending = useRef(false) const toggle = (): void => { + if (pending.current) return + pending.current = true setBusy(true) void window.prism .setWinEShortcut(!status.enabled) @@ -60,6 +66,7 @@ export function WinEShortcutRow(): JSX.Element { })) }) .finally(() => { + pending.current = false if (mounted.current) setBusy(false) }) } diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index 98627e11..03368805 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -357,6 +357,10 @@ body { padding-right: 8px; } [data-settings-compact] [data-settings-tab] { + /* 40px, the narrow layout's tile: the rail's item column is centred, so + its full width was the icon's own and the chosen fill an 18px sliver + (MEASURED in settings-compact.png). */ + width: 40px; padding-left: 0; padding-right: 0; } diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index af813b7b..a558fa5c 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -7283,6 +7283,12 @@ async function fullscreenBlackScenario(fixtures) { 'and comes back maximized, not restored' ) } finally { + // THE WINDOW'S STATE IS SAVED IN THE SHARED PROFILE: left maximized, every + // later launch came up maximized, where `setSize` is ignored, and the + // narrow-window checks of `columnHeaders` and `panelsAlign` measured a full + // screen (MEASURED in the #292 review: those two fail after this one alone). + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].unmaximize()).catch(() => {}) + await sleep(300) await app.close() } } @@ -13262,6 +13268,10 @@ async function settingsLookScenario(fixtures) { // Prism's own compact rail, from the title bar's toggle, is the same icons. await win.locator('[data-title-bar] [data-panel-toggle]').click() ok(await until(async () => (await settingsLookOf(win)).rail <= 60, 3000, 50), `the title bar's toggle collapses the rail to icons (${(await settingsLookOf(win)).rail}px)`) + // Each page is a 40px tile there, as in the narrow layout: the chosen + // fill was an 18px sliver round the icon before (review of #292). + const tileW = await win.evaluate(() => Math.round(document.querySelector('[data-settings-tab][aria-current="page"]').getBoundingClientRect().width)) + ok(tileW >= 38 && tileW <= 42, `and each page is a 40px tile there (${tileW}px)`) await win.screenshot({ path: join(SHOTS, 'settings-compact.png') }) await win.locator('[data-title-bar] [data-panel-toggle]').click() ok(await until(async () => (await settingsLookOf(win)).rail >= 200, 3000, 50), 'and back') @@ -13383,6 +13393,18 @@ async function settingsSearchScenario(fixtures) { await win.keyboard.press('Escape') ok((await find.inputValue()) === '' && (await status.count()) === 0, 'Escape clears the field and the status line goes') ok((await win.locator('[data-settings-page]').count()) === 1, 'and Settings stays open') + // TEXT TYPED, THE KEYBOARD ELSEWHERE (review of #292): Escape did nothing + // at all. It now takes the keyboard back to the field, and the next one + // clears it there; Settings stays open throughout. + await find.fill('font') + await win.locator('[data-settings-tab="explorer"]').focus() + await win.keyboard.press('Escape') + ok( + (await win.locator('[data-settings-page]').count()) === 1 && (await win.evaluate(() => document.activeElement?.hasAttribute('data-settings-find'))), + 'Escape with text and the focus on the rail takes the keyboard to the field' + ) + await win.keyboard.press('Escape') + ok((await find.inputValue()) === '' && (await win.locator('[data-settings-page]').count()) === 1, 'and the next Escape clears it, Settings still open') // Nothing found. await find.fill('zebra') ok(await until(async () => ((await status.textContent().catch(() => '')) ?? '') === 'No results', 3000, 50), 'a word that matches nothing says No results') From e72a189bf6376f7fb121fc213eefdea7ac60494a Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:51:44 +0200 Subject: [PATCH 4/5] fix(settings): Font and Font size, the owner's names (#292) Owner, 2026-10-06: "call this setting font size and the app font, just call it font". Interface text size is Font size; App font is Font. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 2 +- src/renderer/src/components/settings/AppearancePage.tsx | 2 +- src/renderer/src/components/settings/ExplorerPage.tsx | 2 +- src/renderer/src/components/settings/appOptions.ts | 4 ++-- tools/e2e/run.mjs | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1bc06be6..cd3d7086 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -533,7 +533,7 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: (`lib/explorerSize.ts`, `prism.explorer.size`, unknown reads Medium): Small 22px / 11.5px / 12px icon, Medium (DEFAULT) 26 / 12.5 / 14, Large the pre-#257 Explorer, 40 / 15 / 18 with its old padding and columns (`[data-row-size='large']` in browse.css). The Explorer's list - ALONE: the tree keeps Interface text size. `explorerSize` e2e. + ALONE: the tree keeps Font size. `explorerSize` e2e. **THE COLUMN HEADER IS FILE EXPLORER'S** (#274; owner, 2026-10-04: the hover "should be inside the whole box"; Size's name "aligned to the left"; the arrow "shows only when you hover over them while the currently sorted item has an arrow at all times"). The header keeps the rows' diff --git a/src/renderer/src/components/settings/AppearancePage.tsx b/src/renderer/src/components/settings/AppearancePage.tsx index 3c31e8ee..1b6a8a99 100644 --- a/src/renderer/src/components/settings/AppearancePage.tsx +++ b/src/renderer/src/components/settings/AppearancePage.tsx @@ -14,7 +14,7 @@ import { StyleWall } from './StyleWall' // APPEARANCE (2026-10-05, the grouped cards redesign; it was Style): the // style and its wall, the style's colours, the text, and the window. Rows -// marked as the style's (colours, App font, Panel edges, Corner roundness) +// marked as the style's (colours, Font, Panel edges, Corner roundness) // are edits of the chosen style and light Save changes; the rest are this // app's own and a style switch leaves them alone. // diff --git a/src/renderer/src/components/settings/ExplorerPage.tsx b/src/renderer/src/components/settings/ExplorerPage.tsx index 46ac4afa..f6074301 100644 --- a/src/renderer/src/components/settings/ExplorerPage.tsx +++ b/src/renderer/src/components/settings/ExplorerPage.tsx @@ -34,7 +34,7 @@ function LayoutSection(): JSX.Element { setTreeSide(v as TreeSide)} options={TREE_SIDES} /> {/* The Explorer's rows alone (owner, 2026-10-03): the tree and the rest - of the app keep Interface text size. */} + of the app keep Font size. */} ({ id, name }))} /> diff --git a/src/renderer/src/components/settings/appOptions.ts b/src/renderer/src/components/settings/appOptions.ts index 4b3bc58b..c210e215 100644 --- a/src/renderer/src/components/settings/appOptions.ts +++ b/src/renderer/src/components/settings/appOptions.ts @@ -59,8 +59,8 @@ export const APP_OPTIONS: readonly AppOption[] = [ { id: 'c-selection', label: 'Selection colour', sub: 'Tint of selected files and places.', section: 'style-colours', page: 'appearance', icon: 'select', keywords: 'marked highlight tint color', store: DRAFT }, { id: 'c-text', label: 'Text colour', sub: 'File names, labels and readouts.', section: 'style-colours', page: 'appearance', icon: 'text', keywords: 'ink foreground color', store: DRAFT }, { id: 'c-folder-icon', label: 'Folder icon colour', sub: 'Folder icons in the file tree.', section: 'style-colours', page: 'appearance', icon: 'folder', keywords: 'folders zip color', store: DRAFT }, - { id: 'c-font', label: 'App font', sub: 'The typeface used across the app.', section: 'app-text', page: 'appearance', icon: 'font', keywords: 'typeface face family', store: DRAFT }, - { id: 'tree-size', label: 'Interface text size', sub: 'Sidebar and settings text.', section: 'app-text', page: 'appearance', icon: 'size', keywords: 'font zoom bigger smaller scale', store: ['prism.tree.size'] }, + { id: 'c-font', label: 'Font', sub: 'The typeface used across the app.', section: 'app-text', page: 'appearance', icon: 'font', keywords: 'typeface face family', store: DRAFT }, + { id: 'tree-size', label: 'Font size', sub: 'Sidebar and settings text.', section: 'app-text', page: 'appearance', icon: 'size', keywords: 'font zoom bigger smaller scale', store: ['prism.tree.size'] }, { id: 'title-bar', label: 'Show title bar', sub: 'When off, tabs share the top row.', section: 'window', page: 'appearance', icon: 'titlebar', keywords: 'caption top frame hide hidden', store: ['prism.window.titleBar'] }, { id: 'tab-width', label: 'Tab width', sub: 'Sized to the name, or all equal.', section: 'window', page: 'appearance', icon: 'tabs', keywords: 'size wide narrow equal fixed dynamic', store: ['prism.window.tabWidth'] }, { id: 'c-edges', label: 'Panel edges', sub: 'Lines between panels and around the window.', section: 'window', page: 'appearance', icon: 'edges', keywords: 'border lines hairline outline faint strong', store: DRAFT }, diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index a558fa5c..23ada03b 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -13144,7 +13144,7 @@ const settingsLookOf = (win) => * and never the accent, Save changes the only accent-filled buttons, rows at * least 58px with a 32px tile, the panel's corner the style's roundness plus * 3px (Onyx 2px gives 5px, Ruby 14px gives 17px), nothing sideways at 1600 - * and 900px or with Interface text size Large, the icon rail under 760px and + * and 900px or with Font size Large, the icon rail under 760px and * from the title bar's toggle. A screenshot of every page in both schemes, * LOOKED AT before a change is called done (#20 in Prism Terminal). * @@ -13232,11 +13232,11 @@ async function settingsLookScenario(fixtures) { await win.screenshot({ path: join(SHOTS, `settings-appearance-${style}.png`) }) } await switchStyle(win, 'aurora', 'dark') - // Interface text size Large zooms the page by 1.12: nothing overflows. + // Font size Large zooms the page by 1.12: nothing overflows. const size = await gotoPref(win, 'tree-size') await size.locator('#tree-size').click() await win.locator('[data-pref="tree-size"] [role="option"]:has-text("Large")').click() - ok(await until(() => win.evaluate(() => localStorage.getItem('prism.tree.size') === 'large'), 3000, 50), 'Interface text size is Large') + ok(await until(() => win.evaluate(() => localStorage.getItem('prism.tree.size') === 'large'), 3000, 50), 'Font size is Large') for (const [page, view] of pages) { await settingsPage(win, page) if (view) await win.locator(`[data-seg="${view}"]`).click() From 755aec387ac99dc6c88d27d2c7e1f63c3bed08dc Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:56:37 +0200 Subject: [PATCH 5/5] feat(settings): style cards preview a small Explorer (#292) The owner's pick of five mockups (2026-10-06, "E. Explorer this is the style to go with"): each style card draws a breadcrumb, folders in the style's folder colour, files in their kind tints, one row marked in the style's selection tint and a progress line in the accent, on the style's own ground (frosted for a see-through style). Every colour comes from derive() and folderIconOf(), so the live card follows an edit. The shared red-to-blue square is gone. Card size, name, chosen ring and hover are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- src/renderer/src/components/StyleMini.tsx | 176 +++++++++++++++------- 1 file changed, 119 insertions(+), 57 deletions(-) diff --git a/src/renderer/src/components/StyleMini.tsx b/src/renderer/src/components/StyleMini.tsx index fba6169a..0deec0fc 100644 --- a/src/renderer/src/components/StyleMini.tsx +++ b/src/renderer/src/components/StyleMini.tsx @@ -1,77 +1,139 @@ -import type { JSX } from 'react' +import { useMemo, type JSX } from 'react' import { FrostBackdrop } from './FrostBackdrop' -import { mix, paintedAlpha, paletteOf, rgba, type Style } from '../lib/theme' +import { derive, folderIconOf, paintedAlpha, paletteOf, rgba, type Style } from '../lib/theme' + +/** + * A style's card preview: a small Explorer, the list most of the time is + * spent in (owner, 2026-10-06, of five mockups: "E. Explorer this is the + * style to go with"; research/prism/2026-10-05-settings-no-subtext/ + * style-cards). A breadcrumb, folders in the style's folder colour, files in + * their kind tints, one row marked in the style's own selection tint, and a + * progress line in the accent, all on the style's ground. It replaced a + * window outline round the same red-to-blue square on every card, where only + * the accent bars changed and Aurora, Void and Onyx read alike. + * + * Every colour is the one the window itself paints (`derive`, `folderIconOf`), + * so an edited style's live card follows the edit. Sizes are whole pixels and + * the strokes 2px: finer detail turned to texture at this size, at 100% and + * at 225% alike. + */ + +// The rows: kind, name length, size column (none on a folder). The second +// row is the marked one. +const ROWS: Array<[string, string, string]> = [ + ['folder', '58%', ''], + ['folder', '70%', ''], + ['folder', '46%', ''], + ['image', '64%', '22%'], + ['video', '52%', '28%'], + ['audio', '60%', '18%'] +] +const MARKED = 1 + +/** Corners follow the style, scaled to the card: square Void, round Ruby. */ +const ROW_RADIUS: Record = { '2': 1, '8': 3, '14': 5 } + +const FolderIcon = ({ c, w }: { c: string; w: number }): JSX.Element => ( + + + +) + +const FileIcon = ({ c }: { c: string }): JSX.Element => ( + + + +) + +const Bar = ({ w, c }: { w: string; c: string }): JSX.Element => ( + +) -/** A miniature of the main window in a given style: the card IS the preview. */ export function StyleMini({ st }: { st: Style }): JSX.Element { - const palette = paletteOf(st.accent) - const accent = palette[0] - const paint = palette.length > 1 ? `linear-gradient(90deg, ${palette.join(', ')})` : accent - const tint = st.material === 'tinted' - const grad = st.material === 'gradient' - // Frost, for real: the window paints translucent surfaces over the desktop, - // so the card does the same - a wallpaper-ish backdrop behind surfaces at the - // exact alpha the window uses - rather than pretending the style is solid. - const glassA = paintedAlpha(st) - const frosted = glassA < 1 - // The same numbers variablesFor uses, so the card's glow matches the window's. - const washA = st.mode === 'light' ? 0.28 : 0.22 - // ONE surface, like the real window: variablesFor derives panel and title - // from `bg` (the Style's side/title fields are legacy), so the card must - // too - drawing them from the old fields left the mini's panel stale when - // the Background was edited, while the actual window followed. - const gradBg = `linear-gradient(180deg, ${mix(st.bg, '#ffffff', 0.06)}, ${st.bg})` - const bg = tint ? mix(st.bg, accent, 0.07) : frosted ? rgba(st.bg, glassA) : st.bg - const side = grad ? gradBg : bg - const title = side - const dim = mix(st.text, st.bg, 0.5) - const line = (w: string, c: string): JSX.Element => ( - - ) + const look = useMemo(() => { + const v = derive(st) + const palette = paletteOf(st.accent) + const light = st.mode === 'light' + // Frost, for real: a see-through style paints its ground over the + // desktop, so the card does too, at the alpha the window uses. + const glassA = paintedAlpha(st) + return { + palette, + light, + frosted: glassA < 1, + ground: glassA < 1 ? rgba(v['--p-bg'], glassA) : v['--p-bg'], + text: v['--p-text'], + dim: v['--p-dim'], + dim2: v['--p-dim2'], + tint: v['--p-sel-tint'], + tintLine: v['--p-sel-line'], + track: v['--p-track'], + folder: folderIconOf(st), + kind: (k: string): string => v['--p-kind-' + k], + edge: rgba(st.text, light ? 0.1 : 0.075), + paint: palette.length > 1 ? `linear-gradient(90deg, ${palette.join(', ')})` : palette[0], + radius: ROW_RADIUS[st.corners] ?? 3 + } + }, [st]) + const washA = look.light ? 0.28 : 0.22 return (
- {frosted && } + {look.frosted && } +
{st.wash && (
)} + {/* The breadcrumb: the folder, a step back, the one you are in. */}
- - {line('30%', rgba(st.text, 0.5))} + + + + + + +
+
+ {ROWS.map(([k, w, size], i) => { + const marked = i === MARKED + return ( +
+ + {k === 'folder' ? : } + + + + {size && } +
+ ) + })}
-
-
- {line('62%', rgba(dim, 0.6))} - {line('80%', rgba(st.text, 0.5))} - - {line('86%', rgba(st.text, 0.3))} - {line('68%', rgba(st.text, 0.3))} -
-
-
-
- -
-
+ {/* A thin progress line in the accent, on the style's own track. */} +
+
)