Skip to main content
ENKO

Themes

Senco ships two themes: light and dark. A theme is not a second design system — it is a remap of the semantic layer. The base palette, the spacing scale, the type scale, and every component contract are identical in both.

This is the property that makes dark mode maintainable. A new component written against semantic tokens is dark-mode-correct the day it ships, because it never named a color in the first place.

Available themes

ThemeScope selectorPage backgroundDefault
Light.ds-layout#FFFFFF✓
Dark.ds-layout.ds-dark#0F172A—

A third scope, .senco-prototype-scope, pins light values inside device-frame prototypes so a dark documentation page cannot leak its theme into a mobile mockup. It is a platform target, not a theme — see Tokens.

How themes work

Themes live in the Platform layer, the fifth of six token layers. The rules are narrow on purpose:

  1. A theme may only remap tokens that already exist. Introducing a token inside a theme block is a defect — it means one theme has a capability the other does not.
  2. A theme remaps the semantic layer, not the base layer. --ds-accent-600 is #0F62FE in both themes; what changes is that --ds-primary stops pointing at it.
  3. Component tokens inherit for free. --ds-actionbtn-primary-bg resolves to var(--ds-primary) and needs no dark-mode entry.
  4. Components never branch on theme. There is no isDark in any component. If a component needs to know the theme, a token is missing.

What actually changes

CategoryRemapped in dark?Why
Semantic colorYesThe entire point
Component color tokensOnly where the semantic remap is insufficientSidebar, code, table, radio card
Spacing, sizing, radiusNoLayout does not change with light level
Type scale, weightsNoSame reading task
MotionNoHandled by prefers-reduced-motion, not by theme
ElevationYes, in behaviorShadow stops working on near-black; lightness takes over

Elevation inverts

In light mode, a raised surface is white with a shadow. In dark mode a shadow is invisible against #0F172A, so raised surfaces get lighter instead: #1E293B sits above #0F172A by value. The --ds-shadow-* tokens remain applied but carry almost none of the signal, and --ds-border does the rest of the work.

This is why --ds-white is a semantic token rather than a literal. In dark mode it resolves to #1E293B — a card still calls for "white", and the platform decides what that means.

Side-by-side theme comparison
Light theme
JD
Fatima B.
Transfer to USD wallet
$120.00
Completed
Dark theme
JD
Fatima B.
Transfer to USD wallet
$120.00
Completed

Token remapping

The dark theme remaps 60+ tokens. The semantic core:

TokenLightDark
--ds-bg#FFFFFF#0F172A
--ds-surface{gray-100}#1E293B
--ds-white#FFFFFF#1E293B
--ds-fg{gray-700}#CBD5E1
--ds-heading{primary-900}#F1F5F9
--ds-muted{gray-500}#94A3B8
--ds-border{gray-200}#334155
--ds-primary#0F62FE#458DFF
--ds-primary-hover#0043CE#3079FF
--ds-primary-light{accent-50}#0F2847
--ds-link#0F62FE#458DFF
--ds-focus#0F62FE#458DFF
--ds-disabled-bg{gray-300}#334155
--ds-disabled-text{gray-500}#7E8EA3
--ds-on-accent#FFFFFF#0F172A
--ds-placeholder{gray-400}#8391A6
--ds-overlay{gray-900} at 10%{gray-900} at 30%

Why brand blue lightens

#0F62FE on #0F172A measures 3.57:1 — below the 4.5:1 required for text. Brand blue as a link color would fail on the dark page. #458DFF measures 5.53:1 and passes.

The base token is untouched. --ds-accent-600 is still #0F62FE everywhere, including in the palette documentation and in Figma. Only the semantic alias moves. Anyone reading the brand spec still sees one brand blue.

Feedback sets

Feedback backgrounds invert from tinted-light to deep-tinted-dark, and the text takes the light step from the same hue:

StateLight bg / textDark bg / text
Info{info-50} / {info-800}#0C2340 / {info-400} #93C5E8
Success{green-50} / {green-800}#0A2E1A / {green-300} #A3DFBA
Warning{yellow-50} / {yellow-900}#3D2008 / {yellow-300} #F0CE70
Error{red-50} / {red-800}#3D1212 / {red-300} #EBADAD

Borders stay on the 400 step in both themes. A green-400 border reads correctly on green-50 and on #0A2E1A, which removes four tokens from the remap and one class of drift.

Icons do not. They sit on the 700 step in light and the 400 step in dark. Left at 700 on a dark feedback background they measure 2.50–3.03:1, under the 3:1 a meaningful graphic needs.

The fixed-dark exception

Camera viewfinder and QR scanner surfaces do not remap. They are #111827 in both themes with hard-coded light overlays. Inverting a viewfinder in light mode costs the user their frame of reference and gains nothing. This is the system's one sanctioned theme exception, and it is recorded here so it is not "fixed" by a well-meaning later pass.

