Color, Icons, and Contrast | LlamaPress Cookbook
LlamaPress
Cookbook
Design Stable

Color, Icons, and Contrast

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.

Proven on llamapress.ai admin dashboards view

Color, Icons, and Contrast

⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.

Color and icons are a preattentive budget: the eye spends them before the reader processes a single word. A single amber badge on a grey page is found instantly. Six hues at equal saturation cancel each other out — the "rainbow effect" — and the page reads slower than plain text. This guide is the spending discipline: every unit of color buys exactly one meaning, and no meaning ever depends on color alone.

When to use: whenever you add a color, a badge, an icon, a chart bar, or an up/down delta — and whenever a page looks like "badge soup" and you can't say why. When not to: brand/marketing pages where color is decoration, not information.


The 80/20 in one breath

  1. Grey is the default. Color is an exception that carries meaning. If you can't state a color's one meaning in a sentence, make it grey.
  2. Pick ≤3 semantic colors plus neutrals and write down what each means. Then never use a hue for anything else.
  3. Split the two color systems by channel: status color (needs a human / broken / moving up or down) lives in text, badges, and icons; entity color (which series/category is this) lives in chart marks only. They never share a channel, so "green = up" and "green = the Spreadsheets series" can coexist.
  4. Never encode by color alone. Every color is backed by an arrow, a sign, a word, an icon, or position. Greyscale-print the page: every value must still read.
  5. Name colors by role in one helper, never text-green-600 sprinkled in views.
  6. Meet the contrast floors: text 4.5:1, large text 3:1, and — the one everyone misses — meaningful icons and chart bars 3:1 (WCAG 1.4.11).

Layer 1 — Design tokens: colors named by role, in one place

One hue hard-coded in twelve views is un-auditable and un-changeable. Abstract color by role, the way design systems use tokens (danger, not red-60). One role → one class string → one file.

# app/helpers/design_tokens_helper.rb
module DesignTokensHelper
  # STATUS system — lives in text, badges, borders, icons. Never in chart marks.
  STATUS_TONES = {
    attention: "bg-amber-500 text-white",                       # needs a human NOW — ideally ONE per screen
    danger:    "bg-rose-50 text-rose-700 border border-rose-200", # broken / destructive
    up:        "text-emerald-700",                              # metric moving the right way — TEXT only, never a badge
    down:      "text-rose-700",                                 # metric moving the wrong way
    calm:      "bg-slate-100 text-slate-600"                    # everything informational
  }.freeze

  # ENTITY system — lives in chart marks (bars, segments, legend swatches) ONLY.
  # One stable hue per entity, identical on every screen, forever.
  ENTITY_COLORS = {
    "Spreadsheets" => "bg-emerald-500",   # semantic resonance: spreadsheets read as green
    "Documents"    => "bg-violet-500",    # distinct from green under deuteranopia
    "Other"        => "bg-slate-400",     # the residual bucket reads as neutral
    "All"          => "bg-indigo-500"     # the aggregate, not a real segment
  }.freeze

  def status_tone(tone)  = STATUS_TONES.fetch(tone)
  def entity_color(name) = ENTITY_COLORS.fetch(name, "bg-slate-400")
end

Why emerald/rose and not green/red: red–green is the exact pair lost to the most common color-vision deficiency (about 1 in 12 men). Emerald-600 and rose-600 sit near the color-blind-safe Okabe–Ito bluish-green/vermillion pair; pure green-500/red-500 do not.

Layer 2 — The View: redundant encoding for every colored value

The delta indicator is the worked example of "never color alone". Direction lives in the arrow, magnitude in the signed number, and color only accelerates what the glyphs already say. Greyscale it and nothing is lost.

<%# app/views/shared/_delta.html.erb — locals: value: (signed numeric change), baseline: %>
<% if value.positive? %>
  <span class="<%= status_tone(:up) %>" title="vs <%= baseline %> last period">
    <i class="fas fa-arrow-trend-up"></i> +<%= value %>
  </span>
<% elsif value.negative? %>
  <span class="<%= status_tone(:down) %>" title="vs <%= baseline %> last period">
    <i class="fas fa-arrow-trend-down"></i> <%= value %>
  </span>
<% else %>
  <span class="text-slate-500" title="vs <%= baseline %> last period">
    <i class="fas fa-minus"></i> no change
  </span>
<% end %>

Three rules visible in that snippet:

  • "No change" is a word, not an absent color. A bare grey 0 reads as missing data.
  • The green is text-emerald-700 — a text color, never a filled badge. A green badge would collide with the green entity bar it might sit next to.
  • The title puts the baseline on hover. Hover may hide elaboration of something visible ("vs 327 last period"); it may never hide the fact itself. A bare number whose movement exists only in a tooltip is a bug.

