fix: keep switches, selections and pressed buttons visible in Windows contrast themes

This commit is contained in:
Linus Rath
2026-10-29 19:38:44 +01:00
parent c4dfa6f3e4
commit 7525fe18e4
5 changed files with 185 additions and 3 deletions
+20
View File
@@ -91,4 +91,24 @@
:is(.bk, .bk *):focus-visible {
outline-color: Highlight !important;
}
/* Forced colours drop the switch's tint and ring; a border and a solid
knob keep on and off apart (as app/accessibility.css does). */
[role="switch"].bk {
border: 1px solid ButtonText !important;
}
[role="switch"].bk > span {
forced-color-adjust: none;
background-color: ButtonText !important;
}
[role="switch"].bk[aria-checked="true"] {
background-color: Highlight !important;
border-color: Highlight !important;
}
[role="switch"].bk[aria-checked="true"] > span {
background-color: HighlightText !important;
}
:is(.bk [aria-pressed="true"], .bk [role="radio"][aria-checked="true"], .bk [role="tab"][aria-selected="true"]) {
background-color: Highlight !important;
color: HighlightText !important;
}
}
+101 -2
View File
@@ -252,12 +252,111 @@ html.dark[data-contrast="more"] {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cpath d='M14 4 h12 M20 4 V36 M14 36 h12' fill='none' stroke='white' stroke-width='7' stroke-linecap='round'/%3E%3Cpath d='M14 4 h12 M20 4 V36 M14 36 h12' fill='none' stroke='black' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E") 20 20, text;
}
/* ---------- Forced colours (Windows contrast themes) ---------- */
/* ---------- Forced colours (Windows contrast themes) ----------
*
* Forced colours replace every background with Canvas and drop box-shadows,
* so state drawn only as a tint or a ring disappears. Each state gets a
* system colour here instead: selected, pressed and current items take
* Highlight with HighlightText (and so does everything inside them, whose own
* colours would otherwise be forced back to CanvasText on Highlight), today
* gets a frame, switches a border and a solid knob, dots CanvasText. A colour
* swatch keeps its own colour (forced-color-adjust on the swatch only),
* because the colour is what it shows.
*/
@media (forced-colors: active) {
:focus-visible:not([tabindex="-1"]:not(a[href], button, input, select, textarea, summary, [contenteditable="true"], [role="button"], [role="link"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], [role="gridcell"], [role="row"], [role="treeitem"], [role="tab"], [role="radio"], [role="checkbox"], [role="switch"], [role="slider"], [role="spinbutton"], [role="columnheader"], [role="rowheader"])) {
outline-color: Highlight !important;
}
:is(
[aria-selected="true"]:not([role="gridcell"]:has(> button)),
[role="gridcell"][aria-selected="true"] > button,
[aria-pressed="true"],
[aria-current="page"],
[aria-current="true"],
[aria-current="step"],
[aria-current="location"],
[data-selected="true"],
[data-active="true"],
[role="menuitemradio"][aria-checked="true"],
[role="radio"][aria-checked="true"]:not(input)
) {
/* Not forced, so text inside gets no Canvas backplate; the rules here
and below set every colour instead. */
forced-color-adjust: none;
background-color: Highlight !important;
color: HighlightText !important;
border-color: HighlightText !important;
}
:is(
[aria-selected="true"]:not([role="gridcell"]:has(> button)),
[role="gridcell"][aria-selected="true"] > button,
[aria-pressed="true"],
[aria-current="page"],
[aria-current="true"],
[aria-current="step"],
[aria-current="location"],
[data-selected="true"],
[data-active="true"],
[role="menuitemradio"][aria-checked="true"],
[role="radio"][aria-checked="true"]:not(input)
) *:not(svg *) {
color: HighlightText !important;
background-color: transparent !important;
border-color: HighlightText !important;
fill: currentColor;
}
/* Today, this month, this year: a frame, so it stays apart from the
selection. */
[aria-current="date"] {
outline: 2px solid CanvasText;
outline-offset: -2px;
}
/* Switches: a track border, and a knob that keeps its fill. On is
Highlight with a HighlightText knob on the far side. */
[role="switch"] {
border: 1px solid ButtonText !important;
}
[role="switch"] > span {
forced-color-adjust: none;
background-color: ButtonText !important;
}
[role="switch"][aria-checked="true"] {
background-color: Highlight !important;
border-color: Highlight !important;
}
[role="switch"][aria-checked="true"] > span {
background-color: HighlightText !important;
}
/* Unread and status dots, drawn as an empty tinted circle or a filled
icon. */
.rounded-full:empty:is(.bg-primary, .bg-unread, .bg-success, .bg-destructive, .bg-warning, .bg-info, [class*="bg-red-"], [class*="bg-green-"], [class*="bg-amber-"], [class*="bg-emerald-"], [class*="bg-blue-"], [class*="bg-yellow-"], [class*="bg-orange-"]) {
forced-color-adjust: none;
background-color: CanvasText !important;
}
:is(.fill-unread, .fill-primary, .fill-current) {
fill: CanvasText;
}
/* Colour swatches: an empty element whose colour is set inline (calendar,
tag and theme colours). */
:is(span, div, i):empty[style*="background"] {
forced-color-adjust: none;
}
/* Calendar events lose their fill; a frame keeps each one apart. */
[data-calendar-event] {
outline: 1px solid CanvasText;
outline-offset: -1px;
}
/* Count badges drawn as a tinted pill (the rail's unread count). */
.rounded-full:not(:empty):is(.bg-primary, .bg-destructive, [class*="bg-red-"]) {
border: 1px solid CanvasText;
}
}
/* ---------- Simple mode: no side panels ---------- */
+1
View File
@@ -178,6 +178,7 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
title={title}
aria-label={title}
aria-pressed={pressed === undefined ? undefined : pressed}
data-active={active || undefined}
aria-haspopup={popup}
aria-expanded={popup ? !!expanded : undefined}
className={cn(
+2
View File
@@ -25,6 +25,7 @@ export const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonPr
({ active, className, ...props }, ref) => (
<IconButton
ref={ref}
data-active={active || undefined}
className={cn("shrink-0", active ? TOOLBAR_ACTIVE : "text-muted-foreground hover:text-foreground", className)}
{...props}
/>
@@ -38,6 +39,7 @@ export const ToolbarSearchAction = React.forwardRef<HTMLButtonElement, React.But
<button
ref={ref}
type={type}
data-active={active || undefined}
className={cn(
"p-1 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
TOUCH_HIT_AREA,
+61 -1
View File
@@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import path from 'path';
const css = readFileSync(path.join(process.cwd(), 'app', 'accessibility.css'), 'utf8');
const css = readFileSync(path.join(process.cwd(), 'app', 'accessibility.css'), 'utf8').replace(/\r\n/g, '\n');
/** Selectors of the first top-level rule whose body contains `declaration`. */
function selectorsOf(declaration: string): string[] {
@@ -73,3 +73,63 @@ describe('global focus outline', () => {
expect(css).toMatch(/--focus-ring-color:\s*var\(--color-ring\)/);
});
});
/** The body of the top-level `@media (forced-colors: active)` block. */
function forcedBlock(): string {
const at = css.indexOf('@media (forced-colors: active) {');
expect(at).toBeGreaterThan(-1);
const open = css.indexOf('{', at);
let depth = 0;
let end = open;
for (; end < css.length; end++) {
if (css[end] === '{') depth++;
if (css[end] === '}' && --depth === 0) break;
}
return css.slice(open + 1, end).replace(/\/\*[\s\S]*?\*\//g, '');
}
/** The selector of the rule in `block` whose body contains `declaration`. */
function ruleSelector(block: string, declaration: string): string {
const at = block.indexOf(declaration);
expect(at).toBeGreaterThan(-1);
const open = block.lastIndexOf('{', at);
return block.slice(block.lastIndexOf('}', open) + 1, open).trim();
}
describe('forced colours', () => {
const block = forcedBlock();
const selected = ruleSelector(block, 'background-color: Highlight !important;\n color: HighlightText');
it.each([
['a pressed segment (settings radio group, formatting toggle)', '<button aria-pressed="true">Light</button>'],
['the selected tab', '<button role="tab" aria-selected="true">All</button>'],
['the open message row', '<div aria-current="true"></div>'],
['the current folder or page', '<button aria-current="page">Inbox</button>'],
['an active toolbar button', '<button data-active="true"></button>'],
['a checked view-mode radio', '<button role="radio" aria-checked="true"></button>'],
['a selected option', '<div role="option" aria-selected="true"></div>'],
])('maps %s to Highlight', (_name, html) => {
expect(el(html).matches(selected)).toBe(true);
});
it.each([
['an unpressed segment', '<button aria-pressed="false">Dark</button>'],
['an unselected tab', '<button role="tab" aria-selected="false">Unread</button>'],
['today without selection', '<button aria-current="date">29</button>'],
['a native radio', '<input type="radio" role="radio" aria-checked="true">'],
])('leaves %s alone', (_name, html) => {
expect(el(html).matches(selected)).toBe(false);
});
it('gives switches a border and a knob that keeps its fill', () => {
expect(ruleSelector(block, 'border: 1px solid ButtonText')).toBe('[role="switch"]');
const knob = block.slice(block.indexOf('[role="switch"] > span'));
expect(knob).toMatch(/^\[role="switch"\] > span \{\s*forced-color-adjust: none;\s*background-color: ButtonText/);
expect(block).toMatch(/\[role="switch"\]\[aria-checked="true"\] > span \{\s*background-color: HighlightText/);
});
it('lets a colour swatch keep its colour, and only the swatch', () => {
expect(el('<span style="background-color: rgb(1, 2, 3)"></span>').matches(':is(span, div, i):empty[style*="background"]')).toBe(true);
expect(block).toContain(':is(span, div, i):empty[style*="background"] {\n forced-color-adjust: none;');
});
});