Color
Senco uses 7 color groups — primary, accent, info, neutral, and 3 status colors. Each one maps to a specific UI function, not decoration. All pairings pass WCAG 2.2 AA contrast checks in both light and dark mode.
Color in a transfer app carries load that color in a marketing site does not. A status color is the difference between "your money arrived" and "your money is stuck", and it has to survive a dimmed screen, a colorblind user, and a screenshot sent to support. Every rule below exists because one of those three broke something.
Palette
The base palette provides raw color values organized by function. Each group spans 10 shades from darkest (900) to lightest (50), used to differentiate contrast levels and visual hierarchy.
- Primary — Dark anchors for headings, text, and UI structure
- Accent — Brand blue for interactive elements, links, and focus states
- Info — Softer blue for informational feedback, distinct from brand accent
- Neutral — Gray scale for text hierarchy, borders, and surfaces
- Green / Yellow / Red — Status colors for success, warning, and error states
Why info is not accent
Both are blue, and separating them costs a whole palette group. The reason: on a transfer screen, accent blue means you can act on this and info blue means the system is telling you something. When they were the same hue, users tapped informational banners expecting them to do something. Info was pulled toward gray-blue (#3B82B8 at 600) until the two read as different roles at a glance.
Status colors are deliberately muted
Status values are one to two steps softer than their conventional equivalents — green-600 is #22915A rather than a saturated #16A34A, red-600 is #BB4A4A rather than #DC2626.
A transfer app shows status constantly. Every pending transaction, every rate alert, every verification step carries a status color. At full saturation the dashboard reads as an emergency. Muted status colors let a genuine error — a failed transfer, a rejected document — actually stand out, because it is not competing with eleven other colored chips.
All muted values were re-verified against AA after toning down. Softening never came out of the contrast budget.
Two neutral steps are off-ramp
| Token | Conventional | Senco | Why |
|---|---|---|---|
--ds-gray-500 | #6B7280 | #636971 | Muted text needed 4.5:1 on gray-100 surfaces, not just white |
--ds-gray-400 | #9CA3AF | #727780 | Placeholder text is text; #9CA3AF fails at 2.54:1 |
Placeholder contrast is the most common AA failure in shipped design systems, and it is invisible in review because the reviewer knows what the field is for. Fixing it in the palette means no component can reintroduce it.
Primary
Neutral / Gray
Accent / Blue
Green
Yellow
Red
Info
Semantic usage
Components reference semantic tokens, not raw palette values. This indirection allows theme switching and ensures consistent intent across the system.
| Token | Maps to | Purpose |
|---|---|---|
color-primary | {accent-600} | Brand, primary actions, links |
color-primary-hover | {accent-700} | Primary hover state |
color-primary-light | {accent-50} | Primary tinted backgrounds |
color-bg | {white} | Page background |
color-surface | {gray-100} | Card/section fill |
color-fg | {gray-700} | Body text |
color-heading | {primary-900} | Headings, strong text |
color-muted | {gray-500} | Secondary text, hints, labels |
color-border | {gray-200} | Dividers, input borders |
color-link | {accent-600} | Inline links |
color-focus | {accent-600} | Focus ring |
Surface layering model
Senco stacks at most three surface levels. The model is defined here once so that a card inside a sheet inside a dialog does not become an improvised gray.
| Level | Name | Light | Dark | Used for |
|---|---|---|---|---|
| 0 | Page | --ds-bg #FFFFFF | #0F172A | Page background |
| 1 | Raised | --ds-white | #1E293B | Cards, form cards, dialogs, sheets |
| 2 | Recessed | --ds-surface {gray-100} | #1E293B | Inset fills — search bars, code blocks, table headers |
Rules:
- Level 1 is raised by shadow in light mode, by lightness in dark mode. Light mode uses
--ds-shadow-xson white; dark mode cannot use shadow on near-black, so#1E293Bsits above#0F172Aby value instead. - Never stack two level-1 surfaces. A card inside a card means the inner one should be a level-2 inset or a divider.
- Borders do the work shadows cannot. At level 2 and in dark mode,
--ds-borderseparates surfaces. Elevation alone is not sufficient in dark mode and never in high-contrast mode. --ds-whiteis a semantic token, not a color. In dark mode it resolves to#1E293B. Anything that means literal white uses#FFFFFFdirectly — which in practice is only--ds-on-accent.
Feedback colors
Each feedback state uses a set of background, border, text, and icon tokens for consistent signaling:
| State | Background | Border | Text | Icon |
|---|---|---|---|---|
| Info | {info-50} | {info-400} | {info-800} | {info-700} |
| Success | {green-50} | {green-400} | {green-800} | {green-700} |
| Warning | {yellow-50} | {yellow-400} | {yellow-900} | {yellow-700} |
| Error | {red-50} | {red-400} | {red-800} | {red-700} |
| Tip | {accent-50} | {accent-400} | {accent-800} | — |
Warning text uses the 900 step where the others use 800. This is a visual weight decision, not a contrast one: yellow-800 (#854D0E) measures 6.46:1 on yellow-50 and passes AA comfortably. At 800 the warning text read lighter than the success and error text beside it in a stacked notice list, so it takes one more step down.
An earlier version of this file justified the 900 step by claiming 800 measured 4.3:1 and failed. It does not. The claim was plausible — yellow is the hue where AA is hardest — which is exactly why it survived unchallenged. Recording the correction here rather than quietly swapping the sentence.
For the full token architecture (base → semantic → state → component → platform → bridge), see Tokens.
| Semantic token | Base token | Resolved | |
|---|---|---|---|
| --ds-bg | --ds-white | ||
| --ds-fg | --ds-gray-700 | ||
| --ds-heading | --ds-primary-900 | ||
| --ds-muted | --ds-gray-500 | ||
| --ds-border | --ds-gray-200 | ||
| --ds-surface | --ds-gray-100 | ||
| --ds-primary | --ds-accent-600 | ||
| --ds-primary-hover | --ds-accent-700 | ||
| --ds-primary-light | --ds-accent-50 | ||
| --ds-success-bg | --ds-green-50 | ||
| --ds-success-border | --ds-green-400 | ||
| --ds-success-text | --ds-green-800 | ||
| --ds-warning-bg | --ds-yellow-50 | ||
| --ds-warning-border | --ds-yellow-400 | ||
| --ds-warning-text | --ds-yellow-900 | ||
| --ds-error-bg | --ds-red-50 | ||
| --ds-error-border | --ds-red-400 | ||
| --ds-error-text | --ds-red-800 | ||
| --ds-info-bg | --ds-info-50 | ||
| --ds-info-border | --ds-info-400 | ||
| --ds-info-text | --ds-info-800 |
Contrast verification
All color pairings are validated against WCAG 2.2 AA requirements: 4.5:1 for text, 3:1 for UI components. Every ratio below is computed from the token value that actually ships, not from a sampled screenshot.
Light mode (verified)
| Pairing | Foreground | Background | Ratio | Pass |
|---|---|---|---|---|
| Body text | #374151 | #FFFFFF | 10.31:1 | ✓ |
| Heading | #111827 | #FFFFFF | 17.74:1 | ✓ |
| Muted text | #636971 | #FFFFFF | 5.54:1 | ✓ |
| Muted on surface | #636971 | #F3F4F6 | 5.04:1 | ✓ |
| Primary link | #0F62FE | #FFFFFF | 5.00:1 | ✓ |
Placeholder (--ds-placeholder) | #727780 | #FFFFFF | 4.50:1 | ✓ |
| White on primary btn | #FFFFFF | #0F62FE | 5.00:1 | ✓ |
| White on destructive | #FFFFFF | #BB4A4A | 5.01:1 | ✓ |
| Info text | #1E4D8C | #F0F7FF | 7.80:1 | ✓ |
| Success text | #166534 | #F0FAF3 | 6.68:1 | ✓ |
| Warning text | #713F12 | #FDF8EA | 8.17:1 | ✓ |
| Error text | #922525 | #FDF3F3 | 7.67:1 | ✓ |
| Tip text | #002D9C | #F5F8FF | 10.65:1 | ✓ |
For dark mode contrast verification, see Themes.
Non-text contrast
Text is not the only thing that must clear a bar. WCAG 2.1 SC 1.4.11 requires 3:1 for UI components and meaningful graphics.
| Element | Foreground | Background | Ratio | Pass |
|---|---|---|---|---|
| Input border | {gray-200} #E5E7EB | #FFFFFF | 1.24:1 | ✗ — see below |
| Input border (focused) | #0F62FE | #FFFFFF | 5.00:1 | ✓ |
| Focus, bordered control — border | #0F62FE | #FFFFFF | 5.00:1 | ✓ — the border is the indicator |
| Focus, borderless control — outline | #0F62FE 3px | #FFFFFF | 5.00:1 | ✓ |
| Focus halo at 20% | #CFE0FF | #FFFFFF | 1.33:1 | — decoration, not the indicator |
| Status dot, success | #22915A | #FFFFFF | 3.99:1 | ✓ |
| Status dot, warning | #C07C14 | #FFFFFF | 3.42:1 | ✓ |
| Status dot, error | #BB4A4A | #FFFFFF | 5.01:1 | ✓ |
| Divider | {gray-200} | #FFFFFF | 1.24:1 | ✓ — decorative |
| Info border | {info-400} | {info-50} | 1.71:1 | ✗ — see below |
| Success border | {green-400} | {green-50} | 1.89:1 | ✗ — see below |
| Warning border | {yellow-400} | {yellow-50} | 1.82:1 | ✗ — see below |
| Error border | {red-400} | {red-50} | 2.40:1 | ✗ — see below |
The four feedback borders are a known and accepted deviation. SC 1.4.11 governs boundaries that are the only indicator of a component's presence or state. A feedback notice signals itself with a tinted background, a 700-step icon, and a text label, all of which clear their own thresholds — the 400-step border is a refinement on an already-identified surface, not the thing that identifies it. Holding the border to 3:1 would push it to the 600 step, at which point the notice reads as an outlined alert box rather than a tinted one, and the muted-status decision above gets undone. The trade is recorded rather than carried silently.
The resting input border at 1.24:1 is a second known and accepted deviation. SC 1.4.11 applies to controls whose boundary is the only indicator of their presence. Senco inputs always carry a persistent visible label above the field and a 48px filled control area, so the field is identifiable without its border. Darkening the border to gray-400 was tested and made dense forms — the contact address form has nine fields — read as a cage. The deviation is recorded rather than silently carried.
Color is never the only signal
Triple signaling is mandatory for every status in the system: color + icon + text.
| Status | Color | Icon | Text |
|---|---|---|---|
| Completed | {green-600} | Check | "Completed" |
| Pending | {yellow-600} | Clock | "Pending" |
| Failed | {red-600} | Alert triangle | "Failed" |
| Processing | {accent-600} | Spinner | "Processing" |
About 1 in 12 men has a color vision deficiency, and deuteranopia collapses exactly the green/yellow/red axis this app relies on. Under simulation, green-600 and yellow-600 converge to near-identical muddy tones. The icon and the label are what carry the meaning; the color is reinforcement. See Status indicators.
How contrast is verified
Verification is not a one-time audit. Every pairing in these tables is re-checked when a base token changes, because a single base edit cascades through the semantic layer into every component. Changing --ds-accent-600 invalidates the link, focus ring, primary button, and tip token rows at once.
/ BG: Usage guidelines
- Always use semantic tokens in components — never reference base palette directly
- Pair color with icon + text for status indicators (never color alone)
- Focus: bordered controls turn the border
--ds-focusand add a 20% halo; borderless controls take a 3px solid--ds-focusoutline with a 2px offset. See Tokens - Disabled states rely on both color and a visual cue, never color alone
Do and don't
| Do | Don't |
|---|---|
Use --ds-primary for the one action you want taken on a screen | Use brand blue for every clickable thing until nothing is primary |
Use --ds-error-* for states the user must resolve | Use red for a neutral "remove" affordance that is easily undone |
| Let a status chip carry icon and label alongside its color | Ship a bare colored dot as the only status indicator |
| Put green on a completed transfer | Put green on a submitted transfer — money has not moved yet |
Use --ds-surface for an inset region inside a card | Nest a second white card inside a white card |
Reference --ds-muted for helper text | Reach for gray-400 because the helper text "looks heavy" |
The submitted-versus-completed rule is the one that matters most. Green in a money app is read as done, and in cross-border transfer a submitted payment can still fail two days later. Submitted is --ds-info-*; completed is --ds-success-*.
Color on dark surfaces
Fixed-dark surfaces — camera viewfinder, QR scanner — do not follow the theme. They stay #111827 in both modes and use hard-coded light values for their overlays. A viewfinder that inverts in light mode costs the user their frame of reference and gains nothing. This is the one documented exception to semantic theming; it lives in Tokens.
Known gaps
| Item | Status |
|---|---|
| Resting input border at 1.24:1 | Accepted deviation, rationale above |
High-contrast mode (forced-colors) | Not implemented — system colors will override, untested |
| Chart palette | Bridged to --chart-1…5 from existing tokens; not validated for categorical distinguishability |
| Skeleton tokens | Not defined — skeletons currently use --ds-surface directly |
background: var(--ds-surface);
Semantic tokens adapt to themes automatically.
background: #F3F4F6;
Hardcoded hex breaks in dark mode and ignores token updates.
Semantic colors with icon + background reinforcement. Success is clear at a glance.
Colored text on white — no background, no icon. Easy to miss and inaccessible.
Heading at 17.7:1, muted at 5.5:1 — both pass AA. Financial data must be readable.
Light gray text fails contrast. Users can't read their own transfer summary.