# 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.

:::caution[Pre-v1 — not yet published to the network]
These schemas are **not yet published** as `com.atproto.lexicon.schema` records. They're in review, and the field shapes below can still change in response to **community feedback before v1** — after publication, evolution is additive-only. The canonical JSON lives in the [lexicons repo](https://tangled.org/mode.place/lexicons); that's the place to read the source and raise feedback.
:::

| 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/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]
Earlier drafts stored the concept in an `expresses` **field** — first a string, then an array of aliases. Each iteration was chasing the same thing: *uniqueness*. 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. That's the one guarantee the protocol gives for free, so identity moved onto the key.

Making themes **self-contained** (each carries its own values, points at nothing) was the other half. Two normalized designs were rejected: a separate *values* record that themes reference (an extra write per look, and deleting the shared values dangles the whole alias group), and a same-collection *symlink* like `default → dark` (which admits cycles and a privileged "canonical" record). A self-contained record 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/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/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 deletes both problems — 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/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/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. The old `defaultTheme` pointer was removed for the same reason the record key won elsewhere — a theme saved at the reserved key `default` designates the baseline with no live pointer to dangle and no "which default?" 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. An earlier layout put `color`/`typography` at the top level, which forced exactly that guesswork into the 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. |

### `colorPriorities` · `textPriorities`

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

| Field | References |
|---|---|
| `primary` | `colorIntent` / `textIntent` |
| `secondary` | `colorIntent` / `textIntent` |
| `auxiliary` | `colorIntent` / `textIntent` |

### `colorAspect`

The complete color aspect — 21 intents. All required.

| Field | References | Purpose |
|---|---|---|
| `control` | `colorIntent` | Form inputs. No priority — all controls are peers. |
| `action` | `colorPriorities` | Buttons / links, by priority. |
| `surface` | `colorPriorities` | Non-interactive containers, by priority. |

### `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]
Fonts once lived *inside* the typography aspect. That forced the emitter to special-case the one key that isn't a scoped variable while walking the aspect — the exact style-vs-asset guess the `aspects` container was created 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. Moving them beside `aspects` restored a pure aspect walk (every leaf → a variable).
:::

### `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/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 — and it means a malformed value simply doesn't render, exactly like any invalid custom property, instead of invalidating the whole theme. The rejected alternative (a structured, validated color/size format) would have locked the standard to one representation and made every future CSS value type 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 under completeness for a value the book itself treats as elusive. So growth is additive-only by construction — the network effect pins the *core*, not the standard's ability to grow.
:::