Add corporate and nitro themes, inline theme banners, code palette sets

- Two new themes (corporate light/dark, nitro racing) and a purple
  retune; theme banner SVGs moved to pagerite/themes/, inlined by the
  backend, recolored per scheme via CSS classes, with scroll parallax.
- pygments.css maps tokens onto --code-* variables; light and dark
  palette sets in the base stylesheet resolve via light-dark() from the
  theme's color-scheme; themes just pick a set or retint --code-bg.
- Sidebar omitted unless the section has at least two published items;
  fetch-navigation and the site editor handle it appearing/disappearing.
- Floated figures keep captions below at 30% of the text column; h1/h2
  clear floats so images never overflow into the next section.
- Links use --link (color-mix of text and accent), weight 500, no
  underlines; hover goes full accent.
This commit is contained in:
2026-08-17 19:16:45 +00:00
parent dcd1e5afc9
commit c0bb3b73c1
16 changed files with 1055 additions and 177 deletions
+28 -8
View File
@@ -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 + `<title>` 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 <title> 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
+40 -13
View File
@@ -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 `<figure>` with
`<figcaption>`. Positioning is by attribute classes:
`![alt](/_f/….avif "Caption"){.right}``{.right}`, `{.left}` float,
`![alt](/_f/….avif "Caption"){.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 href>`; 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 `<html>` (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
+14 -1
View File
@@ -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() {
+78 -22
View File
@@ -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 <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,
@@ -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 <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;
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);
+89 -85
View File
@@ -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 */
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 */
@@ -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);
}
+286
View File
@@ -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);
}
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360">
<rect width="1600" height="360" fill="#1b1830"/>
</svg>

Before

Width:  |  Height:  |  Size: 122 B

