fix: unreadable native <select> option lists in dark themes #999

The browser paints a native <select>'s option list itself, so the
bg-muted / text-foreground classes on the control never reach it. Themes
whose --color-muted is translucent (Aurora Glass dark uses
rgba(255,255,255,0.05)) composited to a light popup with near-white
text, leaving every unselected option almost invisible.

Pin option background/colour to the popover tokens, which every built-in
theme defines as an opaque surface, and set color-scheme: dark on
selects under .dark so Safari (which ignores option colours) renders a
dark popup too. Fixes all ~40 native selects at once.

Adds a stylesheet test that locks the rules and asserts every built-in
theme's --color-popover stays opaque in each variant.
This commit is contained in:
Linus Rath
2026-09-09 18:30:59 +02:00
parent ae403b06af
commit 8d8ef7e2f1
2 changed files with 93 additions and 0 deletions
+16
View File
@@ -187,6 +187,22 @@ body {
"calt" 1;
}
/* Native <select> option lists are painted by the browser, not by the
Tailwind classes on the control, so they only honour color-scheme and an
explicit option colour. Themes whose --color-muted is translucent (Aurora
Glass dark: rgba(255,255,255,0.05)) composited to a light popup with
near-white text (#999). Pin options to the popover tokens, which every
theme defines as an opaque surface, and let dark themes ask the browser
for a dark popup where option colours are ignored (Safari). */
option {
background-color: var(--color-popover);
color: var(--color-popover-foreground);
}
.dark select {
color-scheme: dark;
}
/* Minimalist scrollbar */
::-webkit-scrollbar {
width: 8px;
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import path from 'path';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
/**
* The browser paints a native <select>'s option list itself; the Tailwind
* classes on the control (bg-muted / text-foreground) never reach it. It only
* honours `color-scheme` and an explicit option background/colour. Themes with
* a translucent --color-muted (Aurora Glass dark) therefore composited to a
* light popup with near-white text (#999). globals.css pins options to the
* popover tokens, so every theme must define those as an opaque surface.
*
* jsdom does not render native popups, so lock the stylesheet rules.
*/
const css = readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8');
/** Return the body of the first top-level `selector { ... }` rule. */
function ruleBody(selector: string): string {
const idx = css.indexOf('\n' + selector + ' {');
expect(idx, 'no `' + selector + '` rule in globals.css').toBeGreaterThan(-1);
const open = css.indexOf('{', idx);
return css.slice(open + 1, css.indexOf('}', open));
}
/** True when a CSS colour value has no alpha channel below 1. */
function isOpaque(value: string): boolean {
const v = value.trim().toLowerCase();
if (v === 'transparent') return false;
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(v)) return true;
if (/^#[0-9a-f]{4}$/.test(v)) return v.endsWith('f');
if (/^#[0-9a-f]{8}$/.test(v)) return v.endsWith('ff');
const fn = v.match(/^(rgb|hsl)a?\((.*)\)$/);
if (fn) {
const parts = fn[2].split(/[\s,/]+/).filter(Boolean);
if (parts.length < 4) return true;
const alpha = parts[3];
return alpha.endsWith('%') ? parseFloat(alpha) >= 100 : parseFloat(alpha) >= 1;
}
return false;
}
/** Concrete `--color-popover` values, skipping the `@theme` alias `var(--color-popover)`. */
function popoverValues(source: string): string[] {
return [...source.matchAll(/--color-popover:\s*([^;]+);/g)]
.map((m) => m[1].trim())
.filter((v) => !v.startsWith('var('));
}
describe('native <select> option colours (#999)', () => {
it('pins option backgrounds and text to the popover tokens', () => {
const body = ruleBody('option');
expect(body).toMatch(/background-color:\s*var\(--color-popover\)/);
expect(body).toMatch(/(?:^|[^-])color:\s*var\(--color-popover-foreground\)/);
});
it('asks the browser for a dark popup in dark mode', () => {
expect(ruleBody('.dark select')).toMatch(/color-scheme:\s*dark/);
});
it('keeps the base light and dark popover surfaces opaque', () => {
const values = popoverValues(css);
expect(values.length).toBeGreaterThanOrEqual(2);
for (const v of values) expect(isOpaque(v), `globals.css --color-popover: ${v}`).toBe(true);
});
it.each(BUILTIN_THEMES.map((t) => [t.name, t] as const))(
'%s defines an opaque popover in every variant',
(_name, theme) => {
const values = popoverValues(theme.css);
expect(values.length, 'theme css should set --color-popover').toBeGreaterThanOrEqual(
theme.variants.length
);
for (const v of values) expect(isOpaque(v), `--color-popover: ${v}`).toBe(true);
}
);
});