From 7f140001fa548c4bb6d9faacf3e9a7a800530648 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 18 Aug 2026 19:57:29 +0000 Subject: [PATCH] Simplify image/figure styling: markdown always renders standalone images as figures Standalone markdown images (captioned or not) now become block
elements; only inline-with-text images and raw author HTML stay plain. This collapses the img/figure selector duplication in pagerite.css into a default + override structure: figure fills the column, floats go 30% (1em text gap), an explicit width attribute shrink-wraps the figure (images with width are left untouched by CSS so the attribute hint survives), and .wide re-anchors are grouped with the main rule. --- AGENTS.md | 6 +- docs/design-principles.md | 8 +- frontend/src/assets/pagerite.css | 195 +++++++++++++++++-------------- pagerite/markdown.py | 22 ++-- 4 files changed, 130 insertions(+), 101 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a08846c..cd31b97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,8 +91,10 @@ not for the public pages. See `docs/design-principles.md` for the design. `/favicon.ico` by convention. - `markdown.py` — markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists plugins; typographer + breaks on). Custom - image rule: relative srcs resolve against the page path, titled images - become figures. A `{dates}` line expands to the article's + image rule: relative srcs resolve against the page path; an image + standing alone in its paragraph becomes a figure (captioned when + titled), while inline-with-text images and raw HTML stay plain. + A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/ `modified`; left literal in previews of unsaved pages). - `views.py` — the shared page layout as an html5tagger `Template` with diff --git a/docs/design-principles.md b/docs/design-principles.md index b73c792..abe2f86 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -69,11 +69,13 @@ evolves. are stored by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs that survive page renames and dedupe identical content; pages no longer - own files. An image with a title becomes a `
` with - `
`. Positioning is by attribute classes: + own files. An image standing alone in its paragraph becomes a block + `
` — with `
` when it has a title; images inline + with text and raw `` HTML stay plain inline images. Positioning + is by attribute classes: `![alt](/_f/….avif "Caption"){.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit - `width=300` overrides on uncaptioned images), + `width=300` makes the figure shrink-wrap the image instead), `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. Headings (h1/h2) clear floats, so images never overflow into the diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index 0641c67..19159a4 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -609,40 +609,57 @@ td { padding: 0.35rem 0.8rem; } -/* Images and figures */ +/* Images and figures + + Markdown images standing alone in a paragraph render as a block +
(with
when the image has a title); the + brace-attribute positioning class ({.left}, {.right}, {.wide}) lives + on the img inside, but only the figure is ever positioned, so the + caption stays below the image. Raw HTML written by the author + stays inline and unstyled beyond these defaults. */ img { max-width: 100%; height: auto; border-radius: 6px; } +/* Figures are block containers filling the column; the image fills the + figure. The auto inline margins only take effect once a rule below + shrinks the width (centering the figure); at 100% they compute to 0. */ figure { - margin: 0 0 1.5rem; + margin: 0 auto 1.5rem; + width: 100%; } -figcaption { - color: var(--muted); - font-size: 0.85rem; +/* The image fills the figure, except when it carries an explicit width + attribute — that width is a browser presentational hint which CSS + cannot restore once overridden, so such images are simply left alone. */ +figure img:not([width]) { + width: 100%; } -/* Captions of full-bleed images: centered and kept to a readable width. */ -figure:has(.wide) figcaption { - max-width: 65ch; - margin-inline: auto; - text-align: center; -} - -/* Positioning via brace-attribute classes: {.right}, {.left}, {.wide} */ -figure:has(.right), -img.right { +/* Floated figures: {.right} / {.left}, defaulting to 30% of the column + and capped at half of it. */ +figure:has(.right) { float: right; - margin: 0.3rem 0 1rem 1.5rem; + width: 30%; + max-width: 50%; + margin: 0.3rem 0 1rem 1em; } -figure:has(.left), -img.left { +figure:has(.left) { float: left; - margin: 0.3rem 1.5rem 1rem 0; + width: 30%; + max-width: 50%; + margin: 0.3rem 1em 1rem 0; +} + +/* An image with an explicit width attribute shrink-wraps instead: the + figure fits the image and, per the auto inline margins above, centers + in the column. Placed after the percentage widths above so it + overrides them at equal specificity. */ +figure:has(img[width]) { + width: fit-content; } /* .wide is full bleed: edge to edge of the viewport (or of the space right @@ -651,52 +668,81 @@ img.left { symmetric margins of (50% of the column − 50% of the target width) break out of the column and center the element on the same center — making the left edge land exactly at the viewport's edge (or the editor's right - border). The sidebar stacks above it (z-index + translucent blur). */ -figure:has(.wide), -img.wide { - display: block; - /* no inline strut/descender gaps around the image */ + border). The sidebar stacks above it (z-index + translucent blur). + + The rules below re-anchor the bleed for the layouts where the article + is not viewport-centered; each just overrides width/margin-inline, and + later rules win at equal specificity. */ +figure:has(.wide) { width: 100vw; max-width: none; margin-inline: calc(50% - 50vw); - /* negative margins also kill the UA figure margin */ /* Follow the docked editor's margin-left transition smoothly. */ transition: margin-left 0.25s ease; } -/* A paragraph wrapping only a wide image must not add its line height. */ -p:has(> img.wide:only-child) { - line-height: 0; -} - -/* A captioned image is a
, and the positioning class lives on the - img inside it: only the figure may float/shift, never the img itself, - or the caption would wrap beside the image instead of sitting below. - The image fills the figure's width. */ -figure:has(.right) img, -figure:has(.left) img { - float: none; - margin: 0; - width: 100%; -} - +/* Full bleed means edge to edge — no rounded corners. */ figure:has(.wide) img { - width: 100%; - max-width: 100%; - margin: 0; + border-radius: 0; } -/* Floats take a fixed share of the text column rather than sizing by the - image's intrinsic width, which varies wildly (SVGs have none, and - min-content collapses them). A percentage also scales correctly inside - multi-column segments, where the column is the containing block. The - caption wraps within that width. A width attribute ({.right width=300}) - overrides the default on uncaptioned images. */ -figure:has(.right), -figure:has(.left), -img.right:not([width]), -img.left:not([width]) { - width: 30%; +/* .wide on multicol pages: the article is not viewport-centered (no right + gutter), so the bleed anchors at the left gutter — the 1fr share of the + 1fr + 4fr grid, i.e. 20vw — plus main's padding, and spans on to the + right viewport edge. */ +body:has(.multicol) figure:has(.wide) { + margin-inline: calc(-20vw - 1.25rem) 0; +} + +/* Editing: shrink the bleed to the space right of the docked editor. */ +body.editing figure:has(.wide) { + width: calc(100vw - var(--editor-w)); + margin-inline: calc(50% - (100vw - var(--editor-w)) / 2); +} + +/* Editing + multicol: the left gutter is 1/5 of the space right of the + editor, and the bleed also crosses #content's 1rem editing gap plus + main's 1.25rem left padding (the gutter shrink from the padding roughly + cancels the rounding): −2rem in total. */ +body.editing:has(.multicol) figure:has(.wide) { + margin-inline: calc((100vw - var(--editor-w)) / -5 - 2rem) 0; +} + +/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no + longer both hold the 12rem sidebar, so #content reserves it with a fixed + left track (see the matching media query below) and the article always + starts at 12rem (+ main's 1.25rem padding) — the bleed margin is a plain + constant. Scoped by :has(#sidebar) since the sidebar element is omitted + entirely on pages without sub-navigation, and excluded while editing, + where the editing rules above apply instead. */ +@media (max-width: 102rem) { + body:has(#sidebar):not(.editing) figure:has(.wide) { + margin-inline: -13.25rem 0; + } +} + +figcaption { + color: var(--muted); + line-height: 1; + font-size: 0.85rem; + hyphens: auto; + -webkit-hyphens: auto; + text-wrap: pretty; + text-align: left; + /* Never let a long caption stretch a shrink-to-fit figure wider than the + image; the caption wraps at the figure's width instead. */ + width: 0; + min-width: 100%; +} + +/* Full-bleed figures: center the caption, shrink-wrapped to its text and + capped at a readable width. The min-width shrink-wrap trick above would + win over max-width (min beats max per spec), so it is reset here. */ +figure:has(.wide) figcaption { + width: fit-content; + min-width: 0; + max-width: 65ch; + margin-inline: auto; } /* Section headings start below any floated figures of the previous @@ -709,9 +755,10 @@ article h2 { /* Narrow windows with a sidebar: below 102rem the symmetric gutters can no longer both hold the 12rem sidebar, so reserve its space with a fixed left track instead of letting it overlap the article. The article then - always starts at 12rem (+ main's 1.25rem padding), so the .wide breakout - margin is a plain constant. Scoped by :has(#sidebar) since the sidebar - element is omitted entirely on pages without sub-navigation. */ + always starts at 12rem (+ main's 1.25rem padding), which the matching + .wide breakout rule in the images section relies on. Scoped by + :has(#sidebar) since the sidebar element is omitted entirely on pages + without sub-navigation. */ @media (max-width: 102rem) { body:has(#sidebar):not(.editing) #content { grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr); @@ -720,40 +767,10 @@ article h2 { /* Long articles stay fluid here too: same 12rem reservation for the sidebar, then the article takes everything to the right viewport edge. The article's left edge stays at 12rem either way, so the - constant .wide breakout margin below remains correct. */ + constant .wide breakout margin remains correct. */ body:has(#sidebar):has(.multicol):not(.editing) #content { grid-template-columns: 12rem minmax(0, 1fr); } - - body:has(#sidebar) figure:has(.wide), - body:has(#sidebar) img.wide:not(figure img) { - margin-inline: -13.25rem 0; - } -} - -/* .wide on multicol pages: the article is not viewport-centered (no right - gutter), so the bleed anchors at the left gutter — the 1fr share of the - 1fr + 4fr grid, i.e. 20vw — plus main's padding, and spans on to the - right viewport edge. Loses to the sidebar rule above (id in :has) and to - the editing rules below (same specificity, later in the file). */ -body:has(.multicol) figure:has(.wide), -body:has(.multicol) img.wide:not(figure img) { - margin-inline: calc(-20vw - 1.25rem) 0; -} - -/* Editing: shrink the bleed to the space right of the docked editor. */ -body.editing figure:has(.wide), -body.editing img.wide:not(figure img) { - width: calc(100vw - var(--editor-w)); - margin-inline: calc(50% - (100vw - var(--editor-w)) / 2); -} - -/* Editing + multicol: the left gutter is 1/5 of the space right of the - editor. */ -body.editing:has(.multicol) figure:has(.wide), -body.editing:has(.multicol) img.wide:not(figure img) { - width: calc(100vw - var(--editor-w)); - margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0; } /* Scroll reveal (pagerite.js adds .reveal/.in; JS off = fully visible) */ diff --git a/pagerite/markdown.py b/pagerite/markdown.py index f829c00..01bc446 100644 --- a/pagerite/markdown.py +++ b/pagerite/markdown.py @@ -14,8 +14,10 @@ spans/blocks and raw HTML are left untouched. Images get special treatment: a relative `src` is resolved against the page's own path (so `![alt](photo.avif)` in `/docs/design` is served from -`/docs/design/photo.avif`), and an image with a title becomes a -`
` with `
`. Positioning is done with attribute +`/docs/design/photo.avif`), and an image standing alone in its paragraph +becomes a block `
` — with `
` when it has a title. +Images inline with other content stay plain inline ``, as does raw +`` HTML written by the author. Positioning is done with attribute classes, e.g. `![alt](photo.avif "Caption"){.right}`. """ @@ -70,16 +72,22 @@ def _image_rule( token.attrs["src"] = f"/{page}/{src}" if page else f"/{src}" token.attrs["alt"] = self.renderInlineAsText(token.children, options, env) img = self.renderToken(tokens, idx, options, env) - if title := token.attrs.get("title"): - return f"
{img}
{escapeHtml(title)}
" + if len(tokens) == 1: + # The only inline content of its paragraph: render as a block + # figure, captioned when titled. (The

wrapper is dropped by + # _unwrap_lone_figures below.) + title = token.attrs.get("title") + caption = f"

{escapeHtml(title)}
" if title else "" + return f"
{img}{caption}
" + # Inline with other content: a plain inline image. return img def _unwrap_lone_figures(state) -> None: - """Drop the

wrapper around a lone titled image. + """Drop the

wrapper around a lone image. markdown-it wraps inline content in a paragraph, but our image rule - turns titled images into

— a block element that is invalid + turns lone images into
— a block element that is invalid inside

. Browsers hoist it out, leaving an empty paragraph whose margins disturb the layout. """ @@ -88,7 +96,7 @@ def _unwrap_lone_figures(state) -> None: if token.type != "inline" or not token.children: continue [child] = token.children if len(token.children) == 1 else [None] - if child and child.type == "image" and child.attrs.get("title"): + if child and child.type == "image": if (tokens[i - 1].type == "paragraph_open" and tokens[i + 1].type == "paragraph_close"): tokens[i - 1].hidden = True