# Design tokens

> Colours, sizes and typography as CSS variables, TypeScript constants and a Tailwind theme.

Every value is a CSS variable in `tokens.css`: colours, spacing and font sizes alike.

```js
import "@lo-tek/interfaces/tokens.css"
```

```css
.my-component {
  color: var(--lt-color-accent-fill);
  padding: var(--lt-spacing-4);
  font-size: var(--lt-font-size-m);
}
```

## Colours as a matrix

One colour layer, not two. A colour is a point in a matrix of **family**, what it means, and **role**, what it does. The name is `--lt-color-<family>-<role>`.

Every family has the same roles. That structure is what makes them match: a danger area and a brand area look like siblings because they are built from the same roles at the same lightnesses.

### Families

| Family | For |
| - | - |
| `accent` | Links, primary button, focus, selected state |
| `visited` | Visited links |
| `brand1`, `brand2`, `brand3` | Surfaces, sections and categories. They mean nothing in themselves |
| `neutral` | The page, body text, borders |
| `danger` | Errors, deletion, rejection |
| `warning` | Needs attention |
| `success` | Completed, approved |

### Roles

Nine roles, the same for every family. Text and border have three steps each, mirroring each other.

| Role | For | Promise |
| - | - | - |
| `surface` | Tinted surface: a badge, an alert, a section | none |
| `border-subtle` | Decorative line, such as between two rows | none |
| `border` | The border around a field or a surface | 3.1:1 against the page, against `raised` and against its own `surface` |
| `border-strong` | The field border on hover, and a table rule | follows from `border`, which it lies outside |
| `fill` | Filled surface: a button, a checked checkbox | 3.1:1 against the page and against `raised` |
| `content` | The text on top of `fill`, and on top of `text` on a button on hover | 4.6:1 against `fill` and against `text` |
| `text-subtle` | Muted text: help text, placeholders | 4.6:1 against the page, against `neutral-surface` and against `raised` |
| `text` | The family’s colour as text, and the button on hover | the same three, and against its own `surface` |
| `text-strong` | Body text, headings and the link on hover | the same three |

The neutral family has two extra roles, because it carries the page itself:

| Token | For | Promise |
| - | - | - |
| `--lt-color-neutral-canvas` | The page | every text role in every family holds against it |
| `--lt-color-neutral-raised` | A surface lying above a card | the same |

Text has to hold against all three layers: the page, `neutral-surface` and `neutral-raised`. A plain card has the page background (`canvas`), a filled card has `neutral-surface`, and a surface above a card has `neutral-raised`. If text only held against the page, a card on a card would become unreadable without anything saying so.

Each row is a family, each square a role, in the order above:

### Contrast by construction

The hue is yours, the lightness is the role’s. The lightnesses are not picked by hand: they are computed against a sweep around the whole colour wheel at the highest chroma sRGB can show. That is why the promises hold whatever brand colour you give, and there is no adjustment pass that moves your colour afterwards.

If you write your own values, keeping the promises is up to you. `lotek check-theme theme.css` tells you which cell breaks and why.

### Tokens outside the matrix

| Token | For |
| - | - |
| `--lt-color-disabled-surface`, `--lt-color-disabled-text` | Disabled controls. Exempt from the contrast requirement in WCAG 1.4.3, and muted on purpose: a control that is off should look off |
| `--lt-color-overlay` | The surface behind a modal |
| `--lt-shadow-overlay` | The shadow under a panel lying above the page |
| `--lt-focus-ring` | The whole ring around whatever has focus, as an `outline` shorthand. The width and style apply everywhere, the colour does not (see below the table) |
| `--lt-icon-search`, `--lt-icon-check`, `--lt-icon-dash`, `--lt-icon-calendar`, `--lt-icon-clock` | Icons drawn into a background |

The colour in `--lt-focus-ring` does not apply everywhere. It is replaced on purpose in the invalid state (Input, Textarea, Select, Checkbox, Radio), in the success state (Input, Textarea, Select), in Skip Link and Error Summary, and on the selected option in Select with a styled list and in Toggle Group, where the ring sits on a coloured surface. If you change the colour in the token, it does not apply there.

`--lt-color-disabled-surface`, `--lt-color-disabled-text` and `--lt-focus-ring` are built from other tokens and declared again at every theme boundary, so they follow the element’s theme. If you override one of them on `:root`, the override does not apply inside a `data-theme`. Write it on `[data-theme="light"]` and `[data-theme="dark"]` as well, the way the colours are written in [Customising](/customising.md).

**The icons do not follow the matrix.** An icon drawn into a background cannot read a CSS variable: the stroke is painted into the image itself. They therefore have a dark and a light version, and switch with the theme. If you use them yourself, set them as `background-image`.

**`disabled` or `neutral`?** They look alike, but the requirements differ. `disabled` is for controls that are switched off. `neutral` is for muted information the user is meant to read or press, and keeps its promise. If the element is switched off, use `disabled`.

## Size scale

One scale, `--lt-spacing-*`, for everything spatial: `padding`, `margin`, `gap`, `width`, `height`. Four pixels as the base unit, as in Tailwind, so `--lt-spacing-4` matches `p-4`.

| Variable | Value | Tailwind |
| - | - | - |
| `--lt-spacing-px` | 1px | `px` |
| `--lt-spacing-0-5` | 0.125rem | `0.5` |
| `--lt-spacing-1` | 0.25rem | `1` |
| `--lt-spacing-2` | 0.5rem | `2` |
| `--lt-spacing-3` | 0.75rem | `3` |
| `--lt-spacing-4` | 1rem | `4` |
| `--lt-spacing-5` | 1.25rem | `5` |
| `--lt-spacing-6` | 1.5rem | `6` |
| `--lt-spacing-7` | 1.75rem | `7` |
| `--lt-spacing-8` | 2rem | `8` |
| `--lt-spacing-10` | 2.5rem | `10` |
| `--lt-spacing-12` | 3rem | `12` |
| `--lt-spacing-16` | 4rem | `16` |

