Simplify image/figure styling: markdown always renders standalone images as figures

Standalone markdown images (captioned or not) now become block <figure>
elements; only inline-with-text images and raw author <img> 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.
This commit is contained in:
2026-08-18 20:29:56 +00:00
parent d87e5ae323
commit 7f140001fa
4 changed files with 130 additions and 101 deletions
+4 -2
View File
@@ -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 <img> 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
+5 -3
View File
@@ -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 `<figure>` with
`<figcaption>`. Positioning is by attribute classes:
own files. An image standing alone in its paragraph becomes a block
`<figure>` — with `<figcaption>` when it has a title; images inline
with text and raw `<img>` 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
+106 -89
View File
@@ -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
<figure> (with <figcaption> 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 <img> 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 <figure>, 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) */
+15 -7
View File
@@ -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
`<figure>` with `<figcaption>`. Positioning is done with attribute
`/docs/design/photo.avif`), and an image standing alone in its paragraph
becomes a block `<figure>` with `<figcaption>` when it has a title.
Images inline with other content stay plain inline `<img>`, as does raw
`<img>` 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"<figure>{img}<figcaption>{escapeHtml(title)}</figcaption></figure>"
if len(tokens) == 1:
# The only inline content of its paragraph: render as a block
# figure, captioned when titled. (The <p> wrapper is dropped by
# _unwrap_lone_figures below.)
title = token.attrs.get("title")
caption = f"<figcaption>{escapeHtml(title)}</figcaption>" if title else ""
return f"<figure>{img}{caption}</figure>"
# Inline with other content: a plain inline image.
return img
def _unwrap_lone_figures(state) -> None:
"""Drop the <p> wrapper around a lone titled image.
"""Drop the <p> wrapper around a lone image.
markdown-it wraps inline content in a paragraph, but our image rule
turns titled images into <figure> — a block element that is invalid
turns lone images into <figure> — a block element that is invalid
inside <p>. 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