Theme Token Inheritance Model

Updated: 2026-08-29 · Status: proposal — not implemented; superseded by a simpler shipped design (AB#2609)

Reconciled 2026-08-29 (AB#2609) — this proposal was not built. The Mobile Content API that actually shipped (see Dynamic Feed — Integration Guide and Mobile Admin Portal — Integration Guide) does not implement the ThemeNode/ scopeType/scopeKey/parentThemeId cascading model described below, at any rollout phase. The real model is simpler: a theme is an independent row (GUID id + key + a full, non-sparse tokens dictionary), assigned to a Site via activeThemeId/activeThemeKey with an optional full-swap override at the Page level — no cascade, no per-node status, no mainBanner field, and no GET /api/theme/{mecId} endpoint. See Theme Data Structure — Schema Reference for the real schema. The rest of this document is kept as a historical record of the proposal and why it was considered — read it as design exploration, not as a description of current or future-planned behavior.
Token count referenced below is also unsettled. Every "45" here (and the worked examples' arithmetic built on it) described a pre-shipped themes.json test-data baseline. The real, authoritative count — independent of this proposal's cascade model — is 95 tokens across 15 categories, per the Admin Portal guide's token registry (updated 2026-08-26, the most recently updated of Vlad Oprica's docs); see the token-count warning in theme-endpoint-contract.html for the full accounting, including a "107" figure cited in an earlier revision of this doc that was never independently re-verified.
TL;DR — The current theme test data (themes.json) stores every theme as a full, standalone copy of all ~45 design tokens. Once themes exist per Site and per Page, that duplication becomes expensive to maintain and impossible to bulk-update safely. This doc proposes a sparse-override model: each theme node stores only the tokens it changes, plus a parentThemeId, and the effective token set is resolved by cascading from the root down — the same mental model as CSS custom properties, Android style parent=, or Figma variable modes. Container and Item deliberately sit outside this cascade — see Proposed model.
Rollout status — superseded, not reserved. This section originally framed scopeType/scopeKey/parentThemeId as columns being reserved now for a cascade to switch on later ("Phase 1: present but unused" → "Future: cascading"). That rollout never happened. The shipped schema has no such columns at all, reserved or otherwise — per-Page theme overrides shipped as a plain activeThemeId full-swap field on PageDto instead (see Theme assignment: Site and Page). Read the rest of this document purely as a record of the design that was considered and not taken.

Contents

Context

Themes will be applied by an MEC Admin through a WIP Admin tool. This doc covers the backend data structure and test data for that tool — not the tool's UI itself. Tokens are assigned per theme. The intended hierarchy, once turned on (see the rollout-status note above — none of this is active for the initial rollout), is:

UAL is used as the running example throughout — including in the Phase 1 (current rollout) examples below, where it's simply a Site-level row with no parent, same as every other MEC.

Current state: full-copy themes

Today's themes.json is a flat array of standalone theme rows, each carrying a complete copy of all 45 tokens:

{
  "themeId": 2,
  "description": "UAL Theme 1",
  "mecId": "UAL",
  "mainBanner": "/banners/ual-main-banner.png",
  "tokens": { /* all 45 tokens, most identical to "Default" */ }
}

Diffing the two existing entries (Default / mecId ALPA, and UAL Theme 1 / mecId UAL) shows only 10 of 45 tokens actually differ:

TokenDefaultUAL Theme 1
Surface/Brand#05273e#002243
Text/On-Brand#ffffff#bed6fb
Text/Link#007bc2#94ebfe
Border/Brand#05273e#0008ce
Icon/Active#007bc2#94ebfe
Action/Primary#007bc2#0008ce
Action/Accent#007bc2#94ebfe
Nav/Background#05273e#002243
Font/HeadingFuturaPTLeagueSpartan
Font/BodyFuturaPTLeagueSpartan

The other 35 — every Status/*, Spacing/*, and BorderRadius/* token, plus a handful of others — are byte-identical duplicates.

Problems with full-copy at scale

These problems are deferred, not solved, by the initial rollout — Phase 1 keeps every row a full, exhaustive copy (per the rollout-status note above). They only start getting solved once Page/LEC-level rows actually begin populating parentThemeId and storing sparse overrides instead of full copies. They're listed here as the reason the columns are being reserved now, not as problems Phase 1 itself fixes.

Proposed model

Add five fields to every theme node: scopeType, scopeKey, parentThemeId, status, and modifiedAt. Only the root node in a chain is required to be exhaustive (define all 45 tokens); every other node stores only what it changes.

scopeType and scopeKey answer two different questions, and both are needed because neither alone is enough to say what a theme node is for:

Together they're how a resolver (or an Admin tool query) finds the right node without needing a separate table per tier: "give me the Page node with scopeKey = "UAL/HomePage"" is a single, uniform lookup regardless of how many other Pages or Sites exist. scopeKey's "MEC/PageName" convention (and "MEC/LEC-Name" for LEC pages) is just a readability choice for this doc's examples, not a structural requirement — the field is an opaque string as far as resolution is concerned, so nothing actually parses it apart at that /.

status and modifiedAt support the Admin tool's Live/Draft workflow and versioning:

Phase 1 (today): columns present, nothing cascades

Every row is its own root — parentThemeId: null on both, each carrying its full, independent 45-token copy exactly as themes.json does today. scopeType/scopeKey replace mecId one-for-one; nothing else about how these rows behave changes:

{
  "themeId": 1,
  "scopeType": "Site",
  "scopeKey": "ALPA",
  "parentThemeId": null,
  "status": "Live",
  "modifiedAt": "2026-06-26T00:00:00Z",
  "description": "Default",
  "mainBanner": "/banners/default-main-banner.png",
  "tokens": { /* all 45 tokens */ }
},
{
  "themeId": 2,
  "scopeType": "Site",
  "scopeKey": "UAL",
  "parentThemeId": null,
  "status": "Live",
  "modifiedAt": "2026-06-26T00:00:00Z",
  "description": "UAL_Base",
  "mainBanner": "/banners/ual-main-banner.png",
  "tokens": { /* all 45 tokens — still a full copy, same as UAL Theme 1 today */ }
}

