From c49ee768c98f9f8628ffad4fcee3c70e625b6fda Mon Sep 17 00:00:00 2001 From: xarmian Date: Thu, 24 Sep 2026 00:25:03 -0400 Subject: [PATCH] fix(BUG-3112): a mermaid diagram rendered in dark mode prints light (#1489) * fix(BUG-3112): a mermaid diagram rendered in dark mode prints light Printing always uses the light palette (app.css pins it for every theme), but mermaid bakes its palette into the SVG at render time, so a diagram drawn in dark mode printed dark on white. Printing an item is browser-native and beforeprint is synchronous, so a re-render cannot land in the printout; the flip is CSS, applied at print paint. Each diagram now records the theme it was rendered on (data-mermaid-theme), and an @media print rule applies filter: invert(1) hue-rotate(180deg) to dark-rendered diagrams only. It is an approximation, ruled on the trail: mermaid's dark palette inverted, with author classDef colours keeping roughly their hue and flipping lightness. The exact alternative (a light twin rendered per diagram) was declined as doubling mermaid work for every dark-mode reader. e2e (print emulation, painted pixel inside a node): dark mode is dark on screen and light in print; light mode stays light in print and is not inverted. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX * test(BUG-3112): sample the node's shape fill, not the g.node box that holds the label (codex round 1) Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX --- .../bug-3112-mermaid-print-dark-mode.spec.ts | 126 ++++++++++++++++++ web/src/lib/components/editor/Editor.svelte | 30 +++++ 2 files changed, 156 insertions(+) create mode 100644 web/e2e/bug-3112-mermaid-print-dark-mode.spec.ts diff --git a/web/e2e/bug-3112-mermaid-print-dark-mode.spec.ts b/web/e2e/bug-3112-mermaid-print-dark-mode.spec.ts new file mode 100644 index 000000000..9bc3c3cb8 --- /dev/null +++ b/web/e2e/bug-3112-mermaid-print-dark-mode.spec.ts @@ -0,0 +1,126 @@ +import { test, expect } from './fixtures'; +import type { Page } from '@playwright/test'; +import { browserLogin, EDITOR_SELECTOR, SYNCED_BADGE_SELECTOR } from './lib/collab-helpers'; + +/** + * BUG-3112: printing from dark mode put dark-palette mermaid diagrams on white + * paper. + * + * The palette is baked into the SVG when the diagram renders, and print is + * browser-native, so the fix is a print-only CSS flip for diagrams rendered + * dark (Editor.svelte, `.mermaid-diagram[data-mermaid-theme='dark']`). What is + * asserted is the PAINTED colour inside a node, read from a screenshot taken + * under print emulation, because the filter changes paint and nothing in the + * DOM or in computed fills. + * + * Three legs, so the pixel read is shown able to go both ways (CONVE-34): + * - dark mode, printed: the node interior is light (the fix); + * - dark mode, on screen: the SAME point is dark (the counterfactual: without + * the print rule, this is what printed); + * - light mode, printed: still light, and the diagram is not tagged dark (an + * unscoped filter would invert a light diagram into a dark one). + */ + +const DIAGRAM = '.mermaid-wrapper .mermaid-diagram'; +const SOURCE = '```mermaid\ngraph TD; Alpha-->Bravo\n```\n'; + +async function openDoc( + page: Page, + fixture: import('./fixtures').SuiteFixture, + request: import('@playwright/test').APIRequestContext, + theme: 'dark' | 'light', +) { + await page.addInitScript((t) => { + try { + localStorage.setItem('pad-theme', t); + } catch { + /* storage blocked: the attribute check below fails loudly instead */ + } + }, theme); + const ws = fixture.workspaceSlug; + const resp = await request.post(`/api/v1/workspaces/${ws}/collections/docs/items`, { + headers: { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' }, + data: { title: `BUG-3112 ${theme} ${Date.now()}`, fields: '{}', content: `Diagram:\n\n${SOURCE}\nAfter.\n` }, + }); + expect(resp.ok()).toBe(true); + const { slug } = (await resp.json()) as { slug: string }; + await browserLogin(page); + await page.goto(`/${fixture.adminUsername}/${ws}/docs/${slug}`); + await expect(page.locator(EDITOR_SELECTOR)).toBeVisible(); + await expect(page.locator(SYNCED_BADGE_SELECTOR)).toBeVisible(); + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); +} + +/** + * Relative luminance (0 black .. 1 white) of the painted pixel inside the first + * node's FILL. + * + * Measured on the node's SHAPE, not on `g.node`, whose box also holds the label + * (codex round 1): the point is a few px inside the shape's left edge, past the + * stroke, at mid-height, and the shape must be wide enough that the centred + * label cannot reach it. The screen leg (dark fill, luminance < 0.3) is what + * shows the point is fill rather than a light label on a dark node. + */ +async function nodeInteriorLuminance(page: Page): Promise { + const shape = page + .locator(`${EDITOR_SELECTOR} ${DIAGRAM} svg g.node`) + .first() + .locator(':scope > rect, :scope > path, :scope > polygon, :scope > circle, :scope > g > rect, :scope > g > path') + .first(); + await shape.scrollIntoViewIfNeeded(); + const box = await shape.boundingBox(); + expect(box, 'the node shape has a box').not.toBeNull(); + expect(box!.width, 'the shape is wide enough to sample clear of its label').toBeGreaterThan(40); + const x = Math.round(box!.x + 5); + const y = Math.round(box!.y + box!.height / 2); + const png = await page.screenshot({ clip: { x, y, width: 1, height: 1 } }); + return page.evaluate(async (b64) => { + const img = new Image(); + img.src = `data:image/png;base64,${b64}`; + await img.decode(); + const c = document.createElement('canvas'); + c.width = img.width; + c.height = img.height; + const ctx = c.getContext('2d')!; + ctx.drawImage(img, 0, 0); + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + const lin = (v: number) => { + const s = v / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + }, png.toString('base64')); +} + +test.describe('BUG-3112: a diagram rendered in dark mode prints light', () => { + test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'print emulation; one engine is enough'); + }); + + test('dark mode: dark on screen, light in print', async ({ page, fixture, request }) => { + await openDoc(page, fixture, request, 'dark'); + const diagram = page.locator(`${EDITOR_SELECTOR} ${DIAGRAM}`).first(); + await expect(diagram.locator('svg')).toBeVisible({ timeout: 15_000 }); + await expect(diagram).toHaveAttribute('data-mermaid-theme', 'dark'); + + // COUNTERFACTUAL: on screen the node is dark, so this point would have + // printed dark without the print rule. + const screen = await nodeInteriorLuminance(page); + expect(screen, `screen luminance ${screen}`).toBeLessThan(0.3); + + await page.emulateMedia({ media: 'print' }); + const printed = await nodeInteriorLuminance(page); + expect(printed, `print luminance ${printed}`).toBeGreaterThan(0.5); + }); + + test('light mode: light in print, and not inverted', async ({ page, fixture, request }) => { + await openDoc(page, fixture, request, 'light'); + const diagram = page.locator(`${EDITOR_SELECTOR} ${DIAGRAM}`).first(); + await expect(diagram.locator('svg')).toBeVisible({ timeout: 15_000 }); + await expect(diagram).toHaveAttribute('data-mermaid-theme', 'default'); + + await page.emulateMedia({ media: 'print' }); + const printed = await nodeInteriorLuminance(page); + expect(printed, `print luminance ${printed}`).toBeGreaterThan(0.5); + }); +}); diff --git a/web/src/lib/components/editor/Editor.svelte b/web/src/lib/components/editor/Editor.svelte index 26c4e5574..24d99ea79 100644 --- a/web/src/lib/components/editor/Editor.svelte +++ b/web/src/lib/components/editor/Editor.svelte @@ -149,15 +149,22 @@ renderQueue = renderQueue.then(async () => { try { const m = await initMermaid(); + // The palette THIS render bakes in, read after initMermaid set it + // and before anything else can run in the serialized queue. + const renderedTheme = appliedMermaidTheme; const id = `mmd-${Math.random().toString(36).slice(2, 10)}`; const { svg } = await m.default.render(id, source); target.innerHTML = svg; + // BUG-3112: the print rule below keys on this, because print + // cannot re-render (see `.mermaid-diagram[data-mermaid-theme]`). + if (renderedTheme) target.dataset.mermaidTheme = renderedTheme; // A successful render means the source is now valid — drop any // error styling left over from a prior failed render. target.classList.remove('mermaid-error'); } catch { target.textContent = '⚠ Invalid Mermaid syntax'; target.classList.add('mermaid-error'); + delete target.dataset.mermaidTheme; } }); } @@ -170,6 +177,7 @@ renderQueue = renderQueue.then(() => { target.textContent = ''; target.classList.remove('mermaid-error'); + delete target.dataset.mermaidTheme; }); } @@ -1892,6 +1900,28 @@ max-width: 100%; height: auto; } + /* + BUG-3112: printing always uses the light palette (app.css pins it for + every theme), but a diagram's palette is baked into its SVG when it is + rendered, so one drawn in dark mode would print dark-on-white. Print is + browser-native (Ctrl+P, no app action) and `beforeprint` is synchronous, + so an async re-render cannot land in the printout; the flip has to be + CSS, which applies at print paint. + + AN APPROXIMATION, deliberately (lead ruling on the BUG-3112 trail): this + is mermaid's DARK palette inverted, not its light one, and author + classDef / style colours are inverted too. hue-rotate(180deg) undoes the + hue half of the inversion, so a colour keeps roughly its hue with its + lightness flipped, which reads correctly on paper. The exact alternative, + a light twin rendered per diagram in dark mode, was declined as doubling + mermaid work for every dark-mode reader. Transparent areas of the SVG + stay transparent, so the wrapper's (print-light) background shows through. + */ + @media print { + .editor-content :global(.mermaid-diagram[data-mermaid-theme='dark'] svg) { + filter: invert(1) hue-rotate(180deg); + } + } .editor-content :global(.mermaid-error) { color: var(--accent-orange); font-size: 0.85em;