# Theme assertions — does the app really look the way the theme says?

> A theme was write-only — Theme.Toggle and Theme.Set are both writes, and a token identifier is compile-time style material that never becomes a value — so an app author could not test their own theme at all. Assert.Token reads the RESOLVED token, following token references the way the cascade does, and `mode:` reads the per-mode value, which is where theming actually goes wrong. Assert.Font asks the other question a token cannot: whether the app really serves a face for a family its theme merely names. Both ask the server, so neither needs a browser.

<!-- id: testing-theme · area: testing · stability: preview · html: https://osysharp.com/reference/testing/theme/ -->

## Summary        {#summary}

A `theme` used to be the one part of an app you could **write** in Osy# and never **read back**. `Theme.Toggle()` and
`Theme.Set(mode)` are the whole runtime surface and both are writes; a token identifier is compile-time style
material that lowers to `var(--path)` and never becomes a value an expression can hold. So the answer to *"is my
accent colour actually `#00566f`?"* was: look at it.

**`Assert.Token("Colors.FillAccent", "#00566f")`** answers it, and **`mode: "dark"`** answers the half nobody looks
at. **`Assert.Font("Inter")`** answers a different question that a token cannot: a `Font { Sans = "Inter, …" }` token
says what to **ask for**; whether the platform actually **serves** a face for `Inter` is a fact about the files your
app pins.

Both ask the **server** what your stylesheet resolves to — the same read `/_osy/app.css` is built from — so they hold
under plain `osy test`. No browser, no driver, no `--pixels`.

## Signature      {#signature}

```osy syntax
Assert.Token(<token path>, <expected value>);                  // what the app resolves by default
Assert.Token(<token path>, <expected value>, mode: "dark");    // …and what it resolves in that mode

Assert.Font(<family>);                                          // the app serves a face for this family

Assert.Contrast(<ink path>, <ground path>);                     // the two colours contrast at 4.5:1 (WCAG AA)
Assert.Contrast(<ink path>, <ground path>, 3, mode: "dark");    // …at another bar, in a mode
```

A token path is spelled the way your theme spells it — `Colors.FillAccent`, `Radius.Card`. The CSS custom property
(`--colors-fillaccent`) is accepted too, because that is what browser devtools shows you.

## Description    {#description}

### What "resolved" means, and why it is not the same as what you wrote   {#resolved}

A theme's values pass through three things before a browser paints them, and an assertion is only useful if it sees
all three:

| in the theme | on the page | `Assert.Token` reports |
|---|---|---|
| `Border = "#c1d4d4";` | `--colors-border: #c1d4d4` | `#c1d4d4` |
| `Bg = Colors.Surface0;` | `--colors-bg: var(--colors-surface0)` | the colour at the end of the chain |
| `Surface0 = Modes.Of(light: …, dark: …);` | `:root` + a dark override block | the one for the mode you asked about |

The second row is the one that matters. A purpose-layer theme is mostly references — that is what lets a whole app be
re-skinned by changing values and editing no component — so an assertion that stopped at `var(--colors-surface0)`
would be asking you to assert **the platform's lowering** rather than your own colour. The chain is followed, per
mode at every hop, and a failure prints the route it took.

### Which mode am I asserting?   {#mode}

Omit `mode:` and you are asserting what the app paints **before anybody touches a toggle** — the Light values, or the
mode your theme pins with `Mode = Dark;`. Pass `mode: "dark"` and you are asserting the dark override.

⚑ **A token with no per-mode value still has a value in dark.** A mode block carries only the tokens that *differ*,
so everything else keeps its base value there — exactly as the cascade behaves. An assertion that answered "not
declared in dark" would be describing your stylesheet's shape rather than the colour on the screen.

⛔ The mode is a **closed set** (`Light` / `Dark`), checked when you compile. `mode: "drak"` is a compile error naming
both, not a run that quietly reads the base value and passes.

