Skip to content

Image sizing, alignment, and full bleed; Paper's heading scale (#109) - #110

Merged
alcor merged 12 commits into
mainfrom
feat/109-image-sizing
Sep 14, 2026
Merged

alcor merged 12 commits into
mainfrom
feat/109-image-sizing

Conversation

@alcor

@alcor alcor commented Sep 13, 2026 •

Copy link
Copy Markdown
Member

Closes #109.

Images carry their layout in the markdown, as a Pandoc-style attribute block after the image:

![cat.png](/abcd1234/attachments/x1y2/cat.png){width=50% align=left}
![banner.png](/abcd1234/attachments/x1y2/banner.png){width=full}

width is a percent from 1% to 100% or the token full; align is left, center, or right. Both are optional and both round-trip. Pandoc reads width=50% natively, which is why the brace block was chosen over the title slot, the Obsidian alt pipe, or class tokens; the cost is that GitHub prints the block as literal text after the image. The design, the alternatives, and the amendments made during review are in docs/plans/2026-09-13-image-sizing-design.md.

What it does

  • Natural size by default. An image with no width renders at its intrinsic size, capped by the column.
  • align=left / align=right float, and body text wraps beside the image until the next heading, rule, table, code block, or blockquote. Floats are capped at a third of the column so the text keeps a readable measure. Dropbox Paper caps its own floats at the same third.
  • width=full breaks out of the centred column using container queries against .doc-frame, so it measures the space beside the comment rail rather than the viewport. There is an @supports not (width: 1cqw) fallback.
  • Every image is capped at 80% of the viewport height. The cap holds the aspect ratio rather than letterboxing, so it can override a stated width: a 400x3000 image at width=100% renders 82px wide in a 630px column.
  • Below 640px alignment drops and every image takes its own line.
  • The bubble toolbar offers full bleed (a toggle) and the three alignments. It sets no percentages; a width other than full comes from the markdown.
  • The filename is no longer drawn under the image. It stays as the image's alt.
  • Headings take Dropbox Paper's extended-headings scale in the system font: 30px/36px at -0.4px, 24px/30px at -0.2px, 20px/26px, all at weight 600. The editor sizes them in em, not rem: the root font-size is 14px, so the old 1.875rem rendered an h1 at 26.25px against Paper's 30px.

The EPUB and print pages get the same layout as inline styles, since neither has a per-image stylesheet hook. There, full means 100% of the text width: both are a centred 42em column with no canvas outside it.

Shape

app/shared/image-layout.ts is pure and free of ProseMirror, so the editor schema and the Worker's export path share one parser, one serializer, and one set of rules. width and align default to null, so every existing document serialises byte-identical.

Verification

Measured against a local dev instance at a 630px column, 768px viewport:

markdown rendered
no attributes, 400x200 400px wide
align=left, 400x200 210px, which is 630/3
align=left, 80x80 80px, not stretched
align=center, 400x200 400px, left edge at the column's midpoint minus 200px
{width=60% align=right}, 400x200 210px
no attributes, 400x3000 82 x 614; source ratio 0.133, rendered ratio 0.133
width=full, 1600x300 702 x 132

Headings measured in the editor: h1 30.19px/36.23px at weight 600 and -0.39px; h2 24.15px/30.19px, -0.19px; h3 20.13px/26.16px. Margins 30/12, 26/10, 24/8.

Also verified: a click on Align right reaching /:id.md as {width=50% align=right}; the print page carrying inline styles rather than literal braces; the bubble's buttons reading Full width, Align left, Align centre, Align right and nothing else.

Not verified: no EPUB was opened in a reading system, so whether float and vh behave there is unknown.

Tests

272 pass across tests/unit/shared, tests/unit/components, and the image-command suite, including new files for imageLayoutStyle and for the bubble's layout row. Lint and typecheck clean.

The three localStorage suites (safe-storage, use-theme, anon-identity) fail on Node 25 with localStorage.clear is not a function. They fail identically on main; CLAUDE.md records this as an environment issue.

Deployed

This branch is live on vapor.fyi as version 1d46aa0b-60ca-45f5-9bfb-5155e60ad778, deployed from the branch before merge at Nicholas's direction.

🤖 Generated with Claude Code

alcor and others added 12 commits September 13, 2026 14:12
Layout metadata rides in a Pandoc-style attribute block after the
image, so width and alignment survive /:id.md, the EPUB, the print
page, and an agent read rather than living only in the editor.

align=left and align=right float the image and text wraps beside it;
center and an absent align keep their own line. At the 65ch column a
50% float leaves about 32 characters beside it, which the doc records
as the weakest part of the design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An image alone in a paragraph may now carry a Pandoc-style attribute
block: `![cat.png](/doc/attachments/id/cat.png){width=50% align=left}`.
Width is a percent from 1% to 100% or the token `full`; align is left,
center, or right. Both default to null, so a document without a block
serialises byte-identical and no existing round-trip changes.

Unrecognised keys and out-of-range values are dropped rather than
carried, so the node never holds what it cannot serialise back. Text
after the image that is not brace-shaped leaves the paragraph as it
was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The export parser is a separate MarkdownIt instance that knew nothing
about the attribute block, so `{width=50% align=left}` would have
printed as literal text in both the EPUB and the print page. It now
gets the same rule, applying the layout as inline CSS on the <img>.

