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.
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.
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.
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.
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_Base is the Site-level Base theme for UAL. LECs are not their own Site.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.
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:
| Token | Default | UAL 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/Heading | FuturaPT | LeagueSpartan |
Font/Body | FuturaPT | LeagueSpartan |
The other 35 — every Status/*, Spacing/*, and
BorderRadius/* token, plus a handful of others — are byte-identical
duplicates.
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.
Status/Error) requires bulk-editing every copy, with no way to tell
"this row customized Status/Error on purpose" from "this row just has
the old default because it was copied before the change."UAL_Base," or "what actually changed here relative to its base."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:
scopeType answers "what kind of thing is this?" —
a fixed, small set of values ("Site", "Page"). It's the
category, not the instance — think of it like a table name, or the Kind
in a Kubernetes resource.scopeKey answers "which one, specifically?" — a
string identifying the actual instance within that category:
"UAL" for a Site, "UAL/HomePage" for a Page. It's the
instance identifier, not the category — the primary key within that "table," or the
Name alongside a Kubernetes Kind.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:
status — enum, "Live" or "Draft". Only
Live nodes are used when resolving the theme served to end users;
Draft nodes exist for an Admin to edit and preview without affecting
anyone else. An Admin publishes a Page (or Site) by flipping its
status from Draft to Live.modifiedAt — ISO-8601 timestamp, updated whenever a node's own
tokens (or mainBanner, or status) changes.
This is the per-node building block that the
Theme
Endpoint's ThemeResponse.ThemeVersion is derived from — see
Resolving the effective theme for how the two connect.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 */ }
}
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"
}
}
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.
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.)
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.
The sourceOf map from resolution gives the Admin tool everything it
needs to show, for the node currently being edited:
sourceOf[key] == node.themeId; count = len(node.tokens).sourceOf[key] identifying exactly which ancestor (and its scopeType/scopeKey) it came from, e.g. "inherited from UAL_Base."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.
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.
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.
"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.
"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.
var() resolution through the DOM tree)<style parent="..."> theme inheritance