feat: auto OS-driven dark mode (prefers-color-scheme)

Refactor public/style.css to semantic CSS custom properties under :root
(light palette by default) and add a @media (prefers-color-scheme: dark)
override block that flips background/foreground/accent/danger tokens to
a soft near-black palette. The original --black/--white/--gray-*/--red
literal tokens and the lone #fff5f5 floating literal are gone — every
color now derives from a semantic token, so the dark override is the
only place that re-declares them.

Add <meta name="color-scheme" content="light dark"> to the shared
layout <head> so the browser renders its UA backdrop, native scrollbars,
and form-control popups in the user's preferred scheme before the
stylesheet parses — this is what avoids a white flash on initial paint
in dark mode.

No toggle, no JS, no preference persistence — the page follows the OS
setting directly via the media query. The 16px font-size rule on
text-entry inputs is preserved (still asserted by tests/integration
/style.test.ts).
This commit is contained in:
2026-05-11 09:50:44 -07:00
parent 398c008c32
commit 012c544bdc
4 changed files with 122 additions and 67 deletions

View File

@@ -29,6 +29,11 @@ describe('GET /', () => {
expect(res.statusCode).toBe(302);
expect(res.headers['location']).toBe('/upload');
});
it('emits <meta name="color-scheme" content="light dark"> in <head>', async () => {
const res = await ctx.app.inject({ method: 'GET', url: '/' });
expect(res.body).toMatch(/<meta\s+name="color-scheme"\s+content="light dark"/);
});
});
describe('POST /login (page)', () => {

View File

@@ -31,6 +31,23 @@ describe('public/style.css (file contents)', () => {
/input\[type="text"\][\s\S]*?input\[type="password"\][\s\S]*?\{[\s\S]*?font-size:\s*(1[6-9]|[2-9]\d)px/
);
});
it('declares a prefers-color-scheme: dark override', () => {
expect(css).toMatch(/@media\s*\(\s*prefers-color-scheme:\s*dark\s*\)/);
});
it('uses CSS custom properties for the core palette', () => {
expect(css).toMatch(/--bg:/);
expect(css).toMatch(/--fg:/);
expect(css).toMatch(/--accent:/);
});
it('does not contain unreplaced literal hex colors outside the :root and @media blocks', () => {
const stripped = css
.replace(/:root\s*\{[\s\S]*?\}/g, '')
.replace(/@media\s*\([^)]*\)\s*\{[\s\S]*?\}\s*\}/g, '');
expect(stripped).not.toMatch(/#[0-9a-fA-F]{3,6}\b/);
});
});
describe('GET /public/style.css', () => {