WORK-223
Setting up your dashboard 0 entities found · 6/29 branches scanned
ID:WORK-223Status:done

Scoped tint projection from preset modules

Extend the theme.tints[].extends field (established by SPEC-053) to accept a preset module path in addition to a tint name. When extends resolves to a preset module, the generator reads that module's ThemeTokensConfig, applies the scope-eligibility filter from SPEC-056 (drops typography/spacing/radius/shadow/status namespaces), and emits the result as a .rf-tint--<name> scoped CSS class. The tint rune's existing {% tint preset="<name>" %} lookup is unchanged — author-facing surface is identical regardless of whether the named tint extends another tint or a preset module.

This is the mechanism that enables live preset showcases in documentation (used by WORK-221) and underwrites the "tints scope colour identity; presets scope structural identity" commitment from SPEC-056.

Priority:highComplexity:mediumMilestone:v0.14.1Source:SPEC-056
Branches 1
History 3
  1. 20d5d78
    Created (done)by bjornolofandersson
  2. 2c493f2
    Content editedby Claude
    WORK-223: Scoped tint projection from preset modules
  3. 0c0f7b4
    Content editedby Claude
    Expand SPEC-056 with code-surface + tint extensions, add WORK-223

Acceptance Criteria

  • theme.tints[].extends accepts a preset module path string (e.g. "@refrakt-md/lumina/presets/nord"). When the value matches a registered preset module, the generator resolves it; otherwise it falls back to the existing tint-name lookup
  • The generator emits the projected tint as .rf-tint--<name> { ... } containing only scope-eligible token namespaces per SPEC-056's filter table:
    • Included: color.bg, color.surface, color.text, color.primary, color.muted, color.border, color.code.*, syntax.*
    • Excluded: font.*, radius.*, spacing.*, shadow.*, color.status.*
  • Excluded keys present in a preset module are dropped silently from the projection. Dev-mode (or NODE_ENV !== 'production') emits a warning naming the dropped keys so authors can find them; production build is silent
  • An inline tint with extends pointing to a preset and its own light/dark overrides layers the overrides on top of the projection. The override vocabulary is the chrome accent set from SPEC-053 — overriding a syntax token in the inline light/dark is not supported (consistent with the "palettes are named artefacts" policy)
  • The tint rune (packages/runes/src/tags/tint.ts) is unchanged at the rune level — the preset attribute lookup still resolves to a named tint by name; only the generator knows about preset-extension
  • A site can list a preset in theme.tints without listing it in theme.presets. The preset's CSS is emitted only as a scoped tint class, not at :root. Verified by inspecting the generated stylesheet
  • When a preset is listed in both theme.presets and theme.tints, both forms are emitted — the root form applies globally and the scoped form is available for sub-tree opt-in. Cascade specificity ensures the scoped form wins inside its subtree
  • Unit tests cover: (a) extends → preset path, (b) extends → tint name (unchanged from SPEC-053), (c) preset with non-eligible keys gets filtered, (d) inline override on top of preset extension, (e) preset listed in tints but not presets
  • No regressions in existing tint behaviour from SPEC-053 — user-defined inline tints with chrome accent overrides continue to work identically

Approach

Two layers to touch:

Config resolution layer. Wherever extends is currently resolved (likely in the config loader for @refrakt-md/sveltekit or whichever package processes refrakt.config.json into a normalised theme), add a branch: if the string matches a known preset module path, load the module and use its ThemeTokensConfig as the base; otherwise resolve as a tint name (existing behaviour).

