Skip to content

The Mode Contract

A theme is a named look someone has saved and can carry with them — a set of style choices (its color, its typography, or both), complete for each part it includes. Applied to a scope, a theme becomes a mode — a look put in place. The point of this standard is that modes are predictable: given the same page and the same viewer, every conforming app resolves a look the same way. That doesn’t always mean everyone sees an identical picture — a viewer who brings their own preference sees theirs, on purpose; that’s the heart of Mise en Mode. What’s guaranteed is the rules: a look renders faithfully wherever nothing more-preferred supersedes it, and where the viewer has asked for their own, that’s applied instead — resolved the same way everywhere. A schema alone can’t promise this: it fixes the shape of the saved data, not what an app does with it. This document is the missing half — the rules an app follows so a look resolves the same way no matter who renders it.

Who this is for. If you’re just curious what a theme is, you don’t need this page — read the introduction. This page is for people building an app that reads themes and applies them. It’s written plainly, but it’s precise on purpose: the parts in boxes are the rules you must follow to be conforming.

Every rule below is running in the live demo — toggle the sources and watch the cascade resolve them.

Throughout, the rules use the keywords MUST, SHOULD, and MAY in the usual sense: MUST is required for a conforming app, SHOULD is strongly recommended, MAY is optional.

A small vocabulary keeps everything below precise. Each is a plain idea first; the technical anchor (for those wiring this into the AT Protocol ecosystem) is in parentheses.

  • A theme — one saved look: its values for color, typography, or both — complete for each aspect it includes. Plainly, “a look.” (A place.mode.standard.theme record.)
  • An intentwhat a value is for, not a raw color or size: the background of an action, the text of a surface. The current core is 33; the set grows over time (see below).
  • An aspect — a slice of a look: its color (currently 21 intents) or its typography (currently 12 intents).
  • A scope — a region of the page a look is applied to: the whole page, or a single card, section, or widget.
  • An expression — the concept a look is for, named so different people’s versions line up — “critical,” “Easter,” or a personal “midnight.” Every theme serves exactly one, and it is the theme’s address. (A theme’s record key…/theme/dark serves dark.)
  • A mode — a theme applied to a scope for its expression: a look put in place, by marking that scope’s element with a data-mode attribute and setting the theme’s variables there — not a saved thing.
  • The viewer — the person looking at the page, who may have a look of their own.
  • A preference — the viewer’s baseline look: their theme saved at the record key default. (A place.mode.standard.theme keyed default.)

The core grows. Today the core is 33 intents across two aspects (color, typography). The standard is built to add more over time — new intents, new purposes, even new aspects — always as optional additions, so older themes stay valid. So an app should emit whatever a theme actually contains (Rule 1 turns any intent into a variable by its path) rather than hard-code a list of 33.


Rule 1 — Every value becomes a CSS variable named after where it lives

Section titled “Rule 1 — Every value becomes a CSS variable named after where it lives”

A theme’s values live under aspects, nested by aspect. To use one, turn each value’s path from its aspect into a CSS custom property name: read from the aspect down and join the segments with dashes.

color → surface → primary → background → --color-surface-primary-background
typography → text → auxiliary → lineHeight → --typography-text-auxiliary-line-height

That’s the whole rule. The aspects container that groups them is structural — it isn’t part of the name — and a two-word segment splits on its capital (lineHeightline-height).

Rule. For each intent value, an app MUST name its CSS custom property by joining the value’s path from its aspect (color, typography, …) with -, lowercasing and kebab-casing each segment; the aspects container is not included. An app MUST NOT abbreviate, reorder, prefix, or otherwise alter these names.

Why it’s strict: identical names across every app are the entire reason a look can move between apps. The moment two apps name the same value differently, a shared look stops working — one app’s page can no longer pick up another’s theme.

Rule 2 — Fonts are declared before the text that uses them

Section titled “Rule 2 — Fonts are declared before the text that uses them”

Fonts are theme-level assets — they live in the theme’s top-level fonts, beside aspects, not inside typography. They sit apart because an @font-face is document-global: it can’t be scoped and isn’t a variable, so it’s a different kind of output from the intents that become scoped variables. A text intent points at one by its family name. (Only text uses fonts, so a color-only theme — or one using only system fonts — carries none.)

