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:
+84
-24
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user