+75 -19
View File
@@ -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 <html>), 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 {
+36 -1
View File
@@ -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 <head id="pagerite-user"> and must be
// kept in sync across fetch-navigations. It is kept last in <head>:
// in dev Vite injects the base stylesheet after the server-rendered
+11 -1
View File
@@ -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,
},
},
},
+2 -1
View File
@@ -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 <head>.
#: Trusted author content; not sanitized.
+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="cbg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" class="cb-bg0"/>
<stop offset="1" class="cb-bg1"/>
</linearGradient>
<linearGradient id="cribbon" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" class="cb-r0"/>
<stop offset="1" class="cb-r1"/>
</linearGradient>
<radialGradient id="cglow" cx="0.78" cy="0.25" r="0.55">
<stop offset="0" class="cb-g0" stop-opacity="0.16"/>
<stop offset="1" class="cb-g1" stop-opacity="0"/>
</radialGradient>
<pattern id="cdots" width="26" height="26" patternUnits="userSpaceOnUse">
<circle cx="4" cy="4" r="3" class="cb-dot"/>
</pattern>
</defs>
<rect width="1600" height="360" fill="url(#cbg)"/>
<rect width="1600" height="360" fill="url(#cglow)"/>
<!-- Dot-matrix patch, fading towards the middle -->
<rect x="40" y="30" width="520" height="150" fill="url(#cdots)" opacity="0.14"/>
<rect x="40" y="200" width="340" height="104" fill="url(#cdots)" opacity="0.08"/>
<!-- Diagonal ribbons slashing across the right side -->
<g transform="rotate(-18 1250 180)">
<rect x="1060" y="-140" width="130" height="640" rx="8" fill="url(#cribbon)"/>
<rect x="1225" y="-140" width="36" height="640" rx="8" fill="url(#cribbon)" opacity="0.55"/>
<rect x="1295" y="-140" width="230" height="640" rx="8" fill="url(#cribbon)" opacity="0.14"/>
</g>
<!-- Large orbit circles, bottom right -->
<g fill="none" class="cb-orbit">
<circle cx="1410" cy="310" r="150" stroke-width="2" opacity="0.22"/>
<circle cx="1310" cy="335" r="85" stroke-width="2" opacity="0.14"/>
</g>
<!-- A few accent sparks -->
<g class="cb-spark">
<circle cx="620" cy="70" r="5" opacity="0.5"/>
<circle cx="700" cy="120" r="3.5" opacity="0.35"/>
<circle cx="840" cy="55" r="4" opacity="0.4"/>
<circle cx="950" cy="290" r="5" opacity="0.3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+46
View File
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="flare" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ff6a00"/>
<stop offset="0.55" stop-color="#ff9e00"/>
<stop offset="1" stop-color="#ffd23f"/>
</linearGradient>
<!-- Layered bezier sweeps shade the panel: no flat fills, no plain
one-shot gradients. The dark tones are the nb-* classes so the
theme can tint them per color scheme (neutral in light mode,
violet to match the dark page in dark mode). -->
<linearGradient id="sweep1" x1="0" y1="0" x2="0.7" y2="1">
<stop offset="0" stop-color="#242428" class="nb-s1a"/>
<stop offset="1" stop-color="#0b0b0d" class="nb-s1b"/>
</linearGradient>
<linearGradient id="sweep2" x1="1" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#19191d" class="nb-s2a"/>
<stop offset="1" stop-color="#060607" class="nb-s2b"/>
</linearGradient>
<linearGradient id="cowl" x1="0" y1="0" x2="0.8" y2="1">
<stop offset="0" stop-color="#2a2a2f" class="nb-c0"/>
<stop offset="0.6" stop-color="#131315" class="nb-c1"/>
<stop offset="1" stop-color="#0b0b0d" stop-opacity="0" class="nb-c2"/>
</linearGradient>
</defs>
<rect width="1600" height="360" fill="#0b0b0d" class="nb-base"/>
<!-- Sweeping fairing curves shading the panel -->
<path d="M 0 360 C 350 240, 700 290, 1050 200 C 1250 150, 1450 170, 1600 110 L 1600 360 L 0 360 Z" fill="url(#sweep1)"/>
<path d="M 0 330 C 350 225, 700 272, 1050 188 C 1250 145, 1450 162, 1600 104" fill="none" stroke="#ffffff" stroke-opacity="0.07" stroke-width="3"/>
<path d="M 0 360 C 480 295, 950 345, 1600 250 L 1600 360 Z" fill="url(#sweep2)"/>
<path d="M 0 352 C 480 288, 950 336, 1600 242" fill="none" stroke="#ffffff" stroke-opacity="0.05" stroke-width="2"/>
<!-- The wide orange stripes -->
<g transform="skewX(-24)">
<rect x="880" y="-40" width="96" height="440" fill="url(#flare)"/>
<rect x="1002" y="-40" width="26" height="440" fill="#ff6a00"/>
<rect x="1050" y="-40" width="10" height="440" fill="#ffd23f"/>
</g>
<!-- Top-right cowl: a glossy bezier brow echoing the console language -->
<path d="M 950 -5 C 1150 80, 1380 130, 1605 190 L 1605 -5 Z" fill="url(#cowl)"/>
<path d="M 950 -5 C 1150 80, 1380 130, 1605 190" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="3"/>
<path d="M 950 -5 C 1150 80, 1380 130, 1605 190" fill="none" stroke="#ff6a00" stroke-opacity="0.3" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+38
View File
@@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2b1b4d"/>
<stop offset="1" stop-color="#ff8a5c"/>
</linearGradient>
<radialGradient id="sunglow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#ffd9a0" stop-opacity="0.9"/>
<stop offset="0.35" stop-color="#ffb37a" stop-opacity="0.55"/>
<stop offset="1" stop-color="#ff8a5c" stop-opacity="0"/>
</radialGradient>
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#131022" stop-opacity="0"/>
<stop offset="1" class="banner-fade"/>
</linearGradient>
</defs>
<rect width="1200" height="300" fill="url(#sky)"/>
<!-- Stars in the upper dusk sky -->
<g fill="#ffffff">
<circle cx="120" cy="38" r="1.6" opacity="0.8"/>
<circle cx="240" cy="70" r="1.1" opacity="0.5"/>
<circle cx="365" cy="30" r="1.4" opacity="0.7"/>
<circle cx="520" cy="58" r="1" opacity="0.45"/>
<circle cx="660" cy="26" r="1.5" opacity="0.75"/>
<circle cx="790" cy="66" r="1.1" opacity="0.5"/>
<circle cx="930" cy="34" r="1.6" opacity="0.8"/>
<circle cx="1060" cy="72" r="1" opacity="0.45"/>
<circle cx="1140" cy="28" r="1.3" opacity="0.6"/>
</g>
<!-- Sun low on the horizon with a wide glow (rises on scroll) -->
<circle class="sun-glow" cx="600" cy="212" r="120" fill="url(#sunglow)"/>
<circle class="sun" cx="600" cy="212" r="42" fill="#ffd9a0"/>
<path d="M0 210 Q 300 150 600 210 T 1200 210 V300 H0 Z" fill="#3d2b6b"/>
<path d="M0 250 Q 300 200 600 250 T 1200 250 V300 H0 Z" fill="#241842"/>
<!-- Gentle fade into the page background behind the nav; part of the
theme artwork, so a user banner replaces it entirely. -->
<rect y="190" width="1200" height="110" fill="url(#fade)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+51 -23
View File
@@ -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)),
),
)