Skip to content

vscode: in-preview typography controls (zoom) for the Codev Markdown Preview #1070

Description

@amrmelsayed

Problem

#1053 added a typography token tier to the Codev Markdown Preview (artifact canvas) plus two user settings — codev.markdownPreview.fontSize and codev.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:

  • A small toolbar affordance in the preview (zoom + / −, and a reset), and/or a command-palette command (Codev: Increase/Decrease Markdown Preview Font Size) with optional keybindings.
  • The control adjusts codev.markdownPreview.fontSize (and possibly lineHeight) and persists the value back to the setting, so the in-preview control and the Settings editor stay the single source of truth.
  • Reuse the existing live-reflow path (onDidChangeConfiguration re-render in preview-provider.ts), so a control change reflows immediately.

Scope notes

  • The tokens and settings already exist (vscode: typography tokens for the Codev Markdown Preview (artifact canvas) #1053) — this issue is purely the UI affordance + write-back, not new theming.
  • Decide: webview toolbar buttons (needs a webview→host message for the +/− intent) vs. command-palette commands (simpler, no webview chrome) vs. both.
  • Consider whether the control should widen to the other typography tokens (paragraph spacing, prose width, heading scale) or stay font-size/line-height only.

Related

Activity

  1. added a commit that references this issue on Jun 18, 2026
  2. self-assigned this
    on Jun 18, 2026
  3. amrmelsayed commented on Aug 14, 2026

    @amrmelsayed
    CollaboratorAuthor

    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.fontSize and codev.markdownPreview.lineHeight exist and live-reflow (apps/vscode/src/markdown-preview/preview-provider.ts:258-259 feeding preview-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 fontSize will 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:

    1. 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.
    2. The column height caps bite silently. --_codev-column-cap is 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.
    3. 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.css states 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-gap and --codev-canvas-paragraph-spacing are all part of the public token vocabulary, pinned by a snapshot test in packages/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 though apps/vscode is 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.

  4. amrmelsayed commented on Aug 14, 2026

    @amrmelsayed
    CollaboratorAuthor

    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-69 scopes 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:92 is 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." Changing 400px to 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-width as 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.

  5. amrmelsayed commented on Aug 14, 2026

    @amrmelsayed
    CollaboratorAuthor

    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-gap is 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-width nor --codev-canvas-column-gap carries 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-spacing is asserted as 16px (line 71).
    • --codev-canvas-gutter is asserted as 1.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, and horizontal-input.test.tsx:151 and :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-width as 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:

    1. 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.
    2. Rhythm tightens, because paragraph spacing is a fixed pixel value.
    3. 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-width and --codev-canvas-column-gap em-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.

  6. amrmelsayed commented on Aug 14, 2026

    @amrmelsayed
    CollaboratorAuthor

    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:92 is 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-spacing is asserted as 16px (line 71) and --codev-canvas-gutter as 1.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/48px geometry in mocked layout (fragment-geometry.test.ts:10, horizontal-input.test.tsx:151 and :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.

  7. amrmelsayed commented on Aug 14, 2026

    @amrmelsayed
    CollaboratorAuthor

    On it! Working on this with the PIR protocol (plan + dev-approval gates before PR).

  8. added 2 commits that reference this issue on Aug 14, 2026
  9. amrmelsayed commented on Aug 14, 2026

    @amrmelsayed
    CollaboratorAuthor

    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-width is a preferred minimum and columns stretch to fill, so rendered measure is (pane − (n−1)·gap)/n and 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: none by 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:

    1. 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.

    2. 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. The max-width: none rule 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.)

  10. added a commit that references this issue on Aug 14, 2026
  11. amrmelsayed commented on Aug 14, 2026

    @amrmelsayed
    CollaboratorAuthor

    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  characters
    

    That 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:

    1. It cannot be a static CSS max-width — n depends 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-height from JS, so a sibling --codev-canvas-column-container-max follows the established pattern.
    2. 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.

  12. added 10 commits that reference this issue on Aug 14, 2026
  13. added 3 commits that reference this issue on Aug 15, 2026
  14. amrmelsayed commented on Aug 15, 2026

    @amrmelsayed
    CollaboratorAuthor

    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 on activeCustomEditorId == 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 both fontSize and lineHeight to the 0 sentinel rather than a hardcoded baseline.
    • --codev-canvas-column-width and --codev-canvas-column-gap are now em-relative (25em / 3em, byte-identical to 400px / 48px at 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+0 scoped on activeCustomEditorId, 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-width is a preferred minimum and columns stretch to fill, so the rendered measure is (pane − (n−1)·gap)/n and 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 yields measure = 50 + 6/n characters (about 52 to 56), in which pane width and font size cancel entirely and only the column count survives. It cannot be static CSS, since n depends 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): the activeCustomEditorId active-versus-focused trap, and the multicol measure analysis.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area/vscodeArea: VS Code extension

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions