{% extends "admin/base.html" %}
{% use_elements %}
{# Customization page: design-token reference + component catalog.
Pure reference doc — every token shown as a swatch, every component
shown with copyable markup. To preview in dark mode, use the theme
menu in the top-right header.
On lg+ a sticky side nav rides the right margin; on smaller screens
that collapses and a flat TOC sits at the top. IntersectionObserver
in the inline init script highlights the current section as the user
scrolls. #}
{% macro example(caption='') %}
{% set markup = caller() %}
{{ markup }}
Show markup{% if caption %} — {{ caption }}{% endif %}
Reference for the design tokens shipped with the admin and
copy-pasteable markup for every UI primitive.
{# Compact TOC for small screens (large screens get the sticky side nav on the right). #}
{# ====================================================================== #}
{# Theme #}
{# ====================================================================== #}
{{ group("Theme") }}
{% call section("customizing", "Customizing the admin",
"Plain's admin is themeable through CSS variables. Override tokens in your own stylesheet — no need to fork templates.") %}
The design system has three layers, loaded in order:
Tailwind v4 — utility classes
styles/tokens.css — design tokens, scoped to .plain-admin
styles/components/*.css — component primitives, wrapped in @scope (.plain-admin)
Override any token in your own stylesheet, loaded after the admin's
Tailwind output. Example — swap the primary action color to indigo:
Every .btn-primary and focus ring in the admin will pick
that up — no template changes required.
{% endcall %}
{% call section("beyond-tokens", "Beyond tokens",
"When a token swap doesn't cover what you need, override the `.admin-*` classes directly in your stylesheet. They're treated as a stable surface — every class shown on this page can be overridden. The admin's component rules live in `@layer components`, so unlayered declarations in your stylesheet beat them automatically.") %}
Forking templates remains as an escape hatch but should be the last resort —
you take on keeping the fork in sync as the admin evolves.
{% endcall %}
{% call section("dark-mode", "Dark mode",
"The admin ships with a built-in theme menu in the top-right with Light, Dark, and System options. The choice is stored in localStorage; `.dark` is set on ``; the no-flash init in base.html resolves before paint. Wire your own picker by adding `data-theme-set=\"light|dark|system\"` to any element.") %}
{% endcall %}
{% call section("typography", "Typography",
"Defaults: Inter (sans) and JetBrains Mono (mono), both vendored under `assets/admin/fonts/`. Override `--font-sans` / `--font-mono` on `.plain-admin` to retheme — Tailwind's `font-sans` / `font-mono` utilities pick those up automatically. To swap or drop the bundled `@font-face` declarations, shadow `admin/base.html` in your app (extending `admin/_base.html`) and override the `admin_fonts` block.") %}
Sans Mlw 0Oo
--font-sans
Mono Mlw 0Oo
--font-mono
Heading 1 — page title
Heading 2 — section
Heading 3 — subsection
Body text. The quick brown fox jumps over the lazy dog.
Muted helper text — used for secondary information.
Tiny text — labels, metadata, timestamps.
{% endcall %}
{# ====================================================================== #}
{# Tokens #}
{# ====================================================================== #}
{{ group("Tokens") }}
{% call section("tokens-surface", "Surface tokens",
"The neutral palette: page backgrounds, muted/accent surfaces, borders. Component-scoped surfaces (`--card`, `--popover`, chart) live in their respective component sections below.") %}
{{ swatch_grid(_surface) }}
{% endcall %}
{% call section("tokens-action", "Action tokens",
"Colors for buttons and focus rings. `--primary` and `--ring` drive `.admin-btn-primary`, focus outlines, and the active-tab indicator. (Danger actions like `.admin-btn-danger` pull from the `--danger` status token below.)") %}
{{ swatch_grid(_action) }}
{% endcall %}
{% call section("tokens-status", "Status tokens",
"Status colors exposed as Tailwind utilities (`text-success`, `bg-warning/10`, `border-info`). Used by alerts, badges, preflight, and form validation.") %}
{{ swatch_grid(_status) }}
{% endcall %}
{% call section("tokens-header", "Header tokens",
"The admin's sticky top header. Plain ships a faint warm tint that's a hair off the page background — `#f6f3f2` (slightly darker than white) in light mode, and a subtly lighter shade in dark mode. Override `--header-bg` to match your own brand. For full contrast inversion (light page, dark header), add `class=\"dark\"` to the `` element instead — that flips every token via the dark variant.") %}
{{ swatch_grid(_header_tokens) }}
{% endcall %}
{% call section("tokens-sizing", "Sizing tokens",
"Default column shows what Plain ships in `tokens.css`. Current column shows the resolved value as the page is currently loaded — overrides on `.plain-admin` flow through.") %}
Each component owns its own --radius-* token. Override one to retune just that
component, or override --radius (the master) to shift every default proportionally.
Token
Default
Current
--radius
0.5rem
--radius-card
var(--radius)
--radius-button
var(--radius)
--radius-input
var(--radius)
--radius-select
var(--radius)
--radius-textarea
var(--radius)
--radius-dialog
calc(var(--radius) + 2px)
--radius-alert
var(--radius)
--radius-popover
calc(var(--radius) - 2px)
--radius-tooltip
calc(var(--radius) - 2px)
--radius-segmented
var(--radius)
--radius-segmented-item
calc(var(--radius) - 2px)
--radius-dropdown-item
calc(var(--radius) - 4px)
--radius-kbd
calc(var(--radius) - 4px)
--radius-checkbox
calc(var(--radius) - 4px)
--radius-tabs-focus
calc(var(--radius) - 4px)
--scrollbar-width
6px
--scrollbar-radius
6px
Generic rounded-admin-sm / -md / -lg Tailwind utilities
are also available for ad-hoc rounding in templates (menu items, preflight rows, chart legends).
Component CSS doesn't use them — each component has its own token above.
Add class="admin-scrollbar" to any scrollable element to opt in to the slim
themed scrollbar — uses --scrollbar-width, --scrollbar-radius,
--scrollbar-thumb, --scrollbar-track. The default browser
scrollbar is left alone elsewhere.
{% endcall %}
{# ====================================================================== #}
{# Content #}
{# ====================================================================== #}
{{ group("Content") }}
{% call section("prose", "Prose",
"Element-level defaults for raw HTML inside admin views — prose links, description lists, code blocks. Form chrome stays opt-in via component classes.") %}
Plain-only prose tokens:
{{ swatch_grid(_prose_tokens) }}
Prose links
This paragraph contains a prose link styled with --link and underlined by default. Hover for --link-hover.
Description list
Package
plain-admin
Status
Stable
Maintainer
Dropseed
Code
{% call example("Inline") %}
Run uv run plain check to lint, type-check, and run tests.
{% endcall %}
{% endcall %}
{% call section("icons", "Icons",
"Bootstrap Icons are vendored. Use the admin.Icon element with a name.") %}
{% call example() %}
{% endcall %}
{% endcall %}
{% call section("kbd", "Keyboard hints",
"Small inline labels for key names — useful inline in help text or alongside menu items that have a keyboard shortcut. Wrap the key in ``.") %}
{% call example() %}
Press Esc to dismiss, or ⌘ + K to open the search.
{% endcall %}
{% endcall %}
{# ====================================================================== #}
{# Actions #}
{# ====================================================================== #}
{{ group("Actions") }}
{% call section("buttons", "Buttons",
"Compose a button by stacking a base `.admin-btn` with size, shape, and color modifiers. Neutral variants (primary, secondary, outline, ghost, link) plus the status family (success, warning, danger, info) for actions that match an alert tone. Sizes: default, `.admin-btn-sm`, `.admin-btn-lg`. Add `.admin-btn-icon` for square icon-only.") %}
{% call example("Neutral variants") %}
{% endcall %}
{% call example("Status variants") %}
{% endcall %}
{% call example("Sizes") %}
{% endcall %}
{% call example("With icon") %}
{% endcall %}
{% endcall %}
{% call section("button-groups", "Button groups",
"Wrap related buttons in `.button-group` to fuse them visually — the wrapper joins corners and collapses inner borders. Set `data-orientation='vertical'` to stack instead. Insert `` for a divider.") %}
{% call example("Horizontal") %}
{% endcall %}
{% endcall %}
{% call section("badges", "Badges",
"Semantic badges for status indicators. Neutral variants (primary/secondary/outline) and Plain's translucent status family (success/warning/danger/info) — the latter use a 10% fill of the matching token so they read in dense table contexts.") %}
{% call example("Neutral variants") %}
PrimarySecondaryOutline
{% endcall %}
{% call example("Plain semantic variants") %}
StableBetaDeprecatedNew
{% endcall %}
{% endcall %}
{# ====================================================================== #}
{# Feedback #}
{# ====================================================================== #}
{{ group("Feedback") }}
{% call section("alerts", "Alerts",
"Inline status panels. Wrap the title in an h2-h6 or [data-title] and put body content in a .") %}
{% call example() %}
Heads up
This is a default alert. Use it for neutral status messages.
{% endcall %}
{% call example() %}
All checks passed
The success variant confirms a positive outcome.
{% endcall %}
{% call example() %}
Heads up
The warning variant is for non-fatal issues that need attention.
{% endcall %}
{% call example() %}
Something went wrong
The danger variant is for errors and failed actions.
{% endcall %}
{% call example() %}
Just so you know
The info variant is for incidental notes and pointers.