diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7ff72..ec67237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ The long form, with what was wrong before and how it was found, is in ## [Unreleased] +### Added + +- Six more themes in Settings: Dracula, Material, Adwaita, Flat Remix, Paper and Nordic, beside + Classic and Modern (the existing dark and light). Each that comes both ways has its own Light, + Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them. + A theme chosen before this carries over. + ### Changed - A feed that fails to check gets a red ! in the feed list, and its page says why, in place of a diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 03a5a65..eb8e0cc 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -20,28 +20,54 @@ test('the page loads and lists the configured feeds', async ({ page }) => { expect(errors, 'the page script must not throw at load').toEqual([]); }); -test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => { - const theme = () => page.evaluate(() => document.documentElement.dataset.theme); +test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => { + const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]); + const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor); await page.locator('#prefs').click(); - await expect(page.locator('#stheme')).toHaveValue(await theme()); + await expect(page.locator('#stheme')).toHaveValue((await root())[0]); - await page.locator('#stheme').selectOption('auto'); - await expect.poll(theme).toBe('auto'); - // Auto follows the system; emulating a light system must show the light palette live, - // no reload needed, since it is a media query rather than something JS picks per click. + await page.locator('#stheme').selectOption('modern'); + await page.locator('#smode').selectOption('auto'); + // Auto follows the system, live, with no reload. await page.emulateMedia({ colorScheme: 'light' }); - await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor)) - .toBe('rgb(242, 244, 247)'); // --bg in the light palette + await expect.poll(root).toEqual(['modern', 'light']); + await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg await page.emulateMedia({ colorScheme: 'dark' }); - await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor)) - .toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here + await expect.poll(root).toEqual(['modern', 'dark']); + await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg + // Dracula, then its light half, Alucard. + await page.locator('#stheme').selectOption('dracula'); + await page.locator('#smode').selectOption('dark'); + await expect.poll(bg).toBe('rgb(40, 42, 54)'); // #282A36 + await page.locator('#smode').selectOption('light'); + await expect.poll(bg).toBe('rgb(255, 251, 235)'); // #FFFBEB + + // Classic and Paper come one way only, so there is nothing to choose. + await page.locator('#stheme').selectOption('paper'); + await expect(page.locator('#smode')).toBeHidden(); + await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE await page.locator('#stheme').selectOption('classic'); + await expect(page.locator('#smode')).toBeHidden(); await page.reload(); - await expect.poll(theme).toBe('classic'); + await expect.poll(root).toEqual(['classic', 'light']); // The 2004 Mac app set its type in Lucida Grande. expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande'); expect(await page.locator('#theme').count(), 'the theme lives in Settings only').toBe(0); + + // Back to Nordic, dark: the mode chosen before Classic is kept for the themes that have one. + await page.locator('#prefs').click(); + await page.locator('#stheme').selectOption('nordic'); + await expect(page.locator('#smode')).toHaveValue('light'); + await page.locator('#smode').selectOption('dark'); + await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0 +}); + +test('a theme saved before there was light and dark carries over', async ({ page }) => { + await page.evaluate(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); }); + await page.reload(); + expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode])) + .toEqual(['modern', 'light']); }); test('settings opens and saves the global schedule', async ({ page }) => { diff --git a/web/build.mjs b/web/build.mjs index 164e211..482e49e 100644 --- a/web/build.mjs +++ b/web/build.mjs @@ -15,7 +15,7 @@ const here = path.dirname(fileURLToPath(import.meta.url)); // The files are one script, concatenated in this order, not modules: they share one top-level // scope, as the single inline script did, and code that runs at load needs what came before it. const PAGES = { - 'index.html': ['util', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'events'], + 'index.html': ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'events'], 'login.html': ['login'], }; diff --git a/web/index.html b/web/index.html index d0ca08e..93436ba 100644 --- a/web/index.html +++ b/web/index.html @@ -30,7 +30,10 @@ --bad:#e2705f; --shadow:0 8px 28px rgba(6,10,16,.55); } -:root[data-theme="light"] { +/* Every other theme overrides the same variables, under data-theme and data-mode. The page's + script sets data-mode to light or dark, working Auto out from the system, so each theme has + one block per variant here and none needs repeating under a media query. */ +:root[data-theme="modern"][data-mode="light"] { --bg:#f2f4f7; --panel:#ffffff; /* #FFFFFF device body */ --panel2:#e9edf3; @@ -47,28 +50,203 @@ --bad:#b3402f; --shadow:0 8px 28px rgba(45,83,145,.14); } -/* Auto: the same palette as Light, but only while the system is set to light -- the default - :root above is already dark, so nothing is needed for the dark half of Auto. Duplicated - rather than shared with [data-theme="light"], the same way Classic repeats its own values; - CSS custom properties have no way to say "these vars, but only under this media query". */ -@media (prefers-color-scheme: light) { - :root[data-theme="auto"] { - --bg:#f2f4f7; - --panel:#ffffff; - --panel2:#e9edf3; - --raise:#dde3ec; - --line:#d6d6d6; - --fg:#1a1a1a; - --dim:#606060; - --faint:#767676; - --accent:#2d5391; - --accent2:#9a5f0a; - --ink:#ffffff; - --good:#2f7d4f; - --warn:#b06f10; - --bad:#b3402f; - --shadow:0 8px 28px rgba(45,83,145,.14); - } +/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour + (#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */ +:root[data-theme="dracula"] { + --bg:#282a36; + --panel:#21222c; + --panel2:#343746; + --raise:#44475a; /* selection */ + --line:#414558; + --fg:#f8f8f2; + --dim:#c9cbd6; + --faint:#9ea8c7; + --accent:#bd93f9; /* purple */ + --accent2:#ff79c6; /* pink */ + --ink:#282a36; + --good:#50fa7b; + --warn:#ffb86c; + --bad:#ff8080; + --shadow:0 8px 28px rgba(0,0,0,.45); +} +:root[data-theme="dracula"][data-mode="light"] { + --bg:#fffbeb; + --panel:#f7f2df; + --panel2:#efe9d3; + --raise:#cfcfde; /* selection */ + --line:#ddd6bd; + --fg:#1f1f1f; + --dim:#454137; + --faint:#635d44; + --accent:#644ac9; + --accent2:#a3144d; + --ink:#ffffff; + --good:#14710a; + --warn:#a34d14; + --bad:#b83322; + --shadow:0 8px 28px rgba(108,102,75,.18); +} +/* Material 3's baseline scheme: surface, the surface containers, outline and primary. */ +:root[data-theme="material"] { + --bg:#141218; /* surface */ + --panel:#1d1b20; /* surface-container-low */ + --panel2:#211f26; /* surface-container */ + --raise:#36343b; /* surface-container-highest */ + --line:#49454f; /* outline-variant */ + --fg:#e6e0e9; /* on-surface */ + --dim:#cac4d0; /* on-surface-variant */ + --faint:#9a95a0; /* outline, a shade up to clear AA on the containers */ + --accent:#d0bcff; /* primary */ + --accent2:#efb8c8; /* tertiary */ + --ink:#381e72; /* on-primary */ + --good:#8fd6a0; + --warn:#f2c46b; + --bad:#f2b8b5; /* error */ + --shadow:0 8px 28px rgba(0,0,0,.5); +} +:root[data-theme="material"][data-mode="light"] { + --bg:#fef7ff; + --panel:#f7f2fa; + --panel2:#f3edf7; + --raise:#e6e0e9; + --line:#cac4d0; + --fg:#1d1b20; + --dim:#49454f; + --faint:#67626c; /* outline (#79747E), a shade down to clear AA on the containers */ + --accent:#6750a4; + --accent2:#7d5260; + --ink:#ffffff; + --good:#2b6c3c; + --warn:#7c5800; + --bad:#b3261e; + --shadow:0 8px 28px rgba(103,80,164,.14); +} +/* Adwaita, from libadwaita's own CSS variables: view, window and sidebar backgrounds, the blue + accent, and its destructive, success and warning colours. */ +:root[data-theme="adwaita"] { + --bg:#1d1d20; /* view */ + --panel:#2e2e32; /* sidebar, headerbar */ + --panel2:#222226; /* window */ + --raise:#3a3a3f; + --line:#3f3f45; + --fg:#ffffff; + --dim:#c4c4c8; + --faint:#a0a0a7; + --accent:#81d0ff; + --accent2:#3584e4; + --ink:#1d1d20; + --good:#78e9ab; + --warn:#ffc252; + --bad:#ff938c; + --shadow:0 8px 28px rgba(0,0,0,.5); +} +:root[data-theme="adwaita"][data-mode="light"] { + --bg:#ffffff; + --panel:#ebebed; + --panel2:#fafafb; + --raise:#dcdce0; + --line:#d9d9dd; + --fg:#333338; /* rgb(0 0 6 / 80%) on white */ + --dim:#57575d; + --faint:#66666c; + --accent:#0461be; + --accent2:#3584e4; + --ink:#ffffff; + --good:#00753a; + --warn:#905400; + --bad:#c30000; + --shadow:0 8px 28px rgba(0,0,6,.12); +} +/* Flat Remix, from its GTK theme's _colors.scss: base and bg, fg, the selection blue, and its + link colours, which are the selection blue taken down (light) or up (dark) until readable. */ +:root[data-theme="flatremix"] { + --bg:#272a34; /* base */ + --panel:#23262f; /* bg: base darkened 2% */ + --panel2:#2e323d; + --raise:#363b48; + --line:#3a3f4c; + --fg:#eeeeec; + --dim:#babec8; + --faint:#959baa; + --accent:#8fb6ff; /* link */ + --accent2:#fd7d00; /* warning orange */ + --ink:#1a1c23; + --good:#4cc28c; + --warn:#ff9a3c; + --bad:#ff6b6b; + --shadow:0 8px 28px rgba(0,0,0,.3); +} +:root[data-theme="flatremix"][data-mode="light"] { + --bg:#fafafa; /* base */ + --panel:#ffffff; /* bg */ + --panel2:#f0f0f1; + --raise:#e4e5e7; + --line:#d9d9d9; /* borders: bg darkened 15% */ + --fg:#22252b; + --dim:#5c616c; /* fg */ + --faint:#686d78; + --accent:#0060f0; /* link */ + --accent2:#fd7d00; + --ink:#ffffff; + --good:#23794f; + --warn:#a85400; + --bad:#d41919; + --shadow:0 8px 28px rgba(0,0,0,.1); +} +/* Paper: a single light palette, black on the colour of paper, colour kept for what matters. + Its blue and red are taken down a shade to clear AA on that background. */ +:root[data-theme="paper"] { + --bg:#f2eede; + --panel:#ebe7d7; + --panel2:#e4e0d0; + --raise:#d8d5c7; /* highlight */ + --line:#cdc9b9; + --fg:#000000; + --dim:#3d3a33; + --faint:#5a564c; + --accent:#1a5fae; + --accent2:#b58900; + --ink:#ffffff; + --good:#216609; + --warn:#795a00; + --bad:#b1331f; + --shadow:0 8px 28px rgba(0,0,0,.12); +} +/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for + the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */ +:root[data-theme="nordic"] { + --bg:#2e3440; /* nord0 */ + --panel:#3b4252; /* nord1 */ + --panel2:#434c5e; /* nord2 */ + --raise:#4c566a; /* nord3 */ + --line:#434c5e; + --fg:#eceff4; /* nord6 */ + --dim:#d8dee9; /* nord4 */ + --faint:#b4bccb; + --accent:#93c9d8; /* nord8, a shade up */ + --accent2:#ebcb8b; /* nord13 */ + --ink:#2e3440; + --good:#b0c99b; /* nord14, a shade up */ + --warn:#ebcb8b; + --bad:#f2aeb4; + --shadow:0 8px 28px rgba(0,0,0,.35); +} +:root[data-theme="nordic"][data-mode="light"] { + --bg:#eceff4; /* nord6 */ + --panel:#e5e9f0; /* nord5 */ + --panel2:#d8dee9; /* nord4 */ + --raise:#cdd4e0; + --line:#c5cedb; + --fg:#2e3440; /* nord0 */ + --dim:#3b4252; /* nord1 */ + --faint:#4c566a; /* nord3 */ + --accent:#3a5e8a; + --accent2:#b0674e; + --ink:#ffffff; + --good:#446632; + --warn:#7e590e; + --bad:#9c3f49; + --shadow:0 8px 28px rgba(46,52,64,.15); } /* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */ :root[data-theme="classic"] { diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 52b0120..b99f9bc 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -333,10 +333,12 @@ async function prefsModal(){ const admin = !!(S.me&&S.me.admin); openModal(`