Live token remap table
TokenLightDark
--ds-bg
...white
...navy-900
--ds-fg
...gray-700
...primary-300
--ds-heading
...primary-900
...primary-100
--ds-muted
...gray-500
...primary-400
--ds-border
...gray-200
...primary-700
--ds-surface
...gray-100
...primary-800
--ds-white
...#FFFFFF
...#FFFFFF
--ds-on-accent
...#FFFFFF
...#FFFFFF
--ds-primary
...accent-600
...accent-500
--ds-primary-hover
...accent-700
...accent-400
--ds-primary-light
...accent-50
...accent-900
--ds-primary-900
...gray-900
...gray-900
--ds-link
...accent-600
...accent-500
--ds-focus
...accent-600
...accent-500
--ds-disabled-bg
...gray-200
...gray-700
--ds-disabled-text
...gray-400
...gray-500
--ds-info-bg
...info-50
...#0C2340
--ds-info-border
...info-200
...info-700
--ds-info-text
...info-800
...#93C5E8
--ds-success-bg
...green-50
...#0A2E1A
--ds-success-border
...green-200
...green-700
--ds-success-text
...green-800
...#A3DFBA
--ds-warning-bg
...yellow-50
...#3D2008
--ds-warning-border
...yellow-200
...yellow-700
--ds-warning-text
...yellow-900
...#F0CE70
--ds-error-bg
...red-50
...#3D1212
--ds-error-border
...red-200
...red-700
--ds-error-text
...red-800
...#EBADAD

Switching themes

The theme is applied by toggling ds-dark on the .ds-layout element. Because every value resolves through CSS custom properties, the switch is a single class change with no re-render and no flash.

ModeBehavior
Class toggle.ds-layout.ds-dark — explicit user choice, persisted
System preferenceHonored on first load via prefers-color-scheme

The dark palette is painted before first paint rather than after hydration. A transfer app that flashes white at 6am is a worse experience than one with no dark mode at all.

Design guidelines

  • Design in light mode first, then verify dark. Light mode is the harder contrast problem because the background has nowhere to go.
  • Never hard-code a color in a component. If you typed a hex value, the theme cannot reach it.
  • Do not use pure black (#000000) or pure white for dark-mode surfaces. #0F172A and #F1F5F9 reduce halation on OLED without losing separation.
  • Raise by lightness in dark mode, not by shadow.
  • Re-check every status color after a dark change. Status is the one place where a small contrast slip becomes a user reading "completed" as "pending".
  • Screenshots in documentation are taken in both themes. A component that was only ever reviewed in light mode has not been reviewed.

Do and don't

DoDon't
Remap --ds-primary and let components inheritAdd a dark-mode override to a component
Keep spacing and type identical across themes"Tighten" dark mode because it looks heavier
Let --ds-white mean level-1 surfaceWrite #FFFFFF when you mean a card
Use --ds-border to separate dark surfacesRely on a shadow that is invisible on #0F172A
Test status chips in both themesAssume a passing light ratio implies a passing dark one
Do / Don't: semantic tokens vs hardcoded values
Do
color: var(--ds-fg);
Light: readable
Dark: readable

Use semantic tokens. They adapt to both themes automatically.

Don't
color: #374151;
Light: readable
Dark: invisible

Hardcoded hex values break in the opposite theme. gray-700 on dark bg = 2.5:1 contrast (fail).

Accessibility

Both themes target WCAG 2.2 AA. Ratios below are measured, not estimated.

Dark mode (verified)

PairingForegroundBackgroundRatioPass
Body text#CBD5E1#0F172A12.02:1✓
Body on surface#CBD5E1#1E293B9.85:1✓
Heading#F1F5F9#0F172A16.30:1✓
Muted text#94A3B8#0F172A6.96:1✓
Muted on surface#94A3B8#1E293B5.71:1✓
Primary link#458DFF#0F172A5.53:1✓
Primary link on surface#458DFF#1E293B4.53:1✓
Disabled text#7E8EA3#0F172A5.34:1✓
Info text#93C5E8#0C23408.57:1✓
Success text#A3DFBA#0A2E1A9.74:1✓
Warning text#F0CE70#3D20089.77:1✓
Error text#EBADAD#3D12128.59:1✓
Status badge, Pending#F0CE70#3D20089.77:1✓
Status badge, Sent / Processing#93C5E8#0C23408.57:1✓
Status badge, Completed#A3DFBA#0A2E1A9.74:1✓
Status badge, Failed#EBADAD#3D12128.59:1✓
Status badge, Cancelled#94A3B8#1E293B5.71:1✓
Disabled button label on its fill#7E8EA3#3341553.10:1Exempt — SC 1.4.3
Tip text#A6C8FF#0F28478.71:1✓
Sidebar active link#458DFF#0F28474.60:1✓
Label on primary button#0F172A#458DFF5.53:1✓
Label on primary button, hover#0F172A#3079FF4.51:1✓
Placeholder#8391A6#0F172A5.58:1✓
Placeholder on surface#8391A6#1E293B4.57:1✓

Resolved — dark primary button label

--ds-on-accent is remapped to #0F172A in dark mode. Before the remap it stayed #FFFFFF while --ds-primary lightened to #458DFF, and the label measured 3.23:1 at rest and 3.96:1 on hover — below the 4.5:1 a 14px semibold label needs.

StateLabelFillRatio
Rest#0F172A#458DFF5.53:1 ✓
Hover#0F172A#3079FF4.51:1 ✓

Hover clears the bar by 0.01. Any future change to --ds-primary-hover in dark mode must be re-measured against this pair before it ships.

The rejected alternative — darkening --ds-primary to #1E6BFF and keeping the white label (4.56:1) — pushed the button back toward the page: 3.91:1 against #0F172A, where the remap keeps it at 5.53:1. It would have weakened the one element the screen exists for.

Other checks

  • Focus ring uses color-mix() against --ds-focus, so it re-derives correctly in dark mode without a separate token.
  • Reduced motion is handled at the Platform layer independently of theme.
  • forced-colors / Windows high-contrast mode is not implemented or tested in either theme.
  • Both themes are verified against the same pairing list, so a light-mode regression cannot pass by only checking dark.