/* 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); /* 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; } html { scroll-behavior: smooth; /* 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; min-height: 11rem; 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. */ display: grid; } /* :not(style, script): author-level display:block would override the UA's display:none on those and render their source as banner text. */ #page-banner>*:not(style, script) { grid-area: 1 / 1; display: block; width: 100%; height: 100%; object-fit: cover; /* 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. */ transform: scale(1.25) translateY(var(--pry, 0px)); 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; font-size: 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; 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); /* The docked editor pushes the content (not the header) right. */ transition: margin-left 0.25s ease; } /* Long articles (.multicol is added by pagerite.js based on content length) lift the 78rem cap and scrap the right gutter: a 1fr left gutter (which holds the overlaying sidebar) and the article taking all the rest, out to the right viewport edge. The column count follows the width (see the `columns: 30rem` rule below). The .wide breakout is re-anchored to the left gutter below (the article is no longer viewport-centered). */ body:has(.multicol) #content { grid-template-columns: minmax(0, 1fr) minmax(0, 4fr); } body.editing #content { margin-left: var(--editor-w); padding-left: 1rem; /* gap between the docked editor and the content */ /* No overflow clipping here: .editor-host lives outside this box (negative left), and .wide shrink-wraps to the remaining space. */ } /* The sidebar's gutter space is needed by the editor instead. */ body.editing #sidebar { display: none; } /* The editor host lives inside #content: it starts below the banner and ends above the footer. The panel itself sticks to the viewport while scrolling (but never taller than the content area). */ .editor-host { position: absolute; top: 0; bottom: 0; left: calc(0px - var(--editor-w)); width: var(--editor-w); } .editor-root.overlay { position: sticky; top: 0; height: 100vh; max-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%); } } /* The banner's own pen: opens the site editor (banner + structure). Qualified with `button` to beat the later .edit-link rule's left offset (both classes apply to the same element). */ button.banner-edit-link { position: absolute; top: 0.6rem; right: 1.25rem; left: auto; z-index: 10; font: inherit; border: none; cursor: pointer; background: none; padding: 0; } #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; } #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; } article h1, article h2, article h3 { font-family: var(--font-heading); font-weight: 600; line-height: 1.25; } article h1 { font-size: 2.2rem; margin: 2rem 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 { margin-top: 0; margin-bottom: 1rem; } article h3 { margin: 1.4rem 0 0.4rem; } /* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels, orange 🔸 on even. The marker occupies a 1em outdented box so wrapped lines align. */ article ul { list-style: none; padding-inline-start: 1em; } article ul li::before { content: "🔹"; display: inline-block; margin-left: -1.3em; width: 1.3em; } article ul ul li::before { content: "🔸"; } article ul ul ul li::before { content: "🔹"; } /* Task lists: real checkboxes (not emoji), clickable in the public page. */ article .task-list-item::before { content: none; } article .task-list-item-checkbox { cursor: pointer; margin-inline-end: 0.35em; vertical-align: middle; } 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; } .edit-link:hover { opacity: 1; } /* Login/profile buttons injected by pagerite.js when Paskia SSO is in use. Both sit in the banner's top-right corner, to the right of the site pen, and are styled like the edit pens. */ button.login-link, button.profile-link { position: absolute; top: 0.6rem; right: 0.5rem; z-index: 10; font: inherit; border: none; cursor: pointer; background: none; padding: 0; opacity: 0.7; text-shadow: 0 0 0.1em black; } button.login-link:hover, button.profile-link:hover { opacity: 1; } article p, article li, article dd { text-align: justify; hyphens: auto; } /* Multi-column reading, but only for long articles (pagerite.js adds .multicol based on content length and splits the body into .colseg segments separated by full-width h2s and wide figures; only segments with enough text get .cols). No fixed breakpoint: `columns: 30rem` lets CSS fit as many columns of at least 30rem as the article's current width allows — since .multicol also uncaps the article width (see #content above), a wider window simply yields more columns. */ .multicol .colseg.cols { columns: 30rem; column-gap: 3.5rem; column-rule: 1px solid var(--line); } .multicol .colseg { margin-bottom: 1rem; p, li { break-inside: avoid-column; } figure, pre, blockquote, table, dl { break-inside: avoid; } } article h2 { font-size: 1.5rem; margin: 2.2rem 0 0.6rem; } article a { color: var(--link); font-weight: 500; text-decoration: none; } article a:hover { color: var(--accent); } /* Blockquotes: inner paragraphs carry no margins (spacing comes from the blockquote itself, bottom-only like everything else in articles). 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; } 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 */ p code, li code { font-size: 0.85em; } /* 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); } code { font-family: var(--font-code); font-size: 0.88em; } /* 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:nth-child(even) td { background: linear-gradient(160deg, color-mix(in srgb, var(--table-tint, var(--accent)) 9%, transparent), color-mix(in srgb, var(--table-tint, var(--accent)) 3%, 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
s or
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
(with
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 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; } /* .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. */ figure:has(.wide) { width: 100vw; max-width: none; margin-inline: calc(50% - 50vw); /* Follow the docked editor's margin-left transition smoothly. */ transition: margin-left 0.25s ease; } /* Full bleed means edge to edge — no rounded corners. */ figure:has(.wide) img { 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) { margin-inline: calc(-20vw - 1.25rem) 0; } /* Editing: shrink the bleed to the space right of the docked editor. */ body.editing figure:has(.wide) { width: calc(100vw - var(--editor-w)); margin-inline: calc(50% - (100vw - var(--editor-w)) / 2); } /* Editing + multicol: the left gutter is 1/5 of the space right of the editor, and the bleed also crosses #content's 1rem editing gap plus main's 1.25rem left padding (the gutter shrink from the padding roughly cancels the rounding): −2rem in total. */ body.editing:has(.multicol) figure:has(.wide) { margin-inline: calc((100vw - var(--editor-w)) / -5 - 2rem) 0; } /* Narrow windows with a sidebar: below 102rem the symmetric gutters can no longer both hold the 12rem sidebar, so #content reserves it with a fixed left track (see the matching media query below) and the article always starts at 12rem (+ main's 1.25rem padding) — the bleed margin is a plain constant. Scoped by :has(#sidebar) since the sidebar element is omitted entirely on pages without sub-navigation, and excluded while editing, where the editing rules above apply instead. */ @media (max-width: 102rem) { body:has(#sidebar):not(.editing) figure:has(.wide) { margin-inline: -13.25rem 0; } } figcaption { color: var(--muted); line-height: 1; font-size: 0.85rem; hyphens: auto; -webkit-hyphens: auto; text-wrap: pretty; text-align: left; /* Never let a long caption stretch a shrink-to-fit figure wider than the image; the caption wraps at the figure's width instead. */ width: 0; min-width: 100%; } /* Full-bleed figures: center the caption, shrink-wrapped to its text and capped at a readable width. The min-width shrink-wrap trick above would win over max-width (min beats max per spec), so it is reset here. */ figure:has(.wide) figcaption { width: fit-content; min-width: 0; max-width: 65ch; margin-inline: auto; } /* Section headings start below any floated figures of the previous 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 fixed left track instead of letting it overlap the article. The article then always starts at 12rem (+ main's 1.25rem padding), which the matching .wide breakout rule in the images section relies on. Scoped by :has(#sidebar) since the sidebar element is omitted entirely on pages without sub-navigation. */ @media (max-width: 102rem) { body:has(#sidebar):not(.editing) #content { grid-template-columns: 12rem minmax(0, 78rem) minmax(0, 1fr); } /* 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); } } /* 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; } /* Rotating-cube page transition, adapted from termotohtori.fi. FRAGILE: do not tweak; the view-transition pseudo-tree is picky. */ ::view-transition { perspective: 1000px; inset: 0; } ::view-transition-group(root), ::view-transition-image-pair(root) { transform-style: preserve-3d; isolation: auto; } ::view-transition-old(root), ::view-transition-new(root) { mix-blend-mode: normal; backface-visibility: hidden; animation: none; } @keyframes group-rotate { to { transform: rotateY(-90deg); } } @keyframes fade-out-a-bit { to { opacity: 0.5; } } @keyframes fade-in-a-bit { from { opacity: 0.5; } } ::view-transition-group(root) { transform-origin: 50% 50% -50vw; animation: 300ms ease-in-out forwards group-rotate; } ::view-transition-old(root) { animation: 300ms ease-in-out forwards fade-out-a-bit; } ::view-transition-new(root) { transform-origin: 0 0; transform: rotateY(90deg); inset: 0 auto 0 100%; animation: 300ms ease-in-out forwards fade-in-a-bit; } /* Reverse direction for browser back navigation (same geometry, mirrored). */ @keyframes group-rotate-back { to { transform: rotateY(90deg); } } html.nav-back::view-transition-group(root) { animation-name: group-rotate-back; } html.nav-back::view-transition-new(root) { transform-origin: 100% 0; transform: rotateY(-90deg); inset: 0 100% 0 auto; } /* Same-section navigation: a plain crossfade instead of the cube. These rules only override animation/geometry, leaving the FRAGILE block's perspective and layering untouched. The old snapshot stays fully opaque underneath while the new one fades in on top — never a dip to black. */ @keyframes nav-fade-in { from { opacity: 0; } } html.nav-fade::view-transition-group(root) { animation: none; } html.nav-fade::view-transition-old(root) { animation: none; } html.nav-fade::view-transition-new(root) { transform: none; inset: 0; animation: 200ms ease-in-out nav-fade-in; }