Render the column layout structure on the backend, cap at two columns, add a left-margin breakout.

render() now segments the body into .colseg wrappers and flags .multicol
itself, replacing the fragile colseg injection in pagerite.js. Columns
are capped at two; shrink-wrapped figures left-align inside columns.
New {.margin} breakout (and ::: aside) drops blocks into the left gutter
on wide viewports, falling back to in-column floats.
This commit is contained in:
2026-08-27 18:36:49 +00:00
parent f708e1dbca
commit ddf323bf41
8 changed files with 266 additions and 120 deletions
+6 -2
View File
@@ -240,9 +240,13 @@ function runScripts(root) {
}
}
function previewIntoArticle(html, hasH1) {
function previewIntoArticle(html, hasH1, multicol) {
const article = document.querySelector('#main article')
if (!article) return
// The server render owns the column layout: .multicol on the article,
// the segmented .colseg/.cols structure inside .body. Both arrive with
// the preview and must stay in sync as edits cross the thresholds.
article.classList.toggle('multicol', multicol)
const h1 = article.querySelector('h1')
const body = article.querySelector('.body')
// The edit pen may be tucked inside an h1 (title or markdown-owned);
@@ -270,7 +274,7 @@ function onMessage(ev) {
requestRender()
dirty.value = false // just loaded from the server, nothing unsaved
} else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.has_h1)
previewIntoArticle(msg.html, msg.has_h1, msg.multicol)
} else if (msg.type === 'saved') {
saveError.value = ''
pendingSave = null
+92 -32
View File
@@ -275,12 +275,13 @@ body {
grid-template-columns: minmax(0, 1fr) minmax(0, 78rem) minmax(0, 1fr);
}
/* Long articles (.multicol is added by pagerite.js based on content length)
lift the 78rem cap and scrap the right gutter: a 1fr left gutter (which
holds the overlaying sidebar) and the article taking all the rest, out
to the right viewport edge. The column count follows the width (see the
`columns: 30rem` rule below). The .wide breakout is re-anchored to the
left gutter below (the article is no longer viewport-centered). */
/* Long articles (.multicol comes from the backend render, based on
content length — code excluded) lift the 78rem cap and scrap the right
gutter: a 1fr left gutter (which holds the overlaying sidebar and any
margin-breakout boxes) and the article taking all the rest, out to the
right viewport edge. Columns are capped at two (see the
`columns: 30rem 2` rule below). The .wide breakout is re-anchored to
the left gutter below (the article is no longer viewport-centered). */
body:has(.multicol) #content {
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
}
@@ -561,20 +562,28 @@ article dd {
hyphens: auto;
}
/* Multi-column reading, but only for long articles (pagerite.js adds
.multicol based on content length — code blocks excluded — and splits
the body into .colseg segments separated by full-width h2s and .wide
elements; only segments with enough text get .cols, and a ::: nocols
container opts its section out). No fixed breakpoint: `columns: 30rem` lets CSS fit as
many columns of at least 30rem as the article's current width allows —
since .multicol also uncaps the article width (see #content above), a
wider window simply yields more columns. */
/* Multi-column reading, but only for long articles: the backend render
splits the body into .colseg segments (separated by full-width h2s and
.wide elements), tags segments with enough text as .cols (a ::: nocols
container opts its section out) and flags the article .multicol based
on content length — code blocks excluded. Never more than two columns:
`columns: 30rem 2` fits one or two columns of at least 30rem into the
article's current width — since .multicol also uncaps the article
width (see #content above), a wider window widens the two columns
instead of adding more. */
.multicol .colseg.cols {
columns: 30rem;
columns: 30rem 2;
column-gap: 3.5rem;
column-rule: 1px solid var(--line);
}
/* A shrink-wrapped figure (explicit image width) centers in the plain
layout; inside a column the centering looks adrift — left-align.
Floated figures keep their own margins (the text gap). */
.multicol .colseg.cols figure:has(img[width]):not(:has(.left), :has(.right), :has(.margin)) {
margin-inline: 0;
}
.multicol .colseg {
margin-bottom: 1rem;
@@ -725,13 +734,13 @@ blockquote p + p {
--admonition-color: var(--accent3);
}
/* Asides (::: aside): a floated side box in the floated-figure idiom;
consecutive asides stack (clear: right). On wide single-column pages it
leans into the empty right gutter (below 104rem the gutter cannot hold
the box; multicol pages have no right gutter at all, and while editing
the docked panel reshapes the gutters — in all these it stays a plain
float). Headings already clear floats, so asides never bleed into the
next section. */
/* Side boxes: ::: aside is a muted floated box (consecutive asides stack
via clear: right); {.margin} / ::: margin is a plainer margin note, and
figures take {.margin} like {.left}. All of them drop into the left
margin when the layout has room for it (the breakout rules live with
the figure rules below); without room they stay in-column floats —
asides on the right, margin boxes on the left. Headings already clear
floats, so boxes never bleed into the next section. */
.aside {
float: right;
clear: right;
@@ -745,15 +754,19 @@ blockquote p + p {
border-radius: 0.3rem;
}
.aside> :last-child {
.aside> :last-child,
.margin> :last-child {
margin-bottom: 0;
}
@media (min-width: 104rem) {
body:not(.editing):not(:has(.multicol)) .aside {
width: 12rem;
margin-right: -13rem;
}
.margin {
float: left;
clear: left;
width: 30%;
max-width: 20rem;
margin: 0.3rem 1.2rem 1rem 0;
font-size: 0.9rem;
color: var(--muted);
}
pre {
@@ -917,6 +930,52 @@ figure:has(img[width]) {
width: fit-content;
}
/* {.margin} figures float left like {.left} ones — until the margin
breakout below pulls them into the left gutter. */
figure:has(.margin) {
float: left;
width: 30%;
max-width: 50%;
margin: 0.3rem 1em 1rem 0;
}
/* The left-margin breakout: margin boxes ({.margin} / ::: margin blocks,
::: aside, {.margin} figures) leave the text column for the left
gutter, hugging the article's left edge (a 12rem box on a 13.25rem
pull: the gutter plus main's padding). The sticky sidebar shares the
gutter and slides over them translucently — the same overlap full-bleed
.wide images get. Scoped to direct .body children: the backend render
keeps breakout blocks out of the column segments, and nested ones keep
the in-column float fallback. Room exists once the single-column
layout's symmetric gutters fit the box (≥104rem), and on multicol
pages whose 1fr left gutter fits it (≥65rem) — but never while editing
(the docked panel owns the left edge), and not below 102rem with a
sidebar (the fixed 12rem sidebar track, see #content, leaves no free
gutter there). */
@media (min-width: 104rem) {
body:not(.editing) .body>.margin,
body:not(.editing) .body>.aside,
body:not(.editing) .body>figure:has(.margin) {
float: left;
clear: left;
width: 12rem;
max-width: none;
margin: 0.3rem 0 1rem -13.25rem;
}
}
@media (min-width: 65rem) {
body:not(.editing):has(.multicol):not(:has(#sidebar)) .body>.margin,
body:not(.editing):has(.multicol):not(:has(#sidebar)) .body>.aside,
body:not(.editing):has(.multicol):not(:has(#sidebar)) .body>figure:has(.margin) {
float: left;
clear: left;
width: 12rem;
max-width: none;
margin: 0.3rem 0 1rem -13.25rem;
}
}
/* .wide is full bleed: edge to edge of the viewport (or of the space right
of the docked editor), while staying in flow so it keeps its vertical
space. The article column is centered in the available space, so negative
@@ -1038,9 +1097,9 @@ article h2 {
/* Phones and other narrow viewports: single-column layout with the
sidebar lifted above the article as a wrapping link strip, and no
floated figures — .left/.right fall back to plain centered figures
(explicit img widths still shrink-wrap), while .wide keeps its full
viewport bleed. */
floated figures — .left/.right/.margin fall back to plain centered
figures (explicit img widths still shrink-wrap), while .wide keeps its
full viewport bleed. */
@media (max-width: 48rem) {
/* Nav type shrinks fluidly as space runs out. The nav font-size is
@@ -1105,7 +1164,8 @@ article h2 {
}
figure:has(.right),
figure:has(.left) {
figure:has(.left),
figure:has(.margin) {
float: none;
width: 100%;
max-width: none;
-59
View File
@@ -286,66 +286,8 @@ import "overlayscrollbars/overlayscrollbars.css";
// created page has no pen for commitPending's handover click.
renderAuthUi();
placeEditPen();
// Preview swaps also wipe the .colseg wrappers (the server render has
// none), which would drop the multi-column layout until a full reload;
// re-split so columns survive both live editing and closing the editor.
const main = document.getElementById("main");
if (main) applyMulticol(main);
});
// Multi-column layout only when there is enough text to justify it.
// Split the body into columned segments: h1s, h2s and wide elements are
// full-width separators and never go inside columns.
function applyMulticol(main) {
const article = main.querySelector("article");
if (!article) return;
const body = article.querySelector(".body");
// Code blocks don't read as flowing text and are often generated
// filler; exclude them when measuring whether the text justifies
// columns.
const textLen = (el) => {
let n = el.textContent.trim().length;
for (const pre of el.querySelectorAll("pre")) n -= pre.textContent.length;
return n;
};
article.classList.toggle(
"multicol",
!!body && textLen(body) > 1800,
);
if (body && article.classList.contains("multicol")
&& !body.querySelector(".colseg")) {
// h1s, h2s and wide elements (a {.wide} block or anything holding
// one, e.g. a figure with a wide image) are full-width separators
const isSeparator = (el) =>
el.tagName === "H1" || el.tagName === "H2"
|| el.classList.contains("wide")
|| el.querySelector(".wide") !== null;
let seg = null;
for (const el of [...body.children]) {
if (isSeparator(el)) {
seg = null;
body.append(el);
} else {
if (!seg) {
seg = document.createElement("div");
seg.className = "colseg";
body.append(seg);
}
seg.append(el);
}
}
// Columns are per section: only segments with enough text get them,
// so a short ingress or a brief section stays single-column. A
// .nocols container (::: nocols) opts its whole section out.
for (const s of body.querySelectorAll(".colseg")) {
s.classList.toggle(
"cols",
s.querySelector(".nocols") === null && textLen(s) > 600,
);
}
}
}
function applyEffects() {
(window.requestIdleCallback || setTimeout)(preload);
const main = document.getElementById("main");
@@ -355,7 +297,6 @@ import "overlayscrollbars/overlayscrollbars.css";
renderAuthUi();
placeEditPen();
fitNav();
applyMulticol(main);
if (reduceMotion.matches) return;
for (const el of main.querySelectorAll(
"h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item",