```osy title="a theme, and the four claims worth making about it" run app=testing-theme
theme Console {
  Colors {
    Surface0   = Modes.Of(light: "#eef3f3", dark: "#08161b");
    FillAccent = Modes.Of(light: "#00566f", dark: "#5fc8e8");
    Border     = "#c1d4d4";
    Bg         = Colors.Surface0;          // a reference — the purpose layer
  }
  Radius { Card = "12px"; }
}

[Test]
void The_theme_resolves_what_it_says() {
  Assert.Token("Radius.Card", "12px");                          // a flat literal
  Assert.Token("Colors.FillAccent", "#00566f");                 // a mode map's default (its Light value)
  Assert.Token("Colors.FillAccent", "#5fc8e8", mode: "dark");   // the half nobody looks at
  Assert.Token("Colors.Border", "#c1d4d4", mode: "dark");       // no dark value: it keeps its one value
  Assert.Token("Colors.Bg", "#eef3f3");                         // through var(--colors-surface0)
  Assert.Token("Colors.Bg", "#08161b", mode: "dark");           // …and the token it points at is mode-mapped
}
```

### Naming a font is not shipping one   {#fonts}

`Font { Sans = "Inter, system-ui, sans-serif"; }` is a **request**. The browser honours the first family it can
actually load, and a compiled app's [What an app page is allowed to load](https://osysharp.com/reference/ui/content-security-policy/) blocks font CDNs — so a family your theme merely
names, with no face shipped, falls silently through to the next entry in the stack. The page still renders. It just
is not your typeface.

`Assert.Font("Inter")` asks whether the app **serves** a face for that family — a fact about the files pinned in your
`osyrin.lock` by `osy font add`, not about the stack in your theme.

```osy title="the token and the face are two different claims" test app=testing-theme-font
theme Console {
  Font { Sans = "Inter, system-ui, sans-serif"; }
}

[Test]
void The_app_ships_the_face_it_asks_for() {
  Assert.Token("Font.Sans", "Inter, system-ui, sans-serif");   // the app ASKS for Inter
  Assert.Font("Inter");                                        // …and really serves a face for it
}
```

⚠ It asks what the app **declares a face for**, not whether the stored bytes are still fetchable. A family with no
row cannot produce an `@font-face` at all, which is the failure worth an app's own test; a stored blob that has gone
missing is a platform fault with its own guard, and nothing your app's test could act on.

### Do my colours still read? — `Assert.Contrast`   {#contrast}

