diff --git a/AGENTS.md b/AGENTS.md
index 4f9538f..8f07c74 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -54,7 +54,11 @@ not for the public pages. See `docs/design-principles.md` for the design.
moves never break them. `Node.banner` is a raw trusted HTML snippet
for the header banner (img, styled div, canvas+script...); empty
inherits from the node's ancestors (front page last), then the active
- theme's banner artwork (or a plain gradient in the base stylesheet).
+ theme's banner artwork: an inline SVG from
+ `pagerite/themes/{theme}/banner.svg`, inlined into `#page-banner` by
+ the backend only when no user banner applies (so it is recolorable
+ from the theme CSS via `var(...)` and never fights user designs; the
+ base stylesheet falls back to a plain gradient).
`Data.version` is bumped on every write
and embedded in page ETags so nav-affecting changes invalidate caches.
`Data.brand` is the site name (header link + `
` suffix), editable
@@ -81,8 +85,11 @@ not for the public pages. See `docs/design-principles.md` for the design.
is NOT rendered as an additional h1 (it still supplies and nav
labels). The navbar holds
top-level items only; the current section's subitems go to a left
- `#sidebar` (empty and hidden elsewhere). Dynamic regions have stable ids
- (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps.
+ `#sidebar`, which is rendered only when the section offers at least two
+ published items (no aside element at all on the front page, leaf pages
+ and one-page sections). Dynamic regions have stable ids
+ (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps
+ (`#sidebar` may be absent on either side of a swap).
- `seed.py` — demo content written on startup for paths missing from the
database (never overwrites existing pages).
- `frontend/src/` — the Vue editor and public-page entries.
@@ -98,8 +105,15 @@ not for the public pages. See `docs/design-principles.md` for the design.
stylesheets (base and theme) so they can be swapped or augmented.
- `assets/` — shared styles and data files built by Vite and served hashed
under `/_assets/`: `pagerite.css` (base layout + conservative variables),
- `themes/purple/theme.css` (the purple/dark theme override, including its
- own `banner.svg`), `pygments.css`, and `fonts/` (self-hosted Source
+ `themes/{purple,corporate,nitro}/theme.css` (theme overrides and font
+ picks: `purple` = dark dusk palette with Fraunces/Literata and a tilted
+ oversized gradient brand; `corporate` = light-first with automatic
+ `prefers-color-scheme` dark mode, Montserrat/Inter and a huge solid
+ brand; `nitro` = racing/HUD style following `prefers-color-scheme`
+ (warm light-grey page, deep violet in dark), Montserrat/Literata,
+ black as an accent only, a straight orange blade under the banner, and
+ an orange racing-tab nav clipped with a bezier `shape()`), `pygments.css`,
+ and `fonts/` (self-hosted Source
Sans 3/Source Serif 4/Fraunces/Literata/Cormorant/Playfair
Display/Inter/Montserrat/Fira Code
variable woff2). The `::view-transition*` block at the end of `pagerite.css` (from
@@ -151,7 +165,10 @@ not for the public pages. See `docs/design-principles.md` for the design.
`appType: 'mpa'` (no SPA fallback) and builds with `manifest: true`,
`assetsDir: '_/assets'` (so the build mirrors the URL space;
`frontend/public/favicon.ico` lands at the build root and is served at
- `/favicon.ico`). JS inputs are `src/main.js` and `src/pagerite.js`; there
+ `/favicon.ico`). JS inputs are `src/main.js` and `src/pagerite.js`, plus
+ `src/assets/pagerite.css` and every `src/assets/themes/*/theme.css` as
+ separate stylesheet entries (enumerated from the themes directory, so new
+ themes need no config change); there
is no `index.html` source (it would shadow `/` and turn missing dev paths
into an empty Vue shell). All outputs are ES modules. The build sets
`preserveEntrySignatures: 'exports-only'` because main.js is consumed
@@ -202,8 +219,11 @@ not for the public pages. See `docs/design-principles.md` for the design.
keep its integration points (`Frontend`, build hook) intact.
- **markdown-it-py** — Markdown rendering with `html=True` raw
passthrough; mdit-py-plugins for footnote/deflist/tasklists/attrs;
- **Pygments** for server-side code highlighting (`nowrap` spans, styles
- in `frontend/src/assets/pygments.css` scoped to "pre code").
+ **Pygments** for server-side code highlighting (`nowrap` spans, styled
+ by `frontend/src/assets/pygments.css` which maps token classes 1:1 onto
+ the `--code-*` variables; light/dark palette sets live in
+ `pagerite.css` and resolve via `light-dark()` from the theme's
+ `color-scheme` — themes pick a set, not individual colors).
## Conventions
diff --git a/docs/design-principles.md b/docs/design-principles.md
index e9b151d..9dde61f 100644
--- a/docs/design-principles.md
+++ b/docs/design-principles.md
@@ -52,8 +52,12 @@ evolves.
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition
lists, task lists, brace-attributes; tables and strikethrough from the
default preset), with `html=True` for raw passthrough. Fenced code blocks
- are highlighted server-side with **Pygments** (github-dark palette in
- `/_assets/pygments-*.css`); a JS copy button appears on hover. Should this
+ are highlighted server-side with **Pygments** (`nowrap` spans styled by
+ `/_assets/pygments-*.css`, which maps every token class onto the `--code-*`
+ variables; the base stylesheet defines light and dark palette sets resolved
+ via `light-dark()`, so each theme gets the set matching its `color-scheme`
+ and may only retint `--code-bg` to keep the well in the page's color
+ family); a JS copy button appears on hover. Should this
prove limiting, we implement our own renderer on top of html5tagger,
which we already use for all HTML generation.
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`)
@@ -62,10 +66,13 @@ evolves.
that survive page renames and dedupe identical content; pages no longer
own files. An image with a title becomes a `` with
``. Positioning is by attribute classes:
- `{.right}` — `{.right}`, `{.left}` float,
+ `{.right}` — `{.right}`, `{.left}` float at
+ 30% of the text column (the caption wraps within it; an explicit
+ `width=300` overrides on uncaptioned images),
`{.wide}` goes full bleed (viewport edge to edge, or up to the docked
editor; the sidebar stacks on top of it); plain attributes like `width=300`
- work too.
+ work too. Headings (h1/h2) clear floats, so images never overflow into the
+ next section.
## Page structure and navigation
@@ -78,8 +85,15 @@ evolves.
**per-page configurable**: `Node.banner` holds an arbitrary trusted HTML
snippet (an image, a styled div, canvas + script — anything), resolved by
walking up the node's ancestors to the front page; when nothing in the
- chain sets one, the active theme's banner artwork shows (the purple theme
- ships a `banner.svg`; the base stylesheet falls back to a plain gradient).
+ chain sets one, the active theme's banner artwork shows. That artwork is
+ an **inline SVG** (`pagerite/themes/{name}/banner.svg`) the backend
+ inlines into `#page-banner`: as markup it can be recolored from the theme
+ stylesheet (corporate's single SVG serves both light and dark mode via
+ `var()`-driven stops) and it is never rendered underneath a user banner.
+ The base stylesheet falls back to a plain gradient. There is deliberately
+ no scrim fading the banner into the page background — any such fade would
+ ruin user-supplied designs; themes that want one bake it into their SVG
+ (purple does).
- **Fetch-navigation.** Links are plain ``; a small script
(`frontend/src/pagerite.js`) intercepts same-origin clicks, fetches the
page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions,
@@ -99,8 +113,10 @@ evolves.
cannot have children. The header navbar holds only the top level; a
top-level item is highlighted when viewing any of its subpages. When the
current page is inside a main level section with children, those direct
- children are listed in a **left sidebar** (`#sidebar`), one level deep;
- the sidebar is empty (and hidden) elsewhere. Other sections' subitems
+ children are listed in a **left sidebar** (`#sidebar`), one level deep.
+ The sidebar exists only when there is something to navigate — sections
+ with fewer than two published items, leaf pages and the front page render
+ no aside element at all. Other sections' subitems
are never shown without navigating into them first.
- **Landing pages are optional.** Every label can either have content
(`Node.content`, a Markdown page) or none — a content-less label renders
@@ -133,13 +149,24 @@ evolves.
- The base stylesheet `frontend/src/assets/pagerite.css` provides the layout,
typography and interaction rules with conservative CSS variables. A theme layer
- (`frontend/src/assets/themes/purple/theme.css`) overrides those variables and
+ (`frontend/src/assets/themes/{name}/theme.css` — currently `purple`, `corporate`
+ and `nitro`) overrides those variables and
adds the visual styling; `Data.theme` selects the active theme (empty = none/base
only) and the site editor can switch it. Vue may add per-component styles on top
- where needed.
-- Fonts, the shared stylesheet, pygments styles and the theme's banner SVG
- live under `frontend/src/assets/` (the banner SVG under
- `themes/purple/`) and are emitted as hashed assets under `/_assets/`
+ where needed. The corporate and nitro themes switch palettes automatically via
+ `prefers-color-scheme` (corporate is light-first with a matching dark palette;
+ nitro a warm light-grey page or, in dark mode, a deep violet one — its dark
+ banner and orange accents carry over unchanged); purple (dusk) uses one
+ fixed palette for everyone. Themes may restyle structural details the base
+ leaves plain — heading colors and underlines, list markers, nav treatment,
+ brand sizing. The banner artwork has scroll parallax: pagerite.js sets the
+ `--pry` scroll parameter on `` (event-driven, so it is still when the
+ page is idle), the banner contents drift within their window (with scale
+ overscan so no edge shows), and themes may key their own effects off the
+ same parameter — purple's sun rises as you scroll.
+- Fonts, the shared stylesheet and pygments styles
+ live under `frontend/src/assets/` and are emitted as hashed assets under
+ `/_assets/`
(Source Serif 4 for headings, Source Sans 3 for body, Fira Code for code
by default; Fraunces, Literata, Cormorant, Playfair Display, Inter and
Montserrat kept as woff2 options with local `@font-face`, variable-weight
diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue
index 84fca3b..ca643a2 100644
--- a/frontend/src/SiteEditor.vue
+++ b/frontend/src/SiteEditor.vue
@@ -105,11 +105,22 @@ function openPath(p) {
// --- In-place navigation (no transitions, replaceState) ------------------
function swapRegions(doc) {
- for (const id of ['page-banner', 'nav', 'sidebar', 'main']) {
+ for (const id of ['page-banner', 'nav', 'main']) {
const fresh = doc.getElementById(id)
const el = document.getElementById(id)
if (fresh && el) el.replaceWith(document.importNode(fresh, true))
}
+ // #sidebar is omitted entirely when the section has no sub-navigation,
+ // so it may be absent on either side: replace, insert, or remove.
+ const freshSidebar = doc.getElementById('sidebar')
+ const curSidebar = document.getElementById('sidebar')
+ if (freshSidebar && curSidebar) {
+ curSidebar.replaceWith(document.importNode(freshSidebar, true))
+ } else if (freshSidebar) {
+ document.getElementById('main')?.before(document.importNode(freshSidebar, true))
+ } else if (curSidebar) {
+ curSidebar.remove()
+ }
// The brand link lives in the header, outside the swappable regions,
// and is absent entirely when no brand is configured.
const freshBrand = doc.getElementById('brand')
@@ -283,6 +294,8 @@ const theme = ref('purple')
const THEME_OPTIONS = [
{ value: '', label: 'none' },
{ value: 'purple', label: 'purple' },
+ { value: 'corporate', label: 'corporate' },
+ { value: 'nitro', label: 'nitro' },
]
async function loadSettings() {
diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css
index d9043dc..a3a06d4 100644
--- a/frontend/src/assets/pagerite.css
+++ b/frontend/src/assets/pagerite.css
@@ -5,6 +5,9 @@
@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;
@@ -12,6 +15,28 @@
--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;
@@ -77,8 +102,10 @@ body {
border-bottom: 1px solid var(--line);
}
-/* Per-page banner content (img, styled div, canvas...) overlays the
- default artwork; swapped along with #nav/#main on fetch-navigation. */
+/* 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;
@@ -90,16 +117,11 @@ body {
width: 100%;
height: 100%;
object-fit: cover;
-}
-
-/* Legibility scrim: fade banner artwork into the page background behind
- the overlaid brand/nav. Text shadows are not used for this — on light
- backgrounds they just look like blurry text. */
-#page-banner::after {
- content: "";
- position: absolute;
- inset: 30% 0 0;
- background: linear-gradient(transparent, var(--bg));
+ /* 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;
}
#brand,
@@ -247,10 +269,6 @@ button.banner-edit-link {
backdrop-filter: blur(0.5rem);
}
-#sidebar:empty {
- display: none;
-}
-
#sidebar ul {
list-style: none;
margin: 0;
@@ -436,12 +454,13 @@ article h2 {
}
article a {
- color: var(--accent);
+ color: var(--link);
+ font-weight: 500;
text-decoration: none;
}
article a:hover {
- text-decoration: underline;
+ color: var(--accent);
}
/* Blockquotes: inner paragraphs carry no margins (spacing comes from the
@@ -463,9 +482,11 @@ 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. */
+ 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;
}
@@ -531,7 +552,6 @@ figure {
figcaption {
color: var(--muted);
font-size: 0.85rem;
- margin-top: 0.4rem;
}
/* Captions of full-bleed images: centered and kept to a readable width. */
@@ -546,14 +566,12 @@ figure:has(.right),
img.right {
float: right;
margin: 0.3rem 0 1rem 1.5rem;
- max-width: 45%;
}
figure:has(.left),
img.left {
float: left;
margin: 0.3rem 1.5rem 1rem 0;
- max-width: 45%;
}
/* .wide is full bleed: edge to edge of the viewport (or of the space left
@@ -577,6 +595,44 @@ p:has(> img.wide:only-child) {
line-height: 0;
}
+/* A captioned image is a , 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;
+ transform: none;
+}
+
+/* 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
+ two-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;
+}
+
body.editing figure:has(.wide),
body.editing img.wide {
width: calc(100vw - var(--editor-w) - 1rem);
diff --git a/frontend/src/assets/pygments.css b/frontend/src/assets/pygments.css
index c654eea..04835df 100644
--- a/frontend/src/assets/pygments.css
+++ b/frontend/src/assets/pygments.css
@@ -1,86 +1,90 @@
+/* Pygments token styling, mapped 1:1 onto the --code-* palette variables
+ defined in pagerite.css (light and dark sets resolved there via
+ light-dark() from the theme's color-scheme). No literal colors in this
+ file: recoloring code happens through the variables only. */
pre { line-height: 125%; }
-td.linenos .normal { color: #6e7681; background-color: #0d1117; padding-left: 5px; padding-right: 5px; }
-span.linenos { color: #6e7681; background-color: #0d1117; padding-left: 5px; padding-right: 5px; }
-td.linenos .special { color: #e6edf3; background-color: #6e7681; padding-left: 5px; padding-right: 5px; }
-span.linenos.special { color: #e6edf3; background-color: #6e7681; padding-left: 5px; padding-right: 5px; }
-pre code .hll { background-color: #6e7681 }
-pre code { color: #E6EDF3 }
-pre code .c { color: #8B949E; font-style: italic } /* Comment */
-pre code .err { color: #F85149 } /* Error */
-pre code .esc { color: #E6EDF3 } /* Escape */
-pre code .g { color: #E6EDF3 } /* Generic */
-pre code .k { color: #FF7B72 } /* Keyword */
-pre code .l { color: #A5D6FF } /* Literal */
-pre code .n { color: #E6EDF3 } /* Name */
-pre code .o { color: #FF7B72; font-weight: bold } /* Operator */
-pre code .x { color: #E6EDF3 } /* Other */
-pre code .p { color: #E6EDF3 } /* Punctuation */
-pre code .ch { color: #8B949E; font-style: italic } /* Comment.Hashbang */
-pre code .cm { color: #8B949E; font-style: italic } /* Comment.Multiline */
-pre code .cp { color: #8B949E; font-weight: bold; font-style: italic } /* Comment.Preproc */
-pre code .cpf { color: #8B949E; font-style: italic } /* Comment.PreprocFile */
-pre code .c1 { color: #8B949E; font-style: italic } /* Comment.Single */
-pre code .cs { color: #8B949E; font-weight: bold; font-style: italic } /* Comment.Special */
-pre code .gd { color: #FFA198; background-color: #490202 } /* Generic.Deleted */
-pre code .ge { color: #E6EDF3; font-style: italic } /* Generic.Emph */
-pre code .ges { color: #E6EDF3; font-weight: bold; font-style: italic } /* Generic.EmphStrong */
-pre code .gr { color: #FFA198 } /* Generic.Error */
-pre code .gh { color: #79C0FF; font-weight: bold } /* Generic.Heading */
-pre code .gi { color: #56D364; background-color: #0F5323 } /* Generic.Inserted */
-pre code .go { color: #8B949E } /* Generic.Output */
-pre code .gp { color: #8B949E } /* Generic.Prompt */
-pre code .gs { color: #E6EDF3; font-weight: bold } /* Generic.Strong */
-pre code .gu { color: #79C0FF } /* Generic.Subheading */
-pre code .gt { color: #FF7B72 } /* Generic.Traceback */
-pre code .g-Underline { color: #E6EDF3; text-decoration: underline } /* Generic.Underline */
-pre code .kc { color: #79C0FF } /* Keyword.Constant */
-pre code .kd { color: #FF7B72 } /* Keyword.Declaration */
-pre code .kn { color: #FF7B72 } /* Keyword.Namespace */
-pre code .kp { color: #79C0FF } /* Keyword.Pseudo */
-pre code .kr { color: #FF7B72 } /* Keyword.Reserved */
-pre code .kt { color: #FF7B72 } /* Keyword.Type */
-pre code .ld { color: #79C0FF } /* Literal.Date */
-pre code .m { color: #A5D6FF } /* Literal.Number */
-pre code .s { color: #A5D6FF } /* Literal.String */
-pre code .na { color: #E6EDF3 } /* Name.Attribute */
-pre code .nb { color: #E6EDF3 } /* Name.Builtin */
-pre code .nc { color: #F0883E; font-weight: bold } /* Name.Class */
-pre code .no { color: #79C0FF; font-weight: bold } /* Name.Constant */
-pre code .nd { color: #D2A8FF; font-weight: bold } /* Name.Decorator */
-pre code .ni { color: #FFA657 } /* Name.Entity */
-pre code .ne { color: #F0883E; font-weight: bold } /* Name.Exception */
-pre code .nf { color: #D2A8FF; font-weight: bold } /* Name.Function */
-pre code .nl { color: #79C0FF; font-weight: bold } /* Name.Label */
-pre code .nn { color: #FF7B72 } /* Name.Namespace */
-pre code .nx { color: #E6EDF3 } /* Name.Other */
-pre code .py { color: #79C0FF } /* Name.Property */
-pre code .nt { color: #7EE787 } /* Name.Tag */
-pre code .nv { color: #79C0FF } /* Name.Variable */
-pre code .ow { color: #FF7B72; font-weight: bold } /* Operator.Word */
-pre code .pm { color: #E6EDF3 } /* Punctuation.Marker */
-pre code .w { color: #6E7681 } /* Text.Whitespace */
-pre code .mb { color: #A5D6FF } /* Literal.Number.Bin */
-pre code .mf { color: #A5D6FF } /* Literal.Number.Float */
-pre code .mh { color: #A5D6FF } /* Literal.Number.Hex */
-pre code .mi { color: #A5D6FF } /* Literal.Number.Integer */
-pre code .mo { color: #A5D6FF } /* Literal.Number.Oct */
-pre code .sa { color: #79C0FF } /* Literal.String.Affix */
-pre code .sb { color: #A5D6FF } /* Literal.String.Backtick */
-pre code .sc { color: #A5D6FF } /* Literal.String.Char */
-pre code .dl { color: #79C0FF } /* Literal.String.Delimiter */
-pre code .sd { color: #A5D6FF } /* Literal.String.Doc */
-pre code .s2 { color: #A5D6FF } /* Literal.String.Double */
-pre code .se { color: #79C0FF } /* Literal.String.Escape */
-pre code .sh { color: #79C0FF } /* Literal.String.Heredoc */
-pre code .si { color: #A5D6FF } /* Literal.String.Interpol */
-pre code .sx { color: #A5D6FF } /* Literal.String.Other */
-pre code .sr { color: #79C0FF } /* Literal.String.Regex */
-pre code .s1 { color: #A5D6FF } /* Literal.String.Single */
-pre code .ss { color: #A5D6FF } /* Literal.String.Symbol */
-pre code .bp { color: #E6EDF3 } /* Name.Builtin.Pseudo */
-pre code .fm { color: #D2A8FF; font-weight: bold } /* Name.Function.Magic */
-pre code .vc { color: #79C0FF } /* Name.Variable.Class */
-pre code .vg { color: #79C0FF } /* Name.Variable.Global */
-pre code .vi { color: #79C0FF } /* Name.Variable.Instance */
-pre code .vm { color: #79C0FF } /* Name.Variable.Magic */
-pre code .il { color: #A5D6FF } /* Literal.Number.Integer.Long */
\ No newline at end of file
+td.linenos .normal { color: var(--code-muted); background-color: var(--code-bg); padding-left: 5px; padding-right: 5px; }
+span.linenos { color: var(--code-muted); background-color: var(--code-bg); padding-left: 5px; padding-right: 5px; }
+td.linenos .special { color: var(--code-text); background-color: var(--code-muted); padding-left: 5px; padding-right: 5px; }
+span.linenos.special { color: var(--code-text); background-color: var(--code-muted); padding-left: 5px; padding-right: 5px; }
+pre code .hll { background-color: var(--code-hl) }
+pre code { color: var(--code-text) }
+pre code .c { color: var(--code-comment); font-style: italic } /* Comment */
+pre code .err { color: var(--code-error) } /* Error */
+pre code .esc { color: var(--code-text) } /* Escape */
+pre code .g { color: var(--code-text) } /* Generic */
+pre code .k { color: var(--code-keyword) } /* Keyword */
+pre code .l { color: var(--code-string) } /* Literal */
+pre code .n { color: var(--code-text) } /* Name */
+pre code .o { color: var(--code-keyword); font-weight: bold } /* Operator */
+pre code .x { color: var(--code-text) } /* Other */
+pre code .p { color: var(--code-text) } /* Punctuation */
+pre code .ch { color: var(--code-comment); font-style: italic } /* Comment.Hashbang */
+pre code .cm { color: var(--code-comment); font-style: italic } /* Comment.Multiline */
+pre code .cp { color: var(--code-comment); font-weight: bold; font-style: italic } /* Comment.Preproc */
+pre code .cpf { color: var(--code-comment); font-style: italic } /* Comment.PreprocFile */
+pre code .c1 { color: var(--code-comment); font-style: italic } /* Comment.Single */
+pre code .cs { color: var(--code-comment); font-weight: bold; font-style: italic } /* Comment.Special */
+pre code .gd { color: var(--code-error); background-color: color-mix(in oklab, var(--code-error) 25%, var(--code-bg)) } /* Generic.Deleted */
+pre code .ge { color: var(--code-text); font-style: italic } /* Generic.Emph */
+pre code .ges { color: var(--code-text); font-weight: bold; font-style: italic } /* Generic.EmphStrong */
+pre code .gr { color: var(--code-error) } /* Generic.Error */
+pre code .gh { color: var(--code-builtin); font-weight: bold } /* Generic.Heading */
+pre code .gi { color: var(--code-added); background-color: color-mix(in oklab, var(--code-added) 25%, var(--code-bg)) } /* Generic.Inserted */
+pre code .go { color: var(--code-muted) } /* Generic.Output */
+pre code .gp { color: var(--code-muted) } /* Generic.Prompt */
+pre code .gs { color: var(--code-text); font-weight: bold } /* Generic.Strong */
+pre code .gu { color: var(--code-builtin) } /* Generic.Subheading */
+pre code .gt { color: var(--code-error) } /* Generic.Traceback */
+pre code .g-Underline { color: var(--code-text); text-decoration: underline } /* Generic.Underline */
+pre code .kc { color: var(--code-builtin) } /* Keyword.Constant */
+pre code .kd { color: var(--code-keyword) } /* Keyword.Declaration */
+pre code .kn { color: var(--code-keyword) } /* Keyword.Namespace */
+pre code .kp { color: var(--code-builtin) } /* Keyword.Pseudo */
+pre code .kr { color: var(--code-keyword) } /* Keyword.Reserved */
+pre code .kt { color: var(--code-builtin) } /* Keyword.Type */
+pre code .ld { color: var(--code-builtin) } /* Literal.Date */
+pre code .m { color: var(--code-string) } /* Literal.Number */
+pre code .s { color: var(--code-string) } /* Literal.String */
+pre code .na { color: var(--code-text) } /* Name.Attribute */
+pre code .nb { color: var(--code-text) } /* Name.Builtin */
+pre code .nc { color: var(--code-type); font-weight: bold } /* Name.Class */
+pre code .no { color: var(--code-builtin); font-weight: bold } /* Name.Constant */
+pre code .nd { color: var(--code-function); font-weight: bold } /* Name.Decorator */
+pre code .ni { color: var(--code-type) } /* Name.Entity */
+pre code .ne { color: var(--code-type); font-weight: bold } /* Name.Exception */
+pre code .nf { color: var(--code-function); font-weight: bold } /* Name.Function */
+pre code .nl { color: var(--code-builtin); font-weight: bold } /* Name.Label */
+pre code .nn { color: var(--code-keyword) } /* Name.Namespace */
+pre code .nx { color: var(--code-text) } /* Name.Other */
+pre code .py { color: var(--code-builtin) } /* Name.Property */
+pre code .nt { color: var(--code-tag) } /* Name.Tag */
+pre code .nv { color: var(--code-builtin) } /* Name.Variable */
+pre code .ow { color: var(--code-keyword); font-weight: bold } /* Operator.Word */
+pre code .pm { color: var(--code-text) } /* Punctuation.Marker */
+pre code .w { color: var(--code-muted) } /* Text.Whitespace */
+pre code .mb { color: var(--code-string) } /* Literal.Number.Bin */
+pre code .mf { color: var(--code-string) } /* Literal.Number.Float */
+pre code .mh { color: var(--code-string) } /* Literal.Number.Hex */
+pre code .mi { color: var(--code-string) } /* Literal.Number.Integer */
+pre code .mo { color: var(--code-string) } /* Literal.Number.Oct */
+pre code .sa { color: var(--code-builtin) } /* Literal.String.Affix */
+pre code .sb { color: var(--code-string) } /* Literal.String.Backtick */
+pre code .sc { color: var(--code-string) } /* Literal.String.Char */
+pre code .dl { color: var(--code-builtin) } /* Literal.String.Delimiter */
+pre code .sd { color: var(--code-string) } /* Literal.String.Doc */
+pre code .s2 { color: var(--code-string) } /* Literal.String.Double */
+pre code .se { color: var(--code-builtin) } /* Literal.String.Escape */
+pre code .sh { color: var(--code-builtin) } /* Literal.String.Heredoc */
+pre code .si { color: var(--code-builtin) } /* Literal.String.Interpol */
+pre code .sx { color: var(--code-string) } /* Literal.String.Other */
+pre code .sr { color: var(--code-builtin) } /* Literal.String.Regex */
+pre code .s1 { color: var(--code-string) } /* Literal.String.Single */
+pre code .ss { color: var(--code-string) } /* Literal.String.Symbol */
+pre code .bp { color: var(--code-text) } /* Name.Builtin.Pseudo */
+pre code .fm { color: var(--code-function); font-weight: bold } /* Name.Function.Magic */
+pre code .vc { color: var(--code-builtin) } /* Name.Variable.Class */
+pre code .vg { color: var(--code-builtin) } /* Name.Variable.Global */
+pre code .vi { color: var(--code-builtin) } /* Name.Variable.Instance */
+pre code .vm { color: var(--code-builtin) } /* Name.Variable.Magic */
+pre code .il { color: var(--code-string) } /* Literal.Number.Integer.Long */
diff --git a/frontend/src/assets/themes/corporate/theme.css b/frontend/src/assets/themes/corporate/theme.css
new file mode 100644
index 0000000..7ea0db0
--- /dev/null
+++ b/frontend/src/assets/themes/corporate/theme.css
@@ -0,0 +1,219 @@
+/* Corporate theme: bright and bold professional. Saturated royal-blue
+ gradients on white, geometric Montserrat display type over Inter body,
+ and a genuinely large brand with a soft blue overlap shadow. Automatic
+ dark mode keeps the same saturated blue identity on deep navy; the
+ banner artwork (inlined by the backend) is recolored from here via the
+ cb-* classes, so one SVG serves both modes. */
+
+:root {
+ color-scheme: light dark;
+ --bg: #ffffff;
+ --surface: #eef3fd;
+ --text: #12203f;
+ --muted: #4d5f83;
+ --accent: #0a5cff;
+ --accent2: #0933a0;
+ --line: #12203f14;
+ --font-body: var(--font-inter);
+ --font-heading: var(--font-montserrat);
+}
+
+/* Banner artwork colors, light mode */
+.cb-bg0 {
+ stop-color: #ffffff;
+}
+
+.cb-bg1 {
+ stop-color: #e6eefe;
+}
+
+.cb-r0 {
+ stop-color: var(--accent);
+}
+
+.cb-r1 {
+ stop-color: #00b3ff;
+}
+
+.cb-g0,
+.cb-g1 {
+ stop-color: var(--accent);
+}
+
+.cb-dot {
+ fill: var(--accent);
+}
+
+.cb-orbit {
+ stroke: var(--accent);
+}
+
+.cb-spark {
+ fill: var(--accent);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #0b1428;
+ --surface: #142446;
+ --text: #e9eefe;
+ --muted: #93a7d0;
+ --accent: #4d8dff;
+ --accent2: #8ab6ff;
+ --line: #ffffff17;
+ /* Code wells stay navy in dark mode (light mode uses --surface). */
+ --code-bg: #0d1b3e;
+ }
+
+ /* Banner artwork colors, dark mode */
+ .cb-bg0 {
+ stop-color: #0d1830;
+ }
+
+ .cb-bg1 {
+ stop-color: #0a1122;
+ }
+
+ .cb-r0 {
+ stop-color: #2f7bff;
+ }
+
+ .cb-r1 {
+ stop-color: #00d0ff;
+ }
+
+ .cb-g0,
+ .cb-g1 {
+ stop-color: #2f7bff;
+ }
+
+ .cb-dot {
+ fill: #4d8dff;
+ }
+
+ .cb-orbit {
+ stroke: #4d8dff;
+ }
+
+ .cb-spark {
+ fill: #6ea8ff;
+ }
+}
+
+::selection {
+ background: var(--accent);
+ color: #fff;
+}
+
+/* Genuinely large solid brand with a soft blue shadow overlapping the
+ artwork — conservative, but unmissable. */
+#brand {
+ font-size: clamp(4rem, 11vw, 8.5rem);
+ font-weight: 800;
+ letter-spacing: -0.04em;
+ line-height: 1;
+ white-space: nowrap;
+ color: var(--accent2);
+ filter: drop-shadow(0 0.4rem 1.4rem rgb(10 92 255 / 0.3));
+}
+
+@media (prefers-color-scheme: dark) {
+ #brand {
+ color: #eaf1ff;
+ filter: drop-shadow(0 0.4rem 1.4rem rgb(0 0 0 / 0.6));
+ }
+}
+
+#banner {
+ min-height: 15rem;
+ border-bottom: none;
+}
+
+#nav {
+ font-size: 1.05em;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ text-shadow: none;
+}
+
+#nav .current {
+ color: #fff;
+ background: linear-gradient(120deg, var(--accent), var(--accent2));
+ border-radius: 999px;
+ padding: 0.15rem 0.85rem;
+ margin: -0.15rem -0.85rem;
+}
+
+@media (prefers-color-scheme: dark) {
+ #nav {
+ text-shadow: 0 0 0.15em #000;
+ }
+}
+
+/* Heading hierarchy: h1 navy with a short gradient bar, h2 in accent
+ blue, h3 as an uppercase kicker. */
+article h1 {
+ font-weight: 800;
+ letter-spacing: -0.025em;
+ color: var(--accent2);
+}
+
+@media (prefers-color-scheme: dark) {
+ article h1 {
+ color: var(--text);
+ }
+}
+
+article h1::after {
+ content: "";
+ display: block;
+ width: 3.6rem;
+ height: 0.32rem;
+ margin-top: 0.5rem;
+ border-radius: 2px;
+ background: linear-gradient(90deg, var(--accent), #00b3ff);
+}
+
+article h2 {
+ font-weight: 700;
+ letter-spacing: -0.015em;
+ color: var(--accent);
+}
+
+article h3 {
+ font-weight: 700;
+ font-size: 0.95rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+blockquote {
+ border-left-color: var(--accent);
+ background: color-mix(in oklab, var(--accent) 6%, transparent);
+ padding: 0.4rem 0.9rem;
+ /* Keep the quoted text on the paragraph edge: the tinted box extends
+ past it by its own border/padding, like code blocks. */
+ margin: 0 -0.9rem 1rem calc(-0.25rem - 0.9rem);
+ border-radius: 6px;
+}
+
+/* Code follows the color scheme: the light set on a faint-blue surface in
+ light mode, a navy well in dark mode (--code-bg above). The accent side
+ bar stays in both. */
+pre {
+ border: 1px solid transparent;
+ border-left: 0.25rem solid var(--accent);
+ /* Text on the paragraph edge: the box extends by padding + border. */
+ margin-left: calc(-0.8rem - 0.25rem);
+ border-radius: 6px;
+}
+
+img {
+ border-radius: 4px;
+}
+
+::view-transition {
+ background: var(--bg);
+}
diff --git a/frontend/src/assets/themes/nitro/theme.css b/frontend/src/assets/themes/nitro/theme.css
new file mode 100644
index 0000000..db24b64
--- /dev/null
+++ b/frontend/src/assets/themes/nitro/theme.css
@@ -0,0 +1,286 @@
+/* Nitro theme: racing/HUD style — saturated orange + cyan, console/HUD-style
+ monospace display type, an orange racing-tab nav with a bezier clip-path,
+ and a dark bezier banner. Follows prefers-color-scheme: the light scheme
+ is a warm light-grey page, the dark scheme a deep violet page; the banner
+ and other dark/bright elements carry over unchanged.
+
+ Color model: the base stylesheet's variables carry the page; nitro adds
+ a small set of theme-specific colors below, and everything else in the
+ file references variables only, so recoloring happens here. */
+
+:root {
+ color-scheme: light dark;
+
+ /* Page palette (base variables) */
+ --bg: #eee;
+ /* warm light grey page background */
+ --surface: #fff;
+ --text: #1a1a1a;
+ --muted: #655f52;
+ /* warm grey secondary text */
+ --line: #1a1a1a1f;
+ /* hairlines: tables, column rules */
+ --accent: #ff6a00;
+ /* racing orange: decorations, nav tab, markers */
+ --accent2: #008ba3;
+ /* deep cyan: h3, blockquote bar, nested markers */
+
+ /* Nitro-specific colors */
+ --orange-deep: #d95a00;
+ /* orange dark enough for body text (h2, links) */
+ --ink: #000;
+ /* pure black details: tab text, brand shadow */
+ --neon: #59ecff;
+ /* glowing cyan: current page on the orange tab */
+ --tab-current: #eee;
+ --tab-hover: #fff;
+ /* glowing white: nav hover (kept bright: the
+ nav glow is text-shadow, which fades dimmer) */
+ --tab-muted: #0000008c;
+ /* secondary labels on the orange tab */
+
+ --font-body: var(--font-montserrat);
+ --font-heading: var(--font-literata);
+}
+
+/* Dark scheme: same identity, but the page goes deep violet (never muddy
+ near-black); the cyan and orange accents brighten to keep their punch. */
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #17141f;
+ --surface: #231f2e;
+ --text: #f0ede8;
+ --muted: #a89f8f;
+ --line: #ffffff22;
+ --accent2: #2fc3dd;
+ /* brighter cyan, readable on dark */
+ --orange-deep: #ff8a3d;
+ /* brighter orange, readable on dark */
+ --code-bg: #12101b;
+ /* code wells join the violet family */
+ }
+
+ /* Banner dark tones tinted to the same violet family as the page. */
+ .nb-base {
+ fill: #100d18;
+ }
+
+ .nb-s1a {
+ stop-color: #292536;
+ }
+
+ .nb-s1b {
+ stop-color: #100d18;
+ }
+
+ .nb-s2a {
+ stop-color: #1e1a2b;
+ }
+
+ .nb-s2b {
+ stop-color: #090811;
+ }
+
+ .nb-c0 {
+ stop-color: #322d44;
+ }
+
+ .nb-c1 {
+ stop-color: #171422;
+ }
+
+ .nb-c2 {
+ stop-color: #100d18;
+ }
+}
+
+::selection {
+ background: var(--accent);
+ color: var(--ink);
+}
+
+/* Oversized outlined brand, spilling off the banner edge: orange stroke,
+ solid black fill. */
+#brand {
+ font-size: 10rem;
+ line-height: 1.2;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ color: var(--orange-deep);
+ text-shadow: 0 0 0.1em black;
+}
+
+/* Bezier-swept banner with wide orange stripes (inlined SVG), separated
+ from the page by a straight orange blade. */
+#banner {
+ height: 13rem;
+ border-bottom: 4px solid var(--accent);
+}
+
+/* Banner artwork dark tones: neutral greys in light mode (retinted to the
+ page's violet family by the dark-scheme block above). */
+.nb-base {
+ fill: #0b0b0d;
+}
+
+.nb-s1a {
+ stop-color: #242428;
+}
+
+.nb-s1b {
+ stop-color: #0b0b0d;
+}
+
+.nb-s2a {
+ stop-color: #19191d;
+}
+
+.nb-s2b {
+ stop-color: #060607;
+}
+
+.nb-c0 {
+ stop-color: #2a2a2f;
+}
+
+.nb-c1 {
+ stop-color: #131315;
+}
+
+.nb-c2 {
+ stop-color: #0b0b0d;
+}
+
+#nav {
+ font-family: var(--font-heading);
+ font-size: 0.95em;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ /* Racing tab: sitting at the bottom of the banner, with a bezier clip —
+ slanted sides that join top and bottom horizontally, and a shallow
+ wide notch at the top middle. Generous side padding so the em-based
+ slants fit. The border-radius is the fallback for browsers without
+ shape(). */
+ background: var(--accent);
+ width: fit-content;
+ padding: 0.3em 2.5em 0;
+ border-radius: 1.2em 1.2em 0 0;
+ clip-path: shape(
+ /* left side */
+ from 0% 100%,
+ curve to 2.5em 0 with 2em 100% / 1em 0,
+ /* notch: ≈ golden-ratio width of the available top edge */
+ hline to calc(19.1% + 1.7em),
+ line to calc(19.1% + 2.1em) 0.2em,
+ hline to calc(80.9% - 2.1em),
+ line to calc(80.9% - 1.7em) 0,
+ /* right side, bottom */
+ hline to calc(100% - 2.5em),
+ curve to 100% 100% with calc(100% - 1em) 0 / calc(100% - 2em) 100%,
+ close);
+}
+
+#nav a {
+ color: var(--ink);
+}
+
+/* Current page and hover glow on the orange tab; both stay bright colors
+ because the glow is text-shadow. */
+#nav .current {
+ color: var(--tab-current);
+ text-shadow: 0 0 0.1em;
+}
+
+#nav a:hover {
+ color: var(--tab-hover);
+ text-shadow: 0 0 0.1em;
+}
+
+#nav span {
+ color: var(--tab-muted);
+}
+
+/* Console-style headings: uppercase monospace. h1 in the page text color
+ with a hazard-stripe underline, h2 deep orange, h3 cyan. */
+article h1,
+article h2,
+article h3 {
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+article h1 {
+ color: var(--text);
+ font-weight: 700;
+ padding-bottom: 0.5rem;
+ /* The hazard-stripe underline breaks out of the page box: the negative
+ right margin extends the h1's box (and thus its background) all the
+ way to the viewport's right edge. */
+ margin-right: calc((100% - 100vw) / 2);
+ background:
+ linear-gradient(-55deg,
+ transparent 0 0.2rem,
+ var(--accent) 0.2rem 0.9rem,
+ transparent 0.9rem 1.4rem) -0.2rem bottom / 1.4rem 4px repeat-x;
+}
+
+article h2 {
+ color: var(--orange-deep);
+ font-weight: 700;
+}
+
+article h3 {
+ color: var(--accent2);
+ font-weight: 700;
+}
+
+article a:hover {
+ color: var(--accent2);
+ text-decoration: none;
+}
+
+/* Chevron markers instead of the base emoji diamonds. */
+article ul li::before {
+ content: "»";
+ color: var(--accent);
+ font-weight: 700;
+}
+
+article ul ul li::before {
+ content: "›";
+ color: var(--accent2);
+}
+
+article ul ul ul li::before {
+ content: "»";
+ color: var(--accent);
+}
+
+blockquote {
+ border-left-color: var(--accent2);
+ background: color-mix(in oklab, var(--accent2) 6%, transparent);
+ padding: 0.25rem 0.75rem;
+ /* Keep the quoted text on the paragraph edge: the tinted box extends
+ past it by its own border/padding, like code blocks. */
+ margin: 0 -0.75rem 1rem -1rem;
+}
+
+/* Code follows the color scheme; the dark-scheme well joins the violet
+ family (--code-bg above). The orange side bar stays in both. */
+pre {
+ border-left: 0.25rem solid var(--accent);
+ /* Text on the paragraph edge: the box extends by padding + border. */
+ margin-left: calc(-0.8rem - 0.25rem);
+ border-radius: 3px;
+}
+
+img {
+ border-radius: 3px;
+}
+
+::view-transition {
+ background: var(--bg);
+}
diff --git a/frontend/src/assets/themes/purple/banner.svg b/frontend/src/assets/themes/purple/banner.svg
deleted file mode 100644
index e93bc03..0000000
--- a/frontend/src/assets/themes/purple/banner.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/frontend/src/assets/themes/purple/theme.css b/frontend/src/assets/themes/purple/theme.css
index 376ae30..1498cbf 100644
--- a/frontend/src/assets/themes/purple/theme.css
+++ b/frontend/src/assets/themes/purple/theme.css
@@ -1,38 +1,74 @@
-/* Purple theme: overrides the conservative base variables and adds the
- more elaborate visual styling (dark palette, gradient brand, etc.). */
+/* Purple theme: bold dusk palette (sky/violet/pink on deep indigo),
+ editorial typography (Fraunces display over Literata body), the sunrise
+ banner artwork (inlined by the backend into #page-banner) and a
+ playfully oversized tilted brand. */
:root {
color-scheme: dark;
- --bg: #12101c;
- --surface: #1b1830;
- --text: #e8e6f2;
- --muted: #9a94b8;
- --accent: #00d4c8;
- --accent2: #7c5cff;
- --line: #ffffff1a;
+ --bg: #131022;
+ --surface: #1e1a36;
+ --text: #ece9f7;
+ --muted: #a79ecb;
+ /* Icy sky-cyan: the cool counterweight to violet/pink (green-leaning
+ teal clashed with them), and complementary to the sunrise's sun. */
+ --accent: #5ad1f5;
+ --accent2: #9b6bff;
+ --accent3: #ff6b9d;
+ --line: #ffffff1c;
+ --font-body: var(--font-literata);
+ --font-heading: var(--font-fraunces);
}
::selection {
background: var(--accent2);
+ color: #fff;
}
+/* Oversized tilted brand in the sky→violet gradient. */
#brand {
+ font-size: clamp(3.2rem, 9vw, 7.5rem);
+ line-height: 1;
+ margin-bottom: -0.28em;
+ transform: rotate(-2deg);
+ transform-origin: left bottom;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: none;
- filter: drop-shadow(0 0 0.1rem #000);
+ filter: drop-shadow(0 0.15rem 0.6rem #9b6bff55);
+}
+
+/* Sunrise parallax: the sun and its glow rise faster than the artwork
+ drift (pagerite.js sets --pry on ), so scrolling the page makes
+ the sun come up. */
+#page-banner .sun,
+#page-banner .sun-glow {
+ transform-box: fill-box;
+ transform: translateY(calc(var(--pry, 0px) * -2));
+}
+
+/* The banner artwork fades into the page background at its bottom edge
+ (baked into the SVG, so a user banner replaces it cleanly). */
+.banner-fade {
+ stop-color: var(--bg);
}
#banner {
- background: url("./banner.svg") center 40% / cover;
+ min-height: 13rem;
}
-/* Colored headings are part of the theme's more elaborate styling; the
- base keeps headings in plain text color. */
+/* Dark artwork: keep the nav readable with a shadow. */
+#nav {
+ text-shadow: 0 0 0.15em black;
+}
+
+/* Colored headings, one accent per level: teal h1 with a gradient
+ underline, violet h2, pink h3 in a quieter weight. */
article h1 {
color: var(--accent);
+ padding-bottom: 0.35rem;
+ background: linear-gradient(90deg, var(--accent), var(--accent2) 45%, transparent) bottom left / 100% 2px no-repeat;
}
article h2 {
@@ -40,17 +76,37 @@ article h2 {
}
article h3 {
- color: color-mix(in oklab, var(--accent2) 60%, var(--muted));
+ color: var(--accent3);
+ font-weight: 500;
}
-/* Dark artwork: keep the nav readable with a shadow (the base style uses
- a scrim instead — shadows only work on dark backgrounds). */
-#nav {
- text-shadow: 0 0 0.15em black;
+/* Theme-colored diamond markers instead of the base emoji (blue/orange
+ clashes with this palette). */
+article ul li::before {
+ content: "◆";
+ color: var(--accent);
+ font-size: 0.7em;
+ vertical-align: 0.15em;
}
+article ul ul li::before {
+ content: "◆";
+ color: var(--accent2);
+}
+
+article ul ul ul li::before {
+ content: "◆";
+ color: var(--accent3);
+}
+
+blockquote {
+ border-left-color: var(--accent2);
+}
+
+/* Code panels sit slightly lighter than the page; the token colors come
+ from the base dark set (this theme declares color-scheme: dark). */
pre {
- background: #ffffff09;
+ --code-bg: var(--surface);
}
::view-transition {
diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js
index cee20af..ed6ce05 100644
--- a/frontend/src/pagerite.js
+++ b/frontend/src/pagerite.js
@@ -20,7 +20,9 @@
});
}
- const REGIONS = ["page-banner", "nav", "sidebar", "main"];
+ // Regions every page has. #sidebar is NOT among them: it is omitted
+ // entirely when the section has no sub-navigation, and handled below.
+ const REGIONS = ["page-banner", "nav", "main"];
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)");
let editorModule = null;
@@ -235,6 +237,27 @@
currentPath = location.pathname;
});
+ // --- Banner parallax ----------------------------------------------------
+ // The banner artwork stays windowed in place while its contents drift
+ // against the scroll. The --pry scroll parameter is also available to
+ // themes for their own effects (e.g. the purple sun rising faster than
+ // the drift). Event-driven only: perfectly still when the page is idle.
+ if (!reduceMotion.matches) {
+ let ticking = false;
+ const drift = () => {
+ ticking = false;
+ document.documentElement.style.setProperty(
+ "--pry", `${Math.min(scrollY * 0.1, 30)}px`,
+ );
+ };
+ addEventListener("scroll", () => {
+ if (!ticking) {
+ ticking = true;
+ requestAnimationFrame(drift);
+ }
+ }, { passive: true });
+ }
+
// --- Fetch navigation ------------------------------------------------
async function load(url, push = true, back = false) {
// Navigating with the editor open closes it; unsaved edits are lost
@@ -264,6 +287,18 @@
const el = document.getElementById(id);
el.replaceWith(document.importNode(doc.getElementById(id), true));
}
+ // #sidebar is omitted entirely when the section has no
+ // sub-navigation, so it may be absent on either side of the swap:
+ // replace, insert (as #main's preceding sibling), or remove.
+ const oldSidebar = document.getElementById("sidebar");
+ const newSidebar = doc.getElementById("sidebar");
+ if (oldSidebar && newSidebar) {
+ oldSidebar.replaceWith(document.importNode(newSidebar, true));
+ } else if (newSidebar) {
+ document.getElementById("main").before(document.importNode(newSidebar, true));
+ } else if (oldSidebar) {
+ oldSidebar.remove();
+ }
// Site-wide custom CSS lives in and must be
// kept in sync across fetch-navigations. It is kept last in :
// in dev Vite injects the base stylesheet after the server-rendered
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 36be43a..955cd2d 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,4 +1,5 @@
import { fileURLToPath, URL } from 'node:url'
+import { readdirSync } from 'node:fs'
import fastapiVue from './vite-plugin-fastapi.js'
import { defineConfig } from 'vite'
@@ -7,6 +8,15 @@ import vueDevTools from 'vite-plugin-vue-devtools'
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
+// Every theme directory ships its theme.css as a separate build entry, so
+// the backend can link base and theme stylesheets independently.
+const themesDir = fileURLToPath(new URL('./src/assets/themes', import.meta.url))
+const themeInputs = Object.fromEntries(
+ readdirSync(themesDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => [`theme_${d.name}`, `${themesDir}/${d.name}/theme.css`]),
+)
+
// Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev.
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
// backend's /_ prefix. /_api and /_f are handled by the fastapi-vue plugin.
@@ -40,7 +50,7 @@ export default defineConfig({
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
- pagerite_theme: fileURLToPath(new URL('./src/assets/themes/purple/theme.css', import.meta.url)),
+ ...themeInputs,
},
},
},
diff --git a/pagerite/data.py b/pagerite/data.py
index 78e3ed3..a849f82 100644
--- a/pagerite/data.py
+++ b/pagerite/data.py
@@ -80,7 +80,8 @@ class Data(msgspec.Struct):
#: site editor. Empty = no brand link in the header, no title suffix.
brand: str = "Pagerite"
#: Active theme name (empty = none/base only). Themes live in
- #: frontend/src/assets/themes/{theme}/theme.css.
+ #: frontend/src/assets/themes/{theme}/theme.css, with their banner
+ #: artwork at pagerite/themes/{theme}/banner.svg (inlined server-side).
theme: str = "purple"
#: Raw site-wide custom CSS, injected inline in every page .
#: Trusted author content; not sanitized.
diff --git a/pagerite/themes/corporate/banner.svg b/pagerite/themes/corporate/banner.svg
new file mode 100644
index 0000000..8cb3d41
--- /dev/null
+++ b/pagerite/themes/corporate/banner.svg
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pagerite/themes/nitro/banner.svg b/pagerite/themes/nitro/banner.svg
new file mode 100644
index 0000000..c7052b5
--- /dev/null
+++ b/pagerite/themes/nitro/banner.svg
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pagerite/themes/purple/banner.svg b/pagerite/themes/purple/banner.svg
new file mode 100644
index 0000000..45814a3
--- /dev/null
+++ b/pagerite/themes/purple/banner.svg
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pagerite/views.py b/pagerite/views.py
index beb8cd6..15eaa4d 100644
--- a/pagerite/views.py
+++ b/pagerite/views.py
@@ -7,8 +7,9 @@ can swap them without reloading the page chrome.
Navigation walks the Node tree directly (see data.py): nav_html lists the
top level — the front page (slug "") is an ordinary top-level item, not
-the parent of the others — and sidebar_html the children of the current
-top-level section. Nodes without content are category labels; nav links
+the parent of the others — and sidebar_html the sub-navigation of the
+current top-level section, rendered only when the section offers at
+least two published items. Nodes without content are category labels; nav links
to them point straight at their first child page (first_leaf), and their
own URL renders a placeholder page (render_category).
"""
@@ -54,11 +55,13 @@ def _shared_css_urls(vite_url: str | None, theme: str) -> list[str]:
In dev the JS entries import these files, so Vite injects them; the
backend does not link them, avoiding the HMR-wrapped module output.
+ Themes added after the last frontend build are missing from the
+ manifest — fall back to the base stylesheet rather than failing.
"""
if vite_url:
return []
manifest = _manifest()
- return [f"/{manifest[key]['file']}" for key in _css_keys(theme)]
+ return [f"/{manifest[key]['file']}" for key in _css_keys(theme) if key in manifest]
def _editor_css_url(vite_url: str | None, theme: str) -> str | None:
@@ -118,7 +121,7 @@ def _layout(
id="banner",
)
.div(
- E.aside(E.Sidebar, id="sidebar"),
+ E.Sidebar,
E.main(E.Main, id="main"),
id="content",
)
@@ -168,10 +171,13 @@ def nav_html(menu: dict[str, Node], current: str) -> HTML:
def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
- """Render the contents of the #sidebar element for the current path.
+ """Render the #sidebar element for the current path (empty when none).
- Lists the direct children of the current main level section; empty when
- the path is not inside a section or the section has no children.
+ The sidebar is the current main level section's sub-navigation, so it
+ exists only when there is something to navigate: the section must
+ offer at least two published items. The front page, leaf pages and
+ one-page sections get no aside element at all (rather than an empty
+ or one-item box).
"""
if not current:
return HTML("")
@@ -179,12 +185,14 @@ def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
node = menu.get(section)
if node is None:
return HTML("")
+ items = [(s, c) for s, c in sorted_nodes(node.children) if c.published]
+ if len(items) < 2:
+ return HTML("")
nav = E.ul
with nav:
- for slug, child in sorted_nodes(node.children):
- if child.published:
- _nav_link(nav, menu, child, f"{section}/{slug}", current)
- return HTML(str(nav))
+ for slug, child in items:
+ _nav_link(nav, menu, child, f"{section}/{slug}", current)
+ return HTML(str(E.aside(nav, id="sidebar")))
def first_leaf(menu: dict[str, Node], path: str) -> str | None:
@@ -208,18 +216,36 @@ def _first_leaf(node: Node, path: str) -> str | None:
return None
-def banner_html(menu: dict[str, Node], path: str) -> HTML:
+def banner_html(menu: dict[str, Node], path: str, theme: str = "") -> HTML:
"""Resolve the banner for a path: the nearest node on the ancestor
- chain (the node itself first), then the front page, then the default
- CSS artwork. The front page is a top-level *sibling* of the other
+ chain (the node itself first), then the front page, then the theme
+ artwork. The front page is a top-level *sibling* of the other
main-level nodes, not their parent, so it never appears in the chain
and is consulted explicitly, last. The snippet is raw trusted HTML,
so a banner can be anything — an img, a styled div, canvas + script.
+
+ With no user banner anywhere in the chain, the active theme's inline
+ SVG artwork is inlined instead: as markup it can be recolored from the
+ theme stylesheet (``var(--accent)`` etc.) and animated, and it is not
+ rendered at all when the user supplies their own banner.
"""
source = banner_source(menu, path)
- if source is None:
+ if source is not None:
+ return HTML(resolve(menu, source)[-1].banner)
+ return _theme_banner(theme)
+
+
+_banner_cache: dict[str, HTML] = {}
+
+
+def _theme_banner(theme: str) -> HTML:
+ """The theme's inline banner SVG (empty for none/unknown themes)."""
+ if not theme or "/" in theme:
return HTML("")
- return HTML(resolve(menu, source)[-1].banner)
+ if theme not in _banner_cache:
+ path = Path(__file__).parent / "themes" / theme / "banner.svg"
+ _banner_cache[theme] = HTML(path.read_text()) if path.exists() else HTML("")
+ return _banner_cache[theme]
def banner_source(menu: dict[str, Node], path: str) -> str | None:
@@ -267,7 +293,7 @@ def render_page(
Brand=_brand_link(brand),
Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path),
- Banner=banner_html(menu, path),
+ Banner=banner_html(menu, path, theme),
Main=page_content(menu, path),
),
)
@@ -288,20 +314,22 @@ def render_category(
"""
node = resolve(menu, path)[-1]
title = _title(path.rpartition("/")[2], node)
+ sidebar = sidebar_html(menu, path)
doc = E.article
with doc:
doc.h1(title)
- doc.p(
- "Pages in this section are listed in the menu on the left."
- )
+ if sidebar:
+ doc.p("Pages in this section are listed in the menu on the left.")
+ else:
+ doc.p("This section has no page of its own yet.")
scripts, styles = _page_assets(theme)
return str(
_layout(styles, scripts, custom_css, theme)(
Title=f"{title} – {brand}" if brand else title,
Brand=_brand_link(brand),
Nav=nav_html(menu, path),
- Sidebar=sidebar_html(menu, path),
- Banner=banner_html(menu, path),
+ Sidebar=sidebar,
+ Banner=banner_html(menu, path, theme),
Main=HTML(str(doc)),
),
)
@@ -326,7 +354,7 @@ def render_not_found(
Brand=_brand_link(brand),
Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path),
- Banner=banner_html(menu, path),
+ Banner=banner_html(menu, path, theme),
Main=HTML(str(doc)),
),
)