Skip to content
Merged
4 changes: 3 additions & 1 deletion src/editor/ConfigObj.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/editor/MainMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,8 @@ class MainMenu {
<se-menu-item id="tool_docprops" label="tools.docprops" shortcut="shift+D" src="docprop.svg"></se-menu-item>
<se-menu-item id="tool_editor_prefs" label="config.editor_prefs" src="editPref.svg"></se-menu-item>
<se-menu-item id="tool_editor_homepage" label="tools.editor_homepage" src="logo.svg"></se-menu-item>
</se-menu>`
</se-menu>
<se-theme-toggle id="theme_toggle"></se-theme-toggle>`
this.editor.$svgEditor.append(template.content.cloneNode(true))

// register action to main menu entries
Expand Down Expand Up @@ -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)
})
}
}

Expand Down
17 changes: 16 additions & 1 deletion src/editor/Rulers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions src/editor/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ import './seListItem.js'
import './se-paint-picker.js'
import './seSelect.js'
import './seText.js'
import './seThemeToggle.js'
52 changes: 52 additions & 0 deletions src/editor/components/seThemeToggle.ts
Original file line number Diff line number Diff line change
@@ -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`<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>`
const sun = html`<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19"/>`
return html`
<button type="button" title="Toggle light/dark theme" aria-label="Toggle light/dark theme" @click=${this._onClick}>
<svg viewBox="0 0 24 24" aria-hidden="true">${this._theme === 'dark' ? sun : moon}</svg>
</button>`
}
}
6 changes: 5 additions & 1 deletion src/editor/editorInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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')
Expand Down
33 changes: 24 additions & 9 deletions src/editor/styles/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
6 changes: 3 additions & 3 deletions src/embed/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Expand Down
27 changes: 12 additions & 15 deletions src/embed/theme.ts
Original file line number Diff line number Diff line change
@@ -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 } }))
}
17 changes: 11 additions & 6 deletions tests/e2e/embed-theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
91 changes: 91 additions & 0 deletions tests/e2e/theme-toggle.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
return page.evaluate(() => {
const canvas = document.querySelector<HTMLCanvasElement>('#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)
})
})
Loading
Loading