`Assert.Contrast(ink, ground)` takes two of the theme's colour tokens, resolves both the way `Assert.Token` does, and
holds their WCAG contrast ratio to **4.5:1** — the bar for words. A third argument sets another bar: **3** for large
text, or for something a person must see that is not words (a focus ring, a field's edge). `mode:` asks the pair in
a mode:

```osy title="the pairs a theme lives or dies by, light and dark" run app=testing-theme-contrast
theme Console {
  Colors {
    Surface0   = Modes.Of(light: "#eef3f3", dark: "#08161b");
    FillAccent = Modes.Of(light: "#00566f", dark: "#5fc8e8");
    Border     = "#c1d4d4";
  }
}

[Test]
void The_accent_reads_on_the_page() {
  Assert.Contrast("Colors.FillAccent", "Colors.Surface0");                  // 7.3:1
  Assert.Contrast("Colors.FillAccent", "Colors.Surface0", mode: "dark");    // 9.6:1
  Assert.Contrast("Colors.Border", "Colors.Surface0", 1.3);                 // a hairline, not words
}
```

It is how a theme's contrast becomes a unit test instead of a screenshot to squint at — run in seconds, in CI, with
no browser. A finished look ([Looks for business apps — start from a finished design and make it yours](https://osysharp.com/reference/ui/looks-kit/)) ships its whole list of pairs this way; an app that derives from the
look and changes its accent runs the same list to see what it broke. A failure names both colours, the mode and the
ratio reached:

```console
Assert.Contrast(…) failed: 'Colors.TextMuted' (#BBBBBB) on 'Colors.Surface' (#FFFFFF) in light mode is 1.92:1,
  where it must reach 4.5:1. Darken the words or lighten the ground (the other way round on a dark ground) — or,
  for a brand colour used as words, read it as `X.Text`, which the theme derives to read on the page.
```

⚠ It compares two SOLID colours. A token that resolves to a translucent colour (`rgba(…, .4)`) or a CSS expression is
refused by name — its contrast depends on what is under it, so name the opaque token it sits on. Words drawn on a
ground the PAGE chose at run time are [[testing-ui-layout#readable|`Assert.Readable`]]'s question, in a real browser.

### Why these are not UI assertions   {#not-ui}

Every assertion in [Layout assertions — is it actually usable on screen?](https://osysharp.com/reference/testing/ui-layout/) needs a renderer with a compositor and reports itself **NOT CHECKED** under
plain `osy test`. These do not. The resolved tokens and the font manifest are loaded **server-side**, from the same
place the served stylesheet is generated, so a theme test is as cheap and as deterministic as a data test — and it
runs in CI without a browser anywhere.

That also means these say nothing about **layout**. A correct colour on an unclickable button is still an unclickable
button; that is [Layout assertions — is it actually usable on screen?](https://osysharp.com/reference/testing/ui-layout/)'s question.

## Examples       {#examples}

### What a failure tells you   {#failures}

A refusal here is the thing a theme author meets most, so each one carries what you need to act:

```console
# a token the theme does not declare — the CLOSED SET, grouped the way you wrote it. Every token the
# app RESOLVES is in it, so an app that also takes the kit's theme sees the kit's leaves beside its own.
Assert.Token (line 12) failed: this app's theme declares no token 'Colors.FilAccent'.
  Did you mean 'Colors.FillAccent'? It declares: Colors { Bg, Border, FillAccent, Surface0, … } ·
  Font { Mono, Sans } · Radius { Card, … } · …. A colour also carries its generated ramp steps
  (`Colors.Primary.Hover`, `Colors.Primary[600]`), which are not listed here because every colour
  has the same ones.

# a wrong value — with the route the token resolved through
Assert.Token (line 18) failed: 'Colors.Bg' resolves to "#eef3f3", not "#ffffff"
  (through --colors-surface0)

# a wrong value in a mode — which mode is part of the sentence
Assert.Token (line 21) failed: 'Colors.FillAccent' in Dark mode resolves to "#5fc8e8", not "#00566f"

# a difference a browser would not have noticed
Assert.Token (line 24) failed: 'Colors.Border' resolves to "#c1d4d4", not "#C1D4D4" — they differ
  only in CASE, which a browser does not care about but this comparison does.

# a family the app does not serve — again, the closed set
Assert.Font (line 27) failed: this app serves no font face for 'Intr'. It serves: Inter,
  JetBrains Mono. Did you mean 'Inter'?
```

### Asserting that an assertion refuses   {#refuses}

`Assert.Refuses` composes with both, which is how you pin a refusal you are relying on:

```osy title="pinning the refusal itself" run app=testing-theme-refuses
theme Console {
  Colors { Accent = "#00566f"; }
}

[Test]
void A_token_the_theme_lacks_is_refused_not_answered() {
  Assert.Refuses(() => { Assert.Token("Colors.Nonesuch", "#000000"); }, "declares no token");
}
```

## See also       {#see-also}
- [theme tokens](https://osysharp.com/reference/ui/theming/) — declaring the tokens these assert
- [Assert](https://osysharp.com/reference/testing/assert/) — the rest of the assertion vocabulary
- [[Test] / [TestFixture]](https://osysharp.com/reference/testing/test/) — writing a `[Test]` at all
- [Layout assertions — is it actually usable on screen?](https://osysharp.com/reference/testing/ui-layout/) — the geometry claims, which DO need a real browser
- [[testing-ui#theme]] — `Ui.Theme("dark")`: to SEE the app in a mode (a `Ui.Shot`, an `Assert.Readable`) rather than read its tokens
