diff --git a/CLAUDE.md b/CLAUDE.md
index f147ab4b..96c1e551 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -98,7 +98,11 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan:
"Colours of Volt": "dont have this show the theme name, just call that section colours"). The folder
browser paints no ground; its address row, list, preview slot and status paint `--p-bg`, the places
panel (and `.browse-viewer-places`) `--p-side`, the Settings rail's and project tree's colour, one
- coat each. Every catalogue theme's panel differs from its ground. The `sidebarGround` e2e holds both.
+ coat each. Every catalogue theme's panel differs from its ground EXCEPT VOID, which is all black
+ (#313; owner, 2026-10-07, of Background #000000 and Sidebar and tab bar colour #070707: "make void
+ fully black for both of these"): its panel is #000000, its panels told apart by its faint edges. A
+ saved draft colour the theme has since caught up with is dropped at load (`withoutOwn`), so a
+ hand-set black on Void shows no Reset and no lit Save changes. The `sidebarGround` e2e holds both.
**THE ADDRESS ROW WEARS IT TOO** (#306; owner, 2026-10-07, of a mockup with the row in the panel
colour: "yes make this the same color as the sidebar"): back/forward/up/refresh, the address field,
preview and search, across the window (`.folder-browser > .browse-toolbar`, and
diff --git a/package-lock.json b/package-lock.json
index 6abb6c33..064162d6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "prism",
- "version": "0.94.2",
+ "version": "0.94.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "prism",
- "version": "0.94.2",
+ "version": "0.94.3",
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.11.0",
diff --git a/package.json b/package.json
index 85404888..170ad703 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "prism",
- "version": "0.94.2",
+ "version": "0.94.3",
"description": "A fast, universal media viewer for Windows.",
"author": "Max",
"license": "MIT",
diff --git a/src/renderer/src/components/browse/drive-rows.css b/src/renderer/src/components/browse/drive-rows.css
index 21c27ca4..a17dda65 100644
--- a/src/renderer/src/components/browse/drive-rows.css
+++ b/src/renderer/src/components/browse/drive-rows.css
@@ -110,6 +110,20 @@ section[data-drive-style='tiles'] > h2 + .browse-drive-tiles {
.folder-browser .browse-place.browse-drive-tiles:not([aria-current]):not([data-menu]):hover {
background-color: color-mix(in srgb, var(--p-text) 7.5%, transparent);
}
+/* On a black sidebar the same steps glare (owner, 2026-10-07, of the drive
+ tiles on Void: "too much of a contrast"): about half as strong, hover
+ included, the track still the clearest grey so the bar reads. */
+:root[data-side='black'] .folder-browser .browse-place.browse-drive {
+ --drive-track: color-mix(in srgb, var(--p-text) 10%, transparent);
+ --drive-chip: color-mix(in srgb, var(--p-text) 4%, transparent);
+}
+:root[data-side='black'] .folder-browser .browse-place.browse-drive-tiles {
+ --drive-tile: color-mix(in srgb, var(--p-text) 2.5%, transparent);
+ --drive-chip: color-mix(in srgb, var(--p-text) 4%, transparent);
+}
+:root[data-side='black'] .folder-browser .browse-place.browse-drive-tiles:not([aria-current]):not([data-menu]):hover {
+ background-color: color-mix(in srgb, var(--p-text) 4.5%, transparent);
+}
.folder-browser .browse-place.browse-drive-tiles[aria-current] {
border-color: transparent;
}
diff --git a/src/renderer/src/lib/fieldColours.test.ts b/src/renderer/src/lib/fieldColours.test.ts
index 42cc47e5..9992fbc0 100644
--- a/src/renderer/src/lib/fieldColours.test.ts
+++ b/src/renderer/src/lib/fieldColours.test.ts
@@ -41,7 +41,7 @@ describe('the toolbar fields (#267)', () => {
it('Void wears the dark field; Paper the control step; both off the row (#306)', () => {
const v = variablesFor(byId('new-void'))
const vRow = v['--p-side-flat']
- expect(vRow).toBe('#070707')
+ expect(vRow).toBe('#000000')
expect(v['--p-field']).toBe(nearBlackField('#000000', '#e8eaf0', vRow)!.fill)
expect(contrastOf(v['--p-field-edge'], vRow)).toBeGreaterThanOrEqual(1.6)
expect(contrastOf(v['--p-field-edge'], vRow)).toBeLessThan(1.8)
diff --git a/src/renderer/src/lib/theme.ts b/src/renderer/src/lib/theme.ts
index 2c07fc05..bd996c3d 100644
--- a/src/renderer/src/lib/theme.ts
+++ b/src/renderer/src/lib/theme.ts
@@ -14,7 +14,7 @@ import {
withAlpha
} from 'prism-term-core/renderer/lib/colour'
import { accentAlphaOf, fillOf } from './accentAlpha'
-import { controlFieldOn, hintOn, nearBlackField } from './fieldColours'
+import { controlFieldOn, hintOn, isNearBlack, nearBlackField } from './fieldColours'
import { THEME_STYLES } from './themes/catalogue'
import { migrateThemeStorage, RETIRED_KEY } from './themes/migrate'
import { RETIRED_MAP } from './themes/retired'
@@ -1097,9 +1097,17 @@ function mirrorMode(mode: Mode): void {
function paint(style: Style, preview = false): void {
const r = document.documentElement.style
- for (const [k, v] of Object.entries(variablesFor(style))) r.setProperty(k, v)
+ const vars = variablesFor(style)
+ for (const [k, v] of Object.entries(vars)) r.setProperty(k, v)
document.documentElement.dataset.mode = style.mode
+ // A sidebar that is black or near it (Void since #313) gets quieter raised
+ // surfaces: a step that reads as a soft lift on a grey panel is a glare on
+ // black (owner, 2026-10-07, of the drive tiles on Void: "too much of a
+ // contrast"). The CSS reads this; no colour is chosen here.
+ const side = vars['--p-side-flat']
+ if (side && isNearBlack(side)) document.documentElement.dataset.side = 'black'
+ else delete document.documentElement.dataset.side
// A translucent style needs the window itself to be transparent, which only
// the main process can arrange.
const translucent = style.material === 'acrylic' || style.material === 'mica'
@@ -1462,6 +1470,7 @@ function loadRetired(): string | null {
let current = load()
// A preset that has since been deleted leaves a dangling id; normalise it.
current = allStyles().some((s) => s.id === current) ? current : DEFAULT_STYLE
+draft = settledDraft(draft)
let retired: string | null = loadRetired()
/** The theme the window is PREVIEWING (the wall's arrows), or null. */
let previewing: string | null = null
@@ -1636,6 +1645,17 @@ function commitDraft(next: Overrides): void {
apply()
}
+/** Is this panel pick the style's own panels? Eight digits are judged on
+ * their spelling (see `withColour`), six on the colour. */
+function chromeIsOwn(base: Style, value: string): boolean {
+ if (value.length !== 9) return isStylesOwn(base, 'chrome', value)
+ return [
+ base.sideOwn ? base.side : sideOf(base),
+ base.titleOwn ? base.title : titleOf(base),
+ base.tabsOwn && base.tabs ? base.tabs : tabsOf(base)
+ ].every((c) => c.toLowerCase() === value.toLowerCase())
+}
+
/** A colour role set on a draft, or cleared when it is the style's own. */
function withColour(next: Overrides, role: 'bg' | 'chrome' | 'accent', value: string): Overrides {
// A panel colour with an alpha of its own (eight digits, `ff` included) is
@@ -1643,20 +1663,44 @@ function withColour(next: Overrides, role: 'bg' | 'chrome' | 'accent', value: st
// drops, so a solid panel on glass in the colour it already had read as the
// style's own put back and was thrown away (review of #251).
const base = byId(current)
- const own =
- role === 'chrome' && value.length === 9
- ? [
- base.sideOwn ? base.side : sideOf(base),
- base.titleOwn ? base.title : titleOf(base),
- base.tabsOwn && base.tabs ? base.tabs : tabsOf(base)
- ].every((c) => c.toLowerCase() === value.toLowerCase())
- : isStylesOwn(base, role, value)
+ const own = role === 'chrome' ? chromeIsOwn(base, value) : isStylesOwn(base, role, value)
if (role === 'chrome') return withChrome(next, own ? null : value)
if (own) delete next[role]
else next[role] = value
return next
}
+/**
+ * A SAVED EDIT THE THEME HAS SINCE CAUGHT UP WITH IS NO EDIT (#313; owner,
+ * 2026-10-07, of Void: "make void fully black for both of these", having set
+ * its sidebar colour to #000000 by hand). A colour put back is judged when it
+ * is written (`withColour`); a theme that moves to a colour already in the
+ * draft is judged here, at load: each colour that now equals the style's own
+ * is dropped, so its Reset link and Save changes go. Pure, so it is testable;
+ * the same object back when nothing moved.
+ */
+export function withoutOwn(base: Style, o: Overrides): Overrides {
+ let next: Overrides | null = null
+ const drop = (keys: Array): void => {
+ next = next ?? { ...o }
+ for (const k of keys) delete (next as Overrides)[k]
+ }
+ for (const role of ['bg', 'accent', 'text', 'folderIcon', 'selection'] as const) {
+ const v = o[role]
+ if (typeof v === 'string' && v && isStylesOwn(base, role, v)) drop([role])
+ }
+ if (o.side && o.side === o.title && o.side === o.tabs && chromeIsOwn(base, o.side)) drop(['side', 'title', 'tabs'])
+ return next ?? o
+}
+
+/** The draft as loaded, settled against the style on screen; written back
+ * when that dropped something, so storage says what the window shows. */
+function settledDraft(o: Overrides): Overrides {
+ const next = withoutOwn(byId(current), o)
+ if (next !== o) saveJson(DRAFT_KEY, next)
+ return next
+}
+
/**
* The Primary row's picker (decision 1). The colour's six digits are the
* background; its alpha is the Acrylic slider's level, written ONLY when the
@@ -1927,6 +1971,7 @@ if (typeof window !== 'undefined') {
presets = cleanPresets(loadJson(PRESETS_KEY, []))
draft = cleanDraft(loadJson(DRAFT_KEY, {}))
current = load()
+ draft = withoutOwn(byId(current), draft)
retired = loadRetired()
version += 1
// The originating window already saved related accent preferences. Receiving
diff --git a/src/renderer/src/lib/theme.voidBlack.test.ts b/src/renderer/src/lib/theme.voidBlack.test.ts
new file mode 100644
index 00000000..d899ae0b
--- /dev/null
+++ b/src/renderer/src/lib/theme.voidBlack.test.ts
@@ -0,0 +1,79 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { STYLES, variablesFor, withoutOwn, type Style } from './theme'
+import { CATALOGUE } from './themes/catalogue'
+
+// VOID IS ALL BLACK (#313; owner, 2026-10-07, of Void's Background #000000
+// and "Sidebar and tab bar colour" #070707: "make void fully black for both of
+// these"). The other themes keep a panel a step off their ground (#302).
+
+const VOID = STYLES.find((s) => s.id === 'new-void') as Style
+
+describe('Void', () => {
+ it('paints the sidebar, the title bar and the tab bar the black of its ground', () => {
+ const v = variablesFor(VOID)
+ expect(VOID.bg).toBe('#000000')
+ for (const k of ['--p-bg', '--p-side', '--p-side-flat', '--p-title', '--p-tabs', '--p-tab-active']) expect(v[k], k).toBe('#000000')
+ })
+
+ it('still draws its edges, its menus and its selection off the black', () => {
+ const v = variablesFor(VOID)
+ expect(v['--p-edge']).not.toBe('#000000')
+ expect(v['--p-raised']).toBe('#0f0f10')
+ expect(v['--p-line']).toBe('#151516')
+ expect(v['--p-sel-tint-seen']).not.toBe('#000000')
+ })
+
+ it('is the only theme whose panel is its ground', () => {
+ expect(CATALOGUE.filter((t) => t.panel === t.ground).map((t) => t.id)).toEqual(['new-void'])
+ })
+})
+
+describe('a saved edit the theme has caught up with', () => {
+ it('is no edit: a hand-set black sidebar on Void is dropped, the rest kept', () => {
+ const draft = { side: '#000000', title: '#000000', tabs: '#000000', accent: '#22aa66' }
+ expect(withoutOwn(VOID, draft)).toEqual({ accent: '#22aa66' })
+ expect(withoutOwn(VOID, { side: '#000000', title: '#000000', tabs: '#000000' })).toEqual({})
+ })
+
+ it('keeps a colour that differs, and hands the same draft back when nothing moved', () => {
+ const draft = { side: '#070707', title: '#070707', tabs: '#070707' }
+ expect(withoutOwn(VOID, draft)).toBe(draft)
+ // A panel with an alpha of its own is not the theme's solid panel.
+ const own = { side: '#00000080', title: '#00000080', tabs: '#00000080' }
+ expect(withoutOwn(VOID, own)).toBe(own)
+ })
+
+ it("drops the other roles when they are the style's own", () => {
+ expect(withoutOwn(VOID, { bg: '#000000', text: VOID.text, accent: VOID.accent })).toEqual({})
+ })
+})
+
+describe('at the store', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ localStorage.clear()
+ vi.stubGlobal('window', new EventTarget())
+ })
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.resetModules()
+ })
+
+ it("the owner's hand-set black on Void opens as no edit: no Reset, Save changes not lit", async () => {
+ localStorage.setItem('prism.style', 'new-void')
+ localStorage.setItem('prism.style.v', '2')
+ localStorage.setItem('prism.style.draft', JSON.stringify({ side: '#000000', title: '#000000', tabs: '#000000' }))
+ const theme = await import('./theme')
+ expect(theme.currentStyle().id).toBe('new-void')
+ expect(theme.isEdited()).toBe(false)
+ expect(JSON.parse(localStorage.getItem('prism.style.draft') ?? 'null')).toEqual({})
+ })
+
+ it('an edit that still differs stays an edit', async () => {
+ localStorage.setItem('prism.style', 'new-void')
+ localStorage.setItem('prism.style.v', '2')
+ localStorage.setItem('prism.style.draft', JSON.stringify({ side: '#101010', title: '#101010', tabs: '#101010' }))
+ const theme = await import('./theme')
+ expect(theme.isEdited()).toBe(true)
+ })
+})
diff --git a/src/renderer/src/lib/themes/catalogue.json b/src/renderer/src/lib/themes/catalogue.json
index 25200809..bcf87500 100644
--- a/src/renderer/src/lib/themes/catalogue.json
+++ b/src/renderer/src/lib/themes/catalogue.json
@@ -57,7 +57,7 @@
"corners": "2",
"edges": "faint",
"ground": "#000000",
- "panel": "#070707",
+ "panel": "#000000",
"raised": "#0f0f10",
"line": "#151516",
"text": "#e8eaf0",
diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs
index 7341317c..a1bb2376 100644
--- a/tools/e2e/run.mjs
+++ b/tools/e2e/run.mjs
@@ -9104,6 +9104,8 @@ async function sidebarPlacesScenario(fixtures) {
* panel's ground is not the list's and IS the Settings rail's: both are
* --p-side, the "Sidebar and tab bar colour". Before, the places panel was
* the folder browser's --p-bg. The see-through count of coats is seeThrough's.
+ * Void is the exception (#313; owner, 2026-10-07: "make void fully black for
+ * both of these"): its sidebar colour is its ground, so there both are black.
*/
async function sidebarGroundScenario(fixtures) {
console.log('sidebar ground')
@@ -9152,13 +9154,18 @@ async function sidebarGroundScenario(fixtures) {
row = await groundOf('.folder-browser > .browse-toolbar')
return places === side && row === side
}, 4000, 100)
- ok(places !== null && places !== list, `${style}: the places panel's ground is not the list's (${places} vs ${list})`)
+ if (style === 'new-void')
+ // VOID IS ALL BLACK (#313; owner, 2026-10-07: "make void fully black
+ // for both of these"): its sidebar colour is its ground, #000000.
+ ok(places === 'rgb(0, 0, 0)' && list === 'rgb(0, 0, 0)', `${style}: the places panel and the list are both black (${places} vs ${list})`)
+ else ok(places !== null && places !== list, `${style}: the places panel's ground is not the list's (${places} vs ${list})`)
ok(places === side, `${style}: the places panel wears the sidebar colour (${places}, --p-side ${side})`)
// 3. THE ADDRESS ROW (#306; owner, 2026-10-07: "yes make this the same
// color as the sidebar"): the places panel's ground, not the list's,
// its hairline kept, and its field still a box on it.
ok(row === places, `${style}: the address row wears the places panel's ground (${row} vs ${places})`)
- ok(row !== list, `${style}: and not the list's (${row} vs ${list})`)
+ // Void's list is black like its row (#313), so only the others differ.
+ if (style !== 'new-void') ok(row !== list, `${style}: and not the list's (${row} vs ${list})`)
const rowLook = await win.evaluate(() => {
const tb = document.querySelector('.folder-browser > .browse-toolbar')
const field = tb?.querySelector('nav.browse-path')
@@ -9182,6 +9189,9 @@ async function sidebarGroundScenario(fixtures) {
const rail = await groundOf('[data-settings-page] > nav')
ok(rail === places, `${style}: and that is the Settings rail's ground (${rail})`)
ok(rail === row, `${style}: and the address row's (${row})`)
+ await win.mouse.move(2, 400)
+ await sleep(600)
+ await win.screenshot({ path: join(SHOTS, `sidebar-ground-settings-${style}.png`) })
await win.click('[aria-label="Settings"]')
await sleep(300)
}