
# Design System

`design` is where your app's design system lives. It holds the tokens — the same ones `theme` has always held — plus everything a design system needs that tokens alone cannot carry: the principles behind them, the mark it is built around, the voice the app speaks in, what each colour is _for_, the ladder its type is set on, the register its images work in, and what each component is _for_.

That last part is the point. A palette of hex values tells a renderer everything and a human nothing. `primary: '#3b5bdb'` does not say whether it is a CTA fill or a body-text colour, so anyone adding a page later — a teammate, or an AI agent working in your repo — picks by feel and the design drifts. `design` makes those rules declarable, and `sovrium design-system` hands them over as a file.

```yaml
design:
  theme:
    colors:
      primary: '#3b5bdb'
      primary-fg: '#ffffff'

  principles:
    - Restraint over ornament
    - Colour is spent on error, and nowhere else

  logo:
    src: /logos/wordmark.svg
    srcDark: /logos/wordmark-light.svg
    alt: Acme
    minWidth: 96px
    misuse:
      - Never re-colour the mark.

  typeScale:
    h1:
      size: '3rem'
      lineHeight: 1.1
      weight: 700
    body:
      size: '1rem'
      lineHeight: 1.6

  imagery:
    iconSet: Lucide
    photography:
      - No stock photography.

  voice:
    personality: [warm, direct, never condescending]
    pronoun: you
    prefer:
      - 'Lead a CTA with its verb: Deploy, Save, Delete.'
      - Every empty state carries a guidance line naming the next action.
    avoid:
      - No exclamation marks in product chrome.
      - No emoji.
    tone:
      empty: Say what this is, then the one next action.
      loading: Say how long, and that they can leave.
      error: State the constraint, then offer two ways forward. Never accuse.
      success: One line, ending in a period. No fireworks.
      destructive: Name what is deleted, how many, and whether it is reversible.

  colorRoles:
    primary:
      usage: Primary CTA fill only. Never body text, never a status pill.
      pairsWith: primary-fg

  components:
    section-header:
      usage: A titled band introducing a page section.
      when: Use above any section carrying more than three children.
      dont: Never nest one inside another — the heading levels collide.
```

## `design.theme` and the `theme` alias

`design.theme` is the canonical home for design tokens. It accepts exactly what the top-level `theme` key accepts — every category, unchanged: `colors`, `darkColors`, `fonts`, `spacing`, `shadows`, `borderRadius`, `breakpoints`, `animations`, `baseline`, `colorScheme`, `codeBlock`.

**Top-level `theme` still works.** It is a supported alias, and every existing config keeps running untouched.

:::callout
**Declaring both is an error, not a merge.** If `theme` and `design.theme` are both present, `sovrium validate` refuses the config and names the fix. A merge would have to pick a winner, and whichever side lost would take effect nowhere with nothing said — so Sovrium refuses instead of guessing.
:::

```yaml
# Canonical
design:
  theme:
    colors:
      primary: '#3b5bdb'

# Still supported — a deprecated alias
theme:
  colors:
    primary: '#3b5bdb'
```

`sovrium validate` prints a deprecation notice when it sees the top-level form, on stderr, and still exits `0`. The alias is removed in the next major version.

## Principles

`design.principles` is an ordered list of the convictions behind your tokens. They render at the top of the export, so whoever reads it gets the reasoning before the values.

```yaml
design:
  principles:
    - Restraint over ornament
    - The visitor is the hero, not the product
```

## Logo

`design.logo` holds the mark and the rules for placing it — the first section of every brand charter.

```yaml
design:
  logo:
    src: /logos/wordmark.svg
    srcDark: /logos/wordmark-light.svg
    alt: Acme
    clearSpace: Leave clear space equal to the height of the mark on all four sides.
    minWidth: 96px
    misuse:
      - Never re-colour the mark.
      - Never stretch, rotate or add effects.
      - Never place it on a busy photograph without a plate.
```

| Field        | Notes                                                                               |
| ------------ | ----------------------------------------------------------------------------------- |
| `src`        | **Required.** The primary mark, as it appears on the app's default (light) surface. |
| `srcDark`    | The variant shown when the interface is in **dark mode**. See the warning below.    |
| `alt`        | **Required.** The accessible name — normally just the app's name.                   |
| `clearSpace` | The exclusion zone, in prose.                                                       |
| `minWidth`   | The smallest width the mark may be reproduced at: a number with `px` or `rem`.      |
| `misuse`     | What must never be done to the mark.                                                |

`alt` is required alongside `src` because a mark with no accessible name is a defect rather than a partial declaration — it reaches a screen reader as nothing at all. Write the app's name, not `logo`: a screen reader already announces the element as an image.

`clearSpace` is deliberately prose and not a dimension. Every charter worth reading states clear space **relative to the mark** — "the height of the S on all four sides" — because that is the rule that survives the mark being resized. `minWidth` is a dimension because, unlike clear space, it genuinely is one number, and it is the number a consumer can enforce.

`misuse` is the half that changes behaviour. "Use the wordmark" tells a designer nothing they were not already going to do; "never re-colour it, never set it on a busy photograph" is the sentence that prevents the thing you did not want.

:::callout
**`srcDark` is named for the MODE, not for the ink.** This is the field most often filled in backwards, because logo files are usually named for their ink and the two conventions are opposites. A **dark-ink** file is the one shown in **light** mode, so it belongs in `src`; the **light-ink** file belongs in `srcDark`. Omit `srcDark` entirely when one mark reads on both surfaces — requiring a second file invites a duplicate that then goes stale.
:::

### Where the file lives

`src` and `srcDark` hold a **reference**, not bytes, and the only thing any consumer can do with one is put it in a `src=` attribute. Two forms are accepted:

- **Root-relative** — `/logos/wordmark.svg`. This covers both storage homes with one rule: a file in the app's public directory is served at `/name.ext`, and a bucket object at `/api/buckets/{bucket}/files/{path}`.
- **Absolute `https://`** — `https://cdn.example.com/wordmark.svg`, for a mark on a CDN or owned by another party.

Everything else is refused, each for a reason:

| Refused                               | Why                                                                                                                                                                                                                                              |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| A bare relative path (`wordmark.svg`) | It resolves against the **current page**, so the same declaration loads `/wordmark.svg` on the home page and `/fr/docs/guide/wordmark.svg` inside the docs zone. There is no way to write a correct one, so accepting one accepts only mistakes. |
| `http://`                             | An https app loading an http image is mixed content: the browser blocks it silently and the mark is simply absent.                                                                                                                               |
| `data:`                               | Inlining a wordmark into every page's HTML is paid for on every request and cached on none.                                                                                                                                                      |

**Prefer SVG.** A wordmark is line art: resolution-independent, smaller than any raster encoding of it, sharp at every density, and with no codec question at all. The AVIF rule that governs [committed imagery](/en/docs/ecoconception) is about screenshots, not marks — a customer's wordmark is their trademark, and re-encoding it is not Sovrium's call. A mark declared here is served verbatim and never enters the runtime image-transform pipeline.

## Voice

`design.voice` describes how your app speaks, whatever the situation. Every field is optional.

| Field         | Type       | What it holds                                                             |
| ------------- | ---------- | ------------------------------------------------------------------------- |
| `personality` | `string[]` | Traits that hold everywhere: `['warm', 'direct', 'never condescending']`. |
| `pronoun`     | `string`   | How the app addresses the reader — `tu`, `vous`, `you`, `Sie`.            |
| `prefer`      | `string[]` | Patterns to reach for, written as instructions.                           |
| `avoid`       | `string[]` | Patterns to refuse.                                                       |
| `tone`        | `object`   | How the register shifts per situation — see below.                        |

`pronoun` is a free string rather than a fixed list, because the register contract is per-language: an app written in French chooses between `tu` and `vous`, one written in English has no choice to make, and a fixed list would refuse the first language it never enumerated.

`prefer` / `avoid` are the two halves of a house style guide. Put the work into `avoid`: refusals are what stop plausible-looking off-brand copy.

### Tone

`design.voice.tone` covers the five moments where a system speaks. Each value is one instruction telling the writer _how_ to write that moment, not the literal string: the strings are per-locale and live in [`languages`](/en/docs/languages).

| Key           | The moment                                                    |
| ------------- | ------------------------------------------------------------- |
| `empty`       | A table with zero rows, a feature never used.                 |
| `loading`     | A long-running task the reader is waiting on.                 |
| `error`       | Anything that stops the reader: validation, network, refusal. |
| `success`     | A save confirmed, a deploy finished.                          |
| `destructive` | A confirmation that must name its consequence.                |

