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