{"topic":"antipatterns","format":"markdown","content":"# Anti-patterns: the rules nobody writes down\n\nCheck these before anything cosmetic. They are defects that pass code review\nbecause each one looks intentional in isolation. They cluster around one root\ncause: **a signal is being sent that the interface can't honor.** A hover state\npromises clickability; a spinner promises progress; a cursor promises action.\nEvery unhonored promise erodes trust in the ones that are honored.\n\n## Affordance lies\n\n- **No hover states on non-interactive elements.** A background tint, lift, or\n  cursor change on a static card/row/box tells the user it's clickable. If\n  clicking does nothing, remove the hover treatment — or make the element\n  actually clickable. Corollary: every genuinely interactive element *must*\n  have a hover and press treatment; the absence is the same lie inverted.\n- **`cursor: pointer` only on things that act.** Not on static text, not on\n  disabled controls, not on a whole card when only its button is interactive.\n- **Never nest interactive in interactive.** A button inside a clickable card\n  (or a link inside a link) is invalid HTML and ambiguous for every input\n  method. Make the card's primary link cover the card (stretched-link\n  pattern) and lift secondary actions above it with `position: relative`.\n- **Hover must never be the only path to content or actions.** Touch devices\n  can't hover; a delete button revealed only on hover is invisible on mobile\n  and to keyboard users. Hover may *emphasize*; it may not *gate*.\n- **`title` attributes are not tooltips.** They don't appear on touch, on\n  focus, or reliably at all. If the information matters, render it.\n- **Text that looks like a link but isn't** (accent color, underline) and\n  links that look like plain text. The visual grammar of \"this is\n  interactive\" must be exclusive to interactive elements.\n\n## State-change honesty\n\n- **Transitions belong on the base state, not the `:hover` block.** Declared\n  only under `:hover`, the enter eases but the exit snaps. (The one sanctioned\n  asymmetry is the reverse: `:active { transition-duration: 0s }` so presses\n  read as instant while release eases back.)\n- **State changes must not reflow.** Bolding the active tab, growing a border,\n  or swapping to a wider label shifts every sibling. Reserve the space:\n  pre-render the boldest/widest state invisibly, use an inset ring or a\n  pre-allocated 2px transparent border, keep a button's label (and width)\n  while it's busy and shimmer it rather than swapping it for a spinner.\n- **Don't animate the focus ring, and never style bare `:focus` away.** Style\n  `:focus-visible`; the ring appears instantly or keyboard users lose their\n  place.\n- **Hover styles need `@media (hover: hover)`** when they'd misbehave on\n  touch — mobile browsers apply `:hover` sticky-on-tap, leaving cards \"stuck\"\n  highlighted after a scroll-tap.\n- **Selection must be visible.** A brand-tinted `::selection` below ~15–20%\n  alpha is indistinguishable from the page — users can't see what they\n  selected. Check the highlight against the background, not just the text\n  against the highlight.\n\n## Motion that betrays\n\n- **Reveals fire once.** Content that re-animates on every scroll-back turns\n  the page into a slideshow. Disconnect observers after first fire.\n- **Don't make returning users watch the intro.** Entrance choreography on\n  app chrome (nav, sidebars, dashboards users open 20× a day) taxes every\n  single visit. Reserve staged entrances for genuinely first-run or\n  marketing surfaces.\n- **Nothing moves without a cause the user can name.** Ambient looping motion\n  near reading content pulls the eye on every line. If asked \"why did that\n  move?\", the answer must never be \"to look alive.\"\n- **Skeletons advertise slowness.** A page of grey bars says \"this is slow\"\n  before anything has gone wrong. Hold the space the result will take, and\n  shimmer the label that names the work inside it. Under ~300ms, show nothing\n  at all — a flash of any indicator reads as jank, not responsiveness.\n- **Spinners as the default loading state.** A spinner says \"something\" and\n  nothing more. Shimmer the label that names the work;\n  keep spinners for a tiny indeterminate wait with no label.\n\n## Environmental blind spots\n\n- **`100vh` is broken on mobile.** Browser chrome makes `100vh` taller than\n  the visible screen; bottom-anchored content gets clipped. Use `100dvh`\n  (layout that may resize) or `100svh` (stable minimum).\n- **Fixed/sticky chrome must respect `env(safe-area-inset-*)`** — bottom bars\n  and FABs collide with home indicators and notches without it.\n- **Modals and drawers need `overscroll-behavior: contain`** or the page\n  behind scroll-chains when the user hits the end of the panel.\n- **Check every theme you ship.** A hardcoded `white`, a shadow tuned only\n  for light mode, a gray border invisible on dark surfaces — every visual\n  change is a two-theme change if two themes exist.\n- **Zoom to 200%.** Fixed heights on text containers clip at zoom and on\n  translation; use `min-height`. Never `user-scalable=no` or\n  `maximum-scale=1`.\n\n## Silent failures\n\n- **Every async action needs a failure branch.** A `.then()` with no\n  `.catch()` on clipboard/share/fetch means the button silently does nothing\n  in the failure case — the most trust-destroying state an interface has.\n  Feature-detect (`if (navigator.clipboard)`) and degrade visibly.\n- **Icon-only state changes are inaudible.** A copy button whose icon flips\n  to a checkmark tells screen-reader users nothing. Pair visual state changes\n  with a `role=\"status\"` live region (\"Copied\").\n- **Disabled without explanation is a dead end.** Prefer enabled +\n  validation message; if truly disabled, the *why* must be discoverable\n  without hovering.\n\n## Stacking & overflow debt\n\n- **No z-index arms race.** `z-index: 9999` means the stacking model is\n  broken. Keep a small documented ladder (dropdown 10, sticky 20, overlay 30,\n  modal 40, toast 50) and create stacking contexts deliberately with\n  `isolate`.\n- **`overflow: hidden` as a fix is usually a symptom.** It silently clips\n  focus rings, shadows, and dropdowns of every descendant. Find the actual\n  overflowing element; prefer `overflow: clip` on the axis that needs it. (Truncation is the exception: its fade needs `hidden`, since `clip` isn't a scroll container.)\n- **Scrollable regions need keyboard access.** An `overflow-x: auto` code\n  block or table with no focusable content can't be scrolled by keyboard in\n  all browsers — add `tabindex=\"0\"` with an accessible name, or let content\n  wrap instead.\n\n- **Tracking that switches ligatures off.** Any `letter-spacing` on text,\n  even −0.01em, turns off optional ligatures and contextual forms. The\n  system face tracks itself by size; leave it at `normal` (typography).\n- **Ellipsis, or a fade on text that fits.** Truncate with a fade at the\n  end of the line, applied only while the text is actually cut; a fade on a\n  label that fits reads as a rendering bug (typography).\n\n## Audit heuristic\n\nFor each element ask two questions: *what does its styling promise?* and\n*does interacting keep that promise?* Every mismatch — in either direction —\nis a defect, and these mismatches outrank cosmetic findings: a\nbroken promise is worse than a missing flourish.\n"}