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 <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 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 `<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 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 <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); 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 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360"> - <rect width="1600" height="360" fill="#1b1830"/> -</svg> 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 <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 { 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 <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 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 <head>. #: 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 @@ +<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> 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 @@ +<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> 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 @@ +<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> 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)), ), )