From 3ff12ae097ad4a943ca2e1bb00d7695e554c0278 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 8 Oct 2026 15:14:26 -0500 Subject: [PATCH 1/3] Fix(mermaid): long labels wrap instead of being cut off at 200px (#911) Reported in #911: long flowchart labels were cut off mid-word ("aurora, forwarding disabled P"), while GitHub, GitLab and Zed wrap the same diagrams onto two or three lines. Mermaid lays a label out on one line under an inline 200px max-width and switches it to wrapping only when it measures exactly 200px wide. At a fractional device scale the browser lays that cap out in device pixels and hands back a width just off it: 199.9953px at a 1.333 display scale, 200.0000152px at 120% app zoom on a Retina Mac. The check fails, the label stays on one line, its node is sized to the cap, and the rest of the text is clipped. Mermaid 11.17.2 and 12.1.0 still compare exactly, so an upgrade would not help. While mermaid lays a diagram out, a label measured within half a device pixel of its own max-width is now reported at exactly that width. Laying the cap out in device pixels moves it by far less than that, and a label genuinely that much narrower still fits on one line once mermaid wraps it, so nothing visible changes for it. Every surface that draws a diagram renders through this one helper (Preview, the live preview, the expanded view, PDF export, the share viewer, and the phone apps once they take this core), so they all get it. What it deliberately does not do: change mermaid's wrapping width or turn labels into SVG text. Diagrams look the way they always did; long labels now wrap. Verified in the built app over CDP with isolated stores and the reporter's two diagrams, across twelve scales: forced display scales 1.25, 1.333, 1.5, 1.666 and 1.75, app zoom 90% to 130% on a 2x display, and 90% and 120% on a 1x one. Seven of them hand back a cap width that is not exactly 200px. Before the fix 12 of 21 labels were clipped in both the live preview and Preview (observed at 90%, 120% and 1.333); after it, none are at any of the twelve, and the labels wrap the way Zed draws them. mermaid-render.test.ts feeds the measured widths through a stand-in mermaid and fails without the fix. --- .../app-core/src/lib/mermaid-render.test.ts | 88 +++++++++++++++++++ packages/app-core/src/lib/mermaid-render.ts | 63 ++++++++++++- 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 packages/app-core/src/lib/mermaid-render.test.ts diff --git a/packages/app-core/src/lib/mermaid-render.test.ts b/packages/app-core/src/lib/mermaid-render.test.ts new file mode 100644 index 000000000..1ec60c562 --- /dev/null +++ b/packages/app-core/src/lib/mermaid-render.test.ts @@ -0,0 +1,88 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderMermaidSvg } from './mermaid-render' + +// What the stand-in mermaid measured: a capped label inside a diagram, and the +// same capped box outside one. +const measured = vi.hoisted(() => ({ label: [] as number[], outside: [] as number[] })) + +// Stands in for mermaid's addHtmlSpan: lay a label out under an inline +// max-width inside a foreignObject and read its width the way mermaid does, +// after an await like its fastdom read. +vi.mock('mermaid', () => ({ + default: { + initialize: vi.fn(), + render: vi.fn(async (id: string) => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject') + const label = document.createElement('div') + label.style.maxWidth = '200px' + const outside = document.createElement('div') + outside.style.maxWidth = '200px' + foreignObject.append(label) + svg.append(foreignObject) + document.body.append(svg, outside) + await Promise.resolve() + measured.label.push(label.getBoundingClientRect().width) + measured.outside.push(outside.getBoundingClientRect().width) + svg.remove() + outside.remove() + return { svg: `` } + }) + } +})) + +const unpatched = Element.prototype.getBoundingClientRect +let reportedWidth = 0 +function layoutReports(width: number): void { + reportedWidth = width +} +const measuring = function (): DOMRect { + return new DOMRect(0, 0, reportedWidth, 21) +} + +describe('renderMermaidSvg label measurement (#911)', () => { + beforeEach(() => { + measured.label.length = 0 + measured.outside.length = 0 + Element.prototype.getBoundingClientRect = measuring + }) + + afterEach(() => { + Element.prototype.getBoundingClientRect = unpatched + }) + + // Both widths are what Chromium reported for a label held at its 200px cap. + it.each([ + ['120% app zoom on a 2x display', 200.00001525878906], + ['a 1.333 display scale', 199.9953155517578] + ])('reads a label held at its cap as exactly the cap at %s', async (scale, width) => { + layoutReports(width) + await renderMermaidSvg(`flowchart TB\n a["at ${scale}"]`, 'light') + expect(measured.label).toEqual([200]) + }) + + it('leaves a label well under its cap alone', async () => { + layoutReports(133.33333333333334) + await renderMermaidSvg('flowchart TB\n a["short"]', 'light') + expect(measured.label).toEqual([133.33333333333334]) + }) + + it('touches nothing outside a diagram label, and restores the browser measurement after', async () => { + layoutReports(199.9953155517578) + await renderMermaidSvg('flowchart TB\n a["outside"]', 'light') + expect(measured.outside).toEqual([199.9953155517578]) + expect(Element.prototype.getBoundingClientRect).toBe(measuring) + }) + + it('keeps reporting the cap until the last of two overlapping renders finishes', async () => { + layoutReports(199.9953155517578) + await Promise.all([ + renderMermaidSvg('flowchart TB\n a["first"]', 'light'), + renderMermaidSvg('flowchart TB\n a["second"]', 'dark') + ]) + expect(measured.label).toEqual([200, 200]) + expect(Element.prototype.getBoundingClientRect).toBe(measuring) + }) +}) diff --git a/packages/app-core/src/lib/mermaid-render.ts b/packages/app-core/src/lib/mermaid-render.ts index ce36941ff..b2d725a87 100644 --- a/packages/app-core/src/lib/mermaid-render.ts +++ b/packages/app-core/src/lib/mermaid-render.ts @@ -225,6 +225,67 @@ export function buildMermaidTheme(mode: "light" | "dark"): MermaidThemeConfig { }; } +/* -------------------------------------------------------------------------- */ +/* Label measurement at fractional scales */ +/* -------------------------------------------------------------------------- */ + +/** + * Mermaid decides whether an HTML label wraps by laying it out on one line + * under an inline `max-width: px` and asking whether it came + * back exactly that wide (`bbox.width === width` in its addHtmlSpan, + * unchanged through 12.1). At a fractional device scale the browser lays the + * cap out in device pixels and hands back a width just off it: 200.0000152px + * at 120% app zoom on a 2x display, 199.9953px at a 1.333 display scale. The + * comparison fails, the label never wraps, its node is sized to the cap, and + * every long label is cut off at 200px (#911). + * + * While mermaid lays a diagram out, a label measured within half a device + * pixel of its own cap is reported at the cap. Laying the cap out in device + * pixels moves it by far less than that. A label that really is that much + * narrower than its cap still fits on one line once mermaid switches it to + * wrapping, so nothing visible changes for it. + */ +function reportedLabelWidth(element: Element, width: number): number { + if (!(element instanceof HTMLElement) || !element.style.maxWidth.endsWith("px")) return width; + if (!insideDiagramLabel(element)) return width; + const cap = Number.parseFloat(element.style.maxWidth); + return Math.abs(width - cap) <= 0.5 / (window.devicePixelRatio || 1) ? cap : width; +} + +/** By local name rather than `closest("foreignObject")`: a selector's case + * rules for SVG names differ between engines. */ +function insideDiagramLabel(element: Element): boolean { + for (let node = element.parentElement; node; node = node.parentElement) { + if (node.localName === "foreignObject") return true; + } + return false; +} + +/** Renders overlap (the live preview and Preview both draw), so the patch is + * installed by the first and removed by the last. */ +let capAwareRenders = 0; +let browserGetBoundingClientRect: Element["getBoundingClientRect"] | null = null; + +async function withCapAwareLabelMeasurement(render: () => Promise): Promise { + if (capAwareRenders++ === 0) { + const original = Element.prototype.getBoundingClientRect; + browserGetBoundingClientRect = original; + Element.prototype.getBoundingClientRect = function (this: Element): DOMRect { + const rect = original.call(this); + const width = reportedLabelWidth(this, rect.width); + return width === rect.width ? rect : new DOMRect(rect.x, rect.y, width, rect.height); + }; + } + try { + return await render(); + } finally { + if (--capAwareRenders === 0 && browserGetBoundingClientRect) { + Element.prototype.getBoundingClientRect = browserGetBoundingClientRect; + browserGetBoundingClientRect = null; + } + } +} + /* -------------------------------------------------------------------------- */ /* Rendered SVG, cached */ /* -------------------------------------------------------------------------- */ @@ -282,7 +343,7 @@ export function renderMermaidSvg( // The id must be unique per render: mermaid puts it in the DOM and a // repeat collides with the diagram already on screen. const id = `${idPrefix}-${renderSeq++}`; - const { svg } = await mermaid.render(id, source); + const { svg } = await withCapAwareLabelMeasurement(() => mermaid.render(id, source)); return { ok: true, svg }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; From e3ee8e238c15f444ff7b6757e3fbc9cf8c0cebed Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 8 Oct 2026 15:42:55 -0500 Subject: [PATCH 2/3] Fix(packaging): macOS apps are notarized once, by our afterSign hook 2.63.0's Mac apps were each notarized twice. electron-builder runs its own @electron/notarize step whenever the APPLE_* secrets are set and build.mac.notarize is left unset, and the afterSign hook (scripts/notarize.cjs) then submitted the same app again. On a slow Apple queue that doubled the wait: the 2.63.0 macOS job sat 95 minutes, and the first rebuild failed on the second x64 pass with an empty "unexpected result" from notarytool. build.mac.notarize is now false, which electron-builder 26 treats as an explicit skip (it logs "skipped macOS notarization"), so the hook is the only submission. The hook still demands the credentials when REQUIRE_MAC_SIGNING is set, and @electron/notarize staples the app after it is accepted, so the shipped apps come out the same: notarized and stapled. What it deliberately does not do: change which credentials notarize, or notarize the DMG itself. The release run's macOS log is the proof: one "[notarize] Notarizing" per architecture next to electron-builder's skip. --- apps/desktop/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c5dbcb089..12318ef4a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -211,6 +211,7 @@ "icon": "build/icon.icns", "category": "public.app-category.productivity", "hardenedRuntime": true, + "notarize": false, "gatekeeperAssess": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.inherit.plist", From 82943bafaf9a839b17756c6527fa9565f8e6743a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 8 Oct 2026 15:43:12 -0500 Subject: [PATCH 3/3] Release: align desktop and shared packages at 2.64.0 --- apps/desktop/package.json | 2 +- apps/quicklook/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 20 ++++++++++---------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 12318ef4a..1e1e3d710 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.63.0", + "version": "2.64.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 308a89023..187d49c30 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.63.0", + "version": "2.64.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 93e46c3f6..3f7f26554 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.63.0", + "version": "2.64.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 2969b4ef8..0209d1151 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.63.0", + "version": "2.64.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 0d7f6f57c..daaadcf17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.63.0", + "version": "2.64.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.63.0", + "version": "2.64.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.63.0", + "version": "2.64.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -914,7 +914,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.63.0", + "version": "2.64.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -987,7 +987,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.63.0", + "version": "2.64.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1058,7 +1058,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.63.0", + "version": "2.64.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16360,7 +16360,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.63.0", + "version": "2.64.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16447,14 +16447,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.63.0", + "version": "2.64.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.63.0", + "version": "2.64.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16466,7 +16466,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.63.0" + "version": "2.64.0" } } } diff --git a/package.json b/package.json index 5a9615b8e..0d271b0b1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.63.0", + "version": "2.64.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 56758897b..57273affe 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.63.0", + "version": "2.64.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index b8bf6c3f7..72374d392 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.63.0", + "version": "2.64.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index e4878c206..2d8a0c5f4 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.63.0", + "version": "2.64.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index d57c4b981..012502077 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.63.0", + "version": "2.64.0", "type": "module", "exports": { ".": "./src/index.ts"