diff --git a/CLAUDE.md b/CLAUDE.md index 045235be..1bca6f0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ SvelteKit 2 + Svelte 5 (runes) SPA using `@sveltejs/adapter-static`. **`docs/` i - `inputState` — a `$state` rune holding `State` (code, mermaid config JSON, pan/zoom, etc.), initialized from localStorage key `codeStore`. **All writes must go through the exported update functions** (`updateCode`, `updateCodeStore`, `updateConfig`, `replaceInputState`, ...), which call `persistAndProcess()`: persist to localStorage → async re-validate via `mermaid.parse` → publish to `validatedState`. Reads inside update functions are wrapped in `untrack` so calling effects don't depend on the whole input state. - `validatedState` — read-only validated snapshot (adds `error`, `errorMarkers`, `serialized`, `diagramType`). Internal reads should use this, but it is never persisted or shared externally. +- The diagram `theme` in the config is editor-managed: after every validation and on site light/dark changes, `syncManagedTheme` sets it to the diagram type's mermaid default (its config section's `theme`, e.g. `redux-color` for flowcharts) or the redux `-dark` variant / `dark` in dark mode. A theme outside that family (`isManagedTheme` in `mermaid.ts`) is the user's choice and is never touched. The default config is therefore `{}` until the first validation fills the theme in. - The serialized state is mirrored into the URL hash (debounced) — the URL **is** the sharing mechanism. `src/lib/util/serde.ts` encodes state as `pako:` (legacy plain-base64 supported). `mermaid.ink` PNG/SVG links, Kroki, and Mermaid Chart URLs are derived from it in `urls`. - App startup goes through `initHandler()` in `src/lib/util/util.ts`: migrations → load state from URL hash → optional gist/URL loading (`fileLoaders/`) → start URL-hash subscription → analytics. diff --git a/src/lib/util/mermaid.test.ts b/src/lib/util/mermaid.test.ts index 7e8e4c73..98d1b8ee 100644 --- a/src/lib/util/mermaid.test.ts +++ b/src/lib/util/mermaid.test.ts @@ -1,5 +1,47 @@ import { describe, expect, it } from 'vitest'; -import { getSampleDiagrams } from './mermaid'; +import { darkVariantOf, getDefaultTheme, getSampleDiagrams, isManagedTheme } from './mermaid'; + +describe('getDefaultTheme', () => { + it.each([ + ['flowchart-v2', 'redux-color'], + ['flowchart-elk', 'redux-color'], + ['classDiagram', 'redux-color'], + ['stateDiagram', 'redux-color'], + ['sequence', 'redux-color'], + ['pie', 'default'], + ['xychart', 'default'], + ['railroadAbnf', 'default'], + ['wardley', 'default'] + ])('resolves %s to its config section default (%s)', (diagramType, theme) => { + expect(getDefaultTheme(diagramType)).toBe(theme); + }); +}); + +describe('darkVariantOf', () => { + it.each([ + ['redux-color', 'redux-dark-color'], + ['redux', 'redux-dark'], + ['redux-dark-color', 'redux-dark-color'], + ['default', 'dark'], + ['forest', 'dark'] + ])('%s → %s', (theme, dark) => { + expect(darkVariantOf(theme)).toBe(dark); + }); +}); + +describe('isManagedTheme', () => { + it('treats a missing theme and every derivable default as editor-managed', () => { + for (const theme of [undefined, 'default', 'dark', 'redux-color', 'redux-dark-color']) { + expect(isManagedTheme(theme), String(theme)).toBe(true); + } + }); + + it('treats any other theme as the user’s choice', () => { + for (const theme of ['forest', 'neutral', 'neo', 'base', 'redux', 42]) { + expect(isManagedTheme(theme), String(theme)).toBe(false); + } + }); +}); describe('getSampleDiagrams', () => { const samples = getSampleDiagrams(); diff --git a/src/lib/util/mermaid.ts b/src/lib/util/mermaid.ts index 3e626035..e9f58e33 100644 --- a/src/lib/util/mermaid.ts +++ b/src/lib/util/mermaid.ts @@ -29,6 +29,64 @@ export const parse = async (code: string) => { */ export const defaultMermaidConfig = mermaid.mermaidAPI.defaultConfig ?? {}; +// Detector ids (what `mermaid.parse` reports as diagramType) whose config +// section is named differently. Every other id shares its section's name, and +// ids without a section at all (wardley, zenuml) use the global defaults. +const CONFIG_SECTION_ALIASES: Record = { + classDiagram: 'class', + 'flowchart-elk': 'flowchart', + 'flowchart-v2': 'flowchart', + railroadAbnf: 'railroad', + railroadEbnf: 'railroad', + railroadPeg: 'railroad', + stateDiagram: 'state', + xychart: 'xyChart' +}; + +const themeOfSection = (section: unknown): string | undefined => { + if (section && typeof section === 'object' && 'theme' in section) { + const { theme } = section as { theme?: unknown }; + if (typeof theme === 'string') { + return theme; + } + } + return undefined; +}; + +const globalDefaultTheme: string = defaultMermaidConfig.theme ?? 'default'; + +/** Mermaid's own default theme for a diagram type: its config section's, else the global one. */ +export const getDefaultTheme = (diagramType: string): string => { + const key = CONFIG_SECTION_ALIASES[diagramType] ?? diagramType; + const section = (defaultMermaidConfig as Record)[key]; + return themeOfSection(section) ?? globalDefaultTheme; +}; + +/** Dark counterpart of a default theme: redux themes have -dark variants, anything else uses `dark`. */ +export const darkVariantOf = (theme: string): string => { + if (theme.includes('dark')) { + return theme; + } + return theme.startsWith('redux') ? theme.replace('redux', 'redux-dark') : 'dark'; +}; + +// Every theme the editor may set on its own: the global default, each config +// section's default and their dark variants. Anything else is the user's choice. +const managedThemes = new Set(); +for (const theme of [ + globalDefaultTheme, + ...Object.values(defaultMermaidConfig).map(themeOfSection) +]) { + if (theme) { + managedThemes.add(theme); + managedThemes.add(darkVariantOf(theme)); + } +} + +/** Whether the editor may replace this theme (a missing theme counts as managed). */ +export const isManagedTheme = (theme: unknown): boolean => + theme === undefined || (typeof theme === 'string' && managedThemes.has(theme)); + export const standardizeDiagramType = (diagramType: string) => { switch (diagramType) { case 'class': diff --git a/src/lib/util/state.svelte.test.ts b/src/lib/util/state.svelte.test.ts index 16bab07e..1b25e142 100644 --- a/src/lib/util/state.svelte.test.ts +++ b/src/lib/util/state.svelte.test.ts @@ -1,6 +1,6 @@ import type { State } from '$lib/types'; import { flushSync } from 'svelte'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { clearDefaultThemeConfig, defaultState, @@ -11,6 +11,7 @@ import { updateCode, updateCodeStore, updateConfig, + validatedState, verifyState } from './state.svelte'; @@ -94,44 +95,107 @@ describe('default config', () => { }); }); -describe('toggleDarkTheme', () => { - it('sets the dark theme when the config has no theme', () => { +// Validation (mermaid.parse) is asynchronous; these helpers wait for it. +const themeOf = () => (JSON.parse(inputState.mermaid) as { theme?: string }).theme; +const waitForTheme = (theme: string | undefined) => + vi.waitFor(() => expect(themeOf()).toBe(theme), { timeout: 10_000 }); +const settled = () => + vi.waitFor( + () => { + expect(validatedState.current.code).toBe(inputState.code); + expect(validatedState.current.mermaid).toBe(inputState.mermaid); + }, + { timeout: 10_000 } + ); + +const flowchart = 'graph TD\n A[Managed] --> B[Theme]'; +const pie = 'pie\n "A": 1\n "B": 2'; +const slow = { timeout: 15_000 }; + +describe('managed theme', () => { + it('derives the diagram type default once the code validates', slow, async () => { + toggleDarkTheme(false); updateConfig('{}'); + updateCode(flowchart); + await waitForTheme('redux-color'); + expect(readStoredState().mermaid).toBe('{\n "theme": "redux-color"\n}'); + }); + + it('follows the diagram type when the code changes', slow, async () => { + toggleDarkTheme(false); + updateConfig('{}'); + updateCode(flowchart); + await waitForTheme('redux-color'); + updateCode(pie); + await waitForTheme('default'); + }); + + it('uses the redux dark variant in dark mode and switches back', slow, async () => { + updateConfig('{}'); + updateCode(flowchart); toggleDarkTheme(true); - expect(JSON.parse(inputState.mermaid)).toEqual({ theme: 'dark' }); - }); - - it('removes the theme again in light mode instead of pinning "default"', () => { - updateConfig('{\n "theme": "dark"\n}'); + await waitForTheme('redux-dark-color'); toggleDarkTheme(false); - expect(inputState.mermaid).toBe('{}'); - expect(readStoredState().mermaid).toBe('{}'); + await waitForTheme('redux-color'); }); - it('drops a pinned "default" theme in light mode', () => { + it('falls back to the dark theme for diagrams without a redux default', slow, async () => { + updateConfig('{}'); + updateCode(pie); + toggleDarkTheme(true); + await waitForTheme('dark'); + toggleDarkTheme(false); + await waitForTheme('default'); + }); + + it('replaces the legacy pinned "default" theme', slow, async () => { + toggleDarkTheme(false); + updateCode(flowchart); updateConfig('{\n "theme": "default"\n}'); - toggleDarkTheme(false); - expect(inputState.mermaid).toBe('{}'); + await waitForTheme('redux-color'); }); - it('leaves a user-chosen theme alone in both modes', () => { + it('leaves a user-chosen theme alone', slow, async () => { + updateCode(flowchart); updateConfig('{\n "theme": "forest"\n}'); toggleDarkTheme(true); - expect(JSON.parse(inputState.mermaid)).toEqual({ theme: 'forest' }); + await settled(); + expect(themeOf()).toBe('forest'); + updateCode(pie); + await settled(); + expect(themeOf()).toBe('forest'); toggleDarkTheme(false); - expect(JSON.parse(inputState.mermaid)).toEqual({ theme: 'forest' }); + await settled(); + expect(themeOf()).toBe('forest'); }); - it('keeps the other config keys when changing the theme', () => { - updateConfig('{\n "theme": "default",\n "look": "neo"\n}'); - toggleDarkTheme(true); - expect(JSON.parse(inputState.mermaid)).toEqual({ look: 'neo', theme: 'dark' }); + it('keeps the other config keys', slow, async () => { + toggleDarkTheme(false); + updateCode(flowchart); + updateConfig('{\n "look": "classic"\n}'); + await waitForTheme('redux-color'); + expect(JSON.parse(inputState.mermaid)).toEqual({ look: 'classic', theme: 'redux-color' }); + }); + + it('does nothing while the config is not valid JSON', slow, async () => { + updateCode(flowchart); + updateConfig('{ "theme": '); + expect(() => toggleDarkTheme(true)).not.toThrow(); + await settled(); + expect(inputState.mermaid).toBe('{ "theme": '); toggleDarkTheme(false); - expect(JSON.parse(inputState.mermaid)).toEqual({ look: 'neo' }); }); }); describe('clearDefaultThemeConfig migration', () => { + // Code that does not parse has no diagram type, which keeps the managed + // theme sync out of the way so the migration is observed on its own. + beforeEach(async () => { + toggleDarkTheme(false); + updateCode('not a diagram'); + await settled(); + }); + it('clears a config that only pins the "default" theme', () => { updateConfig('{\n "theme": "default"\n}'); clearDefaultThemeConfig(); diff --git a/src/lib/util/state.svelte.ts b/src/lib/util/state.svelte.ts index 72324225..946ccc71 100644 --- a/src/lib/util/state.svelte.ts +++ b/src/lib/util/state.svelte.ts @@ -10,7 +10,7 @@ import { findMostRelevantLineNumber, replaceLineNumberInErrorMessage } from './errorHandling'; -import { parse } from './mermaid'; +import { darkVariantOf, getDefaultTheme, isManagedTheme, parse } from './mermaid'; import { readJSON, writeJSON } from './persist.svelte'; import { findUnsafeConfigPaths, stripConfigPaths } from './sanitize'; import { deserializeState, pakoSerde, serializeState } from './serde'; @@ -125,6 +125,7 @@ const persistAndProcess = (): void => { void processState(snapshot).then((processed) => { validatedCurrent = processed; updateHash?.(processed.serialized); + syncManagedTheme(processed.diagramType); }); }; @@ -277,25 +278,46 @@ export const updateConfig = (config: string): void => { updateCodeStore({ mermaid: config }); }; -// The site's light/dark mode only manages a theme it set itself: none, `dark`, -// or the `default` older versions seeded. Anything else is the user's choice. -// Light mode removes the theme instead of pinning `default`, so mermaid's own -// per-diagram defaults apply. -export const toggleDarkTheme = (dark: boolean): void => { - update((state) => { - const config = JSON.parse(state.mermaid) as MermaidConfig; - if (config.theme && !['dark', 'default'].includes(config.theme)) { +let siteDark = false; + +// The editor manages the diagram theme unless the user picked one of their +// own. A missing theme, mermaid's global default, a config section's default +// or any of their dark variants (see isManagedTheme) is replaced by the +// current diagram type's default, or its dark counterpart while the site is +// dark. Runs after every validation, since the diagram type may have changed, +// and whenever the site mode changes. Converges after one update: the next +// validation finds the theme already in place. Reads are untracked so an +// effect calling toggleDarkTheme does not subscribe to the input state. +const syncManagedTheme = (diagramType: string | undefined): void => { + if (!diagramType) { + return; + } + untrack(() => { + let config: MermaidConfig; + try { + config = JSON.parse(input.mermaid) as MermaidConfig; + } catch { return; } - if (dark) { - config.theme = 'dark'; - } else { - delete config.theme; + if (!isManagedTheme(config.theme)) { + return; } - state.mermaid = formatJSON(config); + const defaultTheme = getDefaultTheme(diagramType); + const theme = siteDark ? darkVariantOf(defaultTheme) : defaultTheme; + if (config.theme === theme) { + return; + } + update((state) => { + state.mermaid = formatJSON({ ...config, theme }); + }); }); }; +export const toggleDarkTheme = (dark: boolean): void => { + siteDark = dark; + syncManagedTheme(untrack(() => validatedCurrent.diagramType)); +}; + const isOnlyDefaultTheme = (config: string): boolean => { try { const parsed: unknown = JSON.parse(config); diff --git a/tests/configMigration.spec.ts b/tests/configMigration.spec.ts index 866d05af..7442f35b 100644 --- a/tests/configMigration.spec.ts +++ b/tests/configMigration.spec.ts @@ -43,16 +43,17 @@ const migrationVersion = (page: Page) => ); test.describe('Default theme config migration', () => { - test('clears a legacy config that only pins the default theme', async ({ page }) => { + test('replaces a legacy default-theme config with the diagram default', async ({ page }) => { await page.emulateMedia({ colorScheme: 'light' }); await seedLegacyInstall(page); await openEditor(page); await expect(page.locator('#view')).toContainText('Legacy'); await expect.poll(() => migrationVersion(page)).toBe(1); - await expect.poll(() => storedConfig(page)).toBe('{}'); + // A flowchart: mermaid 12's own default for it is redux-color. + await expect.poll(() => storedConfig(page)).toBe('{\n "theme": "redux-color"\n}'); }); - test('switches the cleared config to the dark theme when the site is dark', async ({ page }) => { + test('uses the redux dark variant when the site is dark', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); await seedLegacyInstall(page); await openEditor(page); @@ -60,6 +61,6 @@ test.describe('Default theme config migration', () => { await expect.poll(() => migrationVersion(page)).toBe(1); await expect .poll(async () => JSON.parse((await storedConfig(page)) ?? '{}') as unknown) - .toEqual({ theme: 'dark' }); + .toEqual({ theme: 'redux-dark-color' }); }); }); diff --git a/tests/loadSite.spec.ts b/tests/loadSite.spec.ts index f94b03f5..dce4543d 100644 --- a/tests/loadSite.spec.ts +++ b/tests/loadSite.spec.ts @@ -100,6 +100,8 @@ test.describe('Site Loads', () => { const parsedConfig = JSON.parse(parsedStore.mermaid) as Record; expect(parsedConfig).toEqual({ someOtherSetting: 'Test value', + // The editor fills in the diagram type's default theme (a flowchart here). + theme: 'redux-color', themeVariables: {} }); // should scrub unsafe securityLevel but keep other settings diff --git a/tests/managedTheme.spec.ts b/tests/managedTheme.spec.ts new file mode 100644 index 00000000..6222cad2 --- /dev/null +++ b/tests/managedTheme.spec.ts @@ -0,0 +1,32 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from './test'; + +const storedTheme = (page: Page) => + page.evaluate(() => { + const { mermaid } = JSON.parse(localStorage.getItem('codeStore') ?? '{}') as { + mermaid?: string; + }; + return (JSON.parse(mermaid ?? '{}') as { theme?: string }).theme; + }); + +test.describe('Managed theme', () => { + test.beforeEach(async ({ editPage }) => { + await editPage.toggleSampleDiagrams(); + }); + + test('follows the diagram type', async ({ editPage, page }) => { + await editPage.loadSampleDiagram('Pie'); + await expect.poll(() => storedTheme(page)).toBe('default'); + await editPage.loadSampleDiagram('Flowchart'); + await expect.poll(() => storedTheme(page)).toBe('redux-color'); + }); + + test('switches to the dark variant with the site theme', async ({ editPage, page }) => { + await editPage.loadSampleDiagram('Flowchart'); + await expect.poll(() => storedTheme(page)).toBe('redux-color'); + await editPage.toggleTheme(); + await expect.poll(() => storedTheme(page)).toBe('redux-dark-color'); + await editPage.toggleTheme(); + await expect.poll(() => storedTheme(page)).toBe('redux-color'); + }); +});