/* 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(var(--text) 50%, var(--accent)); /* Inline code tint in body paragraphs: halfway between text and muted. Both endpoints are theme constants, so the mix is a definite color per theme — themes may also pin it outright. Outside paragraphs code inherits the context's color (accent headings stay accent). */ --code-inline: color-mix(var(--text), var(--muted)); /* Selection fill for page text and the CodeMirror editors; themes override when the accent tint clashes with accent-colored text. */ --selection-bg: color-mix(var(--accent) 30%, transparent); /* Referer-badge chip in the analytics viewer: a translucent neutral wash, deliberately NOT themed — the chip sits behind transparent favicons, so black-on-transparent and white-on-transparent glyphs must both stay legible on every theme (a slight whitening keeps black glyphs readable on dark bars without a glaring solid-white chip). Being translucent, it takes the page's tone, so its text follows the theme's colors. */ --badge-bg: #aaaaaa44; --badge-text: var(--text); --badge-muted: var(--muted); /* 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: var(--selection-bg); color: inherit; } /* Text size classes for any block ({.small} {.large} {.huge}, set via the format bar or by hand): em units on the single 1rem base scale, so they compose with the theme's typography. */ .small { font-size: 0.7em; } .large { font-size: 1.5em; } .huge { font-size: 3em; } /* 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(var(--muted) 45%, transparent) transparent; } .os-scrollbar { --os-size: 0.5rem; --os-thumb-bg: color-mix(var(--muted) 45%, transparent); --os-thumb-hover-bg: color-mix(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); /* 1rem exactly: the text scale (headings, .small/.large/.huge) keys off one base size — no per-context tweaking, or consistent sizing becomes impossible. */ font-size: 1rem; 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 ; 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.95em; } #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 start gutter (grid columns are flow-relative: on RTL pages the whole composition mirrors), so it appearing or disappearing never shifts the article; the end 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 start 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 start lane gets its own track at every width, so the sidebar never overlaps the article and the article leans on the viewport's end 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
, 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 start 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; /* The extra inline-start padding (the viewport-edge side, mirroring with the direction) matches main's side padding. */ padding: 1rem; padding-inline: 1.25rem 1rem; border-end-start-radius: 0.5rem; background: color-mix(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-inline-start: -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 cards: a category page (and the content-less category 404) lists its published children after the markdown content — one card per child (a child without a page of its own is represented by its first leaf page; see _cards in views.py). The row bleeds to full page width (div.wide); every card has the same fixed 16/10 shape, scaling only with the available width. Cards come in two modes following the child's twitter:card selection (_card_large — the per-article override, else the card image's dimensions): large cards are covered entirely by the page's card 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; small cards (.card.compact) split at the golden ratio: a square cover in the top part with the title beside it, the article description below. The card is one holding only phrasing-level spans; the spans lay out as blocks. */ /* Equal-width columns (grid, not flex: no differential shrink, no cross-axis stretch — every card keeps the fixed 16/10 shape, scaling only with the available width). Cards stop growing at their cap; the row centers in the bleed then. */ .cards { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 24em); 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; } /* Phones: the cards stack vertically instead of shrinking to slivers. */ @media (max-width: 48rem) { .cards { grid-auto-flow: row; grid-auto-columns: unset; grid-template-columns: minmax(0, 24em); } } .card { /* Fill the (equal-width) grid column; the height comes only from the fixed aspect ratio (align-self: start — the row must not stretch it). Overflowing content is clipped. */ width: 100%; align-self: start; position: relative; display: flex; flex-direction: column; aspect-ratio: 16 / 10; min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 0.5em; /* Lifted from the page background (a touch of the text color) so cards read as distinct surfaces. */ background: color-mix(var(--surface) 88%, var(--text)); color: var(--text); text-decoration: none; transition: transform 0.2s, box-shadow 0.2s; box-shadow: 0 0 0.1em 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: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; margin-top: auto; padding: 0.75em 1.25em 0.9em; background: color-mix(var(--bg) 65%, transparent); /* Pinned: the article a:hover accent must not leak through the card. */ color: var(--text); font-family: var(--font-heading); font-size: 1.25em; font-weight: 900; line-height: 1.25; /* Hyphenation needs the card's lang (the target article's language). */ hyphens: auto; } /* 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: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; line-clamp: 4; overflow: hidden; /* Four lines exactly, with an ellipsis on overflow: the text itself is truncated server-side (_cards), this is just the safety net. */ line-height: 1.4; padding: 0.4em 1.25em 0.9em; background: color-mix(var(--bg) 55%, transparent); color: var(--muted); font-size: 0.95em; text-align: start; hyphens: auto; } /* Small cards (_card_large false; the twitter "summary" style): externally the same 16/10 shape as the large card, split horizontally in two sub-grids at the golden ratio — the top part (the image, its full height, with the title beside it at the bottom) takes φ, the bottom part (the description — which only the small format carries — above spare space) takes 1, so the image's bottom edge sits at ~62% of the card instead of centering wherever the differing title/description sizes land it. The title and description carry the translucent band (the same band color as the large cards' title) as their own backgrounds. Imageless cards are just the gradient with the title spanning the full width. */ .card.compact { display: grid; /* minmax(0, …): the parts never grow past the card — overlong text is line-clamped (title/description) instead of pushing the layout. */ grid-template-rows: minmax(0, 1.618fr) minmax(0, 1fr); background: linear-gradient( 135deg, color-mix(var(--surface) 88%, var(--text)), var(--bg) ); } /* Top part: spare space above the title row. The image column is as wide as the top part is tall — the card is 16/10 and the top is φ/(φ+1) of its height, i.e. ~38.6% of its width — so a square image at that width fills the whole top part. */ .card.compact .top { display: grid; grid-template-columns: 38.6% 1fr; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; } /* The image fills its whole grid area (column 1, both rows); cover crops rather than letterboxes. */ .card.compact img.cover { position: static; grid-column: 1; grid-row: 1 / 3; z-index: 2; width: 100%; height: 100%; margin: 0; object-fit: cover; background: none; } .card.compact .title { /* Full width: the translucent band runs behind the image to the left edge (the image paints over it, z-index 2), the text indented past the image column. */ grid-column: 1 / -1; grid-row: 2; z-index: 1; margin: 0; padding: 0.4em 0.6em 0 calc(38.6% + 0.6em); background: color-mix(var(--bg) 65%, transparent); } /* No image: no indentation, the text starts at the left edge. */ .card.compact .top:not(:has(img.cover)) .title { padding-left: 0.6em; } /* No description: the title supplies the band's bottom padding. */ .card.compact:not(:has(.desc)) .title { padding-bottom: 0.4em; } /* Bottom part: the description above spare space. */ .card.compact .bottom { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; } .card.compact .desc { grid-column: 1 / -1; grid-row: 1; z-index: 1; margin: 0; padding: 0.4em 0.6em; background: color-mix(var(--bg) 65%, transparent); /* Three lines fit the bottom part (⅜ of the card) even at the card cap; the line clamp adds the ellipsis past that. */ -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden; max-height: 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-inline-start: 0; --list-indent: 2em; display: flow-root; } article :is(ul, ol:not([type])) > li { padding-inline-start: var(--list-indent); } article ul li::before { content: "🔹"; display: inline-block; margin-inline-start: 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-inline-start: calc(-1 * var(--list-indent)); width: var(--list-indent); text-align: start; } /* Task lists: real clickable checkboxes. The checkbox stands in for the list marker — taken out of flow, start-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; inset-inline-start: 0; top: calc(0.5lh - .15ex); translate: 0 -50%; font-size: inherit; line-height: inherit; cursor: pointer; } /* The item text is a