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
@themeblock and:roottokens. Components compose those tokens — they never hardcode hex, pixels, or durations.
CSS architecture
- Tailwind v4 + daisyUI, compiled by the standalone
tailwindHex binary — zero Node toolchain. Source:assets/css/app.css. Build:mix mailglass_admin.assets.build. - The minified bundle
priv/static/app.cssis committed. CI runs the build thengit diff --exit-code priv/static/— any edit toapp.cssor 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.
| Semantic | Light (brand) | Use |
|---|---|---|
base-100 | Paper #F8FBFD | app/detail surface |
base-200 | Mist #EAF6FB | cards, list/filter panes, sidebar |
base-300 | Ice #A6EAF2 | borders, hover/active |
base-content | Ink #0D1B2A | primary text |
primary | Glass #277B96 | accent — use sparingly |
secondary | Slate #5C6B7A | secondary text/meta |
success / warning / error | Pine / Amber / Crimson | status 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.
| Scale | Tokens | Utilities |
|---|---|---|
| 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/overlay | shadow-overlay (modals only) |
| Easing | --ease-out, --ease-in-out | ease-out |
| Motion (≤300ms) | --duration-instant/fast/reveal/flash | duration-(--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-10…z-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.
| Motion | Class / mechanism | Where |
|---|---|---|
| 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-mounts | preview tabs, modal backdrop |
| overlay | .motion-overlay (scale 0.98→1 + opacity, 220ms) | modal panels |
| row-state | transition-colors duration-(--duration-fast) | list rows, nav, tabs |
| flash | .motion-reveal on toast | flash 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-gridgap-1.5. Touch targets ≥min-h-11(44px). - Radius:
rounded-box/rounded-fieldonly (theme-driven). - Color: semantic tokens + opacity tints only; no hex, no raw palette; accent obeys the 10% rule.
- Type:
text-label/body/heading/display; weightfont-boldor default only (no faux-bold). - Elevation/stacking: border +
shadow-flat;shadow-overlayfor modals only;z-*from the named tier (no ad-hocz-50except 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-modalon 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.shboots the reference demo, walks the screens, and writes screenshots to the gitignoredtmp/ui-audit/(neverpriv/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.jsis 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.