feat: keep the diagram theme in step with the diagram type and site mode

Instead of leaving the config empty, the editor now manages the theme: after every validation and on site light/dark changes, syncManagedTheme sets it to the diagram type's own mermaid default (the config section's theme, e.g. redux-color for flowcharts, else the global default) or, in dark mode, the redux -dark variant (redux-dark-color) or dark.

Only editor-set themes are replaced: a missing theme, the global default, a section default or any of their dark variants (isManagedTheme). A theme the user chose, such as forest, is never touched.

Detector ids that do not match their config section (flowchart-v2, flowchart-elk, classDiagram, stateDiagram, xychart, railroadAbnf/Ebnf/Peg) are aliased in mermaid.ts; ids without a section (wardley, zenuml) use the global default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sidharth VinodandClaude Fable 5.1 committed 2026-09-11 00:39:12 +05:30
1 parent 18d612f42b
commit f87e6429dd
8 files changed
+262 -40

No files matched your search

+1
View File
@@ -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:<base64 deflate>` (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.
+43 -1
View File
@@ -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();
+58
View File
@@ -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<string, string> = {
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<string, unknown>)[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<string>();
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':
+85 -21
View File
@@ -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();
+36 -14
View File
@@ -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);
+5 -4
View File
@@ -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' });
});
});
+2
View File
@@ -100,6 +100,8 @@ test.describe('Site Loads', () => {
const parsedConfig = JSON.parse(parsedStore.mermaid) as Record<string, unknown>;
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
+32
View File
@@ -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');
});
});