Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffa7200a6e | ||
|
|
bdac75e70a | ||
|
|
a116d78951 | ||
|
|
825379ca2a |
+3
-1
@@ -26,7 +26,9 @@ The shared page layout as an html5tagger `Template` with placeholders (`Title`,
|
|||||||
|
|
||||||
Content pages get SEO/social meta (description, canonical link, Open Graph + twitter card) from heuristics over the rendered article: the description is the first paragraph's text, the share image prefers a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG; the first `<video>` yields `og:video`; URLs are made absolute with the site origin (`Data.site_url` — learned from admin browsers reporting their `location.origin` via `POST /_api/site-url`, correct even behind reverse proxies; until learned, the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. If the markdown contains its own h1, the page title is NOT rendered as an additional h1 (it still supplies `<title>` and nav labels).
|
Content pages get SEO/social meta (description, canonical link, Open Graph + twitter card) from heuristics over the rendered article: the description is the first paragraph's text, the share image prefers a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG; the first `<video>` yields `og:video`; URLs are made absolute with the site origin (`Data.site_url` — learned from admin browsers reporting their `location.origin` via `POST /_api/site-url`, correct even behind reverse proxies; until learned, the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. If the markdown contains its own h1, the page title is NOT rendered as an additional h1 (it still supplies `<title>` and nav labels).
|
||||||
|
|
||||||
The navbar holds top-level items only; the current section's subitems go to a left `#sidebar` as a nested list (the section's direct children plain, deeper levels indented with article-list-style markers), which is rendered when the section offers at least two published items, or exactly one while viewing anything other than that only page — the section index, a 404, a grandchild (so those pages can reach the child), and also on that only page itself when it has published children of its own; no aside element at all on the front page, leaf pages and the sole childless page of a one-page section. Also, category labels are nodes without content — None *or* empty markdown — and their nav links point at their first child page. Dynamic regions have stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps (`#sidebar` may be absent on either side of a swap).
|
The navbar holds top-level items only; the current section's subitems go to a left `#sidebar` as a nested list (the section's direct children plain, deeper levels indented with article-list-style markers), rendered only from the second level down — main-level pages list their children as cards after the content instead. Below that, the sidebar renders when the section offers at least two published items, or exactly one while viewing anything other than that only page — the section index, a 404, a grandchild (so those pages can reach the child), and also on that only page itself when it has published children of its own; no aside element at all on the front page, main-level pages, leaf pages and the sole childless page of a one-page section. Also, category labels are nodes without content — None *or* empty markdown — and their nav links point at their first child page. Dynamic regions have stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps (`#sidebar` may be absent on either side of a swap).
|
||||||
|
|
||||||
|
Any page with published children — a category page — lists them as a card grid (`nav.cards`) after the markdown content, as does the content-less category 404. Each card links to the child page (a content-less child to its first leaf) and shows the child's share image (the same hero → first raster → first SVG heuristics as `og:image`) as a full-card cover with the title overlaid.
|
||||||
|
|
||||||
## `seed.py`
|
## `seed.py`
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ The site structure is stored in the kanta database managed by `pagerite/data.py`
|
|||||||
|
|
||||||
`Data.menu` maps top-level slugs to `Node`s, each with `children` keyed by slug — the URL path is the slug chain. The front page is whichever top-level node has slug "" (parallel to the other main level pages, not their parent); it cannot have children, and renaming its slug away leaves no front page ("/" redirects to the first nav item).
|
`Data.menu` maps top-level slugs to `Node`s, each with `children` keyed by slug — the URL path is the slug chain. The front page is whichever top-level node has slug "" (parallel to the other main level pages, not their parent); it cannot have children, and renaming its slug away leaves no front page ("/" redirects to the first nav item).
|
||||||
|
|
||||||
`Node.content` is the Markdown page, or None for a pure category label whose URL renders a placeholder page (while nav links to it point at its first child); every label's title and slug are editable.
|
`Node.content` is the Markdown page, or None for a pure category label whose URL renders a 404 listing its children as cards (while nav links to it point at its first child); every label's title and slug are editable. A page with published children — a category page — lists them as cards after its markdown content; the sidebar sub-navigation renders only from the second level down, never on main-level pages.
|
||||||
|
|
||||||
Siblings order by the fractional `Node.order` key: a moved item gets a fresh key relative to its new siblings, all others keep theirs. `resolve`/`find_slot` walk the tree by path; moves are slot detach/attach carrying the whole subtree. Legacy flat `Data.pages` (pre-tree databases) migrates into `menu` on startup. The app owns the `Data` object; reads are plain attribute access, writes in `kanta.transaction(...)`.
|
Siblings order by the fractional `Node.order` key: a moved item gets a fresh key relative to its new siblings, all others keep theirs. `resolve`/`find_slot` walk the tree by path; moves are slot detach/attach carrying the whole subtree. Legacy flat `Data.pages` (pre-tree databases) migrates into `menu` on startup. The app owns the `Data` object; reads are plain attribute access, writes in `kanta.transaction(...)`.
|
||||||
|
|
||||||
|
|||||||
@@ -19,22 +19,22 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
|
|||||||
|
|
||||||
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
|
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
|
||||||
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
|
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
|
||||||
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — both drop into the left margin when the layout has room for it (the sticky sidebar shares that gutter and slides over them translucently, like full-bleed images), and stay in-column floats otherwise — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
|
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — on all but phone widths they float in the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
|
||||||
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) 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 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: `{.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.margin}` drops it into the left margin like a margin note, `{.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. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
|
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) 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 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: `{.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.margin}` makes it a margin note, floating in the side zone left of the text on all but phone widths, `{.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. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
|
||||||
|
|
||||||
## Page structure and navigation
|
## Page structure and navigation
|
||||||
|
|
||||||
- All pages share one static layout, defined once as an **html5tagger Template** with capitalized placeholders (`Title`, `Banner`, `Nav`, `Sidebar`, `Main`) filled per request. The dynamic regions carry stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`).
|
- All pages share one static layout, defined once as an **html5tagger Template** with capitalized placeholders (`Title`, `Banner`, `Nav`, `Sidebar`, `Main`) filled per request. The dynamic regions carry stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`).
|
||||||
- The page top is a **full-width banner header** with the site name and the navigation bar overlaid on it — no separate chrome header. The banner combines two layers, stacked in `#page-banner` (a grid, so they overlay): first the **banner design** — a named design living in a theme folder (`pagerite/themes/{name}/banner.css` plus artwork as `banner.html` — arbitrary markup like canvas + style + script — or `banner.svg`), chosen per page via `Node.banner_design` (a design name, "" for none, None to inherit from the nearest ancestor, then the front page, then the active theme's own design). The artwork is inlined into a `div[data-design]` wrapper: SVG artwork can be recolored from the theme stylesheet (corporate's single SVG serves both light and dark mode via `var()`-driven stops). Second, **per-page author code**: `Node.banner` holds an arbitrary trusted HTML snippet (an image, a styled div, canvas + script — anything), resolved by walking up the node's ancestors to the front page and rendered **after** the design artwork, so author styles always win over the design's own. The base stylesheet falls back to a plain gradient. There is deliberately no scrim fading the banner into the page background — any such fade would ruin user-supplied designs; themes that want one bake it into their SVG (purple does).
|
- The page top is a **full-width banner header** with the site name and the navigation bar overlaid on it — no separate chrome header. The banner combines two layers, stacked in `#page-banner` (a grid, so they overlay): first the **banner design** — a named design living in a theme folder (`pagerite/themes/{name}/banner.css` plus artwork as `banner.html` — arbitrary markup like canvas + style + script — or `banner.svg`), chosen per page via `Node.banner_design` (a design name, "" for none, None to inherit from the nearest ancestor, then the front page, then the active theme's own design). The artwork is inlined into a `div[data-design]` wrapper: SVG artwork can be recolored from the theme stylesheet (corporate's single SVG serves both light and dark mode via `var()`-driven stops). Second, **per-page author code**: `Node.banner` holds an arbitrary trusted HTML snippet (an image, a styled div, canvas + script — anything), resolved by walking up the node's ancestors to the front page and rendered **after** the design artwork, so author styles always win over the design's own. The base stylesheet falls back to a plain gradient. There is deliberately no scrim fading the banner into the page background — any such fade would ruin user-supplied designs; themes that want one bake it into their SVG (purple does).
|
||||||
- **Fetch-navigation.** Links are plain `<a href>`; a small script (`frontend/src/pagerite.js`) intercepts same-origin clicks, fetches the page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions, the document title, and the site-wide custom CSS (`<style id="pagerite-user">` in `<head>`), keeping the rest of `<head>` and the layout chrome. Without JS everything works as normal page loads. Scripts inside fetched banner and content regions are re-created so they execute. Swaps run inside `document.startViewTransition` for a rotating cube page transition (CSS adapted from termotohtori.fi — the `::view-transition*` block is fragile, do not tweak; skipped under `prefers-reduced-motion`). Navigation within the same top-level section crossfades instead of rotating; browser back navigation rotates in reverse.
|
- **Fetch-navigation.** Links are plain `<a href>`; a small script (`frontend/src/pagerite.js`) intercepts same-origin clicks, fetches the page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions, the document title, and the site-wide custom CSS (`<style id="pagerite-user">` in `<head>`), keeping the rest of `<head>` and the layout chrome. Without JS everything works as normal page loads. Scripts inside fetched banner and content regions are re-created so they execute. Swaps run inside `document.startViewTransition` for a rotating cube page transition (CSS adapted from termotohtori.fi — the `::view-transition*` block is fragile, do not tweak; skipped under `prefers-reduced-motion`). Navigation within the same top-level section crossfades instead of rotating; browser back navigation rotates in reverse.
|
||||||
- **The site structure is a tree of labels.** `Data.menu` holds the top-level items by slug, each with `children` keyed by slug — the URL path is the slug chain. The front page is a top-level node with slug "" (an item *parallel* to the other main level pages, not their parent) and cannot have children. The header navbar holds only the top level; a top-level item is highlighted when viewing any of its subpages. When the current page is inside a main level section with children, those direct children are listed in a **left sidebar** (`#sidebar`), one level deep. The sidebar exists only when there is something to navigate — sections with fewer than two published items, leaf pages and the front page render no aside element at all. Other sections' subitems are never shown without navigating into them first.
|
- **The site structure is a tree of labels.** `Data.menu` holds the top-level items by slug, each with `children` keyed by slug — the URL path is the slug chain. The front page is a top-level node with slug "" (an item *parallel* to the other main level pages, not their parent) and cannot have children. The header navbar holds only the top level; a top-level item is highlighted when viewing any of its subpages. A page with published children lists them as **cards** after its content (the child page's share image as the cover, like the og tags, with the title overlaid); a **left sidebar** (`#sidebar`) with the section's sub-navigation appears only from the second level down, when there is something to navigate — main-level pages, sections with fewer than two published items, leaf pages and the front page render no aside element at all. Other sections' subitems are never shown without navigating into them first.
|
||||||
- **Landing pages are optional.** Every label can either have content (`Node.content`, a Markdown page) or none — a content-less label renders a placeholder page (404 with a pen to create it) instead of redirecting, while nav links to it point straight at its first child, so categories need no filler content and normal navigation never sees the placeholder. Title and slug of every label are editable; renaming a slug moves the whole subtree. The sidebar never lists the section itself, avoiding title duplication with the navbar.
|
- **Landing pages are optional.** Every label can either have content (`Node.content`, a Markdown page) or none — a content-less label renders a 404 page listing its children as cards (with a pen to create the landing page) instead of redirecting, while nav links to it point straight at its first child, so categories need no filler content and normal navigation never sees the 404. Title and slug of every label are editable; renaming a slug moves the whole subtree. The sidebar never lists the section itself, avoiding title duplication with the navbar.
|
||||||
- **Menu order is manual.** Each node has a fractional `order` key among its siblings; reordering/moving writes only the moved node (it takes a fresh value halfway between its new siblings; all other items keep theirs). New pages append at the end of their menu. Structure edits (reorder, move/rename with the whole subtree, retitle) go through `POST /_api/structure` and the editor's structure panel.
|
- **Menu order is manual.** Each node has a fractional `order` key among its siblings; reordering/moving writes only the moved node (it takes a fresh value halfway between its new siblings; all other items keep theirs). New pages append at the end of their menu. Structure edits (reorder, move/rename with the whole subtree, retitle) go through `POST /_api/structure` and the editor's structure panel.
|
||||||
- Unpublished pages are hidden from both nav and URL access (404).
|
- Unpublished pages are hidden from both nav and URL access (404).
|
||||||
|
|
||||||
## Reading experience
|
## Reading experience
|
||||||
|
|
||||||
- The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it; long articles (flagged `.multicol` by the backend render) lift the 78rem cap and take everything right of the left gutter, out to the viewport edge. The backend also splits the body into `.colseg` segments at h1/h2 headings, `.wide` elements and margin-breakout blocks (full-width separators or margin boxes, never inside columns), tagging segments that hold enough text with `.cols` — code blocks are excluded from that measure, and a `::: nocols` container opts its whole section out. The CSS then fits **at most two columns** of at least 30rem per segment, so a wider window widens the pair instead of adding columns. Margin boxes (`.margin`, `::: aside`) leave the column flow for the left gutter on wide viewports and stay in-column floats below that.
|
- The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it. Long articles (flagged `.multicol` by the backend render) lift the cap and become a bounded **composition**, centered in the available space with the surplus left vacant: a fluid text lane (up to 42rem) plus a 16rem **side zone at the article's left** — the region the nav sidebar overlays — which hosts margin boxes (`.margin`, `::: aside`, margin figures) at all but phone widths, without the text ever moving. On pages with a sidebar below ~110rem (where the sidebar gets its own 12rem track) the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem, and the boxes fall into the track, sliding under the translucent sticky nav. Once two lanes fit beside the zone (≥96rem available in `main`), the text flows in two fluid lanes (36rem minimum, capped at 102rem total — technical content wants the wider lanes, and wider windows just add vacant space). The stages step by the space actually available in `main` (container queries + `cqw` units, so the docked editor's inset is automatic). `.wide` figures on multicol pages bleed to the viewport edges measured from `main` (`cqw`), sliding under the sidebar. The backend splits the body into `.colseg` segments at h1/h2 headings, `.wide` elements and margin blocks (full-width separators or margin boxes, never inside columns), tagging segments that hold enough text with `.cols` — code blocks are excluded from that measure, and a `::: nocols` container opts its whole section out. On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well.
|
||||||
- A gentle **scroll-reveal** of headings, figures and block-level elements (IntersectionObserver). It is layout-level: articles need no support for it, and `prefers-reduced-motion` disables all motion.
|
- A gentle **scroll-reveal** of headings, figures and block-level elements (IntersectionObserver). It is layout-level: articles need no support for it, and `prefers-reduced-motion` disables all motion.
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|||||||
+328
-105
@@ -276,14 +276,24 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Long articles (.multicol comes from the backend render, based on
|
/* Long articles (.multicol comes from the backend render, based on
|
||||||
content length — code excluded) lift the 78rem cap and scrap the right
|
content length — code excluded) lift the 78rem cap: main takes the full
|
||||||
gutter: a 1fr left gutter (which holds the overlaying sidebar and any
|
width and the article composes itself inside it — fluid, bounded text
|
||||||
margin-breakout boxes) and the article taking all the rest, out to the
|
lanes, centered, with the surplus left vacant (see the article layout
|
||||||
right viewport edge. Columns are capped at two (see the
|
rules below). The left track — main always sits in column 2 — collapses
|
||||||
`columns: 30rem 2` rule below). The .wide breakout is re-anchored to
|
to zero when the page has no sidebar; the sidebar then simply overlays
|
||||||
the left gutter below (the article is no longer viewport-centered). */
|
the vacant zone, as it does on single-column pages. */
|
||||||
body:has(.multicol) #content {
|
body:has(.multicol) #content {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
|
grid-template-columns: 0 minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only when the vacant zone cannot hold the sidebar does it get its own
|
||||||
|
12rem track — the same compromise single-column pages make below
|
||||||
|
102rem. Above that, a sidebar's presence changes nothing about the
|
||||||
|
article. */
|
||||||
|
@media (max-width: 110rem) {
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) #content {
|
||||||
|
grid-template-columns: 12rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.editing #content {
|
body.editing #content {
|
||||||
@@ -421,6 +431,126 @@ main {
|
|||||||
/* No top padding: a leading wide image sits flush under the banner, and
|
/* No top padding: a leading wide image sits flush under the banner, and
|
||||||
text-first pages get their spacing from the h1's top margin instead. */
|
text-first pages get their spacing from the h1's top margin instead. */
|
||||||
padding: 0 1.25rem 3rem;
|
padding: 0 1.25rem 3rem;
|
||||||
|
/* The layout container for the article composition: the multicol stage
|
||||||
|
(lane count), the margin fall and the .wide bleed respond to the
|
||||||
|
actual available width here — editor inset included — via container
|
||||||
|
queries and cqw units. */
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Child-entry card stacks: a category page (and the content-less category
|
||||||
|
404) lists its published children after the markdown content — one column
|
||||||
|
per child, the child's whole subtree flattened into the column in menu
|
||||||
|
order (see _cards in views.py). The row bleeds to full page width
|
||||||
|
(div.wide): the columns first grow to fill it, then shrink rather than
|
||||||
|
wrap. Every card has the same fixed 16/10 shape, covered entirely by the
|
||||||
|
page's share image (og:image heuristics, as a background — a gradient
|
||||||
|
placeholder when it has none) with the title overlaid on a translucent
|
||||||
|
band at the bottom. The card is one <a> holding only phrasing-level
|
||||||
|
spans; the spans lay out as blocks. */
|
||||||
|
.cards {
|
||||||
|
display: flex;
|
||||||
|
/* Stacks stop growing at their cap; center the row in the bleed then. */
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
/* The full-bleed breakout (div.wide rules) lands the edges exactly on the
|
||||||
|
viewport's; this keeps the cards themselves off the edges. */
|
||||||
|
padding-inline: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards .stack {
|
||||||
|
/* Grow to fill the row (up to the cap — full-width rows would make huge
|
||||||
|
cards), shrink (not wrap) when there are too many. */
|
||||||
|
flex: 1 1 0;
|
||||||
|
max-width: 24rem;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phones: the columns stack vertically instead of shrinking to slivers.
|
||||||
|
Text-only cards (gradient cover + description) then fit their content —
|
||||||
|
the fixed 16/10 shape only makes sense for image covers. */
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.cards {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:has(.desc) {
|
||||||
|
aspect-ratio: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
/* Allow shrinking below the text's min-content width: without this the
|
||||||
|
text cards hold their stack wider than the image-only stacks. */
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
box-shadow: 0 0 0.1rem black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 0 0.3rem black;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cover fills the whole card; the text spans are positioned so they
|
||||||
|
paint above it. */
|
||||||
|
.card .cover {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, var(--surface), var(--bg));
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .title {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0.75rem 1.25rem 0.9rem;
|
||||||
|
background: color-mix(var(--bg) 40%, transparent);
|
||||||
|
/* Pinned: the article a:hover accent must not leak through the card. */
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image-less cards carry the description under the title, extending the
|
||||||
|
same translucent band. */
|
||||||
|
.card .title:has(+ .desc) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .desc {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Four lines exactly: the text itself is truncated server-side (_cards),
|
||||||
|
this is just the safety net. max-height spans the lines plus the
|
||||||
|
vertical padding (border-box), so nothing bleeds past the clip. */
|
||||||
|
line-height: 1.4;
|
||||||
|
max-height: calc(4 * 1.4em + 1.3rem);
|
||||||
|
padding: 0.4rem 1.25rem 0.9rem;
|
||||||
|
background: color-mix(var(--bg) 30%, transparent);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: left;
|
||||||
|
hyphens: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
article h1,
|
article h1,
|
||||||
@@ -562,19 +692,100 @@ article dd {
|
|||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Multi-column reading, but only for long articles: the backend render
|
/* The long-article composition (.multicol): a fluid but bounded text
|
||||||
splits the body into .colseg segments (separated by full-width h2s and
|
lane with a 16rem side zone at the article's left, centered in main —
|
||||||
.wide elements), tags segments with enough text as .cols (a ::: nocols
|
surplus width becomes vacant space, never endless text. (The backend
|
||||||
container opts its section out) and flags the article .multicol based
|
render splits the body into .colseg segments separated by full-width
|
||||||
on content length — code blocks excluded. Never more than two columns:
|
h2s and .wide elements, tags text-heavy segments .cols — a ::: nocols
|
||||||
`columns: 30rem 2` fits one or two columns of at least 30rem into the
|
container opts its section out — and flags the article .multicol; CSS
|
||||||
article's current width — since .multicol also uncaps the article
|
owns the geometry.) Technical content wants a wide lane: up to 42rem
|
||||||
width (see #content above), a wider window widens the two columns
|
single, or two fluid lanes (36rem minimum, never more than two) once
|
||||||
instead of adding more. */
|
they fit beside the zone, capped at 102rem total. The zone — the
|
||||||
.multicol .colseg.cols {
|
region the nav sidebar overlays — is a margin indent on the lane
|
||||||
columns: 30rem 2;
|
content; margin boxes float into it, and the text never moves. */
|
||||||
column-gap: 3.5rem;
|
article.multicol {
|
||||||
column-rule: 1px solid var(--line);
|
margin-inline: auto;
|
||||||
|
max-width: 58rem; /* 42rem lane + 16rem zone */
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 45rem) {
|
||||||
|
/* The side zone (not on phones): lane content indents 16rem; margin
|
||||||
|
boxes ({.margin} / ::: margin blocks, ::: aside, {.margin} figures)
|
||||||
|
float at the article's left edge — the same region the nav sidebar
|
||||||
|
overlays. Scoped to direct .body children (the backend render keeps
|
||||||
|
margin blocks out of the column segments); nested ones keep the
|
||||||
|
in-column float fallback. */
|
||||||
|
.multicol .body>.colseg,
|
||||||
|
.multicol .body>h1,
|
||||||
|
.multicol .body>h2,
|
||||||
|
article.multicol>h1 {
|
||||||
|
margin-left: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multicol .body>.margin,
|
||||||
|
.multicol .body>.aside,
|
||||||
|
.multicol .body>figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
clear: left;
|
||||||
|
width: 14rem;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0.3rem 2rem 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wide separators start below any margin box — their bleed must not
|
||||||
|
wrap around it. */
|
||||||
|
.multicol .body>figure:has(.wide),
|
||||||
|
.multicol .body>div.wide,
|
||||||
|
.multicol .body>pre.wide {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 96rem) {
|
||||||
|
article.multicol {
|
||||||
|
max-width: 102rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two fluid lanes (36rem minimum) beside the zone, up to the 102rem
|
||||||
|
cap — wider windows just add vacant space. */
|
||||||
|
.multicol .colseg.cols {
|
||||||
|
columns: 36rem 2;
|
||||||
|
column-gap: 3.5rem;
|
||||||
|
column-rule: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* With a sidebar, below 110rem the sidebar gets its own 12rem track (see
|
||||||
|
#content). The track IS the left lane then: no in-article zone, the
|
||||||
|
text lane runs fluid (up to 86rem), and margin boxes fall all the way
|
||||||
|
left into the track — sliding under the translucent sticky nav, which
|
||||||
|
only ever occupies its top. (The track never exists once the container
|
||||||
|
reaches 96rem, so the two-lane rules above never meet it. Not below
|
||||||
|
48rem: there the sidebar becomes a link strip above the article and
|
||||||
|
there is no track to fall into.) */
|
||||||
|
@media (min-width: 48rem) and (max-width: 110rem) {
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) article.multicol {
|
||||||
|
max-width: 86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>.colseg,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>h1,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>h2,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>h1 {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>.margin,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>.aside,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) .multicol .body>figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
clear: left;
|
||||||
|
width: 12rem;
|
||||||
|
max-width: none;
|
||||||
|
/* From the text's left edge to the page's left edge: half the
|
||||||
|
centering difference plus the track and main's padding. */
|
||||||
|
margin: 0.3rem 0 1rem calc(50% - 50cqw - 13.25rem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A shrink-wrapped figure (explicit image width) centers in the plain
|
/* A shrink-wrapped figure (explicit image width) centers in the plain
|
||||||
@@ -735,18 +946,19 @@ blockquote p + p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Side boxes: ::: aside is a muted floated box (consecutive asides stack
|
/* Side boxes: ::: aside is a muted floated box (consecutive asides stack
|
||||||
via clear: right); {.margin} / ::: margin is a plainer margin note, and
|
via clear: left); {.margin} / ::: margin is a plainer margin note, and
|
||||||
figures take {.margin} like {.left}. All of them drop into the left
|
figures take {.margin} like {.left}. On multicol pages they float in
|
||||||
margin when the layout has room for it (the breakout rules live with
|
the composition's left side zone — or in the sidebar's track when the
|
||||||
the figure rules below); without room they stay in-column floats —
|
layout reserves one (see the article section); on wide single-column
|
||||||
asides on the right, margin boxes on the left. Headings already clear
|
pages they lean into the left gutter (with the figure rules below);
|
||||||
|
otherwise they stay in-column left floats. Headings already clear
|
||||||
floats, so boxes never bleed into the next section. */
|
floats, so boxes never bleed into the next section. */
|
||||||
.aside {
|
.aside {
|
||||||
float: right;
|
float: left;
|
||||||
clear: right;
|
clear: left;
|
||||||
width: 30%;
|
width: 30%;
|
||||||
max-width: 20rem;
|
max-width: 20rem;
|
||||||
margin: 0.3rem 0 1rem 1.2rem;
|
margin: 0.3rem 1.2rem 1rem 0;
|
||||||
padding: 0.6rem 0.9rem;
|
padding: 0.6rem 0.9rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -759,6 +971,22 @@ blockquote p + p {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A figure inside an aside spans the box to its borders (negative
|
||||||
|
margins matching the box's padding); the caption keeps the box's
|
||||||
|
padding for itself. Explicit-width images keep their shrink-wrap. */
|
||||||
|
.aside>figure:not(:has(img[width])) {
|
||||||
|
width: auto;
|
||||||
|
margin: -0.6rem -0.9rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside>figure figcaption {
|
||||||
|
padding: 0.6rem 0.9rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside>figure:last-child figcaption {
|
||||||
|
padding-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.margin {
|
.margin {
|
||||||
float: left;
|
float: left;
|
||||||
clear: left;
|
clear: left;
|
||||||
@@ -930,8 +1158,8 @@ figure:has(img[width]) {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* {.margin} figures float left like {.left} ones — until the margin
|
/* {.margin} figures float left like {.left} ones — until they fall into
|
||||||
breakout below pulls them into the left gutter. */
|
the side zone (see the composition rules up in the article section). */
|
||||||
figure:has(.margin) {
|
figure:has(.margin) {
|
||||||
float: left;
|
float: left;
|
||||||
width: 30%;
|
width: 30%;
|
||||||
@@ -939,35 +1167,14 @@ figure:has(.margin) {
|
|||||||
margin: 0.3rem 1em 1rem 0;
|
margin: 0.3rem 1em 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The left-margin breakout: margin boxes ({.margin} / ::: margin blocks,
|
/* Wide single-column pages: margin boxes lean into the vacant left
|
||||||
::: aside, {.margin} figures) leave the text column for the left
|
gutter instead (below 104rem the gutter cannot hold the box, and while
|
||||||
gutter, hugging the article's left edge (a 12rem box on a 13.25rem
|
editing the docked panel reshapes the gutters — in both they stay
|
||||||
pull: the gutter plus main's padding). The sticky sidebar shares the
|
plain floats). */
|
||||||
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) {
|
@media (min-width: 104rem) {
|
||||||
body:not(.editing) .body>.margin,
|
body:not(.editing):not(:has(.multicol)) .body>.margin,
|
||||||
body:not(.editing) .body>.aside,
|
body:not(.editing):not(:has(.multicol)) .body>.aside,
|
||||||
body:not(.editing) .body>figure:has(.margin) {
|
body:not(.editing):not(:has(.multicol)) .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;
|
float: left;
|
||||||
clear: left;
|
clear: left;
|
||||||
width: 12rem;
|
width: 12rem;
|
||||||
@@ -1004,42 +1211,55 @@ pre.wide {
|
|||||||
border-radius: 0;
|
border-radius: 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. */
|
|
||||||
body:has(.multicol) figure:has(.wide),
|
|
||||||
body:has(.multicol) pre.wide {
|
|
||||||
margin-inline: calc(-20vw - 1.25rem) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Editing: shrink the bleed to the space right of the docked editor. The
|
/* Editing: shrink the bleed to the space right of the docked editor. The
|
||||||
window keeps its overlay scrollbars while editing, so — unlike a classic
|
window keeps its overlay scrollbars while editing, so — unlike a classic
|
||||||
scrollbar — they take no layout space and the vw math stays exact. */
|
scrollbar — they take no layout space and the vw math stays exact.
|
||||||
|
Multicol pages measure the bleed from main instead (cqw rules below),
|
||||||
|
which insets for the editor automatically. */
|
||||||
body.editing figure:has(.wide),
|
body.editing figure:has(.wide),
|
||||||
|
body.editing div.wide,
|
||||||
body.editing pre.wide {
|
body.editing pre.wide {
|
||||||
width: calc(100vw - var(--editor-w));
|
width: calc(100vw - var(--editor-w));
|
||||||
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
|
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Editing + multicol: the left gutter is 1/5 of the space right of the
|
/* Narrow single-column pages with a sidebar: below 102rem the symmetric
|
||||||
editor, and the bleed also crosses main's 1.25rem left padding. */
|
gutters can no longer both hold the 12rem sidebar, so #content reserves
|
||||||
body.editing:has(.multicol) figure:has(.wide),
|
it with a fixed left track (see the matching media query below) and the
|
||||||
body.editing:has(.multicol) pre.wide {
|
article always starts at 12rem (+ main's 1.25rem padding) — the bleed
|
||||||
margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0;
|
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.
|
||||||
|
(Multicol pages use the same fixed track below 110rem — their rules
|
||||||
|
are below.) */
|
||||||
|
@media (max-width: 102rem) {
|
||||||
|
body:has(#sidebar):not(.editing):not(:has(.multicol)) figure:has(.wide),
|
||||||
|
body:has(#sidebar):not(.editing):not(:has(.multicol)) div.wide,
|
||||||
|
body:has(#sidebar):not(.editing):not(:has(.multicol)) pre.wide {
|
||||||
|
margin-inline: -13.25rem 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
|
/* .wide on multicol pages: the same edge-to-edge bleed, but measured from
|
||||||
longer both hold the 12rem sidebar, so #content reserves it with a fixed
|
main (the container) instead of the viewport — cqw includes the editor
|
||||||
left track (see the matching media query below) and the article always
|
inset automatically, so no editing override is needed. The 2.5rem
|
||||||
starts at 12rem (+ main's 1.25rem padding) — the bleed margin is a plain
|
covers main's side padding. */
|
||||||
constant. Scoped by :has(#sidebar) since the sidebar element is omitted
|
body:has(.multicol) figure:has(.wide),
|
||||||
entirely on pages without sub-navigation, and excluded while editing,
|
body:has(.multicol) div.wide,
|
||||||
where the editing rules above apply instead. */
|
body:has(.multicol) pre.wide {
|
||||||
@media (max-width: 102rem) {
|
width: calc(100cqw + 2.5rem);
|
||||||
body:has(#sidebar):not(.editing) figure:has(.wide),
|
margin-inline: calc(50% - 50cqw - 1.25rem);
|
||||||
body:has(#sidebar):not(.editing) pre.wide {
|
}
|
||||||
margin-inline: -13.25rem 0;
|
|
||||||
|
/* Multicol with a sidebar track (≤110rem, see #content): main starts
|
||||||
|
12rem in, so the bleed extends left past the track to the true viewport
|
||||||
|
edge — sliding under the translucent sidebar. */
|
||||||
|
@media (max-width: 110rem) {
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) figure:has(.wide),
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) div.wide,
|
||||||
|
body:has(#sidebar):has(.multicol):not(.editing) pre.wide {
|
||||||
|
width: calc(100cqw + 14.5rem);
|
||||||
|
margin-inline: calc(50% - 50cqw - 13.25rem) 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1076,30 +1296,23 @@ article h2 {
|
|||||||
|
|
||||||
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
|
/* 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
|
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
|
left track instead of letting it overlap the article (multicol pages use
|
||||||
always starts at 12rem (+ main's 1.25rem padding), which the matching
|
this same track at every width — see the #content rules above). The
|
||||||
.wide breakout rule in the images section relies on. Scoped by
|
article then always starts at 12rem (+ main's 1.25rem padding), which
|
||||||
:has(#sidebar) since the sidebar element is omitted entirely on pages
|
the matching .wide breakout rule in the images section relies on.
|
||||||
without sub-navigation. */
|
Scoped by :has(#sidebar) since the sidebar element is omitted entirely
|
||||||
|
on pages without sub-navigation. */
|
||||||
@media (max-width: 102rem) {
|
@media (max-width: 102rem) {
|
||||||
body:has(#sidebar):not(.editing) #content {
|
body:has(#sidebar):not(.editing) #content {
|
||||||
grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr);
|
grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 remains correct. */
|
|
||||||
body:has(#sidebar):has(.multicol):not(.editing) #content {
|
|
||||||
grid-template-columns: 12rem minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phones and other narrow viewports: single-column layout with the
|
/* Phones and other narrow viewports: single-column layout with the
|
||||||
sidebar lifted above the article as a wrapping link strip, and no
|
sidebar lifted above the article as a wrapping link strip, and no
|
||||||
floated figures — .left/.right/.margin fall back to plain centered
|
floats at all — .left/.right/.margin figures fall back to plain
|
||||||
figures (explicit img widths still shrink-wrap), while .wide keeps its
|
centered figures (explicit img widths still shrink-wrap), margin boxes
|
||||||
full viewport bleed. */
|
go full width, while .wide keeps its full viewport bleed. */
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
|
|
||||||
/* Nav type shrinks fluidly as space runs out. The nav font-size is
|
/* Nav type shrinks fluidly as space runs out. The nav font-size is
|
||||||
@@ -1172,17 +1385,27 @@ article h2 {
|
|||||||
margin: 0 auto 1.5rem;
|
margin: 0 auto 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Margin boxes go full width too — no room for side floats on a
|
||||||
|
phone. */
|
||||||
|
.aside,
|
||||||
|
.margin {
|
||||||
|
float: none;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* An explicit img width still shrink-wraps the figure (redeclared: this
|
/* An explicit img width still shrink-wraps the figure (redeclared: this
|
||||||
block comes after the desktop rule at equal specificity). */
|
block comes after the desktop rule at equal specificity). */
|
||||||
figure:has(img[width]) {
|
figure:has(img[width]) {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The 102rem sidebar/multicol .wide margins assume a left sidebar
|
/* The single-column sidebar .wide margins assume a left sidebar column;
|
||||||
column; with the sidebar on top the article is viewport-wide and the
|
with the sidebar on top the article is viewport-wide and the plain
|
||||||
plain centered bleed applies again. */
|
centered bleed applies again. (Multicol pages need no override: their
|
||||||
body:has(#sidebar):not(.editing) figure:has(.wide),
|
cqw bleed is exact at any width.) */
|
||||||
body:has(.multicol) figure:has(.wide) {
|
body:has(#sidebar):not(.editing):not(:has(.multicol)) figure:has(.wide) {
|
||||||
margin-inline: calc(50% - 50vw);
|
margin-inline: calc(50% - 50vw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ same callout styling). ``::: name`` opens a generic container rendered
|
|||||||
as ``<div class="name">`` and closed by a matching ``:::`` (nest by
|
as ``<div class="name">`` and closed by a matching ``:::`` (nest by
|
||||||
giving the outer container more colons, e.g. `::::`); the name may be
|
giving the outer container more colons, e.g. `::::`); the name may be
|
||||||
followed by brace attributes (``::: aside {.right}``). ``::: aside``
|
followed by brace attributes (``::: aside {.right}``). ``::: aside``
|
||||||
floats as a muted side box, dropping into the left margin on wide
|
floats as a muted side box, floating in the side zone at the article's
|
||||||
viewports — the same margin breakout ``{.margin}`` (or ``::: margin``)
|
left on all but phone widths — the same margin float ``{.margin}`` (or
|
||||||
gives any block — and ``::: nocols`` opts its section out of the column
|
``::: margin``) gives any block — and ``::: nocols`` opts its section out
|
||||||
layout. A brace-attribute
|
of the column layout. A brace-attribute
|
||||||
line as a block's last line (no blank line between) applies to the whole
|
line as a block's last line (no blank line between) applies to the whole
|
||||||
block, e.g. a paragraph ending with ``{.wide}`` breaks out of the column
|
block, e.g. a paragraph ending with ``{.wide}`` breaks out of the column
|
||||||
layout as a full-width element; written after a block (code fence,
|
layout as a full-width element; written after a block (code fence,
|
||||||
@@ -352,9 +352,9 @@ _PRE_BLOCK_RE = re.compile(r"<pre\b.*?</pre>", re.S)
|
|||||||
_TAG_RE = re.compile(r"<[^>]+>")
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
# Classes that take their block out of the column flow: .wide is a
|
# Classes that take their block out of the column flow: .wide is a
|
||||||
# full-width separator, .margin/.aside break into the left margin (their
|
# full-width separator, .margin/.aside float in the side zone at the
|
||||||
# negative-margin breakout only works as a direct .body child, never from
|
# article's left (they must be direct .body children for that — the zone
|
||||||
# inside a column).
|
# rules key off it — never inside a column).
|
||||||
_WIDE = "wide"
|
_WIDE = "wide"
|
||||||
_BREAKOUT = ("margin", "aside")
|
_BREAKOUT = ("margin", "aside")
|
||||||
|
|
||||||
@@ -474,10 +474,10 @@ def render(
|
|||||||
|
|
||||||
def _dateline(created: datetime, modified: datetime | None) -> str:
|
def _dateline(created: datetime, modified: datetime | None) -> str:
|
||||||
"""Dateline for the ``{dates}`` tag: "1 Jan 2026", plus
|
"""Dateline for the ``{dates}`` tag: "1 Jan 2026", plus
|
||||||
" – edited 3 Jan 2026" when the last edit came >= 24h after
|
" – edited 3 Jan 2026" when the last edit came >= 48h after
|
||||||
publishing (quick fixes right after posting stay unmentioned)."""
|
publishing (quick fixes right after posting stay unmentioned)."""
|
||||||
out = f'<time datetime="{created.isoformat()}">{created.day} {created:%b %Y}</time>'
|
out = f'<time datetime="{created.isoformat()}">{created.day} {created:%b %Y}</time>'
|
||||||
if modified is not None and modified - created >= timedelta(hours=24):
|
if modified is not None and modified - created >= timedelta(hours=48):
|
||||||
out += f' – edited <time datetime="{modified.isoformat()}">{modified.day} {modified:%b %Y}</time>'
|
out += f' – edited <time datetime="{modified.isoformat()}">{modified.day} {modified:%b %Y}</time>'
|
||||||
return f'<p class="dateline">{out}</p>'
|
return f'<p class="dateline">{out}</p>'
|
||||||
|
|
||||||
|
|||||||
+129
-39
@@ -8,10 +8,11 @@ can swap them without reloading the page chrome.
|
|||||||
Navigation walks the Node tree directly (see data.py): nav_html lists the
|
Navigation walks the Node tree directly (see data.py): nav_html lists the
|
||||||
top level — the front page (slug "") is an ordinary top-level item, not
|
top level — the front page (slug "") is an ordinary top-level item, not
|
||||||
the parent of the others — and sidebar_html the sub-navigation of the
|
the parent of the others — and sidebar_html the sub-navigation of the
|
||||||
current top-level section, rendered only when the section offers at
|
current top-level section, rendered only from the second level down
|
||||||
least two published items. Nodes without content are category labels; nav links
|
(main-level pages list their children as cards after the content instead,
|
||||||
|
see page_content). Nodes without content are category labels; nav links
|
||||||
to them point straight at their first child page (first_leaf), and their
|
to them point straight at their first child page (first_leaf), and their
|
||||||
own URL renders a placeholder page (render_category).
|
own URL renders a card-listing page (render_category, a 404).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -332,17 +333,19 @@ def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
|
|||||||
The sidebar is the current main level section's sub-navigation: the
|
The sidebar is the current main level section's sub-navigation: the
|
||||||
section's direct children as the top list level, with each item's own
|
section's direct children as the top list level, with each item's own
|
||||||
published children nested under it (third level and deeper), so it
|
published children nested under it (third level and deeper), so it
|
||||||
exists only when there is something to navigate: the section must
|
exists only when there is something to navigate. It renders only from
|
||||||
offer at least two published items, or exactly one while viewing
|
the second level down: main-level pages (and the front page) list
|
||||||
anything else than that only page — the section index, a 404, a
|
their children as cards after the content instead of a sidebar. From
|
||||||
grandchild (otherwise those pages offer no way to reach the child) —
|
there, the section must offer at least two published items, or exactly
|
||||||
and viewing that only page itself still shows the sidebar when the
|
one while viewing anything else than that only page — the section
|
||||||
page has published children of its own to reach.
|
index, a 404, a grandchild (otherwise those pages offer no way to
|
||||||
The front page, leaf pages, the sole childless page of a one-page
|
reach the child) — and viewing that only page itself still shows the
|
||||||
section and childless sections get no aside element at all (rather
|
sidebar when the page has published children of its own to reach.
|
||||||
than an empty or useless one-item box).
|
Leaf pages, the sole childless page of a one-page section and
|
||||||
|
childless sections get no aside element at all (rather than an empty
|
||||||
|
or useless one-item box).
|
||||||
"""
|
"""
|
||||||
if not current:
|
if not current or "/" not in current:
|
||||||
return HTML("")
|
return HTML("")
|
||||||
section = current.split("/", 1)[0]
|
section = current.split("/", 1)[0]
|
||||||
node = menu.get(section)
|
node = menu.get(section)
|
||||||
@@ -511,7 +514,11 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def page_content(menu: dict[str, Node], path: str) -> HTML:
|
def page_content(menu: dict[str, Node], path: str) -> HTML:
|
||||||
"""Render the contents of the #main element for a page."""
|
"""Render the contents of the #main element for a page.
|
||||||
|
|
||||||
|
A page with published children (a category page) lists them as cards
|
||||||
|
after the markdown content.
|
||||||
|
"""
|
||||||
node = resolve(menu, path)[-1]
|
node = resolve(menu, path)[-1]
|
||||||
rendered = render(node.content or "", path, node.created, node.modified)
|
rendered = render(node.content or "", path, node.created, node.modified)
|
||||||
# Long articles get .multicol: the article column cap lifts (see the
|
# Long articles get .multicol: the article column cap lifts (see the
|
||||||
@@ -525,9 +532,69 @@ def page_content(menu: dict[str, Node], path: str) -> HTML:
|
|||||||
if not has_h1(node.content or ""):
|
if not has_h1(node.content or ""):
|
||||||
doc.h1(node.title)
|
doc.h1(node.title)
|
||||||
doc.div(HTML(rendered.html), class_="body")
|
doc.div(HTML(rendered.html), class_="body")
|
||||||
|
_cards(doc, menu, node, path)
|
||||||
return HTML(str(doc))
|
return HTML(str(doc))
|
||||||
|
|
||||||
|
|
||||||
|
def _cards(doc, menu: dict[str, Node], node: Node, path: str) -> None:
|
||||||
|
"""Card stacks of the node's published children (nothing when childless).
|
||||||
|
|
||||||
|
One column per direct child, all in a single full-width row (the .wide
|
||||||
|
breakout): the columns grow to fill the page and shrink rather than
|
||||||
|
wrap. A column holds the child's whole subtree flattened in menu order
|
||||||
|
— nesting levels are not split out — starting with the first page that
|
||||||
|
has actual content (the child itself when it does, its first leaf
|
||||||
|
otherwise, recursively). Each card is one <a> showing the page's share
|
||||||
|
image (the same heuristics as og:image) as the cover and its title;
|
||||||
|
image-less cards get a gradient cover and also show the description.
|
||||||
|
Only phrasing-level elements (spans) go inside the <a>: as a formatting
|
||||||
|
element it would be cloned by the HTML parser around any block-level
|
||||||
|
child, splitting one card into several links.
|
||||||
|
"""
|
||||||
|
items = [(s, c) for s, c in sorted_nodes(node.children) if c.published]
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
with doc.div(class_="cards wide"):
|
||||||
|
for slug, child in items:
|
||||||
|
cpath = f"{path}/{slug}" if path else slug
|
||||||
|
entries = list(_walk(child, cpath))
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
with doc.div(class_="stack"):
|
||||||
|
for epath, enode in entries:
|
||||||
|
_card(doc, enode, epath)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk(node: Node, path: str):
|
||||||
|
"""Published content pages of a subtree, pre-order in menu order: the
|
||||||
|
node itself first when it has content (the stack's landing card), then
|
||||||
|
its descendants (content-less nodes contribute only their subtree)."""
|
||||||
|
if node.content:
|
||||||
|
yield path, node
|
||||||
|
for slug, child in sorted_nodes(node.children):
|
||||||
|
if child.published:
|
||||||
|
yield from _walk(child, f"{path}/{slug}")
|
||||||
|
|
||||||
|
|
||||||
|
def _card(doc, node: Node, path: str) -> None:
|
||||||
|
"""One card in a stack: cover + title, plus the description when the
|
||||||
|
page has no image (its card shows a gradient cover instead)."""
|
||||||
|
image = description = ""
|
||||||
|
if node.content:
|
||||||
|
html = render(node.content, path, node.created, node.modified).html
|
||||||
|
image, _ = _media(html)
|
||||||
|
if not image:
|
||||||
|
description = _description(html, 150)
|
||||||
|
with doc.a(href=f"/{path}", class_="card"):
|
||||||
|
if image:
|
||||||
|
doc.span(class_="cover", style=f'background-image: url("{image}")')
|
||||||
|
else:
|
||||||
|
doc.span(class_="cover")
|
||||||
|
doc.span(_title(path.rpartition("/")[2], node), class_="title")
|
||||||
|
if description:
|
||||||
|
doc.span(description, class_="desc")
|
||||||
|
|
||||||
|
|
||||||
_FIRST_P = re.compile(r"<p[^>]*>(.*?)</p>", re.S)
|
_FIRST_P = re.compile(r"<p[^>]*>(.*?)</p>", re.S)
|
||||||
_TAG = re.compile(r"<[^>]+>")
|
_TAG = re.compile(r"<[^>]+>")
|
||||||
_IMG_TAG = re.compile(r"<img\b[^>]*>")
|
_IMG_TAG = re.compile(r"<img\b[^>]*>")
|
||||||
@@ -536,23 +603,34 @@ _ATTR_SRC = re.compile(r'src="([^"]+)"')
|
|||||||
_ATTR_CLASS = re.compile(r'class="([^"]*)"')
|
_ATTR_CLASS = re.compile(r'class="([^"]*)"')
|
||||||
|
|
||||||
|
|
||||||
def _share_media(html: str, base_url: str) -> tuple[str, str]:
|
_SENTENCE_END = re.compile(r"[.!?][”'\")]*(?=\s|$)")
|
||||||
"""(image, video) share URLs from the rendered article.
|
|
||||||
|
|
||||||
|
def _description(html: str, limit: int = 200) -> str:
|
||||||
|
"""Article description: the first paragraph's text, truncated at a
|
||||||
|
sentence end (or, failing that, a word boundary) within ``limit``.
|
||||||
|
Used for og:description."""
|
||||||
|
m = _FIRST_P.search(html)
|
||||||
|
text = unescape(_TAG.sub("", m.group(1) if m else ""))
|
||||||
|
text = " ".join(text.split())
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
# Prefer a clean cut: the last sentence ending within the limit, as
|
||||||
|
# long as it does not reduce the description to a tiny fragment.
|
||||||
|
if (end := max((m.end() for m in _SENTENCE_END.finditer(text[:limit])), default=0)) > limit // 2:
|
||||||
|
return text[:end]
|
||||||
|
return text[:limit].rsplit(" ", 1)[0] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _media(html: str) -> tuple[str, str]:
|
||||||
|
"""Raw (image, video) srcs from rendered article HTML (relative ok).
|
||||||
|
|
||||||
Image preference: an image with class "hero" (author override, may
|
Image preference: an image with class "hero" (author override, may
|
||||||
appear anywhere in the article), then the first raster image (SVGs
|
appear anywhere in the article), then the first raster image (SVGs
|
||||||
rasterize poorly or not at all on many social scrapers), then the
|
rasterize poorly or not at all on many social scrapers), then the
|
||||||
first SVG. Video: the first <video> — og:video is in the OGP spec and
|
first SVG. Video: the first <video> — og:video is in the OGP spec and
|
||||||
honored mainly by Facebook; X/Twitter ignores it. Absolute URLs are
|
honored mainly by Facebook; X/Twitter ignores it.
|
||||||
built from the request base, scrapers cannot use relative ones.
|
|
||||||
"""
|
"""
|
||||||
if not base_url:
|
|
||||||
return "", ""
|
|
||||||
|
|
||||||
def absolute(src: str) -> str:
|
|
||||||
src = unescape(src)
|
|
||||||
return src if src.startswith(("http://", "https://")) else f"{base_url}{src}"
|
|
||||||
|
|
||||||
hero = raster = svg = video = ""
|
hero = raster = svg = video = ""
|
||||||
for tag in _IMG_TAG.findall(html):
|
for tag in _IMG_TAG.findall(html):
|
||||||
if not (src := _ATTR_SRC.search(tag)):
|
if not (src := _ATTR_SRC.search(tag)):
|
||||||
@@ -571,7 +649,23 @@ def _share_media(html: str, base_url: str) -> tuple[str, str]:
|
|||||||
if m := _ATTR_SRC.search(tag):
|
if m := _ATTR_SRC.search(tag):
|
||||||
video = m.group(1)
|
video = m.group(1)
|
||||||
break
|
break
|
||||||
image = hero or raster or svg
|
return hero or raster or svg, video
|
||||||
|
|
||||||
|
|
||||||
|
def _share_media(html: str, base_url: str) -> tuple[str, str]:
|
||||||
|
"""(image, video) share URLs from the rendered article.
|
||||||
|
|
||||||
|
The _media picks as absolute URLs built from the request base —
|
||||||
|
social scrapers cannot use relative ones.
|
||||||
|
"""
|
||||||
|
if not base_url:
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
def absolute(src: str) -> str:
|
||||||
|
src = unescape(src)
|
||||||
|
return src if src.startswith(("http://", "https://")) else f"{base_url}{src}"
|
||||||
|
|
||||||
|
image, video = _media(html)
|
||||||
return (absolute(image) if image else "", absolute(video) if video else "")
|
return (absolute(image) if image else "", absolute(video) if video else "")
|
||||||
|
|
||||||
|
|
||||||
@@ -587,11 +681,7 @@ def _social_meta(
|
|||||||
(social scrapers cannot use relative ones).
|
(social scrapers cannot use relative ones).
|
||||||
"""
|
"""
|
||||||
url = f"{base_url}/{path}" if base_url else ""
|
url = f"{base_url}/{path}" if base_url else ""
|
||||||
m = _FIRST_P.search(html)
|
text = _description(html)
|
||||||
text = unescape(_TAG.sub("", m.group(1) if m else ""))
|
|
||||||
text = " ".join(text.split())
|
|
||||||
if len(text) > 200:
|
|
||||||
text = text[:200].rsplit(" ", 1)[0] + "…"
|
|
||||||
image, video = _share_media(html, base_url)
|
image, video = _share_media(html, base_url)
|
||||||
return {
|
return {
|
||||||
"description": text,
|
"description": text,
|
||||||
@@ -648,20 +738,20 @@ def render_category(
|
|||||||
favicon: str = "",
|
favicon: str = "",
|
||||||
brand_html: str = "",
|
brand_html: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render the placeholder for a content-less category label (404).
|
"""Render the listing for a content-less category label (404).
|
||||||
|
|
||||||
The node exists in the tree but has no page of its own. Nav links
|
The node exists in the tree but has no page of its own: its published
|
||||||
point straight at its first child, so this is mainly seen in the site
|
children are listed as cards, like on a category page with content.
|
||||||
editor, where the pen creates the landing page.
|
Nav links point straight at the first child, so this is mainly seen
|
||||||
|
in the site editor, where the pen creates the landing page.
|
||||||
"""
|
"""
|
||||||
node = resolve(menu, path)[-1]
|
node = resolve(menu, path)[-1]
|
||||||
title = _title(path.rpartition("/")[2], node)
|
title = _title(path.rpartition("/")[2], node)
|
||||||
sidebar = sidebar_html(menu, path)
|
|
||||||
doc = E.article
|
doc = E.article
|
||||||
with doc:
|
with doc:
|
||||||
doc.h1(title)
|
doc.h1(title)
|
||||||
if sidebar:
|
if any(c.published for c in node.children.values()):
|
||||||
doc.p("Pages in this section are listed in the menu on the left.")
|
_cards(doc, menu, node, path)
|
||||||
else:
|
else:
|
||||||
doc.p("This section has no page of its own yet.")
|
doc.p("This section has no page of its own yet.")
|
||||||
return str(
|
return str(
|
||||||
@@ -669,7 +759,7 @@ def render_category(
|
|||||||
Title=f"{title} – {brand}" if brand else title,
|
Title=f"{title} – {brand}" if brand else title,
|
||||||
Brand=_brand_link(brand, brand_html),
|
Brand=_brand_link(brand, brand_html),
|
||||||
Nav=nav_html(menu, path),
|
Nav=nav_html(menu, path),
|
||||||
Sidebar=sidebar,
|
Sidebar=sidebar_html(menu, path),
|
||||||
Banner=banner_html(menu, path, theme),
|
Banner=banner_html(menu, path, theme),
|
||||||
Main=HTML(str(doc)),
|
Main=HTML(str(doc)),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user