# Schema reference

The standard is three **lexicons** (AT Protocol schemas) under the `place.mode.standard.*` namespace. Two are **records** you save to your account; one is a set of shared **type definitions** the others reuse.

:::note[v1: the field shapes are settled]
The `place.mode.standard.*` schemas are frozen at v1: the fields below won't change shape. Evolution from here is **additive-only**, new optional fields and new aspects, never a removal or a tightened constraint, so a theme written against this reference stays valid. The canonical JSON lives in the [lexicons repo](https://tangled.org/mode.place/lexicons).
:::

| Lexicon | Kind | What it is |
|---|---|---|
| `place.mode.standard.theme` | record | A saved theme, one complete look, **keyed by the expression it serves**. |
| `place.mode.standard.profile` | record | Your participation (no default-theme pointer). |
| `place.mode.standard.defs` | definitions | Shared building blocks the `theme` record references. |

:::note[New to AT Protocol?]
A *record* is a piece of structured data stored on your account (your PDS), like a Bluesky post. A *lexicon* is the schema that fixes its shape. Every value below is a plain **CSS string**: the schema bounds its length but doesn't parse CSS grammar, so a malformed value simply doesn't render (it never invalidates the theme).
:::

## `place.mode.standard.theme`

One complete look, saved to your account. **Its record key is the expression it serves**: `…/theme/dark`, `…/theme/com.homedepot.easter`, `…/theme/default`, so a repo holds at most one theme per expression (a second write to the same key overwrites). You can still publish many themes; each just serves a distinct expression. The record is **self-contained**: it carries its own full copy of the values and references no other theme.

