Backend-served themes and selectable, inheritable banner designs

Themes move from Vite-built frontend assets to pagerite/themes/{name}/
folders holding theme.css and/or banner.css (+ banner.svg), served by the
backend at /_themes/{name}/... and re-read from disk per request (etag by
mtime), so on-disk edits show on the next page load even in prod and new
themes need no build or config. The theme and banner-design selectors
enumerate these folders via GET /_api/settings.

Banner designs: Node.banner_design picks a design per page (None inherits
from ancestors, then the front page, then the active theme's own design;
"" = none). The design's banner.css is linked in <head> (id
pagerite-banner, between theme and custom CSS) and its banner.svg inlined
into #page-banner first (marked svg[data-design]); the page's own
Node.banner HTML renders after it, so author code always wins. #page-banner
is now a stacking grid so artwork and author code overlay.

Dev/prod hot loading unified: the backend renders the theme/design links
in both modes; in dev pagerite.js only re-appends them (and the custom
CSS) after the Vite-injected base styles. Theme switches just swap the
link href. The pagerite:theme meta and Vite theme build entries are gone.
This commit is contained in:
2026-08-18 05:46:02 +00:00
parent c0817330e7
commit 30947df16b
15 changed files with 560 additions and 348 deletions
+84 -24
View File
@@ -302,12 +302,10 @@ async function commitPending() {
// empty brand removes the header link and the title suffix entirely.
const brand = ref('')
const theme = ref('purple')
const THEME_OPTIONS = [
{ value: '', label: 'none' },
{ value: 'purple', label: 'purple' },
{ value: 'corporate', label: 'corporate' },
{ value: 'nitro', label: 'nitro' },
]
// Theme and banner-design options come from the backend (theme folders on
// disk, see GET /_api/settings), so added themes need no frontend changes.
const themeOptions = ref([{ value: '', label: 'none' }])
const bannerDesigns = ref([])
async function loadSettings() {
try {
@@ -316,6 +314,11 @@ async function loadSettings() {
theme.value = s.theme || ''
customCss.value = s.custom_css || ''
favicon.value = s.favicon || ''
themeOptions.value = [
{ value: '', label: 'none' },
...(s.themes || []).map((t) => ({ value: t, label: t })),
]
bannerDesigns.value = s.banner_designs || []
} catch { /* keep default */ }
}
@@ -420,19 +423,23 @@ function saveBrand() {
async function onThemeChange() {
await saveSettings()
if (import.meta.env.DEV) {
// Dev: styles are Vite-injected <style> tags, not <link>s, so the
// stylesheet sync in swapRegions can't switch themes. Drop the old
// theme's injected styles and import the new theme module instead.
for (const el of document.head.querySelectorAll('style[data-vite-dev-id]')) {
if (el.dataset.viteDevId.includes('/themes/')) el.remove()
// Theme CSS is backend-served at /_themes/{theme}/theme.css in both dev
// and prod: swap the link in place, then re-render (the theme's default
// banner design and the page's stylesheet links may change with it).
let link = document.getElementById('pagerite-theme')
if (theme.value) {
const href = `/_themes/${theme.value}/theme.css`
if (link) {
link.href = href
} else {
link = document.createElement('link')
link.rel = 'stylesheet'
link.id = 'pagerite-theme'
document.getElementById('pagerite-base')?.after(link)
?? document.head.prepend(link)
}
if (theme.value) {
await import(/* @vite-ignore */ `/src/assets/themes/${theme.value}/theme.css`)
}
// The freshly injected theme style now sits after the custom CSS;
// move the custom CSS back to the end so it keeps winning.
applyCustomCss(customCss.value)
} else if (link) {
link.remove()
}
loadPlain(path.value)
}
@@ -707,6 +714,34 @@ provide('structureHandlers', {
newPage,
})
// --- Banner design ---------------------------------------------------------
// The page's banner design: null = inherit (nearest ancestor's setting,
// then the theme's default), '' = explicitly none, otherwise a design
// name. bannerDesignFrom tells where an inherited setting comes from
// (null = the theme default), shown in the selector's inherit option.
const bannerDesign = ref(null)
const bannerDesignFrom = ref(null)
const inheritLabel = computed(() => {
if (bannerDesignFrom.value === null) {
return `inherit (theme: ${theme.value || 'none'})`
}
return `inherit (/${bannerDesignFrom.value})`
})
function onBannerDesignChange() {
// Saves immediately; the preview needs a server re-render (the design's
// inline SVG and its stylesheet link both change).
const msg = {
type: 'save',
path: normPath(path.value),
banner_design: bannerDesign.value,
}
pendingSave = msg
send(msg)
loadPlain(path.value)
}
// --- Banner editing ------------------------------------------------------
// The banner HTML is edited in a small CodeMirror window (HTML syntax),
// previewed into the real #page-banner region on every keystroke.
@@ -731,11 +766,15 @@ function previewBanner() {
const el = document.getElementById('page-banner')
if (!el) return
if (banner.value.trim()) {
// Own banner: preview it live over the region.
// Own banner code supplements the design: the inlined design artwork
// (marked svg[data-design]) stays in place, the author code goes after
// it so its styles win.
const artwork = [...el.querySelectorAll('svg[data-design]')]
el.innerHTML = banner.value
el.prepend(...artwork)
runScripts(el)
} else {
// No banner of its own: the region must show the inherited/default
// No banner code of its own: the region must show the inherited/design
// banner — re-render from the server (an empty write here would wipe it).
loadPlain(path.value)
}
@@ -785,12 +824,15 @@ function onMessage(ev) {
const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) {
setDocument(msg.banner ?? '')
// Placeholder tells where an empty banner falls back to.
bannerDesign.value = msg.banner_design ?? null
bannerDesignFrom.value = msg.banner_design_from ?? null
// Placeholder tells where an empty banner code field falls back to;
// the design artwork renders regardless (this code supplements it).
view.dispatch({
effects: bannerPh.reconfigure(placeholder(
msg.banner_from == null
? 'using default artwork'
: `inherited from /${msg.banner_from}`,
? 'own banner code (added after the design)'
: `code inherited from /${msg.banner_from}`,
)),
})
// Overlay this page's own banner on the swapped region. Empty means
@@ -924,7 +966,7 @@ onUnmounted(() => {
title="Theme"
@change="onThemeChange"
>
<option v-for="opt in THEME_OPTIONS" :key="opt.value" :value="opt.value">
<option v-for="opt in themeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
@@ -1007,6 +1049,16 @@ onUnmounted(() => {
<section class="block" @paste="onBannerPaste">
<div class="block-head">
<span class="field-label">Banner on /{{ path }}</span>
<select
v-model="bannerDesign"
class="text-input design-select"
title="Banner design (artwork + its own styles)"
@change="onBannerDesignChange"
>
<option :value="null">{{ inheritLabel }}</option>
<option value="">none</option>
<option v-for="d in bannerDesigns" :key="d" :value="d">{{ d }}</option>
</select>
<button
type="button"
title="upload banner image/video (replaces existing media) — pasting works too"
@@ -1147,6 +1199,14 @@ onUnmounted(() => {
white-space: nowrap;
}
/* The banner design selector sits between the label and the upload button
(which stays pushed right by its auto margin). */
.design-select {
flex: 0 1 auto;
width: auto;
font-size: 0.85rem;
}
.text-input {
flex: 1;
min-width: 4rem;
+9 -3
View File
@@ -131,18 +131,24 @@ body {
}
/* 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). */
banner; swapped along with #nav/#main on fetch-navigation. The backend
inlines the effective banner design's SVG artwork here first
(pagerite/themes/{design}/banner.svg, marked svg[data-design]), then the
page's own banner code after it. */
#page-banner {
position: absolute;
inset: 0;
overflow: hidden;
/* Stack the design artwork and the page's own banner code on top of
each other (artwork first): the banner is a background layer, author
code overlays it. A single child behaves exactly as before. */
display: grid;
}
/* :not(style, script): author-level display:block would override the UA's
display:none on those and render their source as banner text. */
#page-banner>*:not(style, script) {
grid-area: 1 / 1;
display: block;
width: 100%;
height: 100%;
@@ -1,219 +0,0 @@
/* 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
@@ -1,286 +0,0 @@
/* 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);
}
-116
View File
@@ -1,116 +0,0 @@
/* 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: #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;
/* Ensure below baseline stays visible */
padding-bottom: 0.3em;
margin-bottom: -0.3em;
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.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 {
min-height: 13rem;
}
/* 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 {
color: var(--accent2);
}
article h3 {
color: var(--accent3);
font-weight: 500;
}
/* 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 {
--code-bg: var(--surface);
}
::view-transition {
background: #000;
}
+12 -11
View File
@@ -8,17 +8,18 @@ import { showAuthIframe } from 'paskia'
(() => {
if (import.meta.env.DEV) {
// The theme is selectable; the backend names the active one in a meta
// tag (dev links no stylesheets — Vite injects them from JS).
const theme = document.querySelector('meta[name="pagerite:theme"]')?.content;
const sheets = [import("./assets/pagerite.css")];
if (theme) sheets.push(import(/* @vite-ignore */ `./assets/themes/${theme}/theme.css`));
// The injected styles land after the server-rendered custom CSS in
// <head>; move the custom CSS back to the end so its equal-specificity
// :root rules (font variables) win.
Promise.all(sheets).then(() => {
const el = document.getElementById("pagerite-user");
if (el) document.head.append(el);
// In dev the base stylesheet is injected by Vite from JS (linking the
// raw module would pull in its HMR wrapper). Theme and banner-design
// stylesheets are plain files served by the backend (/_themes/...), so
// the backend renders their <link>s in both dev and prod. The injected
// base styles land at the end of <head> — after them, restore the
// canonical order: base < theme < banner design < custom CSS (whose
// equal-specificity :root rules — font variables — must win by order).
import("./assets/pagerite.css").then(() => {
for (const id of ["pagerite-theme", "pagerite-banner", "pagerite-user"]) {
const el = document.getElementById(id);
if (el) document.head.append(el);
}
});
}