# Theme and surfaces

The registry gets its colours from one theme file, and its surfaces layer themselves: nesting decides the shade, and quiet text stays readable at every level.

## theme.css

The registry is the only place classes live, and its colours come from one file. `prongs add` writes `components/prongs/theme.css` and imports it into your global CSS, right after Tailwind. If your project already defines these colours, your own values win because they come later.

```css
/* app/globals.css: prongs add writes the second line */
@import "tailwindcss";
@import "../components/prongs/theme.css";

/* Your values, after the import, win */
:root {
  --primary: oklch(0.55 0.2 260);
}
```

Next to the usual `primary`, `muted` and `destructive` there are `success`, `warning` and `info`, and the surface colours below. The names are the contract; the values are yours.

| Colour | Variable | For |
|---|---|---|
| `background` | `--background` | The page. Surface level 0. |
| `foreground` | `--foreground` | Main text. Surfaces mix towards it as they rise. |
| `card` | `--card` | Card background, for components outside Prongs. Prongs looks use surfaces instead. |
| `card-foreground` | `--card-foreground` | Text on `--card`. |
| `popover` | `--popover` | Popover background, for components outside Prongs. |
| `popover-foreground` | `--popover-foreground` | Text on `--popover`. |
| `primary` | `--primary` | The main action and the accent colour. |
| `primary-foreground` | `--primary-foreground` | Text on `--primary`. |
| `secondary` | `--secondary` | Quieter filled controls. |
| `secondary-foreground` | `--secondary-foreground` | Text on `--secondary`. |
| `muted` | `--muted` | Tinted backgrounds that do not layer. |
| `muted-foreground` | `--muted-foreground` | Quiet text on the page. On surfaces use `--surface-muted`. |
| `accent` | `--accent` | Hover and selection tint. |
| `accent-foreground` | `--accent-foreground` | Text on `--accent`. |
| `destructive` | `--destructive` | Danger: deleting, errors. |
| `destructive-foreground` | `--destructive-foreground` | Text on `--destructive`. |
| `success` | `--success` | Success text. |
| `warning` | `--warning` | Warning text. |
| `info` | `--info` | Neutral notice text. |
| `border` | `--border` | Hairlines on the page. |
| `input` | `--input` | Field borders. |
| `ring` | `--ring` | Focus rings. |
| `surface` | `--surface-base` | The background of the surface you are on. |
| `surface-hover` | `--surface-hover` | One level up from it: hover fills. |
| `surface-active` | `--surface-active` | Two levels up: pressed and selected fills. |
| `surface-inset` | `--surface-inset` | A recess cut into it: wells, code, skeletons. |
| `surface-fg` | `--surface-fg` | Text on it. |
| `surface-muted` | `--surface-muted` | Quiet text on it, kept readable at every level. |
| `surface-border` | `--surface-border` | A hairline that shows on it. |

## Surfaces that layer themselves

