diff --git a/public/docs/css/main.css b/public/docs/css/main.css
index 0fff3ee935..1c51f17543 100644
--- a/public/docs/css/main.css
+++ b/public/docs/css/main.css
@@ -706,26 +706,33 @@ nav.skip-links a:focus {
z-index: 2;
}
-.theme-switcher.dark-mode .switch-slider {
+html[data-theme='dark'] .theme-switcher .switch-slider {
transform: translateX(100%);
}
-.theme-switcher.dark-mode .theme-switcher__icon--light {
+html[data-theme='dark'] .theme-switcher__icon--light {
color: #7c98b4;
}
-.theme-switcher.dark-mode .theme-switcher__icon--light svg path {
+html[data-theme='dark'] .theme-switcher__icon--light svg path {
fill: #7c98b4;
}
-.theme-switcher:not(.dark-mode) .theme-switcher__icon--dark {
+html[data-theme='light'] .theme-switcher__icon--dark {
color: #557999;
}
-.theme-switcher:not(.dark-mode) .theme-switcher__icon--dark svg path {
+html[data-theme='light'] .theme-switcher__icon--dark svg path {
fill: #557999;
}
+/* The input is visually hidden but still the focusable control, so the ring
+ has to be drawn on the label. */
+.theme-switcher input:focus-visible + .theme-switcher__label {
+ outline: 0.125rem solid var(--octo-blue);
+ outline-offset: 0.125rem;
+}
+
.theme-switcher input:disabled + .theme-switcher__label {
opacity: 0.5;
cursor: not-allowed;
diff --git a/public/docs/js/modules/theme-switcher.js b/public/docs/js/modules/theme-switcher.js
deleted file mode 100644
index 2c83dfead5..0000000000
--- a/public/docs/js/modules/theme-switcher.js
+++ /dev/null
@@ -1,49 +0,0 @@
-class ThemeSwitcher {
- constructor(el) {
- this.themeSwitcher = el;
- this.checkbox = this.themeSwitcher.querySelector(
- '[data-theme-toggle-checkbox]'
- );
- this.html = document.documentElement;
- this.isDarkMode = this.html.getAttribute('data-theme') === 'dark';
-
- this.syncCheckboxWithTheme();
- this.addEventListeners();
- }
-
- addEventListeners() {
- this.themeSwitcher.addEventListener('click', (e) => {
- if (e.target === this.checkbox) {
- this.toggleTheme();
- }
- });
- // Handle keydown event for keyboard accessibility
- this.themeSwitcher.addEventListener('keydown', (e) => {
- if (e.keyCode === 13 || e.keyCode === 32) {
- e.preventDefault();
- this.toggleTheme();
- }
- });
- }
-
- toggleTheme() {
- this.isDarkMode = !this.isDarkMode;
- const newTheme = this.isDarkMode ? 'dark' : 'light';
- this.html.setAttribute('data-theme', newTheme);
- localStorage.setItem('theme', newTheme);
-
- this.syncCheckboxWithTheme();
- }
-
- syncCheckboxWithTheme() {
- this.checkbox.checked = this.isDarkMode;
- this.checkbox.setAttribute('aria-checked', this.isDarkMode.toString());
- if (this.isDarkMode) {
- this.themeSwitcher.classList.add('dark-mode');
- } else {
- this.themeSwitcher.classList.remove('dark-mode');
- }
- }
-}
-
-export { ThemeSwitcher };
diff --git a/src/components/Header.astro b/src/components/Header.astro
index 1015018d24..fb75e3aa70 100644
--- a/src/components/Header.astro
+++ b/src/components/Header.astro
@@ -6,8 +6,7 @@ import { SITE } from '@config';
import Search from '../themes/octopus/components/Search.astro';
import MobileMenu from './MobileMenu.astro';
import OctopusLogo from '../../public/docs/img/OctopusLogo.astro';
-import SunIcon from '../../public/docs/img/SunIcon.astro';
-import MoonIcon from '../../public/docs/img/MoonIcon.astro';
+import ThemeSwitcher from './ThemeSwitcher.astro';
const stats = new accelerator.statistics('components/Header.astro');
stats.start();
@@ -34,34 +33,7 @@ stats.stop();
Docs
-
-
-
-
+
{showSearch && }
@@ -71,12 +43,3 @@ stats.stop();
>
-
-
diff --git a/src/components/MobileMenu.astro b/src/components/MobileMenu.astro
index a42123a31a..b01e906f37 100644
--- a/src/components/MobileMenu.astro
+++ b/src/components/MobileMenu.astro
@@ -1,7 +1,6 @@
---
// Drawer body is populated client-side by nav-mobile.js cloning #site-nav.
-import SunIcon from '../../public/docs/img/SunIcon.astro';
-import MoonIcon from '../../public/docs/img/MoonIcon.astro';
+import ThemeSwitcher from './ThemeSwitcher.astro';
---
-
-
-
-
+
diff --git a/src/components/ThemeSwitcher.astro b/src/components/ThemeSwitcher.astro
new file mode 100644
index 0000000000..e65844799c
--- /dev/null
+++ b/src/components/ThemeSwitcher.astro
@@ -0,0 +1,44 @@
+---
+import SunIcon from '../../public/docs/img/SunIcon.astro';
+import MoonIcon from '../../public/docs/img/MoonIcon.astro';
+
+type Props = {
+ /** Unique per instance - the label needs its own `for` target. */
+ id: string;
+};
+
+const { id } = Astro.props satisfies Props;
+---
+
+
+
+
+
+
+
diff --git a/src/config.ts b/src/config.ts
index 31990c4037..350c802660 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -74,14 +74,6 @@ export const HEADER_SCRIPTS = `
-
-
-
diff --git a/tests/theme.spec.ts b/tests/theme.spec.ts
new file mode 100644
index 0000000000..92a30c2e87
--- /dev/null
+++ b/tests/theme.spec.ts
@@ -0,0 +1,147 @@
+import { test, expect, type Page } from '@playwright/test';
+
+const home = '/docs/';
+const otherPage = '/docs/getting-started/';
+
+const headerInput = '#theme-switcher';
+const headerLabel = 'label[for="theme-switcher"]';
+const mobileInput = '#theme-switcher-mobile';
+const slider = '#theme-switcher ~ .theme-switcher__label .switch-slider';
+
+// Auto-retrying so a slow style/attribute write can never make these flaky.
+function expectTheme(page: Page, value: string) {
+ return expect(page.locator('html')).toHaveAttribute('data-theme', value);
+}
+
+function expectPreference(page: Page, value: string) {
+ return expect(page.locator('html')).toHaveAttribute(
+ 'data-theme-preference',
+ value
+ );
+}
+
+function sliderTransform(page: Page) {
+ return page.locator(slider).evaluate((el) => getComputedStyle(el).transform);
+}
+
+test.describe('theme', () => {
+ test.describe('with a dark OS preference', () => {
+ test.use({ colorScheme: 'dark' });
+
+ test('follows the OS when nothing is stored', async ({ page }) => {
+ await page.goto(home);
+
+ await expectTheme(page, 'dark');
+ await expectPreference(page, 'system');
+ await expect(page.locator(headerInput)).toBeChecked();
+ });
+
+ test('an explicit choice wins and survives navigation', async ({
+ page,
+ }) => {
+ await page.goto(home);
+ await page.locator(headerLabel).click();
+
+ await expectTheme(page, 'light');
+ await expectPreference(page, 'light');
+
+ await page.goto(otherPage);
+ await expectTheme(page, 'light');
+ await expect(page.locator(headerInput)).not.toBeChecked();
+ });
+
+ test('falls back to the OS when storage throws', async ({ page }) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(window, 'localStorage', {
+ get() {
+ throw new Error('storage blocked');
+ },
+ });
+ });
+ await page.goto(home);
+
+ await expectTheme(page, 'dark');
+
+ // Still usable for the life of the page, just not persisted.
+ await page.locator(headerLabel).click();
+ await expectTheme(page, 'light');
+ });
+ });
+
+ test.describe('with a light OS preference', () => {
+ test.use({ colorScheme: 'light' });
+
+ test('follows the OS when nothing is stored', async ({ page }) => {
+ await page.goto(home);
+
+ await expectTheme(page, 'light');
+ await expect(page.locator(headerInput)).not.toBeChecked();
+ });
+
+ test('both switchers stay in sync', async ({ page }) => {
+ await page.goto(home);
+
+ await page.locator(headerLabel).click();
+ await expectTheme(page, 'dark');
+ await expect(page.locator(headerInput)).toBeChecked();
+ await expect(page.locator(mobileInput)).toBeChecked();
+
+ await page.locator(headerLabel).click();
+ await expectTheme(page, 'light');
+ await expect(page.locator(mobileInput)).not.toBeChecked();
+ });
+
+ // Space is the native checkbox key; Enter is optional for role="switch"
+ // but the control supported it before, so both have to keep working.
+ for (const key of ['Space', 'Enter']) {
+ test(`is operable with ${key}`, async ({ page }) => {
+ await page.goto(home);
+ await page.locator(headerInput).focus();
+
+ await page.keyboard.press(key);
+ await expectTheme(page, 'dark');
+ await expect(page.locator(headerInput)).toBeChecked();
+
+ await page.keyboard.press(key);
+ await expectTheme(page, 'light');
+ await expect(page.locator(headerInput)).not.toBeChecked();
+ });
+ }
+ });
+
+ // The design token stylesheets define their custom properties only under
+ // [data-theme='light'] / [data-theme='dark'] and nothing at :root, so a page
+ // with no attribute resolves no tokens at all. The server-rendered default in
+ // layouts/Default.astro is what keeps that from happening without scripting.
+ test.describe('with scripting disabled', () => {
+ test.use({ javaScriptEnabled: false });
+
+ test('still carries a data-theme for the tokens to key off', async ({
+ page,
+ }) => {
+ await page.goto(home);
+
+ await expectTheme(page, 'light');
+ });
+ });
+
+ // Regression guard: the knob used to be driven by a JS-applied class, so it
+ // painted on the light side and slid across after hydration. Driving it from
+ // data-theme means the attribute alone decides the position.
+ test('the knob position is driven by data-theme, not by script', async ({
+ page,
+ }) => {
+ await page.goto(home);
+
+ // Polled because the slider transitions between the two positions.
+ await page.evaluate(() =>
+ document.documentElement.setAttribute('data-theme', 'dark')
+ );
+ await expect.poll(() => sliderTransform(page)).not.toBe('none');
+
+ await page.evaluate(() =>
+ document.documentElement.setAttribute('data-theme', 'light')
+ );
+ await expect.poll(() => sliderTransform(page)).toBe('none');
+ });
+});