Glass theme, after Apple's Liquid Glass (#43)
Translucent panels with backdrop-filter blur and saturation over a soft coloured wash, light and dark, going solid under prefers-reduced-transparency and prefers-contrast: more. The sticky filter bar and column headings are frosted, since the list scrolls under them. Text on a see-through panel lands on whatever the wash is behind it, so the palette's hex values alone no longer say whether it clears AA. contrast.js now samples the wash as the browser composites it on three viewport shapes. It caught the first light palette at 3.7:1 for faint text, and a tinted selection that failed everywhere; both were changed. Left out: SVG displacement-map refraction, which Chromium alone applies to a backdrop and only on fixed-size shapes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
118
web/app.css
118
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)}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||
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},
|
||||
|
||||
Reference in New Issue
Block a user