Background: presets already carry a scope, implicitly
The codebase already distinguishes two preset classes by what tokens they touch — it just doesn't name it on the preset itself:
- Foreground / syntax-scoped (e.g.
niwaki): sets only syntax.*. Theme-universal — composes with any chrome. - Integrated / palette (e.g.
nord, tideline): also claims chrome tokens (color.bg, color.surface, color.text, color.primary) and code-surface tokens. Reskins the whole page; tuned to a specific canvas.
When a preset is projected as a scoped tint (SPEC-056's tint-as-preset mechanism), filterScopeEligible in packages/transform/src/token-stylesheet.ts already drops non-eligible namespaces (typography, spacing, radius, shadow, structural sentiments), enforcing "tints scope mood; presets scope skeleton." So the engine already classifies which tokens are "mood" (colour/syntax — universally projectable) versus "skeleton" (structural — theme-bound). This spec surfaces that latent distinction as an explicit, validated property of a preset, rather than something only computed at tint-projection time.
Problem evidence
- Presets are welded to a theme package. All nine presets live under
packages/lumina/src/presets/ and are reachable only via @refrakt-md/lumina/presets/*. A preset is ThemeTokensConfig data that the loader would happily resolve from anywhere, but there is no packaged unit other than a theme to ship them in. - A preset's scope is undeclared. Whether a preset is safe to apply to any theme (syntax-only) or reskins chrome (integrated) is discoverable only by reading the module source and seeing which token namespaces it sets.
merge.ts even comments that "most syntax-only presets … return empty objects" for chrome — the distinction is real but informal. - No compatibility signal. An integrated preset is tuned to a canvas/structure; nothing records which theme(s) it was designed against, so neither an author nor tooling can warn when a preset is applied to a theme it will visually fight.
- No discovery.
refrakt theme info reports the active theme; there is no way to list available presets, let alone filter them by the active theme's compatibility or by scope. - Curation is implicit and lossy. "Which presets suit this theme" lives only in prose docstrings (e.g. Nord's comment explaining its integrated stance), not in data any tool can act on.
Design
A preset pack is a package (independent of any theme) that exports one or more ThemeTokensConfig modules plus a manifest describing them. Shape:
my-presets/
package.json — name, version; exports map for each preset module
presets.json — the pack manifest (see §3)
src/midnight.ts — export default: ThemeTokensConfig
src/aurora.ts — …
Each preset module is exactly today's ThemeTokensConfig default export — no change to the preset data shape, so Lumina's existing presets are already valid pack members. The loader resolves pack entries by the same module-identifier path it uses now; a pack is simply a package whose reason for existing is presets, not a theme.
Lumina's in-package presets remain exported for backwards compatibility; the pack format is the forward path and does not require presets to live in a theme.
2. Scope as a first-class, validated property
Each preset declares a scope:
syntax — touches only syntax.* (and optionally color.code.*). Theme-universal by construction.palette — also claims chrome tokens (color.bg, surface, text, muted, border, primary). Reskins the page; tuned to a canvas.
Scope is validated against the same eligibility classification filterScopeEligible already uses: a preset declared syntax that sets chrome tokens is a validation warning (it is really a palette preset). This makes the universal-vs-tuned distinction a checkable fact, and lets tooling reason about a preset without parsing its values. It also aligns the pack metadata with the existing tint-projection boundary — a syntax preset is exactly the class that projects cleanly as a scoped tint.
The pack manifest records, per preset: a stable id, a human title, the scope, and an optional compatibleThemes (or tunedFor) list. Sketch (final field set settled in the work phase):
{
"name": "@example/midnight-presets",
"presets": [
{ "id": "midnight", "title": "Midnight", "scope": "palette",
"module": "./src/midnight.js", "tunedFor": ["@refrakt-md/lumina"] },
{ "id": "ember-syntax", "title": "Ember", "scope": "syntax",
"module": "./src/ember.js" } // no tunedFor → universal
]
}
Compatibility is advisory: absence means "universal," and applying a preset outside its tunedFor set is never an error (the universal token contract guarantees it degrades to "recolours but keeps the theme's geometry," never breakage). It exists so authors and tooling can express and surface recommended pairings, and so a theme can ship a curated collection that travels as metadata rather than as a hard packaging lock.
refrakt theme presets list — list presets resolvable from the project's installed packs and the active theme, filterable by --scope and by compatibility with the active theme (universal presets always shown; palette presets flagged when outside their tunedFor set).- Pack-manifest validation — verify each declared
module resolves, each preset's actual token namespaces match its declared scope, and tunedFor entries are well-formed. Folds into the existing config/theme validation surface.
5. Presets double as tints — for free
Because a preset module is already projectable as a scoped tint via tints[].extends (SPEC-056, SPEC-053), a preset pack is simultaneously a tint pack: the same module powers a site-wide reskin (theme.presets) or a scoped accent subtree (tints[].extends). filterScopeEligible already guarantees a preset used as a tint can only project mood, never structure — so scope metadata and tint-eligibility are the same boundary viewed from two directions. This spec documents the dual role; no new mechanism is required.
Implications
- No engine or token-merge change. Preset data shape, the loader, merge order, and the scope filter are unchanged. The additions are a pack manifest, a
scope/compatibility vocabulary, validation, and a listing command. - Presets stop depending on a theme package. A pack is resolvable on its own; Lumina's presets remain for back-compat but are no longer the only home.
- Curation becomes data. "Which presets suit this theme" moves from docstrings into
tunedFor, where tooling can act on it — without hard-coupling, so a universal preset still works everywhere. - Lowest-coupling artifact. Presets touch only universal-contract tokens, so a pack carries near-zero maintenance coupling to refrakt's evolution relative to themes or templates — the contract is the stable interface.
Acceptance Criteria
- A preset-pack package format is defined: a package exporting one or more
ThemeTokensConfig modules (unchanged data shape) plus a presets.json manifest, resolvable independently of any theme package. - Each preset declares a
scope (syntax | palette); validation warns when a declared syntax preset sets chrome tokens, reusing the existing scope-eligibility classification. - The manifest records per-preset
id, title, scope, and optional advisory compatibleThemes/tunedFor; absence means universal and applying a preset outside its set is never an error. refrakt theme presets list lists presets resolvable from installed packs + the active theme, filterable by --scope and by compatibility with the active theme.- Pack-manifest validation checks module resolvability, scope-vs-actual-tokens agreement, and
tunedFor well-formedness, folded into the existing validation surface. - Lumina's existing presets remain exported and functional unchanged (back-compat), and are expressible in the new pack format without altering their data.
- Docs cover the pack format, the
scope vocabulary and its relationship to tint scope-eligibility, the dual preset/tint role, and compatibility semantics.
Open Questions
- Manifest filename/shape.
presets.json vs. a field in package.json vs. a typed module export; and whether scope lives in the manifest, on the preset module, or both. palette sub-scopes. Whether palette needs finer gradation (e.g. claims-code-surface-only vs. claims-full-chrome) or whether two buckets suffice.- Compatibility direction. Whether
tunedFor lives on the preset (pointing at themes) or a theme can also advertise recommended packs (pointing at presets), or both.
References
- Preset loader (arbitrary module-id resolution):
packages/transform/src/preset-loader.ts. - Scope-eligibility filter + tint projection:
packages/transform/src/token-stylesheet.ts (filterScopeEligible, generateScopedTintStylesheet); merge behaviour in packages/transform/src/merge.ts. - Site-level preset/token composition:
packages/transform/src/site-tokens.ts; SiteThemeConfig.presets in packages/types/src/theme.ts. - Universal token contract: SPEC-048.
- Syntax token contract + tint-as-preset projection: SPEC-056; curated syntax presets: SPEC-057.
- Tint cascade + shape alignment: SPEC-052, SPEC-053.
- Existing presets (valid pack members as-is):
packages/lumina/src/presets/*. - Install/listing surface this extends:
packages/cli/src/commands/theme.ts.