Rule. An app MUST emit an @font-face declaration for each entry in the theme’s fontsonce per entry, document-global (not per scope) — so the font is loading by the time a text.*.family value references it. A text.*.family value is matched to a font entry by its family; a family shared by several text intents (say body and detail) is declared only once. A referenced family with no matching entry is treated as an ordinary CSS font-family, and normal font fallback applies.

A font entry MUST carry a family and at least one source — a src (a URL) or a file (the font saved with the person’s account); every other descriptor (weight, style, stretch, unicodeRange, display) is optional, so a minimal entry is just { family, src }. If both sources are present, the app MUST prefer file — it travels with the person and can’t rot. (The descriptors are the common CSS @font-face set; more may be added over time as optional fields, never a breaking change.)

Applying a look has two halves that meet at the scope’s element:

  1. The marker. The scope’s element carries a data-mode attribute whose value is a theme’s expression — its record key<main data-mode="dark"> marks the scope for the theme saved at …/theme/dark. That equality is the matching mechanism: a theme applies at a scope exactly when the scope’s data-mode token equals the theme’s expression (its rkey), byte for byte (Rule 4 layers optional /aspect selection on top). The marker is declarative markup, written where the scope is (by whoever builds the page), and it is the only thing the page has to say about placement.
  2. The variables. The theme’s values are set in a rule whose selector matches that marker — [data-mode="dark"] { --color-…: …; } — so they land on the marked element and inherit inward through ordinary CSS. The app’s own styles then read those variables by their Rule 1 names.

Splitting it this way is what makes late binding work: the marker is fixed at build time, but a look’s variables can be added — even a viewer’s, fetched after the page loaded — by appending one rule that finds every matching marker. Nothing has to walk the DOM or inject at each scope.

<style>
[data-mode="dark"] { --color-surface-primary-background: …; /* every intent */ }
</style>
<main data-mode="dark"></main> <!-- the whole subtree is "dark" -->

Rule. To apply a whole theme, an app MUST (a) mark the scope’s element with a data-mode attribute carrying the theme’s expression (its record key), and (b) set every intent of each aspect the theme carries — as custom properties by their Rule 1 names — in a rule that matches that marker, so the values land on the marked element and inherit into the scope. A mode is always a deliberate placement — an app MUST NOT set a theme’s variables on the document root (:root / <html>) as an implicit global default; a data-mode marker is a placement, :root is not. (A whole-page look marks a top-level content element such as <body> or an app-root container; a smaller scope marks that container.) Each aspect a theme carries is complete — see Rule 6.

Fonts (Rule 2) are document-global — their @font-face declarations are emitted once, outside any data-mode rule, not per placement.

Rule 4 — Selecting one aspect instead of the whole look

Section titled “Rule 4 — Selecting one aspect instead of the whole look”

Sometimes only part of a look should apply to a scope — its color but not its type. That choice rides the data-mode marker itself: the expression, optionally qualified by an aspect after a slash. data-mode="pink" is the whole look; data-mode="pink/color" is just its color. (A viewer pins their baseline to one aspect a different way — by making their default theme itself carry only that aspect; there is no pointer to fragment.)

Every expression named inside data-mode is a theme’s record key — the string a scope and a theme match on. A data-mode value has exactly two legal shapes, never mixed:

  • One bare expressiondata-mode="dark" — the whole look: every aspect, including any the standard adds later.
  • A whitespace list of aspect fragmentsdata-mode="easter/color pink/typography" — each expression/aspect, assembling a look from parts, at most one fragment per aspect.
<main data-mode="pink"> <!-- whole look -->
<section data-mode="easter/color"> <!-- color only; typography keeps cascading from "pink" -->
</section>
</main>

The slash is the aspect delimiter (pink/color), and it is deliberately a character that cannot appear in a record key (the rkey charset is A-Za-z0-9._~:-) — so no expression can ever contain it, and a dotted expression name (com.homedepot.easter/color) parses unambiguously. A fragment list is disjoint by construction — each fragment names its aspect, so distinct aspects never collide and their order is irrelevant.

