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')
+ })
})