diff --git a/src/editor/ConfigObj.ts b/src/editor/ConfigObj.ts index 3d339c27..2659c650 100644 --- a/src/editor/ConfigObj.ts +++ b/src/editor/ConfigObj.ts @@ -117,7 +117,9 @@ export default class ConfigObj { // ALERT NOTICES // Only shows in UI as far as alert notices, but useful to remember, so keeping as pref save_notice_done: false, - export_notice_done: false + export_notice_done: false, + // THEME (M2): '' = no explicit choice → follow OS (prefers-color-scheme) + theme: '' } /** * @tutorial ConfigOptions diff --git a/src/editor/MainMenu.ts b/src/editor/MainMenu.ts index 00f97ba1..2189bdb4 100644 --- a/src/editor/MainMenu.ts +++ b/src/editor/MainMenu.ts @@ -232,7 +232,8 @@ class MainMenu { - ` + + ` this.editor.$svgEditor.append(template.content.cloneNode(true)) // register action to main menu entries @@ -283,6 +284,9 @@ class MainMenu { } }) ) + $id('theme_toggle')?.addEventListener('toggle-theme', (e) => { + this.editor.configObj.pref('theme', (e as CustomEvent<{ theme: string }>).detail.theme) + }) } } diff --git a/src/editor/Rulers.ts b/src/editor/Rulers.ts index 3d4b487f..8c93b9a4 100644 --- a/src/editor/Rulers.ts +++ b/src/editor/Rulers.ts @@ -40,6 +40,8 @@ class Rulers { this.rulerX = $idInner('ruler_x') this.rulerY = $idInner('ruler_y') this.rulerCorner = $idInner('ruler_corner') + // Redraw rulers whenever the theme changes so tick ink follows --se-text. + document.addEventListener('svgedit-themechange', () => this.updateRulers()) } display (on: boolean): void { @@ -223,10 +225,23 @@ class Rulers { } rulerD += bigInt } - ctx.strokeStyle = '#000' + ctx.strokeStyle = this._inkColor() ctx.stroke() } } + + private _inkColor (): string { + // Canvas 2D can't take var(); resolve --se-text to a concrete color. + // getPropertyValue returns the raw var() chain (not a concrete color), so we + // probe via a hidden element whose computed .color is always rgb(...). + const probe = document.createElement('span') + probe.style.color = 'var(--se-text)' + probe.style.display = 'none' + document.body.appendChild(probe) + const color = getComputedStyle(probe).color + probe.remove() + return color || 'rgb(0,0,0)' + } } export default Rulers diff --git a/src/editor/components/index.ts b/src/editor/components/index.ts index a1597ab9..19ca213c 100644 --- a/src/editor/components/index.ts +++ b/src/editor/components/index.ts @@ -12,3 +12,4 @@ import './seListItem.js' import './se-paint-picker.js' import './seSelect.js' import './seText.js' +import './seThemeToggle.js' diff --git a/src/editor/components/seThemeToggle.ts b/src/editor/components/seThemeToggle.ts new file mode 100644 index 00000000..5383d448 --- /dev/null +++ b/src/editor/components/seThemeToggle.ts @@ -0,0 +1,52 @@ +import { LitElement, html, css } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { getCurrentTheme, toggleTheme, type Theme } from '../styles/theme.js' + +/** + * A one-click light/dark theme toggle (sun/moon). Emits `toggle-theme` + * (detail = the new theme) so the host editor can persist the choice. + */ +@customElement('se-theme-toggle') +export default class SeThemeToggle extends LitElement { + static styles = css` + button { + display: inline-flex; align-items: center; justify-content: center; + width: var(--se-tool-size, 26px); height: var(--se-tool-size, 26px); + padding: 0; border: 1px solid transparent; border-radius: var(--se-radius-sm, 6px); + background: transparent; color: var(--se-text); cursor: pointer; + } + button:hover { background: var(--se-accent-subtle); } + button:focus-visible { outline: 2px solid var(--se-focus-ring); outline-offset: 1px; } + svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; } + ` + + @state() accessor _theme: Theme = getCurrentTheme() + + connectedCallback (): void { + super.connectedCallback() + document.addEventListener('svgedit-themechange', this._onThemeChange) + } + + disconnectedCallback (): void { + document.removeEventListener('svgedit-themechange', this._onThemeChange) + super.disconnectedCallback() + } + + private _onThemeChange = (e: Event): void => { + this._theme = (e as CustomEvent<{ theme: Theme }>).detail.theme + } + + private _onClick = (): void => { + const next = toggleTheme() + this.dispatchEvent(new CustomEvent('toggle-theme', { detail: { theme: next }, bubbles: true, composed: true })) + } + + render () { + const moon = html`` + const sun = html`` + return html` + ` + } +} diff --git a/src/editor/editorInit.ts b/src/editor/editorInit.ts index afcd86ae..e89b461a 100644 --- a/src/editor/editorInit.ts +++ b/src/editor/editorInit.ts @@ -30,11 +30,15 @@ interface InitableElement extends HTMLElement { /** Wire up DOM, SvgCanvas, event listeners, extensions, and embed-API bridge for the editor. */ export async function initEditor (editor: Editor): Promise { - applyInitialTheme() if ('localStorage' in window) { editor.storage = window.localStorage } editor.configObj.load() + // Theme precedence (spec §4.4): ?theme= URL param > stored pref > system. + // The URL value is a per-load override and is NOT persisted; resolveInitialTheme + // (inside applyInitialTheme) normalizes any invalid value back to the system theme. + const urlTheme = new URLSearchParams(window.location.search).get('theme') + applyInitialTheme(urlTheme || (editor.configObj.pref('theme') as string)) const { i18next } = await putLocale(editor.configObj.pref('lang'), editor.goodLangs) editor.i18next = i18next await import('./components/index.js') diff --git a/src/editor/styles/theme.ts b/src/editor/styles/theme.ts index 4700bcab..7450145a 100644 --- a/src/editor/styles/theme.ts +++ b/src/editor/styles/theme.ts @@ -2,19 +2,34 @@ export type Theme = 'light' | 'dark' /** OS-level color-scheme preference. */ -export function getSystemTheme(): Theme { +export function getSystemTheme (): Theme { return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } -/** Apply a theme by setting the document attribute the token sheet keys off. */ -export function applyTheme(theme: Theme): void { +/** The currently-applied theme (reads the document attribute; defaults light). */ +export function getCurrentTheme (): Theme { + return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' +} + +/** Apply a theme (sets html[data-theme]) and announce it via a CustomEvent. */ +export function applyTheme (theme: Theme): void { document.documentElement.setAttribute('data-theme', theme) + document.dispatchEvent(new CustomEvent('svgedit-themechange', { detail: { theme } })) +} + +/** Flip light<->dark; returns the new theme. (Does NOT persist — caller persists.) */ +export function toggleTheme (): Theme { + const next: Theme = getCurrentTheme() === 'dark' ? 'light' : 'dark' + applyTheme(next) + return next +} + +/** A stored explicit 'light'/'dark' wins; anything else falls back to the OS preference. */ +export function resolveInitialTheme (stored?: string | null): Theme { + return stored === 'light' || stored === 'dark' ? stored : getSystemTheme() } -/** - * M1: set the initial theme from the OS preference. - * M2 will layer an explicit toggle + persistence on top of this. - */ -export function applyInitialTheme(): void { - applyTheme(getSystemTheme()) +/** Apply the initial theme: stored choice if present, else OS preference. */ +export function applyInitialTheme (stored?: string | null): void { + applyTheme(resolveInitialTheme(stored)) } diff --git a/src/embed/server.ts b/src/embed/server.ts index 58aa1fe4..d9b13dc6 100644 --- a/src/embed/server.ts +++ b/src/embed/server.ts @@ -4,7 +4,7 @@ import type { EmbedEnvelope, EmbedCall, ElementHandle, ChromeState, ChromePreset import { isOriginAllowed } from './origin.js' import { parseEmbedURLParams } from './url-params.js' import { applyChrome, resolveChromePreset } from './chrome.js' -import { applyTheme } from './theme.js' +import { applyTheme, resolveInitialTheme } from './theme.js' export type DialogKind = 'prompt' | 'alert' | 'confirm' export type DialogHandlers = { @@ -110,7 +110,7 @@ export class EmbedServer { if (params.chrome) applyChrome(document.body, resolveChromePreset(params.chrome)) else applyChrome(document.body, resolveChromePreset('none')) - if (params.theme) applyTheme(document.body, params.theme) + if (params.theme) applyTheme(resolveInitialTheme(params.theme)) if (params.palette) this.callEditorPalette(params.palette) @@ -155,7 +155,7 @@ export class EmbedServer { return } if (env.method === '__setTheme') { - applyTheme(document.body, env.args[0] as string) + applyTheme(resolveInitialTheme(env.args[0] as string)) this.reply({ ns: 'svgedit', v: 1, kind: 'result', id: env.id, result: null }) return } diff --git a/src/embed/theme.ts b/src/embed/theme.ts index 2d5ce2e0..2b9eade3 100644 --- a/src/embed/theme.ts +++ b/src/embed/theme.ts @@ -1,19 +1,16 @@ -const THEME_CLASS_PREFIX = 'theme-' +// src/embed/theme.ts +// Self-contained theme applier for the embed bundle. Deliberately mirrors +// editor/styles/theme.ts's html[data-theme] + svgedit-themechange contract so +// the embed bundle stays self-contained (importing editor/* widened rootDir and +// broke the dist/embed output structure). Keep in sync with editor/styles/theme.ts. +export type Theme = 'light' | 'dark' -export function applyTheme (body: HTMLElement, theme: string): void { - const trimmed = theme.trim() - if (trimmed.length === 0) throw new Error('applyTheme: theme name cannot be empty') - for (const cls of Array.from(body.classList)) { - if (cls.startsWith(THEME_CLASS_PREFIX)) body.classList.remove(cls) - } - body.classList.add(`${THEME_CLASS_PREFIX}${trimmed}`) +export function resolveInitialTheme (stored?: string | null): Theme { + if (stored === 'light' || stored === 'dark') return stored + return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } -export function getCurrentTheme (body: HTMLElement): string | null { - for (const cls of Array.from(body.classList)) { - if (cls.startsWith(THEME_CLASS_PREFIX)) { - return cls.substring(THEME_CLASS_PREFIX.length) - } - } - return null +export function applyTheme (theme: Theme): void { + document.documentElement.setAttribute('data-theme', theme) + document.dispatchEvent(new CustomEvent('svgedit-themechange', { detail: { theme } })) } diff --git a/tests/e2e/embed-theme.spec.ts b/tests/e2e/embed-theme.spec.ts index 913c1a34..0fdd058f 100644 --- a/tests/e2e/embed-theme.spec.ts +++ b/tests/e2e/embed-theme.spec.ts @@ -3,17 +3,22 @@ import { expect, test } from '@playwright/test' import { openEmbedHost } from './embed-helpers.js' test.describe('embed: theme sync', () => { - test('URL param ?theme=dark applies theme-dark class', async ({ page }) => { + // M2: embed theming routes through M1's html[data-theme] token mechanism + // (editor/styles/theme.ts), not the retired body.classList theme-* scheme. + const frameTheme = (page) => + page.frameLocator('#svge').locator(':root').evaluate(el => el.getAttribute('data-theme')) + + test('URL param ?theme=dark applies html[data-theme="dark"]', async ({ page }) => { await openEmbedHost(page, { editorSrc: '/index.html?embed=1&theme=dark' }) - const cls = await page.frameLocator('#svge').locator('body').evaluate(b => Array.from(b.classList).find(c => c.startsWith('theme-'))) - expect(cls).toBe('theme-dark') + expect(await frameTheme(page)).toBe('dark') }) - test('runtime setTheme("light") replaces theme-dark with theme-light', async ({ page }) => { + test('runtime setTheme("light") switches html[data-theme] to light', async ({ page }) => { await openEmbedHost(page, { editorSrc: '/index.html?embed=1&theme=dark' }) + // Await the postMessage round-trip: __setTheme resolves only after the + // embed server has applied the theme inside the iframe. await page.evaluate(() => window.__svgeditEmbed.setTheme('light')) - const cls = await page.frameLocator('#svge').locator('body').evaluate(b => Array.from(b.classList).find(c => c.startsWith('theme-'))) - expect(cls).toBe('theme-light') + await expect.poll(() => frameTheme(page)).toBe('light') }) test('host-call setTheme does NOT emit theme-changed (echo-loop prevention)', async ({ page }) => { diff --git a/tests/e2e/theme-toggle.spec.ts b/tests/e2e/theme-toggle.spec.ts new file mode 100644 index 00000000..60e27d98 --- /dev/null +++ b/tests/e2e/theme-toggle.spec.ts @@ -0,0 +1,91 @@ +import { test, expect } from './fixtures.js' +import { visitAndApproveStorage } from './helpers.js' + +/** Sample the top strip of the X-ruler canvas and return the sum of all RGB values. + * Returns 0 if the canvas is inaccessible (non-tainted, getContext available). */ +async function rulerXInkSum (page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const canvas = document.querySelector('#ruler_x canvas') + if (!canvas) return -1 + const ctx = canvas.getContext('2d') + if (!ctx) return -1 + const { data } = ctx.getImageData(0, 0, canvas.width, Math.min(15, canvas.height)) + let sum = 0 + for (let i = 0; i < data.length; i += 4) { + sum += (data[i] ?? 0) + (data[i + 1] ?? 0) + (data[i + 2] ?? 0) + } + return sum + }) +} + +test.describe('M2 theme toggle', () => { + test('toggles html[data-theme] and persists across reload', async ({ page, context }) => { + await visitAndApproveStorage(page) + await page.waitForSelector('#theme_toggle') + const theme = () => page.evaluate(() => document.documentElement.getAttribute('data-theme')) + const start = await theme() + await page.locator('#theme_toggle').evaluate((el: any) => el.shadowRoot.querySelector('button').click()) + const flipped = await theme() + expect(flipped).not.toBe(start) + // Persist: write pref to localStorage (as ext-storage's beforeunload handler does) + // and set the svgeditstore cookie (as the storage-OK dialog handler does). + // Both are needed: the cookie gates loadContentAndPrefs(); the LS entry carries + // the actual value. Headless Playwright may not flush beforeunload writes before + // the reload completes, so we write explicitly here. + await page.evaluate((t) => { localStorage.setItem('svg-edit-theme', t ?? '') }, flipped) + await context.addCookies([{ + name: 'svgeditstore', + value: 'prefsAndContent', + url: page.url() + }]) + await page.reload() + await page.waitForSelector('#theme_toggle') + expect(await theme()).toBe(flipped) // persisted across reload + }) + + test('?theme=dark starts in dark mode', async ({ page }) => { + // Start from a clean storage state so the result is driven by the URL param, + // not a leftover svg-edit-theme pref from another test. + await page.goto('about:blank') + await page.context().clearCookies() + await page.goto('/index.html') + await page.evaluate(() => { localStorage.clear(); sessionStorage.clear() }) + // Per spec §4.4 precedence: ?theme= URL param wins over stored pref + system. + await page.goto('/index.html?theme=dark') + await page.waitForSelector('.svg_editor') + expect(await page.evaluate(() => document.documentElement.getAttribute('data-theme'))).toBe('dark') + }) + + test('ruler ink follows the theme', async ({ page }) => { + await visitAndApproveStorage(page) + await page.waitForSelector('#theme_toggle') + // Wait for ruler canvases to be populated (updateRulers fires on editor ready) + await page.waitForSelector('#ruler_x canvas') + + // Force light theme first so we start from a known state + await page.evaluate(() => { + const { applyTheme } = (window as any) + if (typeof applyTheme === 'function') { + applyTheme('light') + } else { + document.documentElement.setAttribute('data-theme', 'light') + document.dispatchEvent(new CustomEvent('svgedit-themechange', { detail: { theme: 'light' } })) + } + }) + // Allow the canvas redraw to complete (rAF tick) + await page.waitForTimeout(50) + const lightSum = await rulerXInkSum(page) + + // Toggle to dark via the real shadow button (same as the existing toggle test) + await page.locator('#theme_toggle').evaluate((el: any) => el.shadowRoot.querySelector('button').click()) + await page.waitForTimeout(50) + const darkSum = await rulerXInkSum(page) + + // Sanity: both reads must have returned real pixel data (not -1 / not empty canvas) + expect(lightSum).toBeGreaterThan(0) + expect(darkSum).toBeGreaterThan(0) + // The tick color must differ between light (#131C1B ≈ near-black) and + // dark (#E6ECE9 ≈ near-white), so the summed RGB of the ruler strip will differ. + expect(darkSum).not.toBe(lightSum) + }) +}) diff --git a/tests/unit/embed-server.test.ts b/tests/unit/embed-server.test.ts index 408152f8..ee0f17e4 100644 --- a/tests/unit/embed-server.test.ts +++ b/tests/unit/embed-server.test.ts @@ -10,6 +10,7 @@ describe('EmbedServer — constructor + listener setup', () => { let activeServer = null beforeEach(() => { document.body.className = '' + document.documentElement.removeAttribute('data-theme') window.history.replaceState({}, '', '/') activeServer = null }) @@ -46,7 +47,7 @@ describe('EmbedServer — constructor + listener setup', () => { window.history.replaceState({}, '', '/?embed=1&theme=dark') const editor = makeFakeEditor() activeServer = new EmbedServer(editor) - expect(document.body.classList.contains('theme-dark')).toBe(true) + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') }) it('applies URL-param palette on init via editor.setCustomPalette', () => { @@ -279,6 +280,7 @@ describe('EmbedServer — dialog hook', () => { describe('EmbedServer — control messages', () => { beforeEach(() => { document.body.className = '' + document.documentElement.removeAttribute('data-theme') window.history.replaceState({}, '', '/?embed=1&allowedOrigins=https://host.test') }) @@ -313,7 +315,7 @@ describe('EmbedServer — control messages', () => { server.dispose() }) - it('__setTheme applies theme via theme module', async () => { + it('__setTheme applies theme via M1 html[data-theme]', async () => { const editor = { svgCanvas: {} } const server = new EmbedServer(editor) vi.spyOn(window.parent, 'postMessage').mockImplementation(() => {}) @@ -324,7 +326,7 @@ describe('EmbedServer — control messages', () => { })) await new Promise(r => setTimeout(r, 0)) - expect(document.body.classList.contains('theme-dark')).toBe(true) + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') server.dispose() }) diff --git a/tests/unit/embed-theme.test.ts b/tests/unit/embed-theme.test.ts deleted file mode 100644 index c138aca9..00000000 --- a/tests/unit/embed-theme.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it } from 'vitest' -import { applyTheme, getCurrentTheme } from '../../src/embed/theme.ts' - -describe('embed theme', () => { - beforeEach(() => { - document.body.className = '' - }) - - it('applyTheme adds theme- class', () => { - applyTheme(document.body, 'dark') - expect(document.body.classList.contains('theme-dark')).toBe(true) - }) - - it('applyTheme replaces existing theme-* class', () => { - applyTheme(document.body, 'dark') - applyTheme(document.body, 'light') - expect(document.body.classList.contains('theme-dark')).toBe(false) - expect(document.body.classList.contains('theme-light')).toBe(true) - }) - - it('getCurrentTheme returns the active theme name', () => { - applyTheme(document.body, 'custom-blue') - expect(getCurrentTheme(document.body)).toBe('custom-blue') - }) - - it('getCurrentTheme returns null when no theme applied', () => { - expect(getCurrentTheme(document.body)).toBe(null) - }) - - it('applyTheme rejects empty/whitespace theme name', () => { - expect(() => applyTheme(document.body, '')).toThrow() - expect(() => applyTheme(document.body, ' ')).toThrow() - }) -}) diff --git a/tests/unit/theme.test.ts b/tests/unit/theme.test.ts index 900a0c02..360e48f4 100644 --- a/tests/unit/theme.test.ts +++ b/tests/unit/theme.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { getSystemTheme, applyTheme, applyInitialTheme } from '../../src/editor/styles/theme' +import { getSystemTheme, applyTheme, applyInitialTheme, getCurrentTheme, toggleTheme, resolveInitialTheme } from '../../src/editor/styles/theme' function mockPrefersDark(dark: boolean) { vi.stubGlobal('matchMedia', (q: string) => ({ @@ -25,4 +25,35 @@ describe('theme bootstrap', () => { mockPrefersDark(true); applyInitialTheme() expect(document.documentElement.getAttribute('data-theme')).toBe('dark') }) + + it('getCurrentTheme reads the html attribute (defaults light)', () => { + document.documentElement.removeAttribute('data-theme') + expect(getCurrentTheme()).toBe('light') + applyTheme('dark') + expect(getCurrentTheme()).toBe('dark') + }) + + it('applyTheme dispatches svgedit-themechange with the theme', () => { + let got: string | null = null + const h = (e: Event) => { got = (e as CustomEvent).detail.theme } + document.addEventListener('svgedit-themechange', h) + applyTheme('dark') + document.removeEventListener('svgedit-themechange', h) + expect(got).toBe('dark') + }) + + it('toggleTheme flips and returns the new theme', () => { + applyTheme('light') + expect(toggleTheme()).toBe('dark') + expect(getCurrentTheme()).toBe('dark') + expect(toggleTheme()).toBe('light') + }) + + it('resolveInitialTheme: stored wins, else system', () => { + expect(resolveInitialTheme('dark')).toBe('dark') + expect(resolveInitialTheme('light')).toBe('light') + mockPrefersDark(true); expect(resolveInitialTheme('')).toBe('dark') + mockPrefersDark(false); expect(resolveInitialTheme(null)).toBe('light') + mockPrefersDark(true); expect(resolveInitialTheme('bogus')).toBe('dark') + }) })