# Artifact Theming

> Make a page correct in all three theme states - explicit dark, explicit light, and the system default that stamps nothing - plus print and greyscale output for decks that get printed. Covers the guarded-override pattern, the transparent-body bug, chart colours under theme change, and verification. Trigger on "dark mode", "light mode", "theme", "prefers-color-scheme", "page looks wrong in dark", "print stylesheet", "greyscale", "colors invert".

- Skill: `lukehle/artifact-theming` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lukehle/artifact-theming`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lukehle/artifact-theming/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Lukehle (https://skillmd.com/u/lukehle)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lukehle/artifact-theming

---


# Artifact theming

The single most common visual bug in published pages, and it comes from a wrong mental model:
**there are three theme states, not two.**

| State | What the root element carries | Detected by |
|---|---|---|
| Explicit dark | `data-theme="dark"` | attribute |
| Explicit light | `data-theme="light"` | attribute |
| **System default** | **nothing** | `prefers-color-scheme` only |

The default setting stamps no attribute. A page that only handles `[data-theme="dark"]` is unstyled
for everyone on system default — which is most people.

---

## The pattern that covers all three

```css
/* 1. Complete light palette on bare :root. EVERY token defined here. */
:root {
  --surface: #ffffff;
  --ink: #14161a;
  --border: #e3e6ea;
  --grid: #eceff3;
  /* … the full set … */
}

/* 2. System dark - guarded so an explicit light choice still wins */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --surface: #101215;
    --ink: #e8eaed;
    --border: #262b31;
    --grid: #1c2126;
  }
}

/* 3. Explicit dark - so the toggle wins in both directions */
:root[data-theme="dark"] {
  --surface: #101215;
  --ink: #e8eaed;
  --border: #262b31;
  --grid: #1c2126;
}
```

Three rules, and each prevents a specific bug:

1. **Every token gets its definition on bare `:root`.** A colour whose only definition lives inside a
   media query or a `[data-theme]` block is undefined in the third state.
2. **Guard the media block with `:not([data-theme="light"])`.** Without it, a viewer who explicitly
   chose light gets dark anyway when their OS is dark.
3. **Redefine explicit dark separately.** The media query does not fire for a viewer whose OS is
   light but who chose dark in the app.

Blocks 2 and 3 carry identical values. That duplication is the price of covering all three states;
do not try to collapse it.

---

## Give the body an explicit background

```css
body { background: var(--surface); color: var(--ink); }
```

**A transparent body borrows the host's background.** The symptom: dark text on a dark ground, or a
page that looks fine for you and unreadable for someone on the other theme. Always paint it.

---

## Charts under theme change

Chart colours are the part people forget, because the chart looks fine in whichever theme they built
in.

- **Grid and axis lines** must be tokens (`--grid`, `--axis`), not hardcoded `#eee`. A light grid on
  a dark surface disappears; a dark grid on a light surface shouts.
- **Series colours need adjusting, not inverting.** A hue that reads well on white is often too dark
  on near-black. Lighten and slightly desaturate series colours in dark mode rather than flipping
  them — flipping breaks the identity mapping between a segment and its colour.
- **Text inside SVG** uses `fill`, not `color`. `fill: var(--ink)` — a common miss, because the
  element inherits `color` and silently stays black.
- **Semantic colours** (positive/negative) need their own dark values; the light-mode green is
  usually too dark to read on a dark surface.

```css
.chart-text { fill: var(--ink); }
.grid       { stroke: var(--grid); }
.axis       { stroke: var(--axis); }
```

---

## Print and greyscale

Board packs get printed, and a chart that only works in colour becomes unreadable. This is not a
nicety — it is where a colour-only encoding finally fails visibly.

```css
@media print {
  :root {                      /* force the light palette regardless of theme */
    --surface: #fff; --ink: #000; --ink-muted: #444;
    --border: #999; --grid: #ddd;
  }
  body { background: #fff; color: #000; }

  .no-print { display: none; }         /* controls, filters, toggles */
  .chart    { break-inside: avoid; }
  table     { break-inside: auto; }
  thead     { display: table-header-group; }   /* repeat headers across pages */
  a[href]::after { content: " (" attr(href) ")"; font-size: 10px; }
}
```

Then check greyscale separation: **every series must be distinguishable without colour.** If two
series converge to the same grey, add a second encoding — dash pattern for lines, hatch or texture
for areas, direct labels for bars. See `artifact-accessibility`.

---

## An optional toggle

If you add one, it must set the attribute and be reachable by keyboard:

```js
const root = document.documentElement;
btn.addEventListener('click', () => {
  const explicit = root.getAttribute('data-theme');
  const systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
  const currentlyDark = explicit ? explicit === 'dark' : systemDark;
  root.setAttribute('data-theme', currentlyDark ? 'light' : 'dark');
});
```

Note it reads the *effective* state, not just the attribute — otherwise the first click on a
system-dark page appears to do nothing.

---

## Verification — all three states, every time

Do not trust one look. Per `artifact-testing`:

1. **System default with OS light** — no attribute set
2. **System default with OS dark** — no attribute set; this is the state that catches missing base
   definitions
3. **Explicit light on a dark OS** — catches a missing `:not()` guard
4. **Explicit dark on a light OS** — catches a missing `[data-theme="dark"]` block
5. **Print preview**
6. **Greyscale** — a browser filter is enough

Check specifically: body background, SVG text fill, grid lines, borders, muted text contrast, and
placeholder/disabled states. Those are where the misses concentrate.

---

## Deliberately single-theme

A design may commit to one look. That is legitimate — but then paint background and colours
explicitly and skip the dark blocks entirely. What is not legitimate is *accidentally* single-theme:
a page that works in the theme you built in and breaks in the other.

---

## Related skills

- `design-tokens` — the role-named tokens this switches
- `artifact-accessibility` — contrast in both themes, greyscale separation
- `artifact-testing` — the verification pass
- `svg-charting` — why SVG text needs `fill`

