Problem
1. The vocabulary swaps primary and accent against the token contract
Today's TintTokenSet:
interface TintTokenSet {
background?: string;
surface?: string;
primary?: string; // → maps to --rf-color-text
secondary?: string; // → maps to --rf-color-muted
accent?: string; // → maps to --rf-color-primary
border?: string;
}
A user writing a tint who is fluent in the token contract will write primary thinking they're setting the interactive primary colour — but they're actually setting body text. A user writing accent thinking they're setting an accent will actually be setting the interactive primary colour. These two are swapped between the two vocabularies, which is the worst kind of inconsistency — silently wrong rather than wrong-and-obviously-so.
Three of the six tint fields use different names from their target tokens:
| Tint field | Target token | Issue |
|---|
background | color.bg | Synonym mismatch |
surface | color.surface.base | Tint flattens contract's namespace |
primary | color.text | Collision: "primary" means different things |
secondary | color.muted | "secondary" isn't a token-contract concept |
accent | color.primary | Collision: see above |
border | color.border | Matches |
Only border is unambiguous. The cost is real every time someone moves between authoring a theme preset and authoring a tint.
2. The mode field is structurally awkward
The current TintDefinition has a top-level mode?: 'auto' | 'dark' | 'light'. In practice, 'auto' is the absence of any forcing, so the three-way enum reduces to "set or unset." The lumina dark tint demonstrates the awkwardness:
dark: {
mode: 'dark',
dark: { background, primary, accent },
}
A tint named dark, with mode: 'dark', that defines only the dark token set. The name, the mode, and the variant structure all repeat the same concept, and the field name mode is generic enough to read as either "this tint applies a dark colour scheme to its subtree" or "this tint is the dark variant of itself" — two different things.
3. No extends mechanism
BgPresetDefinition has extends: 'particles' for inheriting from a base preset. TintDefinition doesn't. A user who wants tideline-warm (the warm tint with one colour adjusted) must duplicate the entire definition. Not urgent — tints are small — but a small consistency gap with the parallel BgPresetDefinition.
4. SiteConfig.tints is untyped
tints?: Record<string, Record<string, unknown>>;
Project-level tint definitions in refrakt.config.json have no compile-time validation. The plugin-level definition is properly typed (Record<string, TintDefinition>), so this is a pure cleanup gap.
Design Principles
Align tint vocabulary with the token contract, even at the cost of a breaking change. v1.0 is the right window. A single coherent vocabulary across theme presets and tints is worth a one-line codemod for existing users.
The CSS bridge is good. Don't touch it. packages/lumina/styles/runes/tint.css uses @property for inheritance, --cs-* intermediaries for same-element selector cases, and a thought-through dark/light cascade. The spec only changes the names of the --tint-* custom properties that bridge to --rf-color-*. Internal CSS structure is preserved.
Boolean-ish fields should be boolean-ish. Replace the three-valued mode enum with lockMode?: 'light' | 'dark' — present means lock, absent means inherit. Removes the "what does auto mean here?" confusion.
extends for parity with backgrounds. Small affordance, copies an existing pattern.
Hard break, not migration shim. Don't accept both old and new field names during a transition. A short list of renames is easier to apply in one shot via codemod than to debug across a half-migrated codebase.
The Revised Shape
/** Set of colour tokens that a tint can override. Field names align with
* the token contract's color namespace (SPEC-048). */
interface TintTokens {
bg?: string; // → --rf-color-bg
surface?: string; // → --rf-color-surface (flattened from color.surface.base)
text?: string; // → --rf-color-text
muted?: string; // → --rf-color-muted
primary?: string; // → --rf-color-primary (the interactive accent)
border?: string; // → --rf-color-border
}
/** Named tint definition in theme config */
interface TintDefinition {
/** Force a colour scheme on the tinted subtree, regardless of page mode.
* Present = lock to this scheme; absent = inherit page mode. */
lockMode?: 'light' | 'dark';
/** Light-mode token overrides */
light?: TintTokens;
/** Dark-mode token overrides */
dark?: TintTokens;
/** Extend another named tint by name, then layer this tint's overrides on top.
* Parallels BgPresetDefinition.extends. */
extends?: string;
}
And in SiteConfig:
tints?: Record<string, TintDefinition>;
(Replacing Record<string, Record<string, unknown>>.)
Field rename map
| Old | New | Rationale |
|---|
TintTokenSet (type) | TintTokens | Parallel with ThemeTokens / token contract naming |
background | bg | Matches color.bg |
surface | surface | Unchanged |
primary | text | Maps to color.text (body text), as it always did under the hood |
secondary | muted | Matches color.muted |
accent | primary | Maps to color.primary (interactive accent), as it always did under the hood |
border | border | Unchanged |
TintDefinition.mode | TintDefinition.lockMode | Two-value enum (present/absent); clearer semantic |
Example: before and after
Before (today's lumina warm):
warm: {
light: {
background: 'var(--rf-color-surface-active)',
primary: 'var(--rf-color-text)',
accent: 'var(--rf-color-warning)',
border: 'var(--rf-color-border)',
},
dark: {
background: '#2a2018',
primary: 'var(--rf-color-text)',
accent: 'var(--rf-color-warning)',
border: '#4a3f33',
},
}
After:
warm: {
light: {
bg: 'var(--rf-color-surface-active)',
text: 'var(--rf-color-text)',
primary: 'var(--rf-color-warning)',
border: 'var(--rf-color-border)',
},
dark: {
bg: '#2a2018',
text: 'var(--rf-color-text)',
primary: 'var(--rf-color-warning)',
border: '#4a3f33',
},
}
Before (today's lumina dark):
dark: {
mode: 'dark',
dark: {
background: 'var(--rf-color-primary-700)',
primary: 'var(--rf-color-primary-50)',
accent: 'var(--rf-color-danger)',
},
}
After:
dark: {
lockMode: 'dark',
dark: {
bg: 'var(--rf-color-primary-700)',
text: 'var(--rf-color-primary-50)',
primary: 'var(--rf-color-danger)',
},
}
Example: extends
tideline-warm: {
extends: 'warm',
light: { primary: '#c2410c' }, // override just one token
}
Resolves at config-merge time: warm is fully expanded, then tideline-warm's light and dark overlays apply per token. Circular extends chains rejected at load time with a clear error.
CSS Bridge Implications
The --tint-* custom properties in tint.css get renamed to match the new field names:
| Old custom property | New custom property |
|---|
--tint-background | --tint-bg |
--tint-primary | --tint-text |
--tint-secondary | --tint-muted |
--tint-accent | --tint-primary |
--tint-surface | --tint-surface (unchanged) |
--tint-border | --tint-border (unchanged) |
--tint-dark-* | Same mapping for dark variants |
The cascade structure (sections 2–4 of tint.css), the @property registrations, and the --cs-* intermediaries are all preserved. Only the property names change.
The transform engine (packages/transform/src/engine.ts) emits these properties; it gets a one-line rename to match the new vocabulary.
Migration
This is a v1.0 breaking change. Migration is mechanical:
- Codemod for tint configs. Rename five field names (
background → bg, primary → text, secondary → muted, accent → primary) and one top-level field (mode → lockMode). Drop mode: 'auto' entries (now expressed as absence). A simple AST or regex codemod against .ts / .json config files handles it. - Codemod for CSS that reads
--tint-* properties. Only sites that have hand-written CSS reaching into the tint custom properties need adjustment — most users don't. Documented in the migration note. - v1.0 release notes. Document the renames in the migration guide with a before/after example. Note that the
tint="" rune attribute and data-tint HTML attribute are unchanged — only the internal token names move.
The user-facing surface — tint="warm" on a rune, tint: warm in frontmatter (per SPEC-052) — is completely unaffected. The change is in the authoring surface for people who define new tints.
Implementation
- Update
TintDefinition and TintTokens types in packages/transform/src/types.ts. Drop TintTokenSet. - Update
mergeThemeConfig in packages/transform/src/merge.ts to resolve extends chains during merge. - Update
engine.ts (packages/transform/src/) to emit new --tint-* property names. The TINT_TOKENS array gets renamed entries. - Update
tint.css in packages/lumina/styles/runes/ to map the new custom properties. CSS cascade structure unchanged. - Update lumina's tint configs in
packages/lumina/src/config.ts — apply the renames to base, subtle, warm, cool, dark. - Type
SiteConfig.tints in packages/types/src/theme.ts as Record<string, TintDefinition>. Add the import. - Update plugin tints. Any plugins that ship their own tint definitions get the rename. (Likely scope:
packages/runes and any plugin under plugins/ that defines tints — a small audit pass.) - Write the codemod as a one-off migration script in
packages/cli/ or as a documented set of sed-style rules in the migration guide. - Update tests. The CSS coverage tests in
packages/lumina/test/css-coverage.test.ts and any tint-specific unit tests get renamed selectors.
Acceptance Criteria
TintTokens and TintDefinition types defined per the revised shape; TintTokenSet removedSiteConfig.tints typed as Record<string, TintDefinition> (no more Record<string, unknown>)mergeThemeConfig resolves extends chains and rejects cycles with a clear errorengine.ts emits the renamed --tint-* propertiestint.css updated with the new property names; CSS cascade behaviour identical to before (verified by visual diff against a baseline)- Lumina's five built-in tints (
base, subtle, warm, cool, dark) migrated to the new shape and render identically to before - Every plugin under
plugins/ that ships tint definitions is migrated - A codemod or documented migration recipe exists for user config files
- v1.0 migration guide documents the field renames with before/after examples
- Tint runtime tests pass with the new shape; CSS coverage tests updated
Out of Scope
- Full structural unification with theme presets — tints stay as inline
TintDefinition entries in theme config, not separate preset modules. The vocabulary alignment is enough for v1.0; directory unification can be revisited if maintenance pressure justifies it. - Adding new tint surface tokens — the six-token surface stays. This spec does not introduce e.g.
surface.raised or status colours to the tint vocabulary. - Background preset alignment —
BgPresetDefinition is a CSS style bag, not a token override, so it doesn't have the same vocabulary issue. Out of scope here. - Per-tint typography — tints stay colour-only. Fonts belong to theme presets (SPEC-051).
- Runtime support for both old and new field names — explicit hard break; no migration shim.
Open Questions
- Should
extends support arrays for multi-extend? Probably not — composition through frontmatter presets: [] covers multi-source merging at a different level. Single-extend keeps the tint definition simple. Worth confirming. - Should
TintTokens.surface map to color.surface.base or expose all four surface elevations? Today's flat surface maps to --rf-color-surface (which is color.surface.base post-SPEC-048). Lean: keep flat, document the mapping. If a tint really needs to vary hover/active/raised surfaces, that's probably a sign it should be a fuller preset. - Codemod authoring location. Could live in
packages/cli as refrakt migrate tints, or as a one-off script shipped in the migration guide, or simply documented as sed rules. Lean toward CLI command since refrakt already has a plan migrate filenames precedent — but this is a v1.0 one-time migration, so it might not justify permanent CLI surface. Worth deciding during implementation. - What about
RefraktConfig.tints at the deprecated flat-shape top level? It's already marked @deprecated. Should this spec also drop it, or leave the deprecation on its existing schedule? Lean drop-with-v1.0, since we're already breaking the tint surface.