diff --git a/CLAUDE.md b/CLAUDE.md index 96c1e551..1d884993 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ was such a decision: a navigation panel bounded by the folder Prism opened in, n ahead and build"; spec `docs/superpowers/specs/2026-10-06-new-themes-design.md`, research and the approved mockup in `Documents/Claude/research/prism/2026-10-06-new-themes/`). The ten old styles are gone (Aurora, Void and Frost kept, refined). The rules (`lib/theme.ts`, `lib/themes/`): -- **The 18, in wall order**: dark Aurora, Void (`new-void`), Carbon, Obsidian, Ember, Volt, +- **The 18, in wall order**: dark Aurora, Void (`new-void`), Carbon, Crimson, Jade, Volt, Midnight HC, Glacier, Lagoon; light Frost, Paper, Sand, Sage, Blush, Chalk, Daylight HC, Orchid, Pearl. High contrast before see-through in each half. Colours exactly as `themes/catalogue.json` (`catalogue.test.ts`); HC themes are MONOCHROME. Every theme sets in the SYSTEM face at 12.5px @@ -57,9 +57,20 @@ are gone (Aurora, Void and Frost kept, refined). The rules (`lib/theme.ts`, `lib Escape goes back. `useStyle` is the KEPT theme: re-rendering its readers on every arrow put a held Right over 50ms (MEASURED). This theme: See-through window (not on HC) and Save changes. - **Retired ids migrate once** (`themes/migrate.ts`, marker `prism.style.v`): Onyx to Void WITH - its glass as an unsaved edit (level 55), Terminal to Obsidian, Driftwood to Carbon, Ruby to - Ember, Linen to Sand; own copies kept, their `base` mapped; one quiet line names the retired - theme until the next pick. Onboarding is three steps, the first the same wall. + its glass as an unsaved edit (level 55), Terminal to Jade (Obsidian until #316), Driftwood to + Carbon, Ruby to Crimson (Ember until #316), Linen to Sand; own copies kept, their `base` mapped; + one quiet line names the retired theme until the next pick. Every map target is a current theme + (`migrate.test.ts`), so a later pass never chains. +- **JADE AND CRIMSON TOOK EMBER'S AND OBSIDIAN'S PLACES** (#316; owner, 2026-10-07, with Volt's + Colours edited to teal: "this theme should replace Ember. its Volt but with this teal instead of + the yellow"; then, with them edited to red `#ff2647`: "have this replace obsidian"). Each is Volt + with one colour for every yellow-green value (accent, selection at Volt's alpha `38`, folder, + archive, the accent-mixed keyword), built by the research `build-themes.mjs`: Jade `#26ffb2`, + Crimson `#ff2647` (near-black ink, 5.26:1; its warn orange and error reds held apart in + `contrast.test.ts`). Marker `4`: `ember` and `obsidian` map to `jade` (`RETIRED_NAMES` names + them on the quiet line), so a window on `2` moves too; a profile the first pass PLACED on Ember + for Ruby and that never picked since (its line still says Ruby: `PLACED_MOVES`) goes on to + Crimson. Onboarding is three steps, the first the same wall. - Not adopted from the mockup, by recorded rules: the active tab stays told by its ink, not a ground fill; the Accent subtext stays (the chosen rail page is grey since #292). - **A ZIP FOLLOWS THE FOLDER COLOUR**, not a hardcoded indigo (owner, 2026-09-20). `--p-tree-zip` diff --git a/package-lock.json b/package-lock.json index 064162d6..222becf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.94.3", + "version": "0.95.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.94.3", + "version": "0.95.0", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index 170ad703..e66be39d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.94.3", + "version": "0.95.0", "description": "A fast, universal media viewer for Windows.", "author": "Max", "license": "MIT", diff --git a/src/renderer/src/lib/accentAlpha.test.ts b/src/renderer/src/lib/accentAlpha.test.ts index 7871cf4b..2851edf1 100644 --- a/src/renderer/src/lib/accentAlpha.test.ts +++ b/src/renderer/src/lib/accentAlpha.test.ts @@ -159,7 +159,11 @@ describe('an icon knockout matches the row it sits on', () => { expect(t['--p-sel-seen']).toMatch(/^#[0-9a-f]{6}$/i) for (const s of STYLES) { const solid = derive(s) - expect(solid['--p-sel-seen']).toBe(selectionBg(paletteHex(s))) + // A theme's designed fill when it has one (#298): Crimson's red keeps its + // near-black ink at 5.26:1 where the derivation, which leans to white, + // would deepen it to #e0213e (#316). Either way it is the selection. + expect(solid['--p-sel-seen']).toBe(s.table?.accentFill ?? selectionBg(paletteHex(s))) + expect(solid['--p-sel-seen']).toBe(solid['--p-sel-bg']) expect(solid['--p-sel-knockout-side']).toBe(solid['--p-accent']) } }) diff --git a/src/renderer/src/lib/theme.test.ts b/src/renderer/src/lib/theme.test.ts index 576d7414..54d06756 100644 --- a/src/renderer/src/lib/theme.test.ts +++ b/src/renderer/src/lib/theme.test.ts @@ -196,7 +196,7 @@ describe('the progress bar follows the accent', () => { it('follows a style switch without being told', () => { setStyle('aurora') const auroraBar = resolveVizTheme(DEFAULT_BAR_THEME).accent - setStyle('ember') + setStyle('jade') expect(resolveVizTheme(DEFAULT_BAR_THEME).accent).not.toBe(auroraBar) }) }) @@ -391,7 +391,8 @@ describe("the owner's picks of 2026-09-20", () => { }) // Ruby's pin (near-black and red) retired with Ruby (#298): its - // successor in the migration is Ember, held by `themes/catalogue.test.ts`. + // successor in the migration is Crimson, the red of #316, held by + // `themes/migrate.test.ts`. it('hands the zip the folder colour, with an ink that reads on it', () => { // "the zip file icon should have dynamically adjusting colours based on the diff --git a/src/renderer/src/lib/themes/catalogue.json b/src/renderer/src/lib/themes/catalogue.json index bcf87500..00f9749b 100644 --- a/src/renderer/src/lib/themes/catalogue.json +++ b/src/renderer/src/lib/themes/catalogue.json @@ -138,95 +138,95 @@ } }, { - "id": "obsidian", - "name": "Obsidian", + "id": "crimson", + "name": "Crimson", "mode": "dark", - "blurb": "Black glass edge with an emerald accent.", + "blurb": "Black and electric red, cyberpunk.", "highContrast": false, "seeThrough": false, "groundAlpha": 1, "material": "oled", "corners": "2", "edges": "hairline", - "ground": "#050505", - "panel": "#0c0c0c", - "raised": "#141414", - "line": "#232323", - "text": "#ededed", - "textDim": "#909090", - "textFaint": "#6d6d6d", - "accent": "#10b981", - "accentSolid": "#10b981", + "ground": "#050706", + "panel": "#0c0e0d", + "raised": "#141615", + "line": "#232624", + "text": "#eef3ea", + "textDim": "#91958f", + "textFaint": "#6e716d", + "accent": "#ff2647", + "accentSolid": "#ff2647", "onAccent": "#0b0b0d", - "selection": "#10b98138", - "selectionSeen": "#072d20", - "folder": "#5fd4a8", + "selection": "#ff264738", + "selectionSeen": "#3c0e14", + "folder": "#ff2647", "kinds": { "image": "#6fb2a8", "video": "#8f8ae0", "audio": "#d3a06a", "pdf": "#cf7f88", "text": "#8d93a1", - "archive": "#5fd4a8" + "archive": "#ff2647" }, "code": { - "keyword": "#58a4ae", - "string": "#8be4a9", - "number": "#e4b88b", - "function": "#8bb8e4", - "type": "#8be4e4", - "tag": "#e48b9a", - "attribute": "#e4c68b", - "constant": "#e48bc6", - "comment": "#7e7e7e", - "punctuation": "#a7a7a7", - "activeLine": "#ededed09" + "keyword": "#ed4c95", + "string": "#85f47b", + "number": "#f4ad7b", + "function": "#7bd6f4", + "type": "#7bf4d6", + "tag": "#f47bad", + "attribute": "#f4e07b", + "constant": "#f47be0", + "comment": "#7a7d78", + "punctuation": "#a8aca6", + "activeLine": "#eef3ea09" } }, { - "id": "ember", - "name": "Ember", + "id": "jade", + "name": "Jade", "mode": "dark", - "blurb": "Near black with the app icon orange.", + "blurb": "Black and electric teal, cyberpunk.", "highContrast": false, "seeThrough": false, "groundAlpha": 1, - "material": "solid", - "corners": "8", - "edges": "faint", - "ground": "#0f0d0c", - "panel": "#161413", - "raised": "#1e1b1a", - "line": "#242120", - "text": "#f3ece6", - "textDim": "#98938f", - "textFaint": "#76716e", - "accent": "#ec9448", - "accentSolid": "#ec9448", + "material": "oled", + "corners": "2", + "edges": "hairline", + "ground": "#050706", + "panel": "#0c0e0d", + "raised": "#141615", + "line": "#232624", + "text": "#eef3ea", + "textDim": "#91958f", + "textFaint": "#6e716d", + "accent": "#26ffb2", + "accentSolid": "#26ffb2", "onAccent": "#0b0b0d", - "selection": "#ec944838", - "selectionSeen": "#402b19", - "folder": "#f0a868", + "selection": "#26ffb238", + "selectionSeen": "#0c3d2c", + "folder": "#26ffb2", "kinds": { "image": "#6fb2a8", "video": "#8f8ae0", "audio": "#d3a06a", "pdf": "#cf7f88", "text": "#8d93a1", - "archive": "#f0a868" + "archive": "#26ffb2" }, "code": { - "keyword": "#e89a67", - "string": "#bfe28d", - "number": "#e2bf8d", - "function": "#e2cd8d", - "type": "#8de2cd", - "tag": "#e28d8d", - "attribute": "#e2b88d", - "constant": "#d48de2", - "comment": "#86827e", - "punctuation": "#afa9a5", - "activeLine": "#f3ece609" + "keyword": "#75c4d0", + "string": "#85f47b", + "number": "#f4ad7b", + "function": "#7bd6f4", + "type": "#7bf4d6", + "tag": "#f47bad", + "attribute": "#f4e07b", + "constant": "#f47be0", + "comment": "#7a7d78", + "punctuation": "#a8aca6", + "activeLine": "#eef3ea09" } }, { diff --git a/src/renderer/src/lib/themes/catalogue.test.ts b/src/renderer/src/lib/themes/catalogue.test.ts index b8d1843a..26ed52ce 100644 --- a/src/renderer/src/lib/themes/catalogue.test.ts +++ b/src/renderer/src/lib/themes/catalogue.test.ts @@ -10,8 +10,8 @@ const ORDER = [ ['aurora', 'Aurora'], ['new-void', 'Void'], ['carbon', 'Carbon'], - ['obsidian', 'Obsidian'], - ['ember', 'Ember'], + ['crimson', 'Crimson'], + ['jade', 'Jade'], ['volt', 'Volt'], ['midnight-hc', 'Midnight HC'], ['glacier', 'Glacier'], diff --git a/src/renderer/src/lib/themes/contrast.test.ts b/src/renderer/src/lib/themes/contrast.test.ts index c17aa4ba..0a7e81f1 100644 --- a/src/renderer/src/lib/themes/contrast.test.ts +++ b/src/renderer/src/lib/themes/contrast.test.ts @@ -113,3 +113,61 @@ for (const s of STYLES) { } }) } + +/** CIE76 distance in Lab: about 2 is a just noticeable step. */ +const lab = (hex: string): [number, number, number] => { + const n = parseInt(hex.slice(1, 7), 16) + const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => { + const c = v / 255 + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + }) + const f = (t: number): number => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116) + const x = f((0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047) + const y = f(0.2126 * r + 0.7152 * g + 0.0722 * b) + const z = f((0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883) + return [116 * y - 16, 500 * (x - y), 200 * (y - z)] +} +const apart = (a: string, b: string): number => { + const [p, q] = [lab(a), lab(b)] + return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]) +} + +// CRIMSON, VOLT IN RED (#316; owner, 2026-10-07: "have this replace +// obsidian"). The first red accent on the wall, so the reds and oranges that +// mean something else must still be told from it. +describe('Crimson, a red accent', () => { + const s = STYLES.find((x) => x.id === 'crimson')! + const v = variablesFor(s) + + it('is Volt with the red', () => { + const volt = variablesFor(STYLES.find((x) => x.id === 'volt')!) + for (const k of ['--p-bg', '--p-side-flat', '--p-raised', '--p-line', '--p-text', '--p-dim', '--p-dim2', '--p-code-string', '--p-code-fn']) + expect(v[k], k).toBe(volt[k]) + expect(v['--p-accent']).toBe('#ff2647') + expect(v['--p-tree-folder']).toBe('#ff2647') + expect(v['--p-sel-tint']).toBe('#ff264738') + }) + + it('writes near-black on its fill, the ink that reaches 4.5:1 (white does not)', () => { + expect(v['--p-on-accent']).toBe('#0b0b0d') + expect(contrast('#0b0b0d', v['--p-accent'])).toBeGreaterThanOrEqual(4.5) + expect(contrast('#ffffff', v['--p-accent'])).toBeLessThan(4.5) + }) + + it("keeps a nearly full drive's orange apart from the red bar", () => { + // Below 90% a drive's bar is the accent; past it, --p-warn. + expect(apart(v['--p-warn'], v['--p-accent-solid'])).toBeGreaterThanOrEqual(40) + expect(apart(v['--p-warn-ink'], v['--p-accent-solid'])).toBeGreaterThanOrEqual(40) + expect(contrast(v['--p-warn'], v['--p-side-flat'])).toBeGreaterThanOrEqual(3) + expect(contrast(v['--p-warn-ink'], v['--p-side-flat'])).toBeGreaterThanOrEqual(4.5) + }) + + it('keeps the error reds apart from the accent', () => { + // Code's invalid mark, a danger button's fill, a danger menu row's ink. + for (const c of [v['--p-code-invalid'], '#b4353f', '#d97b84']) expect(apart(c, v['--p-accent-solid']), c).toBeGreaterThanOrEqual(30) + }) + + it('keeps the keyword, mixed from the red, apart from the tag', () => { + expect(apart(v['--p-code-keyword'], v['--p-code-tag'])).toBeGreaterThanOrEqual(15) + }) +}) diff --git a/src/renderer/src/lib/themes/migrate.test.ts b/src/renderer/src/lib/themes/migrate.test.ts index deb24921..cd0e17dd 100644 --- a/src/renderer/src/lib/themes/migrate.test.ts +++ b/src/renderer/src/lib/themes/migrate.test.ts @@ -18,10 +18,14 @@ describe('the map', () => { ['paper', 'paper', null], ['orchid', 'orchid', null], ['default', 'new-void', 'Onyx'], - ['terminal', 'obsidian', 'Terminal'], + ['terminal', 'jade', 'Terminal'], ['driftwood', 'carbon', 'Driftwood'], - ['acrylic-red', 'ember', 'Ruby'], - ['linen', 'sand', 'Linen'] + ['acrylic-red', 'crimson', 'Ruby'], + ['linen', 'sand', 'Linen'], + ['ember', 'jade', 'Ember'], + ['obsidian', 'jade', 'Obsidian'], + ['crimson', 'crimson', null], + ['volt', 'volt', null] ] for (const [was, now, name] of cases) { it(`${was} becomes ${now}`, () => { @@ -30,14 +34,14 @@ describe('the map', () => { else expect(m.set[STYLE]).toBe(now) if (name) expect(m.set[RETIRED_KEY]).toBe(name) else expect(m.set).not.toHaveProperty(RETIRED_KEY) - expect(m.set[VERSION_KEY]).toBe('2') + expect(m.set[VERSION_KEY]).toBe('4') }) } it('leaves an own copy and an unknown id alone (the store falls back to Aurora)', () => { - expect(migrateThemes({ [STYLE]: 'custom-0-1-Customtheme1' }).set).toEqual({ [VERSION_KEY]: '2' }) - expect(migrateThemes({ [STYLE]: 'nonsense' }).set).toEqual({ [VERSION_KEY]: '2' }) - expect(migrateThemes({}).set).toEqual({ [VERSION_KEY]: '2' }) + expect(migrateThemes({ [STYLE]: 'custom-0-1-Customtheme1' }).set).toEqual({ [VERSION_KEY]: '4' }) + expect(migrateThemes({ [STYLE]: 'nonsense' }).set).toEqual({ [VERSION_KEY]: '4' }) + expect(migrateThemes({}).set).toEqual({ [VERSION_KEY]: '4' }) }) }) @@ -66,6 +70,7 @@ describe("Onyx's glass", () => { it('only Onyx gets glass', () => { expect(migrateThemes({ [STYLE]: 'terminal', [DRAFT]: '{}' }).set).not.toHaveProperty(DRAFT) + expect(migrateThemes({ [STYLE]: 'acrylic-red', [DRAFT]: '{}' }).set).not.toHaveProperty(DRAFT) }) }) @@ -95,7 +100,76 @@ describe('own copies', () => { describe('once', () => { it('does nothing once the marker is set', () => { - expect(migrateThemes({ [VERSION_KEY]: '2', [STYLE]: 'default' })).toEqual({ set: {}, remove: [] }) + expect(migrateThemes({ [VERSION_KEY]: '4', [STYLE]: 'default' })).toEqual({ set: {}, remove: [] }) + }) + + // EMBER AND OBSIDIAN RETIRED AFTER THE FIRST PASS (#316): a window on '2' + // (released) or '3' (#316's first commit, never released) runs the map once + // more, and only Ember, Obsidian and an unpicked Ruby placement move. + for (const v of ['2', '3']) { + it(`moves a window on marker ${v} off Ember and Obsidian, its draft kept as it is`, () => { + const draft = JSON.stringify({ accent: '#ff0000' }) + for (const [was, name] of [ + ['ember', 'Ember'], + ['obsidian', 'Obsidian'] + ]) { + const m = migrateThemes({ [VERSION_KEY]: v, [STYLE]: was, [DRAFT]: draft }) + expect(m.set).toEqual({ [VERSION_KEY]: '4', [STYLE]: 'jade', [RETIRED_KEY]: name }) + } + }) + + it(`leaves every current theme on marker ${v} where it is`, () => { + for (const id of ['aurora', 'new-void', 'carbon', 'crimson', 'jade', 'volt', 'sand', 'pearl']) { + expect(migrateThemes({ [VERSION_KEY]: v, [STYLE]: id, [DRAFT]: '{}' }).set).toEqual({ [VERSION_KEY]: '4' }) + } + }) + + it(`maps own copies based on Ember, Obsidian and Ruby on marker ${v}`, () => { + const copy = { id: 'custom-4', name: 'Custom theme 4', bg: '#0f0d0c', text: '#f3ece6', mode: 'dark' } + const m = migrateThemes({ + [VERSION_KEY]: v, + [PRESETS]: JSON.stringify([ + { ...copy, base: 'ember' }, + { ...copy, base: 'obsidian' }, + { ...copy, base: 'acrylic-red' } + ]) + }) + expect(JSON.parse(m.set[PRESETS]).map((p: { base: string }) => p.base)).toEqual(['jade', 'jade', 'crimson']) + }) + } + + // RUBY FINDS ITS RED (#316). Marker '2' put Ruby on Ember (and the unreleased + // '3' on Carbon). While the quiet line still says Ruby, nobody has picked a + // theme since, so the placement follows to Crimson, the line kept. + it('moves an unpicked Ruby placement on Ember (marker 2) to Crimson', () => { + const m = migrateThemes({ [VERSION_KEY]: '2', [STYLE]: 'ember', [RETIRED_KEY]: 'Ruby' }) + expect(m.set).toEqual({ [VERSION_KEY]: '4', [STYLE]: 'crimson', [RETIRED_KEY]: 'Ruby' }) + }) + + it('moves an unpicked Ruby placement on Carbon (marker 3) to Crimson', () => { + const m = migrateThemes({ [VERSION_KEY]: '3', [STYLE]: 'carbon', [RETIRED_KEY]: 'Ruby' }) + expect(m.set).toEqual({ [VERSION_KEY]: '4', [STYLE]: 'crimson', [RETIRED_KEY]: 'Ruby' }) + }) + + it('leaves a chosen Ember, Carbon or Jade where the user put it', () => { + // A pick removed the line: Ember follows the plain map, Carbon stays. + expect(migrateThemes({ [VERSION_KEY]: '2', [STYLE]: 'ember' }).set[STYLE]).toBe('jade') + expect(migrateThemes({ [VERSION_KEY]: '3', [STYLE]: 'carbon' }).set).toEqual({ [VERSION_KEY]: '4' }) + // Another retired name on the line is not Ruby's placement. + expect(migrateThemes({ [VERSION_KEY]: '2', [STYLE]: 'carbon', [RETIRED_KEY]: 'Driftwood' }).set).toEqual({ [VERSION_KEY]: '4' }) + // Ruby to Ember to Jade under '3' left the line saying Ember: Jade stays. + expect(migrateThemes({ [VERSION_KEY]: '3', [STYLE]: 'jade', [RETIRED_KEY]: 'Ember' }).set).toEqual({ [VERSION_KEY]: '4' }) + // A Ruby line on a theme the user picked since cannot exist, but is left alone. + expect(migrateThemes({ [VERSION_KEY]: '3', [STYLE]: 'paper', [RETIRED_KEY]: 'Ruby' }).set).toEqual({ [VERSION_KEY]: '4' }) + }) + + it('maps every retired id to a current theme, in one step', async () => { + const { RETIRED_MAP, PLACED_MOVES } = await import('./retired') + const { STYLES } = await import('../theme') + const ids = new Set(STYLES.map((s) => s.id)) + for (const to of Object.values(RETIRED_MAP)) expect(ids.has(to), to).toBe(true) + for (const p of Object.values(PLACED_MOVES)) expect(ids.has(p.to), p.to).toBe(true) + for (const id of Object.keys(RETIRED_MAP)) expect(ids.has(id), id).toBe(false) }) it('does not read Colour mode for the choice', () => { @@ -118,7 +192,7 @@ describe('once', () => { migrateThemeStorage(storage) const after = new Map(store) expect(after.get(STYLE)).toBe('new-void') - expect(after.get(VERSION_KEY)).toBe('2') + expect(after.get(VERSION_KEY)).toBe('4') expect(after.get(RETIRED_KEY)).toBe('Onyx') migrateThemeStorage(storage) expect(store).toEqual(after) diff --git a/src/renderer/src/lib/themes/migrate.ts b/src/renderer/src/lib/themes/migrate.ts index d69fb10c..cd5b2731 100644 --- a/src/renderer/src/lib/themes/migrate.ts +++ b/src/renderer/src/lib/themes/migrate.ts @@ -1,4 +1,4 @@ -import { RETIRED_MAP, retiredById } from './retired' +import { PLACED_MOVES, RETIRED_MAP, retiredById, retiredName } from './retired' /** * SAVED THEMES MOVE ONCE (#298, spec 4). The ten styles Prism shipped were @@ -24,7 +24,15 @@ export const PRESETS_KEY = 'prism.style.presets' export const VERSION_KEY = 'prism.style.v' /** The retired theme's NAME, for the one quiet line on the Themes card. */ export const RETIRED_KEY = 'prism.style.retired' -export const THEME_VERSION = '2' +/** + * '2' was the move off the ten old styles (#298); '3' was Ember to Jade and + * '4' is Obsidian to Jade and Ruby to Crimson (#316; '3' never left its pull + * request). A window on an older marker runs the map again: every id an + * earlier pass could write is a current theme or retired since and in the + * map, so only Ember and Obsidian (and own copies based on them) move, plus a + * Ruby profile an earlier pass placed and nobody repicked (`PLACED_MOVES`). + */ +export const THEME_VERSION = '4' /** * Onyx's place on the old Acrylic slider: its glass was the acrylic dark @@ -61,8 +69,8 @@ export function migrateThemes(snap: StorageSnapshot): Migration { const mapped = typeof saved === 'string' ? RETIRED_MAP[saved] : undefined if (saved && mapped) { out.set[STYLE_KEY] = mapped - const was = retiredById(saved) - if (was) out.set[RETIRED_KEY] = was.name + const was = retiredName(saved) + if (was) out.set[RETIRED_KEY] = was if (saved === 'default') { // Onyx was glass: Void carries it as an unsaved edit, unless the draft // already says how much glass (or none) the user wanted. @@ -75,6 +83,15 @@ export function migrateThemes(snap: StorageSnapshot): Migration { } } + // A theme an earlier pass put the user on, still unpicked (its quiet line + // still names the retired theme), follows that theme's better successor. + const placedName = snap[RETIRED_KEY] + const placed = typeof placedName === 'string' ? PLACED_MOVES[placedName] : undefined + if (placed && typeof placedName === 'string' && typeof saved === 'string' && placed.from.includes(saved)) { + out.set[STYLE_KEY] = placed.to + out.set[RETIRED_KEY] = placedName + } + // Own copies: every field kept, their `base` mapped. const presets = parse(snap[PRESETS_KEY]) if (Array.isArray(presets)) { @@ -94,7 +111,7 @@ export function migrateThemes(snap: StorageSnapshot): Migration { /** The store's half: read the keys, write what the migration says. */ export function migrateThemeStorage(storage: Pick): Migration { const snap: StorageSnapshot = {} - for (const k of [STYLE_KEY, DRAFT_KEY, PRESETS_KEY, VERSION_KEY]) snap[k] = storage.getItem(k) + for (const k of [STYLE_KEY, DRAFT_KEY, PRESETS_KEY, VERSION_KEY, RETIRED_KEY]) snap[k] = storage.getItem(k) const m = migrateThemes(snap) // The marker last, so a write that throws half way is tried again. for (const [k, v] of Object.entries(m.set)) if (k !== VERSION_KEY) storage.setItem(k, v) diff --git a/src/renderer/src/lib/themes/retired.ts b/src/renderer/src/lib/themes/retired.ts index 7d94d82b..8754aebe 100644 --- a/src/renderer/src/lib/themes/retired.ts +++ b/src/renderer/src/lib/themes/retired.ts @@ -235,11 +235,43 @@ export const RETIRED_STYLES: Style[] = [ */ export const RETIRED_MAP: Record = { default: 'new-void', // Onyx: black ground and indigo are Void's; its glass is carried over - terminal: 'obsidian', // near-black, emerald, square + // Terminal went to Obsidian until Obsidian retired (#316); Jade is the + // nearest look now: near-black, a green accent, square. + terminal: 'jade', driftwood: 'carbon', // warm charcoal, amber - 'acrylic-red': 'ember', // Ruby: near-black, the warm accent nearest red - linen: 'sand' // beige, terracotta + // Ruby went to Ember (#298); since Crimson (#316) there is a red, and it is + // Ruby's look: near-black and red. + 'acrylic-red': 'crimson', + linen: 'sand', // beige, terracotta + // TWO OF THE 18 RETIRED (#316; owner, 2026-10-07: "this theme should + // replace Ember. its Volt but with this teal instead of the yellow", then, + // of Volt's Colours edited to red, "have this replace obsidian"). Each + // successor took its place on the wall; Obsidian's emerald goes to the + // nearest green, Jade. + ember: 'jade', + obsidian: 'jade' } +/** + * A profile the migration PUT on a theme, which it still wears unpicked: the + * quiet line still names the retired theme, and a pick takes that name away. + * Ruby went to Ember under marker '2' (and to Carbon under the unreleased + * '3'); while its line still says Ruby nobody chose either, so it moves on to + * Crimson, the red that now exists. + */ +export const PLACED_MOVES: Record = { + Ruby: { from: ['ember', 'carbon'], to: 'crimson' } +} + +/** + * Themes of the 18 retired since #298, by the name the quiet line says. They + * are catalogue themes, not the old styles above, so they are not in + * `RETIRED_STYLES` (held to `legacy.snapshot.json`); only the name is needed. + */ +export const RETIRED_NAMES: Record = { ember: 'Ember', obsidian: 'Obsidian' } + /** The retired style an id named, for its name and its glass. */ export const retiredById = (id: string): Style | undefined => RETIRED_STYLES.find((s) => s.id === id) + +/** The name a retired id went by, for the quiet line. */ +export const retiredName = (id: string): string | undefined => retiredById(id)?.name ?? RETIRED_NAMES[id] diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index a1bb2376..a57bb7ee 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -14455,7 +14455,7 @@ async function settingsLookScenario(fixtures) { * keeps; past the row opens the wall; Home, End; Escape goes back with the * draft intact; focus is the fill, never a ring (#272). */ -const THEME_ORDER = ['aurora', 'new-void', 'carbon', 'obsidian', 'ember', 'volt', 'midnight-hc', 'glacier', 'lagoon', 'frost', 'paper', 'sand', 'sage', 'blush', 'chalk', 'daylight-hc', 'orchid', 'pearl'] +const THEME_ORDER = ['aurora', 'new-void', 'carbon', 'crimson', 'jade', 'volt', 'midnight-hc', 'glacier', 'lagoon', 'frost', 'paper', 'sand', 'sage', 'blush', 'chalk', 'daylight-hc', 'orchid', 'pearl'] /** The wall as the page has it. */ const wallState = (win) => @@ -14754,7 +14754,7 @@ async function themeLooksScenario(fixtures) { const catalogue = JSON.parse(readFileSync(join(ROOT, 'src', 'renderer', 'src', 'lib', 'themes', 'catalogue.json'), 'utf8')) try { await win.waitForSelector('.cm-content', { timeout: 10000 }) - for (const id of ['aurora', 'ember', 'chalk', 'midnight-hc', 'daylight-hc', 'orchid']) { + for (const id of ['aurora', 'jade', 'crimson', 'chalk', 'midnight-hc', 'daylight-hc', 'orchid']) { const r = await switchStyle(win, id) if (styleBefore === undefined) styleBefore = r await sleep(350) @@ -14768,7 +14768,7 @@ async function themeLooksScenario(fixtures) { await win.screenshot({ path: join(SHOTS, `theme-code-${id}.png`) }) } await win.locator('[role="tab"]:has-text("Explorer")').first().click() - for (const id of ['aurora', 'carbon', 'paper', 'midnight-hc', 'glacier', 'pearl']) { + for (const id of ['aurora', 'carbon', 'jade', 'crimson', 'paper', 'midnight-hc', 'glacier', 'pearl']) { await switchStyle(win, id) await sleep(350) await win.screenshot({ path: join(SHOTS, `theme-explorer-${id}.png`) }) @@ -14802,7 +14802,14 @@ async function themeMigrationScenario(fixtures) { const light = { ...dark, id: 'custom-0-2-Customtheme2', name: 'Custom theme 2', mode: 'light', material: 'solid', bg: '#f8f4ed', side: '#f1ebe1', title: '#e9e2d5', text: '#241f18', accent: '#92400e' } const cases = [ { name: 'onyx', seed: { 'prism.style': 'default', 'prism.style.draft': JSON.stringify({ accent: '#22aa66' }), 'prism.style.presets': JSON.stringify([dark, light]), 'prism.mode': 'dark' }, want: 'new-void', retired: 'Onyx', mapped: 'Void' }, - { name: 'driftwood', seed: { 'prism.style': 'driftwood', 'prism.mode': 'light' }, want: 'carbon', retired: 'Driftwood', mapped: 'Carbon' } + { name: 'driftwood', seed: { 'prism.style': 'driftwood', 'prism.mode': 'light' }, want: 'carbon', retired: 'Driftwood', mapped: 'Carbon' }, + // Ember and Obsidian retired after the first pass (#316): a profile + // already on the first marker moves once more, to Jade. + { name: 'ember', seed: { 'prism.style': 'ember', 'prism.style.v': '2', 'prism.mode': 'dark' }, want: 'jade', retired: 'Ember', mapped: 'Jade' }, + { name: 'obsidian', seed: { 'prism.style': 'obsidian', 'prism.style.v': '2', 'prism.mode': 'dark' }, want: 'jade', retired: 'Obsidian', mapped: 'Jade' }, + // Ruby, placed on Ember by the first pass and never repicked (its line + // still says Ruby), follows to Crimson, the red #316 added. + { name: 'ruby', seed: { 'prism.style': 'ember', 'prism.style.v': '2', 'prism.style.retired': 'Ruby', 'prism.mode': 'dark' }, want: 'crimson', retired: 'Ruby', mapped: 'Crimson' } ] for (const c of cases) { const profile = `${PROFILE}-migrate-${c.name}` @@ -14814,7 +14821,7 @@ async function themeMigrationScenario(fixtures) { Object.fromEntries(['prism.style', 'prism.style.draft', 'prism.style.presets', 'prism.style.v', 'prism.style.retired', 'prism.mode'].map((k) => [k, localStorage.getItem(k)])) ) ok(keys['prism.style'] === c.want, `${c.name}: the saved theme is now ${c.want} (${keys['prism.style']})`) - ok(keys['prism.style.v'] === '2', `${c.name}: the marker is set`) + ok(keys['prism.style.v'] === '4', `${c.name}: the marker is set`) ok(keys['prism.style.retired'] === c.retired, `${c.name}: the old name is kept for the one line (${keys['prism.style.retired']})`) if (c.name === 'onyx') { const draft = JSON.parse(keys['prism.style.draft'] ?? '{}') @@ -14822,7 +14829,7 @@ async function themeMigrationScenario(fixtures) { ok(await win.evaluate(() => document.documentElement.style.getPropertyValue('--p-bg').startsWith('rgba(')), 'onyx: the window is see-through') const presets = JSON.parse(keys['prism.style.presets'] ?? '[]') ok(JSON.stringify(presets) === JSON.stringify([{ ...dark, base: 'new-void' }, { ...light, base: 'new-void' }]), 'onyx: both own copies are kept field for field, their base mapped') - } else { + } else if (c.name === 'driftwood') { ok(keys['prism.mode'] === 'dark', `driftwood: the old light mode was not read; the boot mirror says dark (${keys['prism.mode']})`) } await settingsPage(win, 'appearance') @@ -14832,7 +14839,10 @@ async function themeMigrationScenario(fixtures) { if (c.name === 'onyx') ok(JSON.stringify(w.ids.slice(-2)) === JSON.stringify([dark.id, light.id]), `onyx: the own copies are the last cards (${w.ids.slice(-2)})`) ok(JSON.stringify(w.checked) === JSON.stringify([c.want]), `${c.name}: the chosen card is ${c.want}, nothing reset to Aurora (${w.checked})`) await win.screenshot({ path: join(SHOTS, `theme-migration-${c.name}.png`) }) - await win.locator('[data-theme-card="aurora"]').click() + // A card in the collapsed wall's one row, not the chosen one: the + // chosen card's row may not hold Aurora. + const other = w.shown.find((id) => id !== c.want) + await win.locator(`[data-theme-card="${other}"]`).click() ok(await until(async () => (await line.count()) === 0, 3000, 50), `${c.name}: a theme pick takes the line away`) ok((await win.evaluate(() => localStorage.getItem('prism.style.retired'))) === null, `${c.name}: and its key`) } finally {