From e123fce1f9c5a95993314c3ea183d8a17f4bc2a3 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Mon, 20 Apr 2026 18:59:26 -0400 Subject: [PATCH 01/11] fix: honor theme="light" attribute when prefers-color-scheme is dark Scope the `@media (prefers-color-scheme: dark)` blocks under `:host(:not([theme='light']))` so an author-set `theme="light"` on a `` takes precedence over the OS-level dark preference. Previously, only `[theme='dark']` had an explicit CSS counterpart; a `theme="light"` attribute was a no-op on dark systems because the dark media query kept matching the bare `:host`/`.ML__container` selectors. Fixes #2999. Co-Authored-By: Claude Opus 4.7 (1M context) --- css/mathfield.less | 5 ++- src/ui/colors/colors.less | 5 ++- src/ui/style.less | 5 ++- test/playwright-tests/theme.spec.ts | 61 +++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 test/playwright-tests/theme.spec.ts diff --git a/css/mathfield.less b/css/mathfield.less index 92ae2d5d7..02ead9d42 100644 --- a/css/mathfield.less +++ b/css/mathfield.less @@ -121,8 +121,11 @@ ); } +// Dark mode via system preference. Scoped with :not([theme='light']) so that +// an explicit theme='light' on the host overrides the system preference. +// See issue #2999. @media (prefers-color-scheme: dark) { - .ML__container { + :host(:not([theme='light'])) .ML__container { --_contains-highlight-color: var( --contains-highlight-color, hsl(var(--_hue), 85%, 75%) diff --git a/src/ui/colors/colors.less b/src/ui/colors/colors.less index aacc6a2e2..df9b2c6cd 100644 --- a/src/ui/colors/colors.less +++ b/src/ui/colors/colors.less @@ -166,8 +166,11 @@ --magenta-900: #8a004c; } +// Dark mode via system preference. Scoped with :not([theme='light']) so that +// an explicit theme='light' on the host overrides the system preference. +// See issue #2999. @media (prefers-color-scheme: dark) { - :host { + :host(:not([theme='light'])) { --semantic-blue: var(--blue-700); --semantic-red: var(--red-400); --semantic-orange: var(--orange-400); diff --git a/src/ui/style.less b/src/ui/style.less index 4e3981f9b..fb6b45074 100644 --- a/src/ui/style.less +++ b/src/ui/style.less @@ -74,8 +74,11 @@ --content-bg: var(--neutral-200); } +// Dark mode via system preference. Scoped with :not([theme='light']) so that +// an explicit theme='light' on the host overrides the system preference. +// See issue #2999. @media (prefers-color-scheme: dark) { - :host { + :host(:not([theme='light'])) { --ui-menu-bg: var(--neutral-200); } } diff --git a/test/playwright-tests/theme.spec.ts b/test/playwright-tests/theme.spec.ts new file mode 100644 index 000000000..08a20693c --- /dev/null +++ b/test/playwright-tests/theme.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; + +// Regression tests for https://github.com/arnog/mathlive/issues/2999 +// theme="light" must override `prefers-color-scheme: dark` so that an +// author-set attribute is authoritative over the OS-level preference. + +const LIGHT_NEUTRAL_100 = '#f5f5f5'; +const DARK_NEUTRAL_100 = '#121212'; + +const readNeutral100 = (id: string) => + `(() => { + const mf = document.getElementById(${JSON.stringify(id)}); + return getComputedStyle(mf).getPropertyValue('--neutral-100').trim(); + })()`; + +test.beforeEach(async ({ page }) => { + await page.goto('/dist/playwright-test-page/'); + await page.waitForSelector('math-field', { timeout: 5000 }); +}); + +test('theme="light" overrides prefers-color-scheme: dark', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }); + + // With no theme attribute, the system dark preference should apply. + const before = await page.evaluate(readNeutral100('mf-1')); + expect(before).toBe(DARK_NEUTRAL_100); + + // Setting theme='light' should flip back to the light palette. + await page.evaluate(() => { + document.getElementById('mf-1')!.setAttribute('theme', 'light'); + }); + const after = await page.evaluate(readNeutral100('mf-1')); + expect(after).toBe(LIGHT_NEUTRAL_100); +}); + +test('theme="dark" overrides prefers-color-scheme: light', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'light' }); + + const before = await page.evaluate(readNeutral100('mf-1')); + expect(before).toBe(LIGHT_NEUTRAL_100); + + await page.evaluate(() => { + document.getElementById('mf-1')!.setAttribute('theme', 'dark'); + }); + const after = await page.evaluate(readNeutral100('mf-1')); + expect(after).toBe(DARK_NEUTRAL_100); +}); + +test('removing theme attribute restores system preference', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }); + + await page.evaluate(() => { + document.getElementById('mf-1')!.setAttribute('theme', 'light'); + }); + expect(await page.evaluate(readNeutral100('mf-1'))).toBe(LIGHT_NEUTRAL_100); + + await page.evaluate(() => { + document.getElementById('mf-1')!.removeAttribute('theme'); + }); + expect(await page.evaluate(readNeutral100('mf-1'))).toBe(DARK_NEUTRAL_100); +}); From a0c89d50086acdbe65b022579fbc6191fd346671 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Mon, 20 Apr 2026 23:28:55 -0400 Subject: [PATCH 02/11] refactor: use color-scheme + light-dark() for theme overrides Replace the mirrored dark-theme blocks with a `color-scheme: light dark` default on :host and `light-dark()` per variable. `:host([theme='light'])` and `:host([theme='dark'])` pin the computed color-scheme so the attribute wins over `prefers-color-scheme` without duplicate declarations or double-negative selectors. The sole remaining attribute+media-query override is `--_smart-fence-opacity`, which is a and therefore can't ride light-dark(). Co-Authored-By: Claude Opus 4.7 (1M context) --- css/mathfield.less | 73 ++++++++++++++--------------- src/ui/colors/colors.less | 70 +++++++-------------------- src/ui/style.less | 17 +------ test/playwright-tests/theme.spec.ts | 18 ++++--- 4 files changed, 64 insertions(+), 114 deletions(-) diff --git a/css/mathfield.less b/css/mathfield.less index 02ead9d42..6ff8b8306 100644 --- a/css/mathfield.less +++ b/css/mathfield.less @@ -60,40 +60,57 @@ touch-action: none; // The color of the caret, the insertion point. - --_caret-color: var(--caret-color, hsl(var(--_hue), 40%, 49%)); + --_caret-color: var( + --caret-color, + light-dark(hsl(var(--_hue), 40%, 49%), hsl(var(--_hue), 65%, 55%)) + ); // The color of the selection, and its background - --_selection-color: var(--selection-color, #000); + --_selection-color: var(--selection-color, light-dark(#000, #fff)); --_selection-background-color: var( --selection-background-color, - hsl(var(--_hue), 70%, 85%) + light-dark(hsl(var(--_hue), 70%, 85%), hsl(var(--_hue), 65%, 55%)) ); // The background color indicating the caret is in a text zone --_text-highlight-background-color: var( --highlight-text, - hsla(var(--_hue), 40%, 50%, 0.1) + light-dark( + hsla(var(--_hue), 40%, 50%, 0.1), + hsla(var(--_hue), 40%, 50%, 0.6) + ) ); // The background color indicating the scope/zone of the caret, i.e. the // inside of a square root or a fraction. --_contains-highlight-background-color: var( --contains-highlight-background-color, - hsl(var(--_hue), 40%, 95%) + light-dark(hsl(var(--_hue), 40%, 95%), hsl(var(--_hue), 5%, 34%)) ); // The color and opacity of a smart fence (automatically added ")" or "}") - --_smart-fence-color: var(--smart-fence-color, currentColor); + --_smart-fence-color: var( + --smart-fence-color, + light-dark(currentColor, #fff) + ); --_smart-fence-opacity: var(--smart-fence-opacity, 0.5); // The color of fence, sqrt sign, when the caret is inside a "contains" zone --_contains-highlight-color: var( --contains-highlight-color, - var(--_caret-color) + light-dark(var(--_caret-color), hsl(var(--_hue), 85%, 75%)) + ); + + --_placeholder-color: var( + --placeholder-color, + light-dark(hsl(var(--_hue), 40%, 49%), hsl(var(--_hue), 60%, 69%)) ); // The text color of content in LaTeX mode - --_latex-color: var(--latex-color, hsl(var(--_hue), 80%, 40%)); + --_latex-color: light-dark( + var(--latex-color, hsl(var(--_hue), 80%, 40%)), + var(--latex-color, var(--primary, hsl(var(--_hue), 40%, 50%))) + ); // The border color when a prompt is in "correct" state --_correct-color: var(--correct-color, #10a000); // The border color when a prompt is in "incorrect" state @@ -101,8 +118,14 @@ // A composition is zone controlled by an input method, for example, the // Japanese input method, or dead keys on macOS, i.e. alt+U. - --_composition-background-color: var(--composition-background-color, #fff1c2); - --_composition-text-color: var(--composition-text-color, black); + --_composition-background-color: var( + --composition-background-color, + light-dark(#fff1c2, #69571c) + ); + --_composition-text-color: var( + --composition-text-color, + light-dark(black, white) + ); --_composition-underline-color: var( --composition-underline-color, transparent @@ -121,35 +144,11 @@ ); } -// Dark mode via system preference. Scoped with :not([theme='light']) so that -// an explicit theme='light' on the host overrides the system preference. -// See issue #2999. +:host([theme='dark']) .ML__container { + --_smart-fence-opacity: var(--smart-fence-opacity, 0.7); +} @media (prefers-color-scheme: dark) { :host(:not([theme='light'])) .ML__container { - --_contains-highlight-color: var( - --contains-highlight-color, - hsl(var(--_hue), 85%, 75%) - ); - --_caret-color: var(--caret-color, hsl(var(--_hue), 65%, 55%)); - --_selection-color: var(--selection-color, #fff); - --_selection-background-color: var( - --selection-background-color, - hsl(var(--_hue), 65%, 55%) - ); - --_text-highlight-background-color: var( - --text-highlight-background-color, - hsla(var(--_hue), 40%, 50%, 0.6) - ); - --_contains-highlight-background-color: var( - --contains-highlight-background-color, - hsl(var(--_hue), 5%, 34%) - ); - --_latex-color: var(--primary, hsl(var(--_hue), 40%, 50%)); - --_composition-background-color: #69571c; - --_composition-text-color: white; - --_placeholder-color: hsl(var(--_hue), 60%, 69%); - - --_smart-fence-color: var(--smart-fence-color, #fff); --_smart-fence-opacity: var(--smart-fence-opacity, 0.7); } } diff --git a/src/ui/colors/colors.less b/src/ui/colors/colors.less index df9b2c6cd..d8cb1d023 100644 --- a/src/ui/colors/colors.less +++ b/src/ui/colors/colors.less @@ -1,4 +1,6 @@ :host { + color-scheme: light dark; + --primary-color: #5898ff; --primary-color-dimmed: #c0c0f0; --primary-color-dark: var(--blue-500); @@ -22,15 +24,20 @@ --semantic-orange: var(--orange-400); --semantic-green: var(--green-700); - --neutral-100: #f5f5f5; - --neutral-200: #eeeeee; - --neutral-300: #e0e0e0; - --neutral-400: #bdbdbd; + --semantic-bg-blue: light-dark(transparent, var(--blue-25)); + --semantic-bg-red: light-dark(transparent, var(--red-25)); + --semantic-bg-orange: light-dark(transparent, var(--orange-25)); + --semantic-bg-green: light-dark(transparent, var(--green-25)); + + --neutral-100: light-dark(#f5f5f5, #121212); + --neutral-200: light-dark(#eeeeee, #424242); + --neutral-300: light-dark(#e0e0e0, #616161); + --neutral-400: light-dark(#bdbdbd, #757575); --neutral-500: #9e9e9e; - --neutral-600: #757575; - --neutral-700: #616161; - --neutral-800: #424242; - --neutral-900: #212121; + --neutral-600: light-dark(#757575, #bdbdbd); + --neutral-700: light-dark(#616161, #e0e0e0); + --neutral-800: light-dark(#424242, #eeeeee); + --neutral-900: light-dark(#212121, #f5f5f5); --red-25: #fff8f7; --red-50: #fff1ef; @@ -166,53 +173,12 @@ --magenta-900: #8a004c; } -// Dark mode via system preference. Scoped with :not([theme='light']) so that -// an explicit theme='light' on the host overrides the system preference. -// See issue #2999. -@media (prefers-color-scheme: dark) { - :host(:not([theme='light'])) { - --semantic-blue: var(--blue-700); - --semantic-red: var(--red-400); - --semantic-orange: var(--orange-400); - --semantic-green: var(--green-700); - - --semantic-bg-blue: var(--blue-25); - --semantic-bg-red: var(--red-25); - --semantic-bg-orange: var(--orange-25); - --semantic-bg-green: var(--green-25); - - --neutral-100: #121212; - --neutral-200: #424242; - --neutral-300: #616161; - --neutral-400: #757575; - --neutral-500: #9e9e9e; - --neutral-600: #bdbdbd; - --neutral-700: #e0e0e0; - --neutral-800: #eeeeee; - --neutral-900: #f5f5f5; - } +:host([theme='light']) { + color-scheme: light; } :host([theme='dark']) { - --semantic-blue: var(--blue-700); - --semantic-red: var(--red-400); - --semantic-orange: var(--orange-400); - --semantic-green: var(--green-700); - - --semantic-bg-blue: var(--blue-25); - --semantic-bg-red: var(--red-25); - --semantic-bg-orange: var(--orange-25); - --semantic-bg-green: var(--green-25); - - --neutral-100: #121212; - --neutral-200: #424242; - --neutral-300: #616161; - --neutral-400: #757575; - --neutral-500: #9e9e9e; - --neutral-600: #bdbdbd; - --neutral-700: #e0e0e0; - --neutral-800: #eeeeee; - --neutral-900: #f5f5f5; + color-scheme: dark; } /* @media (prefers-color-scheme: dark) { diff --git a/src/ui/style.less b/src/ui/style.less index fb6b45074..13c713595 100644 --- a/src/ui/style.less +++ b/src/ui/style.less @@ -43,7 +43,7 @@ --ui-field-border-invalid: 0.5px solid var(--border-color); --ui-field-border-focus: 0.5px solid var(--border-color); - --ui-menu-bg: var(--neutral-100); + --ui-menu-bg: light-dark(var(--neutral-100), var(--neutral-200)); --ui-menu-text: var(--neutral-900); --ui-menu-bg-hover: var(--neutral-200); @@ -74,20 +74,7 @@ --content-bg: var(--neutral-200); } -// Dark mode via system preference. Scoped with :not([theme='light']) so that -// an explicit theme='light' on the host overrides the system preference. -// See issue #2999. -@media (prefers-color-scheme: dark) { - :host(:not([theme='light'])) { - --ui-menu-bg: var(--neutral-200); - } -} - -:host([theme='dark']) { - --ui-menu-bg: var(--neutral-200); -} - -/* PingFang SC is a macOS font. Microsoft Yahei is a Windows font. +/* PingFang SC is a macOS font. Microsoft Yahei is a Windows font. Noto is a Linux/Android font. */ :lang(zh-cn), diff --git a/test/playwright-tests/theme.spec.ts b/test/playwright-tests/theme.spec.ts index 08a20693c..82b6ec7b2 100644 --- a/test/playwright-tests/theme.spec.ts +++ b/test/playwright-tests/theme.spec.ts @@ -1,17 +1,15 @@ import { test, expect } from '@playwright/test'; -// Regression tests for https://github.com/arnog/mathlive/issues/2999 -// theme="light" must override `prefers-color-scheme: dark` so that an -// author-set attribute is authoritative over the OS-level preference. +const LIGHT_NEUTRAL_100 = 'rgb(245, 245, 245)'; // #f5f5f5 +const DARK_NEUTRAL_100 = 'rgb(18, 18, 18)'; // #121212 -const LIGHT_NEUTRAL_100 = '#f5f5f5'; -const DARK_NEUTRAL_100 = '#121212'; - -const readNeutral100 = (id: string) => - `(() => { +const readNeutral100 = (id: string) => ` + (() => { const mf = document.getElementById(${JSON.stringify(id)}); - return getComputedStyle(mf).getPropertyValue('--neutral-100').trim(); - })()`; + mf.style.setProperty('outline-color', 'var(--neutral-100)'); + return getComputedStyle(mf).outlineColor; + })() +`; test.beforeEach(async ({ page }) => { await page.goto('/dist/playwright-test-page/'); From 2b6c0de932e314951ae609552e4960658d5c9132 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Mon, 20 Apr 2026 23:34:28 -0400 Subject: [PATCH 03/11] preserve master fallback chains in light-dark() rewrite A review pass revealed several variables in the master dark-mode block that used different user-override names than their light-mode counterparts, or that had no user-override hook at all in dark mode. The first light-dark() draft silently consolidated these, which would change observable behavior for consumers setting the affected custom properties. - --_text-highlight-background-color: preserve dual hooks (--highlight-text in light, --text-highlight-background-color in dark) - --_latex-color: preserve dark's --primary-only hook (don't upgrade --latex-color to higher priority in dark) - --_placeholder-color, --_composition-background-color, --_composition-text-color: preserve hardcoded dark values (no user hook) Also restore a trailing-space diff artifact in ui/style.less. Co-Authored-By: Claude Opus 4.7 (1M context) --- css/mathfield.less | 29 +++++++++++++---------------- src/ui/style.less | 2 +- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/css/mathfield.less b/css/mathfield.less index 6ff8b8306..a219e97c3 100644 --- a/css/mathfield.less +++ b/css/mathfield.less @@ -73,12 +73,9 @@ ); // The background color indicating the caret is in a text zone - --_text-highlight-background-color: var( - --highlight-text, - light-dark( - hsla(var(--_hue), 40%, 50%, 0.1), - hsla(var(--_hue), 40%, 50%, 0.6) - ) + --_text-highlight-background-color: light-dark( + var(--highlight-text, hsla(var(--_hue), 40%, 50%, 0.1)), + var(--text-highlight-background-color, hsla(var(--_hue), 40%, 50%, 0.6)) ); // The background color indicating the scope/zone of the caret, i.e. the @@ -101,15 +98,15 @@ light-dark(var(--_caret-color), hsl(var(--_hue), 85%, 75%)) ); - --_placeholder-color: var( - --placeholder-color, - light-dark(hsl(var(--_hue), 40%, 49%), hsl(var(--_hue), 60%, 69%)) + --_placeholder-color: light-dark( + var(--placeholder-color, hsl(var(--_hue), 40%, 49%)), + hsl(var(--_hue), 60%, 69%) ); // The text color of content in LaTeX mode --_latex-color: light-dark( var(--latex-color, hsl(var(--_hue), 80%, 40%)), - var(--latex-color, var(--primary, hsl(var(--_hue), 40%, 50%))) + var(--primary, hsl(var(--_hue), 40%, 50%)) ); // The border color when a prompt is in "correct" state --_correct-color: var(--correct-color, #10a000); @@ -118,13 +115,13 @@ // A composition is zone controlled by an input method, for example, the // Japanese input method, or dead keys on macOS, i.e. alt+U. - --_composition-background-color: var( - --composition-background-color, - light-dark(#fff1c2, #69571c) + --_composition-background-color: light-dark( + var(--composition-background-color, #fff1c2), + #69571c ); - --_composition-text-color: var( - --composition-text-color, - light-dark(black, white) + --_composition-text-color: light-dark( + var(--composition-text-color, black), + white ); --_composition-underline-color: var( --composition-underline-color, diff --git a/src/ui/style.less b/src/ui/style.less index 13c713595..0c00f9e87 100644 --- a/src/ui/style.less +++ b/src/ui/style.less @@ -74,7 +74,7 @@ --content-bg: var(--neutral-200); } -/* PingFang SC is a macOS font. Microsoft Yahei is a Windows font. +/* PingFang SC is a macOS font. Microsoft Yahei is a Windows font. Noto is a Linux/Android font. */ :lang(zh-cn), From 1d353a17832a978d0677e5bdbbe6d31058e54ef9 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Mon, 20 Apr 2026 23:46:45 -0400 Subject: [PATCH 04/11] wip --- css/mathfield.less | 75 +++++++++++++---------- src/ui/colors/colors.less | 73 +++++++++++++++++++---- src/ui/style.less | 15 ++++- test/playwright-tests/theme.spec.ts | 92 +++++++++++++++++++++++++---- 4 files changed, 197 insertions(+), 58 deletions(-) diff --git a/css/mathfield.less b/css/mathfield.less index a219e97c3..2bf702840 100644 --- a/css/mathfield.less +++ b/css/mathfield.less @@ -60,54 +60,40 @@ touch-action: none; // The color of the caret, the insertion point. - --_caret-color: var( - --caret-color, - light-dark(hsl(var(--_hue), 40%, 49%), hsl(var(--_hue), 65%, 55%)) - ); + --_caret-color: var(--caret-color, hsl(var(--_hue), 40%, 49%)); // The color of the selection, and its background - --_selection-color: var(--selection-color, light-dark(#000, #fff)); + --_selection-color: var(--selection-color, #000); --_selection-background-color: var( --selection-background-color, - light-dark(hsl(var(--_hue), 70%, 85%), hsl(var(--_hue), 65%, 55%)) + hsl(var(--_hue), 70%, 85%) ); // The background color indicating the caret is in a text zone - --_text-highlight-background-color: light-dark( - var(--highlight-text, hsla(var(--_hue), 40%, 50%, 0.1)), - var(--text-highlight-background-color, hsla(var(--_hue), 40%, 50%, 0.6)) + --_text-highlight-background-color: var( + --highlight-text, + hsla(var(--_hue), 40%, 50%, 0.1) ); // The background color indicating the scope/zone of the caret, i.e. the // inside of a square root or a fraction. --_contains-highlight-background-color: var( --contains-highlight-background-color, - light-dark(hsl(var(--_hue), 40%, 95%), hsl(var(--_hue), 5%, 34%)) + hsl(var(--_hue), 40%, 95%) ); // The color and opacity of a smart fence (automatically added ")" or "}") - --_smart-fence-color: var( - --smart-fence-color, - light-dark(currentColor, #fff) - ); + --_smart-fence-color: var(--smart-fence-color, currentColor); --_smart-fence-opacity: var(--smart-fence-opacity, 0.5); // The color of fence, sqrt sign, when the caret is inside a "contains" zone --_contains-highlight-color: var( --contains-highlight-color, - light-dark(var(--_caret-color), hsl(var(--_hue), 85%, 75%)) - ); - - --_placeholder-color: light-dark( - var(--placeholder-color, hsl(var(--_hue), 40%, 49%)), - hsl(var(--_hue), 60%, 69%) + var(--_caret-color) ); // The text color of content in LaTeX mode - --_latex-color: light-dark( - var(--latex-color, hsl(var(--_hue), 80%, 40%)), - var(--primary, hsl(var(--_hue), 40%, 50%)) - ); + --_latex-color: var(--latex-color, hsl(var(--_hue), 80%, 40%)); // The border color when a prompt is in "correct" state --_correct-color: var(--correct-color, #10a000); // The border color when a prompt is in "incorrect" state @@ -115,14 +101,8 @@ // A composition is zone controlled by an input method, for example, the // Japanese input method, or dead keys on macOS, i.e. alt+U. - --_composition-background-color: light-dark( - var(--composition-background-color, #fff1c2), - #69571c - ); - --_composition-text-color: light-dark( - var(--composition-text-color, black), - white - ); + --_composition-background-color: var(--composition-background-color, #fff1c2); + --_composition-text-color: var(--composition-text-color, black); --_composition-underline-color: var( --composition-underline-color, transparent @@ -141,11 +121,42 @@ ); } +// `--_smart-fence-opacity` is a so it can't ride a `light-dark()` +// expression — mirror the system-preference value on the attribute selector so +// theme='dark' matches system-dark behavior. :host([theme='dark']) .ML__container { --_smart-fence-opacity: var(--smart-fence-opacity, 0.7); } + +// Dark mode via system preference. Scoped with :not([theme='light']) so that +// an explicit theme='light' on the host overrides the system preference. +// See issue #2999. @media (prefers-color-scheme: dark) { :host(:not([theme='light'])) .ML__container { + --_contains-highlight-color: var( + --contains-highlight-color, + hsl(var(--_hue), 85%, 75%) + ); + --_caret-color: var(--caret-color, hsl(var(--_hue), 65%, 55%)); + --_selection-color: var(--selection-color, #fff); + --_selection-background-color: var( + --selection-background-color, + hsl(var(--_hue), 65%, 55%) + ); + --_text-highlight-background-color: var( + --text-highlight-background-color, + hsla(var(--_hue), 40%, 50%, 0.6) + ); + --_contains-highlight-background-color: var( + --contains-highlight-background-color, + hsl(var(--_hue), 5%, 34%) + ); + --_latex-color: var(--primary, hsl(var(--_hue), 40%, 50%)); + --_composition-background-color: #69571c; + --_composition-text-color: white; + --_placeholder-color: hsl(var(--_hue), 60%, 69%); + + --_smart-fence-color: var(--smart-fence-color, #fff); --_smart-fence-opacity: var(--smart-fence-opacity, 0.7); } } diff --git a/src/ui/colors/colors.less b/src/ui/colors/colors.less index d8cb1d023..9977d340e 100644 --- a/src/ui/colors/colors.less +++ b/src/ui/colors/colors.less @@ -1,3 +1,8 @@ +// TODO: once Browserslist drops browsers older than Chrome 123 / Safari 17.5 / +// Firefox 120, collapse the mirrored dark-mode blocks below into `light-dark()` +// declarations and rely on `color-scheme` to switch. Custom-property +// declarations fall back invalid-at-computed-value-time in older browsers, so +// the palette must stay expressed as discrete cascade branches until then. :host { color-scheme: light dark; @@ -24,20 +29,15 @@ --semantic-orange: var(--orange-400); --semantic-green: var(--green-700); - --semantic-bg-blue: light-dark(transparent, var(--blue-25)); - --semantic-bg-red: light-dark(transparent, var(--red-25)); - --semantic-bg-orange: light-dark(transparent, var(--orange-25)); - --semantic-bg-green: light-dark(transparent, var(--green-25)); - - --neutral-100: light-dark(#f5f5f5, #121212); - --neutral-200: light-dark(#eeeeee, #424242); - --neutral-300: light-dark(#e0e0e0, #616161); - --neutral-400: light-dark(#bdbdbd, #757575); + --neutral-100: #f5f5f5; + --neutral-200: #eeeeee; + --neutral-300: #e0e0e0; + --neutral-400: #bdbdbd; --neutral-500: #9e9e9e; - --neutral-600: light-dark(#757575, #bdbdbd); - --neutral-700: light-dark(#616161, #e0e0e0); - --neutral-800: light-dark(#424242, #eeeeee); - --neutral-900: light-dark(#212121, #f5f5f5); + --neutral-600: #757575; + --neutral-700: #616161; + --neutral-800: #424242; + --neutral-900: #212121; --red-25: #fff8f7; --red-50: #fff1ef; @@ -173,12 +173,59 @@ --magenta-900: #8a004c; } +// Dark mode via system preference. Scoped with :not([theme='light']) so that +// an explicit theme='light' on the host overrides the system preference. +// See issue #2999. +@media (prefers-color-scheme: dark) { + :host(:not([theme='light'])) { + --semantic-blue: var(--blue-700); + --semantic-red: var(--red-400); + --semantic-orange: var(--orange-400); + --semantic-green: var(--green-700); + + --semantic-bg-blue: var(--blue-25); + --semantic-bg-red: var(--red-25); + --semantic-bg-orange: var(--orange-25); + --semantic-bg-green: var(--green-25); + + --neutral-100: #121212; + --neutral-200: #424242; + --neutral-300: #616161; + --neutral-400: #757575; + --neutral-500: #9e9e9e; + --neutral-600: #bdbdbd; + --neutral-700: #e0e0e0; + --neutral-800: #eeeeee; + --neutral-900: #f5f5f5; + } +} + :host([theme='light']) { color-scheme: light; } :host([theme='dark']) { color-scheme: dark; + + --semantic-blue: var(--blue-700); + --semantic-red: var(--red-400); + --semantic-orange: var(--orange-400); + --semantic-green: var(--green-700); + + --semantic-bg-blue: var(--blue-25); + --semantic-bg-red: var(--red-25); + --semantic-bg-orange: var(--orange-25); + --semantic-bg-green: var(--green-25); + + --neutral-100: #121212; + --neutral-200: #424242; + --neutral-300: #616161; + --neutral-400: #757575; + --neutral-500: #9e9e9e; + --neutral-600: #bdbdbd; + --neutral-700: #e0e0e0; + --neutral-800: #eeeeee; + --neutral-900: #f5f5f5; } /* @media (prefers-color-scheme: dark) { diff --git a/src/ui/style.less b/src/ui/style.less index 0c00f9e87..fb6b45074 100644 --- a/src/ui/style.less +++ b/src/ui/style.less @@ -43,7 +43,7 @@ --ui-field-border-invalid: 0.5px solid var(--border-color); --ui-field-border-focus: 0.5px solid var(--border-color); - --ui-menu-bg: light-dark(var(--neutral-100), var(--neutral-200)); + --ui-menu-bg: var(--neutral-100); --ui-menu-text: var(--neutral-900); --ui-menu-bg-hover: var(--neutral-200); @@ -74,6 +74,19 @@ --content-bg: var(--neutral-200); } +// Dark mode via system preference. Scoped with :not([theme='light']) so that +// an explicit theme='light' on the host overrides the system preference. +// See issue #2999. +@media (prefers-color-scheme: dark) { + :host(:not([theme='light'])) { + --ui-menu-bg: var(--neutral-200); + } +} + +:host([theme='dark']) { + --ui-menu-bg: var(--neutral-200); +} + /* PingFang SC is a macOS font. Microsoft Yahei is a Windows font. Noto is a Linux/Android font. */ diff --git a/test/playwright-tests/theme.spec.ts b/test/playwright-tests/theme.spec.ts index 82b6ec7b2..0adfdab2b 100644 --- a/test/playwright-tests/theme.spec.ts +++ b/test/playwright-tests/theme.spec.ts @@ -3,14 +3,37 @@ import { test, expect } from '@playwright/test'; const LIGHT_NEUTRAL_100 = 'rgb(245, 245, 245)'; // #f5f5f5 const DARK_NEUTRAL_100 = 'rgb(18, 18, 18)'; // #121212 -const readNeutral100 = (id: string) => ` +// Probe a custom property via a real color channel so that light-dark() +// resolves. Reading the custom property with getPropertyValue returns the +// unresolved `light-dark(...)` expression. +const readVar = (id: string, varName: string) => ` (() => { const mf = document.getElementById(${JSON.stringify(id)}); - mf.style.setProperty('outline-color', 'var(--neutral-100)'); + mf.style.setProperty('outline-color', 'var(${varName})'); return getComputedStyle(mf).outlineColor; })() `; +const readColorScheme = (id: string) => ` + (() => { + const mf = document.getElementById(${JSON.stringify(id)}); + return getComputedStyle(mf).colorScheme; + })() +`; + +// --_caret-color is defined in mathfield.less on .ML__container. Probing +// it verifies that file's light-dark() rewrite resolves correctly, not just +// colors.less. The container inherits its computed color-scheme from the +// host, so probing the container itself is sufficient. +const readCaretColor = (id: string) => ` + (() => { + const mf = document.getElementById(${JSON.stringify(id)}); + const container = mf.shadowRoot.querySelector('.ML__container'); + container.style.setProperty('outline-color', 'var(--_caret-color)'); + return getComputedStyle(container).outlineColor; + })() +`; + test.beforeEach(async ({ page }) => { await page.goto('/dist/playwright-test-page/'); await page.waitForSelector('math-field', { timeout: 5000 }); @@ -20,28 +43,32 @@ test('theme="light" overrides prefers-color-scheme: dark', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); // With no theme attribute, the system dark preference should apply. - const before = await page.evaluate(readNeutral100('mf-1')); - expect(before).toBe(DARK_NEUTRAL_100); + expect(await page.evaluate(readVar('mf-1', '--neutral-100'))).toBe( + DARK_NEUTRAL_100 + ); // Setting theme='light' should flip back to the light palette. await page.evaluate(() => { document.getElementById('mf-1')!.setAttribute('theme', 'light'); }); - const after = await page.evaluate(readNeutral100('mf-1')); - expect(after).toBe(LIGHT_NEUTRAL_100); + expect(await page.evaluate(readVar('mf-1', '--neutral-100'))).toBe( + LIGHT_NEUTRAL_100 + ); }); test('theme="dark" overrides prefers-color-scheme: light', async ({ page }) => { await page.emulateMedia({ colorScheme: 'light' }); - const before = await page.evaluate(readNeutral100('mf-1')); - expect(before).toBe(LIGHT_NEUTRAL_100); + expect(await page.evaluate(readVar('mf-1', '--neutral-100'))).toBe( + LIGHT_NEUTRAL_100 + ); await page.evaluate(() => { document.getElementById('mf-1')!.setAttribute('theme', 'dark'); }); - const after = await page.evaluate(readNeutral100('mf-1')); - expect(after).toBe(DARK_NEUTRAL_100); + expect(await page.evaluate(readVar('mf-1', '--neutral-100'))).toBe( + DARK_NEUTRAL_100 + ); }); test('removing theme attribute restores system preference', async ({ page }) => { @@ -50,10 +77,51 @@ test('removing theme attribute restores system preference', async ({ page }) => await page.evaluate(() => { document.getElementById('mf-1')!.setAttribute('theme', 'light'); }); - expect(await page.evaluate(readNeutral100('mf-1'))).toBe(LIGHT_NEUTRAL_100); + expect(await page.evaluate(readVar('mf-1', '--neutral-100'))).toBe( + LIGHT_NEUTRAL_100 + ); await page.evaluate(() => { document.getElementById('mf-1')!.removeAttribute('theme'); }); - expect(await page.evaluate(readNeutral100('mf-1'))).toBe(DARK_NEUTRAL_100); + expect(await page.evaluate(readVar('mf-1', '--neutral-100'))).toBe( + DARK_NEUTRAL_100 + ); +}); + +test('theme attribute pins the computed color-scheme', async ({ page }) => { + // Default tracks the system preference. + await page.emulateMedia({ colorScheme: 'dark' }); + expect(await page.evaluate(readColorScheme('mf-1'))).toBe('light dark'); + + await page.evaluate(() => { + document.getElementById('mf-1')!.setAttribute('theme', 'light'); + }); + expect(await page.evaluate(readColorScheme('mf-1'))).toBe('light'); + + await page.evaluate(() => { + document.getElementById('mf-1')!.setAttribute('theme', 'dark'); + }); + expect(await page.evaluate(readColorScheme('mf-1'))).toBe('dark'); +}); + +test('mathfield.less variables respect theme attribute', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }); + + // `--_caret-color` is a container-scoped variable defined in mathfield.less, + // not colors.less. The container inherits its computed color-scheme from + // the host, so light-dark() in mathfield.less resolves accordingly. + const darkCaret = await page.evaluate(readCaretColor('mf-1')); + + await page.evaluate(() => { + document.getElementById('mf-1')!.setAttribute('theme', 'light'); + }); + const lightCaret = await page.evaluate(readCaretColor('mf-1')); + + // The two modes should produce different caret colors; we don't care about + // the exact hsl values, just that light-dark() actually resolved to a + // different branch. + expect(darkCaret).not.toBe(lightCaret); + expect(darkCaret).not.toBe(''); + expect(lightCaret).not.toBe(''); }); From 78c2f74fb2a4ecbc4985f3060168acf303b7d7c4 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Tue, 21 Apr 2026 00:01:36 -0400 Subject: [PATCH 05/11] simplify theme='light' override via selector-list, drop color-scheme churn Co-Authored-By: Claude Opus 4.7 (1M context) --- css/mathfield.less | 19 +++++------- src/ui/colors/colors.less | 24 ++++----------- src/ui/style.less | 11 +++---- test/playwright-tests/theme.spec.ts | 46 +++++------------------------ 4 files changed, 27 insertions(+), 73 deletions(-) diff --git a/css/mathfield.less b/css/mathfield.less index 2bf702840..bcd17a219 100644 --- a/css/mathfield.less +++ b/css/mathfield.less @@ -39,7 +39,12 @@ // > .ML__toggles // > .ML__virtual-keyboard-toggle // > .ML__menu-toggle -.ML__container { +// +// `:host([theme='light']) .ML__container` is listed alongside `.ML__container` +// so that an explicit theme='light' attribute outranks the +// `@media (prefers-color-scheme: dark)` block below. +.ML__container, +:host([theme='light']) .ML__container { display: inline-flex; flex-flow: row; justify-content: space-between; @@ -121,18 +126,8 @@ ); } -// `--_smart-fence-opacity` is a so it can't ride a `light-dark()` -// expression — mirror the system-preference value on the attribute selector so -// theme='dark' matches system-dark behavior. -:host([theme='dark']) .ML__container { - --_smart-fence-opacity: var(--smart-fence-opacity, 0.7); -} - -// Dark mode via system preference. Scoped with :not([theme='light']) so that -// an explicit theme='light' on the host overrides the system preference. -// See issue #2999. @media (prefers-color-scheme: dark) { - :host(:not([theme='light'])) .ML__container { + .ML__container { --_contains-highlight-color: var( --contains-highlight-color, hsl(var(--_hue), 85%, 75%) diff --git a/src/ui/colors/colors.less b/src/ui/colors/colors.less index 9977d340e..800a223d8 100644 --- a/src/ui/colors/colors.less +++ b/src/ui/colors/colors.less @@ -1,11 +1,8 @@ -// TODO: once Browserslist drops browsers older than Chrome 123 / Safari 17.5 / -// Firefox 120, collapse the mirrored dark-mode blocks below into `light-dark()` -// declarations and rely on `color-scheme` to switch. Custom-property -// declarations fall back invalid-at-computed-value-time in older browsers, so -// the palette must stay expressed as discrete cascade branches until then. -:host { - color-scheme: light dark; - +// `:host([theme='light'])` is listed alongside `:host` so that an explicit +// theme='light' attribute outranks the `@media (prefers-color-scheme: dark)` +// block below (which matches `:host` at lower specificity). +:host, +:host([theme='light']) { --primary-color: #5898ff; --primary-color-dimmed: #c0c0f0; --primary-color-dark: var(--blue-500); @@ -173,11 +170,8 @@ --magenta-900: #8a004c; } -// Dark mode via system preference. Scoped with :not([theme='light']) so that -// an explicit theme='light' on the host overrides the system preference. -// See issue #2999. @media (prefers-color-scheme: dark) { - :host(:not([theme='light'])) { + :host { --semantic-blue: var(--blue-700); --semantic-red: var(--red-400); --semantic-orange: var(--orange-400); @@ -200,13 +194,7 @@ } } -:host([theme='light']) { - color-scheme: light; -} - :host([theme='dark']) { - color-scheme: dark; - --semantic-blue: var(--blue-700); --semantic-red: var(--red-400); --semantic-orange: var(--orange-400); diff --git a/src/ui/style.less b/src/ui/style.less index fb6b45074..4f531dfed 100644 --- a/src/ui/style.less +++ b/src/ui/style.less @@ -1,6 +1,10 @@ @import 'colors/colors.less'; -:host { +// `:host([theme='light'])` is listed alongside `:host` so that an explicit +// theme='light' attribute outranks the `@media (prefers-color-scheme: dark)` +// block below (which matches `:host` at lower specificity). +:host, +:host([theme='light']) { --ui-font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', @@ -74,11 +78,8 @@ --content-bg: var(--neutral-200); } -// Dark mode via system preference. Scoped with :not([theme='light']) so that -// an explicit theme='light' on the host overrides the system preference. -// See issue #2999. @media (prefers-color-scheme: dark) { - :host(:not([theme='light'])) { + :host { --ui-menu-bg: var(--neutral-200); } } diff --git a/test/playwright-tests/theme.spec.ts b/test/playwright-tests/theme.spec.ts index 0adfdab2b..32e06cc5a 100644 --- a/test/playwright-tests/theme.spec.ts +++ b/test/playwright-tests/theme.spec.ts @@ -3,9 +3,8 @@ import { test, expect } from '@playwright/test'; const LIGHT_NEUTRAL_100 = 'rgb(245, 245, 245)'; // #f5f5f5 const DARK_NEUTRAL_100 = 'rgb(18, 18, 18)'; // #121212 -// Probe a custom property via a real color channel so that light-dark() -// resolves. Reading the custom property with getPropertyValue returns the -// unresolved `light-dark(...)` expression. +// Probe a custom property via a real color channel so computed-value +// normalization applies — lets us compare against rgb(...) constants. const readVar = (id: string, varName: string) => ` (() => { const mf = document.getElementById(${JSON.stringify(id)}); @@ -14,17 +13,6 @@ const readVar = (id: string, varName: string) => ` })() `; -const readColorScheme = (id: string) => ` - (() => { - const mf = document.getElementById(${JSON.stringify(id)}); - return getComputedStyle(mf).colorScheme; - })() -`; - -// --_caret-color is defined in mathfield.less on .ML__container. Probing -// it verifies that file's light-dark() rewrite resolves correctly, not just -// colors.less. The container inherits its computed color-scheme from the -// host, so probing the container itself is sufficient. const readCaretColor = (id: string) => ` (() => { const mf = document.getElementById(${JSON.stringify(id)}); @@ -89,28 +77,13 @@ test('removing theme attribute restores system preference', async ({ page }) => ); }); -test('theme attribute pins the computed color-scheme', async ({ page }) => { - // Default tracks the system preference. +test('mathfield.less container variables respect theme attribute', async ({ + page, +}) => { + // `--_caret-color` is defined on .ML__container in mathfield.less. This + // verifies the :not([theme='light']) scoping on that file's media query, + // not just the palette overrides in colors.less. await page.emulateMedia({ colorScheme: 'dark' }); - expect(await page.evaluate(readColorScheme('mf-1'))).toBe('light dark'); - - await page.evaluate(() => { - document.getElementById('mf-1')!.setAttribute('theme', 'light'); - }); - expect(await page.evaluate(readColorScheme('mf-1'))).toBe('light'); - - await page.evaluate(() => { - document.getElementById('mf-1')!.setAttribute('theme', 'dark'); - }); - expect(await page.evaluate(readColorScheme('mf-1'))).toBe('dark'); -}); - -test('mathfield.less variables respect theme attribute', async ({ page }) => { - await page.emulateMedia({ colorScheme: 'dark' }); - - // `--_caret-color` is a container-scoped variable defined in mathfield.less, - // not colors.less. The container inherits its computed color-scheme from - // the host, so light-dark() in mathfield.less resolves accordingly. const darkCaret = await page.evaluate(readCaretColor('mf-1')); await page.evaluate(() => { @@ -118,9 +91,6 @@ test('mathfield.less variables respect theme attribute', async ({ page }) => { }); const lightCaret = await page.evaluate(readCaretColor('mf-1')); - // The two modes should produce different caret colors; we don't care about - // the exact hsl values, just that light-dark() actually resolved to a - // different branch. expect(darkCaret).not.toBe(lightCaret); expect(darkCaret).not.toBe(''); expect(lightCaret).not.toBe(''); From cf1e604a07e4bfcdf31bee905d5e7b195fdf59a4 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Tue, 21 Apr 2026 00:16:56 -0400 Subject: [PATCH 06/11] wip --- css/mathfield.less | 4 ++++ css/virtual-keyboard.less | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/css/mathfield.less b/css/mathfield.less index bcd17a219..5348d35a1 100644 --- a/css/mathfield.less +++ b/css/mathfield.less @@ -113,6 +113,10 @@ transparent ); + // Duplicated from core.less so that `:host([theme='light'])` can outrank + // the dark `@media (prefers-color-scheme: dark)` rule below. + --_placeholder-color: var(--placeholder-color, hsl(var(--_hue), 40%, 49%)); + // Tooltip displayed with \mathtip or \texttip --_tooltip-border: var(--tooltip-border, 1px solid transparent); --_tooltip-border-radius: var(--tooltip-border-radius, 8px); diff --git a/css/virtual-keyboard.less b/css/virtual-keyboard.less index 785dd5765..b01b80da9 100644 --- a/css/virtual-keyboard.less +++ b/css/virtual-keyboard.less @@ -1145,6 +1145,9 @@ Note there are a different set of tooltip rules for the keyboard toggle --_toolbar-text: var(--keyboard-toolbar-text, #2c2e2f); --_toolbar-background: var(--keyboard-toolbar-background, transparent); + // Reset the public var set in the dark block so its light fallback takes + // effect when the OS prefers dark but theme='light' is explicit. + --keyboard-toolbar-background-hover: #eee; --_toolbar-background-hover: var(--keyboard-toolbar-background-hover, #eee); --_toolbar-background-selected: var( --keyboard-toolbar-background-selected, @@ -1177,6 +1180,10 @@ Note there are a different set of tooltip rules for the keyboard toggle #7d8795 ); --_keycap-secondary-text: var(--keycap-secondary-text, #060707); + // Reset the public vars set in the dark block so their light fallbacks take + // effect when the OS prefers dark but theme='light' is explicit. + --keycap-secondary-border: #c5c9d0; + --keycap-secondary-border-bottom: #989da6; --_keycap-secondary-border: var(--keycap-secondary-border, #c5c9d0); --_keycap-secondary-border-bottom: var( --keycap-secondary-border-bottom, From 1e002cbb50af631d081f06b91a00defcbc5b46e7 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Tue, 21 Apr 2026 00:36:28 -0400 Subject: [PATCH 07/11] wip --- css/virtual-keyboard.less | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/css/virtual-keyboard.less b/css/virtual-keyboard.less index b01b80da9..8b39e219a 100644 --- a/css/virtual-keyboard.less +++ b/css/virtual-keyboard.less @@ -1070,7 +1070,6 @@ Note there are a different set of tooltip rules for the keyboard toggle --keyboard-toolbar-background-hover, #303030 ); - --keyboard-toolbar-background-hover: #303030; --_horizontal-rule: var(--keyboard-horizontal-rule, 1px solid #303030); @@ -1086,8 +1085,6 @@ Note there are a different set of tooltip rules for the keyboard toggle #4d5154 ); --_keycap-secondary-text: var(--keycap-secondary-text, #e7ebee); - --keycap-secondary-border: transparent; - --keycap-secondary-border-bottom: transparent; --_keycap-secondary-border: var(--keycap-secondary-border, transparent); --_keycap-secondary-border-bottom: var( --keycap-secondary-border-bottom, @@ -1110,7 +1107,6 @@ Note there are a different set of tooltip rules for the keyboard toggle --keyboard-toolbar-background-hover, #303030 ); - --keyboard-toolbar-background-hover: #303030; --_horizontal-rule: var(--keyboard-horizontal-rule, 1px solid #303030); @@ -1126,8 +1122,6 @@ Note there are a different set of tooltip rules for the keyboard toggle #4d5154 ); --_keycap-secondary-text: var(--keycap-secondary-text, #e7ebee); - --keycap-secondary-border: transparent; - --keycap-secondary-border-bottom: transparent; --_keycap-secondary-border: var(--keycap-secondary-border, transparent); --_keycap-secondary-border-bottom: var( --keycap-secondary-border-bottom, @@ -1145,9 +1139,6 @@ Note there are a different set of tooltip rules for the keyboard toggle --_toolbar-text: var(--keyboard-toolbar-text, #2c2e2f); --_toolbar-background: var(--keyboard-toolbar-background, transparent); - // Reset the public var set in the dark block so its light fallback takes - // effect when the OS prefers dark but theme='light' is explicit. - --keyboard-toolbar-background-hover: #eee; --_toolbar-background-hover: var(--keyboard-toolbar-background-hover, #eee); --_toolbar-background-selected: var( --keyboard-toolbar-background-selected, @@ -1180,10 +1171,6 @@ Note there are a different set of tooltip rules for the keyboard toggle #7d8795 ); --_keycap-secondary-text: var(--keycap-secondary-text, #060707); - // Reset the public vars set in the dark block so their light fallbacks take - // effect when the OS prefers dark but theme='light' is explicit. - --keycap-secondary-border: #c5c9d0; - --keycap-secondary-border-bottom: #989da6; --_keycap-secondary-border: var(--keycap-secondary-border, #c5c9d0); --_keycap-secondary-border-bottom: var( --keycap-secondary-border-bottom, From 09f02276dc51c1e44a824acc708fddf819eb6aab Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Tue, 21 Apr 2026 00:59:27 -0400 Subject: [PATCH 08/11] scope dark media queries with :not([theme='light']) Restores specificity headroom for the forced-colors block in mathfield.less and the :lang/:dir overrides in ui/style.less. The earlier selector-list form (`.ML__container, :host([theme='light']) .ML__container`) raised the base rule above those later overrides, so theme='light' + forced-colors or theme='light' + :lang(ja)/:dir(rtl) stopped reaching their overrides. Co-Authored-By: Claude Opus 4.7 (1M context) --- css/mathfield.less | 21 ++++++++++----------- src/ui/style.less | 11 +++++------ 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/css/mathfield.less b/css/mathfield.less index 5348d35a1..4f0a86a48 100644 --- a/css/mathfield.less +++ b/css/mathfield.less @@ -39,12 +39,7 @@ // > .ML__toggles // > .ML__virtual-keyboard-toggle // > .ML__menu-toggle -// -// `:host([theme='light']) .ML__container` is listed alongside `.ML__container` -// so that an explicit theme='light' attribute outranks the -// `@media (prefers-color-scheme: dark)` block below. -.ML__container, -:host([theme='light']) .ML__container { +.ML__container { display: inline-flex; flex-flow: row; justify-content: space-between; @@ -113,10 +108,6 @@ transparent ); - // Duplicated from core.less so that `:host([theme='light'])` can outrank - // the dark `@media (prefers-color-scheme: dark)` rule below. - --_placeholder-color: var(--placeholder-color, hsl(var(--_hue), 40%, 49%)); - // Tooltip displayed with \mathtip or \texttip --_tooltip-border: var(--tooltip-border, 1px solid transparent); --_tooltip-border-radius: var(--tooltip-border-radius, 8px); @@ -130,8 +121,16 @@ ); } +// Explicit theme='dark' in a system-light environment: the dark media query +// below won't fire, so mirror its smart-fence-opacity here. +:host([theme='dark']) .ML__container { + --_smart-fence-opacity: var(--smart-fence-opacity, 0.7); +} + +// :not([theme='light']) scoping lets explicit theme='light' override system +// dark without raising base specificity above forced-colors below. See #2999. @media (prefers-color-scheme: dark) { - .ML__container { + :host(:not([theme='light'])) .ML__container { --_contains-highlight-color: var( --contains-highlight-color, hsl(var(--_hue), 85%, 75%) diff --git a/src/ui/style.less b/src/ui/style.less index 4f531dfed..932ffca98 100644 --- a/src/ui/style.less +++ b/src/ui/style.less @@ -1,10 +1,6 @@ @import 'colors/colors.less'; -// `:host([theme='light'])` is listed alongside `:host` so that an explicit -// theme='light' attribute outranks the `@media (prefers-color-scheme: dark)` -// block below (which matches `:host` at lower specificity). -:host, -:host([theme='light']) { +:host { --ui-font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', @@ -78,8 +74,11 @@ --content-bg: var(--neutral-200); } +// :not([theme='light']) scoping lets explicit theme='light' override system +// dark without raising base specificity above :lang/:dir overrides below. +// See #2999. @media (prefers-color-scheme: dark) { - :host { + :host(:not([theme='light'])) { --ui-menu-bg: var(--neutral-200); } } From 5c524061948f08b811059d6e5d0130028652b439 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Tue, 21 Apr 2026 01:01:39 -0400 Subject: [PATCH 09/11] use :not([theme='light']) pattern in colors.less Unifies the theme='light' escape hatch across all three theme stylesheets so the codebase has one pattern, not two. Also drops issue-number references from the accompanying comments. Co-Authored-By: Claude Opus 4.7 (1M context) --- css/mathfield.less | 2 +- src/ui/colors/colors.less | 10 ++++------ src/ui/style.less | 1 - 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/css/mathfield.less b/css/mathfield.less index 4f0a86a48..266d5cb6e 100644 --- a/css/mathfield.less +++ b/css/mathfield.less @@ -128,7 +128,7 @@ } // :not([theme='light']) scoping lets explicit theme='light' override system -// dark without raising base specificity above forced-colors below. See #2999. +// dark without raising base specificity above forced-colors below. @media (prefers-color-scheme: dark) { :host(:not([theme='light'])) .ML__container { --_contains-highlight-color: var( diff --git a/src/ui/colors/colors.less b/src/ui/colors/colors.less index 800a223d8..97ff40dd5 100644 --- a/src/ui/colors/colors.less +++ b/src/ui/colors/colors.less @@ -1,8 +1,4 @@ -// `:host([theme='light'])` is listed alongside `:host` so that an explicit -// theme='light' attribute outranks the `@media (prefers-color-scheme: dark)` -// block below (which matches `:host` at lower specificity). -:host, -:host([theme='light']) { +:host { --primary-color: #5898ff; --primary-color-dimmed: #c0c0f0; --primary-color-dark: var(--blue-500); @@ -170,8 +166,10 @@ --magenta-900: #8a004c; } +// :not([theme='light']) scoping lets explicit theme='light' override system +// dark without raising base specificity. @media (prefers-color-scheme: dark) { - :host { + :host(:not([theme='light'])) { --semantic-blue: var(--blue-700); --semantic-red: var(--red-400); --semantic-orange: var(--orange-400); diff --git a/src/ui/style.less b/src/ui/style.less index 932ffca98..d32d0fb73 100644 --- a/src/ui/style.less +++ b/src/ui/style.less @@ -76,7 +76,6 @@ // :not([theme='light']) scoping lets explicit theme='light' override system // dark without raising base specificity above :lang/:dir overrides below. -// See #2999. @media (prefers-color-scheme: dark) { :host(:not([theme='light'])) { --ui-menu-bg: var(--neutral-200); From c3a67108d42c1c3a59eafaa1af494669776d43dd Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Tue, 21 Apr 2026 01:16:24 -0400 Subject: [PATCH 10/11] remove unneeded comments Co-Authored-By: Claude Opus 4.7 (1M context) --- css/mathfield.less | 4 ---- src/ui/colors/colors.less | 2 -- src/ui/style.less | 2 -- test/playwright-tests/theme.spec.ts | 11 ++--------- 4 files changed, 2 insertions(+), 17 deletions(-) diff --git a/css/mathfield.less b/css/mathfield.less index 266d5cb6e..53341fcd4 100644 --- a/css/mathfield.less +++ b/css/mathfield.less @@ -121,14 +121,10 @@ ); } -// Explicit theme='dark' in a system-light environment: the dark media query -// below won't fire, so mirror its smart-fence-opacity here. :host([theme='dark']) .ML__container { --_smart-fence-opacity: var(--smart-fence-opacity, 0.7); } -// :not([theme='light']) scoping lets explicit theme='light' override system -// dark without raising base specificity above forced-colors below. @media (prefers-color-scheme: dark) { :host(:not([theme='light'])) .ML__container { --_contains-highlight-color: var( diff --git a/src/ui/colors/colors.less b/src/ui/colors/colors.less index 97ff40dd5..23260634b 100644 --- a/src/ui/colors/colors.less +++ b/src/ui/colors/colors.less @@ -166,8 +166,6 @@ --magenta-900: #8a004c; } -// :not([theme='light']) scoping lets explicit theme='light' override system -// dark without raising base specificity. @media (prefers-color-scheme: dark) { :host(:not([theme='light'])) { --semantic-blue: var(--blue-700); diff --git a/src/ui/style.less b/src/ui/style.less index d32d0fb73..7f6632c5c 100644 --- a/src/ui/style.less +++ b/src/ui/style.less @@ -74,8 +74,6 @@ --content-bg: var(--neutral-200); } -// :not([theme='light']) scoping lets explicit theme='light' override system -// dark without raising base specificity above :lang/:dir overrides below. @media (prefers-color-scheme: dark) { :host(:not([theme='light'])) { --ui-menu-bg: var(--neutral-200); diff --git a/test/playwright-tests/theme.spec.ts b/test/playwright-tests/theme.spec.ts index 32e06cc5a..fe18d7d1a 100644 --- a/test/playwright-tests/theme.spec.ts +++ b/test/playwright-tests/theme.spec.ts @@ -1,10 +1,8 @@ import { test, expect } from '@playwright/test'; -const LIGHT_NEUTRAL_100 = 'rgb(245, 245, 245)'; // #f5f5f5 -const DARK_NEUTRAL_100 = 'rgb(18, 18, 18)'; // #121212 +const LIGHT_NEUTRAL_100 = 'rgb(245, 245, 245)'; +const DARK_NEUTRAL_100 = 'rgb(18, 18, 18)'; -// Probe a custom property via a real color channel so computed-value -// normalization applies — lets us compare against rgb(...) constants. const readVar = (id: string, varName: string) => ` (() => { const mf = document.getElementById(${JSON.stringify(id)}); @@ -30,12 +28,10 @@ test.beforeEach(async ({ page }) => { test('theme="light" overrides prefers-color-scheme: dark', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); - // With no theme attribute, the system dark preference should apply. expect(await page.evaluate(readVar('mf-1', '--neutral-100'))).toBe( DARK_NEUTRAL_100 ); - // Setting theme='light' should flip back to the light palette. await page.evaluate(() => { document.getElementById('mf-1')!.setAttribute('theme', 'light'); }); @@ -80,9 +76,6 @@ test('removing theme attribute restores system preference', async ({ page }) => test('mathfield.less container variables respect theme attribute', async ({ page, }) => { - // `--_caret-color` is defined on .ML__container in mathfield.less. This - // verifies the :not([theme='light']) scoping on that file's media query, - // not just the palette overrides in colors.less. await page.emulateMedia({ colorScheme: 'dark' }); const darkCaret = await page.evaluate(readCaretColor('mf-1')); From 4486f498dd1603713f103b2997cd25fa43b132db Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Tue, 21 Apr 2026 10:57:47 -0400 Subject: [PATCH 11/11] wip --- css/mathfield.less | 24 ++++++++++++++++++++++++ test/playwright-tests/theme.spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/css/mathfield.less b/css/mathfield.less index 53341fcd4..ff6a79dd4 100644 --- a/css/mathfield.less +++ b/css/mathfield.less @@ -122,6 +122,30 @@ } :host([theme='dark']) .ML__container { + --_contains-highlight-color: var( + --contains-highlight-color, + hsl(var(--_hue), 85%, 75%) + ); + --_caret-color: var(--caret-color, hsl(var(--_hue), 65%, 55%)); + --_selection-color: var(--selection-color, #fff); + --_selection-background-color: var( + --selection-background-color, + hsl(var(--_hue), 65%, 55%) + ); + --_text-highlight-background-color: var( + --text-highlight-background-color, + hsla(var(--_hue), 40%, 50%, 0.6) + ); + --_contains-highlight-background-color: var( + --contains-highlight-background-color, + hsl(var(--_hue), 5%, 34%) + ); + --_latex-color: var(--primary, hsl(var(--_hue), 40%, 50%)); + --_composition-background-color: #69571c; + --_composition-text-color: white; + --_placeholder-color: hsl(var(--_hue), 60%, 69%); + + --_smart-fence-color: var(--smart-fence-color, #fff); --_smart-fence-opacity: var(--smart-fence-opacity, 0.7); } diff --git a/test/playwright-tests/theme.spec.ts b/test/playwright-tests/theme.spec.ts index fe18d7d1a..f8fec70e7 100644 --- a/test/playwright-tests/theme.spec.ts +++ b/test/playwright-tests/theme.spec.ts @@ -88,3 +88,25 @@ test('mathfield.less container variables respect theme attribute', async ({ expect(darkCaret).not.toBe(''); expect(lightCaret).not.toBe(''); }); + +test('theme="dark" on light OS applies dark container palette', async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: 'light' }); + const lightCaret = await page.evaluate(readCaretColor('mf-1')); + + await page.evaluate(() => { + document.getElementById('mf-1')!.setAttribute('theme', 'dark'); + }); + const darkCaret = await page.evaluate(readCaretColor('mf-1')); + + expect(darkCaret).not.toBe(lightCaret); + + // Sanity: this dark caret should match the one produced by dark OS with no theme attribute. + await page.evaluate(() => { + document.getElementById('mf-1')!.removeAttribute('theme'); + }); + await page.emulateMedia({ colorScheme: 'dark' }); + const osDarkCaret = await page.evaluate(readCaretColor('mf-1')); + expect(darkCaret).toBe(osDarkCaret); +});