Declare the moments you have thought about and omit the rest. An absent key is better than an instruction invented to fill the table.

## Colour roles

`design.colorRoles` answers the question `theme.colors` cannot: what is this colour _for_?

```yaml
design:
  theme:
    colors:
      primary: '#3b5bdb'
      primary-fg: '#ffffff'
  colorRoles:
    primary:
      usage: Primary CTA fill only. Never body text.
      pairsWith: primary-fg
```

`usage` says what it is for and — more usefully — what it is not for. `pairsWith` names the token this one is designed to sit against, usually its foreground companion, so a reader does not have to re-measure the contrast.

**Every key must name a colour declared in your palette.** A role documenting a token that does not exist is guidance nobody can act on, and in practice it is a typo — so `sovrium validate` refuses it and lists the tokens that do exist. `pairsWith` is deliberately _not_ checked the same way: a legitimate companion is often a platform role token your app never redeclared.

## Type scale

`design.typeScale` is the ordered ladder your type is set on — twelve named steps from `display` down to `overline`, each with its size, leading, weight and tracking.

```yaml
design:
  typeScale:
    h1:
      size: '3rem'
      lineHeight: 1.1
      weight: 700
      letterSpacing: '-0.02em'
    body:
      size: '1rem'
      lineHeight: 1.6
```

It is the one key under `design` that emits **CSS**: every declared step becomes a `--text-{step}` custom property with its modifiers, and a `text-{step}` Tailwind utility. That is what makes it the working replacement for the three inert `theme.fonts` fields named at the end of this page.

See [Type Scale](/en/docs/design-type-scale) for the full ladder, the unit rules, and what the export does with it.

## Imagery

`design.imagery` covers how the app looks where tokens cannot reach. Two pages can use identical tokens and still look like two different products, because one chose a stock photograph of people pointing at a whiteboard and the other chose a screenshot of the running app.

```yaml
design:
  imagery:
    principles:
      - Show the product working, never a metaphor for it.
      - A person in an image is doing their job, not posing.
    photography:
      - No stock photography.
      - Natural light only — no colour grading toward a brand hue.
      - Screenshots are captured at 2x on a neutral background.
    iconSet: Lucide
    patterns:
      - A single paper-grain texture at 4% opacity, never more than one surface per screen.
      - Illustrations are single-weight line art in the signature colour, never filled.
```

| Field         | Type       | What it holds                                                                                |
| ------------- | ---------- | -------------------------------------------------------------------------------------------- |
| `principles`  | `string[]` | The register images work in, as convictions. The imagery counterpart of `design.principles`. |
| `photography` | `string[]` | The concrete rules a person applies while choosing or shooting.                              |
| `iconSet`     | `string`   | The name of the **one** icon set every icon is drawn from.                                   |
| `patterns`    | `string[]` | Non-photographic marks: texture, illustration style, background geometry.                    |

`principles` and `photography` are separate because a principle is a standard to judge against and a rule is a constraint to obey. "Show the product working" is a principle; "no stock photography, ever" is a rule, and conflating them makes the rule sound negotiable.

`iconSet` is the highest-leverage field here. Icon drift is not caused by anyone choosing a _bad_ icon — it is caused by three authors each choosing a _reasonable_ icon from three different sets, after which no amount of token discipline makes the toolbar look like one product. Naming the set once removes the decision. It is a **name**, not a URL or a package specifier: what a reader needs is which set to search, and a version pin answers a different question and goes stale on every bump.

**Nothing in `imagery` names a file**, deliberately. A logo is one specific artifact with one URL; imagery is a class of artifact with rules, and the images themselves are declared where they are used — in pages, in records, in buckets. An asset list here would look like it did something and would in fact be a second, unrendered copy going stale the moment either side changed.

## Component guidance

`design.components` gives your own reusable components something to say beside their name.

```yaml
components:
  - name: section-header
    type: container
    children:
      - type: text
        element: h2
        content: $title

design:
  components:
    section-header:
      usage: A titled band introducing a page section.
      when: Use above any section carrying more than three children.
      dont: Never nest one inside another — the heading levels collide.
```

Three fields, three different questions: `usage` is what it _is_, `when` is the situation that selects it over its neighbours, and `dont` is the misuse to refuse. All optional. **Every key must name a declared `components[].name`** — guidance attached to a component that does not exist renders nowhere, and the usual cause is a rename that updated one side and not the other.

## Exporting it

