From c17f4273c6bd6eec2572ec7d08df38cbec21afcc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 21:26:21 -0400 Subject: [PATCH 1/5] test(web): stub SvelteKit app modules suite-wide so no test loads the client runtime (#3943) The home page reaches the real $app/navigation through AlbumCard and AlbumMenu. That loads SvelteKit's client runtime, whose $app/paths reads __SVELTEKIT_PAYLOAD__ at module load. The global is only there when the kit plugin's define reaches the module, and under vitest that is not reliable: page.test.ts failed to load on CI run 6576 and passed on its re-run. #374 was the same class of failure. vitest.setup.ts now mocks $app/navigation, $app/state and $app/paths for every test. Per-file mocks still win. A small guard test fails if the suite-wide mocks are removed. Co-Authored-By: Claude Opus 5.5 --- web/src/test-utils/appModuleMocks.test.ts | 16 ++++++++ web/vitest.setup.ts | 45 +++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 web/src/test-utils/appModuleMocks.test.ts diff --git a/web/src/test-utils/appModuleMocks.test.ts b/web/src/test-utils/appModuleMocks.test.ts new file mode 100644 index 00000000..1359f45c --- /dev/null +++ b/web/src/test-utils/appModuleMocks.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test, vi } from 'vitest'; +import { goto } from '$app/navigation'; +import { page } from '$app/state'; + +// Guards the suite-wide $app mocks in vitest.setup.ts (#3943). If they are +// removed, tests that never mock $app themselves go back to loading +// SvelteKit's client runtime, which fails at module load on some CI runs. +describe('suite-wide SvelteKit app-module mocks', () => { + test('$app/navigation is the mock, not the client runtime', () => { + expect(vi.isMockFunction(goto)).toBe(true); + }); + + test('$app/state gives a page at the root URL', () => { + expect(page.url.pathname).toBe('/'); + }); +}); diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index 8f28a4de..450f1133 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -15,6 +15,51 @@ vi.mock('@tanstack/svelte-query', async (orig) => { return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; }); +// Default SvelteKit app-module mocks for the whole suite (#3943). Without +// them, any page or component that reaches $app/navigation or $app/state — +// the home page does, through AlbumCard → AlbumMenu — loads SvelteKit's +// client runtime, whose $app/paths reads __SVELTEKIT_PAYLOAD__ at module +// load. That global only exists when the kit plugin's define reaches the +// module, which it does not reliably under vitest: page.test.ts failed to +// load on one CI run and passed on the re-run of the same commit (#374 +// was the same class). Stubbing the modules keeps every test off the +// client bootstrap. Tests that assert on goto or need a particular URL +// still mock per file (pageUrlModule in test-utils/mocks/appState.ts), +// and per-file mocks win over these. +vi.mock('$app/navigation', () => ({ + goto: vi.fn(() => Promise.resolve()), + invalidate: vi.fn(() => Promise.resolve()), + invalidateAll: vi.fn(() => Promise.resolve()), + preloadData: vi.fn(() => Promise.resolve()), + preloadCode: vi.fn(() => Promise.resolve()), + beforeNavigate: vi.fn(), + afterNavigate: vi.fn(), + onNavigate: vi.fn(), + pushState: vi.fn(), + replaceState: vi.fn(), + disableScrollHandling: vi.fn() +})); +vi.mock('$app/state', () => ({ + page: { + url: new URL('http://localhost/'), + params: {}, + route: { id: null }, + status: 200, + error: null, + data: {}, + form: null, + state: {} + }, + navigating: null, + updated: { current: false, check: () => Promise.resolve(false) } +})); +vi.mock('$app/paths', () => ({ + base: '', + assets: '', + resolve: (path: string) => path, + asset: (path: string) => path +})); + // Node 25 ships a partial localStorage global that lacks getItem/setItem/clear // unless launched with --localstorage-file. jsdom sees Node's global and skips // installing its own. Replace with an in-memory Storage-compatible polyfill so -- 2.54.0 From fdee77eaec97a66718f6cf145059b66bfbc97ea5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 22:51:52 -0400 Subject: [PATCH 2/5] fix(web): text on a tint of its own hue uses the house -fg tokens (#3150) A hue painted as text on a color-mix tint of itself sits close to the surface under it. On Minstrel's surfaces the raw accent on its 15% tint measures 1.97:1 at worst (dark mode, hover surface), against AA's 4.5. - tokens.json gains colors.fg: the five FabledSword -fg formulas (accent 45%, success 45%, warning, error and info 50%), each mixed toward parchment so one declaration serves both modes. Success is Minstrel's moss. tokens-to-css emits them in :root. - Tailwind exposes them as text-accent-fg, text-warning-fg, text-error-fg and text-info-fg. - 23 sites swapped: 14 Tailwind class strings (PlayerBar and the admin count pills) and 9 CSS rules (StatusPill's four tones and five accent chips). Worst case after: accent-fg 5.03, error-fg 4.75, warning-fg 4.92, success-fg 4.85. - scripts/check-tint-contrast.js finds the pair in either spelling. Its test scans src in the web Vitest lane and fails on any new site, with fixture cases showing it can fail. Co-Authored-By: Claude Opus 5.5 --- web/scripts/check-tint-contrast.js | 89 +++++++++++++++++++ web/scripts/check-tint-contrast.test.js | 61 +++++++++++++ web/scripts/tokens-to-css.js | 8 ++ web/scripts/tokens-to-css.test.js | 12 ++- .../lib/components/DiscoverResultCard.svelte | 2 +- web/src/lib/components/PlayerBar.svelte | 12 +-- web/src/lib/components/StatusPill.svelte | 8 +- web/src/lib/styles/tokens.generated.css | 5 ++ web/src/lib/styles/tokens.json | 7 ++ web/src/routes/admin/duplicates/+page.svelte | 4 +- .../routes/admin/missing-files/+page.svelte | 2 +- .../routes/admin/playback-errors/+page.svelte | 4 +- web/src/routes/admin/quarantine/+page.svelte | 4 +- web/src/routes/admin/requests/+page.svelte | 4 +- web/src/routes/admin/users/+page.svelte | 2 +- web/src/routes/library/hidden/+page.svelte | 2 +- web/src/routes/requests/+page.svelte | 2 +- web/tailwind.config.js | 11 ++- 18 files changed, 211 insertions(+), 28 deletions(-) create mode 100644 web/scripts/check-tint-contrast.js create mode 100644 web/scripts/check-tint-contrast.test.js diff --git a/web/scripts/check-tint-contrast.js b/web/scripts/check-tint-contrast.js new file mode 100644 index 00000000..10ac4e73 --- /dev/null +++ b/web/scripts/check-tint-contrast.js @@ -0,0 +1,89 @@ +// Finds a hue painted as TEXT on a tint of itself (#3150). The tint sits close +// to the surface under it, so the raw hue on it measures 1.6–2.4:1 against +// AA's 4.5. The fix is the hue's -fg partner (tokens.json colors.fg), mixed +// toward parchment: accent-fg on an accent tint measures 5.03. +// +// Two spellings are checked: +// Tailwind one class string holding both `bg-X-tint` / `bg-X/NN` and `text-X` +// CSS one rule block holding both a `color-mix(… var(--fs-X) N%, +// transparent)` background and `color: var(--fs-X)` +// +// A pair split across two strings (a tint in one class: directive, the text +// in another) is not seen. The lookbehind on `color:` keeps border-color and +// outline-color out: a border is a graphic with a 3:1 floor, not text. +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +export const HUES = { + accent: 'accent-fg', + warning: 'warning-fg', + error: 'error-fg', + info: 'info-fg', + moss: 'success-fg' +}; + +// The quoted string around index i. Read outward from the tint class rather +// than pairing every quote in the file, which an apostrophe in prose +// ("Couldn't") throws out of step. +function enclosingString(source, i) { + const start = Math.max(...['"', "'", '`'].map((q) => source.lastIndexOf(q, i))); + if (start < 0) return ''; + const end = source.indexOf(source[start], i); + return end < 0 ? '' : source.slice(start, end + 1); +} + +/** Tailwind class strings that put text-X on a bg-X tint. */ +export function findTailwind(source) { + const found = []; + for (const hue of Object.keys(HUES)) { + const tint = new RegExp(`(?<=^|[\\s'"\`])bg-${hue}(?:-tint|/\\d+)(?=$|[\\s'"\`])`, 'g'); + const text = new RegExp(`(?:^|[\\s'"\`])text-${hue}(?=$|[\\s'"\`])`); + for (const m of source.matchAll(tint)) { + if (text.test(enclosingString(source, m.index))) { + found.push({ hue, index: m.index, fix: `text-${hue === 'moss' ? 'success' : hue}-fg` }); + } + } + } + return found; +} + +/** CSS rule blocks that put color: var(--fs-X) on a color-mix tint of X. */ +export function findCss(source) { + const found = []; + for (const m of source.matchAll(/\{([^{}]*)\}/g)) { + for (const hue of Object.keys(HUES)) { + const tint = new RegExp( + `background(?:-color)?\\s*:\\s*color-mix\\(\\s*in srgb\\s*,\\s*var\\(--fs-${hue}\\)\\s*\\d+%\\s*,\\s*transparent\\s*\\)` + ); + const text = new RegExp(`(? { + test('no source paints a hue as text on a tint of itself', () => { + expect(scan(resolve(web, 'src'), web)).toEqual([]); + }); + + describe('Tailwind', () => { + test('finds text-X on bg-X-tint and bg-X/NN in one class string', () => { + expect(findTailwind(`class="rounded bg-accent-tint px-2 text-accent"`)).toHaveLength(1); + expect(findTailwind(`'bg-error/15 text-error'`)).toHaveLength(1); + expect(findTailwind("`bg-moss/10 text-moss`")[0].fix).toBe('text-success-fg'); + }); + + test('a class string spanning lines is one string', () => { + expect(findTailwind(`class="bg-accent-tint\n text-accent"`)).toHaveLength(1); + }); + + test('an apostrophe earlier in the file does not hide a pair', () => { + expect( + findTailwind(`

Couldn't add

\n`) + ).toHaveLength(1); + }); + + test('passes the -fg partner, another hue, and a tint with no hue text', () => { + expect(findTailwind(`"bg-accent-tint text-accent-fg"`)).toEqual([]); + expect(findTailwind(`"bg-accent-tint text-error"`)).toEqual([]); + expect(findTailwind(`"bg-accent-tint text-text-primary"`)).toEqual([]); + expect(findTailwind(`"text-accent"`)).toEqual([]); + }); + }); + + describe('CSS', () => { + const tinted = (color) => + `.pill { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); ${color} }`; + + test('finds color: var(--fs-X) on a color-mix tint of X', () => { + expect(findCss(tinted('color: var(--fs-warning);'))).toHaveLength(1); + expect(findCss(tinted('color: var(--fs-warning);'))[0].fix).toBe('var(--fs-warning-fg)'); + }); + + test('passes the -fg partner and border or outline colours', () => { + expect(findCss(tinted('color: var(--fs-warning-fg);'))).toEqual([]); + expect(findCss(tinted('border-color: var(--fs-warning);'))).toEqual([]); + expect(findCss(tinted('outline-color: var(--fs-warning);'))).toEqual([]); + }); + + test('a tint and a colour in different rules are not a pair', () => { + expect( + findCss( + `.a { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }\n.b { color: var(--fs-accent); }` + ) + ).toEqual([]); + }); + }); +}); diff --git a/web/scripts/tokens-to-css.js b/web/scripts/tokens-to-css.js index d72f50a3..42495359 100644 --- a/web/scripts/tokens-to-css.js +++ b/web/scripts/tokens-to-css.js @@ -19,6 +19,14 @@ export function emit(tokens) { for (const [name, value] of Object.entries(tokens.colors.flat)) { lines.push(` --fs-${name}: ${value};`); } + // The house -fg tokens (FabledSword, 2026-08-27; #3150): a hue as TEXT on a + // tint of itself, mixed toward parchment. Parchment inverts in light mode, + // so one declaration lightens the text on dark surfaces and darkens it on + // light ones. Declared on :root, the element data-theme is set on, so the + // var()s resolve against that mode's parchment. + for (const [name, value] of Object.entries(tokens.colors.fg ?? {})) { + lines.push(` --fs-${name}: ${value};`); + } for (const [name, value] of Object.entries(tokens.radii)) { lines.push(` --fs-radius-${name}: ${value};`); } diff --git a/web/scripts/tokens-to-css.test.js b/web/scripts/tokens-to-css.test.js index 0190d81c..2b38a578 100644 --- a/web/scripts/tokens-to-css.test.js +++ b/web/scripts/tokens-to-css.test.js @@ -5,7 +5,8 @@ const sample = { colors: { dark: { obsidian: '#000', parchment: '#FFF' }, light: { obsidian: '#FFF', parchment: '#000' }, - flat: { accent: '#4A6B5C', 'on-action': '#E8E4D8' } + flat: { accent: '#4A6B5C', 'on-action': '#E8E4D8' }, + fg: { 'accent-fg': 'color-mix(in srgb, var(--fs-accent) 45%, var(--fs-parchment))' } }, radii: { sm: '4px' }, fontStacks: { @@ -40,4 +41,13 @@ describe('tokens-to-css emit()', () => { expect(css).toContain('--fs-radius-sm: 4px;'); expect(css).toContain("--fs-font-display: 'Fraunces', serif;"); }); + + test('emits the -fg tokens in :root, not the light block', () => { + const css = emit(sample); + const root = css.match(/:root \{([\s\S]*?)\}/); + expect(root).not.toBeNull(); + expect(root[1]).toContain('--fs-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-parchment));'); + const light = css.match(/\[data-theme="light"\] \{([\s\S]*?)\}/); + expect(light[1]).not.toContain('-fg'); + }); }); diff --git a/web/src/lib/components/DiscoverResultCard.svelte b/web/src/lib/components/DiscoverResultCard.svelte index ea3ae967..a4710371 100644 --- a/web/src/lib/components/DiscoverResultCard.svelte +++ b/web/src/lib/components/DiscoverResultCard.svelte @@ -169,7 +169,7 @@ font-size: 11px; line-height: 14px; background: color-mix(in srgb, var(--fs-accent) 15%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); } /* Muted rather than accented: a parked card should recede, not compete with the live suggestions around it. Same geometry as .kept-pill so the diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte index bd9397c8..f9173f87 100644 --- a/web/src/lib/components/PlayerBar.svelte +++ b/web/src/lib/components/PlayerBar.svelte @@ -184,7 +184,7 @@ aria-pressed={player.shuffle} onclick={toggleShuffle} class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.shuffle - ? 'bg-accent-tint text-accent' + ? 'bg-accent-tint text-accent-fg' : 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}" > @@ -194,7 +194,7 @@ aria-label={repeatLabel} onclick={cycleRepeat} class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.repeat !== 'off' - ? 'bg-accent-tint text-accent' + ? 'bg-accent-tint text-accent-fg' : 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}" > {#if player.repeat === 'one'} @@ -209,7 +209,7 @@ aria-label={player.queueDrawerOpen ? 'Close queue' : 'Open queue'} aria-pressed={player.queueDrawerOpen} class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.queueDrawerOpen - ? 'bg-accent-tint text-accent' + ? 'bg-accent-tint text-accent-fg' : 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}" > @@ -392,7 +392,7 @@ aria-pressed={player.shuffle} onclick={toggleShuffle} class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.shuffle - ? 'bg-accent-tint text-accent' + ? 'bg-accent-tint text-accent-fg' : 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}" > @@ -402,7 +402,7 @@ aria-label={repeatLabel} onclick={cycleRepeat} class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.repeat !== 'off' - ? 'bg-accent-tint text-accent' + ? 'bg-accent-tint text-accent-fg' : 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}" > {#if player.repeat === 'one'} @@ -417,7 +417,7 @@ aria-label={player.queueDrawerOpen ? 'Close queue' : 'Open queue'} aria-pressed={player.queueDrawerOpen} class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.queueDrawerOpen - ? 'bg-accent-tint text-accent' + ? 'bg-accent-tint text-accent-fg' : 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}" > diff --git a/web/src/lib/components/StatusPill.svelte b/web/src/lib/components/StatusPill.svelte index cc6907b8..cad18c67 100644 --- a/web/src/lib/components/StatusPill.svelte +++ b/web/src/lib/components/StatusPill.svelte @@ -37,18 +37,18 @@ } .pill[data-tone='warning'] { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); - color: var(--fs-warning); + color: var(--fs-warning-fg); } .pill[data-tone='info'] { background: color-mix(in srgb, var(--fs-info) 15%, transparent); - color: var(--fs-info); + color: var(--fs-info-fg); } .pill[data-tone='success'] { background: color-mix(in srgb, var(--fs-moss) 15%, transparent); - color: var(--fs-moss); + color: var(--fs-success-fg); } .pill[data-tone='error'] { background: color-mix(in srgb, var(--fs-error) 15%, transparent); - color: var(--fs-error); + color: var(--fs-error-fg); } diff --git a/web/src/lib/styles/tokens.generated.css b/web/src/lib/styles/tokens.generated.css index 0ebfd72f..af3a9d32 100644 --- a/web/src/lib/styles/tokens.generated.css +++ b/web/src/lib/styles/tokens.generated.css @@ -15,6 +15,11 @@ --fs-info: #3D5A6E; --fs-accent: #4A6B5C; --fs-on-action: #E8E4D8; + --fs-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-parchment)); + --fs-success-fg: color-mix(in srgb, var(--fs-moss) 45%, var(--fs-parchment)); + --fs-warning-fg: color-mix(in srgb, var(--fs-warning) 50%, var(--fs-parchment)); + --fs-error-fg: color-mix(in srgb, var(--fs-error) 50%, var(--fs-parchment)); + --fs-info-fg: color-mix(in srgb, var(--fs-info) 50%, var(--fs-parchment)); --fs-radius-sm: 4px; --fs-radius-md: 8px; --fs-radius-lg: 12px; diff --git a/web/src/lib/styles/tokens.json b/web/src/lib/styles/tokens.json index c602dfe7..91776147 100644 --- a/web/src/lib/styles/tokens.json +++ b/web/src/lib/styles/tokens.json @@ -27,6 +27,13 @@ "info": "#3D5A6E", "accent": "#4A6B5C", "on-action": "#E8E4D8" + }, + "fg": { + "accent-fg": "color-mix(in srgb, var(--fs-accent) 45%, var(--fs-parchment))", + "success-fg": "color-mix(in srgb, var(--fs-moss) 45%, var(--fs-parchment))", + "warning-fg": "color-mix(in srgb, var(--fs-warning) 50%, var(--fs-parchment))", + "error-fg": "color-mix(in srgb, var(--fs-error) 50%, var(--fs-parchment))", + "info-fg": "color-mix(in srgb, var(--fs-info) 50%, var(--fs-parchment))" } }, "radii": { "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px" }, diff --git a/web/src/routes/admin/duplicates/+page.svelte b/web/src/routes/admin/duplicates/+page.svelte index fb27e7d3..b401b1ba 100644 --- a/web/src/routes/admin/duplicates/+page.svelte +++ b/web/src/routes/admin/duplicates/+page.svelte @@ -138,7 +138,7 @@

Duplicates

{#if total > 0} {total} @@ -302,7 +302,7 @@ diff --git a/web/src/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte index c2c91e68..cad77789 100644 --- a/web/src/routes/admin/missing-files/+page.svelte +++ b/web/src/routes/admin/missing-files/+page.svelte @@ -53,7 +53,7 @@

Missing files

{#if total > 0} {total} diff --git a/web/src/routes/admin/playback-errors/+page.svelte b/web/src/routes/admin/playback-errors/+page.svelte index 0008619a..23d8309f 100644 --- a/web/src/routes/admin/playback-errors/+page.svelte +++ b/web/src/routes/admin/playback-errors/+page.svelte @@ -167,7 +167,7 @@

{#if !query.isPending && !query.isError && !resolved} - + {rows.length} unresolved {/if} @@ -220,7 +220,7 @@ {kindLabel(r.kind)} {#if resolved && r.resolution} - + {resolutionLabel(r.resolution)} {/if} diff --git a/web/src/routes/admin/quarantine/+page.svelte b/web/src/routes/admin/quarantine/+page.svelte index 420274c9..dc4b316c 100644 --- a/web/src/routes/admin/quarantine/+page.svelte +++ b/web/src/routes/admin/quarantine/+page.svelte @@ -150,7 +150,7 @@

Quarantine

{#if totalReports > 0} {totalReports} @@ -392,6 +392,6 @@ font-size: 11px; line-height: 14px; background: color-mix(in srgb, var(--fs-accent) 15%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); } diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte index 11cb77c5..363e70f4 100644 --- a/web/src/routes/admin/requests/+page.svelte +++ b/web/src/routes/admin/requests/+page.svelte @@ -183,7 +183,7 @@ {tab.label} {#if isActive} {rows.length} @@ -377,7 +377,7 @@ font-size: 11px; line-height: 14px; background: color-mix(in srgb, var(--fs-accent) 15%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); text-transform: capitalize; } diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index 96d5aa19..efca14e8 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -274,7 +274,7 @@ {u.username} {#if u.is_admin} admin {/if} diff --git a/web/src/routes/library/hidden/+page.svelte b/web/src/routes/library/hidden/+page.svelte index 3566e437..5c23d95b 100644 --- a/web/src/routes/library/hidden/+page.svelte +++ b/web/src/routes/library/hidden/+page.svelte @@ -100,6 +100,6 @@ font-size: 11px; line-height: 14px; background: color-mix(in srgb, var(--fs-accent) 15%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); } diff --git a/web/src/routes/requests/+page.svelte b/web/src/routes/requests/+page.svelte index c0de0908..43d19a64 100644 --- a/web/src/routes/requests/+page.svelte +++ b/web/src/routes/requests/+page.svelte @@ -160,7 +160,7 @@ font-size: 11px; line-height: 14px; background: color-mix(in srgb, var(--fs-accent) 15%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); text-transform: capitalize; } diff --git a/web/tailwind.config.js b/web/tailwind.config.js index b91d7432..75a9b272 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -25,11 +25,14 @@ export default { }, accent: { DEFAULT: 'var(--fs-accent)', - tint: 'color-mix(in srgb, var(--fs-accent) 12%, transparent)' + tint: 'color-mix(in srgb, var(--fs-accent) 12%, transparent)', + // Accent TEXT on an accent tint (#3150). The raw accent on its own + // tint measures 1.97:1 at worst; this measures 5.03. + fg: 'var(--fs-accent-fg)' }, - warning: 'var(--fs-warning)', - error: 'var(--fs-error)', - info: 'var(--fs-info)' + warning: { DEFAULT: 'var(--fs-warning)', fg: 'var(--fs-warning-fg)' }, + error: { DEFAULT: 'var(--fs-error)', fg: 'var(--fs-error-fg)' }, + info: { DEFAULT: 'var(--fs-info)', fg: 'var(--fs-info-fg)' } }, borderRadius: { sm: 'var(--fs-radius-sm)', -- 2.54.0 From 743b6f5eac758425db868d26ebabb5dabfbefcd7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 23:00:28 -0400 Subject: [PATCH 3/5] fix(web): error text uses error-fg on every surface, not only on tints (#3150) Raw error red fails AA as text even with no tint behind it: in dark mode it measures 3.63:1 on obsidian, 3.23 on iron and 2.64 on slate, against 4.5. error-fg (the house formula, 50% toward parchment) measures 5.30 at worst across both modes. - All 19 text-error uses become text-error-fg: the "Couldn't load" messages on the admin pages, the integrations form errors, the flag popover, and the error toast's text. The toast keeps its error border, since a border is a graphic with a 3:1 floor. - check-tint-contrast flags raw error text anywhere (text-error, class:text-error, color: var(--fs-error)) and leaves borders and outlines alone. Run against the files before the swap, it finds all 19. Co-Authored-By: Claude Opus 5.5 --- web/scripts/check-tint-contrast.js | 23 ++++++++++++++++++- web/scripts/check-tint-contrast.test.js | 16 ++++++++++++- web/src/lib/components/FlagPopover.svelte | 2 +- web/src/lib/components/ToastHost.svelte | 2 +- web/src/routes/admin/diagnostics/+page.svelte | 2 +- web/src/routes/admin/duplicates/+page.svelte | 2 +- .../routes/admin/integrations/+page.svelte | 16 ++++++------- .../routes/admin/missing-files/+page.svelte | 2 +- .../routes/admin/playback-errors/+page.svelte | 2 +- web/src/routes/admin/quarantine/+page.svelte | 4 ++-- web/src/routes/admin/requests/+page.svelte | 2 +- web/src/routes/admin/users/+page.svelte | 4 ++-- 12 files changed, 56 insertions(+), 21 deletions(-) diff --git a/web/scripts/check-tint-contrast.js b/web/scripts/check-tint-contrast.js index 10ac4e73..a86abafc 100644 --- a/web/scripts/check-tint-contrast.js +++ b/web/scripts/check-tint-contrast.js @@ -64,6 +64,27 @@ export function findCss(source) { return found; } +// Hues that fail AA as text even on a plain surface, so the raw hue is never +// text at all: error measures 2.64:1 on slate and 3.23 on iron in dark mode, +// against 4.5; error-fg measures 5.30 at worst. A border or outline in the +// hue is a graphic with a 3:1 floor and stays allowed. +export const TEXT_NEVER_RAW = ['error']; + +/** Raw text-X classes and color: var(--fs-X) for the hues above. */ +export function findRawText(source) { + const found = []; + for (const hue of TEXT_NEVER_RAW) { + const patterns = [ + [new RegExp(`(?<=^|[\\s'"\`:])text-${hue}(?=$|[\\s'"\`=])`, 'g'), `text-${hue}-fg`], + [new RegExp(`(? { }); }); + describe('raw error text, on any surface', () => { + test('finds text-error, class:text-error and color: var(--fs-error)', () => { + expect(findRawText(`

x

`)).toHaveLength(1); + expect(findRawText(`
`)).toHaveLength(1); + expect(findRawText(`.msg { color: var(--fs-error); }`)[0].fix).toBe('var(--fs-error-fg)'); + }); + + test('passes error-fg, and error as a border or outline', () => { + expect(findRawText(`

x

`)).toEqual([]); + expect(findRawText(`
`)).toEqual([]); + expect(findRawText(`.a { border-color: var(--fs-error); outline-color: var(--fs-error); }`)).toEqual([]); + }); + }); + describe('CSS', () => { const tinted = (color) => `.pill { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); ${color} }`; diff --git a/web/src/lib/components/FlagPopover.svelte b/web/src/lib/components/FlagPopover.svelte index a3a75db6..ebccc63a 100644 --- a/web/src/lib/components/FlagPopover.svelte +++ b/web/src/lib/components/FlagPopover.svelte @@ -79,7 +79,7 @@ > {#if error} -

Couldn't save flag — {error}

+

Couldn't save flag — {error}

{/if}
diff --git a/web/src/routes/admin/diagnostics/+page.svelte b/web/src/routes/admin/diagnostics/+page.svelte index 7224421b..bd7c487f 100644 --- a/web/src/routes/admin/diagnostics/+page.svelte +++ b/web/src/routes/admin/diagnostics/+page.svelte @@ -325,7 +325,7 @@ {#if diagQuery.isError} -

Couldn't load: {errMessage(diagQuery.error)}

+

Couldn't load: {errMessage(diagQuery.error)}

{:else if diagQuery.isPending}

Loading…

{:else if rows.length === 0} diff --git a/web/src/routes/admin/duplicates/+page.svelte b/web/src/routes/admin/duplicates/+page.svelte index b401b1ba..5cf7a768 100644 --- a/web/src/routes/admin/duplicates/+page.svelte +++ b/web/src/routes/admin/duplicates/+page.svelte @@ -188,7 +188,7 @@ {#if query.isPending}

Loading duplicates…

{:else if query.isError} -

Couldn't load the duplicates report.

+

Couldn't load the duplicates report.

{:else if groups.length === 0} diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index 9d6bae2d..f98096b1 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -463,7 +463,7 @@ {/each} {#if testListErrors.quality_profiles} -

+

Couldn't fetch quality profiles — {testListErrors.quality_profiles}

{/if} @@ -481,7 +481,7 @@ {/each} {#if testListErrors.metadata_profiles} -

+

Couldn't fetch metadata profiles — {testListErrors.metadata_profiles}

{/if} @@ -501,7 +501,7 @@ {/each} {#if testListErrors.root_folders} -

+

Couldn't fetch root folders — {testListErrors.root_folders}

{/if} @@ -513,11 +513,11 @@ Connected — Lidarr {testResult.version}

{:else} -

Connection failed — {testResult.error}

+

Connection failed — {testResult.error}

{/if} {/if} {#if saveError} -

Save failed — {saveError}

+

Save failed — {saveError}

{/if}
@@ -625,7 +625,7 @@ OK{coverTestResults[provider.id].duration_ms ? ` (${coverTestResults[provider.id].duration_ms}ms)` : ''}

{:else} -

+

Failed — {coverTestResults[provider.id].error}

{/if} @@ -722,7 +722,7 @@ OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''}

{:else} -

+

Failed — {tagTestResults[provider.id].error}

{/if} @@ -837,7 +837,7 @@ class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" /> {#if disconnectError} -

Disconnect failed — {disconnectError}

+

Disconnect failed — {disconnectError}

{/if}