Skip to main content
Executive Support by Beige Threat

Modes

Light is the default. Dark is for late-night reading on a phone, an archive scrolled at midnight, a long interview opened after the kitchen is shut. Same brand, same words, same photographs – translated for a darker room.

Components never know which mode they’re in. They bind to semantic names – --es-color-surface-page, --es-color-text-primary, --es-color-border-subtle – and the mode swap rebinds those names in a single block at [data-theme="dark"]. The primitive ramps stay constant. Only the bindings flip.

DefaultLight
Switchdata-theme on <html>
StoragelocalStorage['es-theme'] with system fallback
ContractSemantic tokens only – never primitives
ContrastWCAG 2.2 AA in both modes

Mapping

Four families flip between modes: surface, content, border, feedback. Each row below names a semantic token, the primitive it resolves to in light, and the primitive it resolves to in dark. Components reach for the left column. The right two columns are the system’s job.

Surface — the canvas, raised cards, sunken wells
--es-color-surface-page
paper → navy-950

Body background. The stage the design sits on.

#FDFDFD
paper
#101622
navy-950
--es-color-surface-raised
white → navy-900

Cards, dialogs, popovers. One step above the page.

#FFFFFF
white
#181F2C
navy-900
--es-color-surface-sunken
cream → navy-800

Sidebars, alt sections, table headers.

#E3E4DC
cream
#3C434E
navy-800
--es-color-surface-muted
stone-100 → navy-700

Inset wells, code blocks, tertiary panels.

#F6F3EC
stone-100
#585E68
navy-700
Content — text and icon colour on those surfaces
--es-color-text-primary
ink → paper

Headlines, body, key data.

Aa primary
Aa primary
--es-color-text-secondary
stone-700 → stone-300

Captions, supporting copy, helper text.

Aa secondary
Aa secondary
--es-color-text-tertiary
stone-600 → stone-500

Metadata, timestamps, count badges.

Aa tertiary
Aa tertiary
--es-color-text-onInverse
paper → paper

Text drawn on the inverse surface. Stays paper in both modes – the surface flips, the foil doesn't.

Aa on inverse
Aa on inverse
--es-color-text-link
teal-700 → teal-400

Hyperlinks. Lifts in dark to clear the contrast bar.

Read the issue
Read the issue
Border — hairlines, control outlines, focus rings
--es-color-border-subtle
stone-200 → navy-800

Card edges, dividers, table rules.

--es-color-border-default
stone-300 → navy-700

Inputs, buttons, control outlines.

--es-color-border-strong
stone-500 → stone-400

Emphasis borders, separators in dense UI.

--es-color-border-focus
teal-600 → teal-500

Focus ring. Locked to brand teal at full saturation in both modes.

Feedback — paired surface and text per status
--es-color-feedback-success-{surface,text}
sage-100 / sage-900 → sage-800 / sage-100

Confirmation, completion, success.

Saved.
Saved.
--es-color-feedback-warning-{surface,text}
mustard-100 / mustard-900 → mustard-800 / mustard-100

Attention. The system needs you, not now.

Check the date.
Check the date.
--es-color-feedback-danger-{surface,text}
red-100 / red-900 → red-900 / red-100

Errors, destructive confirmations.

Couldn’t send.
Couldn’t send.
--es-color-feedback-info-{surface,text}
sky-100 / navy-900 → sky-800 / sky-100

Neutral notice, not actionable.

Issue 88 is queued.
Issue 88 is queued.

Mode bootstrap

Set data-theme on <html> before paint, or the page flashes light, then dark, on every refresh. The script is small enough to inline in the <head> – it reads a saved preference, falls back to the OS setting, and sets the attribute synchronously.

<!-- In <head>, before any stylesheet links -->
<script>
  (function () {
    var saved = localStorage.getItem('es-theme');
    var sys = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    document.documentElement.setAttribute('data-theme', saved || sys);
  })();
</script>
<link rel="stylesheet" href="/styleguide/tokens/color.css" />

That’s the whole bootstrap. Ten lines, no framework, no FOUC. The toggle UI elsewhere on the page writes to localStorage['es-theme'] ('light', 'dark', or removes the key for “system”).

function setTheme(value) {
  if (value === 'system') {
    localStorage.removeItem('es-theme');
    var sys = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    document.documentElement.setAttribute('data-theme', sys);
  } else {
    localStorage.setItem('es-theme', value);
    document.documentElement.setAttribute('data-theme', value);
  }
}

Token bindings

