mailglass_admin Design System

Copy Markdown View Source

The reference for how the admin UI is built, so each component compounds the same polish rather than re-deciding it. The voice and palette come from prompts/mailglass-brand-book.md; this doc covers the mechanics — tokens, motion, conformance, and how to audit them.

One rule above all: there is a single source of truth for every visual decision. Color lives in the daisyUI theme blocks; size / type / elevation / motion live in the @theme block and :root tokens. Components compose those tokens — they never hardcode hex, pixels, or durations.


CSS architecture

  • Tailwind v4 + daisyUI, compiled by the standalone tailwind Hex binary — zero Node toolchain. Source: assets/css/app.css. Build: mix mailglass_admin.assets.build.
  • The minified bundle priv/static/app.css is committed. CI runs the build then git diff --exit-code priv/static/ — any edit to app.css or to HEEx class usage must be followed by a rebuild + commit of the bundle in the same change, or the gate fails.
  • No second CSS file, no @apply, no CSS-in-JS, no BEM. Utilities + daisyUI semantic classes inline in HEEx. Stay on this path — do not introduce a competing styling system.
  • Footgun: never construct class names dynamically ("p-#{n}"). Tailwind's scanner only emits utilities it finds as literal strings; a computed class is silently tree-shaken to nothing. Use static class strings.

Token layers

Two layers that own disjoint namespaces, so they never collide:

1. Color — daisyUI theme blocks (@plugin "daisyui-theme" in app.css)

The only place light and dark diverge. Brand palette mapped to daisyUI semantic tokens. Use the semantic names, never raw Tailwind palette (text-gray-500) and never hex in HEEx.

SemanticLight (brand)Use
base-100Paper #F8FBFDapp/detail surface
base-200Mist #EAF6FBcards, list/filter panes, sidebar
base-300Ice #A6EAF2borders, hover/active
base-contentInk #0D1B2Aprimary text
primaryGlass #277B96accent — use sparingly
secondarySlate #5C6B7Asecondary text/meta
success / warning / errorPine / Amber / Crimsonstatus only

Accent discipline (the 10% rule): primary/Glass appears only on the selected-row border, the primary CTA, the active nav/timeline node, and focus emphasis. It is never the default border or badge color. Status tints use opacity (bg-warning/10), not new tokens.

2. Structure — @theme block + :root tokens (theme-independent)

Each @theme token is simultaneously a CSS variable and a Tailwind utility.

ScaleTokensUtilities
Spacing (4px grid)--spacing-xs…3xl (4/8/16/24/32/48/64)p-md, gap-sm, px-lg, …
Type (400/700 only)--text-label/body/heading/display (12/14/20/28)text-body, text-heading, …
Elevation--shadow-flat/raised/overlayshadow-overlay (modals only)
Easing--ease-out, --ease-in-outease-out
Motion (≤300ms)--duration-instant/fast/reveal/flashduration-(--duration-fast)
Control size--size-control-sm/md/lg (36/44/52)maps to min-h-9/11/13
Z-index--z-sticky/dropdown/overlay/modal/toast (10/20/30/40/50)z-10z-50

Type weights are exactly 400 and 700. font-medium / font-semibold (500/600) are NOT loaded — the browser synthesizes a fake bold. Use font-bold or default; faux-bold is a conformance failure.

Elevation is borders-first. Default surfaces are border border-base-300 with no shadow (brand --depth:0, flat — no glassmorphism/bevels). Only modals/popovers use shadow-overlay (a faint Ink-tinted shadow). Never shadow-2xl/-xl/-lg.

Motion vocabulary

Brand metaphor "clarity through panes": content arrives by becoming visible (opacity) and settling a few px into place — never sliding across the screen, never bouncing. Six named motions, deliberately restrained.

MotionClass / mechanismWhere
reveal.motion-reveal (opacity + translateY 6px, 220ms)detail pane, cards, flash
timeline-in.motion-timeline > * (staggered 40ms, capped at 8)event timelines
tab-swap.motion-tab-swap (crossfade 150ms), id keyed so it re-mountspreview tabs, modal backdrop
overlay.motion-overlay (scale 0.98→1 + opacity, 220ms)modal panels
row-statetransition-colors duration-(--duration-fast)list rows, nav, tabs
flash.motion-reveal on toastflash region

Rules (from great-animations, reinforced by the brand): ease-out only (never ease-in), ≤300ms, animate transform/opacity only (never height/width/padding), exits faster than entries, no springs/overshoot, never animate keyboard-repeatable actions, and fire entrance motions on mount (phx-mounted / element insertion), not on every LiveView patch. Implementation is Phoenix.LiveView.JS + CSS only — there is no client JS build to add hooks to. A global @media (prefers-reduced-motion: reduce) block neutralizes movement while letting crossfades effectively snap.

Per-component conformance checklist

A component passes only if all hold:

  • Spacing/size: token utilities on the 4px grid; no arbitrary p-[14px], no off-grid gap-1.5. Touch targets ≥ min-h-11 (44px).
  • Radius: rounded-box / rounded-field only (theme-driven).
  • Color: semantic tokens + opacity tints only; no hex, no raw palette; accent obeys the 10% rule.
  • Type: text-label/body/heading/display; weight font-bold or default only (no faux-bold).
  • Elevation/stacking: border + shadow-flat; shadow-overlay for modals only; z-* from the named tier (no ad-hoc z-50 except the toast tier).
  • Motion: a named motion above, or intentionally instant; inherits reduced motion.
  • A11y: selected state via aria-current/aria-selected (not color alone); semantic list/table markup; visible focus ring; role="dialog"/aria-modal on modals.

Visual audit loop

Matrix: screen × theme (light/dark) × viewport (390/768/1440) × state (default / row-selected / modal-open / reduced-motion).

  • Ad-hoc (agent-browser): scripts/ui-audit.sh boots the reference demo, walks the screens, and writes screenshots to the gitignored tmp/ui-audit/ (never priv/static/ — must not trip the bundle gate). Review the PNGs (or hand them to a multimodal model with this checklist as the rubric: accent overuse? faux-bold? non-flat shadow? off-grid spacing? contrast ≥ 4.5:1?).
  • CI regression net (Playwright): e2e/operator.spec.js is the committed gate. Because relative asset URLs leave direct loads unstyled (see below), the e2e asserts structure/order/data-testid/text — not pixels.

State is URL-driven on every screen, so any state is reproducible by URL (?tenant_id=…&delivery_id=…&theme=dark) — the audit script relies on this rather than on driving clicks.

Known limitations

  • Relative asset URLs + trailing slash. The CSS/font URLs are relative so the bundle resolves under any adopter mount path. The consequence: a page is only styled when the relative css-<md5> resolves to the operator mount root where the asset route lives. In practice the dashboard is entered at its mount root and navigated in-app (live navigation keeps the stylesheet loaded), so this is invisible in normal use — but a hard refresh on a deep URL can load unstyled. This is the asset-serving strategy (a stable seam), independent of the design system; fixing it robustly is a separate change.