Rule. A data-mode value is either a single bare expression (the whole look) or a whitespace-separated list of expression/aspect fragments (each aspect appearing at most once); an app MUST NOT mix the two forms or list two bare expressions. A rule that applies an aspect MUST set only that aspect’s variables (color → the --color-* variables; typography → the --typography-* variables) and MUST NOT set the others; the omitted variables keep inheriting from the enclosing scope.

How a theme’s rule matches both forms. For each aspect a theme carries, an app emits one rule whose selector accepts the whole-look marker and that aspect’s fragment:

:where([data-mode="dark"], [data-mode~="dark/color"]) { /* the --color-* set */ }
:where([data-mode="dark"], [data-mode~="dark/typography"]) { /* the --typography-* set */ }

The exact [data-mode="dark"] arm fires for the bare whole-look marker; the ~= (whitespace-token) arm fires when dark/color appears as a token, alone or among other fragments. So data-mode="dark" picks up every aspect, data-mode="dark/color" picks up only color, and a fragment list picks up exactly the aspects it names.

Why it works cleanly: the two aspects never share a variable name, so applying one leaves the other exactly as the ancestor set it. Nothing is overwritten or merged — the example above shows Easter’s colors over Pink’s typography purely because only the --color-* rule matched.

Bare vs. fragments differ on future aspects. data-mode="dark" means the whole living expression — when a new aspect (say motion) is added, a bare-dark scope picks it up automatically. data-mode="dark/color dark/typography" is pinned to those two named aspects; a later dark/motion won’t apply there. Bare is the whole look; fragments are exactly these slices.

How expressions are otherwise spelled — a bare word like dark for a common concept, a reverse-DNS string like com.homedepot.easter for your own — is a naming convention, not a rule (see Common expressions). Because the two delimiters data-mode parsing relies on — the slash (aspect) and whitespace (fragment separator) — sit outside the record-key charset, every valid record key is automatically a safe data-mode token: there is nothing to avoid, and no expression name is reserved except default. (Examples here use short bare names for readability; a real brand or personal expression would be namespaced.)

Rule 5 — Whose look wins: fill-through, not conflict

Section titled “Rule 5 — Whose look wins: fill-through, not conflict”

A scope can have more than one look available for it: the viewer’s own, the content author’s, the app’s. They don’t blend. Instead, each variable at a scope is filled by the most-preferred source that sets it, and anything unfilled falls through to the next source — and ultimately inherits from the enclosing scope.

Two ordinary CSS mechanisms do all the work, and it’s worth being clear about which does which:

  • Cascade layers rank the sources. An app declares the order once — @layer app, author, viewer; — and emits each source’s data-mode rules into its layer. A later layer wins, so where two sources set the same variable on the same marked element (both have a dark, both matching [data-mode="dark"]), the viewer’s beats the author’s beats the app’s — per variable, with anything a winning layer leaves unset falling through to a lower one.
  • Inheritance handles locality. Because each look is set on its own marked element, a value set directly on a nearer element beats one merely inherited from an ancestor — regardless of layer. So a baseline placed high up fills everything below it except where a more-local scope sets that variable itself.

Together these give the precedence, most-preferred first:

  1. The viewer, when they have a look that applies here (see below).
  2. The content author, when the content carries its own look.
  3. The app’s own look.
  4. Inherited from the enclosing scope; failing that, the app’s bare, un-moded styling.

The viewer “has a look that applies here” in one of two ways:

  • Their default look is their baseline — the theme they saved at the reserved record key default (see Common expressions), emitted at a top-level scope marked data-mode="default" (in the viewer layer), inheriting into everything below that no more-local scope re-sets. Its own aspects decide what it covers.
  • A matching look: when a scope is marked for a concept (data-mode="critical") and the viewer has saved a theme at that key (…/theme/critical), the app emits the viewer’s look into the viewer layer targeting that same marker — so it wins there over the author’s and app’s.

Matching is exact, and the data-mode string is where it happens: the viewer’s “My Critical” stands in for the app’s critical only because both are saved at record key critical, so both render to [data-mode="critical"] — not because their names look alike, and not for a coincidentally similar one.