## Typography

Its own rhythm rather than a numeric scale, so the name says how large the text is relative to body text.

`--lt-font-size-m` is body text. `--lt-font-size-reference` is `16px`, the base size the rem values are computed from.

### Weight and line height

The values are the ones the components already had written out. They became tokens because an organisation adopting the system often has its own typography, and then the difference should be set in one place rather than in twenty stylesheets.

| Token | Value | Used by |
| - | - | - |
| `--lt-font-weight-regular` | 400 | the markers “(required)” and “(optional)” on label and fieldset |
| `--lt-font-weight-medium` | 500 | button, error text |
| `--lt-font-weight-semibold` | 600 | accordion, alert, avatar, badge, breadcrumbs, card, dialog, error summary, fieldset, label, pagination, session timeout, table, tabs, toggle group |
| `--lt-font-weight-bold` | 700 | heading, and the asterisk marking a required field |
| `--lt-line-height-default` | 1.5 | 25 stylesheets: button, input, textarea, select, table, tabs, dialog and the rest of the controls |
| `--lt-line-height-heading` | 1.2 | heading |
| `--lt-line-height-article` | 1.6 | paragraph, list, card text |
| `--lt-line-height-compact` | 1.4 | help text, error text, tooltip, tag, card title, dialog title, the error summary’s title, the connection status bar |

The button, the heading and the paragraph read them through their own component variable, so a theme can change every button without changing all text: `font-weight: var(--lt-button-font-weight, var(--lt-font-weight-medium))`. The others read the token directly. The split is deliberate: a separate variable per component is worth it where you want to tell them apart, and pure noise where you do not.

Three line heights are still plain numbers in the stylesheets. `1` in pagination, avatar and the close button on the toast is not typography but a way to centre a number or a cross in a circle. `1.3` and `1.55` appear in one component each.

## TypeScript

Every variable name exists as a typed lookup table:

```ts
import {
  colorTokens,
  cssTokens,
  type CssToken,
} from "@lo-tek/interfaces/tokens"


const token: CssToken = "--lt-color-accent-fill"
element.style.setProperty(token, "red")


colorTokens["--lt-color-accent-fill"] // "#226dba"
cssTokens["--lt-font-size-mega"]         // "2.625rem"
```

If you write `--lt-color-accent-fil`, you get an error in the editor instead of a `var()` that silently falls back to nothing. The same goes for a role that does not exist: `--lt-color-accent-fill-hover` is a type error, not an empty value.

The colours are in `colorTokens` and `darkColorTokens`, the rest in `cssTokens`. The split exists because the colours are computed by the contract and are therefore not written in the source. `CssToken` covers both, since the colour names are derived from the two axes.

## Breakpoints and containers

Build-time values, not CSS variables, and therefore exported as constants:

```ts
import { Breakpoints, Containers } from "@lo-tek/interfaces/tokens"


Breakpoints.md   // "768px"
Containers.aside // "384px"
```

The breakpoints follow Tailwind: `sm` 640px, `md` 768px, `lg` 1024px, `xl` 1280px, `2xl` 1536px.

## Tailwind

The theme is a CSS file, not a JavaScript preset. The package therefore has no dependency on Tailwind at all, and two Tailwind versions cannot conflict: the version in your app is the one that reads the file.

```css title="app.css"
@layer theme, base, lotek, lotek-theme, components, utilities;


@import "tailwindcss";
@import "@lo-tek/interfaces/tokens.css";
@import "@lo-tek/interfaces/tailwind.css";
```

The first line is the most important one. See [Customising](/customising.md#tailwind-and-the-layer-order) for why.

Everything the theme adds is named `lt-`, like the other classes in the system. That way `bg-neutral-100` is still Tailwind’s own colour.

Every cell in the matrix becomes a class with the same name: `bg-lt-accent-fill` reads `--lt-color-accent-fill`.

| Class | Value |
| - | - |
| `bg-lt-accent-fill` | `--lt-color-accent-fill` |
| `text-lt-accent-text` | `--lt-color-accent-text` |
| `bg-lt-danger-surface` | `--lt-color-danger-surface` |
| `text-lt-neutral-text-subtle` | `--lt-color-neutral-text-subtle` |
| `border-lt-neutral-border` | `--lt-color-neutral-border` |
| `text-lt-mega` | `--lt-font-size-mega` |
| `max-w-lt-aside` | 384px |
| `shadow-lt-overlay` | `--lt-shadow-overlay` |

The spacing is tied to the base unit: Tailwind computes `p-4` as `calc(var(--spacing) * 4)`, and `--spacing` is set to `--lt-spacing-1`. `p-4` is thus four times the unit, which is `--lt-spacing-4`. The steps are separate values and are not computed from the unit, so if you change the scale, rewrite every step and not just `--lt-spacing-1`. Otherwise the Tailwind classes, and whatever in the components uses the unit directly, follow along, while the rest of the components stay where they were. [Tailwind](/tailwind.md#spacing-is-the-same) shows how.

The breakpoints are left out on purpose. LO-TEK’s `sm` to `2xl` have the same values Tailwind already uses, so `md:` works as before.

The classes are not written from memory: every pull request compiles the theme with a real Tailwind and checks that every colour in the matrix comes out as a class with its token in it.

The full setup, with examples mixed freely with the components, is on [Tailwind](/tailwind.md).

To change colours, shape or size, see [Customising](/customising.md).