The one allowed crossover between the two systems: a direct label inside a chart may take its series color (labelling a bar "Spreadsheets" in the bar's own green kills a legend lookup). That text is part of the mark. Everywhere else, a number, badge, or arrow in body copy never takes an entity hue.

Layer 3 — Icons

Icon research is blunt, and the rules are short:

  • An icon never carries meaning alone. It repeats meaning that visible text already carries, or it sits beside a visible label. Hover-revealed labels don't exist on touch.
  • The 5-second rule: if it takes you more than 5 seconds to think of the right icon for a concept, no icon communicates it. Use a word.
  • One icon per meaning, app-wide. A second icon for the same idea is badge soup in another costume.

A minimal vocabulary that covers most admin screens (Font Awesome shown; swap inline SVG or plain glyphs — , , — if FA isn't loaded in your app):

Icon Means Notes
fa-arrow-trend-up / fa-arrow-trend-down metric rose / fell always beside the signed number
fa-minus no meaningful change
fa-triangle-exclamation needs a human (amber)
fa-circle-exclamation broken (red/rose)
fa-chevron-right, rotating on open a disclosure door the visible affordance collapsed content demands
fa-circle-info explanation available on hover and focus elaboration only, never the fact

Layer 4 — Contrast floors (WCAG 2.2 Level AA)

What Ratio Criterion
Normal text 4.5:1 1.4.3 Contrast (Minimum)
Large text (≥24 px, or ≥18.66 px bold) 3:1 1.4.3
Icons and chart marks that carry meaning, component boundaries, focus rings 3:1 against adjacent colors 1.4.11 Non-text Contrast

1.4.11 is the one that gets missed. It explicitly covers "lines in graphs, pie slices" and standalone icons. Concretely, in Tailwind terms:

  • A bg-gray-200 bar on a white card fails — the bar carries the value. Use gray-400 or darker for meaningful bars; keep gray-100 only for the empty track behind a fill (the track is decorative, the fill is not).
  • A pale badge (bg-amber-50) needs a border or dark text to reach 3:1 — the pale fill alone will not.

Verify, don't eyeball — light mid-greys and pastel ambers fail far more often than they look like they do. Any contrast-checker with the two hex values settles it.


Gotchas (the hard-won stuff)

  • divide-* silently repaints accent borders. Tailwind's divide-y divide-gray-100 emits a sibling rule that sets all four border sides on every row after the first — so a border-l-4 border-amber-600 accent renders amber on row 1 and grey on every row below, which reads as "only the first one is urgent". On any list whose rows carry an accent border, drop divide-* and use per-side utilities:

    <%# app/views/items/index.html.erb %>
    <ul class="border-t border-gray-100">            <%# not divide-y %>
      <li class="border-b border-b-gray-100 border-l-4
                 <%= flagged ? 'border-l-amber-600' : 'border-l-transparent' %>">
    
  • A correct color can render grey because the wrong stylesheet loaded. If a page can render under more than one layout (a fallback path, an email preview, an embedded view), the second layout may ship a different CSS bundle where your classes don't exist. Screenshot the page before believing a palette is applied.

  • Reserve the most saturated tone for the single most important thing. If two elements are both the loudest, neither is. One attention block per screen.

  • Roles are structural, not colored. "Theirs vs ours", "parent vs child" are roles — show them with indentation, position, or a subtle left border, like a chat transcript. Tinting one side blue and the other purple burns the hues you need for real alerts.

  • Blue means interactive, only. The moment a blue badge means a status, every link on the page loses its affordance.

  • The greyscale test is the acceptance test. Screenshot the page, drop it to greyscale, and confirm every state, direction, and value still reads. If anything vanishes, a color is carrying meaning alone.

  • Hover content must also appear on focus. A CSS-only group-hover: with no :focus-visible state locks out keyboard and screen-reader users. Pair title attributes with focusable elements.


Ship checklist

[ ] Grey is the default; I can state each color's ONE meaning in a sentence
[ ] Colors are named by ROLE in one helper, not hard-coded hues in views
[ ] Entity colors match every other screen showing the same entity
[ ] Marks carry identity; text/icons carry status — never the same channel
[ ] No meaning depends on color alone (arrow / sign / word / position backs it)
[ ] Greyscale screenshot: every value still readable
[ ] Text ≥ 4.5:1; meaningful icons and bars ≥ 3:1
[ ] Every icon repeats visible text or sits beside a label; one icon per meaning
[ ] Anything hidden on hover is elaboration, and also appears on focus
[ ] Squint test: exactly one thing on the page is loudest

Files this pattern touches

app/helpers/design_tokens_helper.rb       # STATUS_TONES + ENTITY_COLORS, the single source
app/views/shared/_delta.html.erb          # the redundant-encoded delta indicator

How to adapt to your schema

  1. Rename the entities in ENTITY_COLORS to your app's series/categories (plans, channels, product lines). Pick hues with semantic resonance where one exists (a brand's real color, "spreadsheets are green"), then never change them.
  2. Keep STATUS_TONES to the five roles shown. Resist adding a sixth — a new state almost always maps to attention, danger, or calm.
  3. If your app has no charts, you can drop ENTITY_COLORS entirely; the status system and the contrast floors still apply to every badge and icon.
  4. No Font Awesome? Swap the icon table for inline SVGs or text glyphs (▲ ▼ › !); the rules (label, one-per-meaning, 3:1) are unchanged.

Related