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)',