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?). - Before/after LLM-critique ritual: The Phase 74 baseline represents the
pre-v1.7 state (PNG set captured to the maintainer's local
tmp/ui-audit/at baseline time, or regenerated from the Phase 74 git state — do not commit either set). To run a comparison: open the Phase 74 baseline PNGs alongside the currenttmp/ui-audit/run, then supply both sets to a multimodal model with the 6-pillar rubric above (Spacing/size, Radius, Color, Type, Elevation, Motion/A11y) as the scoring framework. Ask for a per-pillar before/after score and a list of remaining issues if any. The following GAP rows should show visible improvement in the comparison:- GAP-01/03/05/06 — badge color consistency: colors now come from
Components.status_badge/1; phantom:suppressedand blanket:badge-errorfor all replay types should be absent. - GAP-13 — support-card hierarchy: Tier 1 full cards for non-zero/actionable states; Tier 2 compact border-t row for zero states.
- GAP-07 — 390px orientation strip visible on the deliveries surface.
- GAP-21 — single
h1"Operator overview" heading on the landing screen. - IA note:
/ops/mail/now lands on the Operator Overview, not the Deliveries list. A reviewer comparing deliveries-at-landing screenshots should expect a different page — this is intentional, not a regression.
- GAP-01/03/05/06 — badge color consistency: colors now come from
- 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.GAP-22 disposition (Phase 75 / IA-04): The deep-link-unstyled-CSS behavior described above is tracked as GAP-22 and deferred to Phase 79 (VERIF-04). A robust fix touches the stable asset-serving seam (the relative
css-<md5>URL resolves against the deep path on hard refresh, not the mount root). This seam is out of churn scope for v1.7. The bug affects only hard refreshes on deep URLs; normal in-app live navigation is unaffected because live navigation keeps the stylesheet loaded. GAP-22 is held at severity 3 — it does not block Phase 79 closeout before the decision is reconfirmed there.