| Field | Type | Required | Notes |
|---|---|---|---|
| *(record key)* | string · valid rkey | ✓ | **The expression this theme serves** (`dark`, `com.homedepot.easter`, `default`). Case-sensitive; any valid record key is a safe `data-mode` token (the delimiters `data-mode` uses, `/` and whitespace, can't appear in one). This is the identity; there is no `expresses` field, and `displayName` below is presentation only. See [Common expressions](https://standard.mode.place/docs/reference/expressions/). |
| `displayName` | string · ≤64 graphemes |: | Optional display name, **presentation only**: identity and matching stay on the record key; an app never matches or dedupes on it. (Same limits as a Bluesky profile's `displayName`.) |
| `description` | string · ≤256 graphemes |: | Optional human-readable description. |
| `aspects` | [`aspects`](#aspects) | ✓ | The theme's renderable style, grouped by aspect, the only part rendered to CSS **variables**. A required container; each aspect inside is optional but complete when present. |
| `fonts` | array of [`fontDescriptor`](#fontdescriptor) |: | Font faces the theme's text relies on, theme-level **assets** referenced by `text.*.family`, rendered to document-global `@font-face` (not variables), which is why they sit here beside `aspects` rather than inside it. Optional. |
| `createdAt` | string · datetime | ✓ | ISO-8601 timestamp. |

:::note[Why the expression is the record key]
Storing the concept in an `expresses` **field** doesn't hold up: nothing stops two of your themes from both claiming `default`, and AT Protocol can't enforce a constraint *across* records (validation is per-record). The **record key** can: a collection holds one record per key, so "two themes both `default`" becomes **unrepresentable**, no rule, no tie-break needed. That's the one guarantee the protocol gives for free, so identity lives on the key.

Making themes **self-contained** (each carries its own values, points at nothing) is the other half. A separate *values* record that themes reference costs an extra write per look, and deleting the shared values dangles the whole alias group. A same-collection *symlink* like `default → dark` admits cycles and a privileged "canonical" record. A self-contained record avoids both: it can't dangle or cycle, and resolving one is a single `getRecord`. The price, reuse across names means *copying*, falls where it belongs: the owner's build tooling (one source of truth, one record per key), not the protocol. It's the Bluesky shape: your `profile` and your `post`s are separate self-contained records, never one folded into the other.
:::

:::note[One look, several names]
Because the expression is the key, a theme serves exactly one. A look that should answer to several markers (a `dark` that's also your `default`) is several **self-contained records**, one per key, each carrying the same values. Keeping the copies in sync is the theme owner's **tooling** job (one source of truth, one record per key); the protocol has no alias field and no references between themes. See [Common expressions](https://standard.mode.place/docs/reference/expressions/#one-look-many-names--a-tooling-job-not-a-protocol-one).
:::

:::note[Per-aspect completeness]
Inside `aspects`, `color` and `typography` are each optional, a theme may be color-only, typography-only, or both, but **any aspect it includes must be complete** (every one of that aspect's intents present). That's what lets a present aspect fully paint its slice with no partial-look merging. Keeping style under `aspects` (metadata stays outside) also lets a renderer emit whatever aspects are present without telling style from metadata. See the [Mode Contract](https://standard.mode.place/docs/contract/).

*Why not the alternatives?* Requiring **all** 33 would forbid a color-only or type-only look, too heavy for the common case. Allowing **partial** aspects would reopen per-intent merging: two sources each filling *some* of the color variables would need a specificity/merge engine to reconcile. Complete-when-present avoids both: a winning source supplies a whole aspect, and the rest falls through by plain inheritance.
:::

```json
// saved at rkey "dark" → at://did:plc:…/place.mode.standard.theme/dark
{
  "$type": "place.mode.standard.theme",
  "displayName": "Midnight Ride",
  "description": "My dark look.",
  "aspects": {
    "color": {
      "control": { "background": "#0b1020", "foreground": "#e6e9f5", "border": "#1c2540" },
      "action":  { "primary": { "…": "…" }, "secondary": { "…": "…" }, "auxiliary": { "…": "…" } },
      "surface": { "primary": { "…": "…" }, "secondary": { "…": "…" }, "auxiliary": { "…": "…" } }
    }
  },
  "createdAt": "2026-07-09T00:00:00Z"
}
```

## `place.mode.standard.profile`

A singleton (record key `self`) that declares you participate in the standard. It's a participation flag and the anchor for domain [verification](https://standard.mode.place/docs/guides/verification/), and the natural home for future user-level settings. There is **no default-theme pointer**: your baseline look is simply the theme you saved at the reserved record key `default`, matched like any other concept (see [Common expressions](https://standard.mode.place/docs/reference/expressions/#the-one-reserved-word-default)).

:::note[Why the profile stays thin]
It's tempting to fold your themes into the profile: they're "your settings," after all. But themes are a **one-to-many** collection, like your Bluesky posts, and in AT Protocol those live in their own collection under your DID, never crammed into a singleton record. So the profile carries only what's genuinely one-per-account: participation and the verification anchor. A `defaultTheme` pointer field would need its own answer to "which default?" and a live reference to keep valid; a theme saved at the reserved key `default` designates the baseline with nothing to dangle and no ambiguity.
:::

| Field | Type | Required | Notes |
|---|---|---|---|
| `createdAt` | string · datetime | ✓ | |

```json
{
  "$type": "place.mode.standard.profile",
  "createdAt": "2026-07-09T00:00:00Z"
}
```

## `place.mode.standard.defs`

The shared building blocks. The `theme` record references these by name; you never write them standalone. This is where the 33 intents live, grouped by aspect.

### `aspects`

The container the `theme` record's `aspects` field points at, the renderable style, grouped by aspect. Each aspect is optional but complete when present; new aspects (motion, density, …) are added here as the core grows.

:::note[Why a container, not top-level `color`/`typography`]
Grouping style under one `aspects` object (with metadata like `description`/`createdAt` outside it) lets a renderer emit *everything under `aspects`* as variables without deciding, key by key, which fields are style and which are metadata. Putting `color`/`typography` at the top level instead would force exactly that guesswork into every emitter. The container also gives new aspects (motion, density, data-viz) a single additive home.
:::

| Field | References | Notes |
|---|---|---|
| `color` | [`colorAspect`](#coloraspect) | The 21 color intents. Optional, complete when present. |
| `typography` | [`typographyAspect`](#typographyaspect) | The 12 text intents. Optional, complete when present. (Fonts its text relies on are theme-level; see the theme's [`fonts`](#placemodestandardtheme).) |

### `colorIntent`

The three color values for one purpose. All required.

| Field | Type | Notes |
|---|---|---|
| `background` | string · ≤128 | Any CSS `<color>`. |
| `foreground` | string · ≤128 | Text / icon color. |
| `border` | string · ≤128 | Border color. |

### `textIntent`

Typography metrics for one text priority. All required. (No color; a text's color comes from the surface it sits on.)

| Field | Type | Notes |
|---|---|---|
| `size` | string · ≤128 | CSS `<length-percentage>` (e.g. `1rem`). |
| `weight` | string · ≤128 | CSS `<font-weight>`. |
| `family` | string · ≤256 | CSS font-family; matched to a `fonts` entry by name. |
| `lineHeight` | string · ≤128 | CSS line-height. |

### `actionPriorities` · `surfacePriorities` · `textPriorities`

Three priority levels, each referencing the intent above. All three required.

Priority counts **down**: from the thing claiming most of a reader's attention to the thing claiming least. What that means depends on the purpose, which is why each purpose has its own definition rather than sharing one.

| Field | `actionPriorities` | `surfacePriorities` | `textPriorities` |
|---|---|---|---|
| `primary` | The one thing the screen most wants you to do | A surface that interrupts the page: modal, dialog, alert, sheet | Headlines and titles |
| `secondary` | A supporting choice | A surface over the page, not instead of it: flyout, menu, popover | Body copy |
| `auxiliary` | The quietest choice | The page, and everything on it rather than above it: cards, bars, panels | Detail and help: captions, hints, legal |

The first two reference `colorIntent`, the third `textIntent`.

:::caution[The page is `auxiliary`]
A page is the surface everything else rises above, so it takes the *lowest* priority, not the first one. Themes that paint the page with `surface.primary` will look wrong under any app that follows this table.
:::

### `colorAspect`

The complete color aspect, 21 intents. All required.

| Field | References | Purpose |
|---|---|---|
| `control` | `colorIntent` | Form inputs. No priority; all controls are peers. |
| `action` | `actionPriorities` | Buttons / links, by priority. |
| `surface` | `surfacePriorities` | Non-interactive containers, by how far above the page they sit. |

### `typographyAspect`

The complete typography aspect, 12 text intents.

| Field | Type | Required | Notes |
|---|---|---|---|
| `text` | `textPriorities` | ✓ | The 12 text intents. |

The font faces the text relies on are **not** part of this aspect; they're theme-level assets in the theme's [`fonts`](#placemodestandardtheme), matched to a `text.*.family` by name. (An aspect renders to scoped variables; fonts render to document-global `@font-face`, so they live separately.)

:::note[Why fonts are theme-level, not in this aspect]
Putting fonts *inside* the typography aspect would force the emitter to special-case the one key that isn't a scoped variable while walking the aspect, exactly the style-vs-asset guess the `aspects` container exists to remove. Fonts are **assets referenced by intents** (a `text.*.family` names one), and they render to document-global `@font-face`, a categorically different output from the scoped variables every other field becomes. Keeping them beside `aspects` keeps the aspect walk pure: every leaf is a variable, with no exceptions.
:::

### `fontDescriptor`

One CSS `@font-face`. Per the CSS spec only two things are necessary, so only `family` is schema-required, and a **source** (`src` or `file`) is required by the standard. A minimal entry is `{ family, src }`.

| Field | Type | Required | Notes |
|---|---|---|---|
| `family` | string · ≤256 | ✓ | The font-family name. |
| `src` | string · ≤1024 | see note | A URL, or a CSS `src` value. |
| `file` | blob · font/\* · ≤5 MB | see note | A font file stored on your account; **preferred over `src`** (it travels with you and can't rot). |
| `weight` | string · ≤32 |: | font-weight descriptor or range. |
| `style` | string · ≤32 |: | font-style. |
| `stretch` | string · ≤64 |: | font-stretch / width. |
| `unicodeRange` | string · ≤2048 |: | unicode-range, lets several entries share one `family` for subsetting. |
| `display` | string · ≤32 |: | font-display. |

:::note[Source, and growth]
At least one of `src` / `file` must be present. An `@font-face` is emitted **once per entry**, not per reference, so a family shared by several text intents is declared only once. Further `@font-face` descriptors (`font-feature-settings`, `font-variation-settings`, the metric overrides) can be added later as optional fields, never a breaking change.
:::

## From value to CSS

Each intent's path becomes a CSS custom property: `color.surface.primary.background` → `--color-surface-primary-background` (camelCase segments kebab-cased: `lineHeight` → `line-height`). That deterministic naming is what lets one person's theme travel between apps. An app applies a theme by setting those variables on an element marked with a `data-mode` attribute (never `:root`) and ranking competing sources with cascade layers; the [Mode Contract](https://standard.mode.place/docs/contract/) has the full rules for emitting and resolving these.

:::note[Why values are plain CSS strings]
The schema stores each value as a length-bounded string and never parses CSS grammar. That keeps it **format-agnostic**: any valid CSS `<color>` (hex, `oklch()`, a named color) or length works, with no proprietary color format or transform layer to maintain. It also means a malformed value simply doesn't render, exactly like any invalid custom property, instead of invalidating the whole theme. A structured, validated color/size format would lock the standard to one representation and turn every future CSS value type into a schema change.
:::

:::note[The core grows, and why it's frozen today]
Today the core is 33 intents (21 color + 12 typography) across two aspects. New intents, purposes, and even whole aspects (density, motion, data-viz colors) are added over time as **optional** additions, so older themes always stay valid. Emit whatever a theme actually contains rather than assuming a fixed list.

The current 33 are deliberately **frozen**, reviewed field by field against the *Mise en Mode* book before publishing, because AT Protocol records can't be tightened once they exist on the network (a breaking change needs a whole new `themeV2` NSID). Likely additions like `letterSpacing`, density, and motion are **deferred rather than pre-included**: adding one later is a lossless optional field, whereas shipping it early would burden every theme with completeness for a value the book itself treats as unsettled. Growth stays additive-only by construction; the network effect pins the *core*, not the standard's ability to grow.
:::