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?).
  • 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 current tmp/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 :suppressed and blanket :badge-error for 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.
  • 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.

    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.