fix(export): let the exported HTML follow the preview's max width - #468
Merged
Conversation
`buildExportDocument` wrote `.markdown-body { max-width: 900px }` into
every file the HTML export produced. 900 is not a number the app has
anywhere else: Preview -> Max width (#346-#349, renamed to "Max width" in
#456) is bounded 640-1600 and defaults to 880. So a document read at 640
and a document read at 1600 both left the app at 900, and the one setting
that governs how wide the same document renders everywhere else stopped
at the file dialog. #467 asks for the width to be configurable; it
already is, the export just was not listening.
`exportAsHtml` in MarkdownViewer.svelte now hands the export the value it
already derives for the live preview -- `getPreviewContentWidth(
settings.previewMaxWidth, isFullWidth)`, the same thing that becomes
`--preview-max-width` on the article the user is looking at -- and
`buildExportDocument` interpolates it in place of the constant. One
source of truth, and what leaves the app is the measure it was read at.
Full-width mode is that function's `null`, and it becomes `max-width:
none` rather than the preview's `100%`. The two are equivalent for this
element (`.markdown-body` is a block child of `<body>`, so a percentage
cap resolves to the width it already has), but `none` states the
intention without depending on that reasoning and cannot interact with
`box-sizing`. Checked in a browser on a real exported file: the column
follows the window, `margin: 0 auto` has no slack left to distribute,
and the `padding: 40px !important` still holds the text off the edge.
The number is normalized through `normalizePreviewMaxWidth` at the point
where it stops being a number and becomes a CSS declaration.
`previewMaxWidth` is restored from localStorage, and a corrupted or
hand-edited key must not be able to interpolate itself raw into the
exported stylesheet; an unusable value degrades to the app default the
same way the preview's does.
The print/PDF route is untouched and stays untouched: `@media print` in
styles.css already forces `.markdown-body` to `max-width: 100%
!important` with its own .75in padding, so printing an exported file
never saw the 900 and does not see the setting either. There is a test
for that, because the two routes staying separate is now a property
worth pinning rather than an accident.
scripts/exportContentWidth.test.ts resolves the stylesheet the export
actually emits instead of matching the template that emits it: a
declaration only means something once it is in the `<style>` block, on a
selector the shipped `<article class="markdown-body">` matches, and last
in the cascade. Two of the tests run the real `exportAsHtml` end to end
on the harness exportRichContent.test.ts established and read the bytes
handed to `save_file_content`, with a decoy `max-width: 900px` planted in
the copied stylesheet so the run also answers "does the export's cap win"
and not merely "is it in the file". The one link that cannot be executed
under the Node runner is the call site itself, since it lives in a
`.svelte` component; that is checked against the source of that single
function via `functionSource`.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 6, 2026
PathGao
added a commit
that referenced
this pull request
Aug 6, 2026
The review discipline here is real but undocumented: a mechanism section that explains why the old behaviour happened rather than what was done about it, a Scope section saying what was deliberately left alone, a falsification step on fixes, and a Verification section with the exact commands, their counts and an honest list of what was not checked. #468, #464, #462, #460 and #458 all have that shape. Nobody arriving from outside can know it. #463 came close by instinct, which is the argument for writing it down rather than hoping. Five headings, prompts only, no checkboxes. A checkbox that feels mandatory is a required field wearing a disguise, and friction is what makes a contributor abandon a template rather than fill it in; the header says outright that every section can be deleted. No licensing, conduct or "I read the guide" line -- there is no contributing guide to read. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #467.
@Haiulus asked whether the width of the exported HTML can be changed. Today it cannot:
buildExportDocumentwrites.markdown-body { max-width: 900px }into every file the export produces.What 900 was
Nothing, as far as the rest of the app is concerned. Preview → Max width has existed since #346–#349 (renamed to Max width in #456): bounded 640–1600, default 880, persisted, with widen/narrow shortcuts and a modified-row indicator in Settings. The export ignored it and shipped 900 — not the user's choice, and not even the app's own default. That reads like an oversight rather than a decision, which is why this is a fix and not a feature.
What changed
exportAsHtmlinMarkdownViewer.sveltealready had the value in hand:previewContentWidth, i.e.getPreviewContentWidth(settings.previewMaxWidth, isFullWidth)— the same derived value that becomes--preview-max-widthon the article the user is looking at. It is now one more field on the export context, andbuildExportDocumentinterpolates it where the constant used to be. One source of truth; what leaves the app is the measure it was read at.Full width →
max-width: none.getPreviewContentWidthreturnsnullfor full-width mode. In a standalone file that means "no cap". The preview spells the same thing100%, and for this element the two are equivalent —.markdown-bodyis a block child of<body>, so a percentage cap resolves to the width it already has — butnonestates the intention without depending on that reasoning and cannot interact withbox-sizing. Confirmed on a real exported file in a browser: the column follows the window,margin: 0 autosimply has no slack left to distribute, andpadding: 40px !importantstill holds the text off the window edge.Normalized at the seam. The value goes through
normalizePreviewMaxWidthinsideexportContentMaxWidth, because that is where it stops being a number and becomes a CSS declaration.previewMaxWidthis restored from localStorage; a corrupted or hand-edited key must not be able to write its own rules into the exported stylesheet. Out of range clamps, unparseable degrades to the default — exactly as the preview does.Nothing else used the 900. The only other occurrences in the repo are unrelated (a
scrollTopfixture, an out-of-range font size in a persistence test, astroke-dashoffsetinside a captured Mermaid SVG).The print/PDF route is untouched, and now provably so.
@media printinstyles.cssalready forces.markdown-bodytomax-width: 100% !importantwith its own.75inpadding, and that block travels into the export with the rest of the sheet. Printing an exported file therefore never saw the 900 and does not see the setting either. There is a test for it, so the two routes staying separate concerns is pinned rather than assumed.The design call — follow the setting, or add a second one?
This is a real question and I would rather put it to you than pretend it is not.
For following the preview width (what this PR does): no new UI, no new i18n keys, one source of truth, and what you export matches what you were reading.
For a separate export-only width: an exported file is read on someone else's screen. An author's editing measure is not necessarily their publishing measure, and someone who reads narrow but publishes wide is not being unreasonable.
The reason I came down on "follow" anyway is that the escape hatch is already excellent. The export is a self-contained single HTML file with its
<style>inline — anyone who wants a different width edits one line in the file they just produced. Adding a second width control to Settings, with its own label, its own translations, its own modified-row logic and its own reset, for something that is a one-line edit downstream, is not a good trade against the existing surface.If you would rather have the separate setting, say so and I will add it — the plumbing here is the same either way, it just reads from a different field.
Workaround for @Haiulus in the meantime: open the exported
.html, find.markdown-body { … max-width: … }in the<style>block near the top, and change that one value. The stylesheet is inline, so nothing else has to be touched and the file stays self-contained.Tests
scripts/exportContentWidth.test.ts(9 tests). They resolve the stylesheet the export actually emits rather than matching the template that emits it — a declaration only means something once it is inside the<style>block, attached to a selector the shipped<article class="markdown-body">matches, and last in the cascade.getPreviewContentWidthfor every combination of stored value and full-width statenone, and the padding and centring survive it99999,-40,'1e9','900px; } body { display: none; } .x {',NaN,Infinity) is clamped or defaulted, never interpolated raw — asserted both as a table and as a shape, so a future value that is neither a clamped pixel count nornonecannot pass by looking plausible@media printblock, so the PDF route is unchangedTwo of the nine run the real
exportAsHtmlend to end, on the harnessexportRichContent.test.tsestablished (Tauri boundary and the rich-content libraries stubbed, everything in between running), and read the bytes handed tosave_file_content. One of them plants a decoy.markdown-body { max-width: 900px }in the copied stylesheet, so the run answers "does the export's cap win the cascade" and not merely "is the number in the file".The one link that genuinely cannot be executed under the Node runner is the call site itself — it is in a
.sveltecomponent, which the runner cannot import (scripts/sourceTree.tsexplains why). That one is checked against the source of that single function viafunctionSource, not a whole-file match, so acontentWidth:appearing anywhere else in a 3700-line component cannot satisfy it.Falsification. Reverting the template to
max-width: 900pxand keeping the tests turns 7 of the 9 red, naming the actual problem:The two that stay green under that revert are the print-block test (correct — that path is unaffected) and the call-site test (correct — the wiring was not what was reverted). Reverting only the
MarkdownViewer.sveltewiring instead inverts it exactly: 8 pass, and the call-site test alone fails.Verification
npm audit(0 vulnerabilities),npm run check(645 files, 0 errors),npm test(692 pass), andcargo testinsrc-tauri/(157 pass) — what.github/workflows/test.ymlruns.Beyond that, I drove the real
exportAsHtmlwith the realsrc/styles.cssto produce four actual export files (640, 880, 1600, full width) and opened them in a browser at a 1440px viewport. Measured: the 640 file lays out at 720px wide (640 + 2×40 padding), centred at x=360; the 880 file at 960px, centred at x=240; the full-width file fills the viewport with no horizontal overflow, and reflows to 700px when the window is narrowed to 700. Screenshot confirms the 640 file renders centred and correctly measured.I did not export from a running Tauri build — the export path was driven directly rather than through the app's file dialog.