diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index cb04c7f8bd..499cb2078b 100644 --- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue +++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue @@ -5,6 +5,7 @@ @@ -25,6 +26,11 @@ export default { required: true, type: String }, + diagrams: { + type: Array, + required: false, + default: () => [] + }, messageUuid: { required: true, type: String diff --git a/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue b/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue index 039f31c20c..3745eb00a6 100644 --- a/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue +++ b/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue @@ -12,11 +12,21 @@ import { mapState } from 'pinia' import { CODE_BLOCK_ICONS, useMarkdownHelper } from '@/composables/strings/Markdown.js' +import { THEME_VARIABLES, sanitizeDiagram, serializeSvg, svgToPngBlob } from '@/composables/strings/Mermaid.js' import useStreamingWords from '@/composables/strings/StreamingWords.js' import { sanitize } from '@/composables/strings/String.js' import { useProductAssistantStore } from '@/stores/product-assistant.js' +// Sources are compared ignoring whitespace, so a diagram still matches its block if the spacing differs +function normalizeSource (source) { + return source.replace(/\s+/g, ' ').trim() +} + +function diagramButton (label, className, attributes) { + return `` +} + export default { name: 'StreamableContent', props: { @@ -64,12 +74,19 @@ export default { type: Boolean, required: false, default: false + }, + // SVGs rendered by the Expert, matched to mermaid blocks by their source + diagrams: { + type: Array, + required: false, + default: () => [] } }, emits: ['streaming-complete', 'update:modelValue'], setup () { const { stream, text, isStreaming } = useStreamingWords({ - delayMs: 30 + delayMs: 10, + wordsPerTick: 4 }) const { markedInstance } = useMarkdownHelper() @@ -86,6 +103,13 @@ export default { return rawText }, + // Streaming re-renders the content on every tick, so each diagram is sanitized once up front + safeDiagrams () { + return this.diagrams.map(diagram => ({ + source: normalizeSource(diagram.source || ''), + svg: sanitizeDiagram(diagram.svg || '') + })) + }, text () { const text = this.shouldStream ? this.streamedText : this.rawText @@ -104,6 +128,7 @@ export default { isStreaming (isStreaming) { if (!isStreaming) { this.$emit('streaming-complete') + this.renderDiagrams() if (this.modelValue) { this.$emit('update:modelValue', { ...this.modelValue, @@ -117,9 +142,124 @@ export default { if (this.shouldStream) { this.stream(this.rawText) } + this.renderDiagrams() + }, + updated () { + this.renderDiagrams() }, methods: { + // Mermaid blocks stay as plain code until their closing fence has streamed in, then + // are swapped for the SVG the Expert rendered, so earlier diagrams show while later text streams. + renderDiagrams () { + if (!this.richContent) return + const blocks = [...this.$el.querySelectorAll('.ff-code-block--mermaid')] + const ready = this.isStreaming ? this.completedMermaidBlocks() : blocks.length + for (const block of blocks.slice(0, ready)) { + if (block.hasAttribute('data-mermaid-state')) continue + const source = block.querySelector('pre code')?.textContent || '' + const svg = this.findDiagramSvg(source) + if (svg) { + this.showDiagram(block, svg) + } else { + block.setAttribute('data-mermaid-state', 'failed') + } + } + }, + findDiagramSvg (source) { + const key = normalizeSource(source) + return this.safeDiagrams.find(diagram => diagram.source === key)?.svg + }, + // The page theme, so exports look like the diagram on screen + diagramTheme (block) { + const style = getComputedStyle(block.querySelector('.ff-code-block--diagram')) + const theme = {} + for (const name of THEME_VARIABLES) { + const value = style.getPropertyValue(name).trim() + if (value) { + theme[name] = value + } + } + return theme + }, + // The streamed text is cut mid-block, so count the mermaid blocks whose end has arrived. + completedMermaidBlocks () { + const parts = this.streamedText.split('ff-code-block--mermaid').slice(1) + return parts.filter(part => part.includes('')).length + }, + showDiagram (block, svg) { + const diagram = document.createElement('div') + diagram.className = 'ff-code-block--diagram' + diagram.innerHTML = svg + block.appendChild(diagram) + block.querySelector('pre').hidden = true + const actions = document.createElement('span') + actions.className = 'ff-code-block--diagram-actions' + actions.innerHTML = [ + diagramButton('Code', 'ff-code-block--toggle', 'aria-pressed="false"'), + diagramButton('Copy SVG', 'ff-code-block--export', 'data-export="svg"'), + diagramButton('Copy image', 'ff-code-block--export', 'data-export="image"'), + diagramButton('Download PNG', 'ff-code-block--export', 'data-export="png"') + ].join('') + block.querySelector('.ff-code-block--copy').after(actions) + block.setAttribute('data-mermaid-state', 'rendered') + }, + async exportDiagram (btn) { + const block = btn.closest('.ff-code-block') + const source = block.querySelector('pre code')?.textContent || '' + const svg = this.findDiagramSvg(source) + if (!svg) return + const theme = this.diagramTheme(block) + const label = btn.textContent + const flash = text => { + btn.textContent = text + setTimeout(() => { btn.textContent = label }, 2000) + } + try { + switch (btn.dataset.export) { + case 'svg': + await navigator.clipboard.writeText(serializeSvg(svg, theme)) + flash('Copied') + break + case 'image': { + const blob = svgToPngBlob(svg, { theme }) + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) + flash('Copied') + break + } + case 'png': { + const url = URL.createObjectURL(await svgToPngBlob(svg, { theme })) + const a = document.createElement('a') + a.href = url + a.download = 'diagram.png' + a.click() + // Revoking straight after the click can cancel the download in Safari and Firefox + setTimeout(() => URL.revokeObjectURL(url), 40 * 1000) + break + } + } + } catch (_) { + flash('Failed') + } + }, handleClick (event) { + const toggleBtn = event.target.closest('.ff-code-block--toggle') + if (toggleBtn) { + const block = toggleBtn.closest('.ff-code-block') + const showCode = toggleBtn.getAttribute('aria-pressed') !== 'true' + block.querySelector('pre').hidden = !showCode + block.querySelector('.ff-code-block--diagram').hidden = showCode + block.querySelectorAll('.ff-code-block--export').forEach(btn => { btn.hidden = showCode }) + toggleBtn.setAttribute('aria-pressed', String(showCode)) + toggleBtn.classList.toggle('active', showCode) + toggleBtn.blur() + return + } + const exportBtn = event.target.closest('.ff-code-block--export') + if (exportBtn) { + this.exportDiagram(exportBtn) + exportBtn.blur() + return + } const copyBtn = event.target.closest('.ff-code-block--copy') || event.target.closest('.ff-table-block--copy') if (!copyBtn) return @@ -265,6 +405,40 @@ export default { } } + // The diagram takes its colours from these variables, so it follows the theme without being re-rendered + :deep(.ff-code-block--diagram) { + --pele-bg: var(--ff-color-bg-surface); + --pele-fg: var(--ff-color-text); + --pele-muted: var(--ff-color-text-subtle); + --pele-line: var(--ff-color-text-subtle); + --pele-surface: var(--ff-color-bg-surface-raised); + --pele-surface-alt: var(--ff-color-bg-surface-raised); + --pele-border: var(--ff-color-accent); + --pele-accent: var(--ff-color-accent); + + padding: 1rem; + overflow-x: auto; + background: var(--ff-color-bg-surface); + + svg { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; + } + } + + :deep(.ff-code-block--diagram-actions) { + display: inline-flex; + gap: 0.5rem; + margin: 0 auto 0 0.5rem; + + // .ff-btn sets display, which would override the hidden attribute + [hidden] { + display: none; + } + } + :deep(.ff-code-block--header) { display: flex; align-items: center; diff --git a/frontend/src/composables/strings/Markdown.js b/frontend/src/composables/strings/Markdown.js index d467dfb6a0..1badfefd6a 100644 --- a/frontend/src/composables/strings/Markdown.js +++ b/frontend/src/composables/strings/Markdown.js @@ -19,6 +19,48 @@ hljs.registerLanguage('typescript', typescript) hljs.registerLanguage('ts', typescript) hljs.registerLanguage('xml', xml) hljs.registerLanguage('html', xml) +// highlight.js ships no mermaid grammar +hljs.registerLanguage('mermaid', () => ({ + name: 'Mermaid', + keywords: { + keyword: [ + // diagram types, the ones with a dash are matched by their own mode below + 'graph', 'flowchart', 'sequenceDiagram', 'classDiagram', 'stateDiagram', 'erDiagram', 'journey', 'gantt', + 'pie', 'quadrantChart', 'requirementDiagram', 'gitGraph', 'mindmap', 'timeline', 'sankey', 'xychart', + 'block', 'C4Context', 'C4Container', 'C4Component', 'C4Dynamic', 'C4Deployment', 'packet', 'kanban', + 'treemap', 'ishikawa', 'info', + // statements + 'subgraph', 'end', 'direction', 'participant', 'actor', 'autonumber', 'activate', 'deactivate', 'create', + 'destroy', 'box', 'loop', 'alt', 'else', 'opt', 'par', 'critical', 'option', 'break', 'rect', 'note', + 'links', 'link', 'class', 'classDef', 'cssClass', 'style', 'linkStyle', + 'click', 'call', 'href', 'callback', 'namespace', 'state', 'section', 'title', 'accTitle', 'accDescr', + 'dateFormat', 'axisFormat', 'tickInterval', 'excludes', 'includes', 'todayMarker', 'weekday', 'showData', + 'commit', 'branch', 'checkout', 'merge', 'requirement', 'functionalRequirement', 'interfaceRequirement', 'performanceRequirement', + 'physicalRequirement', 'designConstraint', 'verifymethod', 'satisfies', 'traces', 'copies', 'derives', + 'refines', 'verifies', 'columns', 'service', 'junction', + 'Person', 'Person_Ext', 'System', 'System_Ext', 'SystemDb', 'SystemQueue', 'Container', 'ContainerDb', + 'ContainerQueue', 'Component', 'ComponentDb', 'Boundary', 'System_Boundary', 'Container_Boundary', + 'Enterprise_Boundary', 'Deployment_Node', 'Rel', 'BiRel', 'Rel_U', 'Rel_D', 'Rel_L', 'Rel_R', + 'Rel_Back', 'UpdateLayoutConfig', 'UpdateRelStyle', 'UpdateElementStyle' + ], + literal: ['TB', 'TD', 'BT', 'RL', 'LR', 'true', 'false'] + }, + contains: [ + hljs.COMMENT('%%', '$'), + // keywords with a dash, which would otherwise read as a name followed by an arrow + { className: 'keyword', begin: /\b(?:[A-Za-z]+-(?:beta|v2)|[xy]-axis|cherry-pick)\b/ }, + hljs.QUOTE_STRING_MODE, + hljs.NUMBER_MODE, + // entity relationship cardinalities such as ||--o{ and }|..|{, before the |label| mode takes the pipes + { className: 'operator', begin: /[|}][|o](?:--|\.\.)[|o][|{]/ }, + // flowchart, sequence, class and state arrows such as -->, -.->, ==>, --o, <-->, ->>, -x, -), <|--, *--, ..|> + { className: 'operator', begin: /(?:<\||[<*ox])?(?:[-=.~]{2,}(?:\|>|[>ox)]{1,2})?|-(?:>>?|x|\)))(?=[\s\w"[({|:]|$)/ }, + { className: 'meta', begin: /:::\w+|\[\*\]/ }, + // edge labels and node text + { className: 'string', begin: /\|/, end: /\|/ }, + { className: 'string', begin: /\[/, end: /\]/ } + ] +})) // Inline SVGs are intentional here — the copy button lives inside v-html rendered // content where Vue components cannot be used. @@ -33,6 +75,7 @@ export function useMarkdownHelper () { renderer: { code ({ text, lang }) { const language = lang ? lang.split(/\s/)[0].toLowerCase() : '' + const isMermaid = language === 'mermaid' let highlighted if (language && hljs.getLanguage(language)) { highlighted = hljs.highlight(text, { language }).value @@ -40,7 +83,8 @@ export function useMarkdownHelper () { highlighted = hljs.highlightAuto(text).value } const langLabel = language ? `${language}` : '' - return `
${langLabel}
${highlighted.replace(/\n/g, '
')}
` + const blockClass = isMermaid ? 'ff-code-block ff-code-block--mermaid' : 'ff-code-block' + return `
${langLabel}
${highlighted.replace(/\n/g, '
')}
` }, checkbox ({ checked }) { return `${checked ? '☑' : '☐'}` diff --git a/frontend/src/composables/strings/Mermaid.js b/frontend/src/composables/strings/Mermaid.js new file mode 100644 index 0000000000..b25b1f1910 --- /dev/null +++ b/frontend/src/composables/strings/Mermaid.js @@ -0,0 +1,64 @@ +import DOMPurify from 'dompurify' + +// The CSS variables the diagram takes its colours from +export const THEME_VARIABLES = [ + '--pele-bg', + '--pele-fg', + '--pele-muted', + '--pele-line', + '--pele-surface', + '--pele-surface-alt', + '--pele-border', + '--pele-accent' +] + +// The SVG is rendered by the Expert and arrives with the message, so it is untrusted markup. +// Ids are dropped because nothing in the SVG refers to them and they repeat across the diagrams on a page. +export function sanitizeDiagram (svg) { + return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, FORBID_ATTR: ['id'] }) +} + +function parseSvg (svg) { + const doc = new DOMParser().parseFromString(svg, 'image/svg+xml') + return doc.documentElement +} + +// Returns the diagram as standalone SVG markup with explicit pixel dimensions. The theme +// variables are written onto the SVG, as outside the page it no longer inherits them. +export function serializeSvg (svg, theme = {}) { + const el = parseSvg(svg) + const [, , width, height] = (el.getAttribute('viewBox') || '').split(/\s+/).map(Number) + if (width && height) { + el.setAttribute('width', width) + el.setAttribute('height', height) + } + el.style.maxWidth = '' + for (const [name, value] of Object.entries(theme)) { + el.style.setProperty(name, value) + } + el.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + return new XMLSerializer().serializeToString(el) +} + +export async function svgToPngBlob (svg, { scale = 2, theme = {} } = {}) { + const standalone = serializeSvg(svg, theme) + const el = parseSvg(standalone) + const width = Number(el.getAttribute('width')) + const height = Number(el.getAttribute('height')) + const image = new Image() + await new Promise((resolve, reject) => { + image.onload = resolve + image.onerror = reject + image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(standalone) + }) + const canvas = document.createElement('canvas') + canvas.width = Math.ceil(width * scale) + canvas.height = Math.ceil(height * scale) + const ctx = canvas.getContext('2d') + ctx.fillStyle = theme['--pele-bg'] || '#ffffff' + ctx.fillRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(image, 0, 0, canvas.width, canvas.height) + return new Promise((resolve, reject) => { + canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('PNG export failed')), 'image/png') + }) +} diff --git a/frontend/src/composables/strings/StreamingWords.js b/frontend/src/composables/strings/StreamingWords.js index 731fbbcda6..05d0aae239 100644 --- a/frontend/src/composables/strings/StreamingWords.js +++ b/frontend/src/composables/strings/StreamingWords.js @@ -1,6 +1,6 @@ import { nextTick, onBeforeUnmount, ref } from 'vue' -export default function useStreamingWords ({ delayMs = 30, splitRegex = /\s+/, joinWith = ' ' } = {}) { +export default function useStreamingWords ({ delayMs = 30, wordsPerTick = 1 } = {}) { const text = ref('') const isStreaming = ref(false) @@ -20,7 +20,8 @@ export default function useStreamingWords ({ delayMs = 30, splitRegex = /\s+/, j stop() text.value = '' - words = String(message).trim().split(splitRegex).filter(Boolean) + // Each word keeps the whitespace before it, so code indentation survives streaming + words = String(message).trim().match(/\s*\S+/g) || [] index = 0 isStreaming.value = true @@ -34,8 +35,8 @@ export default function useStreamingWords ({ delayMs = 30, splitRegex = /\s+/, j return resolve() } - text.value += (text.value ? joinWith : '') + words[index] - index += 1 + text.value += words.slice(index, index + wordsPerTick).join('') + index += wordsPerTick timer = setTimeout(tick, delayMs) } diff --git a/test/unit/frontend/components/expert/StreamableContent.spec.js b/test/unit/frontend/components/expert/StreamableContent.spec.js new file mode 100644 index 0000000000..9573ad38ae --- /dev/null +++ b/test/unit/frontend/components/expert/StreamableContent.spec.js @@ -0,0 +1,113 @@ +import { createTestingPinia } from '@pinia/testing' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, test, vi } from 'vitest' + +import * as Mermaid from '../../../../../frontend/src/composables/strings/Mermaid.js' + +vi.mock('../../../../../frontend/src/composables/strings/Mermaid.js', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + sanitizeDiagram: vi.fn(original.sanitizeDiagram), + svgToPngBlob: vi.fn(async () => new Blob(['png'], { type: 'image/png' })) + } +}) + +const StreamableContent = (await import('../../../../../frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue')).default + +const source = 'flowchart LR\n A[Start] --> B[End]' +const content = '```mermaid\n' + source + '\n```\n\nDone.' +const diagrams = [{ source, svg: '' }] + +function mountContent ({ shouldStream, diagrams }) { + return mount(StreamableContent, { + props: { string: content, richContent: true, shouldStream, diagrams }, + global: { plugins: [createTestingPinia({ createSpy: vi.fn })] } + }) +} + +describe('StreamableContent mermaid diagrams', () => { + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + vi.unstubAllGlobals() + }) + + test('shows the diagram the Expert rendered', async () => { + const wrapper = mountContent({ shouldStream: false, diagrams }) + await flushPromises() + const block = wrapper.element.querySelector('.ff-code-block--mermaid') + expect(block.getAttribute('data-mermaid-state')).toBe('rendered') + expect(block.querySelector('.ff-code-block--diagram svg')).not.toBeNull() + expect(block.querySelector('pre').hidden).toBe(true) + expect(block.querySelectorAll('.ff-code-block--export')).toHaveLength(3) + }) + + test('switches between the diagram and its code', async () => { + const wrapper = mountContent({ shouldStream: false, diagrams }) + await flushPromises() + const block = wrapper.element.querySelector('.ff-code-block--mermaid') + block.querySelector('.ff-code-block--toggle').click() + expect(block.querySelector('pre').hidden).toBe(false) + expect(block.querySelector('.ff-code-block--diagram').hidden).toBe(true) + block.querySelector('.ff-code-block--toggle').click() + expect(block.querySelector('pre').hidden).toBe(true) + expect(block.querySelector('.ff-code-block--diagram').hidden).toBe(false) + }) + + test('shows the diagram while streaming and keeps the code indentation', async () => { + vi.useFakeTimers() + const wrapper = mountContent({ shouldStream: true, diagrams }) + await vi.advanceTimersByTimeAsync(5000) + await flushPromises() + const block = wrapper.element.querySelector('.ff-code-block--mermaid') + expect(block.getAttribute('data-mermaid-state')).toBe('rendered') + expect(block.querySelector('pre code').textContent).toBe(source) + }) + + test('sanitizes each diagram once while streaming', async () => { + vi.useFakeTimers() + mountContent({ shouldStream: true, diagrams }) + await vi.advanceTimersByTimeAsync(5000) + await flushPromises() + expect(Mermaid.sanitizeDiagram).toHaveBeenCalledTimes(1) + }) + + test('copies the SVG with the theme colours of the page', async () => { + const writeText = vi.fn() + vi.stubGlobal('navigator', { clipboard: { writeText } }) + const wrapper = mountContent({ shouldStream: false, diagrams }) + await flushPromises() + const block = wrapper.element.querySelector('.ff-code-block--mermaid') + block.querySelector('.ff-code-block--diagram').style.setProperty('--pele-bg', '#101010') + block.querySelector('[data-export="svg"]').click() + await flushPromises() + expect(writeText).toHaveBeenCalledTimes(1) + expect(writeText.mock.calls[0][0]).toContain('--pele-bg: #101010') + }) + + test('revokes the PNG download URL only after the download has started', async () => { + vi.useFakeTimers() + const createObjectURL = vi.fn(() => 'blob:diagram') + const revokeObjectURL = vi.fn() + vi.stubGlobal('URL', { ...URL, createObjectURL, revokeObjectURL }) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const wrapper = mountContent({ shouldStream: false, diagrams }) + await flushPromises() + wrapper.element.querySelector('[data-export="png"]').click() + await flushPromises() + expect(click).toHaveBeenCalledTimes(1) + expect(revokeObjectURL).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(40 * 1000) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:diagram') + click.mockRestore() + }) + + test('keeps the source when there is no diagram for the block', async () => { + const wrapper = mountContent({ shouldStream: false, diagrams: [] }) + await flushPromises() + const block = wrapper.element.querySelector('.ff-code-block--mermaid') + expect(block.getAttribute('data-mermaid-state')).toBe('failed') + expect(block.querySelector('.ff-code-block--toggle')).toBeNull() + }) +}) diff --git a/test/unit/frontend/composables/Markdown.spec.js b/test/unit/frontend/composables/Markdown.spec.js new file mode 100644 index 0000000000..f547bd65dc --- /dev/null +++ b/test/unit/frontend/composables/Markdown.spec.js @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest' + +import { useMarkdownHelper } from '../../../../frontend/src/composables/strings/Markdown.js' + +describe('useMarkdownHelper', () => { + const { markedInstance } = useMarkdownHelper() + + test('marks mermaid fences so they can be rendered as diagrams', () => { + const html = markedInstance.parse('```mermaid\nflowchart LR\n A[Inject] --> B[Debug]\n```') + expect(html).toContain('ff-code-block ff-code-block--mermaid') + expect(html).toContain('ff-code-block--copy') + expect(html).toContain('hljs-keyword">flowchart') + expect(html).toContain('hljs-operator">-->') + }) + + test('does not mark other fences as mermaid', () => { + const html = markedInstance.parse('```js\nconst a = 1\n```') + expect(html).not.toContain('ff-code-block--mermaid') + }) +}) diff --git a/test/unit/frontend/composables/Mermaid.spec.js b/test/unit/frontend/composables/Mermaid.spec.js new file mode 100644 index 0000000000..f8a316e20b --- /dev/null +++ b/test/unit/frontend/composables/Mermaid.spec.js @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest' + +import { sanitizeDiagram, serializeSvg } from '../../../../frontend/src/composables/strings/Mermaid.js' + +describe('Mermaid', () => { + describe('sanitizeDiagram', () => { + test('removes scripts and event handlers', () => { + const svg = sanitizeDiagram('') + expect(svg).not.toContain('script') + expect(svg).not.toContain('onclick') + expect(svg).toContain('') + }) + + test('removes ids so they do not repeat across diagrams', () => { + const svg = sanitizeDiagram('') + expect(svg).not.toContain('id=') + expect(svg).toContain('class="node"') + }) + }) + + describe('serializeSvg', () => { + test('sets the pixel size from the viewBox', () => { + const svg = serializeSvg('') + expect(svg).toContain('width="120"') + expect(svg).toContain('height="80"') + expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"') + expect(svg).not.toContain('max-width') + }) + + test('writes the theme variables onto the SVG', () => { + const svg = serializeSvg('', { '--pele-bg': '#101010', '--pele-fg': 'rgb(255, 255, 255)' }) + expect(svg).toContain('--pele-bg: #101010') + expect(svg).toContain('--pele-fg: rgb(255, 255, 255)') + }) + }) +})