Skip to content

Commit 9340b9c

Browse files
feat: support application tokens (#4276)
Adds a third top-level token directory `apps/` alongside `core/` and `themes/`. Tokens placed under `apps/<app-name>/` are emitted without the `--pgn-` prefix so brand package authors can override CSS variables defined by individual MFEs (which use their own naming conventions). References to Paragon tokens are preserved as `var(--pgn-…)` so theme variation flows through automatically. App outputs are inlined into each theme variant's bundle by build-scss, so existing brand packages get app overrides without any MFE-side configuration change. See ADR 0021 for the full design rationale. Refs #4274. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 51702d5 commit 9340b9c

7 files changed

Lines changed: 403 additions & 5 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
21. Support application tokens
2+
------------------------------
3+
4+
Status
5+
======
6+
7+
Accepted
8+
9+
Context
10+
=======
11+
12+
Paragon's design token system (see `ADR 0019: Scaling Paragon's styles
13+
architecture with design tokens
14+
<0019-scaling-styles-with-design-tokens.rst>`__) prefixes every emitted CSS
15+
variable with ``--pgn-``. This is intentional for tokens that belong to the
16+
Paragon design system: it namespaces them, keeps them from colliding with
17+
consumer CSS, and reflects their origin.
18+
19+
However, Open edX micro-frontends (MFEs) define their own CSS variables for
20+
component-level customization, and those variables are *not* part of Paragon.
21+
They use names chosen by the MFE itself. For example, ``frontend-app-catalog``
22+
defines this in its home banner stylesheet:
23+
24+
.. code-block:: scss
25+
26+
background-color: var(--catalog-home-page-banner-background-color, var(--pgn-color-gray-500));
27+
28+
The fallback (``var(--pgn-color-gray-500)``) covers default rendering when no
29+
MFE-specific override is supplied. Brand package authors who want to customize
30+
the catalog banner separately are expected to set
31+
``--catalog-home-page-banner-background-color`` to a value of their choosing.
32+
33+
With Paragon's existing token tooling, brand package authors cannot do this.
34+
Every token built through ``paragon build-tokens`` is unconditionally prefixed
35+
with ``pgn``. A brand package author defining a token at path
36+
``catalog.home-page.banner.background-color`` would emit a CSS variable named
37+
``--pgn-catalog-home-page-banner-background-color`` — a name no MFE actually
38+
reads.
39+
40+
This is the gap reported in `issue #4274
41+
<https://github.com/openedx/paragon/issues/4274>`__: brand package authors
42+
should be able to use the existing token system to customize MFE CSS
43+
variables, but the system has no mechanism for tokens that don't belong to
44+
Paragon.
45+
46+
Decision
47+
========
48+
49+
We will add a third top-level token directory, ``apps/``, alongside ``core/``
50+
and ``themes/``. Tokens placed under ``apps/<app-name>/`` will be emitted
51+
**without** the ``--pgn-`` prefix into a new build artifact at
52+
``<buildDir>/apps/<app-name>/variables.css``. References from app tokens to
53+
Paragon tokens will be preserved as ``var(--pgn-…)`` so theme variation
54+
continues to flow through automatically.
55+
56+
The decision breaks down into three sub-decisions, each with a meaningful
57+
rejected alternative.
58+
59+
1. Apps emit unprefixed CSS variables; the variable name is the JSON path
60+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
61+
62+
The app build runs with ``prefix: ''`` and a token defined at JSON path
63+
``catalog.home-page.banner.background-color`` emits the CSS variable
64+
``--catalog-home-page-banner-background-color``. The brand package author has
65+
full control over the resulting CSS name through the path they choose.
66+
67+
**Rejected alternative: a per-app prefix (e.g. ``--<app-name>-…``) injected
68+
automatically by the build.** The reason this fails is that MFE CSS variable
69+
conventions vary across the Open edX codebase. Some MFEs use the unprefixed
70+
app slug (``--catalog-…``), some use the full package name
71+
(``--frontend-app-catalog-…``), some use neither. Any single auto-prefix
72+
scheme would either require brand package authors to write extra path
73+
segments to compensate, or would simply fail to match the MFE's actual
74+
variable name. Letting the JSON path be the source of truth is the only
75+
flexible answer.
76+
77+
**Rejected alternative: keep the ``--pgn-`` prefix and ask MFEs to adopt
78+
``--pgn-…`` for their own variables.** The ``--pgn-`` prefix marks a variable
79+
as belonging to the Paragon design system. MFE-defined variables are *not*
80+
part of Paragon — they belong to the MFE that defines them — so prefixing
81+
them with ``--pgn-`` would be a category error regardless of any practical
82+
considerations. Asking MFEs to migrate would also shift a non-trivial burden
83+
across the ecosystem and contradict variable conventions MFEs have already
84+
published, but the namespacing argument is the load-bearing one: app tokens
85+
fundamentally aren't Paragon tokens.
86+
87+
2. App tokens are inlined into each theme variant's CSS bundle
88+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
89+
90+
The per-theme ``index.css`` produced by ``build-tokens`` adds an ``@import``
91+
line per discovered app, and ``postcss-import`` resolves those imports during
92+
the SCSS compilation step in ``build-scss``. The published
93+
``dist/<variant>.min.css`` therefore already contains every app's
94+
custom-property declarations.
95+
96+
This means no MFE-side runtime change is required to consume app tokens.
97+
Every MFE that loads a brand's ``light.min.css`` (via
98+
``MFE_CONFIG["PARAGON_THEME_URLS"]``) automatically receives every app's
99+
overrides.
100+
101+
**Rejected alternative (deferred): ship per-app CSS files in ``dist/`` and
102+
extend the runtime manifest.** A natural future shape is one CSS file per app
103+
at ``dist/apps/<app>.min.css``, advertised through a new ``apps`` section of
104+
``theme-urls.json``, with Paragon's runtime loading only the app file
105+
matching the current MFE. This is more efficient when many apps have brand
106+
overrides — each MFE downloads only its own. We didn't do it because it
107+
requires changes across multiple repositories (Paragon's runtime, the
108+
MFE-side convention for "what's my app name", the manifest schema, every
109+
brand package's build), all to optimize a cost (theme bundle size) that
110+
isn't yet a real problem. Brands typically override a small number of apps,
111+
so the linear growth of the bundled approach is bounded. We can revisit
112+
this once volume justifies it.
113+
114+
**Rejected alternative: convention-based URLs without a manifest.** MFEs
115+
could try-load ``<brandOverride-base>/apps/<self>.min.css`` and fall back
116+
silently if the file is absent. This is the worst of both worlds: it requires
117+
MFE-side changes (so it doesn't deliver the bundled approach's "no MFE
118+
change" benefit), but it provides no manifest for discovery and produces 404
119+
noise in production logs.
120+
121+
3. App builds include a theme variant for reference-resolution vocabulary
122+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
123+
124+
The app build's ``include`` covers Paragon's ``core/`` *and* one theme
125+
variant (``themes/light/``). This makes paths like ``color.gray.500`` and
126+
``color.brand.500`` available to ``style-dictionary``'s reference resolver,
127+
so an app token writing ``{ "$value": "{color.gray.500}" }`` correctly emits
128+
``var(--pgn-color-gray-500)`` in the output.
129+
130+
The choice of theme variant is incidental — only the path layout matters for
131+
reference resolution; values fall out at runtime against whichever Paragon
132+
theme CSS is loaded. ``light`` is hardcoded in
133+
``getAppStyleDictionaryConfig`` because it is the only theme variant Paragon
134+
ships today.
135+
136+
**Rejected alternative: include only ``core/``.** A natural-looking simpler
137+
design, but it fails: many tokens that brand package authors will want to
138+
reference (colors in particular) are declared in ``themes/<variant>/`` files,
139+
not in ``core/``. Without a theme in scope, those references would not
140+
resolve and the build would fail or warn.
141+
142+
This points at an architectural smell — the coupling of "the path exists"
143+
and "the path has this value in this theme" — that is captured in `issue
144+
#4275 <https://github.com/openedx/paragon/issues/4275>`__ as a future
145+
refactor. A semantic-vs-primitive token split would let app builds reference
146+
a theme-invariant schema in ``core/`` and remove the implicit light-theme
147+
dependency. That refactor is out of scope here; the hardcoded ``light`` is a
148+
deliberate workaround that this ADR records so it can be revisited.
149+
150+
Consequences
151+
============
152+
153+
* **Brand package authors gain a token-level mechanism for MFE
154+
customization.** What previously required hand-rolled CSS now flows through
155+
the same JSON-driven design token pipeline as Paragon's own tokens.
156+
157+
* **No MFE-side change is required.** Brand packages continue to publish
158+
``dist/<variant>.min.css`` that MFEs load via
159+
``MFE_CONFIG["PARAGON_THEME_URLS"]``. App overrides arrive as additional
160+
``:root`` declarations in that same file. MFEs that don't read a particular
161+
``--<app>-…`` variable simply ignore the declaration; the theme bundle
162+
grows linearly with the number of apps a brand actually overrides.
163+
164+
* **The ``--pgn-`` prefix is no longer a hard invariant of the token
165+
system.** Output prefixes now depend on which top-level directory a token
166+
lives under. Consumers writing tokens don't need to reason about it — the
167+
directory choice does it for them.
168+
169+
* **Per-app CSS files are not currently emitted to ``dist/``.** Apps are
170+
bundled into the theme variant's CSS rather than served independently. If
171+
app overrides become widespread enough that per-MFE loading is worth it, a
172+
future iteration can revisit decision (2) above.
173+
174+
* **App tokens reach into ``themes/`` for reference resolution, not just
175+
``core/``.** Many of the paths a brand package author will want to
176+
reference — colors especially — are declared in ``themes/<variant>/``, not
177+
in ``core/``. The app build accommodates that today by including
178+
``themes/light`` in its style-dictionary ``include`` purely for vocabulary.
179+
Issue `#4275 <https://github.com/openedx/paragon/issues/4275>`__ tracks
180+
splitting Paragon's tokens into a theme-invariant schema (in ``core/``)
181+
and theme-specific values (in ``themes/``), at which point app builds
182+
would only need ``core/``.
183+
184+
Resources
185+
=========
186+
187+
* `Issue #4274 — Handle non-pgn-prefixed tokens from MFEs
188+
<https://github.com/openedx/paragon/issues/4274>`__
189+
* `Issue #4275 — Decouple semantic token declarations from theme-specific
190+
values <https://github.com/openedx/paragon/issues/4275>`__
191+
* `ADR 0019 — Scaling Paragon's styles architecture with design tokens
192+
<0019-scaling-styles-with-design-tokens.rst>`__

lib/__tests__/build-tokens.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
const fs = require('fs');
12
const buildTokensCommand = require('../build-tokens');
23
const {
34
initializeStyleDictionary,
@@ -6,6 +7,7 @@ const {
67
} = require('../../tokens/style-dictionary');
78
const { createIndexCssFile } = require('../../tokens/utils');
89

10+
jest.mock('fs');
911
jest.mock('../../tokens/style-dictionary');
1012
jest.mock('../../tokens/utils');
1113

@@ -122,4 +124,58 @@ describe('buildTokensCommand', () => {
122124
},
123125
}));
124126
});
127+
128+
describe('app token discovery', () => {
129+
const mockAppsDirectory = (appNames) => {
130+
fs.existsSync.mockReturnValue(true);
131+
fs.readdirSync.mockReturnValue(
132+
appNames.map((name) => ({ name, isDirectory: () => true })),
133+
);
134+
};
135+
136+
it('builds one config per discovered app, in addition to core and themes', async () => {
137+
mockAppsDirectory(['catalog', 'discussions']);
138+
139+
await buildTokensCommand(['--source', '/fake/source']);
140+
141+
// 1 core + 1 light theme + 2 apps = 4 StyleDictionary calls
142+
expect(StyleDictionary).toHaveBeenCalledTimes(4);
143+
});
144+
145+
it('uses the expected per-app config shape', async () => {
146+
mockAppsDirectory(['catalog']);
147+
148+
await buildTokensCommand(['--source', '/fake/source']);
149+
150+
const appCallArgs = StyleDictionary.mock.calls
151+
.map(([config]) => config)
152+
.find((config) => config.platforms.css.files[0].destination.startsWith('apps/'));
153+
154+
expect(appCallArgs).toBeDefined();
155+
expect(appCallArgs.platforms.css.prefix).toBe('');
156+
expect(appCallArgs.platforms.css.transformGroup).toBe('paragon-css-app');
157+
expect(appCallArgs.platforms.css.files).toHaveLength(1);
158+
expect(appCallArgs.platforms.css.files[0].destination).toBe('apps/catalog/variables.css');
159+
expect(appCallArgs.platforms.css.files[0].options.outputReferences).toBe(true);
160+
161+
// Inline filter passes source tokens, rejects include'd Paragon tokens.
162+
const appFilter = appCallArgs.platforms.css.files[0].filter;
163+
expect(typeof appFilter).toBe('function');
164+
expect(appFilter({ isSource: true })).toBe(true);
165+
expect(appFilter({ isSource: false })).toBe(false);
166+
});
167+
168+
it('does not create an index.css for app configs', async () => {
169+
mockAppsDirectory(['catalog', 'discussions']);
170+
171+
await buildTokensCommand(['--source', '/fake/source']);
172+
173+
// Only core + light theme should get an index; the two apps should not.
174+
expect(createIndexCssFile).toHaveBeenCalledTimes(2);
175+
const indexCalls = createIndexCssFile.mock.calls.map(([params]) => params);
176+
indexCalls.forEach(({ themeVariant }) => {
177+
expect(themeVariant === undefined || themeVariant === 'light').toBe(true);
178+
});
179+
});
180+
});
125181
});

