From dc3cef5863531f760919d182ea6afeb5bdf5b3b2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 16:43:39 -0500 Subject: [PATCH 01/10] Perf(harness): the markdown stack belongs to the editor, so guard boot instead ZEN_PERF_ENFORCE=1 failed every run of perf:desktop-runtime on the deferred-chunk check, because vendor-markdown and vendor-highlight load in the normal flow. That was already true of 2.71.0; ENFORCE being off was the only reason its run passed. The list is from #37, when markdown rendering belonged to Preview. Since then the live preview took the stack into the editor: table cells render through renderMarkdown (cm-table's renderInlineCell, 2.4.0, kept on the reading view's parser on purpose by #559), so do comment bodies in CommentsPanel, and formulas render through KaTeX (cm-math-render, cm-latex-completions), all synchronously in a widget's toDOM or a render. KaTeX lives in vendor-markdown, which statically imports vendor-highlight through lowlight, so each of the four puts both chunks in the Editor chunk's static graph. App also warms the Editor module during boot (workspace.ready waits for it), so the stack arrives before any note is open, by design. Deferring it would paint raw source for a frame on the first table, formula or comment of a session. Stubbing the four imports out in a throwaway build bought 5 to 9 ms on the first note open and 1.3 MB of heap, and nothing on boot or note switches. The check was wrong, not the app. The two chunks leave the normal-flow list and get the check that still means something: they must not be fetched before App's first render. Anything that early is in the entry's static graph, ahead of first paint, which is the failure e7a9879e fixed (a manualChunks rule hoisted mermaid into the entry and vendor-markdown came with it). The renderer loads over file://, which Resource Timing never records, so the window is timed from each CDP request's wallTime against the page's timeOrigin and the renderer.app.mounted sample. The report prints the chunks fetched before mount. When that timing cannot be taken (no renderer.app.mounted sample, as in an older ZEN_PERF_DESKTOP_APP_PATH build, or the entry chunk outside the window, which means the two clocks disagree), the report says "unavailable", and under ZEN_PERF_ENFORCE the run fails and says which of the two it was. A guard that quietly cannot run would pass exactly the regression it is there to catch. No app change. On the 764c10d2 build, interleaved: the old harness exits 1 in 4 of 4 runs, the new one exits 0 in 4 of 4 with the same numbers (ready-to-show 205 ms, workspace ready 531 ms, first note open 110 ms, switch p50 33.6 ms), and the before-mount list is exactly the entry's static closure (index, vendor-react, app-store, vendor-editor, app-wikilinks). A build importing renderMarkdown from main.tsx fails the new check with both chunks named, and a copy of the harness forced into the unavailable case exits 1 under ENFORCE and 0 without it. The final harness passes ENFORCE on a fresh build. typecheck 8/8, app-core 3,308 tests, desktop 1,183 tests. --- tooling/scripts/perf-desktop-runtime.mjs | 82 +++++++++++++++++++++--- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/tooling/scripts/perf-desktop-runtime.mjs b/tooling/scripts/perf-desktop-runtime.mjs index 47f0baac5..3e3551b31 100644 --- a/tooling/scripts/perf-desktop-runtime.mjs +++ b/tooling/scripts/perf-desktop-runtime.mjs @@ -87,8 +87,6 @@ const deferredNormalFlowChunkPatterns = [ /^Preview-/, /^NoteHoverPreview-/, /^wardley-/, - /^vendor-markdown-/, - /^vendor-highlight-/, /^vendor-d3-/, // Pele draws mermaid fences; its runtime chunk loads before any diagram type. /^pele-runtime-/, @@ -100,6 +98,18 @@ const deferredNormalFlowChunkPatterns = [ /^vendor-jsxgraph-/, /^vendor-function-plot-/ ] +// The markdown stack is the editor's, so it loads in the normal flow but must +// never be fetched before the app first renders. The live preview draws table +// cells (cm-table's renderInlineCell, #559) and comment bodies (CommentsPanel) +// through renderMarkdown, and formulas through KaTeX (cm-math-render, +// cm-latex-completions), all synchronously in a widget's toDOM or a render, so +// both chunks sit in the Editor chunk's static graph, and App warms that module +// during boot (renderer.workspace.ready waits for it). Deferring them would +// paint raw source for a frame on the first table, formula or comment. Fetched +// before the app mounts, though, they are in the ENTRY's static graph, ahead of +// first paint, the way a manualChunks rule once hoisted mermaid there and +// brought vendor-markdown along (e7a9879e). +const editorChunkPatterns = [/^vendor-markdown-/, /^vendor-highlight-/] function parsePositiveInt(raw, fallback) { const parsed = Number.parseInt(raw ?? '', 10) @@ -609,10 +619,31 @@ function budgetStatus(label, actual, budget) { } } -function findDeferredNormalFlowChunks(scripts) { - return scripts.filter((script) => - deferredNormalFlowChunkPatterns.some((pattern) => pattern.test(script)) - ) +function matchingChunks(scripts, patterns) { + return scripts.filter((script) => patterns.some((pattern) => pattern.test(script))) +} + +// Scripts the reloaded document asked for before App's first render. Only the +// entry's static graph and the HTML's own modulepreloads can be that early: +// App's first dynamic import waits for an effect. Timed from the CDP request's +// wallTime because the renderer loads over file://, which Resource Timing never +// records. Null when the app has no renderer.app.mounted sample (an older +// ZEN_PERF_DESKTOP_APP_PATH build) or when the entry chunk itself falls outside +// the window, which means the two clocks do not line up. Either way the guard +// cannot run, so the report says unavailable instead of printing an empty list +// that looks like a pass, and ZEN_PERF_ENFORCE fails the run. +function scriptsRequestedBeforeMount(requests, timeOrigin, appMountedSample) { + const mount = sampleWindow(appMountedSample, timeOrigin) + if (!mount || typeof timeOrigin !== 'number') return null + const mountedAt = timeOrigin + mount.start + const scripts = [ + ...new Set( + requests + .filter((request) => request.wallTimeMs >= timeOrigin && request.wallTimeMs < mountedAt) + .map((request) => request.script) + ) + ] + return scripts.some((script) => /^index-/.test(script)) ? scripts : null } function printMetric(label, value, suffix = 'ms') { @@ -686,9 +717,12 @@ async function main() { consoleMessages.push({ type: event.type, text }) } }) + const networkScriptRequests = [] client.on('Network.requestWillBeSent', (event) => { const script = scriptNameFromUrl(event.request?.url ?? '') - if (script) networkScripts.push(script) + if (!script) return + networkScripts.push(script) + networkScriptRequests.push({ script, wallTimeMs: (event.wallTime ?? 0) * 1000 }) }) await Promise.all([ @@ -747,6 +781,7 @@ async function main() { refreshApply: samples.find((sample) => sample.name === 'store.refreshNotes.apply') ?? null, workspaceRestore: samples.find((sample) => sample.name.startsWith('workspace.restore')) ?? null, samples, + timeOrigin: performance.timeOrigin, rowCounts: rowCounts(), nav: nav ? { domContentLoaded: Math.round(nav.domContentLoadedEventEnd * 100) / 100, @@ -1055,7 +1090,13 @@ async function main() { const mainListNotes = mainPerfSamples.find((sample) => sample.name === 'main.vault.listNotes') ?? null const longTaskSummary = summarizeLongTasks(finalSnapshot.longTasks) const loadedScripts = [...new Set([...finalSnapshot.scripts, ...networkScripts])] - const deferredNormalFlowChunks = findDeferredNormalFlowChunks(loadedScripts) + const deferredNormalFlowChunks = matchingChunks(loadedScripts, deferredNormalFlowChunkPatterns) + const scriptsBeforeMount = scriptsRequestedBeforeMount( + networkScriptRequests, + startup.timeOrigin, + startup.appMounted + ) + const editorChunksBeforeMount = matchingChunks(scriptsBeforeMount ?? [], editorChunkPatterns) const statuses = [ ...(mainReady ? [budgetStatus('main ready-to-show', mainReady.durationMs, budgets.mainReadyMs)] : []), budgetStatus('renderer workspace ready', startup.ready.durationMs, budgets.rendererReadyMs), @@ -1186,6 +1227,21 @@ async function main() { } } + if (scriptsBeforeMount === null) { + console.log('\nJS chunks fetched before the app mounted: unavailable') + } else { + console.log('\nJS chunks fetched before the app mounted') + for (const script of scriptsBeforeMount) { + console.log(`- ${script}`) + } + } + if (editorChunksBeforeMount.length > 0) { + console.log('\nEditor chunks fetched before the app mounted') + for (const script of editorChunksBeforeMount) { + console.log(`- ${script}`) + } + } + const failed = statuses.filter((status) => !status.ok) if (failed.length > 0) { console.log('\nBudget warnings') @@ -1220,6 +1276,16 @@ async function main() { if (enforceDeferredChunks && deferredNormalFlowChunks.length > 0) { throw new Error('Desktop runtime benchmark loaded deferred-heavy chunks during normal flow') } + if (enforceDeferredChunks && scriptsBeforeMount === null) { + throw new Error( + startup.appMounted + ? 'Desktop runtime benchmark could not time chunk fetches against the app mount (the entry chunk fell outside the before-mount window); the before-mount guard cannot run' + : 'Desktop runtime benchmark could not time chunk fetches against the app mount (no renderer.app.mounted sample); the before-mount guard cannot run' + ) + } + if (enforceDeferredChunks && editorChunksBeforeMount.length > 0) { + throw new Error('Desktop runtime benchmark fetched editor chunks before the app mounted') + } if (enforceBudgets && failed.length > 0) { throw new Error('Desktop runtime benchmark exceeded enforced budgets') } From 56937694cab22d282b99ec61f2e40932b87ee185 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 17:26:25 -0500 Subject: [PATCH 02/10] Fix(settings): text fields wrap under their label on a narrow pane On an iPad or an Android tablet, Settings > Vault > Location showed the Vault name label and its description one word per line, with the last word of a sentence running under the field. A narrow desktop window did the same at a milder scale. Every text field in Settings had the problem, and so did the daily, weekly and monthly note template pickers. TextInputRow put its field on one line beside the label and the line never wrapped. The field was 23rem and could not shrink, and its only cap was half the viewport. Settings is a modal much narrower than the viewport, so that cap never engaged before the label ran out of room: the label column was the one thing that gave way. TemplateSelectRow used the same field classes and broke the same way. Phones never showed it, because the phone shells already stack every Settings row. Both rows now wrap the way the Vault Location row does since #871. The label keeps a 14rem minimum, the field moves under it when the row is too narrow, and the field is capped at the row's width instead of the viewport's. At full width nothing moves. Not done here: SegmentedRow, FontRow, SliderRow and NumberRow have the same unwrapping shape with narrower controls. The worst of them, the Default drawings, databases and tasks location rows, still collapse at tablet widths; that is a separate change. Measured over CDP on a built app at 1280, 1100, 1000, 900, 800, 768, 700 and 600 px, across the 27 rows that use the two components (periodic notes on): before, every row's label was 0 to 18 px wide at 800 px and below, and the Vault name label was 124 px with 7 description lines at 900 px, the desktop's minimum window. After, no row overflows or overlaps at any width, the Vault name label is 384 px with 3 lines at 768 px, and layouts at 1280 and 1100 px are unchanged. The template dropdown opens under its wrapped trigger. app-core typecheck clean. --- .../app-core/src/components/SettingsModal.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 4cfa2bd2b..4da33c8dc 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -7206,12 +7206,16 @@ function TextInputRow({ onChange(next ? next : null); }; + // The field wraps under the label when the row is too narrow, as the Vault + // Location buttons do (#871). On one unwrapping line, a fixed-width field + // in a tablet-width or narrow-window Settings pane squeezed the label and + // description to one word per line. return (
-
+
{label}
{description && (
@@ -7251,7 +7255,7 @@ function TextInputRow({ } }} placeholder={placeholder} - className="w-[23rem] max-w-[50vw] shrink-0 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3 py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400 focus:border-accent/45" + className="w-[23rem] max-w-full shrink-0 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3 py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400 focus:border-accent/45" />
); @@ -7386,12 +7390,13 @@ function TemplateSelectRow({ } }; + // Wraps the picker under the label on a narrow row, like TextInputRow. return (
-
+
{label}
{description && (
@@ -7406,7 +7411,7 @@ function TemplateSelectRow({ aria-expanded={open} onClick={() => setOpen((o) => !o)} onKeyDown={onTriggerKeyDown} - className="flex w-[23rem] max-w-[50vw] shrink-0 items-center justify-between gap-2 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3 py-2 text-left text-sm text-ink-900 outline-none transition-colors hover:bg-paper-200 focus:border-accent/45" + className="flex w-[23rem] max-w-full shrink-0 items-center justify-between gap-2 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3 py-2 text-left text-sm text-ink-900 outline-none transition-colors hover:bg-paper-200 focus:border-accent/45" > {triggerLabel} From c634af68f7d62c0b2927a5790a046f77f260b159 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 19 Oct 2026 17:44:21 -0500 Subject: [PATCH 03/10] Fix(settings): segmented, font and slider rows wrap on a narrow pane Settings is a modal (min(1120px, 96vw) with a 252 px nav), so at tablet widths and at the 900 px desktop minimum window its content pane is much narrower than the viewport. SegmentedRow, FontRow and SliderRow put a fixed-width control on one unwrapping line beside a min-w-0 label column, so only the label gave way. At 768 px the Default drawings, databases and tasks location rows had a 0 px label column with 29 to 31 description lines, and their three options sat on top of the description and ran past the card. Completed task style got 88 px, the font pickers 118 px, the sliders 148 px. At 900 px the location rows were 117 px. The three components now use the Vault Location pattern from #871: the row wraps, the label keeps at least 14rem before the control moves under it, and the control may not exceed the row. A segmented group also wraps its options within itself when even a row of its own is too narrow (600 to 700 px), and a slider's track gives way before its value does. The periodic-notes token table chose two columns on a viewport breakpoint, which kept two columns in a 374 px card at 768 px: meanings got 25 px, broke a word per line and collided with the second column's tokens. It now keeps two columns only while the card has room for them (the same widths as before at 1000 px and up, one column below), and a meaning drops under its token when less than 6rem is left. The Saved Remote Workspaces intro line, squeezed to 77 px and 8 lines beside New Remote at 600 px, wraps the button under it. Not touched: NumberRow, which has no call sites, and the Keymap rows and a few one-off rows (Reset to defaults, the Open today's note buttons, template and workflow rows) that squeeze the same way below 768 px. The rows keep the classes the phone shells' mobile.css matches, so phones keep stacking them. Measured over CDP on a desktop build at 1280, 1100, 1000, 900, 800, 768, 700 and 600 px across every Settings category and tab (249 label and control rows): at 1280 and 1100 no row changed height, label position, line count or control placement, and the token table and intro line are identical. At 768 px no segmented, font or slider label is under 160 px (was 18 of them), the token table has no cell past the card's edge (was 6) and the location rows sit under full-width descriptions of 1 to 3 lines. The font picker's dropdown opens directly under its wrapped trigger. typecheck clean. --- .../app-core/src/components/SettingsModal.tsx | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 4da33c8dc..059ba4d6f 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -4419,8 +4419,10 @@ export function SettingsModal(): JSX.Element { settingId="saved-remote-workspaces" >
-
-
+ {/* New Remote wraps under the intro on a narrow card + instead of squeezing it to a word per line. */} +
+
Saved connections can point at different servers or different vaults on the same server.
@@ -6914,15 +6916,26 @@ function DateNotePatternSupportRow({ .
-
+ {/* Two columns only while the card itself has room for them, and the + meaning drops under its token when a column is too narrow. A + viewport breakpoint kept two columns in a tablet-width Settings + pane, where the meanings shrank to a word per line and ran past + the card. */} +
{DATE_NOTE_PATTERN_TOKENS.map((item) => (
-
{item.token}
-
{item.output}
-
{item.meaning}
+
+ {item.token} +
+
+ {item.output} +
+
+ {item.meaning} +
))}
@@ -7613,12 +7626,13 @@ function FontRow({ } }; + // Wraps the picker under the label on a narrow row, like SegmentedRow. return (
-
+
{label}
{description && (
@@ -7630,7 +7644,7 @@ function FontRow({ ref={buttonRef} type="button" onClick={() => setOpen((o) => !o)} - className="flex w-[236px] shrink-0 items-center justify-between gap-2 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3.5 py-2 text-left text-sm text-ink-900 transition-colors hover:bg-paper-200" + className="flex w-[236px] max-w-full shrink-0 items-center justify-between gap-2 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3.5 py-2 text-left text-sm text-ink-900 transition-colors hover:bg-paper-200" > -
+
{label}
{description && (
@@ -7777,7 +7793,7 @@ function SliderRow({
)}
-
+
onChange(Number(e.target.value))} - className="zen-slider h-1 w-[140px] cursor-pointer appearance-none rounded-full" + className="zen-slider h-1 w-[140px] min-w-0 cursor-pointer appearance-none rounded-full" />
{display} @@ -8315,12 +8331,17 @@ function SegmentedRow({ settingId?: string; onChange: (next: T) => void; }): JSX.Element { + // The options wrap under the label when the row is too narrow, as the + // Vault Location buttons do (#871), and wrap within themselves when even + // a row of their own is too narrow. On one unwrapping line, three long + // options in a tablet-width or minimum-width Settings pane squeezed the + // label and description to one word per line, and ran past the card. return (
-
+
{label}
{description && (
@@ -8328,7 +8349,7 @@ function SegmentedRow({
)}
-
+
{options.map((option) => (