Files
pagerite/frontend/src/assets/pagerite.css
T
LeoVasanko d03ee7fa8f Make article layout fluid: CSS-driven column count, uncapped width
Replace the fixed 100rem two-column breakpoint with 'columns: 30rem' so
CSS fits as many >=30rem columns as the article's width allows, and let
long (.multicol) articles grow past the 78rem cap (4:1 share against the
gutters, keeping them symmetric for the .wide breakout math). Also treat
mid-article h1s as full-width column separators like h2s.
2026-08-18 05:14:21 +00:00

862 lines
22 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);
/* 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;
/* Overlay scrollbars where supported (Chromium/WebKit, deprecated but
working): a scrollbar that appears covers a strip of the layout instead
of shifting it, and no space is reserved while absent — so 100vw always
equals the body width and .wide breakout margins stay exact. Ignored by
Firefox (unknown value), which keeps its thin classic scrollbar. */
overflow-y: overlay;
/* Lightweight scrollbar styling: thin, theme-muted thumb on a transparent
track where supported (Firefox: the two properties; WebKit: the rules
below). Full restyling deliberately not attempted. */
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent;
}
::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 45%, transparent);
border-radius: 0.25rem;
}
::-webkit-scrollbar-track {
background: transparent;
}
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. When the page
sets no banner of its own, the backend inlines the active theme's SVG
artwork here instead (pagerite/themes/{theme}/banner.svg). */
#page-banner {
position: absolute;
inset: 0;
overflow: hidden;
}
/* :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) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
/* 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. */
transform: scale(1.25) translateY(var(--pry, 0px));
will-change: transform;
}
#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: the center track keeps 78rem as its minimum and then
takes a 4:1 share of any extra space against each gutter, so the article
grows without limit and its column count follows its width (see the
`columns: 30rem` rule below). The gutters stay symmetric, so the .wide
breakout math (50% 50vw) still centers on the viewport — and at very
wide windows the gutters still hold the 12rem sidebar. */
body:has(.multicol) #content {
grid-template-columns: minmax(0, 1fr) minmax(78rem, 4fr) minmax(0, 1fr);
}
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 {
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;
}
table {
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--line);
padding: 0.35rem 0.8rem;
}
/* Images and figures */
img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
figure {
margin: 0 0 1.5rem;
}
figcaption {
color: var(--muted);
font-size: 0.85rem;
}
/* Captions of full-bleed images: centered and kept to a readable width. */
figure:has(.wide) figcaption {
max-width: 65ch;
margin-inline: auto;
text-align: center;
}
/* Positioning via brace-attribute classes: {.right}, {.left}, {.wide} */
figure:has(.right),
img.right {
float: right;
margin: 0.3rem 0 1rem 1.5rem;
}
figure:has(.left),
img.left {
float: left;
margin: 0.3rem 1.5rem 1rem 0;
}
/* .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). */
figure:has(.wide),
img.wide {
display: block;
/* no inline strut/descender gaps around the image */
width: 100vw;
max-width: none;
margin-inline: calc(50% - 50vw);
/* negative margins also kill the UA figure margin */
/* Follow the docked editor's margin-left transition smoothly. */
transition: margin-left 0.25s ease;
}
/* A paragraph wrapping only a wide image must not add its line height. */
p:has(> img.wide:only-child) {
line-height: 0;
}
/* A captioned image is a <figure>, and the positioning class lives on the
img inside it: only the figure may float/shift, never the img itself,
or the caption would wrap beside the image instead of sitting below.
The image fills the figure's width. */
figure:has(.right) img,
figure:has(.left) img {
float: none;
margin: 0;
width: 100%;
}
figure:has(.wide) img {
width: 100%;
max-width: 100%;
margin: 0;
}
/* Floats take a fixed share of the text column rather than sizing by the
image's intrinsic width, which varies wildly (SVGs have none, and
min-content collapses them). A percentage also scales correctly inside
multi-column segments, where the column is the containing block. The
caption wraps within that width. A width attribute ({.right width=300})
overrides the default on uncaptioned images. */
figure:has(.right),
figure:has(.left),
img.right:not([width]),
img.left:not([width]) {
width: 30%;
}
/* 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), so the .wide breakout
margin is a plain constant. 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 uncapped here too: same 12rem reservation for the
sidebar, but the center track keeps growing (see the .multicol rule
at #content). The article's left edge stays at 12rem either way, so
the constant .wide breakout margin below remains correct. */
body:has(#sidebar):has(.multicol):not(.editing) #content {
grid-template-columns: 12rem minmax(78rem, 4fr) minmax(0, 1fr);
}
body:has(#sidebar) figure:has(.wide),
body:has(#sidebar) img.wide:not(figure img) {
margin-inline: -13.25rem 0;
}
}
/* Editing: shrink the bleed to the space right of the docked editor. */
body.editing figure:has(.wide),
body.editing img.wide:not(figure img) {
width: calc(100vw - var(--editor-w));
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
}
/* 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;
}
.footnote {
font-size: 0.85rem;
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;
}