lib/build-tokens.js

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ async function buildTokensCommand(commandArgs) {
6262
}
6363
let themesToProcess = null;
6464

65+
const tokensPath = tokensSource || path.resolve(__dirname, '../tokens/src');
66+
6567
if (allThemes) {
66-
const tokensPath = tokensSource || path.resolve(__dirname, '../tokens/src');
6768
themesToProcess = fs
6869
.readdirSync(`${tokensPath}/themes/`, { withFileTypes: true })
6970
.filter(entry => entry.isDirectory())
@@ -74,6 +75,15 @@ async function buildTokensCommand(commandArgs) {
7475
themesToProcess = (themes || 'light').split(',').map(t => t.trim());
7576
}
7677

78+
// Discover app token directories. Skip silently if `apps/` is absent.
79+
const appsPath = path.join(tokensPath, 'apps');
80+
const appsToProcess = fs.existsSync(appsPath)
81+
? fs
82+
.readdirSync(appsPath, { withFileTypes: true })
83+
.filter(entry => entry.isDirectory())
84+
.map(entry => entry.name)
85+
: [];
86+
7787
const StyleDictionary = await initializeStyleDictionary({ themes: themesToProcess });
7888

7989
const coreConfig = {
@@ -173,6 +183,51 @@ async function buildTokensCommand(commandArgs) {
173183
},
174184
});
175185

186+
// Per-app style-dictionary config. Outputs the app's own tokens unprefixed
187+
// (`prefix: ''`) into `apps/<appName>/variables.css`. References to
188+
// include'd Paragon core/theme tokens emit `var(--pgn-…)` thanks to the
189+
// `paragon-css-app` transform group's conditional name transform.
190+
//
191+
// `themes/light/**` is included purely for reference vocabulary — app
192+
// tokens reference paths like `{color.gray.500}` that live in theme files,
193+
// and the build needs those paths in scope to resolve refs. The actual
194+
// values are filtered out of the output. Light is hardcoded because it's
195+
// the only theme Paragon ships and the schema is the same across variants.
196+
// See https://github.com/openedx/paragon/issues/4275 for a proposed split
197+
// that would let us reference a theme-invariant schema directly.
198+
const getAppStyleDictionaryConfig = (appName) => ({
199+
...coreConfig,
200+
include: [
201+
...coreConfig.include,
202+
path.resolve(__dirname, '../tokens/src/themes/light/**/*.json'),
203+
path.resolve(__dirname, '../tokens/src/themes/light/**/*.toml'),
204+
],
205+
source: [
206+
`${tokensPath}/apps/${appName}/**/*.json`,
207+
`${tokensPath}/apps/${appName}/**/*.toml`,
208+
],
209+
platforms: {
210+
css: {
211+
...coreConfig.platforms.css,
212+
prefix: '',
213+
transformGroup: 'paragon-css-app',
214+
files: [
215+
{
216+
format: 'css/custom-variables',
217+
destination: `apps/${appName}/variables.css`,
218+
// Inline filter — strict source-only, distinct from the
219+
// registered `isSource` filter (which also pulls in Paragon
220+
// tokens marked as referenced by source). For apps we want refs
221+
// to Paragon tokens to stay as `var(--pgn-…)` and resolve at
222+
// runtime against Paragon's separately-loaded CSS.
223+
filter: (token) => token.isSource,
224+
options: { outputReferences: true },
225+
},
226+
],
227+
},
228+
},
229+
});
230+
176231
// Create list of style-dictionary configurations to build
177232
const configs = [];
178233