One look can serve several names — but that’s the owner’s tooling, not the protocol. A theme serves exactly one expression (its key), so a look that should answer to several markers — a dark that is also your default, or that also overrides com.homedepot.dark — is saved as several self-contained records, one per key (…/theme/dark, …/theme/default, …/theme/com.homedepot.dark), each carrying the same values. Keeping those copies in sync is a job for the theme owner’s authoring/build tooling — its single source of truth compiles to one record per key. The protocol stores independent records keyed by expression; it neither models nor enforces the aliasing (there are no references between theme records, so nothing can dangle). This is not composition either: a look that genuinely combines two concepts is one theme saved at one composite key — …/theme/com.homedepot.easter-dark — never a blend. One record is always one complete set of values.

Rule. An app MUST rank sources with cascade layers in the order app, author, viewer (least- to most-preferred), emitting each source’s rules into its layer, so that per variable the most-preferred source that sets it wins and the rest fall through. An app MUST NOT blend two sources’ values for the same variable by any other means, and MUST NOT place any source’s look outside its layer (an unlayered rule would beat every layer). Two looks match (viewer stands in for app) only when their themes have the same record key — the expression, byte-for-byte equal — which is exactly when they render to the same data-mode marker. A theme serves one expression (its key); one look answering to several markers is several self-contained records the owner’s tooling keeps in sync, never a composite blend. An app SHOULD resolve and apply the viewer’s look when it can identify the viewer; honoring the viewer is what makes the standard worth adopting.

Worked example. The viewer prefers ecommerce; they’re on a Home Depot page. The app sets the viewer’s ecommerce at a top-level scope (viewer layer) as their baseline. Inside, the app marks a section data-mode="easter/color" and emits Home Depot’s Easter into the app layer. At that section: its color is set directly on the section by the app’s Easter rule, so it beats the ecommerce color merely inherited from above — the viewer has no Easter, so nothing in the viewer layer sets it there. Its typography, which nothing re-set on the section, keeps inheriting from the viewer’s ecommerce. Home Depot’s Easter colors, the viewer’s type, no merge — and had the viewer had an Easter look, the app would emit it into the viewer layer at that same data-mode, and it would win.

Rule 6 — Aspects are optional, but each is complete

Section titled “Rule 6 — Aspects are optional, but each is complete”

A theme’s aspects carry color, typography, or both — it needn’t fake an opinion it doesn’t have. But whichever aspect it does include is fully defined: every intent of that aspect is present. That’s what lets a present aspect fully paint its slice and lets fill-through work by simple inheritance — there’s never a half-aspect that leaves gaps to reason about.

Rule. An app MUST treat an aspect as valid only if it defines all of that aspect’s intents (currently color = 21, typography = 12 — counts that may grow in later versions; the schema defines the current set, and the author’s tooling validates against it before publishing). An aspect that is present but missing any value is invalid — an app MUST NOT partially apply it; it ignores the invalid aspect and falls through to the next source in Rule 5. An aspect a theme omits is not invalid: it simply isn’t provided, and that scope’s other sources (or the cascade) supply it. (A theme SHOULD carry at least one aspect; one with none paints nothing.)

Rule 7 — Keeping a foreign look contained

Section titled “Rule 7 — Keeping a foreign look contained”

Because every look emits the same variable names (Rule 1 — the very thing that makes looks portable), applying a content author’s look to a scope restyles everything in that scope that uses those names — including the app’s own buttons and controls if they happen to render inside it.

Picture a blog post that carries its author’s look, shown inside a feed reader. The post’s look should dress the post — not quietly repaint the reader’s own toolbar sitting next to it.

Rule. When an app applies a look that isn’t its own (a content author’s, or a viewer’s look over foreign content), it MUST confine that look to the DOM subtree that is purely that content. The app MUST keep its own chrome — navigation, action bars, controls — outside that subtree, or re-apply its own look to that chrome. An author’s look is meant for content surfaces and text, not the host’s controls.