The whole point of declaring this is to be able to hand it over. One generator produces all three surfaces, so they cannot disagree about what your design system is.

### `sovrium design-system`

```bash
# The agent brief, on stdout
sovrium design-system app.yaml

# Committed beside the config, referenced from your agent instructions
sovrium design-system app.yaml --output DESIGN.md

# The token document, for tooling
sovrium design-system app.yaml --format json --output tokens.json
```

Markdown is the default because the default reader is a model. The command runs offline — no server, no database — so it fits a pre-commit hook or a CI step. It refuses an unknown `--format` and refuses a config that fails validation, rather than exporting a design system describing an app that cannot boot.

### The endpoints

Both are admin-gated and read-only, and return `404` to anyone else.

| Endpoint                            | Format                                                                                       |
| ----------------------------------- | -------------------------------------------------------------------------------------------- |
| `GET /api/admin/design-system.json` | [W3C Design Tokens (DTCG) 2025.10](https://www.designtokens.org/tr/drafts/format/) document. |
| `GET /api/admin/design-system.md`   | The same brief the CLI prints.                                                               |

The [Design System Console](/en/docs/design-system-console) renders the same content as a page a person reads, and can publish it behind a revocable link to someone with no login.

In the JSON document, tokens appear as standard DTCG types — colours as `{colorSpace, components, hex}` objects, dimensions as `{value, unit}` — the type scale as `typography` composite tokens, and the Sovrium-specific layer (principles, logo, voice, colour roles, imagery, component guidance) rides in `$extensions` under the key `com.sovrium.design-system`, which is exactly what DTCG reserves `$extensions` for.

### What the export deliberately leaves out of the token tree

Some values have no faithful DTCG form, and a malformed token is worse than an absent one — a tool would act on it. Those keep their raw text under `$extensions` instead of being bent into a standard type:

| Value                                         | Why                                                                                                                                                    |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `theme.shadows.*`                             | DTCG shadows are decomposed into `color`/`offsetX`/`offsetY`/`blur`/`spread`; a raw CSS `box-shadow` string cannot be parsed back into those reliably. |
| A spacing value like `clamp(1rem, 2vw, 3rem)` | A DTCG dimension is one number and one unit.                                                                                                           |
| `theme.darkColors`                            | DTCG has no mode/scheme concept in this version.                                                                                                       |

## Three superseded `theme.fonts` fields

These validate, and then nothing reads them. They are called out here, and named in the export under `$extensions`, so you do not maintain a value that has no effect:

| Field                      | What actually happens                                                                                                                                                        |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `theme.fonts.*.lineHeight` | **Reaches nothing.** No CSS custom property is emitted for it.                                                                                                               |
| `theme.fonts.*.size`       | Reaches only the legacy `hero` section renderer, as an inline font size. It never becomes a CSS variable.                                                                    |
| `theme.fonts.*.weights`    | Only the **first** entry is read, and only by that same `hero` renderer. It never reaches an `@font-face` rule, so `weights: [300, 400, 700]` loads no additional font file. |

**Declare sizes, leading, weight and tracking in [`design.typeScale`](/en/docs/design-type-scale) instead**, where each step emits a real CSS custom property and a usable `text-{step}` utility. To load several weights of a font, declare the variants you need in `theme.fonts` explicitly.

`sovrium validate` prints a `Superseded:` notice naming each declared path, and exits `0`. **Superseded, not deprecated**: "deprecated" means _this worked and is going away_, and these three never worked. They keep decoding until the next major, alongside the top-level `theme` alias — refusing them now would stop a booting app in exchange for zero rendering change. Nothing is translated for you either: `typeScale` is per **step** where `theme.fonts` is per **face**, so an automatic mapping would have to guess which face is which rung of the ladder.

## Related Pages

- [Theme Overview & Colors](/en/docs/theme) — the token categories in full.
- [Type Scale](/en/docs/design-type-scale) — `design.typeScale` in full.
- [Typography](/en/docs/theme-typography) — `theme.fonts`, the faces a type step is set in.
- [Design System Console](/en/docs/design-system-console) — the console page and the revocable share link.
- [Validation & Schema Generation](/en/docs/cli-validate) — `sovrium design-system` beside `validate` and `schema`.
- [Admin Dashboard](/en/docs/admin-dashboard) — the admin-gated read API.
- [App Schema Overview](/en/docs/schema-overview) — every root property.