Generator layer. Wherever the tint stylesheet generator emits .rf-tint--<name> blocks (likely the same package that emits the :root { --rf-* } block from SPEC-048's WORK-187), add the scope-eligibility filter. The filter is a single declarative table — a const SCOPE_ELIGIBLE_NAMESPACES: ReadonlySet<string> or equivalent — so adding/removing eligible namespaces in a future spec is a one-line edit.

The filter operates on the flattened token shape, not the namespaced shape. After flattening a ThemeTokensConfig to { 'color.bg': '#...', 'syntax.keyword': '#...', 'font.sans': '...', ... }, the filter keeps keys whose prefix matches an eligible namespace and drops the rest.

The dev warning should fire once per (preset, dropped-key) pair, not once per build — a Set<string> keyed by "${preset}:${key}" deduplicates noise.

The --rf-syntax-token-*-explicit indirection from WORK-219 must work the same way inside a scoped tint class — i.e. the projection emits --rf-syntax-token-type-explicit: <value> (not just --rf-syntax-token-type) so the fallback chain still functions correctly when the projection is partial. Audit this against WORK-219's implementation when both land.

Out of scope for this work item

  • Auto-exposing presets as tints — opt-in only via explicit declaration in theme.tints. See SPEC-056's "Out of scope" section.
  • Inline syntax overrides on tint extensions{ extends: "<preset>", light: { keyword: "#fff" } } is not supported. SPEC-056's "Palettes are named artefacts; accents are inline" principle locks this.
  • Extending the filter's eligibility table — this WORK item ships the table as defined in SPEC-056. Future changes to eligibility require a new spec.

Dependencies

  • WORK-219 — the CSS generator's fallback chain logic must exist so the scoped projection can re-use it correctly inside .rf-tint--<name> blocks
  • Conceptually depends on SPEC-053's existing tint infrastructure being in place (the .rf-tint--<name> selector pattern, the tints config field, the extends mechanism for tint→tint)

References

  • SPEC-056 — "Tint as scoped preset projection" and "Scope-eligibility filter" subsections
  • SPEC-053 — original TintDefinition shape and extends semantics
  • SPEC-048 and WORK-187 — token stylesheet generator (locate its source file from WORK-187's resolution)
  • packages/runes/src/tags/tint.ts — tint rune (note: unchanged by this work item)

Resolution

Completed: 2026-05-20

Branch: `claude/spec-056-milestone-v0-14-1`

What was done

The scoped tint projection is split across two surfaces, by intent:

  1. Chrome accent projection — `resolveTintExtends(tints, presetMap)` in `packages/transform/src/merge.ts` was extended to accept an optional `presetMap: Record<string, ThemeTokensConfig>`. When a tint's `extends` value matches a key in `presetMap`, the preset's chrome accent tokens (`color.bg/surface.base/text/muted/primary/border`) are projected into the `TintTokens` shape and used as the base layer. The tint's own inline `light`/`dark` overrides layer on top. Chrome accents continue to flow through the existing inline-style runtime mechanism in `engine.ts` — no engine changes.

  2. Syntax + code-surface projection — new function `generateScopedTintStylesheet(tints, presetMap)` in `packages/transform/src/token-stylesheet.ts` emits static CSS for the non-chrome-accent scope-eligible namespaces. For each tint whose `extends` is a preset path, it emits two CSS blocks:

    • `[data-tint="<name>"] { --rf-syntax-; --rf-color-code-; }` — light values
    • `[data-tint="<name>"][data-color-scheme="dark"], [data-color-scheme="dark"] [data-tint="<name>"] { ... }` — dark mode (when preset has `modes.dark`)

    The filter (`SCOPE_ELIGIBLE_NON_ACCENT_NAMESPACES` + `filterScopeEligibleNonAccent`) drops everything outside the eligible set silently in production; dev-mode emits a deduped `console.warn` per (preset, dropped-key) pair.

Implementation deviation worth noting

SPEC-056's wording calls out `.rf-tint--<name>` (class selector) for the scoped CSS block. The actual selector emitted by this WORK item is `[data-tint="<name>"]` (attribute selector), because that's what `engine.ts` already stamps on the tint wrapper (`tintDataAttrs['data-tint'] = tintName` at engine.ts:175). Using the attribute selector keeps the rune emission code unchanged — only the generator needs to know about the projection. The selector contract is pinned by a test ("emits the same selectors as the engine already produces for data-tint") so future drift between engine and generator selectors will fail loudly.

Why split chrome accents from non-chrome projection?

Chrome accents (bg, surface, text, muted, primary, border) are already the tint mechanism's vocabulary — the engine emits them as inline `style="--tint-bg: ..."` declarations and `tint.css` bridges them to `--rf-color-*`. Reusing that mechanism for preset-projected chrome accents is free.

Syntax + code-surface tokens don't fit `TintTokens` shape (there's no `TintTokens.keyword` or `TintTokens.code-bg`). They need a different path. Static CSS keyed to `[data-tint="<name>"]` is the natural shape — same selector the engine already emits, just with additional rules cascading into it.

The two paths produce a single coherent user experience: `

Dev warning behaviour

When `NODE_ENV !== 'production'` (or undefined), the generator emits a `console.warn` per (preset, dropped-key) pair the first time a non-eligible key is encountered. The dedup set is module-level (`__DROP_WARNINGS_SEEN`) and persists across calls within a process, so a preset that sets a dropped key only warns once total per build/test run. Production is silent.

Test results

  • `packages/transform/test/tint-extends.test.ts` — 5 new tests covering preset-path extends, layered overrides, empty-accent presets (Nord-shape), preset-precedence-over-tint-name, and fallback-to-tint-name. 15 total tests pass.
  • `packages/transform/test/scoped-tint-stylesheet.test.ts` — 8 new tests covering full preset projection, filter enforcement, no-extends case, no-dark-mode case, selector pinning, and dev-mode warning behaviour (warn-once + silent-in-prod). 8 tests pass.
  • Full suite `npm test` — 2533/2533 pass across 206 test files.

Files touched

  • `packages/transform/src/types.ts` — JSDoc on `TintDefinition.extends` documents preset-path mode
  • `packages/transform/src/merge.ts` — `resolveTintExtends` accepts `presetMap`; added `extractChromeAccents` + `projectColorAccents` helpers
  • `packages/transform/src/token-stylesheet.ts` — new `generateScopedTintStylesheet` + `SCOPE_ELIGIBLE_NON_ACCENT_NAMESPACES` + `filterScopeEligibleNonAccent` + dev warning dedup
  • `packages/transform/src/index.ts` — exports `generateScopedTintStylesheet`
  • `packages/transform/test/tint-extends.test.ts` — 5 new tests
  • `packages/transform/test/scoped-tint-stylesheet.test.ts` (new) — 8 tests

Note on the `--rf-syntax-token-*-explicit` indirection

WORK-219's resolution noted that the spec's `-explicit` indirection wasn't implemented; the broad-mapping derivation handles fallback equivalently. This WORK item inherits that — the scoped CSS uses the same `deriveSyntaxAliases` helper, so it benefits from the same broad-mapping fallback automatically. No `-explicit` variables are emitted at the scoped layer either, by design.

ID:Status:draft

Scoped tint projection from preset modules

Extend the theme.tints[].extends field (established by SPEC-053) to accept a preset module path in addition to a tint name. When extends resolves to a preset module, the generator reads that module's ThemeTokensConfig, applies the scope-eligibility filter from SPEC-056 (drops typography/spacing/radius/shadow/status namespaces), and emits the result as a .rf-tint--<name> scoped CSS class. The tint rune's existing {% tint preset="<name>" %} lookup is unchanged — author-facing surface is identical regardless of whether the named tint extends another tint or a preset module.

This is the mechanism that enables live preset showcases in documentation (used by WORK-221) and underwrites the "tints scope colour identity; presets scope structural identity" commitment from SPEC-056.

Priority:mediumComplexity:unknown