Marking the content element with data-mode contains a look only when its marker is unique to that content — a namespaced com.alice.dark matches only Alice’s subtree, so its variables never reach anyone else’s. But when several sources share a marker — a feed where every author’s post is dressed in its own default — one author’s [data-mode="default"] rule matches every post, and inheritance can’t save you (the rule sets the variable directly on each). An app MUST bound each such look to its own subtree with @scope: writing the look’s <style> inside the post lets a prelude-less @scope { … } root at that post automatically (no selector needed), or use @scope (selector) when the CSS lives elsewhere. Either form restricts which elements the rule matches without adding specificity, so the @layer order is untouched and a viewer’s page-global look still overrides inside the scope. (Separately, for host chrome that must render inside a look’s subtree yet keep the host’s own look, re-mark it with the host’s data-mode, or use @scope (…) to (…) to carve out a donut.)

A safety note that costs nothing to state: a look can only ever set the standard’s known intents to CSS-string values. It can’t inject arbitrary CSS or script. Containment is about layout and legibility, not untrusted code.

Scopes nest, and so do looks. An inner data-mode re-declares the variables for its subtree, so its values apply within it; anything it doesn’t set keeps inheriting from the scope around it. No containment machinery is needed for the ordinary case — a light scope inside a dark one just re-sets the variables it changes.

Rule. An app MUST let nested scopes resolve through ordinary CSS inheritance: a variable set on an inner scope (a nested data-mode marker) applies within it, and a variable it doesn’t set inherits from the nearest enclosing scope that did. Apps MUST NOT implement a separate merge or precedence engine for nesting — the cascade is the mechanism.

Putting it together — resolving one scope

Section titled “Putting it together — resolving one scope”

Each rule above governs one facet. Here’s how they combine into the single procedure an app runs for every scope: first where the candidate looks come from, then the steps, then a full trace.

Where each source’s look comes from:

  • The viewer — you first need to know who they are: their AT Protocol identity (their DID), which an app typically obtains when the viewer signs in (AT Protocol OAuth). Their candidate for a scope is a theme of theirs saved at the scope’s concept key (…/theme/critical) — or, for the page root and any scope no concept has claimed, their default look (the theme they saved at the reserved key default; its own aspects decide what it covers).
  • The author — if the content you’re rendering carries its own theme reference (a publication that points at one), that theme.
  • The app — your own theme for the scope’s concept, or your default look.

The setup, done once: declare the layer order @layer app, author, viewer;. The procedure, run per scope:

  1. Mark what the scope expresses — a data-mode attribute on the scope’s element (the page root, plain content, or a concept the app or author places there). Author markup carries these for its own content; the app adds them for concepts it places.
  2. Gather the candidate themes above (skip any that don’t exist): the viewer’s matching theme and preference, the author’s, the app’s.
  3. Emit each source’s themes into its layerapp, author, or viewer — each rule targeting its data-mode marker by exact name (Rules 1–3), emitting only the aspects it carries (Rule 4). Set the viewer’s preference at a top-level scope as their baseline.
  4. Let the cascade resolve. Layers pick the winner per variable where sources compete on the same marker; inheritance fills each scope from the nearest source and lets a baseline flow into everything unclaimed (Rules 5, 8). Confine any foreign look to its content subtree (Rule 7).

Rule. An app MUST resolve every scope this way — ranking sources by layer and letting the cascade fill each variable independently from the precedence order. It MUST NOT pick a single source for a whole scope, nor implement a separate merge engine — a scope’s color and typography may legitimately come from different sources, and the cascade is the mechanism. Two looks are the “same concept” (viewer stands in for app) only when their themes share the same record key — the expression, byte-for-byte equal.

A worked trace (multi-party). A viewer whose baseline default look is color-only — its default theme carries color but no typography — reads Alice’s post, published with her own tools and rendered inside a feed app. Alice’s post carries her theme alice-zine (color + typography). At the post scope, each aspect resolves on its own:

Aspect viewer author (Alice) app → applied
color default alice-zine app default viewer’s default
typography — (color only) alice-zine app default Alice’s alice-zine

