diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be80ce..22e5c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It + goes solid when the system asks for less transparency or more contrast. + ## [0.8.3] - 2026-09-19 ### Security diff --git a/tests/contrast.js b/tests/contrast.js index 0b009aa..67abd7e 100644 --- a/tests/contrast.js +++ b/tests/contrast.js @@ -58,5 +58,39 @@ for (const [name, p] of Object.entries(themes)) { // A border the same colour as the ground it is drawn on does not show (Nordic, once). if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); } } +// Glass draws its text on a coloured wash, or on a panel that lets the wash through, so its hex +// grounds are not what the text lands on. Sample the wash the way the browser composites it, on a +// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest. +const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)] + .map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] })); +const rgba = (sel, n) => { + const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`)); + return [[+m[1], +m[2], +m[3]], +m[4]]; +}; +const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)); +const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join(''); +const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a)); +for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) { + const p = themes[name]; + const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top + const worst = {}; + for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]]) + for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) { + let g = rgb(p.bg); + for (const [w, [c, a]] of tint) { + const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100)); + g = over(c, a * Math.max(0, 1 - d), g); + } + // The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it. + for (const [gn, gc] of [['the wash', g], ['a panel', over(rgb(p.panel), .7, g)]]) + for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) { + const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`; + if (!(k in worst) || r < worst[k]) worst[k] = r; + } + } + if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); } + for (const [k, r] of Object.entries(worst)) + if (r < 4.5) { bad++; console.log(`FAIL ${name}: ${k} is ${r.toFixed(2)}:1 at worst, needs 4.5`); } +} if (bad) process.exit(1); console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`); diff --git a/web/app.css b/web/app.css index 0a641d2..e36d19d 100644 --- a/web/app.css +++ b/web/app.css @@ -403,6 +403,53 @@ --bad:#c42b1c; --shadow:0 4px 16px rgba(0,0,0,.28); } +/* Glass, after Apple's Liquid Glass: Apple's system colours, on panels that let a soft coloured + wash show through. The hex values are what a panel reads as once blended, so the contrast check + still means something; the translucency and the wash are in the chrome at the end of the sheet. */ +:root[data-theme="glass"] { + --bg:#0b0d14; + --panel:#161a24; + --panel2:#1f2430; + --raise:#2a3040; + --line:#343b4c; + --fg:#f5f5f7; /* Apple's label */ + --dim:#aeaeb2; /* systemGray2 */ + --faint:#98989f; /* systemGray, a shade up to clear AA over the wash */ + --accent:#409cff; /* systemBlue's accessible dark variant; #0a84ff falls short as link text */ + --accent2:#ff9f0a; /* systemOrange */ + --ink:#000000; + --good:#30d158; + --warn:#ff9f0a; + --bad:#ff6961; + --shadow:0 10px 36px rgba(0,0,0,.45); + --glass-edge:rgba(255,255,255,.10); + --glass-hi:rgba(255,255,255,.14); + --wash1:rgba(64,120,255,.30); + --wash2:rgba(175,82,222,.24); + --wash3:rgba(48,176,199,.20); +} +:root[data-theme="glass"][data-mode="light"] { + --bg:#eef1f7; + --panel:#ffffff; + --panel2:#f2f4f8; + --raise:#e4e8f0; + --line:#d1d5de; + --fg:#1d1d1f; + --dim:#515154; + --faint:#5a5a5f; /* systemGray, taken down to clear AA over the wash */ + --accent:#0055aa; /* a shade under Apple's #0066cc, which falls short over the wash */ + --accent2:#b25000; /* systemOrange taken down until white on it clears AA */ + --ink:#ffffff; + --good:#248a3d; + --warn:#824200; + --bad:#b8000f; + --shadow:0 10px 36px rgba(30,50,90,.16); + --glass-edge:rgba(255,255,255,.65); + --glass-hi:rgba(255,255,255,.9); + --wash1:rgba(64,120,255,.22); + --wash2:rgba(175,82,222,.16); + --wash3:rgba(48,176,199,.18); +} *{box-sizing:border-box} /* A rule that sets display beats the UA's [hidden], and several below do. */ [hidden]{display:none!important} @@ -1006,3 +1053,74 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu /* Lists were white in the original; the pale blue-grey belongs to the source list alone. */ :root[data-theme="classic"] .childrow{background:#fff} :root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px} + +/* ---------- Glass: after Apple's Liquid Glass ---------- */ +/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it. + Apple's glass also bends light at its edges; that needs an SVG displacement filter that only + Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43). */ +:root[data-theme="glass"] body{ + font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif; + background: + radial-gradient(60% 55% at 8% 0%,var(--wash1),transparent), + radial-gradient(50% 50% at 92% 18%,var(--wash2),transparent), + radial-gradient(60% 60% at 60% 100%,var(--wash3),transparent), + var(--bg); +} +:root[data-theme="glass"] #sidebar, +:root[data-theme="glass"] #topbar, +:root[data-theme="glass"] #player, +:root[data-theme="glass"] #status, +:root[data-theme="glass"] #files, +:root[data-theme="glass"] #detail, +:root[data-theme="glass"] .card, +:root[data-theme="glass"] .toast{ + background:color-mix(in srgb,var(--panel) 70%,transparent); + -webkit-backdrop-filter:blur(24px) saturate(180%); + backdrop-filter:blur(24px) saturate(180%); + border-color:var(--glass-edge); + box-shadow:inset 0 1px 0 var(--glass-hi); +} +:root[data-theme="glass"] .toolbar, +:root[data-theme="glass"] .ephead{ + /* Sticky, so the list scrolls under them: the one place the frosting has something to blur. */ + background:color-mix(in srgb,var(--bg) 65%,transparent); + -webkit-backdrop-filter:blur(20px) saturate(180%); + backdrop-filter:blur(20px) saturate(180%); +} +:root[data-theme="glass"] .card, +:root[data-theme="glass"] .toast{box-shadow:inset 0 1px 0 var(--glass-hi),var(--shadow)} +:root[data-theme="glass"] .card{border-radius:20px} +:root[data-theme="glass"] .toast{border-radius:14px} +:root[data-theme="glass"] .toast.bad{border-color:var(--bad)} +/* A narrow screen slides the feed list over the page, so it keeps its shadow. */ +@media (max-width:820px){ + :root[data-theme="glass"] #sidebar{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)} +} +/* The card is the glass; a lighter scrim leaves something behind it to see through. */ +:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)} +:root[data-theme="glass"] .tgroup, +:root[data-theme="glass"] .tabs{ + background:color-mix(in srgb,var(--panel2) 60%,transparent); + border-color:var(--glass-edge);border-radius:12px;box-shadow:inset 0 1px 0 var(--glass-hi); +} +:root[data-theme="glass"] .btn, +:root[data-theme="glass"] .sidetools button, +:root[data-theme="glass"] .sidefoot button{border-radius:10px} +:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)} +/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */ +@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){ + :root[data-theme="glass"] body{background:var(--bg)} + :root[data-theme="glass"] #sidebar, + :root[data-theme="glass"] #topbar, + :root[data-theme="glass"] #player, + :root[data-theme="glass"] #status, + :root[data-theme="glass"] #files, + :root[data-theme="glass"] #detail, + :root[data-theme="glass"] .card, + :root[data-theme="glass"] .toast, + :root[data-theme="glass"] .tgroup, + :root[data-theme="glass"] .tabs{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)} + :root[data-theme="glass"] .toolbar, + :root[data-theme="glass"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none} + :root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)} +} diff --git a/web/src/theme.ts b/web/src/theme.ts index 037d724..f51e89e 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -10,6 +10,7 @@ const THEMES: Record = { classic: {name: 'Classic', modes: false}, dracula: {name: 'Dracula', modes: true}, flatremix: {name: 'Flat Remix', modes: true}, + glass: {name: 'Glass', modes: true}, gruvbox: {name: 'Gruvbox', modes: true}, contrast: {name: 'High contrast', modes: true}, material: {name: 'Material', modes: true},