design-interface-polish

Micro-detail polish for emisar's rendered UI — the small craft that makes an interface feel finished: optical alignment, interruptible motion, icon cross-fades, image outlines, transition specificity,

SKILL.md

GitHub

Make interfaces feel better

Great interfaces are rarely one big thing — they're a pile of small details that compound. This is the micro-craft layer that sits under the macro art direction (design-creative-director, design-review) and the screen-level UX (design-ux): apply it when building or reviewing any rendered HEEx/Tailwind surface.

Adapted from Jakub Krehel's make-interfaces-feel-better skill (github.com/jakubkrehel/make-interfaces-feel-better) for emisar's stack: server-rendered HEEx + Tailwind v4, no React / framer-motion. Every framer-motion instruction below is translated to Tailwind utilities, CSS, or Phoenix.LiveView.JS.

What the design system already fixes — apply, don't restate

design-system.md §3 is the one home for the values; a polish pass checks the surface against it and cites the section, never a number from memory:

  • concentric radius, elevation on dark (surface step + a light ring, no hard borders), and the minimum hit area — §3.3
  • tabular numerals on anything that updates in place, text-balance / text-pretty — §3.2
  • the one easing, the shipped .rise-1…5 stagger, named transition properties (never transition-all), and the global reduced-motion block — §3.5
  • the global focus ring and selection — §3.6
  • press feedback (a slight scale-down on the marketing CTA) — the button primitives in §4

Console vs marketing — where motion is allowed

The polish details split by surface. Get this right or you'll fight design-ux:

  • Operator console (LiveView): calm. Decoration must mean something (design-ux). Animate only to show a real state change (queued→running, a row landing in audit) — never on hover/press for flourish. The static principles always apply here; the motion principles (exits, icon cross-fade) are used sparingly and only when they encode a real change.
  • Marketing pages (controllers/marketing_html/**): distinctive. Authored enter/exit motion, press feedback, and staggered reveals are welcome here when the creative direction calls for them — they must still have static meaning and survive reduced motion (which the global block guarantees).

Principles the rule leaves to craft

1. Optical over geometric alignment

When geometric centering looks off, align optically. A play ▸, a chevron, any asymmetric icon, and icon-plus-label buttons all usually need a manual nudge (pl-px, an asymmetric px) — trust the eye, not items-center alone.

2. Interruptible animations

Use CSS transitions for interactive state changes — they can be interrupted mid-flight when the state flips back. Reserve @keyframes for staged sequences that run once. For LiveView show/hide, use Phoenix.LiveView.JS.transition/show/hide (CoreComponents already do) rather than hand-rolled toggles.

3. Subtle exit animations (marketing)

Exits should be softer than enters: a small fixed translateY (~6–8px) and a fade, never collapsing full height. A loud exit feels broken.

4. Contextual icon swaps — cross-fade, don't toggle

When an icon changes (copy→check, menu→close, sun→moon), never flip hidden. We have no motion library, so use the dependency-free path: keep both icons in the DOM, one absolute-positioned over the other, and cross-fade with CSS transitions on opacity, scale, and blur — scale 0.25→1, opacity 0→1, blur 4px→0, the house easing. This gives both an enter and an exit for free.

5. Font smoothing — already done

-webkit-font-smoothing: antialiased is already on <body> (via the antialiased class in root.html.heex). Only re-check it if you introduce a new top-level layout.

6. Image outlines

Give images and screenshots a 1px low-opacity outline so they don't bleed into the surface. On our dark UI that's ring-1 ring-white/10 — pure white at 10%, never a tinted neutral (zinc/slate). A tinted ring picks up the surface under it and reads as dirt on the image edge.

7. Don't animate default-state elements

Enter animations belong on genuine entrances, not on things already on screen. In LiveView remember mount runs twice and re-renders re-trigger CSS animations — so keep enter-motion off persistent elements and put it only where a row truly arrives (a stream insert). On static marketing pages, on-load enters are intended; elsewhere they read as jank.

8. will-change sparingly

Only on transform, opacity, filter — the properties the GPU composites — and only when you actually see first-frame stutter. Never will-change: all; a permanent will-change wastes memory.

Common mistakes

Mistake Fix
Icon/label looks off-center Nudge optically; don't trust items-center alone (§1)
hidden-toggling a changing icon Cross-fade two stacked icons (§4)
Tinted ring on an image ring-white/10, pure white (§6)
Enter animation on a persistent LiveView element Motion only where a row arrives (§7)
A radius, shadow, numeral, easing, or hit-area value from memory Cite design-system §3
Decorative motion on the console Remove it — animate only real state change

Review output format

When reviewing, present changes as Before / After tables grouped by principle — include every change, not a subset, one diff per row so it scans. Cite the file:line and the exact property when it isn't obvious from the snippet. Omit a principle's table entirely if nothing needed to change (no empty tables).

Concentric border radius (design-system §3.3)

Before After
rounded-2xl card + rounded-2xl inner icon tile inner → rounded-lg (step down the scale; a child is never larger than its parent)

Icon swaps

Before After
copy icon hidden-toggled to a check both icons stacked, cross-faded

Checklist

  • Every token-level value checked against design-system §3, not memory
  • Icons optically centered, not just geometrically
  • Interactive state uses transitions; @keyframes only for one-shot sequences
  • Changing icons cross-fade (both in DOM), never hidden-toggle
  • Images carry a pure-white ring-white/10 outline
  • Enter motion only where an element truly arrives; none on persistent elements
  • will-change only on transform/opacity/filter, and only if stutter is real
  • Console motion encodes a real state change; marketing motion has static meaning