Future (once enabled): the same rows, cascading

The rest of this document describes what becomes possible once someone actually sets a non-null parentThemeId and starts storing sparse overrides instead of full copies — nothing below is active in Phase 1. Default becomes the root UAL_Base cascades from, rewritten as a sparse override:

{
  "themeId": 1,
  "scopeType": "Site",
  "scopeKey": "ALPA",
  "parentThemeId": null,
  "status": "Live",
  "modifiedAt": "2026-06-26T00:00:00Z",
  "description": "Default",
  "mainBanner": "/banners/default-main-banner.png",
  "tokens": { /* all 45 tokens — root must be exhaustive */ }
},
{
  "themeId": 2,
  "scopeType": "Site",
  "scopeKey": "UAL",
  "parentThemeId": 1,
  "status": "Live",
  "modifiedAt": "2026-06-26T00:00:00Z",
  "description": "UAL_Base",
  "mainBanner": "/banners/ual-main-banner.png",
  "tokens": {
    "Surface/Brand": "#002243",
    "Text/On-Brand": "#bed6fb",
    "Text/Link": "#94ebfe",
    "Border/Brand": "#0008ce",
    "Icon/Active": "#94ebfe",
    "Action/Primary": "#0008ce",
    "Action/Accent": "#94ebfe",
    "Nav/Background": "#002243",
    "Font/Heading": "LeagueSpartan",
    "Font/Body": "LeagueSpartan"
  }
}

A Page-level ThemeVariation under it — say HomePage only wants a different Surface/Brand, and an Admin is still tuning it — becomes a single-key override sitting in Draft:

{
  "themeId": 7,
  "scopeType": "Page",
  "scopeKey": "UAL/HomePage",
  "parentThemeId": 2,
  "status": "Draft",
  "modifiedAt": "2026-07-20T15:42:00Z",
  "description": "UAL HomePage",
  "tokens": {
    "Surface/Brand": "#001a33"
  }
}

End users still see the Live UAL_Base cascade for HomePage until an Admin flips themeId: 7 to Live; until then, only Admin preview resolves this node — see Resolving the effective theme.

An LEC is a Page too — but unlike a plain content page, it's meant to act as its own base for whatever is scoped beneath it (its sub-pages, containers, items), the same way UAL_Base acts as a base for Pages under the UAL Site. Structurally it's identical to any other Page node — same scopeType: "Page", same parentThemeId pointing at the MEC's Site Base — it just happens to sit higher in practice, with its own children pointing their parentThemeId at it instead of at the Site directly:

{
  "themeId": 8,
  "scopeType": "Page",
  "scopeKey": "UAL/LEC-Denver",
  "parentThemeId": 2,
  "status": "Live",
  "modifiedAt": "2026-07-01T09:15:00Z",
  "description": "UAL LEC Denver Base",
  "tokens": {
    "Surface/Brand": "#003a66"
  }
}

Container and Item: inline overrides, not cascading nodes

Extending the same node-per-scope pattern down to Container and Item is deliberately not done — a full ThemeNode row (with its own scopeType, scopeKey, parentThemeId, status, and modifiedAt) is overkill for what's typically a one-off tweak to a single container or item. Instead, the Container and Item content models each get a single field directly on their own record:

// on the Container or Item model itself — not a separate theme table
{
  "id": "hero-container-3",
  "...": "...",
  "themeTokenOverrides": {
    "Surface/Brand": "#003a66"
  }
}

This is a structured, sparse dictionary (validated against the same canonical token set as every other scope — see open decisions), not literal free-form text. The distinction that matters: it stores overrides the same shape as a ThemeNode.tokens dictionary, but it isn't itself a node in the inheritance chain — there's no parentThemeId, no independent lifecycle, no lineage to query. It's just a field on the entity being themed, merged on top of whatever Page it lives under at resolution time. Any draft/publish or audit behavior it needs rides along with the Container/Item's own content lifecycle, rather than the theme system modeling it a second time.

Resolving the effective theme

Walk parentThemeId from root to the target Page, then layer each node's tokens on top of the previous (last write wins). The end-user resolve only ever considers Live nodes; an Admin previewing a Draft node passes mode: "preview" to include it:

resolve(themeId, mode: "live" | "preview" = "live"):
    chain = walk parentThemeId from themeId up to the root, then reverse (root → leaf)
    resolved = {}
    sourceOf = {}                 # token key -> themeId that actually set it
    latestModifiedAt = null       # feeds ThemeResponse.ThemeVersion — see below
    for node in chain:
        if node.status != "Live" and mode != "preview":
            continue              # a Draft ancestor is invisible to end users
        for key, value in node.tokens:
            resolved[key] = value
            sourceOf[key] = node.themeId
        latestModifiedAt = max(latestModifiedAt, node.modifiedAt)
    return resolved, sourceOf, latestModifiedAt

This is the same cascade model as CSS custom properties, Android's style parent=, or Figma variable modes. It only ever walks Site → Page — Container and Item apply on top as a fixed, final merge step, not part of the walk:

pageTokens, pageSourceOf, pageModifiedAt = resolve(page.themeId)

containerTokens = merge(pageTokens, container.themeTokenOverrides ?? {})
itemTokens      = merge(containerTokens, item.themeTokenOverrides ?? {})

No parentThemeId chain to walk at this level — there's exactly one merge for Container, one more for Item, done. (Container/Item don't contribute their own timestamp to latestModifiedAt here since they aren't ThemeNodes — see Container and Item; their own content record's modification timestamp already covers that at the content-entity level.)

This never connected to a real endpoint. This section originally assumed the (also-proposed, also-not-built) GET /api/theme/{mecId} contract would expose a ThemeResponse.ThemeVersion/?since=/ 304 mechanism fed by this model's latestModifiedAt. The real, shipped theme endpoint (GET /api/mobilecontent/dynamic/theme/{themeId}) has no such versioning field and no mecId parameter — see theme-endpoint-contract.html. Nothing in this section describes real, connectable behavior.

Admin UI: inherited vs. overridden

The sourceOf map from resolution gives the Admin tool everything it needs to show, for the node currently being edited:

Each field renders the resolved value either way, but greyed out with a "Reset to inherited" action when it's not this node's own, or normal (with the same reset action, which removes the key and falls back to the cascade) when it is.

The same affordance works unchanged when editing a Container or Item, even though they aren't cascading nodes: "overridden" is just "present in container.themeTokenOverrides / item.themeTokenOverrides," and "inherited" falls back to the resolved Page tokens one level up — a single check instead of a sourceOf lookup through a chain.

Open decisions

Root exhaustiveness is a hard invariant. Enforce at save-time that any node with parentThemeId: null defines every canonical token — otherwise resolution can silently bottom out at undefined for some scope deep in the tree. This also defines the "denominator" (45) for the Admin UI for free, rather than maintaining a separate canonical token-key list.
Override is presence-based, not value-based. If an admin explicitly sets a token to the same value it would've inherited, that still counts as "overridden" (the key is present in tokens) — it should not silently collapse back to inherited. Otherwise editing-then-reverting-by-hand behaves differently than clicking "Reset to inherited," which is a confusing UX inconsistency.
Catalog vs. tree naming. "UAL Theme 1" reads like one of several selectable named presets ("Theme 1" implies a "Theme 2" exists), not "the Base for UAL." Confirm whether this test data models "candidate presets an admin picks from" or the Base/Variation tree described above — they're different designs, and the naming convention should make the distinction obvious.
mainBanner is a non-token, asset-style override, sitting outside tokens. Decide whether every scope level can override it, or whether it's Site-only. A full 45-token palette override is probably overkill at Item level, but "just swap this one image" might be exactly what's wanted there.
status/modifiedAt cover Live/Draft and versioning, but not full audit history. They answer "is this live?" and "when did it last change?" — enough to drive the Admin tool's publish flow and the Theme Endpoint's ThemeVersion cache-busting. They do not answer "what did it change from?" or "who changed it?" — createdBy and a real version-history table (previous values, not just the current one) remain open; see per-node version history for going further than a single timestamp.
Token keys are unvalidated strings — and this now matters at Container/Item too. "Category/Name" string keys are a fine lightweight convention (matches Figma variable naming), but nothing catches a typo'd key at write time. This was a nice-to-have for Site/Page nodes edited through a dedicated theme-management screen; it's load-bearing for themeTokenOverrides on Container/Item, since that field is meant to be a quick, structured-but-inline edit — the Admin tool must validate override keys against the canonical token set at every level, not just the ones with their own dedicated screen.

Prior art