From 18ca19044cc802194a0c0c5abc2b10d4eff3e133 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:37:49 +0200 Subject: [PATCH] fix: gate muted text contrast fix behind High Contrast Mode (#27554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #27495, reopened as a high contrast mode change. Builds on the `high-contrast` class landed in #27555. Muted UI text is written as text-gray-400 dark:text-gray-600. The grey scale in src/tailwind.css is achromatic oklch(L 0 0), so relative luminance is exactly L³: text-gray-400 is 2.07:1 on white and dark:text-gray-600 is 3.12:1 on #171717. The pair is effectively inverted, and both halves fail the 4.5:1 required by WCAG 1.4.3, with the light value also failing the 3:1 required of icons under 1.4.11. This is the text used for settings and admin section headings, field descriptions, sidebar labels, timestamps and counters, all at 10px to 12px, so the large-text exemption does not apply. Rather than rewriting the class literal at 251 sites, the remap is two CSS rules that only apply when the existing High Contrast Mode setting is on, so the default theme is untouched: - text-gray-400 resolves to gray-600 (5.75:1) in light mode - dark:text-gray-600 resolves to gray-400 (8.65:1) in dark mode dark:text-gray-500 already passes at 6.46:1 and is left alone. The rules live in `@layer utilities` and use `:where()` to stay at low specificity: they outrank the base utility but lose to `hover:` and `dark:hover:` variants, so hover feedback keeps working. Verified in a browser against Tailwind's emitted rules and layer order: with the setting on, resting text resolves to gray-600 in light and gray-400 in dark, hover still resolves to its own value, and with the setting off nothing changes in either theme. One component change is required alongside it. In admin/Settings/Audio.svelte the help text puts links inside the muted block via `[&_a]:text-gray-600`; once the surrounding prose resolves to gray-600 the link becomes the same colour as the text it sits in, and it has no resting underline, which would be a new WCAG 1.4.1 failure. The link moves to gray-900. This is the only place in the codebase where a link colour is nested inside muted text. Letting the variants win has one edge: a few elements hover to a grey lighter than their new resting colour, so hovering would have lowered contrast instead of raising it. A light-mode hover landing on gray-500 now resolves to gray-800, which keeps the hover darker than the gray-600 resting state. Sidebar/Section.svelte is the site this branch would otherwise break. Not covered here: text-gray-500 dark:text-gray-400 (2.77:1 in light), which is a separate branch. --- src/app.css | 17 +++++++++++++++++ src/lib/components/admin/Settings/Audio.svelte | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app.css b/src/app.css index f30259dc81..f0b54219d0 100644 --- a/src/app.css +++ b/src/app.css @@ -855,6 +855,23 @@ body { } } +/* High Contrast Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark). + :where() keeps these at low specificity so hover variants still win. */ +@layer utilities { + html:where(.high-contrast):not(:where(.dark)) .text-gray-400 { + color: var(--color-gray-600); + } + + html:where(.high-contrast).dark .dark\:text-gray-600 { + color: var(--color-gray-400); + } + + /* Elements that hover to a lighter grey than the new resting colour. */ + html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover { + color: var(--color-gray-800); + } +} + /* High Contrast Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1). The utility stays in the layer so hover variants still outrank it; the classes below are unlayered, so their override has to be too. */ diff --git a/src/lib/components/admin/Settings/Audio.svelte b/src/lib/components/admin/Settings/Audio.svelte index d68f37cf97..20de15aec6 100644 --- a/src/lib/components/admin/Settings/Audio.svelte +++ b/src/lib/components/admin/Settings/Audio.svelte @@ -83,7 +83,7 @@ const textareaClass = 'w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; const linkedHelpClass = - 'text-[0.6875rem] text-gray-400 dark:text-gray-600 [&_a]:text-gray-600 [&_a]:hover:underline dark:[&_a]:text-gray-300'; + 'text-[0.6875rem] text-gray-400 dark:text-gray-600 [&_a]:text-gray-900 [&_a]:hover:underline dark:[&_a]:text-gray-300'; const getModels = async () => { if (TTS_ENGINE === '') {