Files
pagerite/frontend/src/assets/pagerite.css
T

1612 lines
49 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Base structural and functional styles for Pagerite pages and editors.
Color choices are intentionally conservative CSS variables so themes can
override them without fighting layout or interaction rules. */
@import url("./fonts/fonts.css");
@import url("./pygments.css");
:root {
/* The base theme is light, period — this also resolves the light-dark()
code palette below (themes declare their own color-scheme). */
color-scheme: light;
--bg: #ffffff;
--surface: #f5f5f5;
--text: #1a1a1a;
--muted: #666666;
--accent: #0056b3;
--accent2: #003d80;
--line: #0000001a;
/* Links stay quiet — mostly text color with a hint of the accent —
and light up to the full accent on hover. */
--link: color-mix(in oklab, var(--text) 50%, var(--accent));
/* Code highlighting palette, consumed by pygments.css: complete light and
dark sets (background included), resolved by light-dark() from the
used color-scheme. A theme picks a set simply by declaring
color-scheme; a fixed dark code block on a light page pins the dark
set with `pre { color-scheme: dark }`. Only override individual
--code-* variables for specific theming needs. */
--code-bg: light-dark(var(--surface), #0d1117);
--code-text: light-dark(#24292f, #e6edf3);
--code-muted: light-dark(#6e7781, #6e7681);
--code-comment: light-dark(#59636e, #8b949e);
--code-keyword: light-dark(#cf222e, #ff7b72);
--code-builtin: light-dark(#0550ae, #79c0ff);
--code-string: light-dark(#0a3069, #a5d6ff);
--code-function: light-dark(#8250df, #d2a8ff);
--code-type: light-dark(#953800, #f0883e);
--code-tag: light-dark(#116329, #7ee787);
--code-error: light-dark(#d1242f, #f85149);
--code-added: light-dark(#1a7f37, #56d364);
--code-hl: light-dark(#fff8c5, #6e7681);
/* One variable per available font family (faces in fonts/fonts.css);
everything else references these, so font stacks live here only. */
--font-source-sans: "Source Sans 3", system-ui, sans-serif;
--font-source-serif: "Source Serif 4", serif;
--font-fraunces: "Fraunces", serif;
--font-literata: "Literata", serif;
--font-playfair: "Playfair Display", serif;
--font-cormorant: "Cormorant", serif;
--font-inter: "Inter", system-ui, sans-serif;
--font-montserrat: "Montserrat", system-ui, sans-serif;
--font-fira-code: "Fira Code", ui-monospace, monospace;
--font-cause: "Cause", system-ui, sans-serif;
--font-exo2: "Exo 2", system-ui, sans-serif;
--font-new-rocker: "New Rocker", "Playfair Display", serif;
--font-body: var(--font-source-sans);
--font-heading: var(--font-source-serif);
/* The brand follows the heading font unless overridden separately. */
--font-brand: var(--font-heading);
--font-code: var(--font-fira-code);
/* Target x-height ratio for code text: set to the body font's ratio
(here Source Sans 3's 0.478) so font-size-adjust can scale the code
font to the same optical height. Themes retune it to their body font
(measured: Literata 0.507, Inter 0.546, Montserrat 0.517, Cause 0.5). */
--code-x-height: 0.478;
/* Width of the docked editor panel (used both here for shifting the page
and in the Vue editor's own styles). */
--editor-w: min(46rem, 50vw);
}
* {
box-sizing: border-box;
}
::selection {
background: color-mix(var(--accent) 30%, transparent);
color: inherit;
}
/* Links never underline — including SVG link text, which the UA stylesheet
underlines by default. */
a {
text-decoration: none;
}
html {
scroll-behavior: smooth;
/* No rubber-band bounce past the page ends (macOS trackpads): the banner
parallax in --pry is driven by scrollY and overscroll would let the
artwork drift beyond its designed range. */
overscroll-behavior: none;
/* Native-scrollbar fallback styling (JS off or before pagerite.js runs):
thin, theme-muted thumb on a transparent track. With JS the scrollbars
are replaced by OverlayScrollbars (see pagerite.js) — floating,
auto-hidden scrollbars styled by the --os-* variables below, so they
never reserve layout space or shift the page when appearing. */
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent;
}
.os-scrollbar {
--os-size: 0.5rem;
--os-thumb-bg: color-mix(in srgb, var(--muted) 45%, transparent);
--os-thumb-hover-bg: color-mix(in srgb, var(--muted) 65%, transparent);
--os-thumb-active-bg: var(--muted);
--os-track-bg: transparent;
--os-thumb-border-radius: 0.25rem;
}
body {
font-family: var(--font-body);
font-size: 1.05rem;
line-height: 1.65;
/* Tabular numerals wherever the active font supports them; avoids
numbers jumping in width as counters/values change. */
font-variant-numeric: tabular-nums;
margin: 0;
background: var(--bg);
color: var(--text);
/* Full-bleed elements (.wide) size to 100vw, which counts the vertical
scrollbar; clip the few stray pixels instead of scrolling. */
overflow-x: clip;
/* Full height even on short pages: the footer sits at the bottom and the
docked editor (sized by #content) never collapses. */
min-height: 100vh;
display: flex;
flex-direction: column;
}
#content {
flex: 1;
}
/* Full-width banner: image header with the brand and nav overlaid.
Base provides a plain fallback; themes override with artwork. */
#banner {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
/* Height scales down proportionally on small screens: 13rem at 800px
(50rem) viewport, shrinking with the smaller of viewport width/height
(vmin) below that, floored at 8rem. */
height: clamp(8rem, 26vmin, 13rem);
box-sizing: content-box;
background: linear-gradient(135deg, var(--surface), var(--bg));
border-bottom: 1px solid var(--line);
}
/* Per-page banner content (img, styled div, inline SVG...) fills the
banner; swapped along with #nav/#main on fetch-navigation. The backend
inlines the effective banner design's SVG artwork here first
(pagerite/themes/{design}/banner.svg, marked svg[data-design]), then the
page's own banner code after it. */
#page-banner {
position: absolute;
inset: 0;
overflow: hidden;
/* Stack the design artwork and the page's own banner code on top of
each other (artwork first): the banner is a background layer, author
code overlays it. A single child behaves exactly as before. The track
is explicitly banner-sized: an auto row would size to the content
(an SVG's intrinsic aspect ratio makes it far taller than the banner),
and children's height:100% would resolve against that bloated row. */
display: grid;
grid-template: 100% / 100%;
}
/* :not(style, script): author-level display:block would override the UA's
display:none on those and render their source as banner text. No
width/height: the default stretch alignment fills the track without a
percentage-resolution cycle. */
#page-banner>*:not(style, script) {
grid-area: 1 / 1;
display: block;
object-fit: cover;
/* Banner artwork is bottom-anchored: the meaningful content (horizon,
ground, characters) sits at the bottom, so on wide viewports — where
the fixed-height banner crops the artwork vertically — the sky/top is
what scrolls out of view. object-position covers replaced elements
(img, canvas); inline SVG is anchored by the transform-origin below
together with preserveAspectRatio="...YMax slice" in the artwork. */
object-position: bottom;
/* Scroll parallax: pagerite.js sets --pry on <html>; the banner stays
windowed in place while the artwork drifts inside it. The scale
provides overscan so the drift never reveals an edge; scaling from the
bottom keeps the artwork's bottom edge pinned to the banner's. */
transform: scale(1.25) translateY(var(--pry, 0px));
transform-origin: bottom;
will-change: transform;
}
/* The design artwork wrapper (div[data-design]) takes the sizing and
parallax above; an svg inside it fills it (it is what used to be the
direct child before designs got a wrapper). */
#page-banner [data-design]>svg {
display: block;
width: 100%;
height: 100%;
}
#brand,
#nav {
position: relative;
}
#brand {
font-family: var(--font-brand);
font-weight: 700;
/* Scales down proportionally on small screens, same curve as the banner
height: 2.4rem at 800px, shrinking with vmin below that. */
font-size: clamp(1.4rem, 4.8vmin, 2.4rem);
text-decoration: none;
/* One line always: pagerite.js shrinks the font size to fit instead of
wrapping (the themed size is the maximum). */
white-space: nowrap;
/* Shrink-wrap to the text: as a flex child of the column-direction
#banner it would otherwise stretch full-width, making the empty banner
area beside the text a link to the front page. */
align-self: flex-start;
margin: auto 1.25rem 0;
padding-top: 1.5rem;
color: var(--text);
}
/* Nav overlaid at the bottom of the banner */
#nav {
font-size: 1.3em;
padding: 0.35rem 1.25rem;
}
#nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 0.25rem 1.5rem;
flex-wrap: wrap;
align-items: baseline;
}
#nav a {
text-decoration: none;
color: var(--text);
}
#nav ul ul a {
color: var(--muted);
}
#nav a:hover {
color: var(--accent);
}
#nav span {
color: var(--muted);
font-size: 0.95rem;
}
#nav .current {
color: var(--accent);
font-weight: 500;
}
/* Sidebar + main row. A symmetric grid: the article column is sized by the
viewport alone (never by content), with equally sized flexible gutters
on both sides. The sidebar sits in the left gutter, so it appearing or
disappearing never shifts the article; the right gutter balances it.
The outer tracks are minmax(0, 1fr) — a plain 1fr has an `auto` minimum,
which let the 12rem sidebar expand its track at narrow widths and push
the article off-center; now the sidebar overlays the gutter edge instead
(it is translucent/blurred exactly so it can pass over content, and
full-bleed .wide images slide underneath it). */
#content {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 78rem) minmax(0, 1fr);
}
/* Long articles (.multicol comes from the backend render, based on
content length — code excluded) lift the 78rem cap: main takes the full
width and the article composes itself inside it — fluid, bounded text
lanes with the surplus left vacant (see the article layout rules
below). The left track — main always sits in column 2 — collapses to
zero when the page has no sidebar; the sidebar then simply overlays
the vacant zone, as it does on single-column pages. */
body:has(.multicol) #content {
grid-template-columns: 0 minmax(0, 1fr);
}
/* With a sidebar the left lane gets its own track at every width, so the
sidebar never overlaps the article and the article leans on the
viewport's right edge (surplus extends the lane). The lane is flexible:
12rem when space is tight, growing up to 150% (18rem) once the viewport
exceeds the article's 88.5rem (86rem + main's side padding). The --lane
variable doubles as the measure for the margin boxes and the .wide
bleed below. (Inert below 48rem, where #content becomes a flex column
with the sidebar on top.) */
body:has(#sidebar):has(.multicol):not(.editing) #content {
--lane: clamp(12rem, 100vw - 88.5rem, 18rem);
grid-template-columns: var(--lane) minmax(0, 1fr);
}
body.editing #content {
margin-left: var(--editor-w);
/* No padding/gap here: the article area starts flush at the panel's right
edge so that full-bleed .wide images (anchored to that edge below) line
up with it exactly. */
}
/* The sidebar's gutter space is needed by the editor instead. */
body.editing #sidebar {
display: none;
}
/* While editing, the window keeps scrolling normally (the overlay
scrollbars take no layout space, so the vw-based .wide bleed stays exact
and no horizontal scrollbar appears). The editor panel is fixed to the
viewport's left edge; main.js sets its top each scroll frame — the
banner's bottom edge while the banner is visible, else the viewport top.
The panel scrolls internally. */
.editor-host {
position: fixed;
top: 0;
/* main.js: banner bottom while visible, else 0 */
bottom: 0;
left: 0;
width: var(--editor-w);
/* Above the sidebar, .edit-link and the banner's top-right pens
(z-index 10) while sliding in/out (the host now lives at the end of
<body>, so it needs its own stacking level). */
z-index: 10;
}
.editor-root.overlay {
height: 100%;
background: var(--bg);
animation: editor-slide-in 0.25s ease;
}
.editor-root.overlay.closing {
transform: translateX(-100%);
transition: transform 0.25s ease;
}
@keyframes editor-slide-in {
from {
transform: translateX(-100%);
}
}
/* Banner-area pens (banner editor, site editor) and auth buttons live in a
single flex container pinned to the banner's top-right corner. */
.editor-pens {
position: absolute;
top: 0.6rem;
right: 0.5rem;
z-index: 10;
display: flex;
align-items: center;
gap: 0.6rem;
}
.editor-pens button,
.editor-pens a {
position: static;
font: inherit;
border: none;
cursor: pointer;
background: none;
padding: 0;
text-decoration: none;
}
#sidebar {
grid-column: 1;
/* Pinned to the page's left edge (not the article's) and kept in view
while scrolling. Translucent + blurred rather than an opaque box, so
full-bleed .wide images can pass underneath without a hard edge. */
justify-self: start;
align-self: start;
position: sticky;
top: 0;
z-index: 1;
width: 12rem;
max-height: 100vh;
overflow-y: auto;
padding: 1rem 1rem 1rem 1.25rem;
border-radius: 0 0 0.5rem 0;
background: color-mix(in srgb, var(--bg) 75%, transparent);
backdrop-filter: blur(0.5rem);
}
#sidebar ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.8em;
line-height: 1.0;
}
/* Third level (and deeper): nested like the first level of article lists —
slight indent, emoji marker — while the sidebar's own top level (the
section's direct children) stays plain, without indicators or indent. */
#sidebar ul ul {
gap: 0.5em;
margin-top: 0.5em;
padding-inline-start: 1.8em;
}
#sidebar ul ul li::before {
content: "🔹";
display: inline-block;
margin-left: -1.3em;
width: 1.3em;
}
#sidebar a {
text-decoration: none;
color: var(--muted);
}
#sidebar a:hover {
color: var(--accent);
}
#sidebar .current {
color: var(--accent);
font-weight: 500;
}
main {
grid-column: 2;
/* 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. */
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 h2,
article h3,
article h4,
article h5,
article h6 {
font-family: var(--font-heading);
font-weight: 600;
line-height: 1.25;
}
article h1 {
font-size: 2.2rem;
margin: 0 0 1.2rem;
}
/* Margin strategy: bottom-only inside articles. Top margins misalign
column tops and collapse unpredictably; spacing comes from below. */
article p,
article ul,
article ol,
article dl,
article blockquote,
article pre,
article figure,
article table,
article h2,
article h3,
article h4,
article h5,
article h6 {
margin-top: 0;
margin-bottom: 1rem;
}
/* Headings separate from the text above via a top margin on the sibling
combinator: a heading that is the first child of a container (an aside,
an admonition, a .colseg column segment, ...) gets no gap, and browsers
truncate the margin at column breaks, so column tops stay aligned. The
one exception is a page-top heading: main carries no top padding, so a
leading h1/h2 keeps its spacing from the banner. */
article h3,
article h4,
article h5,
article h6 {
margin-bottom: 0.4rem;
}
article * + h1,
article > h1:first-child {
margin-top: 2rem;
}
article * + h2,
article > h2:first-child {
margin-top: 2.2rem;
}
/* A section heading right after the page title needs no separator of its
own — the h1's bottom margin already sets it off. */
article h1 + h2 {
margin-top: 0;
}
article * + h3,
article * + h4,
article * + h5,
article * + h6 {
margin-top: 1.4rem;
}
/* Margin boxes are compact: the section-separator gaps above also reach
headings inside .aside/.margin (they follow the box's paragraphs, so
the sibling combinator matches) — bring them back to plain block
spacing. First-child headings keep their zero via the sibling
combinator, as everywhere. */
.aside * + :is(h1, h2, h3, h4, h5, h6),
.margin * + :is(h1, h2, h3, h4, h5, h6) {
margin-top: 1rem;
}
/* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels,
orange 🔸 on even; ordered lists get muted counters (an explicit
[type] attribute keeps the browser's native markers). The marker box
spans the whole --list-indent (outdented from the text column,
centered), so the space on both sides of the glyph is equal whatever
its advance width — emoji diamonds, chevrons, flowers, whatever a
theme swaps in. The box is em-sized so it tracks the local text size
(e.g. smaller in asides); a theme that shrinks the glyph via
font-size must widen the box by the inverse factor (see the purple
theme) to keep it spanning the indent. flow-root makes the list a
block formatting context: beside a floated figure the whole list box
clears the float (line boxes alone would dodge it while the marker
box, outdented from the li's padding edge, stayed under the image),
so the gap becomes the figure's margin plus the full --list-indent. */
article :is(ul, ol:not([type])) {
list-style: none;
padding-left: 0;
--list-indent: 2em;
display: flow-root;
}
article :is(ul, ol:not([type])) > li {
padding-left: var(--list-indent);
}
article ul li::before {
content: "🔹";
display: inline-block;
margin-left: calc(-1 * var(--list-indent));
width: var(--list-indent);
text-align: center;
}
article ul ul li::before {
content: "🔸";
}
article ol:not([type]) {
list-style: none;
counter-reset: item;
}
article ol:not([type]) > li {
counter-increment: item;
}
article ol:not([type]) > li::before {
content: counter(item) ".";
color: var(--muted);
display: inline-block;
margin-left: calc(-1 * var(--list-indent));
width: var(--list-indent);
text-align: left;
}
/* Task lists: real clickable checkboxes. The checkbox stands in for the
list marker — taken out of flow, left-aligned in the indent box and
centered on the first line's middle, so the item text starts at the
same edge as every other list item's. */
article .task-list-item {
position: relative;
}
article .task-list-item::before {
content: none;
}
article .task-list-item-checkbox {
position: absolute;
left: 0;
top: calc(0.5lh - .15ex);
translate: 0 -50%;
font-size: inherit;
line-height: inherit;
cursor: pointer;
}
/* The item text is a <label for> the checkbox (tasklists label_after):
clicking it toggles too — without selecting the text on repeat clicks. */
article .task-list-item label {
cursor: pointer;
user-select: none;
}
article {
position: relative;
}
.edit-link {
position: absolute;
top: 0.2rem;
/* In the left gutter, on the same side as the docked editor panel. */
left: -2.2rem;
z-index: 2;
/* stay above full-bleed .wide images */
font: inherit;
background: none;
border: none;
padding: 0;
cursor: pointer;
opacity: 0.7;
text-shadow: 0 0 0.1em black;
}
/* pagerite.js tucks the pen at the end of the article's first h1. */
article h1 .edit-link {
position: static;
font-size: 1.1rem;
vertical-align: 0.3em;
margin-left: 0.4rem;
}
/* Section pens sit at the end of anchored h2s, dimmer than the page pen
until hovered. */
article h2 .edit-section {
position: static;
font-size: 0.85rem;
vertical-align: 0.35em;
margin-left: 0.4rem;
opacity: 0.35;
}
.edit-link:hover {
opacity: 1;
}
/* Login/profile links injected by pagerite.js when Paskia SSO is in use.
They live inside the .editor-pens flex container in the banner's top-right
corner and inherit its reset; keep only their opacity/text-shadow tweaks. */
.editor-pens a.login-link,
.editor-pens a.profile-link {
opacity: 0.7;
text-shadow: 0 0 0.1em black;
}
.editor-pens a.login-link:hover,
.editor-pens a.profile-link:hover {
opacity: 1;
}
article p,
article li,
article dd {
text-align: justify;
hyphens: auto;
}
/* The long-article composition (.multicol): a fluid but bounded text
lane with a 16rem side zone at the article's left, centered in main —
surplus width becomes vacant space, never endless text (with a sidebar
the article leans right instead and the sidebar's track is the left
lane; see below). (The backend render splits the body into .colseg
segments separated by full-width h2s and .wide elements, tags
text-heavy segments of several paragraphs .cols — a ::: nocols
container opts its section out, and column-filling paragraphs are
marked .breakable — and flags the article .multicol; CSS owns the
geometry.) Technical content wants a wide lane: up to 42rem single, or
two fluid lanes (36rem minimum, never more than two) once they fit
beside the zone, capped at 102rem total. The zone — the region the nav
sidebar overlays — is a margin indent on the lane content; margin
boxes float into it, and the text never moves. */
article.multicol {
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 article children (the backend render keeps
margin blocks out of the column segments); nested ones keep the
in-column float fallback. */
article.multicol>.colseg,
article.multicol>h1,
article.multicol>h2 {
margin-left: 16rem;
}
article.multicol>.margin,
article.multicol>.aside,
article.multicol>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. */
article.multicol>figure:has(.wide),
article.multicol>div.wide,
article.multicol>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, the sidebar's 12rem track IS the left lane at every
width (see #content): no in-article zone, the text lane runs fluid (up
to 86rem) and leans on main's right edge — surplus width extends the
left lane instead of balancing out on the right — and margin boxes
hang into the lane off the article's left border, sliding under the
translucent sticky nav, which only ever occupies its top. (Not below
48rem: there the sidebar becomes a link strip above the article and
there is no lane to fall into.) */
@media (min-width: 48rem) {
body:has(#sidebar):has(.multicol):not(.editing) article.multicol {
max-width: 86rem;
margin-inline: auto 0;
}
/* The sidebar fills the flexible lane (its left side stays on the
viewport's left edge, growing rightward). */
body:has(#sidebar):has(.multicol):not(.editing) #sidebar {
width: 100%;
}
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.colseg,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>h1,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>h2 {
margin-left: 0;
}
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.margin,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.aside,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>figure:has(.margin) {
float: left;
clear: left;
/* Attached to the article's left border (1.25rem gap), hanging into
the left lane and growing leftward with it: 12rem when the lane is
tight, up to 150% (18rem) when the track or the surplus has room
(100cqw - 100% is the surplus left of the right-leaning article).
The lane (track + main's padding) always guarantees the room. */
--box-w: min(18rem, var(--lane) + 100cqw - 100% - 1.25rem);
width: var(--box-w);
max-width: none;
margin: 0.3rem 0 1rem calc(-1.25rem - var(--box-w));
}
}
/* 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;
h3,
h4,
h5,
h6 {
break-after: avoid-column;
}
p,
li {
break-inside: avoid-column;
}
/* Column-filling paragraphs (marked .breakable by the backend render)
are allowed to split — keeping them whole would strand the following
content in a column of its own. */
p.breakable {
break-inside: auto;
}
figure,
pre,
blockquote,
table,
ul,
ol,
dl,
.admonition,
.markdown-alert {
break-inside: avoid;
}
}
article h2 {
font-size: 1.5rem;
/* Bottom-only like every other block (margin-top: 0 comes from the group
above); the section-separator top margin lives in the sibling rule, and
a shorthand here would tie on specificity and clobber it. */
margin-bottom: 0.6rem;
}
/* Heading self-links (section anchors) look exactly like plain heading
text — click sets the hash, right-click copies the link. */
article :is(h1, h2) a.anchor,
article :is(h1, h2) a.anchor:hover {
color: inherit;
font-weight: inherit;
}
/* Anchored headings clear the fixed/sticky top nav when scrolled to. */
article :is(h1, h2)[id] {
scroll-margin-top: 4rem;
}
article a {
color: var(--link);
font-weight: 500;
text-decoration: none;
}
article a:hover {
color: var(--accent);
}
/* Blockquotes: spacing comes from the blockquote itself (bottom-only like
everything else in articles); inner paragraphs keep only the gap between
them. The negative left margin pushes the bar out past the text edge, so
quoted text aligns with the surrounding paragraphs — same trick as code
blocks. */
blockquote {
margin: 0 0 1rem -0.5rem;
padding: 0 0 0 0.25rem;
border-left: 0.25rem solid var(--accent2);
color: var(--muted);
}
blockquote p {
margin: 0;
}
blockquote p + p {
margin-top: 0.6rem;
}
/* Admonitions (markdown !!! note/warning/...) and GitHub-style alerts
(> [!NOTE] ...): a lightweight callout in the blockquote idiom — accent
bar and a faint wash, recolored per type, with a type emoji on the
title. The negative left margin pushes bar and wash out past the text
edge so the inner text aligns with surrounding paragraphs — same trick
as blockquotes and code blocks (margin-left = border + padding-left).
Bottom-only margins like everything else in articles; inner paragraphs
carry no margins of their own. */
.admonition,
.markdown-alert {
margin: 0 0 1rem -1.15rem;
padding: 0.4rem 0.9rem;
border-left: 0.25rem solid var(--admonition-color, var(--accent));
border-radius: 0 0.3rem 0.3rem 0;
background: color-mix(in srgb, var(--admonition-color, var(--accent)) 7%, transparent);
}
.admonition> :last-child,
.markdown-alert> :last-child {
margin-bottom: 0;
}
.admonition-title,
.markdown-alert-title {
margin: 0 0 0.2rem;
font-weight: 600;
color: var(--admonition-color, var(--accent));
}
.admonition-title::before,
.markdown-alert-title::before {
padding-right: 0.35em;
}
.admonition.note .admonition-title::before,
.markdown-alert-note .markdown-alert-title::before {
content: "️";
}
.admonition.tip .admonition-title::before,
.admonition.hint .admonition-title::before,
.markdown-alert-tip .markdown-alert-title::before {
content: "✨";
}
.admonition.important .admonition-title::before,
.markdown-alert-important .markdown-alert-title::before {
content: "❗";
}
.admonition.success .admonition-title::before {
content: "✅";
}
.admonition.warning .admonition-title::before,
.markdown-alert-warning .markdown-alert-title::before {
content: "⚠️";
}
.admonition.caution .admonition-title::before,
.markdown-alert-caution .markdown-alert-title::before {
content: "🔥";
}
.admonition.danger .admonition-title::before,
.admonition.failure .admonition-title::before {
content: "⛔";
}
.admonition.tip,
.admonition.important,
.admonition.hint,
.admonition.success,
.markdown-alert-tip,
.markdown-alert-important {
--admonition-color: var(--accent2);
}
.admonition.warning,
.admonition.caution,
.admonition.danger,
.admonition.failure,
.markdown-alert-warning,
.markdown-alert-caution {
--admonition-color: var(--accent3);
}
/* Side boxes: ::: aside is a muted floated box (consecutive asides stack
via clear: left); {.margin} / ::: margin is a plainer margin note, and
figures take {.margin} like {.left}. On multicol pages they float in
the composition's left side zone — or in the sidebar's track when the
layout reserves one (see the article section); on wide single-column
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. */
.aside {
float: left;
clear: left;
width: 30%;
max-width: 20rem;
margin: 0.3rem 1.2rem 1rem 0;
padding: 0.6rem 0.9rem;
font-size: 0.9rem;
color: var(--muted);
background: color-mix(in srgb, var(--accent) 6%, transparent);
border-radius: 0.3rem;
}
.aside> :last-child,
.margin> :last-child {
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 {
float: left;
clear: left;
width: 30%;
max-width: 20rem;
margin: 0.3rem 1.2rem 1rem 0;
font-size: 0.9rem;
color: var(--muted);
}
pre {
overflow-x: auto;
padding: 0.5rem 0.8rem;
/* Code text aligns with the surrounding paragraphs: the box extends
past them by its own padding. Themes that add a left border must
extend margin-left by the border width to keep this alignment. */
margin-left: -0.8rem;
margin-right: -0.8rem;
background: var(--code-bg);
border-radius: 4px;
position: relative;
}
/* Inline code integrates with the text, no box of its own. Instead of a
fixed em shrink (which can't fit every body/code font pairing — Fira
Code's x-height ratio 0.525 is taller than Source Sans 3's 0.478 yet
shorter than Inter's 0.546), font-size-adjust scales whatever code font
is in use so its x-height matches the body font's ratio. Browsers
without font-size-adjust get unadjusted 1em code, which is fine. */
code {
font-family: var(--font-code);
font-size-adjust: ex-height var(--code-x-height);
}
/* Click-to-copy button (added by pagerite.js) */
.copy {
position: absolute;
top: 0.35rem;
right: 0.35rem;
font: inherit;
font-size: 0.75rem;
padding: 0.15rem 0.6rem;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
pre:hover .copy,
.copy:focus-visible {
opacity: 1;
}
.copy.copied {
color: var(--accent);
}
/* Tables separate by color, not lines: the header is a soft vertical
gradient tinted with the theme's accent (themes can override the
--table-head-* stops outright), body cells carry a very faint diagonal
wash of --table-tint that lifts them apart, and only a whisper of a row
separator remains. */
table {
border-collapse: collapse;
}
th,
td {
padding: 0.35rem 0.8rem;
border: none;
}
th {
text-align: left;
background: linear-gradient(180deg,
var(--table-head-a, color-mix(in srgb, var(--accent) 10%, var(--surface))),
var(--table-head-b, color-mix(in srgb, var(--accent) 18%, var(--surface))));
}
td {
background: linear-gradient(160deg,
color-mix(in srgb, var(--table-tint, var(--accent)) 5%, transparent),
transparent 75%);
}
tbody tr+tr td {
border-top: 1px solid color-mix(in srgb, var(--table-tint, var(--accent)) 12%, transparent);
}
/* Definition lists are laid out as a lightweight two-column grid — terms
in an accent column with a sensible minimum width, descriptions filling
the rest — with no borders or fills (tables carry the heavier shading).
Explicit grid columns keep runs of multiple <dt>s or <dd>s stacking
correctly. */
dl {
display: grid;
grid-template-columns: minmax(6rem, max-content) 1fr;
}
dt {
grid-column: 1;
font-weight: 550;
color: var(--accent);
}
dd {
grid-column: 2;
margin: 0;
}
dt,
dd {
padding: 0.2rem 0;
}
/* 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 auto 1.5rem;
width: 100%;
}
/* 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%;
}
/* Floated figures: {.right} / {.left}, defaulting to 30% of the column
and capped at half of it. */
figure:has(.right) {
float: right;
width: 30%;
max-width: 50%;
margin: 0.3rem 0 1rem 1em;
}
figure:has(.left) {
float: left;
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;
}
/* {.margin} figures float left like {.left} ones — until they fall into
the side zone (see the composition rules up in the article section). */
figure:has(.margin) {
float: left;
width: 30%;
max-width: 50%;
margin: 0.3rem 1em 1rem 0;
}
/* Wide single-column pages: margin boxes lean into the vacant left
gutter instead (below 104rem the gutter cannot hold the box, and while
editing the docked panel reshapes the gutters — in both they stay
plain floats). The box grows with the gutter up to 150% (18rem), its
right side 1.25rem off the article's left border. */
@media (min-width: 104rem) {
body:not(.editing):not(:has(.multicol)) article>.margin,
body:not(.editing):not(:has(.multicol)) article>.aside,
body:not(.editing):not(:has(.multicol)) article>figure:has(.margin) {
float: left;
clear: left;
--box-w: min(18rem, (100vw - 78rem) / 2 - 1.25rem);
width: var(--box-w);
max-width: none;
margin: 0.3rem 0 1rem calc(-1.25rem - var(--box-w));
}
}
/* In the wide symmetric gutters (where the sidebar overlays the flexible
left gutter rather than a reserved track) the sidebar flexes with the
gutter up to 150% — its left side stays on the viewport's edge. */
@media (min-width: 102rem) {
body:not(.editing) #sidebar {
width: min(18rem, 100%);
}
}
/* .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
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).
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. The analytics dashboard uses the
same breakout directly on its container (div.wide — it is the page's
whole content, not a figure), and code blocks via a trailing {.wide}
line (fence block attrs land on <pre> itself). */
figure:has(.wide),
div.wide,
pre.wide {
width: 100vw;
max-width: none;
margin-inline: calc(50% - 50vw);
}
/* Full bleed means edge to edge — no rounded corners. */
figure:has(.wide) img,
pre.wide {
border-radius: 0;
}
/* Editing: shrink the bleed to the space right of the docked editor. The
window keeps its overlay scrollbars while editing, so — unlike a classic
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 div.wide,
body.editing pre.wide {
width: calc(100vw - var(--editor-w));
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
}
/* Narrow single-column pages with a sidebar: below 102rem the symmetric
gutters can no longer both hold the sidebar, so #content reserves it
with a flexible left track (see the matching media query below) and the
article always starts at the lane's width (+ main's 1.25rem padding) —
the bleed margin measures off --lane. 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 reserve the same track at every
width — 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: calc(-1.25rem - var(--lane)) 0;
}
}
/* .wide on multicol pages: the same edge-to-edge bleed, but measured from
main (the container) instead of the viewport — cqw includes the editor
inset automatically, so no editing override is needed. The 2.5rem
covers main's side padding. */
body:has(.multicol) figure:has(.wide),
body:has(.multicol) div.wide,
body:has(.multicol) pre.wide {
width: calc(100cqw + 2.5rem);
margin-inline: calc(50% - 50cqw - 1.25rem);
}
/* Multicol with a sidebar track (≥48rem, see #content): main starts at
the flexible lane's width and the article leans right, so the bleed
extends left past the surplus and the lane to the true viewport edge —
sliding under the translucent sidebar — and right past main's
padding. */
@media (min-width: 48rem) {
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 + 2.5rem + var(--lane));
margin-inline: calc(100% - 100cqw - 1.25rem - var(--lane)) 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
section, so images never overflow into the next one. */
article h1,
article h2 {
clear: both;
}
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
longer both hold the 12rem sidebar, so reserve its space with a flexible
left track instead of letting it overlap the article (multicol pages use
the same track at every width — see the #content rules above; their
higher-specificity rule wins there). The lane is 12rem when space is
tight, growing up to 150% (18rem) once the viewport exceeds the
article's 80.5rem (78rem + main's side padding), and the sidebar fills
it. The matching .wide breakout rule in the images section measures off
--lane. 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 {
--lane: clamp(12rem, 100vw - 80.5rem, 18rem);
grid-template-columns: var(--lane) minmax(0, 78rem) minmax(0, 1fr);
}
body:has(#sidebar):not(.editing) #sidebar {
width: 100%;
}
}
/* Phones and other narrow viewports: single-column layout with the
sidebar lifted above the article as a wrapping link strip, and no
floats at all — .left/.right/.margin figures fall back to plain
centered figures (explicit img widths still shrink-wrap), margin boxes
go full width, while .wide keeps its full viewport bleed. */
@media (max-width: 48rem) {
/* Nav type shrinks fluidly as space runs out. The nav font-size is
em-based both in base and in every theme override, so scaling the
banner's font-size (nothing else in the banner is em-sized — brand and
gaps use rem) reaches the nav through all themes with a single rule.
2.6vw crosses 1rem at ≈38.5rem, so only genuinely narrow viewports
shrink. */
#banner {
font-size: clamp(0.65rem, 2.6vw, 1rem);
}
/* Tighter margins/padding/gaps: the 1.25rem side gutter is wasted space
on a phone. */
#brand {
margin-inline: 0.6rem;
}
#nav {
padding: 0.25rem 0.6rem;
}
#nav ul {
gap: 0.15rem 0.9rem;
}
/* The editor panel takes over the entire viewport: no space left for
the banner or the page content (main.js pins its top to 0 at these
widths). */
body.editing #content {
margin-left: 0;
}
.editor-host {
width: 100vw;
}
#content {
display: flex;
flex-direction: column;
}
#sidebar {
position: static;
width: auto;
max-height: none;
overflow-y: visible;
border-radius: 0;
padding: 0.4rem 0.8rem;
/* Smaller type: the horizontal link strip fits roughly a third more
items per line. The nested-list gaps below are em-based and shrink
along. */
font-size: 0.8rem;
}
/* Only the main level becomes a horizontal wrapping strip; submenus stay
vertical blocks attached under their parent item. */
#sidebar>ul {
flex-direction: row;
flex-wrap: wrap;
gap: 0.3rem 0.75rem;
}
figure:has(.right),
figure:has(.left),
figure:has(.margin) {
float: none;
width: 100%;
max-width: none;
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
block comes after the desktop rule at equal specificity). */
figure:has(img[width]) {
width: fit-content;
}
/* The single-column sidebar .wide margins assume a left sidebar column;
with the sidebar on top the article is viewport-wide and the plain
centered bleed applies again. (Multicol pages need no override: their
cqw bleed is exact at any width.) */
body:has(#sidebar):not(.editing):not(:has(.multicol)) figure:has(.wide) {
margin-inline: calc(50% - 50vw);
}
}
/* Scroll reveal (pagerite.js adds .reveal/.in; JS off = fully visible) */
.reveal {
opacity: 0;
translate: 0 14px;
transition:
opacity 0.6s ease,
translate 0.6s ease;
}
.reveal.in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.reveal {
opacity: 1;
translate: none;
transition: none;
}
}
.task-list-item {
list-style: none;
}
/* Published/updated line, expanded from the {dates} tag in the markdown
(typically placed right after the article's h1). */
.dateline {
color: var(--muted);
font-size: 0.85rem;
text-align: left;
}
.footnotes {
font-size: 0.7rem;
color: var(--muted);
}
/* The footer element is kept (the editor host ends above it) but currently
empty and zero-height. */
footer {
padding: 0;
}
/* Page transition styles are NOT here: they live in the transition designs
(pagerite/themes/{cube,crossfade}/transition.css), injected by the backend
as #pagerite-transition per the site settings. */