- Two new themes (corporate light/dark, nitro racing) and a purple retune; theme banner SVGs moved to pagerite/themes/, inlined by the backend, recolored per scheme via CSS classes, with scroll parallax. - pygments.css maps tokens onto --code-* variables; light and dark palette sets in the base stylesheet resolve via light-dark() from the theme's color-scheme; themes just pick a set or retint --code-bg. - Sidebar omitted unless the section has at least two published items; fetch-navigation and the site editor handle it appearing/disappearing. - Floated figures keep captions below at 30% of the text column; h1/h2 clear floats so images never overflow into the next section. - Links use --link (color-mix of text and accent), weight 500, no underlines; hover goes full accent.
234 lines
14 KiB
Markdown
234 lines
14 KiB
Markdown
# Pagerite Design Principles
|
||
|
||
Pagerite is a single-user CMS/blog. This document records the initial
|
||
high-level design decisions; it will be refined as the implementation
|
||
evolves.
|
||
|
||
## Architecture
|
||
|
||
- **Server-side rendered.** FastAPI serves complete HTML pages, generated in
|
||
Python with **html5tagger**. There is no client-side templating or SPA for
|
||
the public site.
|
||
- **Vue only where interactivity demands it.** Small interactive islands
|
||
(editing tools mainly) are Vue components mounted into specific elements of
|
||
the server-rendered pages. The public reading experience has no scripting
|
||
requirement.
|
||
- **Persistence via kanta.** Content is stored in an asyncio-friendly kanta
|
||
database. Rendering happens on the fly on each request — there are no
|
||
pre-built static artifacts.
|
||
|
||
## Content model
|
||
|
||
- Pages and blog articles are fundamentally the same kind of thing: named
|
||
pieces of content. The blog/website distinction is blurred; an article is
|
||
just a page (possibly with metadata such as a publication date and
|
||
listing in a feed).
|
||
- **Pretty URLs.** Content is addressed by its name (slug), not by technical
|
||
constructs — no `/cms/...` or `/blog/post1` prefixes. Slugs usually live
|
||
directly at the site root; structured content may nest
|
||
(`/docs/design-principles`-style). The URL space is the author's, so
|
||
reserved prefixes must be kept few and deliberate: everything internal
|
||
lives under `/_` (`/_api/`, `/_f/`, `/_assets/`). The only
|
||
other reserved root path is `/favicon.ico`, served from the build.
|
||
Slugs are lowercase ASCII letters, digits, hyphens and underscores
|
||
(`[a-z0-9_-]`; input is transliterated and filtered as you type, and a
|
||
new page's empty slug is derived from its title), may not begin with
|
||
`_` or `.`, and such URLs are never looked up as content.
|
||
- **Single user, trusted author.** No auth concerns in the core design.
|
||
Everything published is public; only editing tools will later sit behind
|
||
access control (external SSO when that time comes). The author is trusted
|
||
to create well-meaning slugs and content — no sanitization for safety,
|
||
only for correctness.
|
||
- **Commenting** is not planned now but the model should not preclude it
|
||
later.
|
||
|
||
## Authoring format
|
||
|
||
- Content is written in **Markdown** with powerful extensions (tables,
|
||
footnotes, code highlighting, etc.).
|
||
- **Embedded HTML is passed through unfiltered**, including inline scripts
|
||
and other dynamic content the author wants to post. This is safe by the
|
||
single-trusted-author assumption above.
|
||
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition
|
||
lists, task lists, brace-attributes; tables and strikethrough from the
|
||
default preset), with `html=True` for raw passthrough. Fenced code blocks
|
||
are highlighted server-side with **Pygments** (`nowrap` spans styled by
|
||
`/_assets/pygments-*.css`, which maps every token class onto the `--code-*`
|
||
variables; the base stylesheet defines light and dark palette sets resolved
|
||
via `light-dark()`, so each theme gets the set matching its `color-scheme`
|
||
and may only retint `--code-bg` to keep the well in the page's color
|
||
family); a JS copy button appears on hover. Should this
|
||
prove limiting, we implement our own renderer on top of html5tagger,
|
||
which we already use for all HTML generation.
|
||
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`)
|
||
are stored by content hash — blake3, first 6 bytes hex + original
|
||
extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs
|
||
that survive page renames and dedupe identical content; pages no longer
|
||
own files. An image with a title becomes a `<figure>` with
|
||
`<figcaption>`. Positioning is by attribute classes:
|
||
`{.right}` — `{.right}`, `{.left}` float at
|
||
30% of the text column (the caption wraps within it; an explicit
|
||
`width=300` overrides on uncaptioned images),
|
||
`{.wide}` goes full bleed (viewport edge to edge, or up to the docked
|
||
editor; the sidebar stacks on top of it); plain attributes like `width=300`
|
||
work too. Headings (h1/h2) clear floats, so images never overflow into the
|
||
next section.
|
||
|
||
## Page structure and navigation
|
||
|
||
- All pages share one static layout, defined once as an **html5tagger
|
||
Template** with capitalized placeholders (`Title`, `Banner`, `Nav`,
|
||
`Sidebar`, `Main`) filled per request. The dynamic regions carry stable
|
||
ids (`#page-banner`, `#nav`, `#sidebar`, `#main`).
|
||
- The page top is a **full-width banner header** with the site name and the
|
||
navigation bar overlaid on it — no separate chrome header. The banner is
|
||
**per-page configurable**: `Node.banner` holds an arbitrary trusted HTML
|
||
snippet (an image, a styled div, canvas + script — anything), resolved by
|
||
walking up the node's ancestors to the front page; when nothing in the
|
||
chain sets one, the active theme's banner artwork shows. That artwork is
|
||
an **inline SVG** (`pagerite/themes/{name}/banner.svg`) the backend
|
||
inlines into `#page-banner`: as markup it can be recolored from the theme
|
||
stylesheet (corporate's single SVG serves both light and dark mode via
|
||
`var()`-driven stops) and it is never rendered underneath a user banner.
|
||
The base stylesheet falls back to a plain gradient. There is deliberately
|
||
no scrim fading the banner into the page background — any such fade would
|
||
ruin user-supplied designs; themes that want one bake it into their SVG
|
||
(purple does).
|
||
- **Fetch-navigation.** Links are plain `<a href>`; a small script
|
||
(`frontend/src/pagerite.js`) intercepts same-origin clicks, fetches the
|
||
page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions,
|
||
the document title, and the site-wide custom CSS (`<style id="pagerite-user">`
|
||
in `<head>`), keeping the rest of `<head>` and the layout chrome. Without JS
|
||
everything works as normal page loads. Scripts inside fetched banner and
|
||
content regions are re-created so they execute. Swaps run inside `document.startViewTransition` for a rotating
|
||
cube page transition (CSS adapted from termotohtori.fi — the
|
||
`::view-transition*` block is fragile, do not tweak; skipped under
|
||
`prefers-reduced-motion`). Navigation within the same top-level section
|
||
crossfades instead of rotating; browser back navigation rotates in
|
||
reverse.
|
||
- **The site structure is a tree of labels.** `Data.menu` holds the
|
||
top-level items by slug, each with `children` keyed by slug — the URL
|
||
path is the slug chain. The front page is a top-level node with slug ""
|
||
(an item *parallel* to the other main level pages, not their parent) and
|
||
cannot have children. The header navbar holds only the top level; a
|
||
top-level item is highlighted when viewing any of its subpages. When the
|
||
current page is inside a main level section with children, those direct
|
||
children are listed in a **left sidebar** (`#sidebar`), one level deep.
|
||
The sidebar exists only when there is something to navigate — sections
|
||
with fewer than two published items, leaf pages and the front page render
|
||
no aside element at all. Other sections' subitems
|
||
are never shown without navigating into them first.
|
||
- **Landing pages are optional.** Every label can either have content
|
||
(`Node.content`, a Markdown page) or none — a content-less label renders
|
||
a placeholder page (404 with a pen to create it) instead of redirecting,
|
||
while nav links to it point straight at its first child, so categories
|
||
need no filler content and normal navigation never sees the placeholder.
|
||
Title and slug of every label are editable; renaming a
|
||
slug moves the whole subtree. The sidebar never lists the section
|
||
itself, avoiding title duplication with the navbar.
|
||
- **Menu order is manual.** Each node has a fractional `order` key among
|
||
its siblings; reordering/moving writes only the moved node (it takes a
|
||
fresh value halfway between its new siblings; all other items keep
|
||
theirs). New pages append at the end of their menu. Structure edits
|
||
(reorder, move/rename with the whole subtree, retitle) go through
|
||
`POST /_api/structure` and the editor's structure panel.
|
||
- Unpublished pages are hidden from both nav and URL access (404).
|
||
|
||
## Reading experience
|
||
|
||
- The article column is sized by the **viewport, never by content**: a
|
||
symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps
|
||
the layout stable across navigation. The sidebar occupies the left
|
||
gutter, the right gutter balances it; wide screens get columns inside
|
||
long articles without changing the article's width.
|
||
- A gentle **scroll-reveal** of headings, figures and block-level elements
|
||
(IntersectionObserver). It is layout-level: articles need no support
|
||
for it, and `prefers-reduced-motion` disables all motion.
|
||
|
||
## Styling
|
||
|
||
- 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/{name}/theme.css` — currently `purple`, `corporate`
|
||
and `nitro`) overrides those variables and
|
||
adds the visual styling; `Data.theme` selects the active theme (empty = none/base
|
||
only) and the site editor can switch it. Vue may add per-component styles on top
|
||
where needed. The corporate and nitro themes switch palettes automatically via
|
||
`prefers-color-scheme` (corporate is light-first with a matching dark palette;
|
||
nitro a warm light-grey page or, in dark mode, a deep violet one — its dark
|
||
banner and orange accents carry over unchanged); purple (dusk) uses one
|
||
fixed palette for everyone. Themes may restyle structural details the base
|
||
leaves plain — heading colors and underlines, list markers, nav treatment,
|
||
brand sizing. The banner artwork has scroll parallax: pagerite.js sets the
|
||
`--pry` scroll parameter on `<html>` (event-driven, so it is still when the
|
||
page is idle), the banner contents drift within their window (with scale
|
||
overscan so no edge shows), and themes may key their own effects off the
|
||
same parameter — purple's sun rises as you scroll.
|
||
- Fonts, the shared stylesheet and pygments styles
|
||
live under `frontend/src/assets/` and are emitted as hashed assets under
|
||
`/_assets/`
|
||
(Source Serif 4 for headings, Source Sans 3 for body, Fira Code for code
|
||
by default; Fraunces, Literata, Cormorant, Playfair Display, Inter and
|
||
Montserrat kept as woff2 options with local `@font-face`, variable-weight
|
||
where available). No third-party requests.
|
||
|
||
## Editing
|
||
|
||
- Editing happens **in place**, in two modes opened by two pens:
|
||
- **Page mode** — the 🖊️ next to a page's heading (including 404s, which
|
||
is how new pages start) opens a CodeMirror Markdown editor docked to
|
||
the left of the article: the host sits inside `#content` (below the
|
||
banner, never over the footer), the content shifts right and the
|
||
sidebar hides while editing. Preview renders server-side per keystroke
|
||
(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), **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
|
||
the **structure tree**. Everything saves immediately as you edit — no
|
||
save button, no edit mode.
|
||
- Clicking a pen again closes the editor (without saving; a dirty preview
|
||
reloads the page). The pens are `<button>`s wired up by `pagerite.js` —
|
||
editing is an action, not a navigation. The editor's WebSocket
|
||
**reconnects automatically** with local text and pending saves preserved.
|
||
(All users are trusted authors for now; access control later with
|
||
SSO.)
|
||
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published
|
||
controls.
|
||
Images can be pasted straight into the editor or chosen via a file
|
||
input: they upload to the content store (`PUT /_api/files/...`) and
|
||
insert `` at the cursor.
|
||
- The **structure panel** (vue-draggable tree of the whole site, in site
|
||
mode) covers page management: reorder any menu level, drag across
|
||
sections, add, delete (two clicks: the button arms, then deletes — no
|
||
dialogs). Every node is a real label — content-less category rows offer
|
||
a ➕ to give them a landing page.
|
||
Deleting a category removes only its landing page (the label and its
|
||
subpages stay). Every non-empty list ends with a ➕ row that starts a
|
||
new page as a local-only tree row at that level; the row can be dragged
|
||
into place before its title and slug are filled in and is persisted only
|
||
on commit. While dragging, these ➕ rows double as "end of this list"
|
||
drop targets; dropping ON the lower part of a row makes the page that
|
||
row's first child (even a leaf's, creating a sublist), while a row's
|
||
exposed top edge inserts a sibling before it. A dragged row's
|
||
indentation previews the target list's depth. Rows are always
|
||
editable: titles save while typing, slug edits commit on blur/Enter
|
||
since they rename the path (moving the whole subtree). The front page
|
||
is the root row with an empty slug — renaming it away leaves no front
|
||
page ("/" redirects to the first nav item), and giving another
|
||
top-level row the empty slug makes it the front page.
|
||
- Preview and saving go over a **WebSocket** (`/_api/ws/editor`) with a
|
||
stateless JSON protocol (`open`/`render`/`save`; on save all fields are
|
||
optional and absent ones keep their old values, `move_from` renames),
|
||
avoiding REST polling and races. Rendering always stays server-side.
|
||
- A REST API also exists for scripting, all under `/_api/`:
|
||
`GET pages` (the full tree), `PUT/DELETE pages/{path}`,
|
||
`GET/PUT settings` (site brand, theme and custom CSS), `POST structure`
|
||
(reorder/move/retitle), file upload/removal via `PUT/DELETE files/{name}`.
|
||
- On startup, seed pages from `pagerite/seed.py` are added **only if
|
||
missing** — existing user content is never overwritten.
|