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:
@@ -63,7 +63,13 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
none/base only); themes live in `frontend/src/assets/themes/{theme}`.
|
||||
`Data.custom_css` is raw trusted CSS injected inline in every page
|
||||
`<head>` (id `pagerite-user`) and swapped during fetch-navigation;
|
||||
editable in the site editor.
|
||||
editable in the site editor. Font picks (heading/body/brand) in the
|
||||
site editor are stored as plain `:root` rows in `custom_css`
|
||||
(`--font-body: var(--font-source-sans);` format — parsed out and
|
||||
rewritten on change, the `:root` block added/removed as needed),
|
||||
referencing the per-family variables (`--font-source-sans` etc.) from
|
||||
pagerite.css;
|
||||
the base stylesheet's `--font-brand` defaults to `var(--font-heading)`.
|
||||
- `markdown.py` — markdown-it-py renderer (html passthrough + attrs,
|
||||
footnote, deflist, tasklists plugins). Custom image rule: relative srcs
|
||||
resolve against the page path, titled images become figures.
|
||||
@@ -88,7 +94,8 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
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
|
||||
Sans 3/Source Serif 4/Inter/Montserrat/Fira Code variable woff2). The `::view-transition*` block at the end of `pagerite.css` (from
|
||||
Sans 3/Source Serif 4/Fraunces/Literata/Inter/Montserrat/Fira Code
|
||||
variable woff2). The `::view-transition*` block at the end of `pagerite.css` (from
|
||||
termotohtori.fi) is fragile — do not tweak.
|
||||
- Vite builds ES-module `.js` outputs; the backend renders `<script
|
||||
type="module">` for them (module scripts defer by default).
|
||||
|
||||
@@ -141,7 +141,8 @@ evolves.
|
||||
live under `frontend/src/assets/` (the banner SVG under
|
||||
`themes/purple/`) and are emitted as hashed assets under `/_assets/`
|
||||
(Source Serif 4 for headings, Source Sans 3 for body, Fira Code for code
|
||||
by default; Inter and Montserrat kept as variable woff2 options with
|
||||
by default; Fraunces, Literata, Inter and Montserrat kept as variable
|
||||
woff2 options with
|
||||
local `@font-face`). No third-party requests.
|
||||
|
||||
## Editing
|
||||
@@ -155,7 +156,9 @@ evolves.
|
||||
(no debouncing) straight into the visible article's heading and body.
|
||||
- **Site mode** — the 🖊️ on the banner opens a panel with the site
|
||||
**brand** (applied to the header live), a **theme** selector (swapping
|
||||
the theme stylesheet in place), a **site-wide custom CSS** field (injected
|
||||
the theme stylesheet in place), **font** picks (heading/body/brand —
|
||||
stored as plain `:root` rows inside the custom CSS, referencing the base
|
||||
stylesheet's per-family font variables), a **site-wide custom CSS** field (injected
|
||||
into `<style id="pagerite-user">` in the live page head and swapped during
|
||||
fetch-navigation), the page's **banner HTML** field (previewed into the
|
||||
real banner region, so you see exactly which banner you're editing) and
|
||||
|
||||
+237
-1
@@ -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);
|
||||
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user