Split CSS into base and theme, link them separately
- Move shared structural styles to frontend/src/assets/pagerite.css with conservative variables, and purple theme overrides to frontend/src/assets/themes/purple/theme.css. - Backend now links the base and theme stylesheets as separate files so they can be swapped or augmented. - Add blocking="render" to stylesheet links to avoid FOUC. - Drop redundant defer from module script tags. - Update related docs and comments.
This commit is contained in:
@@ -76,15 +76,17 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
database (never overwrites existing pages).
|
||||
- `frontend/src/` — the Vue editor and public-page entries.
|
||||
- `main.js` — Vue editor app entry, mounts PageEditor/SiteEditor.
|
||||
- `pagerite.js` — public page entry; imports the shared style and runs
|
||||
fetch-navigation, scroll-reveal and code copy buttons.
|
||||
- `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal and
|
||||
code copy buttons. The backend links the shared CSS as two separate
|
||||
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/`: `style.css`, `pygments.css`, `banner.svg` and
|
||||
`fonts/` (self-hosted Fraunces/Literata/Fira Code variable woff2). The
|
||||
`::view-transition*` block at the end of `style.css` (from
|
||||
under `/_/assets/`: `pagerite.css` (base layout + conservative variables),
|
||||
`themes/purple/theme.css` (the purple/dark theme override), `pygments.css`,
|
||||
`banner.svg` and `fonts/` (self-hosted Fraunces/Literata/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" defer>` for them.
|
||||
type="module">` for them (module scripts defer by default).
|
||||
- The database file is `pagerite.kanta` in the cwd (`PAGERITE_DB`
|
||||
overrides); gitignored. Do not delete it without asking.
|
||||
- `scripts/fastapi-vue/` — helper scripts from the fastapi-vue template
|
||||
|
||||
@@ -132,9 +132,11 @@ evolves.
|
||||
|
||||
## Styling
|
||||
|
||||
- A single shared `frontend/src/assets/style.css` covers the server-rendered
|
||||
pages and the Vue components. Vue may add per-component styles on top where
|
||||
needed.
|
||||
- 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` by default) overrides those
|
||||
variables and adds the visual styling. Vue may add per-component styles on top
|
||||
where needed.
|
||||
- Fonts, the shared stylesheet, pygments styles and the default banner SVG
|
||||
live under `frontend/src/assets/` and are emitted as hashed assets under
|
||||
`/_/assets/` (Fraunces for headings, Literata for body, Fira Code for code —
|
||||
|
||||
@@ -335,7 +335,7 @@ onUnmounted(() => {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Docked-overlay positioning lives in the global style.css (.editor-host /
|
||||
/* Docked-overlay positioning lives in the global pagerite.css (.editor-host /
|
||||
.editor-root.overlay) since the host element is created by main.js. */
|
||||
|
||||
.toolbar {
|
||||
|
||||
@@ -640,7 +640,7 @@ onUnmounted(() => {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Docked-overlay positioning lives in the global style.css (.editor-host /
|
||||
/* Docked-overlay positioning lives in the global pagerite.css (.editor-host /
|
||||
.editor-root.overlay) since the host element is created by main.js. */
|
||||
|
||||
.toolbar {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/* Shared styles for server-rendered pages and Vue components. */
|
||||
/* Base structural and functional styles for Pagerite pages and editors.
|
||||
Color choices are intentionally conservative CSS variables so themes can
|
||||
override them without fighting layout or interaction rules. */
|
||||
@import url("./fonts/fonts.css");
|
||||
@import url("./pygments.css");
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #12101c;
|
||||
--surface: #1b1830;
|
||||
--text: #e8e6f2;
|
||||
--muted: #9a94b8;
|
||||
--accent: #00d4c8;
|
||||
--accent2: #7c5cff;
|
||||
--line: #ffffff1a;
|
||||
--bg: #ffffff;
|
||||
--surface: #f5f5f5;
|
||||
--text: #1a1a1a;
|
||||
--muted: #666666;
|
||||
--accent: #0056b3;
|
||||
--accent2: #003d80;
|
||||
--line: #0000001a;
|
||||
/* 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);
|
||||
@@ -45,10 +46,6 @@ body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent2);
|
||||
}
|
||||
|
||||
/* Full-width banner: image header with the brand and nav overlaid. */
|
||||
#banner {
|
||||
position: relative;
|
||||
@@ -87,12 +84,7 @@ body {
|
||||
text-decoration: none;
|
||||
margin: auto 1.25rem 0;
|
||||
padding-top: 1.5rem;
|
||||
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);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Nav overlaid at the bottom of the banner */
|
||||
@@ -433,7 +425,6 @@ pre {
|
||||
past them by its own padding. */
|
||||
margin-left: -0.8rem;
|
||||
margin-right: -0.8rem;
|
||||
background: #ffffff09;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
@@ -595,7 +586,6 @@ footer {
|
||||
FRAGILE: do not tweak; the view-transition pseudo-tree is picky. */
|
||||
::view-transition {
|
||||
perspective: 1000px;
|
||||
background: #000;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/* Purple theme: overrides the conservative base variables and adds the
|
||||
more elaborate visual styling (dark palette, gradient brand, etc.). */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #12101c;
|
||||
--surface: #1b1830;
|
||||
--text: #e8e6f2;
|
||||
--muted: #9a94b8;
|
||||
--accent: #00d4c8;
|
||||
--accent2: #7c5cff;
|
||||
--line: #ffffff1a;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent2);
|
||||
}
|
||||
|
||||
#brand {
|
||||
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);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #ffffff09;
|
||||
}
|
||||
|
||||
::view-transition {
|
||||
background: #000;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import "./assets/style.css";
|
||||
|
||||
// Pagerite editor entries. Two separate apps, mounted in their own
|
||||
// dynamically created host divs inside the static document:
|
||||
// - PageEditor ("page" mode): pen next to an article heading — Markdown
|
||||
@@ -8,6 +6,11 @@ import "./assets/style.css";
|
||||
// (previewed into the real banner) and the site structure tree.
|
||||
// The standalone /_/admin shell (#app in the DOM) mounts PageEditor with the
|
||||
// page selected by location hash, as a no-dynamic-import fallback.
|
||||
if (import.meta.env.DEV) {
|
||||
import("./assets/pagerite.css");
|
||||
import("./assets/themes/purple/theme.css");
|
||||
}
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import PageEditor from './PageEditor.vue'
|
||||
import SiteEditor from './SiteEditor.vue'
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import "./assets/style.css";
|
||||
|
||||
// Fetch-navigation: swap dynamic regions (#nav, #main) instead of full
|
||||
// page loads. Real <a href> links are used throughout, so this is pure
|
||||
// progressive enhancement - without JS every link does a normal load.
|
||||
@@ -7,6 +5,11 @@ import "./assets/style.css";
|
||||
// Also: scroll-reveal effects and code copy buttons. These need no
|
||||
// support from the article itself and are re-applied after each swap.
|
||||
(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
import("./assets/pagerite.css");
|
||||
import("./assets/themes/purple/theme.css");
|
||||
}
|
||||
|
||||
const REGIONS = ["page-banner", "nav", "sidebar", "main"];
|
||||
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let editorModule = null;
|
||||
@@ -191,7 +194,7 @@ import "./assets/style.css";
|
||||
runScripts(document.getElementById("main"));
|
||||
applyEffects();
|
||||
};
|
||||
// Rotating cube page transition (see the FRAGILE block in style.css);
|
||||
// Rotating cube page transition (see the FRAGILE block in pagerite.css);
|
||||
// mirrored when navigating back through history. Navigation within the
|
||||
// same top-level section crossfades instead, in either direction.
|
||||
if (document.startViewTransition && !reduceMotion.matches) {
|
||||
|
||||
@@ -36,6 +36,8 @@ export default defineConfig({
|
||||
input: {
|
||||
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)),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+100
-28
@@ -25,12 +25,73 @@ from pagerite.markdown import has_h1, render
|
||||
SITE_NAME = "Pagerite"
|
||||
BUILD = Path(__file__).with_name("frontend-build")
|
||||
|
||||
# Shared CSS built as separate entries so the backend can link base and theme
|
||||
# independently. Order matters: base first, theme overrides it.
|
||||
_SHARED_CSS = {
|
||||
"src/assets/pagerite.css": "pagerite",
|
||||
"src/assets/themes/purple/theme.css": "pagerite-theme",
|
||||
}
|
||||
|
||||
_manifest_cache: dict | None = None
|
||||
_asset_cache: dict[str, tuple] = {}
|
||||
|
||||
|
||||
def _manifest() -> dict:
|
||||
global _manifest_cache
|
||||
if _manifest_cache is None:
|
||||
_manifest_cache = json.loads((BUILD / ".vite/manifest.json").read_text())
|
||||
return _manifest_cache
|
||||
|
||||
|
||||
def _shared_css_urls(vite_url: str | None) -> list[str]:
|
||||
"""URLs for the base and theme stylesheets.
|
||||
|
||||
In dev the JS entries import these files, so Vite injects them; the
|
||||
backend does not link them, avoiding the HMR-wrapped module output.
|
||||
"""
|
||||
if vite_url:
|
||||
return []
|
||||
manifest = _manifest()
|
||||
return [f"/{manifest[key]['file']}" for key in _SHARED_CSS]
|
||||
|
||||
|
||||
def _editor_css_url(vite_url: str | None) -> str | None:
|
||||
"""URL for the editor-specific stylesheet (Vue component styles).
|
||||
|
||||
This is linked by the public-page edit pen so the editor styles are
|
||||
loaded before the editor JS dynamic-import resolves.
|
||||
"""
|
||||
if vite_url:
|
||||
return None
|
||||
manifest = _manifest()
|
||||
entry = manifest["src/main.js"]
|
||||
shared_files = {manifest[key]["file"] for key in _SHARED_CSS}
|
||||
for css in entry.get("css", []):
|
||||
if css not in shared_files:
|
||||
return f"/{css}"
|
||||
return None
|
||||
|
||||
|
||||
def _editor_page_css_urls(vite_url: str | None) -> list[str]:
|
||||
"""All CSS URLs for the standalone admin editor page."""
|
||||
if vite_url:
|
||||
return []
|
||||
shared = _shared_css_urls(None)
|
||||
editor_css = _editor_css_url(None)
|
||||
return [*shared, editor_css] if editor_css else shared
|
||||
|
||||
|
||||
def _layout(urls: list[str], modules: list[str] = ()) -> Template:
|
||||
"""Page layout template with standard asset URLs and ES-module scripts."""
|
||||
doc = Document(E.Title, lang="en", _urls=urls)
|
||||
"""Page layout template with standard asset URLs and ES-module scripts.
|
||||
|
||||
Stylesheets use ``blocking="render"`` so the browser waits for them before
|
||||
showing the page, avoiding a flash of unstyled content.
|
||||
"""
|
||||
doc = Document(E.Title, lang="en")
|
||||
for url in urls:
|
||||
doc.link(rel="stylesheet", href=url, blocking="render")
|
||||
for src in modules:
|
||||
doc.script(src=src, type="module", defer=True)
|
||||
doc.script(src=src, type="module")
|
||||
return Template(
|
||||
doc
|
||||
.header(
|
||||
@@ -45,7 +106,7 @@ def _layout(urls: list[str], modules: list[str] = ()) -> Template:
|
||||
E.main(E.Main, id="main"),
|
||||
id="content",
|
||||
)
|
||||
.footer(None), # kept empty for now; zero-height (see style.css)
|
||||
.footer(None), # kept empty for now; zero-height (see pagerite.css)
|
||||
)
|
||||
|
||||
|
||||
@@ -169,13 +230,13 @@ def _edit_attrs(path: str, mode: str = "page") -> dict:
|
||||
(the pen on the banner) edits the banner and site structure. They are
|
||||
buttons, not links: editing is an action, not a navigation.
|
||||
"""
|
||||
scripts, styles = _editor_assets()
|
||||
scripts, _styles, editor_css = _editor_assets()
|
||||
return {
|
||||
"type": "button",
|
||||
"class": "edit-link" if mode == "page" else "edit-link banner-edit-link",
|
||||
"title": "edit",
|
||||
"data-editor-src": scripts[-1],
|
||||
"data-editor-css": ",".join(styles),
|
||||
"data-editor-css": editor_css or "",
|
||||
"data-editor-mode": mode,
|
||||
}
|
||||
|
||||
@@ -272,40 +333,51 @@ def _page_assets() -> tuple[list[str], list[str]]:
|
||||
Dev mode loads the entry from the Vite dev server; production uses
|
||||
the Vite build manifest to resolve the hashed asset names.
|
||||
"""
|
||||
if vite_url := os.environ.get("PAGERITE_VITE_URL"):
|
||||
return (
|
||||
[f"{vite_url}/src/pagerite.js"],
|
||||
[], # Vite injects the imported CSS in dev
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
if vite_url:
|
||||
return [f"{vite_url}/src/pagerite.js"], _shared_css_urls(vite_url)
|
||||
if "page" not in _asset_cache:
|
||||
manifest = _manifest()
|
||||
entry = manifest["src/pagerite.js"]
|
||||
_asset_cache["page"] = (
|
||||
[f"/{entry['file']}"],
|
||||
_shared_css_urls(None),
|
||||
)
|
||||
manifest = json.loads((BUILD / ".vite/manifest.json").read_text())
|
||||
entry = manifest["src/pagerite.js"]
|
||||
# Manifest paths already carry the _/assets/ prefix (assetsDir).
|
||||
styles = [f"/{css}" for css in entry.get("css", [])]
|
||||
return [f"/{entry['file']}"], styles
|
||||
return _asset_cache["page"]
|
||||
|
||||
|
||||
def _editor_assets() -> tuple[list[str], list[str]]:
|
||||
"""Script and CSS URLs for the admin editor (main entry).
|
||||
def _editor_assets() -> tuple[list[str], list[str], str | None]:
|
||||
"""Script URLs, page CSS URLs, and editor-specific CSS URL.
|
||||
|
||||
Dev mode loads the modules from the Vite dev server; production uses
|
||||
the Vite build manifest to resolve the hashed asset names.
|
||||
The standalone admin page uses all CSS URLs; the public-page edit pen
|
||||
only needs the editor-specific URL because the shared CSS is already
|
||||
linked on the page.
|
||||
"""
|
||||
if vite_url := os.environ.get("PAGERITE_VITE_URL"):
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
if vite_url:
|
||||
return (
|
||||
[f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"],
|
||||
[], # Vite injects the imported CSS in dev
|
||||
_shared_css_urls(vite_url),
|
||||
None,
|
||||
)
|
||||
manifest = json.loads((BUILD / ".vite/manifest.json").read_text())
|
||||
entry = manifest["src/main.js"]
|
||||
styles = [f"/{css}" for css in entry.get("css", [])]
|
||||
return [f"/{entry['file']}"], styles
|
||||
if "editor" not in _asset_cache:
|
||||
manifest = _manifest()
|
||||
entry = manifest["src/main.js"]
|
||||
_asset_cache["editor"] = (
|
||||
[f"/{entry['file']}"],
|
||||
_editor_page_css_urls(None),
|
||||
_editor_css_url(None),
|
||||
)
|
||||
return _asset_cache["editor"]
|
||||
|
||||
|
||||
def render_editor() -> str:
|
||||
"""Render the admin editor shell: a mount point for the Vue app."""
|
||||
scripts, styles = _editor_assets()
|
||||
doc = Document(f"Admin – {SITE_NAME}", lang="en", _urls=styles)
|
||||
scripts, styles, _editor_css = _editor_assets()
|
||||
doc = Document(f"Admin – {SITE_NAME}", lang="en")
|
||||
for url in styles:
|
||||
doc.link(rel="stylesheet", href=url, blocking="render")
|
||||
for src in scripts:
|
||||
doc.script(src=src, type="module", defer=True)
|
||||
doc.script(src=src, type="module")
|
||||
doc.div(None, id="app")
|
||||
return str(doc)
|
||||
|
||||
Reference in New Issue
Block a user