The post renders in the viewer’s colors and Alice’s alice-zine typography — two sources, one scope, no blend, chosen aspect by aspect. Here the two sources meet on the same marker: at the post element the app emits Alice’s alice-zine into the author layer and the viewer’s baseline color into the viewer layer. For --color-*, both layers set it, so the higher viewer layer wins; for --typography-*, only the author layer sets it (the viewer’s default carries no typography), so Alice’s comes through. Alice’s look is confined to the post subtree (Rule 7); it doesn’t leak into the feed app’s surrounding chrome. Had the viewer’s default carried typography too, the viewer layer would set it and Alice’s wouldn’t appear here — an author’s look shows wherever the viewer hasn’t expressed their own, by design. (A viewer pins their baseline to one aspect by making the default theme itself carry only that aspect — the pin is the theme’s own composition, there is no separate pointer to fragment.)

One remaining detail. Because every value is only a CSS string, a malformed one simply doesn’t render (like any invalid custom property); an app needs no special validation and MUST NOT reject a whole theme over one bad value — that value just has no effect.

The standard checks that values are well-formed strings. It can’t check that a look is readable — that its colors have enough contrast, that its text is legible.

Whoever authors a look’s values owns it — including responsibility when it fails someone. What makes that fair is the viewer’s guaranteed ability to apply their own look instead: nobody is ever trapped in a look that doesn’t work for them. For that safety valve to hold, a conforming app SHOULD honor the viewer’s look when they have one (Rule 5). Author-owns-accessibility plus the viewer’s guaranteed override together form the accountability model — without the standard having to police accessibility itself.

That override reaches even a specific, branded concept. Because looks match by exact record key, a viewer who needs a different take on a particular look saves a theme at that concept’s key — a brand’s own …/theme/com.homedepot.easter, carrying the values they want there — and a conforming app applies the viewer’s version wherever the concept is placed. (Their tooling can generate it from a look they already have; on the wire it’s a distinct record keyed by the brand’s concept.) A namespaced concept is therefore reserved, not locked: a generic viewer preference slides past it, but a deliberately-saved one supersedes it. The more generic the concept an app places (bare dark), the more readily it invites a viewer’s look; the more specific (com.homedepot.easter), the more deliberate the override must be — but it is always available. (See Common expressions.)

  • How to deliver the CSS — the standard fixes the variable names (Rule 1), the data-mode marker and matching rule (Rules 3–4), and the layer ranking (Rule 5), not how the bytes reach the page. An app MAY write the rules in an inline <style> (server-rendered into the HTML for no flash, or appended to <head> after the viewer signs in), or serve them and <link> them — as long as the variables land on the marked element (never :root), each source sits in its layer, and the aspect selectors are as Rule 4 specifies. Any approach that puts the same names at the same markers in the same layers conforms. (@scope remains available as optional sugar for the containment donut of Rule 7.)
  • Where to honor an author’s look — an app MAY apply it in a detail view but not in a dense feed, if that reads better.
  • Interactive states — resting values are all the standard defines; an app MAY derive hover/focus/pressed from them however it likes.
  • Whether to honor viewer looks at all — an app that only ever uses its own look is still a valid consumer; it simply forgoes most of the standard’s value.

An app is conforming when it:

  • MUST name variables exactly by schema path (Rule 1); declare fonts once, document-global (Rule 2); apply a look by marking its scope with data-mode and setting the variables on that element, never :root (Rule 3); honor the two-form data-mode grammar and set only the selected aspect’s variables (Rule 4); rank sources with @layer app, author, viewer so each variable fills from the most-preferred source that sets it, match concepts by record key (a theme serves one expression — its key; a look answering to several markers is several owner-synced records, never a blend), and never place a look outside its layer (Rule 5); treat each aspect as valid only if complete, and never partially apply an incomplete one (Rule 6); contain foreign looks to their content (Rule 7); resolve nesting through inheritance (Rule 8); and resolve every scope by layer and cascade — never picking one source for a whole scope (§ Putting it together).
  • SHOULD honor the viewer’s look when it can identify the viewer (Rule 5); keep its own chrome out of foreign-look subtrees (Rule 7).
  • MAY choose where to honor author looks, derive interactive states, deliver the CSS inline or as served files, use @scope for containment donuts, and — though it forgoes the point — ignore viewer looks entirely.