Reconciled 2026-08-29 (AB#2609). This contract originally proposed a per-mecId endpoint (GET /api/theme/{mecId}) with a ThemeVersion/?since=/304 Not Modified caching scheme. The Mobile Content API that actually shipped (see Dynamic Feed — Integration Guide) is structured differently: themes are independent rows, identified by GUID and semantic key, fetched via GET /api/mobilecontent/dynamic/theme/{themeId} or GET /api/mobilecontent/dynamic/theme (all themes), and assigned to a Site or overridden per-Page via activeThemeId/activeThemeKey. There is no mecId concept, no ThemeVersion field, and no conditional-GET support documented for this endpoint. §2, §3, §4, §8, §9, and §10 below have been corrected accordingly; the client-hydration mechanics in §5–§7 (token → CSS var / XAML resource mapping, value-format parsing) work on the same flat Dictionary<string,string> token map either way and are unaffected by the endpoint-shape correction.
ALPA Mobile supports per-Site branding ("MEC theming"): color palettes, icon tints, and navigation chrome that differ between pilot groups (e.g. United Airlines vs ALPA base). The theme endpoint is an anonymous REST call that returns the complete token map for a given theme, resolved to its final values. The client fetches the pilot's Site's active theme once at launch, caches it, and uses it to hydrate the component library before the first screen renders.
The page feed (Dynamic Feed — Integration Guide) carries only semantic token names — e.g. "Surface/Brand" — never raw color values. The theme endpoint is what resolves those names to actual hex values for the pilot's Site (or Page override). These two endpoints are orthogonal: the feed changes frequently; the theme changes rarely.
The full wire contract — HTTP signature, response codes, and the shipped ThemeDto — is maintained by the backend team and not duplicated here: see Dynamic Feed — Integration Guide ("Get Theme by ID" / "Get All Themes") and Admin Portal — Integration Guide. Condensed 2026-08-29 — this section previously restated that contract in full; it had drifted twice already (see WI 2609) because two documents describing the same shape is exactly what goes stale. The facts below are the ones this app's client code actually depends on and that Vlad Oprica's guides don't spell out from the client's point of view.
| Resolution path | No mecId. The client fetches the pilot's Site (GET /api/mobilecontent/dynamic/site/{siteKey}), reads activeThemeId, then fetches that theme by GUID (GET /api/mobilecontent/dynamic/theme/{themeId}). A Page may carry its own activeThemeId override, taking precedence for that screen only. GET /api/mobilecontent/dynamic/theme (no id) returns every active theme, for pre-fetching the whole catalogue at launch. |
| No reserved "base" theme | Every theme is just a row with a key (e.g. ual-default) — there is no stable mecId=ALPA fallback identifier. Client-side fallback for an unresolvable theme: Open Item OT-7. |
| No conditional-GET | No ThemeVersion/?since=/304 mechanism — every fetch returns the full ThemeDto. Client-side caching strategy this implies: §8. Dynamic endpoints are [AllowAnonymous], so 401 doesn't apply; 404 on an unknown/soft-deleted themeId falls back per §9. |
Id type | Treat as opaque and pass it straight through — Vlad Oprica's two guides disagree on its CLR type (int in dynamic-feed.html vs. guid in admin-portal.html); not resolved here. |
| Payload shape | Complete, not a delta — every token for the theme, resolved values only ("Surface/Brand" → "#002243"), no semantic aliasing left for the client. Nothing to merge with a base dictionary. |
Field-by-field ThemeDto shape lives in the Integration Guides linked above. The token namespace catalogue below is this app's own reference — Vlad's guides don't enumerate it — and is the part worth keeping current here.
Token count is unsettled across this doc set — 2026-08-29. This section originally counted 45 tokens (a pre-shipped test-data baseline). theme-token-inheritance.html separately cited a corrected count of 107, based on one live FDX payload and never re-verified. The authoritative source is the Admin Portal guide's token registry (admin-portal.html — Token Endpoints, updated 2026-08-26, the most recently updated of Vlad Oprica's docs): 95 tokens across 15 categories — Surface, Text, Border, Icon, Action, Navigation, Status, Typography, Font Size, Font Weight, Text Transform, Letter Spacing, Spacing, Border Radius, and Icon Size. (The Ingestion API guide separately says "91 registry tokens are recommended for a complete theme" — a minor inconsistency in Vlad's own docs this doc does not attempt to resolve.) None of Vlad's docs enumerate the full 95-token list by name. The table below covers the subset already documented elsewhere in this doc set; the remaining categories (additional Typography/Font Size/Font Weight/Text Transform/Letter Spacing/Icon Size entries) are an open item — see OT-8.
Summary of the namespaces confirmed so far in every ThemeDto.Tokens response:
| Namespace | Tokens | MEC-variable? |
|---|---|---|
| Surface | Surface/Brand · Surface/Default · Surface/Primary · Surface/Subtle | Yes — Brand |
| Text | Text/Primary · Text/Secondary · Text/On-Brand · Text/On-Subtle · Text/Link · Text/Disabled | Yes — On-Brand, Link |
| Border | Border/Brand · Border/Default · Border/Subtle | Yes — Brand |
| Icon | Icon/Default · Icon/On-Brand · Icon/Active | Yes — On-Brand, Active |
| Action | Action/Primary · Action/PrimaryText · Action/Accent | Yes — Primary, Accent |
| Nav | Nav/Background · Nav/Active · Nav/Inactive · Nav/Hover | Yes — Background |
| Status | Status/Error · Status/Success · Status/Warning · Status/Info · Status/OnTime | No — fixed values |
| Font | Font/Heading · Font/Body — font-family strings per MEC brand. Base: "FuturaPT". UAL: "LeagueSpartan". DAL heading: "BebasNeue", body: "LeagueSpartan". FDX: TBD (DQ-14). See D33. | Yes — both slots |
| Spacing | Spacing/xtiny · Spacing/tiny · Spacing/xxsmall · Spacing/xsmall · Spacing/small · Spacing/medium · Spacing/large · Spacing/xxlarge · Spacing/page-margins | No override today — all MECs receive ALPA base values. Future MEC overrides require only a server change, no app deployment (D35). |
| BorderRadius | BorderRadius/None · BorderRadius/S · BorderRadius/M · BorderRadius/L · BorderRadius/XL · BorderRadius/Full | No override today — all MECs receive ALPA base values. Used by ContainerDto.CornerRadiusToken in the dynamic feed; resolved by client at render time (D38). |
GET /api/mobilecontent/dynamic/theme/{ual-default-theme-guid}GET /api/mobilecontent/dynamic/theme/{alpa-default-theme-guid}There is no reserved mecId=ALPA route (see §2). This is simply the theme whose key a Site with no MEC-specific branding has assigned as its activeThemeId — fetched and cached the same way as any other theme, no special-cased request needed.
The token path in the response ("Surface/Brand") must be mapped to a framework-specific identifier before it can be applied. The mapping rule is mechanical: forward-slash and hyphens become separators; each segment is title-cased for XAML keys; lowercased with hyphens for CSS vars.
| Token Path | CSS custom property | XAML resource key |
|---|---|---|
Surface/Brand | --surface-brand | SurfaceBrand |
Surface/Default | --surface-default | SurfaceDefault |
Text/On-Brand | --text-on-brand | TextOnBrand |
Text/Link | --text-link | TextLink |
Border/Brand | --border-brand | BorderBrand |
Icon/On-Brand | --icon-on-brand | IconOnBrand |
Action/Primary | --action-primary | ActionPrimary |
Nav/Background | --nav-background | NavBackground |
Status/Error | --status-error | StatusError |
BorderRadius/M | --border-radius-m | BorderRadiusM |
Spacing/xsmall | --spacing-xsmall | SpacingXsmall |
CSS vars already declared. The base ALPA values for all tokens are pre-declared as CSS custom properties in the component library stylesheet. The Blazor hydration step (§6) overwrites only those that differ for the active MEC by injecting a scoped <style> block that re-declares them on :root.
The Blazor Hybrid path maps tokens to CSS custom properties on :root. A thin JS interop call injects a <style id="mec-theme"> element that overrides the base values; no Razor component re-renders.
Why this approach works. CSS custom properties cascade — the MEC override on :root takes effect immediately across all existing components, with no component re-render required. Removing the <style id="mec-theme"> element reverts to base ALPA values.
The XAML/MAUI rendering path uses ResourceDictionary.MergedDictionaries. The base ALPA values live in AlpaTheme.xaml (static resources). On login, a MEC-specific dictionary is merged on top; components that reference MEC-variable tokens must use {DynamicResource}.
Deferred — applies to the native-XAML (Track B) surface of ALPAMobile.Presentation. The shared library shipped as a Blazor RCL 2026-07-15 (D61; "scaffold PCL" was the planning-era shorthand) and all current UI is Blazor. The XAML implementation below is the target state if/when a native XAML surface is built. Do not implement XAML theming ahead of that milestone.
The ThemeDto.Tokens map carries four distinct value formats. XAML hydration must branch on token namespace to determine the correct resource type — a single Color.TryParse pass silently drops spacing, radius, and font tokens.
| Token namespace | Value format in payload | XAML resource type | Notes |
|---|---|---|---|
Surface/* · Text/* · Border/* · Icon/* · Action/* · Nav/* · Status/* |
Hex #RRGGBB or rgba(r,g,b,a) |
Color |
Hex parses via Color.TryParse; rgba() requires a separate helper — see Gap 1 |
Spacing/* |
"{n}px" — e.g. "16px" |
double |
Strip px suffix; store as double; consume in component styles via Thickness — see Gap 2 |
BorderRadius/* |
"{n}px" or "50%" |
CornerRadius |
"50%" maps to sentinel CornerRadius(999) — see Gap 3 |
Font/* |
Font-family name string — e.g. "LeagueSpartan" |
string |
Stored as a string resource; must match alias registered via AddFont() — see Gap 4 |
rgba() color valuesNav/Hover is delivered as "rgba(255,255,255,0.12)" — the only token in the set that uses this format. MAUI's Color.TryParse does not handle rgba() syntax; it silently returns false, and the token is dropped. The ParseRgba helper above covers this case. If the backend ever needs to add more semi-transparent tokens, they should follow the same rgba(r,g,b,a) format — the helper handles any value matching that pattern.
px suffixAll Spacing/* values arrive as strings like "16px". XAML Thickness takes a double, not a string — the suffix must be stripped before parsing. The service stores the resolved double in the dictionary. Component styles consume it by constructing a Thickness from the resource: Padding="{DynamicResource SpacingXsmall}" works if the style converts the double to a Thickness in a converter or sets it on a typed property that accepts double directly.
Spacing/page-margins → SpacingPageMargins. The hyphen in page-margins is treated as a word separator by ToResourceKey, producing SpacingPageMargins. Apply this to the root page Padding so all screens maintain consistent horizontal margins.
BorderRadius/Full = "50%" is not an absolute valueMAUI CornerRadius takes an absolute pixel value — it has no concept of a percentage. "50%" cannot be parsed directly. The contract resolves this by mapping "50%" to the sentinel value CornerRadius(999): any value large enough relative to the element's shorter dimension produces a fully-rounded pill or circle visually identical to what border-radius: 50% achieves in CSS. Components that use {DynamicResource BorderRadiusFull} will receive 999 as their corner radius, which is correct for avatar frames and FAB buttons at any reasonable size.
All other BorderRadius/* tokens are absolute. BorderRadius/None through BorderRadius/XL all carry "{n}px" values (0 px, 4 px, 8 px, 12 px, 16 px). Only BorderRadius/Full requires the sentinel mapping.
AddFont() registered aliasesThe theme endpoint delivers Font/Heading and Font/Body as font-family name strings (e.g. "LeagueSpartan", "FuturaPT"). In MAUI, fonts are not resolved by family name at runtime — they must be registered in MauiProgram.cs via AddFont() with an explicit alias. The string stored in the resource dictionary must exactly match that alias.
This creates a coordination requirement: the alias registered in AddFont() must be kept in sync with the string value the backend delivers for each MEC. If the backend changes a font name (e.g. because a MEC rebrands), the client must register the new alias in the same release — a server-only change is not sufficient for fonts. Known aliases at time of writing:
| MEC | Backend value (Font/Heading) | Backend value (Font/Body) | AddFont() alias must match |
|---|---|---|---|
| ALPA base | "FuturaPT" | "FuturaPT" | "FuturaPT" |
| UAL | "LeagueSpartan" | "LeagueSpartan" | "LeagueSpartan" |
| DAL | "BebasNeue" | "LeagueSpartan" | Both aliases required |
| FDX | TBD (DQ-14) | TBD (DQ-14) | Resolve before scaffold |
Font resources are strings, not FontFamily objects. Store the token value as a raw string in the ResourceDictionary. MAUI FontFamily binding accepts a string alias directly. Do not attempt to construct a FontFamily object at hydration time — MAUI resolves the alias lazily when the element is first rendered.
No server-side versioning primitive. Earlier revisions of this section designed a ThemeVersion/?since=/304 conditional-GET scheme, mirrored from an assumed dynamic-feed pattern. Neither the theme endpoint nor the dynamic-feed Site/Page endpoints document any such mechanism (see Dynamic Feed — Integration Guide) — every fetch is a plain, full 200 response. The strategy below is a client-only re-fetch policy, not a documented server contract.
| What | Where | Key | TTL |
|---|---|---|---|
Full ThemeDto JSON | Client secure storage / preferences | theme:{themeId} | Client-chosen — see re-fetch policy below; no server signal to evict on |
GET /dynamic/site/{siteKey}) to get activeThemeId, then GET /dynamic/theme/{activeThemeId}. Cache the resolved ThemeDto keyed by themeId.PageDto.activeThemeId is non-null and differs from the Site's theme, fetch and cache that theme too, keyed by its own themeId.ThemeDto for the pilot's Site (keyed by the last-known activeThemeId), if any. There is no reserved fallback theme served by the API (see §2) — if nothing is cached, degrade per §9 rather than blocking on a network retry.| Condition | Client behavior |
|---|---|
| 200 OK — first fetch | Store response keyed by themeId; apply theme immediately before first render. |
| 200 OK — refetch (§8 policy) | Overwrite cache with fresh copy; re-apply and let components re-render with any updated values. |
404 — unknown/soft-deleted themeId | Log warning. Fallback priority: (1) last cached ThemeDto for this themeId, if any; (2) any other cached theme from a prior successful fetch for this Site. If nothing is cached, degrade to safe hardcoded client defaults rather than blocking — there is no server-guaranteed fallback theme (see §2). |
| Network error / timeout | Fallback priority: (1) last cached theme for this themeId; (2) any other cached theme. Never block navigation waiting on retry — retry silently on next app foreground event. |
| 5xx Server error | Same as network error — use cache or client-side defaults; do not crash the app. |
Theme failure is not fatal. A missing theme degrades gracefully to the last cached theme or safe client defaults — the app remains fully functional. Never block navigation or show an error screen due to a theme fetch failure.
| Item | Status | Notes |
|---|---|---|
| OT-1 — Endpoint URL & API version prefix | Resolved | GET /api/mobilecontent/dynamic/theme/{themeId} and GET /api/mobilecontent/dynamic/theme — no version prefix. Confirmed live per Dynamic Feed — Integration Guide. Supersedes the original /api/theme/{mecId} proposal. |
| OT-2 — Auth scheme | Resolved | None required. All Dynamic endpoints (including theme) are [AllowAnonymous] — authentication is handled at the API gateway level, not per-request on this endpoint. No Authorization header needed for the theme fetch itself. |
| OT-3 — HTTP cache headers | Resolved — no server mechanism | No ETag / Cache-Control / conditional-GET support is documented for this endpoint. The originally proposed ?since=/304 scheme does not exist server-side. See §8 for the client-only re-fetch policy this leaves the client responsible for. |
| OT-4 — theme lookup key | Resolved — superseded | There is no mecId path parameter. A theme is looked up by its GUID id (via activeThemeId on SiteDto/PageDto) or by key for display/logging purposes. UserInfo.MEC is not involved in theme resolution — it's the Site/Page hierarchy that determines which theme applies. |
| OT-5 — Spacing/* tokens | Resolved | Spacing tokens are included in the flat Tokens dictionary the same as any other token path (e.g. Spacing/xsmall) — no special handling. Per-theme overrides go through the same Admin token-editing endpoints as any other token (admin-portal.html — Token Endpoints). |
| OT-6 — themeVersion / change-detection format | Open — not shipped | There is no themeVersion field or equivalent change-detection signal in ThemeDto. If the app needs to detect "this theme changed since I last cached it," that requires a client-side re-fetch policy (§8) rather than a server-provided version — raise with the backend team if a real versioning signal becomes necessary. |
| OT-7 — base/fallback theme identifier | Open | There is no reserved mecId=ALPA-style fallback theme served by the API. Whatever theme a Site assigns as its activeThemeId is just a normal theme row with its own key (e.g. alpa-default) — there's no API-level guarantee it's fetchable independent of a Site lookup. Confirm with the backend team whether a stable, directly-fetchable fallback theme key should be reserved, or whether client-side hardcoded defaults (§9) are the intended fallback for good. |
| OT-8 — full token registry enumeration | Open | Vlad Oprica's docs confirm 95 tokens across 15 categories (admin-portal.html) but don't enumerate them by name — see the warning in §3. Requesting the full list (or pointing at GET /api/mobilecontent/admin/theme/token-metadata as the authoritative source) would let this doc's token tables be completed accurately instead of covering only the previously-known subset. |