Skip to main content

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

  1. Clarity. Every element has one purpose.
  2. Trust. Reliable save states, clear permissions, helpful error messages.
  3. Efficiency. Fast paths for common tasks; minimal clicks to publish.
  4. Editorial focus. Content is king; chrome stays out of the way.
  5. 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.
  6. 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-primary edit) or adding a theme flows through everything automatically.
  7. 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.
  8. 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.
  9. Both themes, always. Anything added must look right in light and dark; because rules use tokens, this is usually free — but check it.
  10. Accessible by construction. WCAG 2.1 AA+ baseline: full keyboard navigation, focus-visible rings, aria-current for nav, real labels, 4.5:1 contrast, semantic HTML preferred. The design language and a11y are the same effort.

Tokens (already in app.css)

GroupTokens
Surfacesbase-100 (page), base-200 (raised/hover), base-300 (borders), base-content (text)
Brandprimary, secondary, accent, neutral (+ *-content)
Statusinfo, 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 — .btn base + .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-default and shrink it with utilities (px-2 py-0.5 text-xs) — utilities outrank the kit. .btn-danger-fill / .btn-warning-fill are the solid second step of a two-step confirm (the "Delete view" that actually deletes), never a first click.
  • Link-styled buttons — .btn-link for 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 and underline / hover:underline utilities.
  • Links — .link on 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-pad for standard interior padding.
  • Stat cards — .stat (a card-shaped link whose number is the headline) with .stat-value and .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 — .pill is a named filter you can switch to (the content list's views; aria-current="page" fills the active one). .chip is an active filter you can remove: a small tinted <button> carrying its own x and an aria-label that says "Remove filter: …".
  • Row menu — .row-menu + .row-menu-item: a row's secondary actions behind a "⋯" btn-ghost button. A native popover opened with popovertarget (Escape and click-outside close it, no JS), anchored under the button where CSS anchor positioning exists. An item that fires a phx-click also carries popovertargetaction="hide" so the menu closes.
  • Fields — .field-input, .field-select (full-width, token border, focus ring), and .field-label for 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, through KilnCMSWeb.AuthOverrides). .field-input-error — or aria-invalid="true" on a .field-input, which draws the same — marks a refused value; .field-checkbox is a checkbox in the brand accent.
  • Auth pages — .auth-page (the centred full-height page), .auth-card (the form column), .auth-title and .auth-muted (heading and secondary copy), and .auth-brand (the logo-and-name row drawn by Layouts.auth_brand/1, over every sign-in page and the first-run wizard). The library's sign-in pages take these through AuthOverrides; Kiln's own /sign-in/verify template 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 with aria-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, from assets/js/tab_keys.js), role="tab", tabindex="0" on the selected tab and -1 on the rest, and role="tabpanel" + aria-labelledby + tabindex="0" on the panel. aria-controls goes 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 — .table on a raw <table> for consistent header/cell/row styling (+ .table-zebra for stripes). Cells keep their own alignment / width / colour utilities; .table owns padding, borders and the header treatment. Wrap wide tables in overflow-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 by data-nav-group; .side-group-op for the ruled-off operator band), .side-section (the section head — a <button data-nav-group-toggle> that collapses the .side-group-items below it, carrying .side-section-chevron and, 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 by KilnCMSWeb.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 the sidebar, sidebar-raised and sidebar-line tokens.
  • 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 native popover, 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.md and 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 on pointer-coarse, since a touch screen has no hover. Fade with opacity, never hidden: a display: none control 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 base in app.css restores it on button, summary, select, checkboxes/radios (and a <label> wrapping one), ARIA button/tab/option/menuitem roles and any [phx-click] element. Tailwind v4's preflight resets button to cursor: default, which is why this has to be stated at all. Disabled controls show not-allowed. A control whose LiveView round-trip is in flight (phx-click-loading, or a submit button under phx-submit-loading) dims after 0.2s and shows progress. Drag handles pair cursor-grab with active:cursor-grabbing. Any cursor-* utility still overrides the base rule.
  • Destructive actions confirm — a phx-click that deletes, removes, revokes or purges carries data-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 is platform: 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 read nav/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 send set_nav_preset, which KilnCMSWeb.NavPreset handles on every signed-in LiveView and answers by updating current_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 by root.html.heex before first paint. The server always renders the section expanded and app.css does the hiding (one --side-group-display rule per group key), so nothing flashes open and no assign is involved; app.js only corrects aria-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: in ConsoleNav gets 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 by root.html.heex and flipped by app.js — LiveView never patches <html>, so it survives every live navigation. Labels go visually hidden (links keep their accessible names) and app.js shows the hovered or focused item's data-side-tip as 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> carrying role="button", aria-controls and aria-expanded, and the checkbox itself is tabindex="-1" (an aria-hidden control in the tab order announces nothing) and phx-update="ignore" (a patch would otherwise reset checked and shut the drawer mid-use). app.js adds 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 honours prefers-reduced-motion from an unlayered rule, since the transition comes from a utility.
  • Top bar (sticky): page title, a search affordance with a ⌘K .kbd, an :actions slot for page-level primary buttons, locale switcher.
  • Workspace: max-w-6xl content 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

  1. Done: tokens confirmed, component kit added, console shell built, <.button> unified onto .btn, and the Content dashboard (/editor) rebuilt on the new shell as the reference implementation.
  2. Done: all authoring LiveViews migrated from Layouts.app → Layouts.console with the right active and 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.
  3. 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 .table also revived the previously dead <.table> / .table-zebra references (leftover DaisyUI class names). Still deliberately bespoke: icon-only / subtle destructive buttons (kept as btn-ghost + hover:text-error rather than a solid btn-danger) and the translation locale chips (a matched link/button pair, not tabs).
  4. Done: the / marketing landing (page_html/home.html.heex) moved to Layouts.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.public took two optional attrs to absorb it — wide (the reading-measure opt-out, replacing the old container_class) and current_user (the account/sign-out pair). Layouts.app/1 now has one caller left, page_html/developers.html.heex; retire it when that page gets Layouts.public or its own treatment.
  5. 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, .chip and .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.