# mailglass_admin Design System
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.
| 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](https://emilkowal.ski/ui/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.