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
- 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.
- Pick ≤3 semantic colors plus neutrals and write down what each means. Then never use a hue for anything else.
- 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.
- 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.
- Name colors by role in one helper, never
text-green-600sprinkled in views. - 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
0reads 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
titleputs 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-200bar on a white card fails — the bar carries the value. Usegray-400or darker for meaningful bars; keepgray-100only 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'sdivide-y divide-gray-100emits a sibling rule that sets all four border sides on every row after the first — so aborder-l-4 border-amber-600accent 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, dropdivide-*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
attentionblock 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-visiblestate locks out keyboard and screen-reader users. Pairtitleattributes 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
- Rename the entities in
ENTITY_COLORSto 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. - Keep
STATUS_TONESto the five roles shown. Resist adding a sixth — a new state almost always maps toattention,danger, orcalm. - If your app has no charts, you can drop
ENTITY_COLORSentirely; the status system and the contrast floors still apply to every badge and icon. - No Font Awesome? Swap the icon table for inline SVGs or text glyphs (
▲ ▼ › !); the rules (label, one-per-meaning, 3:1) are unchanged.