From 5e01e4f777ee0565d9b8dd0511f174777738b76a Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:40:05 +0200 Subject: [PATCH 1/2] fix(style): Void is black in the sidebar and tab bar too (#313) Void's panel colour (sidebar, title bar, tab bar) is #000000, its ground, by the owner's word. A saved draft colour the theme has since caught up with is dropped at load, so a hand-set black on Void is no edit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 6 +- package-lock.json | 4 +- package.json | 2 +- src/renderer/src/lib/theme.ts | 53 +++++++++++-- src/renderer/src/lib/theme.voidBlack.test.ts | 79 ++++++++++++++++++++ src/renderer/src/lib/themes/catalogue.json | 2 +- tools/e2e/run.mjs | 11 ++- 7 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/theme.voidBlack.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index da012e53..40677e0f 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. ## Scope diff --git a/package-lock.json b/package-lock.json index 4f6150d3..f26af95d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.92.1", + "version": "0.92.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.92.1", + "version": "0.92.2", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index be068b68..ba1300f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.92.1", + "version": "0.92.2", "description": "A fast, universal media viewer for Windows.", "author": "Max", "license": "MIT", diff --git a/src/renderer/src/lib/theme.ts b/src/renderer/src/lib/theme.ts index fc811106..707d6cff 100644 --- a/src/renderer/src/lib/theme.ts +++ b/src/renderer/src/lib/theme.ts @@ -1459,6 +1459,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 @@ -1633,6 +1634,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 @@ -1640,20 +1652,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 @@ -1924,6 +1960,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 036981dc..26a198fd 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -9012,6 +9012,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') @@ -9058,7 +9060,11 @@ async function sidebarGroundScenario(fixtures) { list = await groundOf('.folder-browser > .browse-list-area') return places === 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})`) await win.mouse.move(2, 400) await sleep(300) @@ -9067,6 +9073,9 @@ async function sidebarGroundScenario(fixtures) { await win.waitForSelector('[data-settings-page] > nav', { timeout: 10000 }) const rail = await groundOf('[data-settings-page] > nav') ok(rail === places, `${style}: and that is the Settings rail's ground (${rail})`) + 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) } From df2b6ffb31e3427d788987a40ad26e7765c7dab3 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:47:47 +0200 Subject: [PATCH 2/2] fix(style): quieter drive tiles on a black sidebar (#313) Owner, 2026-10-07, of the drive tiles on Void: "these dont look great on void its too much of a contrast". A sidebar that is black or near it sets data-side=black on the root, and the tiles, chips, hover and track take about half the step there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- src/renderer/src/components/browse/drive-rows.css | 14 ++++++++++++++ src/renderer/src/lib/theme.ts | 12 ++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) 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/theme.ts b/src/renderer/src/lib/theme.ts index 707d6cff..90ab566d 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 { hintOn, nearBlackField } from './fieldColours' +import { hintOn, isNearBlack, nearBlackField } from './fieldColours' import { THEME_STYLES } from './themes/catalogue' import { migrateThemeStorage, RETIRED_KEY } from './themes/migrate' import { RETIRED_MAP } from './themes/retired' @@ -1094,9 +1094,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'