refactor(desktop): tighten the diagram theme store and accessible name

Review follow-ups on the Pele swap:

- The theme atom holds the typed DiagramTheme instead of a JSON string.
  The render cache is generic over the theme, so the parse-and-cast
  round-trip goes away, along with the '' sentinel that JSON.parse would
  have thrown on. The cache still keys on JSON.stringify([theme, code]).
- Each distinct `--dt-*` token is probed once: 7 reads per repaint instead
  of 9, since three palette slots share `--dt-muted-foreground`.
- The accessible name reads only the root's own <title>/<desc>. Pele also
  writes nested <title>s (journey actors, click tooltips), so a journey
  diagram used to be named after its first actor.
- KaTeX runs with strict: 'ignore', as katex-memo does, so non-LaTeX input
  doesn't log a warning per label.
- Drops leftovers of the mermaid dependency: the expired mermaid
  min-release-age exclusion, and uuid and stylis from the vendor-util chunk
  group (neither is in the lockfile any more).
This commit is contained in:
kshitijk4poor authored and kshitij committed 2026-11-03 03:41:16 +05:30
1 parent 3f01b90389
commit d42ec58aa0
5 files changed
+49 -45

No files matched your search

+2 -5
View File
@@ -40,9 +40,6 @@ min-release-age-exclude[]=js-yaml
# nanoid 3.3.17 includes fixes for GHSA-2v37-7h3g-55p8. remove when > 2wks old (rel 2026-08-03)
min-release-age-exclude[]=nanoid
# mermaid 11.16.1 includes fixes for 5 GHSAs. remove when > 2wks old (rel 2026-08-04)
min-release-age-exclude[]=mermaid
# dompurify 3.4.17 fixes GHSA-55q2-fjhq-7xh7 follow-ups (low). remove when 3.4.17 is > 2wks old (rel 2026-10-12)
min-release-age-exclude[]=dompurify
@@ -73,6 +70,6 @@ min-release-age-exclude[]=@vitest/*
# eMudhra root distrust per #142533. remove when 44.9.3 is > 2wks old (rel 2026-10-21)
min-release-age-exclude[]=electron
# pele 1.8.0+ adds the math option that keeps $$TeX$$ labels typeset in diagrams. remove when
# 1.13.0 is > 2wks old (rel 2026-10-31)
# pele 1.13.0 is the release the desktop diagram swap was measured and parity-checked on (math
# labels need 1.8.0+). remove when 1.13.0 is > 2wks old (rel 2026-10-31)
min-release-age-exclude[]=pele
@@ -57,6 +57,17 @@ describe('MermaidRenderer', () => {
expect(container.querySelector('img')?.alt).toBe('Mermaid diagram')
})
it('does not name a diagram after a nested <title> (journey actor)', async () => {
const { container } = render(
<MermaidRenderer code={'journey\n title Ship it\n section Build\n Code: 5: Alice, Bob'} />
)
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1))
expect(decodeImage(container.querySelector('img'))).toContain('<title>Alice</title>')
expect(container.querySelector('img')?.alt).not.toContain('Alice')
})
it('typesets $$TeX$$ labels as MathML instead of showing the source', async () => {
// jsdom lays nothing out; give the measured MathML a browser-like size.
const rect = vi
@@ -103,8 +114,8 @@ describe('MermaidRenderer', () => {
const probeReads = computed.mock.calls.filter(([el]) => el instanceof HTMLSpanElement).length
computed.mockRestore()
// One read per palette token for all three diagrams together.
expect(probeReads).toBe(9)
// One read per distinct token for all three diagrams together.
expect(probeReads).toBe(7)
})
it('keeps a label with <br/> and &nbsp; loadable as an image (#133089)', async () => {
@@ -43,19 +43,19 @@ interface DiagramTheme {
}
// Resolved through probes, so color-mix() and oklch() tokens arrive as values
// the isolated SVG document can use. All probes are styled before any is read,
// so the pass costs one style recalc. A token that does not resolve (unset, or
// a broken var() chain in a skin) is left out, so Pele's own default applies
// instead of the probe's inherited color. Serialised: it doubles as the cache
// key.
// the isolated SVG document can use. Each token is probed once and all probes
// are styled before any is read, so the pass costs one style recalc. A token
// that does not resolve (unset, or a broken var() chain in a skin) is left
// out, so Pele's own default applies instead of the probe's inherited color.
const UNRESOLVED = 'rgb(1, 2, 3)'
const TOKENS = [...new Set(PALETTE.map(([, token]) => token))]
function resolveTheme(): string {
function resolveTheme(): DiagramTheme {
const root = getComputedStyle(document.documentElement)
const host = document.createElement('div')
host.style.display = 'none'
const probes = PALETTE.map(([, token]) => {
const probes = TOKENS.map(token => {
const probe = document.createElement('span')
probe.style.color = `var(${token}, ${UNRESOLVED})`
@@ -65,38 +65,34 @@ function resolveTheme(): string {
host.append(...probes)
document.body.append(host)
const vars = PALETTE.flatMap(([pele], i) => {
const color = getComputedStyle(probes[i]).color
const colors = new Map(TOKENS.map((token, i) => [token, getComputedStyle(probes[i]).color]))
host.remove()
const vars = PALETTE.flatMap(([pele, token]) => {
const color = colors.get(token)
return !color || color === UNRESOLVED ? [] : [`${pele}:${color}`]
}).join(';')
host.remove()
const theme: DiagramTheme = {
return {
font: root.getPropertyValue('--dt-font-sans').trim() || 'sans-serif',
fontMono: root.getPropertyValue('--dt-font-mono').trim() || 'monospace',
vars
}
return JSON.stringify(theme)
}
// One resolved theme shared by every mounted diagram, re-resolved once per
// theme repaint (mode flip or skin change) rather than once per diagram. The
// first read mounts the store (nanostores' get() runs onMount), so a diagram
// never renders without a palette; unmounting clears it, so a later mount
// re-resolves instead of drawing with a stale one.
const $diagramTheme = atom('')
// renders with the resolved palette, and a later mount re-resolves rather
// than reusing one from before. The initial value is Pele's own defaults.
const $diagramTheme = atom<DiagramTheme>({ font: 'sans-serif', fontMono: 'monospace', vars: '' })
onMount($diagramTheme, () => {
$diagramTheme.set(resolveTheme())
const stop = onThemeRepaint(() => $diagramTheme.set(resolveTheme()))
return () => {
stop()
$diagramTheme.set('')
}
return onThemeRepaint(() => $diagramTheme.set(resolveTheme()))
})
// Pele's root always carries a style attribute; the palette goes in front of
@@ -138,11 +134,10 @@ const typesetMath: MathResolver = (tex, size) => {
return { height: Math.ceil(height), html, width: Math.ceil(width) }
}
const renderCache = createMermaidRenderCache({
const renderCache = createMermaidRenderCache<DiagramTheme>({
maxEntries: 32,
render: async (code, themeKey) => {
render: async (code, theme) => {
const pele = await loadPele()
const theme = JSON.parse(themeKey) as DiagramTheme
// Links and images off: the diagram is display-only, as under mermaid's
// strict mode (no click handlers, no remote fetches).
@@ -176,15 +171,16 @@ function SourcePreview({ code, muted }: { code: string; muted?: boolean }) {
}
function svgAccessibleText(svg: string): string {
// Pele writes <title>/<desc> only for accTitle/accDescr or a diagram title;
// skip the parse for the common diagram that has neither.
// Pele writes accTitle/accDescr (or a diagram title) as the root's own
// <title>/<desc>; nested ones are tooltips and labels (journey actors, click
// tips), not a name for the whole diagram. Skip the parse when there are none.
if (!svg.includes('<title') && !svg.includes('<desc')) {
return 'Mermaid diagram'
}
const document = new DOMParser().parseFromString(svg, 'image/svg+xml')
const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
const text = [document.querySelector('title')?.textContent, document.querySelector('desc')?.textContent]
const text = [root.querySelector(':scope > title')?.textContent, root.querySelector(':scope > desc')?.textContent]
.map(value => value?.trim())
.filter((value): value is string => Boolean(value))
@@ -1,14 +1,14 @@
// Renders are keyed by (theme, code). `theme` is opaque here: the caller's
// serialised diagram theme, so any palette change misses the cache.
interface MermaidRenderCacheOptions {
// Renders are keyed by (theme, code). `theme` is opaque here: any
// JSON-serialisable diagram theme, so any palette change misses the cache.
interface MermaidRenderCacheOptions<Theme> {
defer?: (signal?: AbortSignal) => Promise<void>
maxEntries: number
render: (code: string, theme: string) => Promise<string>
render: (code: string, theme: Theme) => Promise<string>
yieldToMainThread?: () => Promise<void>
}
interface MermaidRenderCache {
render: (code: string, theme: string, signal?: AbortSignal) => Promise<string>
interface MermaidRenderCache<Theme> {
render: (code: string, theme: Theme, signal?: AbortSignal) => Promise<string>
}
interface PendingRender {
@@ -158,12 +158,12 @@ export function createRetryableLoader<T>(load: () => Promise<T>): () => Promise<
}
}
export function createMermaidRenderCache({
export function createMermaidRenderCache<Theme>({
defer = deferUntilIdle,
maxEntries,
render: renderSvg,
yieldToMainThread = yieldTask
}: MermaidRenderCacheOptions): MermaidRenderCache {
}: MermaidRenderCacheOptions<Theme>): MermaidRenderCache<Theme> {
if (!Number.isInteger(maxEntries) || maxEntries < 1) {
throw new Error('Mermaid render cache must contain at least one entry')
}
+1 -1
View File
@@ -198,7 +198,7 @@ export default defineConfig(({ command }) => ({
// heavy groups for the same boot-path reason.
{
name: 'vendor-util',
test: /node_modules[\\/](lodash-es|es-toolkit|uuid|dayjs|d3-array|d3-color|d3-force|d3-interpolate|d3-time[^\\/]*|dompurify|stylis)[\\/]/
test: /node_modules[\\/](lodash-es|es-toolkit|dayjs|d3-array|d3-color|d3-force|d3-interpolate|d3-time[^\\/]*|dompurify)[\\/]/
},
// One chunk per heavyweight, lazy-only library family.
// @streamdown/code lives WITH shiki because it statically imports