diff --git a/app/(sandbox)/plugin-kit.css b/app/(sandbox)/plugin-kit.css index 0459e062a..19718a50e 100644 --- a/app/(sandbox)/plugin-kit.css +++ b/app/(sandbox)/plugin-kit.css @@ -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; + } } diff --git a/app/accessibility.css b/app/accessibility.css index 3ba221c1a..f9b018c3c 100644 --- a/app/accessibility.css +++ b/app/accessibility.css @@ -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 ---------- */ diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index ab26a6bf3..1969a0423 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -178,6 +178,7 @@ const ToolbarButton = React.forwardRef ( @@ -38,6 +39,7 @@ export const ToolbarSearchAction = React.forwardRef { 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)', ''], + ['the selected tab', ''], + ['the open message row', '
'], + ['the current folder or page', ''], + ['an active toolbar button', ''], + ['a checked view-mode radio', ''], + ['a selected option', '
'], + ])('maps %s to Highlight', (_name, html) => { + expect(el(html).matches(selected)).toBe(true); + }); + + it.each([ + ['an unpressed segment', ''], + ['an unselected tab', ''], + ['today without selection', ''], + ['a native radio', ''], + ])('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('').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;'); + }); +});