fix: gate placeholder contrast fix behind High Contrast Mode (#27555)

Follow-up to #27496, reopened as a high contrast mode change.

Placeholder text is the lowest contrast text in the product. The grey scale in src/tailwind.css is achromatic oklch(L 0 0), so relative luminance is exactly L³, and placeholder:text-gray-300 is 1.58:1 on white against the 4.5:1 required by WCAG 1.4.3. The large text exemption does not apply, the largest of these is text-lg. Placeholders are frequently the only format hint a field gives, for example admin/Settings/General.svelte uses e.g.) "http://localhost:3000".

Rather than deleting the ~200 per-component placeholder utilities and rewriting the base rule, the remap now happens in two CSS rules that only apply when the existing High Contrast Mode setting is on, so the default theme is untouched:

- placeholders resolve to gray-600 (5.75:1) in light mode
- placeholders resolve to gray-500 (6.46:1) in dark mode

The rules sit in `@layer utilities` and are anchored on `input`/`textarea`, which puts them above both the base rule in src/tailwind.css and every per-component `placeholder:text-*` utility, so no call site has to change. Placeholders stay distinguishable from real input values, which are text-gray-700 (8.46:1) in light and dark:text-gray-300 (11.39:1) in dark.

The chat composer placeholder is a tiptap ::before, so neither the base rule nor any utility reaches it. It is hardcoded #676767, which is 5.66:1 in light but only 3.17:1 on the dark canvas, so only the dark side is remapped, to gray-500. That rule stays outside the layer because the rule it overrides is unlayered too.

The root layout toggles a `high-contrast` class on documentElement from `$settings.highContrastMode`, alongside the existing theme classes, so every route is covered and the class is removed again when the setting is turned off.

Verified in a browser against Tailwind's emitted rules and layer order, on inputs both with and without per-component placeholder utilities: with the setting on, placeholders resolve to gray-600 in light and gray-500 in dark, the composer placeholder resolves to gray-500 in dark even with the prefers-color-scheme override treated as unconditional, and with the setting off nothing changes in either theme. Also checked against the oled-dark theme (gray-500 on #000 is 7.57:1) and the dark:bg-white/[0.03] input surface (6.01:1).

Note: the `high-contrast` class toggle is the same hunk as in the muted text contrast branch. Whichever lands first, the other rebases cleanly by dropping it.
This commit is contained in:
Classic298
2026-07-27 00:02:57 +02:00
committed by GitHub
parent 7bfc4bb2c2
commit e3cce68ef2
2 changed files with 23 additions and 0 deletions
+16
View File
@@ -410,6 +410,11 @@ input[type='number'] {
@apply line-clamp-1 absolute;
}
/* #676767 is 3.17:1 on the dark canvas; the light value already passes. */
html.high-contrast.dark .ProseMirror p.is-editor-empty:first-child::before {
color: var(--color-gray-500);
}
.tiptap ul[data-type='taskList'] {
list-style: none;
margin-left: 0;
@@ -838,3 +843,14 @@ body {
#note-content-container .ProseMirror {
padding-bottom: 2rem; /* space for the bottom toolbar */
}
/* High Contrast Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */
@layer utilities {
html.high-contrast:not(.dark) :is(input, textarea)::placeholder {
color: var(--color-gray-600);
}
html.high-contrast.dark :is(input, textarea)::placeholder {
color: var(--color-gray-500);
}
}
+7
View File
@@ -1293,6 +1293,13 @@
};
});
$: if (typeof document !== 'undefined') {
document.documentElement.classList.toggle(
'high-contrast',
$settings?.highContrastMode ?? false
);
}
onDestroy(() => {
bc.close();
});