{"slug":"color-icons-and-contrast","meta":{"title":"Color, Icons, and Contrast","slug":"color-icons-and-contrast","category":"Design","summary":"How to spend color and icons so a screen tells the eye where to look — grey by default, one meaning per color, status color in text/badges vs entity color in chart marks, never color alone, and the WCAG contrast floors including the 3:1 rule for chart bars.","tags":["design","color","icons","accessibility","wcag","contrast","badges","ux-default"],"status":"stable","visibility":"public","source_project":"llamapress.ai admin dashboards","layers":["view"],"related":[{"title":"Progressive Disclosure for Dense Detail Pages","url":"/cookbook/progressive-disclosure-detail-page","summary":"Run that guide FIRST — it decides WHAT goes on the screen and at which layer. This guide decides how the survivors look."},{"title":"Choosing the Right Chart","url":"/cookbook/choosing-the-right-chart","summary":"The third guide in the family — which chart answers which question. It defers to this guide for palettes and contrast."}]},"body":"# Color, Icons, and Contrast\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nColor and icons are a **preattentive budget**: the eye spends them before the reader\nprocesses a single word. A single amber badge on a grey page is found instantly. Six\nhues at equal saturation cancel each other out — the \"rainbow effect\" — and the page\nreads slower than plain text. This guide is the spending discipline: every unit of\ncolor buys exactly one meaning, and no meaning ever depends on color alone.\n\n\u003e **When to use:** whenever you add a color, a badge, an icon, a chart bar, or an\n\u003e up/down delta — and whenever a page looks like \"badge soup\" and you can't say why.\n\u003e **When not to:** brand/marketing pages where color is decoration, not information.\n\n---\n\n## The 80/20 in one breath\n\n1. **Grey is the default.** Color is an exception that carries meaning. If you can't\n   state a color's one meaning in a sentence, make it grey.\n2. **Pick ≤3 semantic colors plus neutrals** and write down what each means. Then never\n   use a hue for anything else.\n3. **Split the two color systems by channel:** *status* color (needs a human / broken /\n   moving up or down) lives in **text, badges, and icons**; *entity* color (which\n   series/category is this) lives in **chart marks only**. They never share a channel,\n   so \"green = up\" and \"green = the Spreadsheets series\" can coexist.\n4. **Never encode by color alone.** Every color is backed by an arrow, a sign, a word,\n   an icon, or position. Greyscale-print the page: every value must still read.\n5. **Name colors by role in one helper**, never `text-green-600` sprinkled in views.\n6. **Meet the contrast floors:** text 4.5:1, large text 3:1, and — the one everyone\n   misses — meaningful icons and chart bars **3:1** (WCAG 1.4.11).\n\n---\n\n## Layer 1 — Design tokens: colors named by role, in one place\n\nOne hue hard-coded in twelve views is un-auditable and un-changeable. Abstract color by\n**role**, the way design systems use tokens (`danger`, not `red-60`). One role → one\nclass string → one file.\n\n```ruby\n# app/helpers/design_tokens_helper.rb\nmodule DesignTokensHelper\n  # STATUS system — lives in text, badges, borders, icons. Never in chart marks.\n  STATUS_TONES = {\n    attention: \"bg-amber-500 text-white\",                       # needs a human NOW — ideally ONE per screen\n    danger:    \"bg-rose-50 text-rose-700 border border-rose-200\", # broken / destructive\n    up:        \"text-emerald-700\",                              # metric moving the right way — TEXT only, never a badge\n    down:      \"text-rose-700\",                                 # metric moving the wrong way\n    calm:      \"bg-slate-100 text-slate-600\"                    # everything informational\n  }.freeze\n\n  # ENTITY system — lives in chart marks (bars, segments, legend swatches) ONLY.\n  # One stable hue per entity, identical on every screen, forever.\n  ENTITY_COLORS = {\n    \"Spreadsheets\" =\u003e \"bg-emerald-500\",   # semantic resonance: spreadsheets read as green\n    \"Documents\"    =\u003e \"bg-violet-500\",    # distinct from green under deuteranopia\n    \"Other\"        =\u003e \"bg-slate-400\",     # the residual bucket reads as neutral\n    \"All\"          =\u003e \"bg-indigo-500\"     # the aggregate, not a real segment\n  }.freeze\n\n  def status_tone(tone)  = STATUS_TONES.fetch(tone)\n  def entity_color(name) = ENTITY_COLORS.fetch(name, \"bg-slate-400\")\nend\n```\n\nWhy `emerald`/`rose` and not `green`/`red`: red–green is the exact pair lost to the most\ncommon color-vision deficiency (about 1 in 12 men). Emerald-600 and rose-600 sit near\nthe color-blind-safe Okabe–Ito bluish-green/vermillion pair; pure green-500/red-500\ndo not.\n\n## Layer 2 — The View: redundant encoding for every colored value\n\nThe delta indicator is the worked example of \"never color alone\". Direction lives in\nthe **arrow**, magnitude in the **signed number**, and color only accelerates what the\nglyphs already say. Greyscale it and nothing is lost.\n\n```erb\n\u003c%# app/views/shared/_delta.html.erb — locals: value: (signed numeric change), baseline: %\u003e\n\u003c% if value.positive? %\u003e\n  \u003cspan class=\"\u003c%= status_tone(:up) %\u003e\" title=\"vs \u003c%= baseline %\u003e last period\"\u003e\n    \u003ci class=\"fas fa-arrow-trend-up\"\u003e\u003c/i\u003e +\u003c%= value %\u003e\n  \u003c/span\u003e\n\u003c% elsif value.negative? %\u003e\n  \u003cspan class=\"\u003c%= status_tone(:down) %\u003e\" title=\"vs \u003c%= baseline %\u003e last period\"\u003e\n    \u003ci class=\"fas fa-arrow-trend-down\"\u003e\u003c/i\u003e \u003c%= value %\u003e\n  \u003c/span\u003e\n\u003c% else %\u003e\n  \u003cspan class=\"text-slate-500\" title=\"vs \u003c%= baseline %\u003e last period\"\u003e\n    \u003ci class=\"fas fa-minus\"\u003e\u003c/i\u003e no change\n  \u003c/span\u003e\n\u003c% end %\u003e\n```\n\nThree rules visible in that snippet:\n\n- **\"No change\" is a word, not an absent color.** A bare grey `0` reads as missing data.\n- The green is `text-emerald-700` — a **text** color, never a filled badge. A green\n  badge would collide with the green entity bar it might sit next to.\n- The `title` puts the baseline on hover. Hover may hide **elaboration of something\n  visible** (\"vs 327 last period\"); it may never hide the fact itself. A bare number\n  whose movement exists only in a tooltip is a bug.\n\nThe one allowed crossover between the two systems: a **direct label inside a chart**\nmay take its series color (labelling a bar \"Spreadsheets\" in the bar's own green kills\na legend lookup). That text is part of the mark. Everywhere else, a number, badge, or\narrow in body copy never takes an entity hue.\n\n## Layer 3 — Icons\n\nIcon research is blunt, and the rules are short:\n\n- **An icon never carries meaning alone.** It repeats meaning that visible text already\n  carries, or it sits beside a visible label. Hover-revealed labels don't exist on touch.\n- **The 5-second rule:** if it takes you more than 5 seconds to think of the right icon\n  for a concept, no icon communicates it. Use a word.\n- **One icon per meaning, app-wide.** A second icon for the same idea is badge soup in\n  another costume.\n\nA minimal vocabulary that covers most admin screens (Font Awesome shown; swap inline\nSVG or plain glyphs — `›`, `▲`, `▼` — if FA isn't loaded in your app):\n\n| Icon | Means | Notes |\n|---|---|---|\n| `fa-arrow-trend-up` / `fa-arrow-trend-down` | metric rose / fell | always beside the signed number |\n| `fa-minus` | no meaningful change | |\n| `fa-triangle-exclamation` | needs a human (amber) | |\n| `fa-circle-exclamation` | broken (red/rose) | |\n| `fa-chevron-right`, rotating on open | a disclosure door | the visible affordance collapsed content demands |\n| `fa-circle-info` | explanation available on hover **and** focus | elaboration only, never the fact |\n\n## Layer 4 — Contrast floors (WCAG 2.2 Level AA)\n\n| What | Ratio | Criterion |\n|---|---|---|\n| Normal text | **4.5:1** | 1.4.3 Contrast (Minimum) |\n| Large text (≥24 px, or ≥18.66 px bold) | **3:1** | 1.4.3 |\n| **Icons and chart marks that carry meaning**, component boundaries, focus rings | **3:1** against adjacent colors | **1.4.11 Non-text Contrast** |\n\n**1.4.11 is the one that gets missed.** It explicitly covers \"lines in graphs, pie\nslices\" and standalone icons. Concretely, in Tailwind terms:\n\n- A `bg-gray-200` bar on a white card **fails** — the bar carries the value. Use\n  `gray-400` or darker for meaningful bars; keep `gray-100` only for the empty track\n  *behind* a fill (the track is decorative, the fill is not).\n- A pale badge (`bg-amber-50`) needs a **border or dark text** to reach 3:1 — the pale\n  fill alone will not.\n\nVerify, don't eyeball — light mid-greys and pastel ambers fail far more often than they\nlook like they do. Any contrast-checker with the two hex values settles it.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **`divide-*` silently repaints accent borders.** Tailwind's `divide-y divide-gray-100`\n  emits a sibling rule that sets **all four** border sides on every row after the first —\n  so a `border-l-4 border-amber-600` accent renders amber on row 1 and grey on every row\n  below, which reads as \"only the first one is urgent\". On any list whose rows carry an\n  accent border, drop `divide-*` and use per-side utilities:\n\n  ```erb\n  \u003c%# app/views/items/index.html.erb %\u003e\n  \u003cul class=\"border-t border-gray-100\"\u003e            \u003c%# not divide-y %\u003e\n    \u003cli class=\"border-b border-b-gray-100 border-l-4\n               \u003c%= flagged ? 'border-l-amber-600' : 'border-l-transparent' %\u003e\"\u003e\n  ```\n\n- **A correct color can render grey because the wrong stylesheet loaded.** If a page can\n  render under more than one layout (a fallback path, an email preview, an embedded\n  view), the second layout may ship a different CSS bundle where your classes don't\n  exist. Screenshot the page before believing a palette is applied.\n- **Reserve the most saturated tone for the single most important thing.** If two\n  elements are both the loudest, neither is. One `attention` block per screen.\n- **Roles are structural, not colored.** \"Theirs vs ours\", \"parent vs child\" are roles —\n  show them with indentation, position, or a subtle left border, like a chat transcript.\n  Tinting one side blue and the other purple burns the hues you need for real alerts.\n- **Blue means interactive, only.** The moment a blue badge means a status, every link\n  on the page loses its affordance.\n- **The greyscale test is the acceptance test.** Screenshot the page, drop it to\n  greyscale, and confirm every state, direction, and value still reads. If anything\n  vanishes, a color is carrying meaning alone.\n- **Hover content must also appear on focus.** A CSS-only `group-hover:` with no\n  `:focus-visible` state locks out keyboard and screen-reader users. Pair `title`\n  attributes with focusable elements.\n\n---\n\n## Ship checklist\n\n```\n[ ] Grey is the default; I can state each color's ONE meaning in a sentence\n[ ] Colors are named by ROLE in one helper, not hard-coded hues in views\n[ ] Entity colors match every other screen showing the same entity\n[ ] Marks carry identity; text/icons carry status — never the same channel\n[ ] No meaning depends on color alone (arrow / sign / word / position backs it)\n[ ] Greyscale screenshot: every value still readable\n[ ] Text ≥ 4.5:1; meaningful icons and bars ≥ 3:1\n[ ] Every icon repeats visible text or sits beside a label; one icon per meaning\n[ ] Anything hidden on hover is elaboration, and also appears on focus\n[ ] Squint test: exactly one thing on the page is loudest\n```\n\n## Files this pattern touches\n\n```\napp/helpers/design_tokens_helper.rb       # STATUS_TONES + ENTITY_COLORS, the single source\napp/views/shared/_delta.html.erb          # the redundant-encoded delta indicator\n```\n\n## How to adapt to your schema\n\n1. Rename the entities in `ENTITY_COLORS` to **your** app's series/categories (plans,\n   channels, product lines). Pick hues with semantic resonance where one exists (a\n   brand's real color, \"spreadsheets are green\"), then never change them.\n2. Keep `STATUS_TONES` to the five roles shown. Resist adding a sixth — a new state\n   almost always maps to `attention`, `danger`, or `calm`.\n3. If your app has no charts, you can drop `ENTITY_COLORS` entirely; the status system\n   and the contrast floors still apply to every badge and icon.\n4. No Font Awesome? Swap the icon table for inline SVGs or text glyphs (`▲ ▼ › !`);\n   the rules (label, one-per-meaning, 3:1) are unchanged.\n"}