Width, align, and the full-width rule move to app/shared/image-layout.ts
so both parsers share them without pulling ProseMirror into the Worker
bundle. READING_CSS clears floats at headings, rules, tables, code, and
blockquotes, so a float runs beside body text and stops at the next
structural boundary.

`full` is 100% of the text width in both exports: READING_CSS sets a
centred 42em column and there is no canvas outside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The node view puts data-width and data-align on the wrapper and sets the
width inline, since an arbitrary percent cannot be a static rule. Full
bleed drops max-width to escape the 65ch column, which costs nothing
here: .tiptap is a left-aligned padded block rather than a centred
column, and the comment rail is a flex sibling that keeps its space.

Floats clear at headings, rules, tables, code, blockquotes, and
non-floated attachments. Below 640px alignment drops and every image
takes its own line, because a 50% float leaves too little text beside
it to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fourth bubble context, detected before the text gates because a node
selection on an atom spans no text and the emptiness and textBetween
checks would both reject it. Four width steps, full width, and three
alignments, each a single setNodeMarkup and so a single Yjs update.

Choosing an alignment on a full-width image steps the width down to 50%:
nothing wraps beside a full-width image, so the alignment would be inert
where it was just asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things running it proved wrong.

The design said .tiptap is a left-aligned padded block and full bleed
cost nothing but a dropped max-width. The editor is inside
`mx-auto w-full max-w-3xl`, a centred 672px column, and a full-bleed
image measured 630px, the same as a heading. It now breaks out with
container units against .doc-frame, the flex sibling of the comment
rail; the viewport would be the wrong unit because it includes the
rail. At a 1400px viewport the frame is 1120, the text column 630, and
full bleed 1078 centred with symmetric gaps, stopping before the rail.

BubbleMenu renders its children into a portal and does not re-render
them per transaction, so the pressed state read a stale editor.state
and every button showed unpressed on a node carrying a width. It reads
through useEditorState now.

Material Symbols is loaded as a named subset, so the four new icons
rendered as raw text until they were added to icon_names in root.tsx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#109)

The filename was drawn under every image as a figcaption, which read as a
caption the author never wrote. It is gone; `alt` still carries the filename.

A floated image now stops at a third of the column, in the editor and in the
EPUB and print stylesheets, so the text beside it keeps a readable measure.
Dropbox Paper caps its own floats at the same third.

Headings take Paper's extended-headings scale: 30px/36px at -0.4px, 24px/30px
at -0.2px, 20px/26px, all at weight 600, with Paper's margins. The face stays
the system sans. The editor sizes them in em rather than rem because the root
here is 14px, which would shrink the whole scale by an eighth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rwise (#109)

The bubble offered 25, 50, 75 and 100 percent. It now offers full bleed and
the three alignments, nothing else. An image with no `width` in its markdown
renders at its natural size, capped by the column; a left or right aligned one
is capped at a third of it.

Full bleed becomes a toggle, and aligning a full-bleed image clears the width
rather than stepping it to 50%.

A centred attachment shrinks to its image, or the auto margins have nothing to
centre: the wrapper is a block and was filling the column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tall image could take the whole screen whatever its width said. The cap is
in app.css and in READING_CSS, so the EPUB and print pages carry it too.

It holds the aspect ratio rather than letterboxing, which means the height can
be the binding constraint: measured at a 768px viewport, a 400x3000 image at
width=100% renders 82x614 in a 630px column, ratio 0.133 either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trigger showed `comment`, which read as "leave a comment" for a button
that opens the whole menu. It shows `menu` in plain Edit; Suggest and Markdown
still replace it with their own glyph.

`menu` joins the named Material Symbols subset in root.tsx and `comment`
leaves it with its last use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
natural goes from 180-320ms between chunks to 60-107ms, and its sentence
pause from 300-900ms to 100-300ms; fast goes from 20-40ms to 7-13ms. Chunk
sizes are unchanged, so the granularity reads the same and only the rate
moves: natural measures about 36 chars/s against 12 before.

The doc comment no longer calls natural "a brisk human typist". 36 chars/s is
roughly 430 words a minute, and the MCP `pace` description now says what the
setting does rather than claiming it is human-paced.

The tests were named for a 30-80ms natural and a 10-20ms fast that the code
never ran at; their assertions were loose enough to pass either way. They now
pin both ends of both ranges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both filters that hide resolved threads exempt the active one, and
resolveThread left activeThreadId pointing at the thread it had just
resolved, so the card sat there until something else was clicked. It now
clears the selection on resolve, which deleteThread already did twenty-seven
lines further down the same file.

This is option 1 from #111: the rail's "Show resolved (n)" is the only undo
path, and the mobile sheet still has no such control. Reopening is unchanged
and leaves the selection alone, since the card is already in view under Show
resolved.

Closes #111

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alcor
alcor merged commit 9451bc2 into main Sep 14, 2026
3 checks passed
@alcor
alcor deleted the feat/109-image-sizing branch September 14, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Image sizing, alignment, and full-bleed

1 participant