# Render themes in your app

This guide is for **app builders**: your app reads themes people have published and renders content their way. (Want to publish a look — a person's, a brand's, or your app's own? See [Publish a theme](https://standard.mode.place/guides/publish-a-theme/).) It uses the AT Protocol directly with `@atproto/api`; there's no SDK of ours to learn.

:::note[What you're working with]
A theme is a **record** on an account, like a Bluesky post — you read it with `@atproto/api`. Field shapes are in the [Schema reference](https://standard.mode.place/reference/schema/); the rules this guide implements are the [Mode Contract](https://standard.mode.place/contract/).

Prefer generated types? The schemas are ordinary lexicon JSON, so the ecosystem's standard codegen works as-is: `npx @atproto/lex-cli gen-api ./generated <the lexicon JSON files>` produces a fully typed client for the `place.mode.standard.*` records. The examples below hand-write minimal types only to stay self-contained.
:::

Your app does two things: **generate** CSS from a theme record, and **write** it into the page as a `data-mode`-targeted rule inside a cascade layer. The generation is a small function (typed below); where the bytes go is a `<style>` you render (server-side, or appended once the viewer signs in).

The app is the **orchestrator** — it writes every `data-mode` marker, so it knows every expression on the page and *when* each source's CSS becomes available. There are three sources, and they arrive at **three different lifecycles**:

| Source | Layer | When it's known | Placement |
|---|---|---|---|
| **The app's own look** | `app` | build / app load | one page-global baseline |
| **Each author's look** | `author` | *as the app renders that post* | scoped to that post (it holds the DID + container right then) |
| **The viewer's look** | `viewer` | once the viewer signs in (maybe after first paint) | one page-global override, appended once |

The key one is the middle: an author's CSS is produced *in the same step that writes the author's post*, because that's exactly when the app has the author's DID and the post's container in hand. The cascade (`@layer` + inheritance) reconciles the three whenever each arrives — they never coordinate.

## 1. Identify the viewer

Get the viewer's DID via the same [AT Protocol OAuth](https://atproto.com/specs/oauth) sign-in shown in [Publish a theme](https://standard.mode.place/guides/publish-a-theme/): they authorize your app, and the session identifies them (`agent.assertDid`).

## 2. Read a theme and render it to CSS

Fetch the record, then turn it into CSS: for each aspect the theme carries, one rule ([Rule 1](https://standard.mode.place/contract/) names, [Rule 4](https://standard.mode.place/contract/) selector) inside the source's cascade layer, plus each font's `@font-face` at the top level.

```ts
import { AtpAgent } from "@atproto/api";

type Owner = "app" | "author" | "viewer"; // the cascade layers, least- to most-preferred
// The parts of a theme record the renderer reads (full shape → Schema reference).
type ThemeRecord = {
  aspects: Record<string, unknown>;                 // → scoped variables
  fonts?: Array<{ family: string; src?: string }>;  // → document-global @font-face
};

// getRecord must be sent to the PDS that HOSTS the repo — a PDS serves only its own
// repos, so the viewer's, an author's, and the app's themes each come from their owner's
// host. Resolve each DID's document to its PDS endpoint first; themes are public records,
// so no auth is needed. (This resolves did:plc; a did:web resolves via its
// /.well-known/did.json instead — @atproto/identity's IdResolver handles both.)
const agents = new Map<string, AtpAgent>();
async function agentFor(did: string): Promise<AtpAgent> {
  let agent = agents.get(did);
  if (!agent) {
    const doc = await fetch(`https://plc.directory/${did}`).then((r) => r.json());
    const pds = doc.service.find((s: any) => s.id === "#atproto_pds").serviceEndpoint;
    agents.set(did, (agent = new AtpAgent({ service: pds })));
  }
  return agent;
}

// Resolution is a DIRECT LOOKUP: the expression is the record key, so you fetch the
// theme at that key — from its owner's PDS. The viewer's default look (what the Mode
// Contract calls their "baseline") is just the theme at key "default"; a concept scope
// wants the theme at that concept's key (e.g. "critical"). No listing-and-scanning.
async function getTheme(did: string, expression: string): Promise<ThemeRecord | null> {
  const agent = await agentFor(did);
  return agent.com.atproto.repo
    .getRecord({ repo: did, collection: "place.mode.standard.theme", rkey: expression })
    .then((res) => res.data.value as ThemeRecord)
    .catch(() => null); // 404 — no theme at that key
}

const viewerDefault = await getTheme(viewerDid, "default");
// the viewer's default is page-global → fonts and rules both go to <head>, nothing scoped
const css = viewerDefault
  ? fontFaces(viewerDefault) + themeToCss(viewerDefault, "default", "viewer")
  : "";
```

`themeToCss` walks the theme, kebab-cases each path into a `--variable`, and wraps each aspect in a rule that matches its `data-mode` marker — the whole look **or** that aspect alone:

```ts
const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());

// Walk an aspect subtree and return one `--var: value;` line per leaf. Pure recursion —
// every leaf under an aspect is a variable, no special cases.
function collectVars(node: Record<string, any>, path: string[] = []): string[] {
  return Object.entries(node).flatMap(([k, v]) =>
    v && typeof v === "object"
      ? collectVars(v, [...path, k])
      : [`      --${[...path, k].map(kebab).join("-")}: ${v};`],
  );
}

// record → its @font-face rules. A @font-face is document-global wherever it's declared, so
// it just needs to sit at the TOP LEVEL of a style block, OUTSIDE any @scope (never nested in
// one). Kept OUT of themeToCss so the scopable rules can be wrapped in @scope while the fonts
// sit right beside them, un-scoped. (Two posts sharing a family each declare it; browsers load
// a given src once, so that's harmless — dedupe only if you care to.)
function fontFaces(record: ThemeRecord): string {
  return (record.fonts ?? [])
    .map((f) => `@font-face { font-family: "${f.family}"; src: url("${f.src}"); }`)
    .join("\n");
}

// record → its layered variable rules. `expression` is the record's key (the marker to
// match); `owner` is the @layer that ranks this source (app | author | viewer). For each
// aspect the theme carries, one rule targets the whole look ([data-mode="dark"]) or that
// aspect alone ([data-mode~="dark/color"]); :where() keeps specificity flat so layer order
// decides. Generic over aspects — reads the `aspects` container, so new aspects emit for free.
//
// PLACEMENT-AGNOSTIC by design: the output holds no @scope and no @font-face, so the SAME
// string is correct wherever you put it — append it to <head> for a page-global source (app,
// viewer), or wrap it in `@scope (…) { … }` to contain it to one subtree (an author's post,
// below). Where it lands is the caller's call, not this function's.
function themeToCss(record: ThemeRecord, expression: string, owner: Owner): string {
  const rules = Object.entries(record.aspects ?? {})
    .map(([aspect, tree]) => {
      const vars = collectVars({ [aspect]: tree }); // keep the aspect key so names stay full: --color-…
      // bare marker (whole look) OR its aspect fragment (Rule 4).
      const sel = `[data-mode="${expression}"], [data-mode~="${expression}/${aspect}"]`;
      return `    :where(${sel}) {\n${vars.join("\n")}\n    }`;
    })
    .join("\n");

  return `@layer ${owner} {\n${rules}\n}`;
}
```

For `theme.aspects.color.surface.primary.background` this yields `--color-surface-primary-background` inside `:where([data-mode="dark"], [data-mode~="dark/color"])` — every conforming renderer produces the *same* names, which is what lets a theme move between apps. Fonts come from `fontFaces(record)` and are written globally, once, wherever the theme's text lands.

## 3. Mark the scopes and write the CSS

Declare the layer order **once**, mark each scope with a `data-mode` attribute whose value is a theme's expression — its record key (that's the whole matching key — `data-mode="dark"` picks up the theme saved at key `dark`), and drop each source's CSS into a `<style>`. The marker is plain markup; the variables land on the marked element and inherit inward — never `:root`.

```html
<style>@layer app, author, viewer;</style>   <!-- precedence, declared once: viewer wins -->
<style>/* fontFaces(appDefault) + themeToCss(appDefault, "default", "app") — the app's own default, the fallback */</style>

<body data-mode="default">
  <!-- the page-root scope: the app's default and the viewer's default both live at key
       "default", so both target it; the viewer's wins by layer, the app's fills any gap. -->
  <section data-mode="easter/color">
    <!-- just this section: Easter's colors; the baseline's typography keeps inheriting -->
  </section>
</body>
```

The viewer's CSS from step 2 goes in the **`viewer`** layer, so it wins over the app's default wherever they meet on the same marker. Because it's personal and arrives after login, you render it server-side into the page (no flash) or append it once:

```ts
const style = document.createElement("style");
style.textContent = css;          // fontFaces(viewerDefault) + themeToCss(viewerDefault, "default", "viewer")
document.head.append(style);       // layer order decides — placement in the DOM doesn't
```

That single append is the whole runtime cost. It doesn't matter *where* it lands or *when* — the `@layer` order ranks the sources and the `[data-mode]` markers the author already wrote route it to every matching scope. Fill-through and nesting ([Rules 5](https://standard.mode.place/contract/), [8](https://standard.mode.place/contract/)) are then just the cascade: a `data-mode="default"` scope takes whatever the winning layer sets and inherits the rest.

## Which theme, and whose

You don't hand-pick a winner per scope — you emit each source into its layer and let the cascade resolve. To honor the viewer over the app's or author's look for the same concept, fetch the *viewer's* theme at that expression's key (`getTheme(viewerDid, concept)`) and render it into the `viewer` layer; because that layer outranks `author` and `app`, it wins per variable, and anything it doesn't set falls through. The precedence and exact record-key matching are the [Mode Contract](https://standard.mode.place/contract/)'s Rule 5; the expression *is* the `data-mode` key, which *is* the record key.

## Rendering authors' looks: feeds and containment

Everything above covers two of the three sources: your app's look and the viewer's. The third — **content authors**, each post dressed in its author's own look — brings a problem of its own: every author's `default` shares one marker and one layer, so each look must be **contained** to its own content ([Mode Contract, Rule 7](https://standard.mode.place/contract/#rule-7--keeping-a-foreign-look-contained)). That's its own guide: **[Feeds and containment](https://standard.mode.place/guides/feeds-and-containment/)** — it reuses `getTheme`, `fontFaces`, and `themeToCss` exactly as defined here.

## Optional: a shared gateway

A single **published** theme is a pure function of a public record, so instead of every app rendering it, a **gateway** could serve any record as a cacheable stylesheet at a stable URL — `https://css.example/{did}/{expression}.css`, where `{expression}` is literally the record key — that apps `<link>`. (A raw DID has colons, fine in a URL path but not a Windows filename, so a static host encodes it or uses the handle; a dynamic gateway keeps the DID.) A gateway is a **trust boundary**: it must emit only the known `--…` variables inside the `@layer` / `[data-mode]` wrapper, never arbitrary CSS.

## Next steps

- **[The live demo](https://standard.mode.place/demo/)** — this guide's functions, running in your browser on fixture themes.
- **[Feeds and containment](https://standard.mode.place/guides/feeds-and-containment/)** — ingest authors' looks: many on one page, each contained to its own content.
- **[The Mode Contract](https://standard.mode.place/contract/)** — the full rules this guide implements.
- **[Schema reference](https://standard.mode.place/reference/schema/)** — every field of every record you'll read.
- **[Publish a theme](https://standard.mode.place/guides/publish-a-theme/)** — your app's own look is published the same way as a person's.