Glass panels catch the light at their edges (#51)

The glass surfaces had one 1px highlight along the top, which read as flat. They now have a rim
lit from the top-left and a fainter one on the far edges, as box-shadows, and a sheen from the
top-left corner as a background layer. A pseudo-element would have been the usual way, but the
detail pane, file list and dialogs scroll, and an absolutely placed layer in a scroller scrolls
away with the content.

Refraction was looked at and left out again: bending the live page needs backdrop-filter: url(),
which Firefox and Safari lack, and the WebGL libraries (liquid-glass-js, liquidGL) bend a
snapshot of the page that goes stale on a list that scrolls in its own pane.

The sheen lightens a dark panel under its text, so tests/contrast.js now checks every text colour
on a panel under the sheen at full strength. That capped it at 7% in dark mode; 9% put --faint,
--accent and --bad under AA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 23:25:24 +00:00
parent 380a552913
commit a573a07ed5
3 changed files with 27 additions and 8 deletions

View File

@@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
elsewhere they copy the link. A link that looks like it carries your own access to a paid elsewhere they copy the link. A link that looks like it carries your own access to a paid
feed asks before it goes anywhere. feed asks before it goes anywhere.
### Changed
- The Glass theme's panels catch the light: a bright rim along the top and left edges, a fainter
one along the far edges, and a soft sheen from the top-left corner.
### Fixed ### Fixed
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a - Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a

View File

@@ -73,6 +73,8 @@ 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"]']]) { for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
const p = themes[name]; const p = themes[name];
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
const sheen = [[+sm[1], +sm[2], +sm[3]], +sm[4]];
const worst = {}; const worst = {};
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]]) 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) { for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
@@ -81,8 +83,10 @@ for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100)); 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); 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. // 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)]]) // and a panel's top-left corner under its sheen at full strength.
const panel = over(rgb(p.panel), .7, g);
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]])
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) { for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`; const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
if (!(k in worst) || r < worst[k]) worst[k] = r; if (!(k in worst) || r < worst[k]) worst[k] = r;

View File

@@ -423,7 +423,9 @@
--bad:#ff6961; --bad:#ff6961;
--shadow:0 10px 36px rgba(0,0,0,.45); --shadow:0 10px 36px rgba(0,0,0,.45);
--glass-edge:rgba(255,255,255,.10); --glass-edge:rgba(255,255,255,.10);
--glass-hi:rgba(255,255,255,.14); --glass-hi:rgba(255,255,255,.26);
--glass-lo:rgba(255,255,255,.10);
--glass-sheen:rgba(255,255,255,.07);
--wash1:rgba(64,120,255,.30); --wash1:rgba(64,120,255,.30);
--wash2:rgba(175,82,222,.24); --wash2:rgba(175,82,222,.24);
--wash3:rgba(48,176,199,.20); --wash3:rgba(48,176,199,.20);
@@ -446,6 +448,8 @@
--shadow:0 10px 36px rgba(30,50,90,.16); --shadow:0 10px 36px rgba(30,50,90,.16);
--glass-edge:rgba(255,255,255,.65); --glass-edge:rgba(255,255,255,.65);
--glass-hi:rgba(255,255,255,.9); --glass-hi:rgba(255,255,255,.9);
--glass-lo:rgba(255,255,255,.45);
--glass-sheen:rgba(255,255,255,.5);
--wash1:rgba(64,120,255,.22); --wash1:rgba(64,120,255,.22);
--wash2:rgba(175,82,222,.16); --wash2:rgba(175,82,222,.16);
--wash3:rgba(48,176,199,.18); --wash3:rgba(48,176,199,.18);
@@ -1082,7 +1086,10 @@ body:has(#player.on) #status{padding-bottom:3px}
/* ---------- Glass: after Apple's Liquid Glass ---------- */ /* ---------- Glass: after Apple's Liquid Glass ---------- */
/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it. /* 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 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). */ Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43).
What is kept is the light: the rim catches it at the top-left edges and, fainter, the far ones,
as it passes through (#51). */
:root[data-theme="glass"]{--glass-rim:inset 1px 1px 0 var(--glass-hi),inset -1px -1px 0 var(--glass-lo)}
:root[data-theme="glass"] body{ :root[data-theme="glass"] body{
font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif; font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif;
background: background:
@@ -1099,11 +1106,14 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="glass"] #detail, :root[data-theme="glass"] #detail,
:root[data-theme="glass"] .card, :root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast{ :root[data-theme="glass"] .toast{
background:color-mix(in srgb,var(--panel) 70%,transparent); /* A sheen from the top-left corner, where the light comes from. It is a background layer rather
than a pseudo-element because the detail pane, the file list and the dialogs scroll, and an
absolutely placed layer inside a scroller scrolls away with the content. */
background:linear-gradient(135deg,var(--glass-sheen),transparent 45%),color-mix(in srgb,var(--panel) 70%,transparent);
-webkit-backdrop-filter:blur(24px) saturate(180%); -webkit-backdrop-filter:blur(24px) saturate(180%);
backdrop-filter:blur(24px) saturate(180%); backdrop-filter:blur(24px) saturate(180%);
border-color:var(--glass-edge); border-color:var(--glass-edge);
box-shadow:inset 0 1px 0 var(--glass-hi); box-shadow:var(--glass-rim);
} }
:root[data-theme="glass"] .toolbar, :root[data-theme="glass"] .toolbar,
:root[data-theme="glass"] .ephead{ :root[data-theme="glass"] .ephead{
@@ -1113,7 +1123,7 @@ body:has(#player.on) #status{padding-bottom:3px}
backdrop-filter:blur(20px) saturate(180%); backdrop-filter:blur(20px) saturate(180%);
} }
:root[data-theme="glass"] .card, :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"] .toast{box-shadow:var(--glass-rim),var(--shadow)}
:root[data-theme="glass"] .card{border-radius:20px} :root[data-theme="glass"] .card{border-radius:20px}
:root[data-theme="glass"] .toast{border-radius:14px} :root[data-theme="glass"] .toast{border-radius:14px}
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)} :root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
@@ -1126,7 +1136,7 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="glass"] .tgroup, :root[data-theme="glass"] .tgroup,
:root[data-theme="glass"] .tabs{ :root[data-theme="glass"] .tabs{
background:color-mix(in srgb,var(--panel2) 60%,transparent); 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); border-color:var(--glass-edge);border-radius:12px;box-shadow:var(--glass-rim);
} }
:root[data-theme="glass"] .btn, :root[data-theme="glass"] .btn,
:root[data-theme="glass"] .sidetools button, :root[data-theme="glass"] .sidetools button,