Skip to content

--source-tokens-only builds leak core tokens transitively referenced from Paragon's bundled themes/light/ files #4281

@brian-smith-tcril

Description

@brian-smith-tcril

Note

Generated by Claude

Summary

When a brand runs paragon build-tokens --source-tokens-only against a source tree that contains zero theme-variant source tokens, the resulting themes/<variant>/variables.css contains --pgn-* declarations the brand never authored. Four core Paragon tokens (sometimes more, depending on the brand) leak into the output via the isReferencedByThemeVariant annotation, because that annotation is triggered by Paragon's own bundled themes/light/** files (which are pulled in via coreConfig.include) rather than by the consumer's source tokens.

Reproduction

A minimal brand whose only source token is an app token with a literal value (no references):

brand/
  tokens/src/apps/some-plugin/foo.json    # one $value: "11" $type: "number" token
package.json scripts:
  build-tokens: paragon build-tokens --source ./tokens/src --build-dir ./paragon/css --source-tokens-only --exclude-core

After npm run build-tokens:

Expected: paragon/css/themes/light/variables.css contains no --pgn-* declarations, since the brand declares no theme-variant source tokens and its sole app token doesn't reference any Paragon tokens. (The app token correctly lands in apps/some-plugin/variables.css instead.)

Actual:

:root {
  --pgn-size-btn-focus-width: 2px;
  --pgn-size-input-btn-focus-width: 1px;
  --pgn-spacing-input-btn-padding-y: 0.5625rem;
  --pgn-spacing-form-input-padding-y-base: var(--pgn-spacing-input-btn-padding-y);
}

None of these declarations come from the brand. They come from tokens/src/core/components/.

The same leak is visible in @openedx/brand-openedx's published dist/light.css — there the four tokens still appear, but the symptom is ordering: --pgn-size-btn-focus-width, --pgn-size-input-btn-focus-width, and --pgn-spacing-input-btn-padding-y are at lines 7–9, ahead of every other size/spacing token in the file. They're emitted out-of-band, ahead of their natural sort cohort, because the annotation mutates them before the rest of the traversal. The contents are the same as a full build would produce, so the visible symptom is just ordering — but the underlying leak is identical.

Root cause

tokens/style-dictionary.js, the pgn-annotate-token-extensions-with-references preprocessor (lines ~181–205) registers two extension annotations:

Annotation filter (gates the referencing token)
isReferencedBySourceToken tkn => tkn.isSource
isReferencedByThemeVariant tkn => themes.some(theme => tkn.filePath.includes(theme))

The first is correctly gated on isSource, so a referencing token only triggers annotation if it's part of the consumer's source. The second isn't — it fires for any token whose filePath contains a theme name. That includes Paragon's own tokens/src/themes/light/** files, which are pulled into the build via coreConfig.include even when the consumer has no theme source files of their own.

The output filter isSource.<themeVariant> (registered around lines 446–461) then has this clause:

return token.filePath.includes(themeVariant) || isReferencedByThemeVariant;

The || isReferencedByThemeVariant branch is correct in intent — when a brand does override a theme token whose value resolves to {spacing.input.btn.padding.y}, the underlying core token needs to be emitted too so the var(...) reference resolves at runtime. But because the annotation isn't gated on isSource, the bypass also fires for core tokens referenced by Paragon's bundled theme files.

The walker in tokens/utils.js (annotateReferencedTokenExtensions) is also intentionally transitive — once a referenced token is marked, its own references are pushed onto the stack and follow the chain, which is why --pgn-spacing-form-input-padding-y-base drags --pgn-spacing-input-btn-padding-y along with it.

The four leak chains, concretely

  1. --pgn-size-btn-focus-width (defined in core/components/Button/core.json) ← referenced ~18× by themes/light/components/IconButton.json (box-shadow.spread).
  2. --pgn-size-input-btn-focus-width (defined in core/components/general/input.json) ← referenced by themes/light/components/general/input.json:16.
  3. --pgn-spacing-form-input-padding-y-base (defined in core/components/Form/spacing.json) ← referenced by themes/light/components/Form/other.json:107.
  4. --pgn-spacing-input-btn-padding-y (defined in core/components/general/input.json) — pulled in transitively via Setting up checkbox #3, since form.input.padding.y.base's value is {spacing.input.btn.padding.y}.

Other similar core tokens (e.g. input-btn-padding-x, input-btn-padding-y-sm) don't leak because no themes/light/** file happens to reference them.

Suggested fix

Gate isReferencedByThemeVariant on isSource, mirroring the gating on isReferencedBySourceToken:

 {
   name: 'isReferencedByThemeVariant',
-  filter: tkn => themes.some(theme => tkn.filePath.includes(theme)),
+  filter: tkn => tkn.isSource && themes.some(theme => tkn.filePath.includes(theme)),
   referenceTokenFilter: tkn => !themes.some(theme => tkn.filePath.includes(theme)),
 },

That keeps the legitimate brand-override case working — when a brand declares a source theme token that references a core token, the annotation still propagates — but stops Paragon's own bundled theme files from triggering it.

Suggested test

Given a fixture brand built with --source-tokens-only whose only source tokens are app tokens that don't reference any Paragon core/theme tokens (e.g. literal-valued apps/<name>/ tokens, since Style Dictionary needs at least one source token to drive the build), the resulting themes/<variant>/variables.css should contain no --pgn-* declarations. The qualifier matters: if an app token did reference a Paragon token, that core token would correctly be emitted via the isReferencedBySourceToken chain.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions