Skip to content

[Proposal]: Tokens-First Theming Pipeline for Open edX Mobile #478

@IvanStepanok

Description

@IvanStepanok

Type of Request

Product Proposal (larger features)

Feature Description

Overview

This proposal describes a tokens-first theming pipeline for Open edX Mobile, where Paragon design tokens are the single design source of truth, and mobile apps adjust their theme at runtime without requiring new builds.

Design tokens are defined in Paragon as JSON and then compiled via Style Dictionary into:

  • CSS artifacts for the web (as today, unchanged), and
  • a single mobile artifact colors.json, consumed by both Android and iOS.

colors.json is deployed as a static file (for example, in the LMS or on a CDN) and exposed via a stable URL.

Live demo:
https://stepanok.com/test-themes/

Detailed Product Proposal
https://openedx.atlassian.net/wiki/x/MoDrPQE

Mobile apps:

  • do not parse “raw” Paragon JSON directly;
  • do not rely on platform-specific artifacts such as colors.xml or Swift/ObjC theme files;
  • instead, fetch a single colors.json from a stable URL;
  • build the light theme directly from colors.json;
  • locally derive a dark theme using deterministic color math (no dedicated dark palette required in tokens);
  • keep neutral gray surfaces under app control so the UI remains clean and legible even if operators choose extreme brand colors.

If tokens are unavailable or the feature is disabled, the app falls back to its built-in color configuration.

The feature is controlled by:

DESIGN_TOKENS_ENABLED: true

in config.yaml.

This approach is also a step toward a future multi-tenant mobile application: a tokens-first pipeline and colors.json make it possible to swap themes for different tenants without changing the binary.


Problem

Operators occasionally update their branding and color palettes. Even if this doesn’t happen frequently, any such change on mobile today typically means:

  • colors are hard-coded into the app;
  • changing the palette requires a new release to the app stores;
  • color behavior is often out of sync with the web and behaves unpredictably in dark mode.

This causes:

  • drift between web and mobile (different colors for the same brand),
  • inconsistent or broken dark mode,
  • accessibility regressions (insufficient contrast),
  • unnecessary operational overhead (a new build even for a once-a-year rebrand).

Additionally, the Open edX ecosystem is moving toward multi-tenant scenarios: one mobile client, multiple installations/tenants. Without a unified token pipeline, this is difficult and expensive to support, because each themed variant tends to turn into a separate branch or build.

We need a single, robust token system that:

  • drives both web and mobile from the same token set;
  • lets operators change brand colors without rebuilding the app;
  • keeps dark mode and contrast under control on mobile;
  • does not require operators to maintain a separate dark palette in tokens;
  • lays the foundation for a future multi-tenant mobile app, where different operators can receive their own themes on top of a single codebase.

Use Cases

Learner Stories

Consistent brand & readability
As a learner, I want colors to look consistent and readable across light and dark modes, so the app feels trustworthy and comfortable to use.

Operator Stories

Rebrand without a new build
As an operator, I want my mobile app to automatically follow the same design as my website, without manually adjusting colors on every platform. I want a single source of truth for the brand.

Compliance & Governance

Store-safe remote configuration
As a compliance owner, I need theming to be driven purely by static presentation data (tokens) that:

  • are fetched securely over HTTPS,
  • are applied in a deterministic way,
  • do not contain arbitrary code or business logic.

Proposed Solution

1. Single Source of Truth: Paragon Design Tokens

  • Design tokens (colors and related metadata) are defined in Paragon as JSON.
  • These tokens are the single design source of truth for:
    • the web (CSS), and
    • mobile apps (via a single colors.json artifact).

Paragon tokens remain “raw” design data. For use in products, they are transformed into consumption-friendly artifacts.

2. Style Dictionary as the Build Pipeline

We use Style Dictionary to compile raw Paragon tokens into:

  • Web: existing CSS outputs (no change to how the web works today).
  • Mobile: a normalized mobile artifact colors.json that contains:
    • brand accent ramps (primary / secondary),
    • semantic roles for accent usage (buttons, toggles, links, borders),
    • on-colors (text and icons on accent surfaces).

Base pipeline:

Paragon JSON Tokens (design source of truth)
Style Dictionary (extended config: web + mobile)
Artifacts:

  • Web: CSS variables
  • Mobile: colors.json (the contract for Android/iOS)

→ Deployed as static assets (LMS / CDN)
→ Mobile apps fetch colors.json and map tokens into their native theme systems (Compose, SwiftUI, etc.).

3. Distribution & Hosting

The mobile artifact colors.json is deployed as a static file, for example:

https://{lms-address}/static/tokens/mobile/colors.json

Key properties:

  • a fixed, documented URL for colors.json;
  • can be hosted on:
    • LMS static assets,
    • an external CDN,
    • any operator-controlled static hosting with HTTPS;
  • no server-side logic required — just static file hosting.

This matches the expectation that mobile clients can consume themes from “any HTTPS URL,” while keeping the path predictable and documented.

4. Mobile Consumption & Caching

For each mobile app (Android/iOS):

  • The app knows the canonical URL for colors.json.
  • On cold start, when DESIGN_TOKENS_ENABLED: true:
    • the app loads the “last known good theme” from local cache or uses the built-in theme;
    • it renders the first frame using this theme (no flash of unthemed UI);
    • in the background, it fetches the latest colors.json using standard HTTP caching (ETag / Last-Modified).

The app validates the received colors.json:

  • checks structure and types,
  • checks presence of required keys,
  • optionally checks signatures/integrity.

If the payload is valid and different from the cached version:

  • it is stored as the new “last known good” theme;
  • it will take effect on the next cold start.

If the payload is unavailable or invalid:

  • the app keeps the current theme (cached or built-in),
  • and logs the failure for later analysis.

This ensures:

  • deterministic theming behavior;
  • no mid-session “color popping”;
  • any issues with colors.json result in a safe fallback to a stable theme.

5. Dark Mode: Local Derivation from Light Tokens

The design tokens do not provide a dedicated dark palette.

  • Operators define only a light palette:
    • base brand color,
    • accent ramps,
    • semantic roles (primary/secondary, on-colors, borders).

The mobile app uses deterministic color math to derive the dark theme from these light tokens.

When computing the dark theme, we consider:

  • target contrast levels (WCAG),
  • brightness caps (to avoid glare in dark mode),
  • saturation limits (to avoid “neon” colors).

Behavior:

  • Light theme: colors from tokens are applied as-is, with no automatic adjustment.
  • Dark theme: is computed on-device using the formulas below, with the light tokens as input.

As a result:

  • dark mode logic is controlled by the mobile apps;
  • readability and visual quality are preserved even with suboptimal brand colors;
  • operators do not need to maintain two independent palettes (light/dark).

6. Neutral Surfaces & Gray Backgrounds

To keep the UI coherent and readable, neutral gray surfaces are not controlled by tokens in v1:

  • The app defines and owns its own set of neutral surfaces:
    • background colors,
    • cards,
    • list surfaces,
    • base layout layers.

These surfaces:

  • are neutral in color,
  • are tested against a wide range of brand accents,
  • are optimized for accessibility and readability.

The role of tokens in v1 is to:

  • control accent colors (primary/secondary),
  • control text/icon colors on accent elements,
  • control accent borders on top of neutral surfaces.

Consequences:

  • even with very bright, dark, or unusual brand colors, the UI remains “designed”:
    • core surfaces stay neutral,
    • accents are automatically adjusted as needed;
  • operators get strong brand presence through buttons, toggles, and highlights, without risking unreadable backgrounds.

In future versions (v2+), we may allow tokens to drive neutral surfaces as well, but in v1 this is a deliberate app-owned safety layer.


Key Principles

  • Single design source of truth: Paragon JSON tokens.
  • Dedicated mobile artifact: Style Dictionary generates colors.json as the contract for Android and iOS.
  • Runtime theming: mobile apps do not depend on build-time theme generation; all color configuration comes from colors.json at runtime.
  • Local dark mode: the dark theme is computed on-device from light tokens using predictable, testable formulas.
  • Stable neutral surfaces: gray backgrounds and base surfaces are owned by the app and are not overridden by tokens in v1.
  • Apply theme before first frame: users do not see a flash of the default theme.
  • Deterministic color math: color transformations are predictable and debuggable.
  • No explicit versioning contracts: if colors.json no longer matches what the client expects, the app simply falls back to its built-in theme.
  • Foundation for multi-tenant: a runtime token pipeline enables future per-tenant themes without changing the binary.

In Scope (v1)

In scope for v1:

  • Colors only, including:
    • brand accent ramps (primary),
    • semantic roles for accent usage (buttons, toggles, links, borders),
    • on-colors for text and icons on accent surfaces.
  • Dark theme, computed locally on-device from light tokens.
  • Fixed neutral surfaces, defined by the app itself (light and dark).
  • Style Dictionary integration:
    • extend existing Paragon configuration to generate colors.json for mobile.
  • Fetch + cache + validation:
    • loading colors.json over HTTPS,
    • using HTTP caching (ETag/Last-Modified),
    • structural validation,
    • persisting the “last known good theme.”

Feature flag:

DESIGN_TOKENS_ENABLED: true
  • when true, the app attempts to load colors.json;
  • when false, it uses the built-in theme.

Out of Scope (v1)

Explicitly out of scope for v1:

  • typography tokens (fonts, sizes, weights);
  • spacing, radius, elevation tokens;
  • per-user theming;
  • full multi-tenant theming (separate tokens per operator/tenant — that is a future step);
  • controlling neutral gray surfaces via tokens.

Implementation Plan

Milestone 1 — Core Token Pipeline & Mobile Integration

Paragon / Style Dictionary

  • Define and lock the set of color tokens in Paragon required for mobile.
  • Extend Style Dictionary configuration to generate:
    • CSS outputs for the web (as today),
    • colors.json for mobile.

Static Hosting

  • Define the canonical URL for colors.json, for example:

    /static/tokens/mobile/colors.json
    
  • Document hosting requirements for operators (LMS static assets or CDN).

Mobile Apps (Light Theme)

Implement:

  • colors.json fetch,
  • caching and HTTP header usage,
  • structural validation,
  • fallback to built-in theme.

Wire token-driven accents into:

  • buttons,
  • toggles,
  • primary CTAs,
  • links,
  • accent borders.

Milestone 2 — Dark Mode & Accessibility

Local Dark Theme Generation

  • Implement deterministic dark theme generation from light tokens according to the color math described below.
  • Ensure:
    • target contrast levels (especially for text),
    • brightness caps (anti-glare),
    • saturation control (anti-neon).

Neutral Surfaces

  • Lock in neutral gray palettes for light and dark modes.
  • Test behavior of the UI with a wide range of brand colors (including “difficult” cases).

Accessibility & QA

Add automated checks for:

  • contrast of tokenized surfaces,
  • visual regressions when brand colors change,
  • correct fallback behavior.

Milestone 3 — Operational Polish & Extensions (Optional)

Operator Tools

  • CLI or CI validator for colors.json.
  • Documentation with examples:
    • colors.json structure,
    • typical brand configurations.

Telemetry

Track:

  • how often colors.json is used vs the built-in theme,
  • frequency of fetch/validation errors,
  • how often automatic contrast/saturation adjustments are applied.

Future Extensions

  • OKLCH-based ramps,
  • typography and spacing tokens,
  • full multi-tenant theming, where colors.json can differ per tenant.

Long-Term Ownership & Maintenance

  • Engineering / Tech: AXIM Mobile WG + Open edX Mobile maintainers own the implementation and maintenance of the mobile side and its integration with colors.json.
  • Design System WG: owns Paragon tokens, ramp structure, and alignment between web and mobile.
  • Accessibility WG: formalizes contrast requirements and color math constraints, audits dark-mode behavior.
  • Operators: own hosting of colors.json and the choice of brand colors within recommended ranges.