The light bindings already live at :root in color.css. Dark is one additional block, scoped to [data-theme="dark"]. Primitives (--es-color-teal-600, --es-color-navy-900, the ramps) are not redeclared – only semantic names rebind.

/* Light is the default — already shipped in color.css */
:root {
  --es-color-surface-page:    var(--es-color-paper);
  --es-color-surface-raised:  var(--es-color-white);
  --es-color-surface-sunken:  var(--es-color-cream);
  --es-color-surface-muted:   var(--es-color-stone-100);
  --es-color-surface-inverse: var(--es-color-navy-900);

  --es-color-text-primary:    var(--es-color-ink);
  --es-color-text-secondary:  var(--es-color-stone-700);
  --es-color-text-tertiary:   var(--es-color-stone-600);
  --es-color-text-onInverse:  var(--es-color-paper);
  --es-color-text-link:       var(--es-color-teal-700);

  --es-color-border-subtle:   var(--es-color-stone-200);
  --es-color-border-default:  var(--es-color-stone-300);
  --es-color-border-strong:   var(--es-color-stone-500);
  --es-color-border-focus:    var(--es-color-teal-600);

  --es-color-feedback-success-surface: var(--es-color-sage-100);
  --es-color-feedback-success-text:    var(--es-color-sage-900);
  --es-color-feedback-warning-surface: var(--es-color-mustard-100);
  --es-color-feedback-warning-text:    var(--es-color-mustard-900);
  --es-color-feedback-danger-surface:  var(--es-color-red-100);
  --es-color-feedback-danger-text:     var(--es-color-red-900);
  --es-color-feedback-info-surface:    var(--es-color-sky-100);
  --es-color-feedback-info-text:       var(--es-color-navy-900);
}

/* Dark — one block, semantic rebindings only */
[data-theme='dark'] {
  --es-color-surface-page:    var(--es-color-navy-950);
  --es-color-surface-raised:  var(--es-color-navy-900);
  --es-color-surface-sunken:  var(--es-color-navy-800);
  --es-color-surface-muted:   var(--es-color-navy-700);
  --es-color-surface-inverse: var(--es-color-teal-600);

  --es-color-text-primary:    var(--es-color-paper);
  --es-color-text-secondary:  var(--es-color-stone-300);
  --es-color-text-tertiary:   var(--es-color-stone-500);
  --es-color-text-onInverse:  var(--es-color-paper);
  --es-color-text-link:       var(--es-color-teal-400);

  --es-color-border-subtle:   var(--es-color-navy-800);
  --es-color-border-default:  var(--es-color-navy-700);
  --es-color-border-strong:   var(--es-color-stone-400);
  --es-color-border-focus:    var(--es-color-teal-500);

  --es-color-feedback-success-surface: var(--es-color-sage-800);
  --es-color-feedback-success-text:    var(--es-color-sage-100);
  --es-color-feedback-warning-surface: var(--es-color-mustard-800);
  --es-color-feedback-warning-text:    var(--es-color-mustard-100);
  --es-color-feedback-danger-surface:  var(--es-color-red-900);
  --es-color-feedback-danger-text:     var(--es-color-red-100);
  --es-color-feedback-info-surface:    var(--es-color-sky-800);
  --es-color-feedback-info-text:       var(--es-color-sky-100);
}

Surface ladder

Four surfaces, side by side. The same hierarchy reads in both modes – page sits behind, muted is the gentle inset, raised lifts a card off the page, sunken pushes a panel below it.

data-theme=“light”
page · paper
muted · stone-100
raised · white + shadow
sunken · cream
data-theme=“dark”
page · navy-950
muted · navy-700
raised · navy-900
sunken · navy-800

Content ladder

Three weights of text – primary for the headline, secondary for the supporting line, tertiary for the timestamp. Inverse sits on the surface-inverse fill; it stays paper-coloured in both modes because the surface itself flips.

data-theme=“light”
Issue 87 · Leaders

The interview: how Marcia turned an executive office into a strategic operations function.

12 min read · 7 May 2026
Open the issue
data-theme=“dark”
Issue 87 · Leaders

The interview: how Marcia turned an executive office into a strategic operations function.

12 min read · 7 May 2026
Open the issue

Feedback in dark mode

Each status keeps its hue – success is sage, danger is red, warning is mustard, info is sky – but the surface drops to the 800/900 stop and the text rises to the 100 stop. Saturation never changes. The status reads as the same status, only translated.

