# Theming

> The Pro sections read shadcn's variable names with the base tokens as fallback. Paste a shadcn or tweakcn theme, set one --ma-brand (and optionally a neutral and an accent), or write the tokens yourself. The tone, density, align, columns and media props set the variants, and a painted tone re-declares the card, border, field and focus colours inside the section.

Every Pro section reads the same variables shadcn's components read, with the
library's base tokens as fallback:

```css
background: var(--card, var(--ma-panel));
color: var(--card-foreground, currentColor);
border-radius: var(--ma-radius-3xl, 1.25rem);
padding: calc(4rem * var(--ma-density, 1)) 1.5rem;
```

So a page that defines nothing renders as before, and a page with a theme
recolours all of them without editing one. The names are the API: renaming
a token is a breaking change.

## Three ways to apply a theme

**1. Paste a shadcn or tweakcn theme.** Whatever `npx shadcn init` or
[tweakcn](https://tweakcn.com) wrote into your `:root` and `.dark` is read
as is. Nothing to map. The [theme fixture](/theme/fixture/) page is tested
with tweakcn's *Amethyst Haze* and shadcn's *neutral* pasted over it, light
and dark, in three engines.

**2. Set one colour.** Import the contract and change the brand:

```css
@import '@moonarc/core/theme.css';

:root {
  --ma-brand: oklch(0.543 0.097 235);
}
```

`theme.css` derives everything else from it with `oklch(from …)`, in CSS,
at runtime:

- the brand scale, `--ma-brand-50` … `950`; `--primary` is the 600 step in
  light and the 400 step in dark, and the focus ring follows it
- `--ma-neutral`, a grey tinted with the primary's hue (31.8° towards
  green, at low chroma), and its scale `--ma-neutral-50` … `950`, which the
  page, the text, the muted surfaces, the borders and the fields point at
- `--ma-accent`, a second colour 143.5° away from the primary, softer and
  lighter, for highlights and fills
- the four tone grounds and five chart colours

A static value is declared first for each, so a browser without relative
colour (before Chrome 122 · Firefox 128 · Safari 18) shows the default
palette instead of an invalid value; the [theme builder](/theme/) copies
every value precomputed for your colours under `@supports not`.

The neutral and the accent follow the brand until you set them yourself:

```css
:root {
  --ma-brand: oklch(0.666 0.157 58.3);
  --ma-neutral: oklch(0.3 0.012 250);
  --ma-accent: oklch(0.74 0.07 180);
}
```

Their scales and everything that reads them follow the new value. A grey
primary gives grey neutrals: the tint scales with the primary's chroma.

Give `--primary` a colour or a `--ma-brand-*` step. The neutral, the accent
and the tones are derived from it, so `--primary: var(--foreground)`,
`var(--ma-neutral-900)` or `var(--ma-tone-ink)` is a loop. A browser drops
every colour in a loop and every colour read from one: the page loses its
ground, its text and its primary. For a near-black primary, write the
colour: `--primary: oklch(0.22 0.01 250)`. Setting `--ma-neutral` as well
ends the first two loops. It does not end one through the accent or a tone:
`var(--ma-tone-ink)` still loops in dark, where the ink tone is derived from
the primary.

**3. Write the tokens yourself.** Any subset; the rest falls back.

The builder at [/theme](/theme/) does 2 with a colour picker, an optional
neutral and accent, a radius, a density, a font pairing and a motion
preset, previews real sections on all four tones, prints the contrast of
every pair it changes, and hands you the block. The registry serves the
default theme as a `registry:theme` item:

```sh
npx shadcn@latest add https://moonarc.dev/r/theme.json
```

