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(`