Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions scripts/build-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,15 @@ await build({
'.woff2': 'file',
},
});

// A small classic script applies the theme synchronously in the document head.
await build({
entryPoints: [resolve(webDir, 'js/color_theme.js')],
absWorkingDir: process.cwd(),
outdir,
bundle: true,
minify: true,
target: ['es2022'],
format: 'iife',
platform: 'browser',
});
18 changes: 18 additions & 0 deletions tests/functional/test_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,24 @@ def test_api_documentation_requires_session_and_uses_local_assets(browser):
assert browser.get('/api/openapi.json').status_code == 401


def test_theme_switcher_is_shared_by_workspace_swagger_and_sign_in(browser):
for path in ('/settings/', '/api/docs'):
page = browser.get(path)
assert page.status_code == 200
assert 'data-color-theme' in page.text
assert all(f'data-theme-icon="{mode}"' in page.text for mode in ('light', 'dark', 'auto'))
assert all(f'data-lucide="{icon}"' in page.text for icon in ('sun', 'moon', 'sun-moon'))
script = page.text.index('<script src="/web/assets/color_theme.js')
assert script < page.text.index('rel="stylesheet"')
assert "script-src 'self';" in page.headers['Content-Security-Policy']
assert browser.get('/web/assets/color_theme.js').status_code == 200
browser.cookies.clear()
login = browser.get('/login')
assert login.status_code == 200
assert 'data-color-theme' in login.text
assert '/web/assets/color_theme.js' in login.text


def test_openapi_describes_request_bodies_authentication_and_unique_operations(browser):
response = browser.get('/api/openapi.json')
assert response.status_code == 200
Expand Down
132 changes: 132 additions & 0 deletions tests/web/color_theme.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { initColorTheme } from '../../web/js/color_theme.js';

const storageKey = 'themerr-color-theme';

function fixture({ saved = null, dark = false, loading = false, blocked = false } = {}) {
const browser = new EventTarget();
const systemTheme = new EventTarget();
systemTheme.matches = dark;
browser.matchMedia = query => {
assert.equal(query, '(prefers-color-scheme: dark)');
return systemTheme;
};
const storage = new Map(saved === null ? [] : [[storageKey, saved]]);
Object.defineProperty(browser, 'localStorage', { get() {
if (blocked) throw new Error('Storage disabled');
return { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) };
} });
const button = new EventTarget();
button.dataset = { autoLabel: 'Automatic; switch to light', lightLabel: 'Light; switch to dark',
darkLabel: 'Dark; switch to automatic' };
button.setAttribute = (name, value) => { button[name] = value; };
const icons = ['auto', 'light', 'dark'].map(themeIcon => ({ dataset: { themeIcon }, hidden: false }));
button.querySelectorAll = () => icons;
const classes = new Set();
const root = new EventTarget();
root.readyState = loading ? 'loading' : 'complete';
root.documentElement = { dataset: {}, classList: { toggle(name, active) {
if (active) classes.add(name);
else classes.delete(name);
} } };
const meta = { setAttribute(name, value) { this[name] = value; } };
root.querySelector = () => meta;
root.querySelectorAll = () => root.readyState === 'loading' ? [] : [button];
initColorTheme(root, browser);
return {
root, browser, button, storage, meta, classes,
get theme() { return root.documentElement.dataset.bsTheme; },
get mode() { return root.documentElement.dataset.themeMode; },
get visibleIcons() { return icons.filter(icon => !icon.hidden).map(icon => icon.dataset.themeIcon); },
click() { button.dispatchEvent(new Event('click')); },
system(dark) { systemTheme.matches = dark; systemTheme.dispatchEvent(new Event('change')); },
stored(value, key = storageKey) {
const event = new Event('storage');
Object.assign(event, { key, newValue: value });
browser.dispatchEvent(event);
},
};
}

test('automatic mode follows the system before controls exist and responds to system changes', () => {
const page = fixture({ dark: true, loading: true });
assert.equal(page.theme, 'dark');
assert.equal(page.mode, 'auto');
assert.equal(page.meta.content, 'dark');
assert.ok(page.classes.has('dark-mode'));
page.root.readyState = 'interactive';
page.root.dispatchEvent(new Event('DOMContentLoaded'));
assert.deepEqual(page.visibleIcons, ['auto']);
assert.equal(page.button['aria-label'], page.button.dataset.autoLabel);
page.system(false);
assert.equal(page.theme, 'light');
assert.equal(page.meta.content, 'light');
assert.ok(!page.classes.has('dark-mode'));
assert.deepEqual(page.visibleIcons, ['auto']);
assert.equal(page.storage.size, 0);
});

test('clicks cycle automatic, light, and dark with saved preferences and accessible icon labels', () => {
const page = fixture();
page.click();
assert.equal(page.mode, 'light');
assert.equal(page.storage.get(storageKey), 'light');
assert.deepEqual(page.visibleIcons, ['light']);
assert.equal(page.button['aria-label'], page.button.dataset.lightLabel);
const reloaded = fixture({ saved: page.storage.get(storageKey), dark: true });
assert.equal(reloaded.theme, 'light');
assert.deepEqual(reloaded.visibleIcons, ['light']);
page.system(true);
assert.equal(page.theme, 'light');
page.click();
assert.equal(page.mode, 'dark');
assert.equal(page.storage.get(storageKey), 'dark');
assert.deepEqual(page.visibleIcons, ['dark']);
assert.equal(page.button['aria-label'], page.button.dataset.darkLabel);
page.system(false);
assert.equal(page.theme, 'dark');
page.click();
assert.equal(page.mode, 'auto');
assert.equal(page.theme, 'light');
assert.equal(page.storage.get(storageKey), 'auto');
assert.deepEqual(page.visibleIcons, ['auto']);
page.system(true);
assert.equal(page.theme, 'dark');
});

test('changes in other tabs update the icon, accessible label, and Swagger palette', () => {
const page = fixture({ saved: 'light', dark: true });
assert.equal(page.theme, 'light');
assert.deepEqual(page.visibleIcons, ['light']);
page.stored('dark', 'unrelated');
assert.equal(page.theme, 'light');
page.stored('dark');
assert.equal(page.theme, 'dark');
assert.deepEqual(page.visibleIcons, ['dark']);
assert.equal(page.button['aria-label'], page.button.dataset.darkLabel);
assert.ok(page.classes.has('dark-mode'));
page.stored(null);
assert.equal(page.mode, 'auto');
page.stored('light');
page.stored(null, null);
assert.deepEqual(page.visibleIcons, ['auto']);
assert.equal(page.theme, 'dark');
});

test('invalid preferences fall back to automatic and unavailable storage does not disable switching', () => {
const invalid = fixture({ saved: 'invalid', dark: true });
assert.equal(invalid.mode, 'auto');
assert.equal(invalid.theme, 'dark');
invalid.stored('invalid');
assert.deepEqual(invalid.visibleIcons, ['auto']);
const page = fixture({ blocked: true });
assert.equal(page.theme, 'light');
page.click();
page.click();
assert.equal(page.theme, 'dark');
page.system(false);
assert.equal(page.theme, 'dark');
page.click();
assert.equal(page.theme, 'light');
});
4 changes: 4 additions & 0 deletions web/css/api_docs.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* Swagger's native palette is controlled by the shared theme script. */
html.dark-mode[data-bs-theme="dark"] { background: var(--canvas); }
.api-docs-panel .swagger-ui { font-family: var(--bs-body-font-family); }
.api-docs-panel .swagger-ui .renderedMarkdown :is(h1, h2, h3, h4, h5, h6) { color: var(--ink); }
Loading
Loading