It merges the seeds and the roles into your `:root` and `.dark` and adds
`theme.css`, which derives the rest, so a later `--ma-brand` still
recolours the scales. The CLI needs the `components.json` from
[Setup](/setup/#registry); its `tailwind.css` names the stylesheet the
variables go into.

## The default palette

The default theme comes from five colours. Each lands on a token:

| Colour | Hex | Token |
|---|---|---|
| Cerulean | `#2978a0` | `--ma-brand`; `--primary` is its 600 step, `#24749c` |
| Jet | `#253031` | `--ma-neutral`, and the ground of `tone="ink"` in light |
| Sand | `#bcab79` | `--ma-accent` |
| Pale Sky | `#b8ddf9` | the ground of `tone="muted"` in light, derived as `#b6def7` |
| Slate | `#315659` | none. As a field's edge on the dark page it reads 2.40 : 1, under the 3 : 1 a field needs |

Each derived value sits within 0.02 ΔEok of its colour (about one
just-noticeable difference), and CI fails if one drifts further.

## The tokens

shadcn's, read by the sections (light values shown; `theme.css` has the dark ones):

| Token | Default | Read by |
|---|---|---|
| `--background` / `--foreground` | `--ma-neutral-50` / `--ma-neutral-900` | `tone="surface"` |
| `--card` / `--card-foreground` | white / `--ma-neutral-900` | cards, panels, table heads, the terminal window |
| `--popover` / `--popover-foreground` | white / `--ma-neutral-900` | the MegaMenu panels (through Popover) |
| `--primary` / `--primary-foreground` | `--ma-brand-600` / `--ma-neutral-50` | buttons, chips, the ring |
| `--secondary` / `--secondary-foreground` | `--ma-neutral-100` / `--ma-neutral-900` | reserved |
| `--muted` / `--muted-foreground` | `--ma-neutral-100` / `--ma-neutral-600` | `tone="muted"` on a page without `theme.css` (the fallback) |
| `--accent` / `--accent-foreground` | `--ma-neutral-100` / `--ma-neutral-900` | hover backgrounds, icon wells, cover placeholders |
| `--destructive` / `--destructive-foreground` | `oklch(0.55 0.19 25)` / `--ma-neutral-50` | an invalid field |
| `--border`, `--input`, `--ring` | `--ma-neutral-200`, `--ma-neutral-500`, `--ma-brand-600` | hairlines, fields, focus |
| `--chart-1` … `--chart-5` | derived from the brand | Sparkline, BarGrow, the StatsRow bars |
| `--radius` | `0.625rem` | every corner, through the scale below |

shadcn's `--accent` is a neutral hover surface. The palette's second colour
is `--ma-accent`, below.

Ours, above them:

| Token | Default | What it does |
|---|---|---|
| `--ma-brand` | `oklch(0.543 0.097 235)` | the seed of the scales, the primary, the ring and the charts |
| `--ma-brand-50` … `--ma-brand-950` | static, then derived | lightness fixed per step, chroma tapered at the ends |
| `--ma-neutral` | derived from the primary | the tinted grey; set it to choose your own |
| `--ma-neutral-50` … `--ma-neutral-950` | derived from `--ma-neutral` | lightness fixed per step, chroma capped at 0.02 |
| `--ma-accent` | derived from the primary | the second colour, as a fill; set it to choose your own |
| `--ma-accent-text` / `--ma-accent-foreground` | derived | the accent dark enough (light) or light enough (dark) to read as text on the page; text on an accent fill |
| `--ma-success`, `--ma-warning`, `--ma-info` (+ `-foreground`) | fixed hues | status colours; `--ma-info` is the primary |
| `--ma-tone-muted`, `--ma-tone-ink`, `--ma-tone-brand` | derived | the grounds of the three painted tones |
| `--ma-tone-ink-foreground`, `--ma-tone-brand-foreground` | `--ma-neutral-100`, `--ma-neutral-50` | the text on ink and on brand |
| `--ma-tone-glow-1` … `3` | derived | the colours of the Aurora on the CTA and the hero |
| `--ma-card-shadow` | two soft shadows (light), a top-edge highlight (dark) | a card's elevation |
| `--ma-radius-sm` … `--ma-radius-4xl` | `--radius × 0.6 … 2.6` | the radius scale, with shadcn's multipliers. Tailwind's own `--radius-*`, which every Tailwind project already defines, is left alone |
| `--ma-density` | `1` | the section padding multiplier; `density="compact"` is 0.8, `roomy` 1.25 |
| `--ma-font-display`, `--ma-font-body` | inherit | headlines, section text |
| `--ma-shadow-1` … `--ma-shadow-3` | three elevations | lifted panels |
| `--ma-sec-floor`, `--ma-dim` | `0.75` | the faintest secondary text is drawn |
| `--ma-sec-floor-brand` | `0.9` | the same floor on a brand ground |

Secondary text inside a section is the section's own colour at reduced
opacity (copy at 0.75 to 0.85, kickers and labels at 0.6 to 0.7), so it
follows every tone without a token per tone. With `theme.css` each level
is drawn at no less than `--ma-sec-floor`: 0.75 on the page, because 0.7
reads 4.29 : 1 on a theme whose own text and ground are 10 : 1, and 0.75
reads 4.91 : 1. `tone="muted"` raises the floor to 0.8 and `tone="brand"`
to 0.9: on a colour, hierarchy comes from size and weight. CtaSection and
HeroField paint brand when no tone is set, and take the same 0.9 from
`--ma-sec-floor-brand`. The primitives read `--ma-dim`, which `theme.css`
sets to the same 0.75.

## Status colours

`--ma-success`, `--ma-warning`, `--ma-info` and shadcn's `--destructive`
each work as text on the page (at least 4.5 : 1) and as a fill under their
`-foreground`. A tinted background is mixed where it is used, so there is
no fourth token to keep in step:

```css
.notice {
  color: var(--ma-success);
  background: color-mix(in oklab, var(--ma-success) 14%, transparent);
}
```

Warning is orange (hue 55 in light, 65 in dark) so it never reads as the
accent. Where the engine has `contrast-color()` (Chrome 147 · Firefox 146 ·
Safari 26), every `-foreground` is black or white, whichever reads better
on your colour.

## Tailwind utilities for the tokens

`theme.css` is plain CSS. With Tailwind v4, a second, optional file maps
the contract onto utilities the shadcn way. It uses `@theme inline`, so
`bg-background` carries `var(--background)` itself and switches with the
theme at runtime:

```css
@import 'tailwindcss';
@import '@moonarc/core/theme.css';
@import '@moonarc/core/styles/theme.tailwind.css';
```

It gives `bg-background`, `text-foreground`, `bg-primary`, `border-border`,
`bg-brand-500`, `bg-ink`, `text-success`, `bg-warning`, `bg-info`, and
replaces Tailwind's fixed `rounded-*` scale with the contract's, so
`rounded-lg` is `--radius`. The accent and the neutral scale have no utility
names, because shadcn's `accent` and Tailwind's `neutral` already exist:
write `bg-(--ma-accent)` or `text-(--ma-neutral-600)`. Without Tailwind,
never import it.

## Dark mode

Light is the base. System dark applies unless the page has chosen light;
`<html data-theme="dark">` or `<html class="dark">` wins in both directions.
This is the three-state rule the base tokens follow, and what `ThemeScript` and
`ThemeToggle` write. Every rule in `theme.css` is `:where()`, with zero
specificity: your `:root { … }` or a pasted `.dark { … }` beats it whatever
the import order.

A subtree can carry a theme of its own: `<section data-ma-theme
style="--ma-brand: oklch(0.6 0.2 30)">` re-derives every token inside it,
and `data-ma-theme="light"` or `"dark"` pins its scheme. The builder's
preview is one such scope.

## Variant props

Five props, on the sections that have a use for them; each writes a `data-*`
attribute the section's own CSS answers, so a prop and an attribute set by
your script are the same thing:

| Prop | Attribute | Values | On |
|---|---|---|---|
| `tone` | `data-ma-tone` | `surface` · `muted` · `ink` · `brand` | every section: the ground and everything on it; unset paints nothing, except the CTA and the HeroField panel (they default to `brand`), NavBar (a translucent card) and NewsletterSection (a card-coloured panel) |
| `density` | `data-density` | `compact` · `cozy` · `roomy` | every section: block padding, or the cell padding of a grid |
| `align` | `data-align` | `start` · `center` | sections with a heading |
| `columns` | `data-columns` | `2` · `3` · `4` | BlogGrid, FeatureAtlas, IntegrationGrid, LogoWall, TeamGrid, TestimonialCards |
| `media` | `data-media` | `right` · `left` · `none` | HeroEditorial, HeroTerminal, FeatureRows (alternating by default) |

The tone's attribute carries the library's prefix because `theme.css`
selects on it, and a global selector needs a name no other library uses.

The four tones are built to be told apart at a glance, in light and dark.
`surface` is `--background`, with the decoration at a trace. `muted` is
`--ma-tone-muted`, a band tinted with the primary in light and a lifted
teal-grey panel in dark, with a hairline. `ink` is `--ma-tone-ink`, a deep
panel in both schemes: the neutral itself in light, a deep version of the
primary in dark. `brand` is `--ma-tone-brand`, the primary's colour at
lightness 0.46 in both schemes, with the decoration at full strength. A
primary that is already a deep colour (a black one, as in shadcn's own
themes) is used as it is. A script proves every pair of grounds is at least
0.1 ΔEok apart for any brand colour with chroma from 0.097 to 0.28, and a
browser test measures it again on every section as rendered.

A painted tone is also a theme scope. With `theme.css`, a section with
`data-ma-tone` re-declares, for everything inside it:

| Inside | `muted` | `ink` | `brand` |
|---|---|---|---|
| `--card` | white at 60 % (light), the text at 6 % (dark) | the text at 6 % | the text at 10 % |
| `--border` | a deeper shade of the band (light), the text at 10 % (dark) | the text at 10 % | the text at 18 % |
| `--input` | the text at 65 % | the text at 65 % | the text at 65 % |
| `--ring` | the text | the text | the text |
| `--card-foreground`, `--accent` | unchanged | the text, and the text at 14 % | the text, and the text at 14 % |
| `--ma-card-shadow` | one soft shadow (light), a highlight (dark) | a highlight at 6 % | a highlight at 12 % |
| `--ma-sec-floor`, `--ma-dim` | 0.8 | 0.75 | 0.9 |

So cards, hairlines, fields and focus rings follow the tone without the
section writing them, and a field's edge and a focus ring hold 3 : 1 on the
ground. Set any of the `--ma-tone-*` tokens, or re-declare these names
inside `[data-ma-tone='…']`, to change a tone. A page without `theme.css`
keeps the tones' grounds (each section paints its own with fallbacks) and
its cards in the page's card colour.

On a painted tone the primary action inverts (the tone's foreground as
the button, its ground as the text), so a button is never the colour of the
panel it sits on. Every section page has a variants panel that writes these
attributes on the running demo; the URL carries the choice.

## Slots and class names for overrides

Every section accepts named slots for its parts (`eyebrow`, `headline`,
`copy`, `actions`, `media`, `aside`, whichever it has), and a slot wins over
the matching prop:

```astro
<CtaSection headline="unused when the slot is filled" primary={{ href: '/', label: 'Go' }}>
  <Fragment slot="headline">Ready <em>when</em> you are.</Fragment>
  <a slot="actions" href="/start/" class="my-button">Start</a>
</CtaSection>
```

A slotted headline is not split or shone by the section's own text effect;
it is your markup. The BEM class names (`.ma-cta`, `.ma-cta__headline`, …)
are the override surface: stable, listed on each section's page, and set at
zero specificity, so one class of yours wins.

## How contrast is checked

Two scripts run in CI. The first generates every static value from one table
and proves, for every hue at chroma up to 0.28: text on the primary, the
ring and the primary on the page, the neutral roles (text, muted text, a
field's edge at 3 : 1), the accent as text, the status colours both ways,
the chart colours (at least 3 : 1, and adjacent series apart as seen and
under a deuteranopia simulation), every pair of tone grounds, and the text
on each tone and on a card inside it at the tone's floor. It checks that
each static value equals its derived line for the default brand, and that
the default theme stays within 0.02 ΔEok of the palette. The second checks
every pair a section paints, per tone, in both default themes. The
contract's own pairs all pass.

A pasted theme brings its own pairs, and some of them fail: tweakcn's
*Amethyst Haze* puts white text on its lavender primary at 3.64 : 1 in
light. The [theme builder](/theme/) prints the contrast of every pair it
changes for the colours you pick.