A panel inside a panel should sit one step above it. Usually someone picks the shade by eye, the inner panel ends up the same colour as the outer one, and a new "panel-inner" style appears. In Prongs the intent says what the box is ([`layout.panel`](https://www.prongs.dev/docs/intents/layout/panel.md)) and the nesting decides the shade. Below, the same intent is used at every level.

Hover fills, quiet text and hairlines follow the surface they sit on, so the hover on the deepest panel is as visible as the one on the page. An overlay, like a dialog, floats over the page and starts its own stack wherever it is opened.

```ts
// The registry's surface looks, from the starter
'surface.panel':   { binding: { classes: 'surface rounded-lg border border-surface-border' } },
'surface.sunken':  { binding: { classes: 'surface-inset rounded-lg' } },
'surface.overlay': { binding: { classes: 'surface-overlay m-auto rounded-lg border border-surface-border shadow-xl' } },

// Anything that sits on a surface uses the surface colours
'control.ghost':   { binding: { classes: 'text-foreground hover:bg-surface-hover' } },
'tone.muted':      { binding: { classes: 'text-surface-muted' } },
```

## How it works

Plain CSS with no JavaScript, so it works in Server Components: `surface` sits one level above the surface it is in, `surface-inset` is a recess (a well, a code block, a field), and `surface-overlay` restarts the count for something that floats. Put `surface-page` on an element that sets its own `--background` or `--foreground`, like a themed profile or a preview, and the levels under it are mixed from its colours. Elements in between that are not surfaces do not count.

```css
/* theme.css: depth comes from nesting, not from a number someone picked */
.surface                   { --surface-level: 1; }
.surface .surface          { --surface-level: 2; }
.surface .surface .surface { --surface-level: 3; }

/* every level is mixed from two colours, so dark mode and your palette come free */
--surface-base:  color-mix(in oklch, var(--foreground) calc(var(--surface-step) * var(--surface-level)), var(--background));
--surface-hover: /* the same, one level up */;
--surface-muted: color-mix(in oklch, var(--foreground) var(--surface-muted-mix), var(--surface-base));
```

Because every level is mixed from `--background` and `--foreground`, a theme sets two colours and gets every level, in light and dark. Dark surfaces get lighter as they rise, which is how elevation reads there.

Dark mode follows the system. To let people choose, put `class="dark"` or `class="light"` on `<html>`; with neither, the system decides.

```css
:root {
  --background: #fafaf9;
  --foreground: #1a1918;
  --surface-step: 4%;        /* how far each level moves towards the foreground */
  --surface-muted-mix: 63%;  /* more ink for quiet text on a softer palette */
}
@media (prefers-color-scheme: dark) {
  :root {
    --surface-step: 5%;      /* dark mode needs bigger steps to read */
    --surface-overlay-level: 2;
  }
}
```

## Readable at every level

A fixed muted colour that passes on the page fails on the panels above it. `text-surface-muted` is mixed from the surface it sits on instead. These are the default theme's contrasts, computed the way the browser mixes them; the Prongs tests fail if any level drops below 4.5:1.

| Level | Text | `text-surface-muted` | fixed `text-muted-foreground` |
|---|---|---|---|
| light page | 19.8:1 | 6.3:1 | 4.7:1 |
| light 1 | 17.9:1 | 6.1:1 | 4.3:1 ✗ |
| light 2 | 16.2:1 | 5.8:1 | 3.9:1 ✗ |
| light 3 | 14.6:1 | 5.6:1 | 3.5:1 ✗ |
| light 4 | 13.1:1 | 5.3:1 | 3.1:1 ✗ |
| light overlay | 19.8:1 | 6.3:1 | 4.7:1 |
| light overlay 1 | 17.9:1 | 6.1:1 | 4.3:1 ✗ |
| light overlay 2 | 16.2:1 | 5.8:1 | 3.9:1 ✗ |
| light overlay 3 | 14.6:1 | 5.6:1 | 3.5:1 ✗ |
| dark page | 19.0:1 | 6.1:1 | 7.6:1 |
| dark 1 | 17.8:1 | 6.1:1 | 7.2:1 |
| dark 2 | 16.2:1 | 5.9:1 | 6.5:1 |
| dark 3 | 14.4:1 | 5.6:1 | 5.8:1 |
| dark 4 | 12.5:1 | 5.2:1 | 5.0:1 |
| dark overlay | 16.2:1 | 5.9:1 | 6.5:1 |
| dark overlay 1 | 14.4:1 | 5.6:1 | 5.8:1 |
| dark overlay 2 | 12.5:1 | 5.2:1 | 5.0:1 |
| dark overlay 3 | 10.6:1 | 4.7:1 | 4.3:1 ✗ |

With your own colours, check the deepest level you use. A softer palette needs a higher `--surface-muted-mix`, as this site's does.

## Checked

`prongs check` reads the registry's classes too. `unknown-token` fails a `var()` that no CSS file declares, which would otherwise render as nothing. `raw-value` warns about a colour the theme cannot change: a literal like `bg-[#e5383b]` or a palette colour like `text-sky-700`. See the [check rules](https://www.prongs.dev/docs/rules.md).