@@ -187,17 +242,29 @@ async function buildTokensCommand(commandArgs) {
187242
configs.push({ config, themeVariant });
188243
});
189244

190-
// Build tokens for each configuration
191-
await Promise.all(configs.map(async ({ config, themeVariant }) => {
245+
// Add app configs (one per discovered app)
246+
appsToProcess.forEach(appName => {
247+
configs.push({ config: getAppStyleDictionaryConfig(appName), isApp: true });
248+
});
249+
250+
// Phase 1: build all token configs (core, themes, apps) in parallel.
251+
await Promise.all(configs.map(async ({ config }) => {
192252
const sd = new StyleDictionary(config);
193253
await sd.cleanAllPlatforms();
194254
await sd.buildAllPlatforms();
255+
}));
256+
257+
// Phase 2: create index.css for core + each theme variant. Apps don't get
258+
// their own index — their variables.css is consumed via @import from each
259+
// theme variant's index.css (added by createIndexCssFile when apps exist).
260+
configs.forEach(({ themeVariant, isApp }) => {
261+
if (isApp) { return; }
195262
createIndexCssFile({
196263
buildDir,
197264
isThemeVariant: !!themeVariant,
198265
themeVariant,
199266
});
200-
}));
267+
});
201268
}
202269

203270
module.exports = buildTokensCommand;

0 commit comments

Comments
 (0)