data-theme=“light”
Saved. Drafts go to your inbox at the end of the day.
Check the publish date – the interview is dated 2025.
Couldn’t send. Check your network and try again.
Issue 88 is queued for 14 May.
data-theme=“dark”
Saved. Drafts go to your inbox at the end of the day.
Check the publish date – the interview is dated 2025.
Couldn’t send. Check your network and try again.
Issue 88 is queued for 14 May.

Mode toggle UI

Three options. System is the default; it follows the OS preference and tracks with the reader’s environment – light at the desk, dark on the train home. Light and Dark are manual overrides for readers who want the same mode everywhere.

Theme
System matches your OS – currently light.

The toggle writes to localStorage ('light' or 'dark') or removes the key (System). The bootstrap script reads it on the next load. Live mode flips happen by re-setting data-theme on <html>; no reload needed because every component already reads from CSS variables.

Editorial photography in dark

Image treatment doesn’t change. The same photographs, the same crops, the same warm-grey duotones we publish in print. Dark mode flips the chrome around the image – the page, the captions, the rules – not the image itself. A photograph that’s been graded for print should look the same on either canvas.

The single concession: a 1px hairline at --es-color-border-subtle around full-bleed images in dark stops them dissolving into the navy page. That’s it.

Usage rules

Do · Bind to semantic tokens
.card {
background: var(--es-color-surface-raised);
color:      var(--es-color-text-primary);
border:     1px solid var(--es-color-border-subtle);
}

Names describe role, not colour. The mode swap rebinds them; the component never knows.

Don't · Hard-code a hex or a primitive
.card {
background: #FFFFFF;
color:      var(--es-color-navy-900);
border:     1px solid var(--es-color-stone-200);
}

Locks the card to light mode. In dark, it renders white-on-navy and breaks the moment the page loads.

Do · Use the existing ramps for dark surfaces

navy-950 → page
navy-900 → raised
navy-800 → sunken
navy-700 → muted

Dark surfaces resolve to the navy ramp we already publish. No new primitives.

Don't · Invent dark-only colours

—es-color-surface-page-dark:
  #0F1622;

A dark-only hex is a second palette. Two palettes, two brands, two contrast bills to pay.

Do · Keep saturation steady across modes
teal-600 · light CTA
teal-600 · dark CTA

The brand teal stays at full saturation in both modes. Same colour, same weight, same recall.

Don't · Crank saturation in dark to make it pop
teal-600 · light
neon · dark “for contrast”

Boosting hue in dark mode is how a publication starts looking like a gaming app at midnight.

Do · Use navy-950 with breathing room
Long-form text on navy-950 reads at full body length without straining.

The dark canvas is #101622 – ink-leaning navy, not pure black. It softens the contrast and matches the warmth of paper.

Don't · Use pure black as the canvas
Pure black on pure white is the harshest contrast a screen can render.

#000000 makes white text vibrate, kills photographic mid-tones, and reads as terminal, not editorial.

Do · Hold WCAG AA in both modes

ink on paper · 18.7:1
paper on navy-950 · 16.4:1
teal-700 on paper · 5.8:1
teal-400 on navy-950 · 6.1:1

Every semantic pair clears AA (4.5:1 for body, 3:1 for large text) in both modes. Audited as part of the token build.

Don't · Skip the contrast check on dark

stone-500 on navy-900 · 3.2:1
teal-600 on navy-950 · 3.9:1
“looks fine” · ✕

Dark contrast lies to the eye. Assume nothing; run the numbers, fail loudly, and rebind.

Tokens to bind

Every semantic token below has a light binding and a dark binding. Primitives stay constant; only these names rebind under [data-theme="dark"].

Surface (5)

  • —es-color-surface-page
  • —es-color-surface-raised
  • —es-color-surface-sunken
  • —es-color-surface-muted
  • —es-color-surface-inverse

Content (5)

  • —es-color-text-primary
  • —es-color-text-secondary
  • —es-color-text-tertiary
  • —es-color-text-link
  • —es-color-text-linkHover

Border (4)

  • —es-color-border-subtle
  • —es-color-border-default
  • —es-color-border-strong
  • —es-color-border-focus

Feedback (12)

  • —es-color-feedback-success-{surface,border,text}
  • —es-color-feedback-warning-{surface,border,text}
  • —es-color-feedback-danger-{surface,border,text}
  • —es-color-feedback-info-{surface,border,text}

Twenty-six semantic tokens bind across modes. Every component in the system reaches for one of these names and nothing else.