{% 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 %}
{{ markup|forceescape }}
{% endmacro %} {% macro group(title) %}

{{ title }}

{% endmacro %} {% macro section(id, title, blurb='') %}

{{ title }}

{% if blurb %}

{{ blurb }}

{% endif %} {{ caller() }}
{% endmacro %} {% macro swatch_grid(swatches) %}
{% for name, cls in swatches %}
--{{ name }}
{% endfor %}
{% endmacro %} {# Token swatch lists. Each is a (display-name, bg-utility) pair. The bg-utility classes must be literal in the template so Tailwind picks them up in its @source scan. #} {% set _surface = [ ("background", "bg-admin-background"), ("foreground", "bg-admin-foreground"), ("secondary", "bg-admin-secondary"), ("secondary-foreground", "bg-admin-secondary-foreground"), ("muted", "bg-admin-muted"), ("muted-foreground", "bg-admin-muted-foreground"), ("accent", "bg-admin-accent"), ("accent-foreground", "bg-admin-accent-foreground"), ("border", "bg-admin-border"), ("input", "bg-admin-input"), ("scrollbar-thumb", "bg-[var(--scrollbar-thumb)]") ] %} {% set _action = [ ("primary", "bg-admin-primary"), ("primary-foreground", "bg-admin-primary-foreground"), ("ring", "bg-admin-ring") ] %} {% set _status = [ ("success", "bg-admin-success"), ("success-foreground", "bg-admin-success-foreground"), ("warning", "bg-admin-warning"), ("warning-foreground", "bg-admin-warning-foreground"), ("danger", "bg-admin-danger"), ("danger-foreground", "bg-admin-danger-foreground"), ("info", "bg-admin-info"), ("info-foreground", "bg-admin-info-foreground") ] %} {# Component-scoped tokens — rendered alongside their component sections below rather than in the top-level token reference. #} {% set _prose_tokens = [ ("link", "bg-admin-link"), ("link-hover", "bg-admin-link-hover"), ("code-bg", "bg-admin-code-bg") ] %} {% set _card_tokens = [ ("card", "bg-admin-card"), ("card-foreground", "bg-admin-card-foreground") ] %} {% set _popover_tokens = [ ("popover", "bg-admin-popover"), ("popover-foreground", "bg-admin-popover-foreground") ] %} {% set _header_tokens = [ ("header-bg", "bg-admin-header-bg") ] %} {% set _chart_tokens = [ ("chart-1", "bg-admin-chart-1"), ("chart-2", "bg-admin-chart-2"), ("chart-3", "bg-admin-chart-3"), ("chart-4", "bg-admin-chart-4"), ("chart-5", "bg-admin-chart-5") ] %} {# Grouped TOC. Each group is (group_title, [(section_id, link_label), ...]). Group titles also render as

dividers in the body. #} {% set _toc_groups = [ ("Theme", [ ("customizing", "Customizing"), ("beyond-tokens", "Beyond tokens"), ("dark-mode", "Dark mode"), ("typography", "Typography"), ]), ("Tokens", [ ("tokens-surface", "Surface tokens"), ("tokens-action", "Action tokens"), ("tokens-status", "Status tokens"), ("tokens-header", "Header tokens"), ("tokens-sizing", "Sizing tokens"), ]), ("Content", [ ("prose", "Prose"), ("icons", "Icons"), ("kbd", "Keyboard hints"), ]), ("Actions", [ ("buttons", "Buttons"), ("badges", "Badges"), ]), ("Feedback", [ ("alerts", "Alerts"), ("progress", "Progress"), ]), ("Containers", [ ("cards", "Cards"), ("admin-cards", "Admin card classes"), ("tables", "Tables"), ]), ("Forms", [ ("forms", "Form fields"), ("field-wrapper", "Field wrapper"), ]), ("Overlays", [ ("dialogs", "Dialogs"), ("popovers", "Popovers"), ("dropdowns", "Dropdowns"), ("tooltips", "Tooltips"), ("hovercards", "Hovercards"), ]), ("Disclosure", [ ("tabs", "Tabs"), ("segmented", "Segmented control"), ("collapsible", "Collapsible"), ]), ("Behaviors", [ ("behaviors", "Data attributes"), ]), ] %} {% block header_scripts %} {% endblock %} {% block content %}

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:

  1. Tailwind v4 — utility classes
  2. styles/tokens.css — design tokens, scoped to .plain-admin
  3. 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:

/* app/static/admin-overrides.css */
.plain-admin {
  --primary: #4f46e5;        /* indigo-600 */
  --primary-foreground: white;
  --ring: #4f46e5;
}
.dark.plain-admin,
.dark .plain-admin {
  --primary: #818cf8;        /* indigo-400 */
  --primary-foreground: #1e1b4b;
}

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.") %}
/* tailwind.css */
.admin-card {
  border-width: 2px;
  box-shadow: 0 1px 0 var(--border) inset;
}

.admin-btn-primary {
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

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.

TokenDefaultCurrent
--radius0.5rem
--radius-cardvar(--radius)
--radius-buttonvar(--radius)
--radius-inputvar(--radius)
--radius-selectvar(--radius)
--radius-textareavar(--radius)
--radius-dialogcalc(var(--radius) + 2px)
--radius-alertvar(--radius)
--radius-popovercalc(var(--radius) - 2px)
--radius-tooltipcalc(var(--radius) - 2px)
--radius-segmentedvar(--radius)
--radius-segmented-itemcalc(var(--radius) - 2px)
--radius-dropdown-itemcalc(var(--radius) - 4px)
--radius-kbdcalc(var(--radius) - 4px)
--radius-checkboxcalc(var(--radius) - 4px)
--radius-tabs-focuscalc(var(--radius) - 4px)
--scrollbar-width6px
--scrollbar-radius6px

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 %} {% call example("Block") %}
def hello(name: str) -> str:
    return f"Hello, {name}!"
{% endcall %} {% endcall %} {% call section("icons", "Icons", "Bootstrap Icons are vendored. Use the admin.Icon element with a name.") %} {% call example() %} {% endcall %}

Browse the full set at icons.getbootstrap.com.

{% 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 %} {% call example("With separator") %}

{% endcall %} {% call example("Vertical") %}
{% 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") %} Primary Secondary Outline {% endcall %} {% call example("Plain semantic variants") %} Stable Beta Deprecated New {% 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.
{% endcall %} {% endcall %} {% call section("progress", "Progress", "Native element. Set value/max (default 0–100); status follows data-status: ok (default success), warning, error.") %} {% call example("Default — success") %} {% endcall %} {% call example("Warning") %} {% endcall %} {% call example("Error") %} {% endcall %} {% endcall %} {# ====================================================================== #} {# Containers #} {# ====================================================================== #} {{ group("Containers") }} {% call section("cards", "Cards", "Visual shell only — themed surface, border, and rounded corners. Layout and padding are yours to compose with utility classes (e.g. `flex flex-col gap-6 p-6`).") %}

Card surface tokens:

{{ swatch_grid(_card_tokens) }}
{% call example("Default") %}

Active users

Last 30 days

Body content sits in a section.

{% endcall %} {% call example("Dense + metric typography (dashboard pattern)") %}

Active users

1,284
View all
{% endcall %} {% endcall %} {% call section("admin-cards", "Admin card classes", "Live previews of the four card subclasses shipped from `plain.admin.cards`. Subclass these and register them on a view's `cards = [...]` to surface them on dashboards.") %}

Chart palette (used by TrendCard):

{{ swatch_grid(_chart_tokens) }}
{% for card in demo_cards %}{{ render_card(card)|safe }}{% endfor %}

Card (small, with metric and link), KeyValueCard (medium, key/value pairs), TrendCard (full-width, exercises --chart-1..5), and TableCard (full-width, header + rows).

{% endcall %} {% call section("tables", "Tables", "Add `class=\"table\"` for header/row/hover styling.") %} {% call example() %}
Package Status Version
plain Stable 0.137.1
plain-admin Stable 0.50.0
plain-htmx Beta 0.19.0
{% endcall %} {% endcall %} {# ====================================================================== #} {# Forms #} {# ====================================================================== #} {{ group("Forms") }} {% call section("forms", "Form fields", "Use the .input/.textarea classes (or the matching admin element templates), Plain's brand color drives the focus ring.") %} {% call example("Text input") %}

We'll never share your email.

{% endcall %} {% call example("Invalid input") %}

Enter a valid email address.

{% endcall %} {% call example("Textarea") %}
{% endcall %} {% call example("Select") %}
{% endcall %} {% call example("Checkbox") %} {% endcall %} {% call example("Radio group") %}
{% endcall %} {% call example("Switch") %} {% endcall %} {% call example("Compact size — pair `input-sm` / `select-sm` for dense rows") %}
{% endcall %} {% call example("Search input — admin element") %}
{% endcall %}

Plain ships matching element templates that wrap these classes: <admin.Input>, <admin.Textarea>, <admin.Select>, <admin.Checkbox>, plus <admin.InputField> / <admin.SelectField> / <admin.CheckboxField> for the full label + control + errors stack when rendering a Plain form field.

{% endcall %} {% call section("field-wrapper", "Field wrapper", "Wrap a label, control, and helper/error text in a `.field` for consistent vertical rhythm. Group several fields under a `.fieldset` with a legend. Set `data-orientation=\"horizontal\"` on a `.field` to lay the label and control side-by-side (handy for switches and checkboxes). Mark a field as invalid with `data-invalid=\"true\"` to flip its label/helper to the danger color.") %} {% call example("Vertical fieldset") %}
Profile

Public information shown on your account page.

Visible to other admins.

Enter a valid email address.
{% endcall %} {% endcall %} {# ====================================================================== #} {# Overlays #} {# ====================================================================== #} {{ group("Overlays") }} {% call section("dialogs", "Dialogs", "Native with the `.admin-dialog` class. Open and close via the HTML Invoker Commands API (`command` + `commandfor`) — no JS, no inline `onclick`, CSP-safe.") %} {% call example() %}

Example dialog

Native <dialog> styled with .dialog.

Press Escape or click the close button to dismiss.
{% endcall %}

Closing from inside without an id also works via <form method="dialog"><button>…</button></form> — submitting that form closes the enclosing dialog and returns the button's value.

{% endcall %} {% call section("popovers", "Popovers", "Generic click-to-open panel. Wrap a trigger `
{% endcall %} {% endcall %} {% call section("dropdowns", "Dropdowns", "Wrap a trigger button + a [data-popover] menu in a .admin-dropdown-menu element. Items use role=\"menuitem\" and pick up styling from the admin-dropdown-menu component. JS in components.js handles open/close, outside-click, and keyboard navigation (arrow keys, Home/End, Enter/Esc).") %} {% call example() %} {% endcall %} {% endcall %} {% call section("tooltips", "Tooltips", "Pure-CSS tooltip via the `data-tooltip=\"…\"` attribute. Position with `data-side` (top/bottom/left/right) and `data-align` (start/center/end). Hover or focus the trigger to reveal — no JS, no popper.") %} {% call example("Sides") %}
{% endcall %} {% call example("On an icon button") %} {% endcall %} {% endcall %} {% call section("hovercards", "Hovercards", "Hover-triggered version of popover. Trigger can be any element (no `
General tab content.
{% endcall %} {% endcall %} {% call section("segmented", "Segmented control", "Pick one of a small set of mutually-exclusive values — theme, density, view mode. Roving tabindex and arrow-key navigation are wired in components.js; the consumer toggles `aria-checked` in response to clicks. For switching content panels, reach for tabs.") %} {% call example() %}
{% endcall %} {% endcall %} {% call section("collapsible", "Collapsible", "Native `
` / `` get a smooth expand/collapse transition (via the new `::details-content` pseudo-element) and the default disclosure triangle is hidden so you can ship your own affordance. Used implicitly by the \"Show markup\" disclosures on this page.") %} {% call example() %}
Advanced options
Tucked-away configuration the everyday user shouldn't have to think about.
{% endcall %} {% call example("Collapsible card") %}

Object Details

Body content of the card, revealed when expanded.
{% endcall %} {% endcall %} {# ====================================================================== #} {# Behaviors #} {# ====================================================================== #} {{ group("Behaviors") }} {% call section("behaviors", "Data attributes", "Declarative `data-*` attributes you sprinkle on existing elements. Behaviors are delegated event handlers in `assets/admin/behaviors.js` — no markup pattern, no CSS. They piggyback on whatever element you stick them on.") %} {# Cells use `whitespace-normal` so the prose descriptions wrap inside the content column. The default `.table` cell style is `whitespace-nowrap`, which is correct for list views (IDs, timestamps) but wrong for doc-prose tables like this. #}
AttributeWhat it does
data-copy-value="…" Click to write the value to the clipboard. A descendant marked data-copy-feedback (or the trigger's last child) gets a brief "Copied!" text swap.
data-autosubmit On a form field — submit the enclosing form on change. Used for list filters and pagination dropdowns.
data-column-autolink="url" Wrap a table cell's content in an <a href> link to url. No-op if the cell already contains an anchor.
data-encrypted="value" Click to reveal/hide an encrypted value (used for API keys and similar). Clicking inside the revealed <code> doesn't re-toggle, so the user can select to copy.

GET-form submissions are also intercepted globally to drop empty params from the URL — keeps list views from accumulating ?q=&filter= cruft when filters are cleared. No attribute needed; applies to every <form method="GET">.

{% endcall %} {# Side nav (sticky on lg+, hidden on mobile where the top TOC takes over) #} {% endblock %}