Security & Privacy

  • All token artifacts, including colors.json, are fetched over HTTPS.
  • colors.json contains no PII — only presentation data (colors and metadata).
  • Optionally, we may use:
    • response signing,
    • domain or certificate pinning for token URLs.

Cache integrity is ensured through:

  • structural validation,
  • type checking,
  • robust error handling.

On any failure, the app falls back to the last known good theme or its built-in theme.


Error Handling & Edge Cases

  • DESIGN_TOKENS_ENABLED = false
    → The app uses its built-in color configuration (light + dark) and app-owned neutral surfaces.

  • Network failure / unreachable URL
    → The app uses the cached “last known good” theme or the built-in theme, and logs the error.

  • Invalid colors.json (schema mismatch, missing keys, invalid values)
    → The payload is rejected, the event is logged, and the previous working or built-in theme remains in use.

  • Extreme brand colors (too bright, too dark, too saturated)
    → Local color math:

    • clamps brightness and saturation,
    • enforces minimum contrast,
    • maintains visual balance against neutral surfaces.

Telemetry & Success Criteria

Functional

  • ≥ 99.9% of cold starts apply a theme before the first frame.
  • < 0.1% of sessions hit a token-related fallback.
  • ≥ 99% of tokenized surfaces meet target contrast thresholds.

Business

  • ≥ 80% reduction in time-to-rebrand for mobile apps.
  • ≥ 60% fewer support tickets related to theming and dark-mode issues.

Technical / Reliability

  • colors.json payload size < 10 KB.
  • High HTTP cache hit rate.
  • p95 fetch time low enough for a smooth UX.
  • Logging includes:
    • validation results,
    • fallback reasons,
    • automatic color adjustment counts.

Color Math (Deterministic Formulas)

Note: the implementation may be encapsulated behind utility functions, but behavior must remain deterministic and testable.

Notation (sRGB)

Channels in [0…1].

Gamma Transform (sRGB ↔ linear)

Gamma expand (sRGB → linear):

$$\gamma^{-1}(u)= \begin{cases} \frac{u}{12.92}, & u \le 0.04045 \\\ \left(\frac{u+0.055}{1.055}\right)^{2.4}, & u > 0.04045 \end{cases}$$

Gamma compress (linear → sRGB):

$$\gamma(v)= \begin{cases} 12.92 \cdot v, & v \le 0.0031308 \\\ 1.055 \cdot v^{1/2.4} - 0.055, & v > 0.0031308 \end{cases}$$

Linear mix (per channel in linear sRGB):

$$\mathrm{mix\_lin}(a,b,t) = \gamma\big((1 - t)\cdot\gamma^{-1}(a) + t\cdot\gamma^{-1}(b)\big)$$

Relative Luminance (WCAG)

$$L = 0.2126R + 0.7152G + 0.0722B$$

(R, G, B — in linear space after gamma expansion.)

Contrast ratio:

$$CR(A,B) = \frac{ \max(L_A,L_B) + 0.05 }{ \min(L_A,L_B) + 0.05 }$$

Targets:

  • Body text: CR ≥ 4.5:1
  • Large / semibold text: CR ≥ 3:1

1) Required Luminance from Target Contrast (Dark-Mode Core)

Given background luminance L_bg (typically 0.01–0.03 for dark UIs) and a light foreground on dark:

$$L_{\text{req}} = \mathrm{targetContrast} \cdot (L_{bg} + 0.05) - 0.05$$

We clamp foreground luminance:

  • lower bound: L ≥ L_req (readability),
  • upper bound: L ≤ L_cap (anti-glare, e.g., 0.85).

2) Saturation Control (Anti-Neon / Anti-Washed-Out)

Working in HSL: HSL(h, s₀, l₀).

  • Initial clamp: s₁ = min(s₀, s_max), for example s_max = 0.80.
  • For dark mode (or very low luminance):
    • if L_target < 0.40, then s₁ *= desaturateDarkFactor (e.g., 0.60).
  • Lower bound: if s₁ < s_min, then s₁ = s_min (e.g., 0.40).
  • Hue stays: h' = h.

This keeps colors vibrant but controlled, avoiding neon in dark mode and overly pale accents.


3) Lightness Search to Hit Target Luminance

HSL lightness does not map linearly to WCAG luminance, so we solve for l via binary search:

  1. Start with HSL(h’, s₁, l).
  2. Convert to sRGB, gamma expand, compute luminance.
  3. Adjust l until luminance ≈ L_target (≈ 20 iterations).
  4. Convert back to sRGB with gamma compression.

Result:

  • the color passes the contrast requirement L ≥ L_req,
  • luminance is capped L ≤ L_cap,
  • saturation stays within [s_min, s_max].

4) Brand Ramp from Base Color

For a base accent color C₀ (sRGB):

  • Lighten toward white:

    $$C_{\text{light}}(t) = \mathrm{mix\_lin}(C_0, (1,1,1), t)$$
  • Darken toward black:

    $$C_{\text{dark}}(t) = \mathrm{mix\_lin}(C_0, (0,0,0), t)$$

Recommended t ranges:

  • fills: t = 0.90–0.96,
  • borders: t = 0.60–0.75,
  • pressed/focus: t = 0.25–0.40 (darkening),
  • “500” ≈ base token.

5) Contrast Checks (Universal)

For any text/icons over surfaces or accents:

  • Compute L_bg from the surface.
  • Compute L_req from the target contrast.
  • Adjust the foreground via the saturation and lightness routines above.

For text on accent surfaces:

  • Try white first.
  • If CR(white, accent) < 4.5:
    • raise text luminance up to 0.98, or
    • slightly darken the accent to meet contrast.

6) Disabled

For active color C and surface Surface:

  • Disabled color:

    $$C_{\text{disabled}} = \mathrm{mix\_lin}(C, \mathrm{Surface}, 0.62)$$
  • Overlays: α_disabled ≈ 0.38.


7) Elevation Overlays (Dark Mode)

For base surface Base and elevation level e (0, 1, 2…):

$$\alpha(e) = \mathrm{clamp}(0.02 + 0.018 \cdot \ln(e + 1), 0.02, 0.12)$$

Elevated surface:

$$\mathrm{Surface}_e = \gamma\big((1 - \alpha(e)) \cdot \mathrm{Base}^{lin} + \alpha(e) \cdot (1,1,1)\big)$$

This adds subtle brightness with elevation while preserving dark-mode character.


8) Borders

  • Light theme:
    border = darken(surface, t = 0.06–0.10).

  • Dark theme:
    border = lighten(surface, t = 0.10–0.16).

  • Accent borders (for example, secondary buttons):

    • use ramp values around “400” (light) / “600” (dark),
    • should maintain CR ≥ 3:1 vs the surface.

Feature Toggle & Deployment

DESIGN_TOKENS_ENABLED: true

When enabled:

  • mobile apps fetch colors.json from the canonical URL;
  • validate, cache, and apply it on cold start;
  • derive the dark theme locally from light tokens;
  • keep neutral surfaces under app control.

When disabled, or whenever there is any issue with colors.json:

  • the app uses its built-in light and dark themes and its own neutral surfaces.

Open Questions

Where should the dark theme be generated?

This proposal currently derives the dark theme on-device from light tokens. We may instead generate the dark palette server-side (in the Paragon / Style Dictionary pipeline) and expose both light and dark tokens in colors.json, which would simplify mobile implementations (no duplicated color math on iOS/Android) and enable future reuse of the same dark theme on the LMS side.


Implementation Plan (Funding)

Raccoon Gang is currently looking for funding for this proposal.

Link to Product Proposal

https://openedx.atlassian.net/wiki/x/MoDrPQE

Status

New

Proposed By

Ivan Stepanok

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions