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
This commit is contained in:
xarmian
2026-09-24 00:25:03 -04:00
committed by GitHub
parent 001d2438a9
commit c49ee768c9
2 changed files with 156 additions and 0 deletions
@@ -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<number> {
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);
});
});
@@ -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;