Add font picker to site editor, neutral base banner styling

- Base stylesheet: per-family font variables (--font-source-sans etc.)
  with functional slots referencing them; --font-brand defaults to
  var(--font-heading). Restore Fraunces/Literata faces dropped in the
  font-set swap
- Site editor: stylized A button (always Fira Code) opens a font panel
  with body/heading/brand tabs; options render their own name in the
  candidate font at the target's size/weight; clicking the current pick
  clears it. Picks are stored as plain :root rows in the custom CSS,
  parsed and rewritten on change
- Base banner: drop black text-shadow on light background; legibility
  via a bottom scrim fading artwork into --bg. Purple keeps its own nav
  shadow
- Headings no longer accent-colored in base (moved to purple theme)
- Keep #pagerite-user custom CSS last in <head> at every mutation point
  so its :root rules win equal specificity (dev injects base/theme
  styles after the server-rendered tag)
This commit is contained in:
2026-08-17 02:22:09 +00:00
parent 4a84aceb4f
commit ce69f69b0e
9 changed files with 311 additions and 16 deletions
+237 -1
View File
@@ -352,6 +352,9 @@ async function onThemeChange() {
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)
}
loadPlain(path.value)
}
@@ -364,9 +367,12 @@ function applyCustomCss(css) {
if (!el) {
el = document.createElement('style')
el.id = 'pagerite-user'
document.head.append(el)
}
el.textContent = css
// Keep it last in <head>: in dev Vite injects the base stylesheet
// after the server-rendered tag, and equal-specificity :root rules
// (font variables) are decided by order.
document.head.append(el)
} else if (el) {
el.remove()
}
@@ -388,6 +394,98 @@ function setCssDocument(text) {
customCss.value = text
}
// --- Font overrides --------------------------------------------------------
// Font picks live in the custom CSS as plain :root rows in one exact format
// (` --font-body: var(--font-source-sans);`), so no marker comments are
// needed: the rows are parsed out on load, and on change they are stripped
// and rewritten — adding a :root block if none exists, dropping it when the
// last font row goes away. Values reference the per-family variables from
// pagerite.css, so no font stacks are spelled out here.
const FONT_ROW = /^\s*--font-(body|heading|brand):\s*var\(--font-[a-z-]+\);\s*$/
const FONT_OPTIONS = [
{ value: 'var(--font-source-serif)', label: 'Source Serif 4', serif: true },
{ value: 'var(--font-fraunces)', label: 'Fraunces', serif: true },
{ value: 'var(--font-literata)', label: 'Literata', serif: true },
{ value: 'var(--font-source-sans)', label: 'Source Sans 3', serif: false },
{ value: 'var(--font-inter)', label: 'Inter', serif: false },
{ value: 'var(--font-montserrat)', label: 'Montserrat', serif: false },
{ value: 'var(--font-fira-code)', label: 'Fira Code', serif: false },
]
const fontHeading = ref('')
const fontBody = ref('')
const fontBrand = ref('')
// Font picker popup: a stylized "A" opens a panel with a tab per target
// (heading/body/brand); each option's name is its own preview, rendered in
// the candidate font at the size and weight of the element being styled.
const fontPicker = ref(null) // open tab: 'heading' | 'body' | 'brand' | null
let fontTabLast = 'body'
const serifFonts = computed(() => FONT_OPTIONS.filter((o) => o.serif))
const sansFonts = computed(() => FONT_OPTIONS.filter((o) => !o.serif))
function toggleFontPanel() {
if (fontPicker.value) {
fontTabLast = fontPicker.value
fontPicker.value = null
} else {
fontPicker.value = fontTabLast
}
}
function fontRefFor(name) {
return { heading: fontHeading, body: fontBody, brand: fontBrand }[name]
}
function fontStyleFor(name) {
if (name === 'brand') return { fontWeight: 700, fontSize: '1.5rem' }
if (name === 'heading') return { fontWeight: 600, fontSize: '1.3rem' }
return {}
}
function pickFont(value) {
const r = fontRefFor(fontPicker.value)
// Clicking the current pick clears it back to the base-style default.
r.value = r.value === value ? '' : value
onFontChange()
}
function fontRows() {
const rows = []
if (fontBody.value) rows.push(` --font-body: ${fontBody.value};`)
if (fontHeading.value) rows.push(` --font-heading: ${fontHeading.value};`)
if (fontBrand.value) rows.push(` --font-brand: ${fontBrand.value};`)
return rows
}
function onFontChange() {
const rows = fontRows()
// Strip our rows wherever they are, then drop :root blocks left empty.
let css = customCss.value
.split('\n')
.filter((l) => !FONT_ROW.test(l))
.join('\n')
.replace(/:root\s*\{\s*\}\n?/g, '')
if (rows.length) {
if (/:root\s*\{/.test(css)) {
// Merge into the existing :root block.
css = css.replace(/:root\s*\{/, (m) => `${m}\n${rows.join('\n')}`)
} else {
const rest = css.trimStart()
css = `:root {\n${rows.join('\n')}\n}\n${rest ? `\n${rest}` : ''}`
}
}
setCssDocument(css)
onCustomCssInput()
}
function parseFonts(css) {
const get = (name) =>
css.match(new RegExp(`^\\s*--font-${name}:\\s*(var\\(--font-[a-z-]+\\));`, 'm'))?.[1] || ''
fontBody.value = get('body')
fontHeading.value = get('heading')
fontBrand.value = get('brand')
}
// Two-step delete (no dialogs): the first click arms the row's button for
// a few seconds, the second actually deletes.
const arming = ref(null)
@@ -691,6 +789,7 @@ onMounted(async () => {
})
addEventListener('keydown', onKeydown)
await loadSettings()
parseFonts(customCss.value)
setCssDocument(customCss.value)
applyCustomCss(customCss.value)
})
@@ -736,7 +835,57 @@ onUnmounted(() => {
{{ opt.label }}
</option>
</select>
<button
type="button"
class="font-btn"
:class="{ active: !!fontPicker }"
title="Fonts"
@click="toggleFontPanel"
>
A
</button>
</label>
<div v-if="fontPicker" class="font-picker">
<div class="font-tabs">
<button
v-for="name in ['body', 'heading', 'brand']"
:key="name"
type="button"
:class="{ active: fontPicker === name }"
@click="fontPicker = name"
>
{{ name }}
</button>
</div>
<div class="font-cols">
<div class="font-col">
<button
v-for="opt in serifFonts"
:key="opt.label"
type="button"
class="font-opt"
:class="{ current: fontRefFor(fontPicker).value === opt.value }"
:style="{ fontFamily: opt.value, ...fontStyleFor(fontPicker) }"
@click="pickFont(opt.value)"
>
{{ opt.label }}
</button>
</div>
<div class="font-col">
<button
v-for="opt in sansFonts"
:key="opt.label"
type="button"
class="font-opt"
:class="{ current: fontRefFor(fontPicker).value === opt.value }"
:style="{ fontFamily: opt.value, ...fontStyleFor(fontPicker) }"
@click="pickFont(opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
<div ref="cssEl" class="css-cm" />
</section>
@@ -864,6 +1013,93 @@ onUnmounted(() => {
width: auto;
}
/* Stylized "A" icon button opening the font panel — always Fira Code, so
it stays distinctive no matter which fonts are configured. */
.font-btn {
font-family: var(--font-fira-code);
font-weight: 700;
font-size: 1.5rem;
padding: 0 0.4rem;
color: var(--muted);
background: none;
border: none;
cursor: pointer;
}
.font-btn:hover {
color: var(--text);
}
.font-btn.active {
color: var(--accent);
}
/* Font picker panel. Colors come from the theme variables, so contrast
against the page background is automatic in both light and dark themes. */
.font-picker {
padding: 0.5rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: 0 4px 16px #0004;
}
.font-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 0.4rem;
}
.font-tabs button {
flex: 1;
padding: 0.15rem 0.5rem;
font: inherit;
color: var(--muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
}
.font-tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.font-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem 0.75rem;
}
.font-col {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
/* Each option's name is its own preview (font family + size/weight of the
element being styled are set inline). */
.font-opt {
padding: 0.3rem 0.45rem;
line-height: 1.3;
color: inherit;
background: none;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
text-align: left;
}
.font-opt:hover {
background: var(--surface);
}
.font-opt.current {
border-color: var(--accent);
}
/* Small CodeMirror window for the banner HTML; scrolls internally. */
.banner-cm {
border: 1px solid var(--line);
+2
View File
@@ -1,4 +1,6 @@
/* Variable fonts: only downloaded when referenced by the page CSS. */
@font-face { font-family: 'Fraunces'; font-weight: 100 1000; font-style: normal; font-display: swap; src: url('fraunces.woff2') format('woff2'); }
@font-face { font-family: 'Literata'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('literata.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('inter.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 100 900; font-style: italic; font-display: swap; src: url('inter-italic.woff2') format('woff2'); }
@font-face { font-family: 'Montserrat'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('montserrat.woff2') format('woff2'); }
Binary file not shown.
Binary file not shown.
+25 -8
View File
@@ -12,9 +12,20 @@
--accent: #0056b3;
--accent2: #003d80;
--line: #0000001a;
--font-body: "Source Sans 3", system-ui, sans-serif;
--font-heading: "Source Serif 4", Georgia, serif;
--font-code: "Fira Code", ui-monospace, monospace;
/* 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;
--font-source-serif: "Source Serif 4", Georgia, serif;
--font-fraunces: "Fraunces", Georgia, serif;
--font-literata: "Literata", Georgia, serif;
--font-inter: "Inter", system-ui, sans-serif;
--font-montserrat: "Montserrat", system-ui, sans-serif;
--font-fira-code: "Fira Code", ui-monospace, monospace;
--font-body: var(--font-source-sans);
--font-heading: var(--font-source-serif);
/* The brand follows the heading font unless overridden separately. */
--font-brand: var(--font-heading);
--font-code: var(--font-fira-code);
/* Width of the docked editor panel (used both here for shifting the page
and in the Vue editor's own styles). */
--editor-w: min(46rem, 50vw);
@@ -79,13 +90,23 @@ body {
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));
}
#brand,
#nav {
position: relative;
}
#brand {
font-family: var(--font-heading);
font-family: var(--font-brand);
font-weight: 700;
font-size: 2.4rem;
text-decoration: none;
@@ -97,7 +118,6 @@ body {
/* Nav overlaid at the bottom of the banner */
#nav {
font-size: 1.3em;
text-shadow: 0 0 0.1em black;
padding: 0.35rem 1.25rem;
}
@@ -271,7 +291,6 @@ article h3 {
article h1 {
font-size: 2.2rem;
margin: 2rem 0 1.2rem;
color: var(--accent);
}
/* Margin strategy: bottom-only inside articles. Top margins misalign
@@ -289,7 +308,6 @@ article figure {
article h3 {
margin: 1.4rem 0 0.4rem;
color: color-mix(in oklab, var(--accent2) 60%, var(--muted));
}
/* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels,
@@ -398,7 +416,6 @@ article dd {
article h2 {
font-size: 1.5rem;
margin: 2.2rem 0 0.6rem;
color: var(--accent2);
}
article a {
@@ -29,6 +29,26 @@
background: url("./banner.svg") center 40% / cover;
}
/* Colored headings are part of the theme's more elaborate styling; the
base keeps headings in plain text color. */
article h1 {
color: var(--accent);
}
article h2 {
color: var(--accent2);
}
article h3 {
color: color-mix(in oklab, var(--accent2) 60%, var(--muted));
}
/* 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;
}
pre {
background: #ffffff09;
}
+13 -3
View File
@@ -6,11 +6,18 @@
// support from the article itself and are re-applied after each swap.
(() => {
if (import.meta.env.DEV) {
import("./assets/pagerite.css");
// 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;
if (theme) import(/* @vite-ignore */ `./assets/themes/${theme}/theme.css`);
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);
});
}
const REGIONS = ["page-banner", "nav", "sidebar", "main"];
@@ -192,11 +199,14 @@
el.replaceWith(document.importNode(doc.getElementById(id), true));
}
// Site-wide custom CSS lives in <head id="pagerite-user"> and must be
// kept in sync across fetch-navigations.
// kept in sync across fetch-navigations. It is kept last in <head>:
// in dev Vite injects the base stylesheet after the server-rendered
// tag, and equal-specificity :root rules are decided by order.
const oldUserStyle = document.getElementById("pagerite-user");
const newUserStyle = doc.getElementById("pagerite-user");
if (oldUserStyle && newUserStyle) {
oldUserStyle.textContent = newUserStyle.textContent;
document.head.appendChild(oldUserStyle);
} else if (newUserStyle) {
document.head.appendChild(document.importNode(newUserStyle, true));
} else if (oldUserStyle) {