mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
3f01b90389
commit
d42ec58aa0
5 files changed
+49
-45
No files matched your search
@@ -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 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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user