KilnCMS — Design Language
Applies to the admin & authoring UI (`/editor/`, media, auth). The public delivery frontend keeps its own minimal chrome.*
KilnCMS is a focused, editor-first content management system for thoughtful creators and teams. It prioritizes clarity, speed, and editorial flow over feature bloat. Voice: calm, confident, precise — like a trusted editor. The design goals: reduce cognitive load for writers/editors, make publishing feel satisfying and safe, scale from solo to team use, and stay keyboard-first accessible.
Why this exists
Kiln already had good bones — a self-owned, DaisyUI-flavored token palette in
assets/css/app.css (base-100/200/300, primary, accent, semantic
info/success/warning/error, light + dark). What was missing was a coherent
layer on top of those tokens: a consistent component vocabulary and an app
shell. Without it, pages reached for one-off Tailwind (rounded border border-base-content/20 px-3 py-1.5 …) and the product read like a stock
Phoenix-generated site rather than a content management application.
This document is the shared reference for that layer — brand principles, tokens, the component kit, and the console shell. The goal: Kiln looks like Kiln — familiar to anyone who's used DaisyUI/Directus/Strapi, but its own thing, and with no CSS-framework dependency.
Principles
- Clarity. Every element has one purpose.
- Trust. Reliable save states, clear permissions, helpful error messages.
- Efficiency. Fast paths for common tasks; minimal clicks to publish.
- Editorial focus. Content is king; chrome stays out of the way.
- Own it, DaisyUI-flavored. Borrow DaisyUI's naming (
btn,card,field-*,kbd) so it feels familiar, but define every rule ourselves, driven by our tokens. There is no DaisyUI (or any component-lib) dependency — a class outside this hand-written kit renders nothing. - Tokens are the single source of truth. Components never hard-code a
color, radius, or font — they read
var(--color-*),var(--radius-*),var(--font-sans). Retuning the brand (one--color-primaryedit) or adding a theme flows through everything automatically. - Semantic over ad-hoc. Prefer a named component (
<.button>,.card,.field-input) over a bespoke stack of utilities. Utilities are for layout (flex, grid, spacing), not for re-inventing a control's look. - The shell carries the identity. A persistent sidebar + workspace top bar is the single biggest signal that this is an app. Every authoring screen lives inside it.
- Both themes, always. Anything added must look right in light and dark; because rules use tokens, this is usually free — but check it.
- Accessible by construction. WCAG 2.1 AA+ baseline: full keyboard
navigation, focus-visible rings,
aria-currentfor nav, real labels, 4.5:1 contrast, semantic HTML preferred. The design language and a11y are the same effort.
Tokens (already in app.css)
| Group | Tokens |
|---|---|
| Surfaces | base-100 (page), base-200 (raised/hover), base-300 (borders), base-content (text) |
| Brand | primary, secondary, accent, neutral (+ *-content) |
| Status | info, success, warning, error (+ *-content) |
| Radius | --radius-sm | md | lg | xl |
| Type | --font-sans (system stack), tightened heading tracking |
Brand palette is the ember language (see Design system):
the #FF6200 ember brand on neutral-gray surfaces in light, and a brighter ember
on neutral charcoal in dark. Note ember only reaches ~3:1 against white, so
--color-primary-content is a near-black ink — primary buttons/badges carry dark
text on the ember fill (≈6.3:1, WCAG AA), and ember-as-text usages (active nav
link, in-content links) are darkened for AA on light surfaces. Retune by editing
the @theme / [data-theme="dark"] blocks — do not introduce new raw colors
in components.
Component kit (@layer components in app.css)
These are plain, token-driven CSS classes so a control looks identical whether
written as a function component or a raw class="…" in a template.
- Buttons —
.btnbase +.btn-primary/.btn-default/.btn-ghost/.btn-danger, size.btn-sm,.btn-block. Prefer the<.button>function component (variant=,size=), which emits exactly these classes. For a compact inline control keep.btn .btn-defaultand shrink it with utilities (px-2 py-0.5 text-xs) — utilities outrank the kit..btn-danger-fill/.btn-warning-fillare the solid second step of a two-step confirm (the "Delete view" that actually deletes), never a first click. - Link-styled buttons —
.btn-linkfor an in-flow text action ("Dismiss", "Undo", "Change") that must stay a<button>because it fires an event rather than navigating. It supplies the reset, keyboard focus ring and disabled state; the call site keeps its own colour andunderline/hover:underlineutilities. - Links —
.linkon an in-text<a>: underline, pointer and focus ring (Tailwind's preflight strips an anchor's colour and underline, so a bare<a>reads as body text). The call site keeps its own colour. - Surfaces —
.card(the one raised container: base-100, hairline border,--radius-lg) +.card-padfor standard interior padding. - Stat cards —
.stat(a card-shaped link whose number is the headline) with.stat-valueand.stat-label;aria-current="page"rings the one whose filter is on screen. The tinted icon disc's tone is the call site's, matched to the badge it counts. Used by the content list's overview strip. - Pills & chips —
.pillis a named filter you can switch to (the content list's views;aria-current="page"fills the active one)..chipis an active filter you can remove: a small tinted<button>carrying its own x and anaria-labelthat says "Remove filter: …". - Row menu —
.row-menu+.row-menu-item: a row's secondary actions behind a "⋯"btn-ghostbutton. A nativepopoveropened withpopovertarget(Escape and click-outside close it, no JS), anchored under the button where CSS anchor positioning exists. An item that fires aphx-clickalso carriespopovertargetaction="hide"so the menu closes. - Fields —
.field-input,.field-select(full-width, token border, focus ring), and.field-labelfor the label above one. The<.input>component is the richer, label+error-aware form control; these bare classes are for inline filters/toolbars and for markup Kiln does not render itself (the AshAuthentication forms, throughKilnCMSWeb.AuthOverrides)..field-input-error— oraria-invalid="true"on a.field-input, which draws the same — marks a refused value;.field-checkboxis a checkbox in the brand accent. - Auth pages —
.auth-page(the centred full-height page),.auth-card(the form column),.auth-titleand.auth-muted(heading and secondary copy), and.auth-brand(the logo-and-name row drawn byLayouts.auth_brand/1, over every sign-in page and the first-run wizard). The library's sign-in pages take these throughAuthOverrides; Kiln's own/sign-in/verifytemplate uses them directly, so the two steps of a sign-in look like one flow. - Tabs —
.tabs(segmented-control container) +.tab; drive the active segment witharia-selected="true"(accessible, no extra class). Used for the media Library/Trash switch. When the tabs switch panels on the same page (the Form Builder's sections, the editor's inspector), use the full ARIA tabs pattern:role="tablist"+phx-hook="TabKeys"(Left/Right/Home/End, fromassets/js/tab_keys.js),role="tab",tabindex="0"on the selected tab and-1on the rest, androle="tabpanel"+aria-labelledby+tabindex="0"on the panel.aria-controlsgoes only on a tab whose panel is in the DOM: the inspector keeps all three panels mounted, so every tab carries it; the Form Builder mounts only the selected section, so only the selected tab does. - Tables —
.tableon a raw<table>for consistent header/cell/row styling (+.table-zebrafor stripes). Cells keep their own alignment / width / colour utilities;.tableowns padding, borders and the header treatment. Wrap wide tables inoverflow-x-auto. Also styles the<.table>component. A row header (<th scope="row">in the body) is styled as an ordinary cell, so naming each row for assistive tech costs no visual change. - Shell nav —
.side-link(+aria-current="page"for the active item: a bordered, raised row with the icon in ember ink),.side-icon(its outlined icon),.side-group(one nav section, keyed bydata-nav-group;.side-group-opfor the ruled-off operator band),.side-section(the section head — a<button data-nav-group-toggle>that collapses the.side-group-itemsbelow it, carrying.side-section-chevronand, on the operator band,.side-section-mark),.side-badge-slot+.side-badge(a count on a nav item — a pill, a dot on the rail; drawn byKilnCMSWeb.NavBadge),.side-icon-btn(the bordered square button),.side-theme(segmented System / Light / Dark switch),.side-account+.side-menu(account row and its menu). Panel colours are thesidebar,sidebar-raisedandsidebar-linetokens. - Help tips —
.help-tip-button+.help-tip-panel, drawn by<.help_tip>: a "?" beside a label or heading that opens one to three plain sentences and an optional Learn more link (docs_url/1). The panel is a nativepopover, so Escape and a click outside close it with no JS. Put the button beside a<label>, never inside it, or it joins the control's name. - Misc —
.kbd(keyboard hint, used by the ⌘K search affordance).
Do / Don't
<%!-- DO --%>
<.button variant="primary" size="sm" phx-click="new">New page</.button>
<button class="btn btn-sm btn-default" phx-click="publish">Publish</button>
<ul class="card divide-y divide-base-content/10">…</ul>
<%!-- DON'T — bespoke button, no shared vocabulary --%>
<button class="rounded border border-base-content/20 px-3 py-1.5 text-sm hover:bg-base-200">
Publish
</button>Content & tone
- Use "Save draft" and "Publish now", not generic labels like "Submit".
- Errors read in plain language with a next step, not a raw exception.
- Status terminology stays consistent everywhere it appears (draft, in review, published, archived — never a synonym mid-flow).
- Copy overall: concise, calm, action-oriented, non-technical where possible.
- No internal metaphors in editor-facing strings. Kiln's own vocabulary —
"firing", the kiln imagery — stays in module names, module docs and
comments. What an author reads says what happened to their content:
"published with…", not "fired as…"; "hasn't finished publishing", not
"hasn't been fired". The same applies to implementation nouns the author
never chose: name the behaviour ("question-and-answer structured data"),
not the schema.org type ("FAQPage"). The one audience that keeps the term
is the operator/integrator — "fired artifact" is the name the API reference,
docs/static-export.mdand the System → Delivery cache panel all use for the same thing, and renaming it only there would break the trail. - The same goes for pictures. A mark an editor has to learn to decode is not a status mark: the content list says "Missing translations" and "Publishes <date>" in words, beside the state badge. The I-Ching trigram glyph it used to show is kept as an opt-in (Your settings → Content list), never the default, and even there its accessible label spells each bit out.
UX patterns
- Entry status system — Draft → Published → Archived, surfaced with
<.badge>. - Save & Publish flow — autosave plus explicit validation before publish.
- Editor preview — a live preview in the inspector rail, a side-by-side mode that widens it to half the editor, and the full-page Preview in a new tab. There are no device-width modes; a narrow window is the phone check.
- Hover-revealed controls — a control that fades in on hover (a block's
move/duplicate/remove, the preview's "Edit" jump) also shows on
focus-within, so Tab reaches it, and onpointer-coarse, since a touch screen has no hover. Fade withopacity, neverhidden: adisplay: nonecontrol is out of the Tab order. - Rows show one next step — a list row gets one visible action (the verb that moves the record along: Publish, Approve, Submit for review, Unarchive) and a hover-revealed row menu for the rest. The title is the link into the editor, so a row needs no separate "Edit" button.
- Bulk bars appear with a selection — a list's bulk actions are drawn in its header only once something is ticked; with nothing selected the header is "Select all" and a count, not a row of disabled buttons.
- Media, empty, and permission states — each has a dedicated treatment
(
<.empty_state>for the first two; permission states degrade gracefully rather than dead-ending). - Cursor & feedback — anything clickable shows the pointer without a
per-site
cursor-pointer:@layer baseinapp.cssrestores it onbutton,summary,select, checkboxes/radios (and a<label>wrapping one), ARIAbutton/tab/option/menuitemroles and any[phx-click]element. Tailwind v4's preflight resetsbuttontocursor: default, which is why this has to be stated at all. Disabled controls shownot-allowed. A control whose LiveView round-trip is in flight (phx-click-loading, or a submit button underphx-submit-loading) dims after 0.2s and showsprogress. Drag handles paircursor-grabwithactive:cursor-grabbing. Anycursor-*utility still overrides the base rule. - Destructive actions confirm — a
phx-clickthat deletes, removes, revokes or purges carriesdata-confirm={gettext("…?")}; name the thing and the consequence ("Stop sending notifications to this device?").
Layout
Responsive breakpoints: 640 / 768 / 1024 / 1280px, with a 12-column grid and the collapsible console sidebar as the base layout. See Design system → Responsive for the concrete per-component conventions (nav collapse, header stacking, two-column forms).
The console shell — Layouts.console/1
The authoring app frame (lib/kiln_cms_web/components/layouts.ex):
- Left sidebar (persistent and full-height on
lg+, slide-in drawer on mobile via a CSS-only peer checkbox — works before the LiveView socket connects), drawn after Untitled UI's dark sidebar: in dark mode the panel sits a step below the workspace. Brand row with a collapse button, the role-gated nav (below), plugin-contributed items, then a foot with the segmented theme switch and the account row (avatar, name over email) whose menu holds Account, GraphQL / JSON:API and Sign out. - The nav itself is data, in
KilnCMSWeb.ConsoleNav— the sidebar draws it, the Configure hub (/editor/configure) lists it with a one-line description per screen, and the ⌘K palette searches it by name, section, description and keyword. So a screen is reachable the day it is added, and one the actor may not open is absent from all three. The author items (Content, Media, Taxonomy, Calendar, Translations, Analytics, …) run ungrouped at the top; below them an admin gets the Configure hub link, then collapsible sections — Content model, Capture, Delivery, Outbound, Integrations, Organization, Account (Your settings, the per-user screen) — and Operations, ruled off below them for the instance-wide, platform-admin screens (Team, Accounts, Billing, Mail, API keys, Backups, System). Every item in that band isplatform: true, so for anyone else the band empties and is dropped: the separation is never half day-to-day admin. A non-admin gets the Account section alone. - Sidebar presets — Essentials and Everything, per user
(
User.nav_preset, saved server-side so it follows them across devices and the first paint is already right). Essentials draws Home, Content, Media, Calendar, Tasks and Inbox, then the Configure hub (admins) and Your settings; Everything is the full map above. Only the sidebar filters (ConsoleNav.sidebar/3) — the hub and ⌘K readnav/2, so a preset hides a link, never a screen — and the page you are on is drawn even when the preset would hide it, marked current. The switch sits at the foot of the sidebar ("Show all tools" / "Show essentials", named for what it will do) and on Your settings; both sendset_nav_preset, whichKilnCMSWeb.NavPresethandles on every signed-in LiveView and answers by updatingcurrent_user, so the sidebar redraws in place. New accounts start on Essentials; accounts that existed before presets were migrated to Everything. - Section collapse is stored the way the rail is: a space-separated list of
group keys on
<html data-nav-collapsed>+localStorage(kiln:nav-collapsed), replayed byroot.html.heexbefore first paint. The server always renders the section expanded andapp.cssdoes the hiding (one--side-group-displayrule per group key), so nothing flashes open and no assign is involved;app.jsonly correctsaria-expanded. In the icon rail collapse is ignored — a 4.5rem column has no room to say why items are gone. - Nav counts — an item carrying
badge:inConsoleNavgets a count beside its label; today only Tasks (the viewer's open tasks). The count is a LiveComponent,KilnCMSWeb.NavBadge, built like the notification bell: it loads itself from the user and org the layout already has, counts once per page, and re-counts on a notification or when asked — never on an ordinary redraw. The Inbox deliberately has none; the bell is its count. - Icon rail (
lg+): the collapse button folds the sidebar to icons. The state lives on<html data-sidebar="collapsed">+localStorage(kiln:sidebar), restored before first paint byroot.html.heexand flipped byapp.js— LiveView never patches<html>, so it survives every live navigation. Labels go visually hidden (links keep their accessible names) andapp.jsshows the hovered or focused item'sdata-side-tipas a tooltip. - Mobile drawer (below
lg): the same panel, slid in over the page by a CSS checkbox — no JS, no socket. The hamburger is a<label>carryingrole="button",aria-controlsandaria-expanded, and the checkbox itself istabindex="-1"(anaria-hiddencontrol in the tab order announces nothing) andphx-update="ignore"(a patch would otherwise resetcheckedand shut the drawer mid-use).app.jsadds what the markup cannot: Enter and Space, Escape, focus moved in on open and handed back on close, Tab kept inside the drawer, and a close on navigation — a live navigation patches the page in place, so the drawer would otherwise stay over the new one. The page behind does not scroll (body:has(#kiln-nav-toggle:checked)), and the slide honoursprefers-reduced-motionfrom an unlayered rule, since the transition comes from a utility. - Top bar (sticky): page title, a search affordance with a
⌘K.kbd, an:actionsslot for page-level primary buttons, locale switcher. - Workspace:
max-w-6xlcontent column.
<Layouts.console flash={@flash} current_user={@current_user}
page_title={gettext("Content")} active={:content}>
<:actions>
<.button variant="primary" size="sm" phx-click="new">New page</.button>
</:actions>
…page body…
</Layouts.console>Layouts.app/1 (the old top-nav shell) still exists during the transition.
Rollout
- Done: tokens confirmed, component kit added,
consoleshell built,<.button>unified onto.btn, and the Content dashboard (/editor) rebuilt on the new shell as the reference implementation. - Done: all authoring LiveViews migrated from
Layouts.app→Layouts.consolewith the rightactiveand one-off controls swapped for the kit — analytics, calendar, content editor, fields, forms, mail, media, search palette, settings, taxonomy, translations, trash, content types, webhooks. Compiles clean under--warnings-as-errors; verified in light + dark. - Done: the tab pattern (
.tabs/.tab) and table treatment (.table) graduated into the kit and adopted — media Library/Trash switch on tabs; the analytics, webhook-deliveries and translation-coverage tables on.table. Defining.tablealso revived the previously dead<.table>/.table-zebrareferences (leftover DaisyUI class names). Still deliberately bespoke: icon-only / subtle destructive buttons (kept asbtn-ghost+hover:text-errorrather than a solidbtn-danger) and the translation locale chips (a matched link/button pair, not tabs). - Done: the
/marketing landing (page_html/home.html.heex) moved toLayouts.public. It is a public-facing page, not an authoring tool, and serving it from the authoring shell gave the site root a theme toggle and an account menu no other public URL has while skipping the site's own nav, theme preset and attribution.Layouts.publictook two optional attrs to absorb it —wide(the reading-measure opt-out, replacing the oldcontainer_class) andcurrent_user(the account/sign-out pair).Layouts.app/1now has one caller left,page_html/developers.html.heex; retire it when that page getsLayouts.publicor its own treatment. - Done (1.1): the Content dashboard (
/editor) re-aligned with the kit it introduced. Its view pills, filter chips and confirm buttons had drifted into one-off utility stacks; they became.pill,.chipand.btn-*-fill. It also gained an overview strip (.stat), rows with one visible action and a.row-menu, a byline (author, last update, type), and a bulk bar drawn only with a selection.
Keep this document in step with the kit: new shared pattern → document it here before using it widely.