diff --git a/CLAUDE.md b/CLAUDE.md
index c3397aec..cd3d7086 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 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'
@@ -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 => (
-
- )
- }
-}
-
-/* ---------- 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 (
-
-
-
- )
-}
-
-/** 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 (
-
-
- )
- })}
-
- {/* 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. */}
-
-
- )
-}
-
-/* ---------- 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. */}
-
-
-
-
-
- setOverride('borders', v)}
- options={EDGE_OPTIONS}
- />
-
- {/* PRIMARY and SECONDARY (owner, 2026-09-03): the viewer's ground,
- and the panels around it. Accent, Text and the rest stay their
- own things. */}
- {/* PRIMARY'S ALPHA IS THE OLD ACRYLIC SLIDER (owner, 2026-10-03,
- decision 1: it replaces the slider "under the slider's own
- rule", saved levels mapping 1:1). Below 100 the window is glass
- at the level that alpha paints (a mica style stays mica), at
- 100 it is solid; the range is the slider's two ends. A hue edit
- never touches the glass. Every style still takes frost (owner,
- 2026-08-08). */}
- {/* THE COLOURS RUN BY IMPORTANCE (owner, 2026-10-03: "make the most
- important colours appear first ... primary and secondary first then
- accent"): Primary, Secondary, Accent, Selection, Text, Folder
- icons. Selection sits by the Accent it used to be part of. */}
-
- {
- setOverride('bg', null)
- setAcrylic(null)
- }}
- onRevert={putBack(['bg', 'acrylic'])}
- alphaMin={PRIMARY_ALPHA_MIN}
- snapAlpha={snapPrimaryAlpha}
- />
-
- {/* The sidebar broke out of the one-surface rule (owner, 2026-09-03).
- Background still paints the window; this narrows it - unset, the
- panel keeps deriving from Background exactly as before, and only
- an explicit pick gives it a colour of its own (`sideOwn`). */}
- {/* ONE PICK for the sidebar, the title bar and the tab bar (owner,
- 2026-09-03): changing one of them means adjusting the others, so
- they moved as three wells for an afternoon and are one again.
- The model still keeps them apart; the well writes all three.
- Unset, each keeps deriving from Background exactly as before. */}
-
- {/* 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'])}
- />
-
- {/* One picker, like Background and Text: the accent is a colour you
- choose, not a scheme you browse. (The swatch grid lived here until
- 2026-08-21.) Its alpha is in the picker (#249; owner, 2026-10-02:
- "the accent colour should be able to have an alpha value", fills
- only), not a slider of its own (owner, 2026-10-03: "it should not
- be a separate opacity setting"). A Pref, so its description is read
- by the plain-words test. */}
-
-
-
- {/* THE SELECTION IS ITS OWN ROW (#257; owner, 2026-10-03: "the
- settings accent colour for the tab should be separated from the
- explorer accent colour"). Its colour and alpha ARE the tint of
- marked files and the current place; the Accent keeps the Settings
- rail, buttons and progress. Unset it shows the accent's tint,
- which is what is on screen, 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'])}
- />
-
- {/* 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 so a style saved while it
- existed cannot strand anyone on a set they can no longer change.
- Everything behind it is intact - IconScheme, iconSchemeOf, the
- iconScheme override, ICON_COLOURS and ICON_FULL_COLOUR - so
- bringing it back is a Pref with a Segmented of Monochrome /
- Coloured wired to setOverride('iconScheme'), plus flipping
- ICON_SCHEME_SHOWN. The zip and the comic are coloured regardless
- of any of it. */}
-
- setOverride('corners', v)}
- options={CORNER_OPTIONS}
- />
-
-
-
-
- )
-}
-
-/* ---------- general ---------- */
-
-// General is a plain list: one row per setting, name + one line of explanation
-// on the left, its control on the right, hairline between. No panels — the
-// page is short enough that grouping it would be ceremony.
-
-// A list of preference rows, hairline above and below each one. Full width: the
-// control sits at the right edge of the page, where the eye already is.
-const ROWS = 'border-t border-[color:var(--p-line)]'
-
-/** One setting: copy on the left, control on the right. The hint is one line —
- * it truncates rather than wraps, because a setting that needs a paragraph
- * needs a better name instead. */
-function Pref({
- id,
- label,
- hint,
- children
-}: {
- id: string
- label: string
- hint?: string
- children: ReactNode
-}): JSX.Element {
- return (
-
-
-
- {hint &&
{hint}
}
-
-
{children}
-
- )
-}
-
-/** A native select - keyboard and screen-reader behaviour for free, and Chromium
- * renders its popup in the right scheme because :root sets color-scheme. */
-/** A styled dropdown, not a native select: the popup wears the app's own menu
- * look (flat panel, hover token, a check on the active row), and each option
- * can carry a style - which is how the font pickers preview their faces. */
-function Select({
- id,
- value,
- onChange,
- options
-}: {
- id: string
- value: string
- onChange: (v: string) => void
- options: Array<{ id: string; name: string; style?: React.CSSProperties }>
-}): JSX.Element {
- const [open, setOpen] = useState(false)
- const box = useRef(null)
- useEffect(() => {
- if (!open) return
- const close = (): void => setOpen(false)
- const onKey = (e: KeyboardEvent): void => {
- if (e.key === 'Escape') {
- e.stopPropagation()
- close()
- }
- }
- const onDown = (e: PointerEvent): void => {
- if (!box.current?.contains(e.target as Node)) close()
- }
- window.addEventListener('keydown', onKey, true)
- window.addEventListener('pointerdown', onDown, true)
- window.addEventListener('blur', close)
- return () => {
- window.removeEventListener('keydown', onKey, true)
- window.removeEventListener('pointerdown', onDown, true)
- window.removeEventListener('blur', close)
- }
- }, [open])
- const cur = options.find((o) => o.id === value)
- return (
-
-
- {open && (
-
- {options.map((o) => {
- const on = o.id === value
- return (
-
- )
- })}
-
- )}
-
- )
-}
-
-
-
-
-function TerminalTab(): JSX.Element {
- // THE TERMINAL'S SETTINGS ARE prism-term-core's (#154): 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, and each app keeps
- // its own. This tab is just where Prism puts them.
- return (
-
-
-
- {/* The Agent indicator sits with its two colours, in the core's own
- list, so both apps show the terminal rows in one order (2026-09-22). */}
- {/* No command help in Prism (owner, 2026-09-22): it is Prism Terminal's. */}
-
-
-
- )
-}
-
-function GeneralTab(): JSX.Element {
- // Explorer's context-menu verb lives in the registry, not in settings.json:
- // the switch reports what Windows actually has, and says so if it refuses.
- 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 size = useTreeSize()
- const follow = useAutoScroll()
- 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 side = useTreeSide()
- return (
-
-
-
-
-
-
-
-
-
-
-
- {/* 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 (
-
- )
-}
-
-// 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 (
-
- )
-}
-
-/* ---------- 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/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