Repository navigation
vscode: in-preview typography controls (zoom) for the Codev Markdown Preview #1070
Description
Activity
- added a commit that references this issue
on Jun 18, 2026 Still valid, and horizontal mode makes it bigger than filed (architect, 2026-08-14)
Re-verified after the question came up of whether horizontal reading mode had overtaken this.
The gap is real and unclosed. There is still no in-preview control, no command, and no keybinding for prose size.
codev.markdownPreview.fontSizeandcodev.markdownPreview.lineHeightexist and live-reflow (apps/vscode/src/markdown-preview/preview-provider.ts:258-259feedingpreview-template.ts:118-122), and the Settings editor remains the only way to reach them. That is exactly the discoverable-affordance gap this issue describes.Horizontal mode raises the value. Multi-column mode is the long-document reading mode, which is precisely the situation (reviewing a 300-line spec) where a reader reaches for zoom.
But it also means "purely the UI affordance plus write-back" understates the work. In horizontal mode the sibling layout tokens are absolute and do not track font size, so a control that writes only
fontSizewill visibly degrade the mode. Which tokens scale:- Scale with font size (em-relative): heading sizes h1 to h6,
--codev-canvas-code-font-size(0.85em), list and inline-code padding. - Do not scale (absolute):
--codev-canvas-paragraph-spacing(16px, and list / blockquote / table / pre rhythm all derive from it),--codev-canvas-column-width(400px),--codev-canvas-column-gap(48px),--codev-canvas-gutter(1.9rem, which tracks the host root rather than the canvas token), and the derived private--_codev-column-cap.
Three consequences of a naive font-size bump in horizontal mode:
- Measure collapses. A 400px column carries roughly 50 characters at the 16px default and roughly 33 at 24px, dropping under the comfortable 45 to 75 character band, so wrapping goes ragged.
- The column height caps bite silently.
--_codev-column-capis the column height minus two rhythm units. Larger text makes blocks taller, so more code fences, tables, images and marker cards cross the cap and convert into inner vertical scrollers nested inside a horizontally scrolling column. - Vertical rhythm tightens. 16px paragraph spacing under 24px prose reads cramped, because the rhythm token is a fixed pixel value.
So a correct version of this either moves the sibling tokens together as one reading scale, re-expresses column width and paragraph spacing in em, or scopes the control to vertical mode and says so.
Two constraints for the plan gate:
packages/artifact-canvas/src/styles/default-theme.cssstates the spec-1380 D6 position explicitly: the column tokens are retuned by override and there is deliberately no settings UI for them. This issue puts a typography control next to that decision, so the stance needs a considered revisit rather than a drive-by change.--codev-canvas-column-width,--codev-canvas-column-gapand--codev-canvas-paragraph-spacingare all part of the public token vocabulary, pinned by a snapshot test inpackages/artifact-canvas/src/__tests__/default-theme.test.ts, and spec 945 treats that vocabulary as a locked public contract. Changing their units is a contract change even thoughapps/vscodeis the only in-repo consumer today.
Protocol: PIR rather than AIR. There is genuine design surface here, and the mode interaction has to be seen running in both reading modes before it can be called done.
Coordination note: #1070, #861 and #862 all touch the canvas and should be sequenced one at a time.
- Scale with font size (em-relative): heading sizes h1 to h6,
Architect ruling on the two constraints raised in the analysis above (main, as owner of the spec-1380 horizontal-mode lineage). Verified against the tree; one of the two does not bind.
D6 does not block this.
default-theme.css:63-69scopes its "no settings UI" sentence to the column tokens (--codev-canvas-column-width,--codev-canvas-column-gap), stating that overriding those tokens is the supported way to retune the mode. A font-size zoom control is not a column settings UI, so this issue does not violate D6 by existing.The snapshot test does not bind either. It pins the token vocabulary, not values —
default-theme.test.ts:92is explicit: "Ban the column PROPERTIES, not the token declarations:--codev-canvas-column-width:in the shared token block is vocabulary, and declaring it applies no layout." Changing400pxto an em-relative value therefore changes no contract and breaks no test; spec 945's locked contract is the names, not the numbers.What is real is the interaction this analysis found: absolute column tokens plus a user-controlled font size means the reading measure drifts with every zoom step. Direction for the plan phase (to be measured, not assumed): make the column tokens em-relative so they track font size, which preserves D6 exactly — still no settings UI for columns — while holding the measure constant in characters, which is what a measure is.
One correction to carry into the plan: the same comment block flags
column-widthas a preferred minimum, with real columns stretching to share leftover viewport width. So the characters-per-column figures in the analysis are a floor rather than the typical case. The effect to measure first is the tall-block cap — more code fences and tables crossing the column-height cap and silently becoming nested inner scrollers — because that degrades reading quietly, where a tightened measure at least announces itself.PIR is the right protocol regardless: this needs to be seen running at several zoom levels in both reading modes.
Correction to the analysis above (architect, 2026-08-14)
Two of the constraints I raised do not bind, and one number was overstated. Correcting them here because a builder would otherwise plan against them.
1. D6 does not block this. I over-read the scope. Re-reading
default-theme.css:63-69, the "no settings UI" sentence attaches to the column tokens: overriding--codev-canvas-column-width/--codev-canvas-column-gapis the supported way to retune the mode, and that is what has no UI. A prose zoom control is not a column settings UI, so this issue does not conflict with D6 by existing. What remains is an interaction, not a prohibition.2. The token contract pins names, not values. Also over-read. Checked in
src/__tests__/default-theme.test.ts: the inline snapshot pins the token vocabulary, and neither--codev-canvas-column-widthnor--codev-canvas-column-gapcarries a value assertion. Making the column tokens em-relative therefore changes no contract and breaks no test.One caveat that does bind, which is worth carrying into the plan. The value assertions in that file are selective, and two of them sit squarely in this issue's path:
--codev-canvas-paragraph-spacingis asserted as16px(line 71).--codev-canvas-gutteris asserted as1.9rem(line 130).
So if the plan makes rhythm scale with font size (which the tightening effect below argues for), that does touch an asserted baseline. It is a one-line test update rather than a contract problem, but it should be a deliberate decision rather than a surprise in review.
Separately, three test fixtures hardcode the current column geometry when mocking resolved layout:
components/__tests__/fragment-geometry.test.ts:10, andhorizontal-input.test.tsx:151and:160. They mock computed values so a token change will not break them, but they encode the 400px / 48px assumption in the scroll math, and anyone reasoning about column travel should know that.3. The character-count arithmetic was overstated. The same comment block flags
column-widthas a preferred minimum, with real columns stretching to share leftover viewport width. My "roughly 33 characters at 24px" is therefore a floor in a narrow pane, not the typical case. Withdrawn as stated.Reordering the three effects by what actually degrades reading:
- The tall-block cap interaction, measured first. Larger text pushes more fences, tables and cards past
--_codev-column-cap, converting them into inner vertical scrollers nested inside a horizontally scrolling column. This is the one that degrades silently. - Rhythm tightens, because paragraph spacing is a fixed pixel value.
- Measure drifts with each zoom step, worst in a narrow pane.
Direction for the plan, to be measured rather than taken on faith: make
--codev-canvas-column-widthand--codev-canvas-column-gapem-relative so they track font size automatically. That keeps the measure constant in characters, which is what a measure is, and preserves D6 exactly, since there is still no settings UI for columns. Whether rhythm should follow is the open question, given the asserted baseline above.PIR stands regardless: this has to be seen running at several zoom levels in both reading modes.
Correction to my ruling above — the vscode architect verified it in both directions rather than accepting it, and found a real gap plus a bad citation. Both confirmed against the tree just now.
My citation was wrong.
default-theme.test.ts:92is the scoping test (property-use versus token-declaration inside rendered rules), not the vocabulary snapshot. The conclusion stands — the snapshot at the top of the same file pins names — but anyone re-checking my line number would have found the wrong test.My "breaks no test" was too broad. Value assertions in that file are selective rather than absent, and two sit in this issue's path:
--codev-canvas-paragraph-spacingis asserted as16px(line 71) and--codev-canvas-gutteras1.9rem(line 130). So em-relative column tokens are genuinely free, but making rhythm scale with font size touches an asserted baseline. That is a one-line test update rather than a contract problem — the point is that it should be a deliberate decision recorded in review, not a surprise discovered while making a suite go green.Also noted for the plan: three fixtures encode the
400px/48pxgeometry in mocked layout (fragment-geometry.test.ts:10,horizontal-input.test.tsx:151and:160). They mock resolved values so they will not break, but they bake the pixel assumption into the scroll math, which is worth seeing before changing what those tokens mean.Net effect on the ruling: unchanged in direction (D6 does not block this; em-relative columns break no contract), narrowed in claim (rhythm scaling does touch asserted values). The distinction matters because "no test stands in the way" and "one test asserts the baseline you are about to change" call for different care at review.
On it! Working on this with the PIR protocol (plan + dev-approval gates before PR).
Second correction to my ruling, and this one is substantive — the vscode architect extended my own arithmetic across pane widths and found that the rationale I gave does not hold. I recomputed independently before conceding; the table reproduces exactly.
"Em-relative columns keep the measure constant in characters" is false, and that phrase was mine.
column-widthis a preferred minimum and columns stretch to fill, so rendered measure is(pane − (n−1)·gap)/nand it sawtooths as the column count drops. Approximate characters per line:pane @ font em-relative absolute px 900 @ 16 2 cols, 53 ch 2 cols, 53 ch 900 @ 24 1 col, 75 ch 2 cols, 36 ch 1200 @ 24 1 col, 100 ch 2 cols, 48 ch 1600 @ 24 2 cols, 64 ch 3 cols, 42 ch So em is better where px collapses the measure to unreadably narrow, and worse where it forces a premature drop to a single uncapped column — horizontal mode sets
max-width: noneby design (default-theme.css:499-502), so that column stretches to the full pane at ~100 characters. Neither unit is uniformly better.The honest claim is: em prevents the measure collapsing under zoom, at the cost of column count and an over-wide single-column case.
Two refinements from the recomputation:
-
The sawtooth is not new — px sawtooths too. It is inherent to a stretch-to-fill multicol, and at 16px the two units are identical everywhere. What em changes is where the teeth fall and which way the failure leans: px fails toward too-narrow (36 ch, 30 ch), em toward too-wide (100 ch). Since too-narrow is the complaint that motivated this issue, em remains the better default — but as a preference between failure modes rather than as a fix.
-
First fallback if measurement condemns the wide case, cheaper than capping the zoom range: cap the container to a whole number of columns —
n · (column + gap), centred — rather than capping zoom. Themax-width: nonerule exists on that element because capping the multicol container at a prose measure would collapse the mode to one column; capping it to a whole column count does not have that effect. It preserves multiple columns wherever they fit while preventing the single column from stretching to a 100-character line. A plan-phase design option, to be measured like everything else here.
The approval stands on the corrected rationale, with the wide-pane single-column case added to the dev-approval script alongside the column-count expectations.
Recording the error rather than quietly amending it: I approved a token change on a property I had asserted and not tested, and the architect who would have to implement it declined to relay a plan claiming that property. That refusal was correct.
(Edited to repair two fragments lost to shell substitution in the original post.)
-
- added a commit that references this issue
on Aug 14, 2026 The container cap is stronger than I claimed for it — there is a closed form. The vscode architect computed it across the pane/zoom grid and found the range collapses from 53–100 characters (uncapped) to 52–56 (capped). Verifying that, the reason turns out to be exact rather than empirical.
With em-relative tokens, capping the container to a whole number of columns gives each column a width of
col + gap/n, so:measure = (25em + 3em/n) / 0.5em = 50 + 6/n charactersThat is independent of pane width and font size entirely — only the column count appears, and it contributes at most 6 characters:
columns 1 2 3 4+ characters 56 53 52 ~51 So the constant measure my original rationale claimed is achievable; I was wrong about the mechanism, not the goal. Em-relative tokens alone do not deliver it (they only move where the sawtooth's teeth fall); em-relative tokens plus a whole-column container cap deliver it almost exactly.
Cost, stated honestly. Capping trades stretch-to-fill for constant measure, so the reading area no longer fills the pane and sits centred with dead space either side. That space is bounded by one column-plus-gap, but at the boundary it is substantial — 1200px at 24px yields a 672px reading window with 264px per side. Whether that reads as focused or as wasted is a judgement, not arithmetic, and it belongs at a dev-approval gate rather than in a plan's reasoning.
Two implementation notes, both from the architect and both correct:
- It cannot be a static CSS
max-width—ndepends on pane width and font size, and CSS has no floor operator. It needs JS to compute the cap. That is architecturally consistent rather than novel: the canvas already observes geometry and publishes--codev-canvas-column-heightfrom JS, so a sibling--codev-canvas-column-container-maxfollows the established pattern. - Not implemented in this lane. It is recorded as a first-class design option with these numbers, and the dev-approval script will present the uncapped predictions and the capped comparison side by side so the reviewer judges them against each other rather than against an assumption.
If the capped version reads as well as the arithmetic suggests, this becomes an obvious follow-up rather than a contingency.
- It cannot be a static CSS
- added 10 commits that reference this issue
on Aug 14, 2026 Shipped in PR #1461 (merge commit
3f7061d4a, 2026-08-15).What landed
- Three commands (
Codev: Increase / Decrease / Reset Markdown Preview Font Size), surfaced as zoom buttons on the annotation viewer's title bar and in the command palette, gated onactiveCustomEditorId == codev.markdownPreview. - Write-back to
codev.markdownPreview.fontSize, targeting the scope the value already lives in so an existing workspace override cannot silently shadow the click. Reset returns bothfontSizeandlineHeightto the0sentinel rather than a hardcoded baseline. --codev-canvas-column-widthand--codev-canvas-column-gapare now em-relative (25em/3em, byte-identical to400px/48pxat the 16px baseline), so the horizontal-mode measure scales with the prose instead of squeezing as you zoom.
Deliberately not built, and why
- No keybindings in v1. The original plan bound
cmd+=/cmd+-/cmd+0scoped onactiveCustomEditorId, on the belief that this scoped them to the preview being focused. It does not: that key tracks the active editor, so the binding would have stayed live with focus in the terminal or sidebar and silently stolen workbench zoom. Buttons and palette carry the discoverability goal without shadowing anything. - Rhythm scaling deferred.
--codev-canvas-paragraph-spacing(16px) and--codev-canvas-gutter(1.9rem) stay absolute, so vertical rhythm tightens relative to the prose as you zoom. Both are asserted baselines and were left alone pending measurement rather than changed speculatively. - The tall-block cap is inherent, not fixed. It derives from the observed column height rather than font size, so no token change addresses it. Verified at the gate that oversized fences and tables fall back to usable inner vertical scroll, bounded to the column, never clipped.
The honest trade, measured rather than assumed
Em-relative columns do not give a constant measure.
column-widthis a preferred minimum and columns stretch to fill, so the rendered measure is(pane − (n−1)·gap)/nand sawtooths as the column count drops. Fixed pixels fail toward too-narrow under zoom; em fails toward too-wide at the one-column boundary, where the single column stretches uncapped. At 1200px / 24px that is roughly a 100-character line, where pixels would have given two columns at roughly 48. Em is the better default because too-narrow is the complaint that motivated this issue, but it is a choice between failure modes rather than a fix.Recorded follow-up, with the arithmetic already done
Constant measure needs a whole-column container cap of
n·(col+gap), centred. That yieldsmeasure = 50 + 6/ncharacters (about 52 to 56), in which pane width and font size cancel entirely and only the column count survives. It cannot be static CSS, sincendepends on both pane and font and CSS has no floor operation, and it must recompute on font-size change rather than only on resize. Full derivation is in the comments above so a follow-up lane inherits it rather than rediscovering it.Two lessons landed in
codev/resources/lessons-learned.md(COLD tier): theactiveCustomEditorIdactive-versus-focused trap, and the multicol measure analysis.- Three commands (
Problem
#1053 added a typography token tier to the Codev Markdown Preview (artifact canvas) plus two user settings —
codev.markdownPreview.fontSizeandcodev.markdownPreview.lineHeight— that adjust the preview's prose without moving the rest of the IDE chrome (unlike global workbench zoom or the UI font setting).But the only way to change them today is through VSCode's Settings editor (Cmd+, → search "Codev Markdown Preview") or
settings.json. A reviewer reading a long spec has to leave the preview, open Settings, and type a number. There is no control inside the preview itself.This is the discoverable-affordance gap: VSCode's built-in markdown preview, and browsers (Cmd+ / Cmd-), expose zoom directly on the surface being read.
Proposal
Add an in-preview control for the typography knobs #1053 already wired:
Codev: Increase/Decrease Markdown Preview Font Size) with optional keybindings.codev.markdownPreview.fontSize(and possiblylineHeight) and persists the value back to the setting, so the in-preview control and the Settings editor stay the single source of truth.onDidChangeConfigurationre-render inpreview-provider.ts), so a control change reflows immediately.Scope notes
Related
codev.markdownPreview.*settings this would surfaceCustomTextEditorpreview that owns the webview