v0.14.0
Released May 19, 2026 · All releases
Design tokens contract + brand identity. The v0.14.0 milestone establishes refrakt's design tokens contract as a typed, theme-agnostic surface (Design tokens contract & config), refreshes the brand mark with a monochrome prism logo (Refrakt Brand Logo — Prism Redesign), aligns tint vocabulary with the rest of the token system (Tint shape alignment), ships Lumina with a neutral default palette plus two opt-in presets — tideline and niwaki — that demonstrate the preset architecture (Lumina neutral default & tideline preset), and lets authors lock or unlock tint modes per page or subtree via frontmatter cascade (Per-page and per-subtree tint cascade).
Changes
- Typed design tokens contract.
ThemeTokensConfigis now the canonical authoring surface for theme values. Site authors can drop tokens directly intorefrakt.config.jsonundertheme.tokens/theme.modesand skip writing CSS for the common case. Validated at build time against the contract; presets and modes deep-merge in declared order. - Two opt-in Lumina presets.
tidelinepreserves the previous cream-and-navy chrome (now with IBM Plex Sans/Mono typography);niwakiis a syntax-only preset with a Japanese-garden palette. Both demonstrate that scoped presets (syntax-only, chrome-only, font-only) are first-class. - Per-page tint cascade. Frontmatter
tint,tint-mode, andtint-lockcascade through the layout tree; the refrakt site uses this to lock its marketing pages to dark while letting docs / runes / plan-docs honour the user's preference. NewThemeTogglecomponent for unlocked subtrees; pre-paint script eliminates FOIT on locked routes. theme.code.colorSchemelets sites force fenced code blocks to a fixed light/dark scheme regardless of page mode (Stripe/Vercel-style always-dark code on light pages). Replaces the hand-mirroredpre, pre[data-language]override pattern that previously drifted from the active preset.- Faster SvelteKit dev server. The virtual content module now caches the loaded Site across navigations and invalidates only on
.mdedits — pages load near-instantly after the first navigation instead of re-running the cross-page pipeline per click. - Nav primitives shipped. Four contextual
navlayouts (sidebar, header menubar, footer columns, section-landing cards), collapsible sidebar groups with URL-aware auto-open, and a newpaginationrune for sequential reading flows. - Prism logo + favicon set. Monochrome prism mark replaces the previous cube; 18-PNG favicon set generated from the canonical SVG via
packages/lumina/scripts/generate-favicons.mjs. - Syntax contract simplified.
SyntaxTokens.numberandSyntaxTokens.typeremoved;constantpromoted to a direct slot covering numeric literals plus boolean/null/Symbol (matches Shiki'stoken-constantvocabulary). Migratesyntax.number→syntax.constant; dropsyntax.type(no Shiki path existed for it). - Shiki CSS variables renamed.
--shiki-*is now--rf-syntax-*(e.g.--shiki-token-keyword→--rf-syntax-token-keyword). Custom CSS reading the highlighter's variables directly needs to update; themes that consume only the contract surface are unaffected. - Lumina's default appearance changed. The previous cream-and-navy palette moved into the
tidelinepreset; the new neutral default is a quiet warm-neutral (#f6f4ef / #1c1a17) designed to disappear behind content. Sites that want the old appearance opt in with"presets": ["@refrakt-md/lumina/presets/tideline"]. - Tint shape aligned with token vocabulary.
TintTokens/TintDefinitionfield names and CSS custom property names (--tint-*) updated for consistency with the rest of the contract; lumina + plugin tint configs migrated. - Default typography changed. Outfit → Inter (body) and JetBrains Mono (code). Sites that prefer the old typography pin
font.sans/font.monoback viatheme.tokens.font, or opt into the tideline preset (which uses IBM Plex Sans/Mono). RefraktConfig.packages[]removed. The deprecated top-level shorthand forconfig.plugins[]is gone (deprecation warning shipped in v0.12.0). Useplugins[]directly.- the tint-vocabulary rename mapping
- the Shiki →
--rf-syntax-*variable rename - the syntax-token contract change (
number/type→constant) - restoring the previous appearance via the tideline preset
- the per-page tint cascade frontmatter fields
Design tokens contract & config
Design tokens contract & config
Promote refrakt's design tokens from an implicit set of CSS custom properties owned by Lumina into a typed, theme-agnostic contract — so any theme can supply values for the same names, sites can override tokens declaratively in refrakt.config.json, presets become shareable data, dark mode is part of the same surface (not a separate CSS file), and the syntax-highlighting surface stops leaking the underlying highlighter (--shiki-* → --rf-syntax-*).
- Released in
- v0.14.4
Problem
Today the design token surface is defined entirely as CSS custom properties in packages/lumina/tokens/base.css (~71 variables across typography, color, radius, spacing, shadow, code, and syntax). Customization means writing a stylesheet that redefines --rf-* vars after Lumina's are loaded. That works for hand-built sites, but has four real costs:
No machine-readable description of what runes depend on. The "contract" between runes and themes is implicit — you have to read Lumina's CSS and the per-rune stylesheets in packages/lumina/styles/runes/ to know which variables are required. A custom theme has no schema to type its tokens against; a hosted UI has no field list to render; an AI agent customizing a site has nothing to validate against.
Hosted environments can't customize via CSS. A "tweak your brand colors" form in a hosted dashboard can't ship arbitrary CSS to the build. It needs a structured config it can serialize, validate, and re-render — exactly what refrakt.config.json already does for plugins.
Presets aren't shareable. A "warm" or "high-contrast" variant means forking the CSS file. There's no way for a theme to ship a few starting points users can opt into, or for users to combine "Lumina + warm preset + my three overrides" without manual CSS work.
The highlighter is leaking into the public token surface. Eleven --shiki-* variables sit in base.css alongside --rf-* tokens. Per-rune CSS in packages/lumina/styles/ reads them directly, which means swapping Shiki for Prism, Starry Night, or a server-side alternative is a breaking change for every downstream theme and any custom user CSS.
Dark mode is a parallel CSS file with no typed surface. packages/lumina/tokens/dark.css duplicates ~30 tokens twice — once under [data-theme="dark"] for explicit toggles, once under @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ... } } for system preference. Custom themes that want dark mode have to author the same duplicated CSS by hand; sites can't override a single dark-mode color via config; presets can't ship a coordinated light + dark pair. Treating dark as out-of-band keeps the contract permanently incomplete.
Design Principles
The contract is universal, the values are themed. The names runes depend on (color.primary, radius.md, syntax.keyword, ...) belong in @refrakt-md/types. Lumina supplies one set of values; other themes supply theirs. A theme is not required to use Lumina to be valid — it just needs to cover the contract.
Strict, with an explicit escape hatch. Contract keys are enumerated and typed. Themes and sites can also write to extra: Record<string, string> for theme-specific tokens that don't fit the universal contract. Strictness on the named surface keeps validation, hosted UIs, and AI tooling tractable; extra keeps the system non-precious.
Config is sugar over CSS, not a replacement. theme.tokens in refrakt.config.json compiles to a :root { --rf-* } stylesheet at build time. That stylesheet is injected after the theme's base CSS and before any user CSS, so power users can still drop a stylesheet to access anything CSS can do that config can't — color-mix(), media queries, scoped overrides. We don't try to model those in JSON.
Presets are plain data. A preset is a ThemeTokensConfig exported from a module — base tokens plus any mode overlays it wants to contribute. There's no preset registry, no runtime hook, no lifecycle — just an object that gets merged in order with other presets and user overrides. Lumina ships a couple as a starting point; any theme can ship its own.
Highlighter is an implementation detail. Rune CSS reads --rf-syntax-*. The Shiki integration writes those names (via its cssVariablePrefix / themed-tokens config) or maps internally — either way, the public surface is highlighter-agnostic. The 9-role SyntaxTokens contract sketched in this spec was widened in SPEC-056 to add 7 optional roles (type, property, parameter, tag, attribute, operator, number, regex) with documented fallback chains. SPEC-056 also formalises (a) optional color.code.* claims on syntax preset modules for integrated palettes that ship a canvas, and (b) theme.tints[].extends accepting a preset module path as the projection mechanism for scoped tint showcases.
Modes are partials over the base, not parallel contracts. Dark mode (and any future mode — high-contrast, sepia, print) is a PartialTokenContract overlay applied to a scoped CSS selector. Modes never have their own contract shape — they reuse TokenContract so the type system and validator work uniformly. Authors only specify the tokens that differ from the base; everything else inherits via CSS variable cascade.
The site's initial mode is configurable, not hard-coded. Today refrakt sites have no way to express "this is a dark-only site" or "default to light, ignore the user's system preference" — the OS preference always wins (via the prefers-color-scheme media query) unless the author writes their own script to set data-theme on <html>. A theme.colorScheme field on the config picks the initial state explicitly. The toggle UI itself (a button users click to switch at runtime) remains out of scope; this controls only the SSR-emitted attribute on <html>.
Authoring Surface
Config
refrakt.config.json:
{
"theme": {
"package": "@refrakt-md/lumina",
"presets": ["@refrakt-md/lumina/presets/warm"],
"colorScheme": "auto",
"tokens": {
"color": {
"primary": "#7c3aed",
"primary-hover": "#6d28d9",
"text": "#0f172a"
},
"font": {
"sans": "'Inter', system-ui, sans-serif"
},
"radius": {
"md": "8px"
},
"extra": {
"rf-hero-overlay": "rgba(15, 23, 42, 0.6)"
}
},
"modes": {
"dark": {
"color": {
"primary": "#a78bfa",
"primary-hover": "#c4b5fd",
"text": "#f1f5f9"
}
}
}
}
}
Layering order (last write wins, applied per scope):
- Theme package's base tokens (e.g. Lumina's
base.cssvalues), plus the theme's own mode overlays - Each entry in
theme.presets[]in declared order (preset can contribute to base and to any mode) theme.tokens(site-specific base overrides) andtheme.modes(site-specific mode overrides)- User-supplied CSS files imported after the generated stylesheet
Modes layer independently of the base: an override in theme.tokens.color.primary only changes the light value, even if dark is declared. To change both, write to both. This is intentional — a single-write-fixes-everything model would require derived-value rules (lighten/darken) we explicitly don't support in v1.
Token contract shape
A typed nested object in @refrakt-md/types:
export interface TokenContract {
font: { sans: string; mono: string };
color: {
text: string;
muted: string;
border: string;
bg: string;
primary: string;
'primary-hover': string;
'primary-scale': Record<'50' | '100' | '200' | '300' | '400' | '500'
| '600' | '700' | '800' | '900' | '950', string>;
surface: { base: string; hover: string; active: string; raised: string };
info: { base: string; bg: string; border: string };
warning: { base: string; bg: string; border: string };
danger: { base: string; bg: string; border: string };
success: { base: string; bg: string; border: string };
code: { bg: string; text: string; 'inline-bg': string };
};
radius: Record<'sm' | 'md' | 'lg' | 'full', string>;
spacing: Record<'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl', string> & {
section: Record<'base' | 'tight' | 'loose' | 'breathe', string>;
};
inset: Record<'flush' | 'tight' | 'loose' | 'breathe', string>;
shadow: Record<'xs' | 'sm' | 'md' | 'lg', string>;
syntax: {
foreground: string;
background: string;
keyword: string;
string: string;
'string-expression': string;
constant: string;
comment: string;
function: string;
parameter: string;
punctuation: string;
link: string;
};
extra: Record<string, string>;
}
export type PartialTokenContract = DeepPartial<TokenContract>;
export interface ThemeTokensConfig {
tokens?: PartialTokenContract;
modes?: Record<string, PartialTokenContract>;
colorScheme?: 'auto' | 'light' | 'dark' | (string & {});
}
export interface Preset extends ThemeTokensConfig {
meta: {
id: string; // 'warm' — stable identifier referenced from theme.presets
name: string; // 'Warm Tones' — human-readable label for pickers
description: string; // one-line summary
tags?: string[]; // optional faceting: ['color'], ['typography', 'serif']
preview?: {
colors?: string[]; // 1–6 hex strings for swatch hints in a picker UI
};
};
}
ThemeTokensConfig is the shape shared by theme in refrakt.config.json, by every preset, and by what a theme package exports as its base. Same shape, different layer in the merge order. colorScheme is the only field that doesn't merge as a deep partial — it's a scalar; the last layer to declare it wins (typically the site config).
A companion runtime const tokenContract enumerates each leaf path and its CSS variable name, so tokensToCss() and validators don't need TypeScript reflection:
export const tokenContract = {
'font.sans': '--rf-font-sans',
'color.primary': '--rf-color-primary',
'color.primary-scale.500':'--rf-color-primary-500',
'spacing.section.tight': '--rf-spacing-section-tight',
'syntax.keyword': '--rf-syntax-keyword',
// ...one entry per leaf
} as const;
Generated CSS
tokensToCss(partial) in packages/transform/src/tokens.ts produces:
:root {
--rf-color-primary: #7c3aed;
--rf-color-primary-hover: #6d28d9;
--rf-color-text: #0f172a;
--rf-font-sans: 'Inter', system-ui, sans-serif;
--rf-radius-md: 8px;
--rf-hero-overlay: rgba(15, 23, 42, 0.6); /* from extra */
}
Emitted in deterministic key order. Empty partial → empty :root block (suppressed). Keys under extra are emitted verbatim — they must begin with -- or rf- and contain only [a-zA-Z0-9-]; otherwise validation rejects them at config-load time.
Composing presets
Presets are partials all the way down — ThemeTokensConfig.tokens is PartialTokenContract, both tokens and modes are optional, and each can write only the keys it cares about. That's the point: a preset can scope itself to one concern (color, type, shape, dark-mode tone), and authors stack several to assemble a look.
"theme": {
"package": "@refrakt-md/lumina",
"presets": [
"warm", // bare id — looked up in @refrakt-md/lumina's manifest
"serif", // bare id — same theme registry
"@my-org/presets/brand-radii" // contains '/' — loaded as a module path
],
"tokens": {
"color": { "primary": "#7c3aed" } // last word: site override beats every preset
}
}
Each preset only touches its keys; the rest cascades through from the prior layer (theme base → preset 1 → preset 2 → preset 3 → site tokens). Last write wins per key.
This makes presets composable in the way design systems usually want them:
- A "color theme" preset and a "typography" preset can be authored independently and combined without coordination.
- A dark-only preset (writes only
modes.dark) layers cleanly on top of any base preset that doesn't customize dark. - Site authors can pull a third-party preset for one concern (e.g. brand radii) without inheriting opinions on color or type.
The cost of partial presets is debuggability — when three presets all touch color.primary, "why is my primary purple?" becomes a layered question. refrakt inspect --tokens (see Tooling) annotates each resolved value with its source layer for exactly this reason.
Discoverable presets
For hosted environments (and any UI that wants a preset picker), the active theme exposes its preset registry through its manifest — the same surface that already advertises the theme's name, version, and layout regions today. No new export path: the manifest is the canonical "what does this theme offer?" file, and presets are part of that.
// extension to packages/lumina/manifest.json
{
"name": "Lumina",
"version": "0.3.0",
// ...existing fields
"presets": [
{
"meta": {
"id": "warm",
"name": "Warm Tones",
"description": "Amber primary with warm cream surfaces",
"tags": ["color", "warm"],
"preview": { "colors": ["#d97706", "#fbbf24", "#fef3c7"] }
},
"tokens": { "color": { "primary": "#d97706" /* ... */ } },
"modes": { "dark": { "color": { "primary": "#fbbf24" /* ... */ } } }
}
// ...
]
}
A hosted UI does:
import manifest from '@refrakt-md/lumina/manifest';
// manifest.presets is Preset[] — render names, descriptions, color swatches
// On user selection, write back theme.presets: ['warm', 'serif'] in refrakt.config.json
theme.presets resolution rule:
- Entry without a
/(e.g.'warm') — look upmeta.id === 'warm'in the active theme'smanifest.presets. Validator errors if no match. - Entry with a
/(e.g.'@my-org/presets/brand-radii') — load as a module specifier, expect the default export to be aPreset. Same as today's intuition for module paths.
Preset ids are part of the theme's public API — renaming warm → amber is a breaking change, same discipline as plugin names. The tags and preview.colors fields are advisory only; absent them, a UI can still render id + name + description.
Custom themes that want preset discoverability follow the same pattern: ship a manifest.json (or whatever JSON the theme already publishes) with a presets array typed as Preset[]. Themes without presets simply omit the field; existing themes don't break.
Modes & Dark Mode
A mode is a PartialTokenContract overlay applied to a scoped CSS selector. Modes share the contract shape with the base — same keys, same types, same validation — so the type system, validator, and tooling work uniformly across base and mode tokens. Authors only specify the tokens that differ from the base; everything else inherits via the CSS variable cascade.
Conventional mode names
The contract reserves three conventional mode names:
dark— emits both[data-theme="dark"](explicit) and@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ... } }(system preference with explicit-light opt-out). This matches the dual-selector pattern Lumina ships today indark.css.light— emits only[data-theme="light"]. No media query — light is the base. The selector exists so users can explicitly force light even when their system prefers dark.- Any other key (e.g.
high-contrast,sepia,print) — emits only the data-attribute selector[data-theme="<name>"]. Custom modes get no system-pref hookup; that's an opt-in future feature.
For print, themes that want @media print instead of a data attribute can author a CSS file alongside — out of scope for the generator. Modes in the contract are about theme variants the user can toggle, not media-type variants.
Picking the initial mode (theme.colorScheme)
theme.colorScheme controls what data-theme value (if any) the SvelteKit integration writes onto the <html> element at SSR time. It does not generate any CSS — it's purely about which of the already-emitted selector blocks wins on first paint.
'auto'(default) — emit nodata-themeattribute. Theprefers-color-schememedia query in the dark-mode block decides; light is the fallback. Equivalent to today's behavior.'light'— setdata-theme="light"on<html>. The dark-mode media query is gated by:root:not([data-theme="light"]), so this disables system-preference dark mode entirely.'dark'— setdata-theme="dark"on<html>. Forces dark mode for every visitor regardless of OS preference.- A custom mode name (e.g.
'high-contrast') — setdata-theme="<name>"on<html>. Only valid if the same key exists undertheme.modes; validator rejects unknown values.
The integration writes the attribute by transforming the SvelteKit response (e.g. via the adapter's HTML transform). No client-side JavaScript runs to pick the mode — it's baked into the SSR output, so there's no flash-of-wrong-theme.
A future runtime toggle (out of scope here) would: (a) read this initial value from <html data-theme> on hydration, (b) flip the attribute on user click, (c) optionally persist to localStorage. The contract gives the toggle a stable starting point; the toggle itself is separate.
tokensToCss mode output
tokensToCss({
tokens: { color: { primary: '#7c3aed' } },
modes: {
dark: { color: { primary: '#a78bfa' } }
}
});
emits:
:root {
--rf-color-primary: #7c3aed;
}
[data-theme="dark"] {
--rf-color-primary: #a78bfa;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--rf-color-primary: #a78bfa;
}
}
A mode block is suppressed entirely when its partial is empty. Output order is deterministic: :root first, then each mode in stable iteration order (dark first if present, then alphabetical).
Merging modes across layers
mergeTokens deep-merges modes alongside the base. Given:
theme base: { tokens: { color: { primary: '#1d3557' } }, modes: { dark: { color: { primary: '#70b4c0' } } } }
preset warm: { tokens: { color: { primary: '#d97706' } }, modes: { dark: { color: { primary: '#fbbf24' } } } }
site: { tokens: { color: { text: '#0f172a' } } }
the merge produces:
- base
color.primary:#d97706(preset wins) - base
color.text:#0f172a(site adds) - dark
color.primary:#fbbf24(preset wins) - dark
color.text: not set (site only wrote to base; dark inherits via cascade)
This is the intended behavior — writing to tokens.color.text doesn't silently fork a dark equivalent. Authors who want a different dark value write it explicitly in modes.dark.color.text.
Presets ship modes
A preset is a ThemeTokensConfig, not a bare PartialTokenContract:
// packages/lumina/presets/warm.ts
import type { ThemeTokensConfig } from '@refrakt-md/types';
export const warm: ThemeTokensConfig = {
tokens: { color: { primary: '#d97706', 'primary-hover': '#b45309' } },
modes: {
dark: { color: { primary: '#fbbf24', 'primary-hover': '#fcd34d' } },
},
};
Presets that only customize one mode (a "dark-only" preset) leave tokens empty and write only to modes.dark. Valid and useful — e.g. a "midnight" preset that only changes dark-mode surface colors.
Lumina migration
packages/lumina/tokens/dark.css becomes a build artifact, regenerated from packages/lumina/src/tokens.ts where Lumina's values include a modes.dark overlay. Same generator that produces base.css produces dark.css — one script, one source of truth, two output files. Both stay committed so downstream consumers (CSS imports in user sites) work without running a build.
Syntax Highlighting Abstraction
Today packages/lumina/tokens/base.css defines --shiki-foreground, --shiki-token-keyword, etc., and per-rune CSS reads them directly. This spec renames the public surface to --rf-syntax-* and treats Shiki as an implementation detail of the code-block integration.
Two things change:
Token names.
--shiki-foreground→--rf-syntax-foreground;--shiki-token-keyword→--rf-syntax-keyword; same forstring,string-expression,constant,comment,function,parameter,punctuation,link. Lumina'sbase.cssanddark.cssare updated; every CSS file inpackages/lumina/styles/that mentions--shiki-*is updated.Highlighter integration. Wherever Shiki is invoked (the code-block transform / pre rune integration), its output is configured to write the refrakt names. Shiki supports a
cssVariablePrefixoption and themed token-class output — either is fine; the constraint is that no--shiki-*name appears in the rendered HTML or in any CSS file. If Shiki's surface doesn't map 1:1 to ours, the integration maps internally — the contract is what runes consume.
The 11 syntax tokens above are the contract. A future highlighter swap means rewriting the integration, not the contract.
Package Layout & Helpers
packages/types/src/tokens.ts—TokenContract,PartialTokenContract,ThemeTokensConfig,tokenContractconst. Zero runtime deps. Re-exported frompackages/types/src/index.ts.packages/transform/src/tokens.ts—tokensToCss(config: ThemeTokensConfig): string,mergeTokens(...layers: ThemeTokensConfig[]): ThemeTokensConfig,validateTokens(config): { ok: boolean; warnings: string[]; errors: string[] }. All three operate on fullThemeTokensConfig(base + modes) uniformly.packages/lumina/src/tokens.ts— Lumina's values typed againstThemeTokensConfig. Single source of truth: bothtokens/base.cssandtokens/dark.cssare generated from this file bypackages/lumina/scripts/build-tokens.ts, which runs as aprebuildstep. The generated CSS files are committed to the repo (so installing@refrakt-md/luminadoesn't require running a build) and CI asserts that re-running the generator produces no diff againstHEAD— that's how drift is caught.packages/lumina/src/presets/—Presetobjects exported per preset (e.g.warm.ts,slate.ts). Each preset can contribute to base and/or to any mode and carries ametablock. The build-tokens script also re-emits these intopackages/lumina/manifest.json'spresetsfield so they're discoverable via@refrakt-md/lumina/manifestwithout a separate import path. Direct import (@refrakt-md/lumina/presets/warm) keeps working as a module specifier for users who prefer that form.packages/sveltekit/— the Vite plugin readsrefrakt.config.json, callsmergeTokens(theme.base, ...presets, { tokens: theme.tokens, modes: theme.modes, colorScheme: theme.colorScheme }), runstokensToCss(), and exposes the result as a virtual module (e.g.virtual:refrakt-tokens.css) injected into the document head before user CSS. When the mergedcolorSchemeis'light','dark', or a custom mode name, the integration also writesdata-theme="<value>"onto the<html>element of every SSR response (via SvelteKit'stransformPageChunkhook in a serverhandle). For'auto'(or unset), no attribute is emitted — the media query in the dark-mode block decides at the browser. HMR re-runs on config change.
Validation
validateTokens(partial) produces warnings (not errors) for:
- Keys not in the contract that also aren't under
extra— likely a typo (color.primry). extrakeys that don't begin with--orrf-, or that collide with a contract-mapped CSS variable name.
Errors (build fails) for:
- Non-string leaf values.
- Malformed
extrakeys (containing characters outside[a-zA-Z0-9-], or--rf-*names that collide with the contract). colorSchemeset to a value that isn't'auto','light','dark', or a key declared undertheme.modes. Forcing a mode that has no token overlay would setdata-theme="<name>"against an empty selector block, silently doing nothing — better to fail loudly.
Warnings surface in the build log and in refrakt inspect --tokens (see below). Failing fast on errors keeps hosted UIs honest.
Tooling
refrakt inspect --tokens— prints the resolved token set (after merging base + presets + overrides) as a table or--json. Shows source of each value (which layer set it). Useful for debugging "why is my primary still blue?" cases.refrakt contracts --tokens— emits the token contract as JSON (path, CSS var name, required/optional). Pairs with the existing structure contracts surface for CI snapshot tests.- JSON Schema for
refrakt.config.json— generated fromTokenContractso editors give autocomplete ontheme.tokenspaths. Lives next to the existing config schema.
Migration
Existing sites consuming --shiki-* in custom CSS need to rename to --rf-syntax-*. This is the only breaking change in this spec.
- A changeset documents the rename with a search/replace mapping (one entry per syntax token).
packages/lumina/tokens/base.csskeeps--shiki-*aliases pointing at the new names for one minor version as a deprecation bridge:--shiki-foreground: var(--rf-syntax-foreground);. Removed in the following minor release.- No migration needed for sites that only override
--rf-*tokens via CSS — those names are unchanged. - Theme packages that want to opt into the typed surface convert their CSS-only token file into a
tokens.tsand re-export; the CSS file can stay generated from it.
Acceptance Criteria
TokenContract,PartialTokenContract,ThemeTokensConfig,Preset, andtokenContractconst defined inpackages/types/src/tokens.tsand re-exported from the package indextokenContractenumerates every--rf-*variable currently defined inpackages/lumina/tokens/base.css(~71 entries, post-rename)extra: Record<string, string>field present onTokenContractfor theme-specific tokens outside the universal surfaceThemeTokensConfigis the shape shared bythemeinrefrakt.config.json, presets, and theme package base exports — withtokens?: PartialTokenContractandmodes?: Record<string, PartialTokenContract>fieldstokensToCss(config)inpackages/transform/src/tokens.tsemits a deterministic stylesheet from aThemeTokensConfig::root { ... }for base, plus a block per modetokensToCssemits[data-theme="dark"] { ... }and@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ... } }blocks whenmodes.darkis presenttokensToCssemits only[data-theme="<name>"] { ... }for non-conventional mode names (no media query)tokensToCssemits[data-theme="light"] { ... }formodes.light(no media query — light is the base)- Empty mode partials (no keys after merge) produce no CSS output for that mode
mergeTokens(...layers)merges multipleThemeTokensConfigobjects with last-write-wins semantics, deep-merging bothtokensand each entry undermodesindependently- Writing to
tokens.color.Xdoes not implicitly mutatemodes.dark.color.X— mode overlays are independent of base overrides (cascade handles inheritance of unset keys) validateTokens(config)returns warnings for unknown keys (outsideextra) in bothtokensand any mode, and errors for malformedextrakeys or non-string leaf values--shiki-*token names renamed to--rf-syntax-*inpackages/lumina/tokens/base.cssandpackages/lumina/tokens/dark.css- All references to
--shiki-*inpackages/lumina/styles/updated to--rf-syntax-*(grep -r '\-\-shiki' packages/lumina/styles/returns nothing) - Shiki integration configured so the rendered code-block HTML and any CSS it emits use
--rf-syntax-*names (not--shiki-*) - Deprecation aliases (
--shiki-* : var(--rf-syntax-*)) shipped inbase.cssfor one minor version with a/* deprecated, remove in vX.Y.Z */comment packages/lumina/src/tokens.tsexports Lumina's values typed againstThemeTokensConfigincluding amodes.darkoverlaypackages/lumina/scripts/build-tokens.tsgeneratespackages/lumina/tokens/base.cssandpackages/lumina/tokens/dark.cssfromsrc/tokens.ts, runs as aprebuildnpm script, and produces byte-identical output across runs- Generated
base.cssanddark.cssfiles are committed to the repo (so consumers don't need to run a build step to import them) - CI runs the generator and fails if
git diff --exit-code packages/lumina/tokens/shows any change — catches commits that hand-edited the CSS without updatingsrc/tokens.ts - At least one preset besides default ships under
packages/lumina/src/presets/(e.g.warm.ts) as aPreset(withmetaand at leasttokens); the preset includes both base andmodes.darkvalues packages/lumina/manifest.jsonextended with apresets: Preset[]field, populated by the build-tokens script fromsrc/presets/- Hosted UIs can read all available presets via
import manifest from '@refrakt-md/lumina/manifest'without a separate sub-path import theme.presetsconfig field accepts both bare ids (resolved against the active theme manifest'spresets[].meta.id) and module specifiers containing/(loaded as before)- Validator errors when a bare-id
theme.presetsentry doesn't match anymeta.idin the active theme's manifest (with the list of available ids in the error message) Preset.meta.id,name, anddescriptionare required;tagsandpreview.colorsare optional and treated as advisory by validators and toolingrefrakt.config.jsonacceptstheme.tokens: PartialTokenContract,theme.modes: Record<string, PartialTokenContract>,theme.colorScheme: 'auto' | 'light' | 'dark' | string, andtheme.presets: string[]fields; all optionaltheme.colorSchemedefaults to'auto'when unset;mergeTokenstreatscolorSchemeas a scalar (last layer wins) rather than deep-merging it- Validator errors when
theme.colorSchemeis set to a value that is neither'auto','light','dark', nor a key present undertheme.modes - SvelteKit integration writes
data-theme="<value>"onto the<html>element of every SSR response whencolorSchemeis'light','dark', or a custom mode name; emits no attribute when'auto' - No client-side script is required for the initial mode to apply — the SSR-emitted attribute is sufficient (no flash-of-wrong-theme)
- SvelteKit Vite plugin in
packages/sveltekit/readstheme.tokens,theme.modes, andtheme.presets, merges them viamergeTokens, generates a stylesheet viatokensToCss, and exposes it as a virtual module injected after theme base CSS and before user CSS - Config changes trigger HMR re-render of the generated token stylesheet during
npm run dev refrakt inspect --tokenscommand prints the resolved token set (base + every mode) with source-of-value annotations; supports--jsonand an optional--mode <name>filterrefrakt contracts --tokensemits the token contract as JSON (paths + CSS var names) for CI snapshot use- JSON Schema for
refrakt.config.jsonupdated to includetheme.tokens,theme.modes,theme.colorScheme, andtheme.presetswith autocomplete-friendly key suggestions - CSS coverage tests updated for renamed syntax tokens
- Theming docs at
site/content/docs/themes/configuration.mdupdated to covertheme.tokens,theme.modes,theme.colorScheme,theme.presets, the layering order, and the conventional mode-name semantics (dark/light/ custom) - Theming docs at
site/content/docs/themes/css.mdupdated to point at--rf-syntax-*(not--shiki-*) - Changeset entry documents the
--shiki-* → --rf-syntax-*rename with the full mapping table and the deprecation timeline
Out of Scope
- Derived / computed tokens in config. No
color-mix(), no token references ("primary-hover": "{color.primary} darken 10%"). Power users drop a CSS file for this. Config stays a static value map. (This is also why writing totokens.color.Xdoesn't auto-derivemodes.dark.color.X— there's no derivation engine.) - Mode-aware media queries beyond dark. Only
modes.darkgets the@media (prefers-color-scheme)hookup.print,reduced-motion,high-contrastsystem-pref bindings are deferred; they need their own opt-in mechanism rather than overloading the mode key. - Runtime mode toggling UI. This spec covers the SSR-emitted initial
data-theme(viatheme.colorScheme) and the CSS selectors modes resolve to. A user-clickable toggle button — flippingdata-themeon the fly, persisting tolocalStorage, syncing across tabs — is a separate concern, likely a@refrakt-md/behaviorsaddition that builds on this foundation. - Per-page or per-route token overrides. Scoped CSS injection is a different mechanism; out of scope here.
- A hosted UI for token customization. This spec enables one by giving it a contract to render against. Building the UI itself is separate.
- Runtime theme switching. Build-time output only. Runtime switching is achievable via standard CSS techniques on top of this work.
- Token naming overhaul. Names (
primary,surface.raised,spacing.section.tight) are preserved as-is from current Lumina CSS, modulo the--shiki-* → --rf-syntax-*rename. Reorganizing the namespace is its own spec. - Plugin-contributed tokens. Plugins can read tokens via CSS today; whether they can contribute to the contract is a follow-up question once we see what plugins actually need.
Decisions
Decisions made during spec drafting, recorded here for future reference. Each entry is a closed question — implementation follows the resolution.
tokensToCss and validator live in @refrakt-md/transform. Transform already owns CSS-adjacent merge logic (mergeThemeConfig) and is the natural home. A separate @refrakt-md/tokens package only pays off if non-transform code paths need token utilities without pulling transform; not the case today.
CSS files are generated from tokens.ts, not hand-written + parity-tested. With dark mode in the contract there are three files to keep in sync; a parity test catches drift after it happens, whereas a generator prevents it. packages/lumina/scripts/build-tokens.ts produces base.css and dark.css deterministically, runs as a prebuild step, and CI fails if re-running the generator changes anything tracked in git. Generated files stay committed so consumers don't need a build step on install.
Presets ship from Lumina, not a shared package. Same model as today's design plugins. A shared @refrakt-md/presets package only makes sense once multiple themes ship and want a common set — defer until that's real.
Preset discoverability lives on the theme manifest. Hosted UIs and pickers read available presets via @refrakt-md/lumina/manifest's new presets: Preset[] field — the same surface that already advertises the theme's name, version, and layout regions. No new export path, no separate registry file. theme.presets accepts bare ids (looked up against the manifest) for ergonomics and module specifiers with / for third-party presets.
Syntax contract starts at the 11 names Lumina uses today. Covers what current rune CSS reads. Mirroring Shiki's full ~30+ token catalog bloats the contract for runes that never reference the extras. Add tokens to the syntax group only when a real use case appears.
JSON Schema is generated at build time, committed to the repo. Simpler for consumers, CI, and editor integration. Runtime generation would let downstream themes contribute to the schema; revisit if themes start shipping contract extensions.
extra keys must begin with --rf-*. Enforced by validateTokens. Keeps the namespace consistent, avoids collision with user-authored CSS variables, and means a hosted UI can safely emit extra keys knowing they won't shadow anything outside the refrakt surface.
Mode selector pattern is hard-coded to [data-theme="<name>"]. Matches Lumina's existing dark.css. Themes that want Tailwind-style .dark classes or a different attribute can author their own CSS file. A theme.modeSelector knob can be added later if multiple themes need it — a single convention is worth more than flexibility no one's asked for yet.
validateTokens warns in dev, fails in CI / production builds. The validator returns both lists; the SvelteKit integration treats errors as fatal when process.env.NODE_ENV === 'production' or when an explicit RF_STRICT_TOKENS=1 is set. In local dev, errors print loudly but don't break HMR — a typo in refrakt.config.json shouldn't take down the dev server.
Refrakt Brand Logo — Prism Redesign
Refrakt Brand Logo — Prism Redesign
As refrakt closes in on v1.0, the brand mark deserves a refresh. The next logo should be a monochrome flat icon — something that reads instantly at favicon size, scales cleanly to hero size, and feels honest to the product.
The brand metaphor we keep coming back to is a prism: refrakt takes one input (markdown) and refracts it into many outputs (themes, layouts, components, frameworks). Two prism variants are captured below for comparison. Both are equilateral triangles pointing downward, drawn in white on the lumina dark navy background (--rf-color-bg = #152238).
- Released in
- v0.14.0
Constraints
- Equilateral triangle, pointing down
- Monochrome — white strokes/fills only, no gradients or shadows
- Single SVG, no rasters
- Must read clearly from 16px (favicon) to 200px+ (hero)
- Background is lumina dark navy (
#152238)
Geometry
Both variants share the same outer triangle so they're visually comparable. ViewBox is 0 0 100 100; vertices are (10, 16), (90, 16), (50, 86) — side length 80, height 70, near-equilateral (true equilateral height for side 80 is 69.28, so we're 0.7 units off — visually indistinguishable, mathematically convenient).
The three cuts are evenly spaced along the top edge and positioned so the smaller apex triangle — the region beyond Cut 3 — has side length 80/φ ≈ 49.44 (golden ratio of the outer side). Cuts land at top-x 20.19, 30.37, 40.56. Cut endpoints fall out from the parallel-to-left-edge constraint and land exactly on the right edge.
Variant 1 — Parallel Cuts
Three lines run parallel to the left edge of the triangle, close to that edge and tightly spaced. They cut the triangle into four parts: three thin slices on the left, and one larger triangle in the top right (negative space, no fill).
Reads as ordered, structural — input on the left being progressively organised toward the right.
Construction
| Element | Coordinates |
|---|---|
| Outline | (10, 16) → (90, 16) → (50, 86) → close |
| Cut 1 | (20.19, 16) → (55.09, 77.09) |
| Cut 2 | (30.37, 16) → (60.19, 68.18) |
| Cut 3 | (40.56, 16) → (65.28, 59.26) |
Cuts spaced 10.19 units apart along the top edge, positioned so the apex triangle's side is 80/φ ≈ 49.44. All four strokes share the same weight (3 in viewBox units, ≈3% of width).
Notes
- Negative-space triangle in the top-right is intentional — no fill, the prism shape carries it
- At 32px the cuts may visually merge into a thicker single stroke. Inspect the favicon-sized SVG above
- Cut count is fixed at 3 — fewer reads as a "Z" shape; more loses the "thin slices" character
Variant 2 — Filled Apex
Identical construction to Variant 1, but the top-right region — the triangle beyond the third cut — is filled solid instead of left as negative space. The three thin slices remain on the left; the right side becomes a saturated apex.
Reads as light entering through the cuts and accumulating into a brighter region on the right — same prism, with weight redistributed.
Construction
| Element | Coordinates |
|---|---|
| Outline | (10, 16) → (90, 16) → (50, 86) → close |
| Cut 1 | (20.19, 16) → (55.09, 77.09) |
| Cut 2 | (30.37, 16) → (60.19, 68.18) |
| Cut 3 | (40.56, 16) → (65.28, 59.26) |
| Apex fill | (40.56, 16) → (90, 16) → (65.28, 59.26) → close |
The fill triangle's vertices are exactly Cut 3's top endpoint, the outer triangle's top-right vertex, and Cut 3's bottom endpoint. The strokes bounding the filled region (top edge, right edge, Cut 3) are visually absorbed into the fill since both are white — no special handling needed.
Notes
- The thin-slice rhythm on the left is preserved, so the mark still reads as "ordered cuts" not just "triangle with corner filled"
- Mass shifts to the right, giving the mark more visual weight than V1 at the same size — useful when the logo sits next to a heavy wordmark
- At favicon scale, the fill survives even if the cuts merge; V1 risks reading as a single triangle, while V2 still has an unambiguous bright/dark asymmetry
Icon-scale preview
V2 at the sizes the mark will actually appear in operating system chrome — 16px (favicon), 24px (toolbar), 32px (app icon @1x), 48px and 64px (app icon @2x and Retina favicon). Same SVG at every size, no per-size variant. Watch for two things:
- Cut legibility — at 16/24px, the three cut strokes render sub-pixel (
3% × 16px ≈ 0.5px). Browsers anti-alias them but they go from "three lines" to "three soft edges of brightness." The mark's asymmetry survives via the fill; the linework recedes. - Edge crispness — the right edge of the apex fill and the outer triangle outline stay sharp at every size because they're solid boundaries, not thin strokes.
And the same icon set on the lumina light background (--rf-color-bg = #faf5eb, mark in --rf-color-text = #1d3557). Same SVG, just colors flipped — useful for sanity-checking that the mark holds up in both themes before we commit to a light-mode treatment.
A forward look at how the mark would read against the slight-warm neutral palette being considered as lumina's new default (the subject of a follow-up spec, SPEC-051). Light surface #f6f4ef, mark #1c1a17; same SVG, just the colors flipped from "branded warm" to "calm warm neutral." Inverted in the dark example below.
And the inverse — warm near-black surface #1c1a17, mark #f6f4ef. Reads as "calm office" rather than "branded dark mode," which is closer to what neutral-default dark should feel like.
If the 16px result reads as "blob with a brighter corner" rather than as a structured prism, we have two options: ship a favicon-specific variant with thicker strokes (e.g. stroke-width: 5 and dropped cuts entirely, just outline + fill), or accept that 16px is a brand impression, not a brand readout. Worth deciding once the rendered preview is in front of us.
Decision Criteria
Both variants share the same construction — V2 just adds a fill in the top-right region. Pick on visual weight:
- Recognizability at 16–32px. V2 holds together better at the favicon end because the fill remains unambiguous even when the cut strokes merge.
- Visual weight. V1 is all linework — lighter, more delicate, reads as a diagram. V2 has a solid mass — heavier, more present, reads as a mark. Pick to match the rest of the v1.0 visual system.
- Wordmark pairing. V1's lighter weight balances better next to a regular-weight wordmark; V2's heavier weight pairs better with a bold or display wordmark.
Open Questions
- Stroke weight. Currently
3in a 100-unit viewBox. Try2.5for finer linework and3.5for bolder. Especially relevant at small sizes. - Line caps and joins. Currently
round. Sharpmiterjoins withbuttcaps would emphasise the optical/geometric reading at the cost of a slightly harder feel. Worth a side-by-side. - Wordmark. Does the v1.0 brand pair the mark with a "refrakt" wordmark? If so, does the wordmark sit beside or below the mark, and what typeface?
- Light-mode rendering. This spec covers white-on-navy only. Light-mode treatment (navy on white? navy on light grey?) is a follow-up.
Out of Scope
- Animated or interactive variants
- Wordmark typography selection
- Favicon
.icopackaging, social card variants, app icon padding - Light-mode colour treatment (deferred until dark-mode mark is locked)
Tint shape alignment
Tint shape alignment
Align the TintDefinition shape with the rest of the design token contract. The current tint vocabulary uses field names that are inconsistent with the token contract (primary in tints means body text; primary in tokens means the interactive accent — a direct collision) and a top-level mode field that's structurally awkward. This spec realigns tint field names to match the token contract, simplifies mode, adds an extends mechanism, and tightens the project-level type from Record<string, unknown> to a proper TintDefinition.
The CSS bridge implementation (packages/lumina/styles/runes/tint.css) is intentionally not changed by this spec — it's well-designed and only needs the rename applied. This is a vocabulary refactor, not a behaviour change.
Lands in v1.0 as a breaking-change cleanup. Depends on SPEC-048 being implemented.
- Released in
- v0.14.0
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 — and a meaningfully exposed one once SPEC-052 lands, since the frontmatter tint: brand-warm cascade implies users will define their own tints in site config when they want page-level branded tints. Today nobody has been pushed there because tint authoring has effectively lived in theme code; tomorrow it becomes a real surface.
Context — How tints are defined today
Worth being explicit about the current state, because it shapes the migration scope:
- Theme-level definition is the canonical path. Lumina defines its five tints (
base,subtle,warm,cool,dark) inpackages/lumina/src/config.tsviamergeThemeConfig(baseConfig, { tints: { … } }).baseConfigitself ships no tint definitions — it defines thetintrune but no named presets. Lumina is the source of every tint name a user can reference today. - Site-level definition exists but is unused.
sites.<name>.tintsaccepts project-level overrides; the deprecated top-leveltintsfield is a shorthand forsites.default.tints. The repo's ownrefrakt.config.jsondoesn't define any tints, and no example sites in the ecosystem use the surface either. It works in principle but has effectively been a theme-author concern in practice. - The merge is shallow per tint name.
mergeThemeConfigdoes{ ...base.tints, ...overrides.tints }. A site-level tint with the same name as a theme tint replaces it entirely; it doesn't deep-merge per field. To tweakwarm.light.bgfrom a site config today, a user has to re-declare all ofwarm.
Why this matters for the spec. The migration blast radius is small — the migration is for theme authors (lumina + plugins that ship tints), not for end users. But the future authoring surface is real: once SPEC-052's frontmatter tint: cascade lands, site-level tint definition becomes a path users actually walk. Two implications:
- The untyped
SiteConfig.tintsis a forward-looking gap, not just a present cleanliness issue. Type it properly as part of this v1.0 cleanup so the surface is ready when SPEC-052 makes it relevant. - Shallow merge + no
extendsmakes the "tweak one field of an existing tint" use case painful enough to discourage. The proposedextendsfield becomes the canonical idiom:myWarm: { extends: 'warm', light: { primary: '#c2410c' } }rather than a full re-declaration. Documentextendsas the way to derive variants — not as an advanced feature.
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, and as the canonical override idiom. Small affordance technically (copies an existing pattern from BgPresetDefinition), but a real ergonomic improvement given the shallow-merge semantics. Documentation should present extends as the way to derive a tint variant — not as a power feature — so the "tweak one field" case never pushes users into full re-declarations.
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:
- Migration recipe in the docs. Rename five field names (
background → bg,primary → text,secondary → muted,accent → primary) and one top-level field (mode → lockMode). Dropmode: 'auto'entries (now expressed as absence). A documented set of regex/sedrules in the migration guide handles it — given the small in-the-wild surface (essentially lumina and any plugin that ships tints; no end users have written their own), a dedicatedrefrakt migrate tintsCLI command isn't worth the permanent surface. - 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 anddata-tintHTML 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
TintDefinitionandTintTokenstypes inpackages/transform/src/types.ts. DropTintTokenSet. - Update
mergeThemeConfiginpackages/transform/src/merge.tsto resolveextendschains during merge. - Update
engine.ts(packages/transform/src/) to emit new--tint-*property names. TheTINT_TOKENSarray gets renamed entries. - Update
tint.cssinpackages/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 tobase,subtle,warm,cool,dark. - Type
SiteConfig.tintsinpackages/types/src/theme.tsasRecord<string, TintDefinition>. Add the import. - Update plugin tints. Any plugins that ship their own tint definitions get the rename. (Likely scope:
packages/runesand any plugin underplugins/that defines tints — a small audit pass.) - Document the migration recipe in the v1.0 migration guide — a small set of regex/
sedrules covering the five field renames and themode → lockModeswap. No dedicated CLI command. - Drop the deprecated
RefraktConfig.tintsflat-shape field inpackages/types/src/theme.ts. Already marked@deprecated; v1.0 is the moment to remove rather than continue carrying it. - Update tests. The CSS coverage tests in
packages/lumina/test/css-coverage.test.tsand any tint-specific unit tests get renamed selectors.
Acceptance Criteria
TintTokensandTintDefinitiontypes defined per the revised shape;TintTokenSetremovedSiteConfig.tintstyped asRecord<string, TintDefinition>(no moreRecord<string, unknown>)mergeThemeConfigresolvesextendschains and rejects cycles with a clear errorengine.tsemits the renamed--tint-*propertiestint.cssupdated 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 documented migration recipe exists in the v1.0 migration guide (regex/
sedrules; no CLI command) - The deprecated
RefraktConfig.tintsflat-shape field is removed - v1.0 migration guide documents the field renames with before/after examples
- Tint runtime tests pass with the new shape; CSS coverage tests updated
- At least one site-level user-defined tint example exists in the design-rune docs pages (per SPEC-051's acceptance criteria for
/docs/themes/lumina/presets/*) — gives the site-author authoring surface visibility now that SPEC-052 makes it relevant
Out of Scope
- Full structural unification with theme presets — tints stay as inline
TintDefinitionentries 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.raisedor status colours to the tint vocabulary. - Background preset alignment —
BgPresetDefinitionis 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.
- Multi-source
extends—extendsaccepts a single tint name, not an array. Composition across multiple tints belongs at a different level (frontmatterpresets: []per SPEC-052); single-extend keeps the tint definition shape simple. - Per-elevation surface tokens in tints —
TintTokens.surfacestays flat (single field, maps tocolor.surface.base). If a tint needs to vary hover/active/raised surfaces, that's a signal it should be a fuller preset, not a tint. - Deep-merging same-name tints —
mergeThemeConfigkeeps the existing shallow merge: a site-level tint with a name that matches a theme tint replaces it. Theextendsfield is the way to derive a variant. Deep-merging would feel ergonomic but hides what's actually being overridden; explicitextendskeeps overrides legible. - A dedicated CLI migration command — no
refrakt migrate tints. The migration surface is small enough (lumina + plugins that ship tints; no end users in practice) that a documented regex/sedrecipe in the v1.0 migration guide is the right weight. CLI surface is permanent; this migration is a one-time event.
Lumina neutral default & tideline preset
Lumina neutral default & tideline preset
Reposition Lumina from "refrakt's opinionated cream-and-navy theme" to "refrakt's flagship theme, neutral by default, with named presets for those who want a starting palette." The current cream-and-navy values move out of Lumina's defaults into a named preset (tideline) that anyone can opt into. The defaults become a deliberately quiet warm-neutral so refrakt's documentation surface reads as a canvas any brand could occupy.
This is primarily a positioning decision. SPEC-048 already designed the preset mechanism as plain data merged in order; this spec is about what we ship in that mechanism, not new infrastructure. It depends on SPEC-048 being implemented.
- Released in
- v0.14.0
Problem
The default palette is competing with the user's brand. Today every refrakt site looks like cream-and-navy until a user manually overrides it. New visitors form a first impression: "this is what refrakt looks like." That impression then has to be argued against when those users imagine putting their own brand on top — the default is doing the opposite of what refrakt's value proposition claims. Refrakt sells the pipeline (Markdoc + runes + theming as data); the visual default is currently selling a look.
The architecture says "themes are pluggable," the defaults say "this is the theme." SPEC-048 promotes design tokens to a typed contract and treats presets as plain data — exactly the architecture you'd want if themes and palettes were genuinely interchangeable. But the flagship demo (the refrakt site, scaffolded projects, every screenshot) renders Lumina's one opinionated palette. The architecture story and the visual story aren't aligned.
The current palette has no name. "Lumina" is the theme — typography, spacing, rune CSS, component structure. The cream-and-navy palette layered on top of Lumina is unnamed, which makes it hard to talk about ("the default colours? the lumina colours? the navy palette?") and hard to opt in or out of as a discrete unit. Naming it makes it portable.
Docs examples look "branded," not canvas-like. When a user lands on a doc page that shows a rune with cream-and-navy chrome, they read the example as "what refrakt looks like" rather than "what your refrakt site could look like." Examples should feel like a neutral canvas the reader can mentally re-skin onto their own brand. Cream-and-navy makes that mental swap harder than it needs to be.
Design Principles
Lumina stays. The palette gets named. This isn't a new theme package, and Lumina isn't getting demoted. Lumina remains refrakt's flagship theme — same typography, spacing, rune styling, component structure, dark-mode handling. The only change is what colour values it ships with by default, and that the current colour values become a named preset (tideline) anyone can opt into in one line of config.
Neutral is a design choice, not an absence. "Neutral default" doesn't mean "no design." It means a quiet warm-neutral palette built with the same care as the current cream-and-navy — typography is still confident, spacing is still deliberate, chrome is still composed. The goal is the Vercel docs register: clearly designed, unmistakably restrained. Not the blank Bootstrap register.
Slight warm, not cool. Pure cool greys read as system UI and feel cold. Pure warm reads as cream and feels branded. The default sits between — low chroma, a warmth bias around 70–80° hue, low enough saturation that the warmth is felt rather than seen. The previews in SPEC-050's icon-scale section use #f6f4ef light / #1c1a17 dark as anchors; the full palette extends from there.
Brand identity lives in chrome and in the prism. With the palette doing less brand work, the new prism logo (SPEC-050) and the site's typographic chrome carry refrakt's identity. The body content area renders neutral; the header, footer, navigation, and marketing surfaces can still feel distinctly refrakt-shaped. This is the same split Vercel, Stripe, and Linear use.
Two presets at launch — one full, one scoped. Ship tideline (full preset, the current cream-and-navy palette named) and niwaki (syntax-only preset, the Japanese garden code palette). The pair demonstrates both preset patterns the architecture supports — full identity overhaul and scoped layered override — at modest maintenance cost (niwaki is only seven tokens). Together they make the "presets are real product surface, composable, not all-or-nothing" story land at v1 rather than asking users to take it on faith. Additional presets (midnight, slate, etc.) remain explicit future work — out of scope here, in scope for community contribution.
Presets can be scoped, not all-or-nothing. A preset is just a ThemeTokensConfig — and that config can override any subset of tokens. Full presets (like tideline) override body, chrome, syntax, and primary for a complete identity overhaul. Syntax presets (like the niwaki proposal) override only the syntax palette, leaving chrome to inherit. Chrome presets are also conceivable (body + status only). Users compose by layering — presets: ["tideline", "niwaki"] gives tideline chrome with niwaki code blocks; presets: ["niwaki"] against the neutral default gives Japanese garden code against neutral chrome. The mechanism doesn't enforce scope; preset authors choose it based on what the preset is for.
create-refrakt shows the unopinionated baseline. Scaffolded projects get the pure neutral default — no presets array, no syntax theme, just Lumina's base values. The refrakt site itself diverges by one layer (it adds niwaki for syntax), and that divergence is intentional: fresh projects represent "what defaults look like" so users see an honest baseline before opting into any preset. The post-init message surfaces both tideline and niwaki as one-line opt-ins for users who want a starting point, with a pointer to the site for live examples of each.
The Neutral Default
Light surface anchors at #f6f4ef (warm off-white, ~OKLCH L 0.967 C 0.005 H 80) with #1c1a17 for text (~OKLCH L 0.205 C 0.005 H 50). Dark surface inverts: #1c1a17 background, #f6f4ef text. From those anchors the rest of the palette extends:
| Token | Light | Dark |
|---|---|---|
color.bg | #f6f4ef | #1c1a17 |
color.text | #1c1a17 | #f6f4ef |
color.muted | #6b6661 | #94908a |
color.border | #e8e5df | #2d2926 |
color.surface.base | #fcfaf6 | #232017 |
color.surface.raised | #ffffff | #2a2622 |
color.surface.hover | #efece5 | #2d2926 |
color.surface.active | #e8e5df | #3a342d |
color.primary | #1c1a17 | #f6f4ef |
color.primary-hover | #3a342d | #d4cfc5 |
color.code.bg | #ebeae8 | #222220 |
color.code.inline-bg | #e6e5e3 | #2b2b29 |
color.code.text | #1c1a17 | #f6f4ef |
Status colours (info, warning, danger, success) stay independent of brand — they communicate function, not personality. Lean muted: a slightly desaturated blue for info, an unsaturated amber for warning, a brick-red rather than fire-engine red for danger, an unsaturated forest green for success. Exact hues nailed down during implementation; the principle is "functionally readable, never garish."
The primary colour is intentionally monochromatic — it shadows text. Buttons, links, and accents render in the same near-black/near-white as body copy, slightly bolder or shifted on hover. This is the strongest "the brand doesn't paint over your content" move: even refrakt's interactive accents are tonal, not chromatic. The new prism logo carries the visual identity that a coloured primary token would otherwise carry.
The primary-scale (50→950) follows the same warm-neutral axis — a ramp from near-bg to near-text in eleven stops. Used for hover/active layering and for runes that need internal contrast steps without introducing a hue.
Typography. font.sans is Inter, font.mono is JetBrains Mono — the de facto modern-neutral pair, both free and available via Google Fonts or self-hosting. Used widely enough that visitors don't register them as deliberate choices, which is what neutral wants: the typography disappears the same way the palette does. Outfit (the previous Lumina default) gets retired in this change — it's serviceable but has acquired a "startup-of-the-mid-2020s" feel that ages less gracefully than Inter. Existing sites that specifically depended on Outfit can pin it back via theme.tokens.font.sans.
The Syntax Highlighting Palette
With the rest of the theme intentionally monochromatic, code blocks become the one place chromatic colour appears in body content. That's a deliberate move — syntax roles carry real cognitive information (keywords vs functions vs types is structural, not decorative), so functional colour use is justified there. It's also where refrakt's "refraction" metaphor can show up subtly: five hues that walk quietly across the spectrum, hinting at light being split without literally drawing a rainbow.
The proposed palette uses five "meaning-bearing" hues plus muted tones. Roles like variable, operator, punctuation, and ordinary identifiers stay tonal (text-coloured or muted) — the eye gets fatigued when every token is doing something different.
| Role | Light (#f6f4ef bg) | Dark (#1c1a17 bg) | Hue |
|---|---|---|---|
syntax.keyword | #2a5c63 | #7eb6bc | deep teal |
syntax.function | #4a3b6e | #a89bc7 | slate violet |
syntax.string | #8a3a3a | #c79a9a | warm rust |
syntax.number | #876327 | #d4b07e | antique ochre |
syntax.type | #3a5c2a | #94b385 | sage moss |
syntax.comment | #8a857d (italic) | #6b6661 (italic) | warm muted |
syntax.punctuation | color.muted | color.muted | tonal |
syntax.variable | color.text | color.text | tonal |
The five chromatic hues walk teal → violet → rust → ochre → sage around the wheel — cool, cool, warm, warm, cool/warm. Spectrum-adjacent without being on the nose. All five sit comfortably on the warm-neutral surface: warm-leaning hues (rust, ochre) are deep enough not to wash out into the bg; cool-leaning hues (teal, violet, sage) provide the contrast that gives the highlighter its read.
A live preview of the palette on the proposed neutral default, light then dark:
A few notes on the choices:
- Code surfaces step down chroma, not just lightness. The page bg is deliberately warm — it's the "paper" character of the theme. But code blocks are a different material, and stacking a warm-equal surface on a warm surface compounds visually into "brown." So
code.bgkeeps roughly the same lightness as a one-step-deeper surface would, but drops chroma to about a third of the page bg's. The result reads as an architectural inset rather than more paper. Same principle in dark mode (less warmth in the code surface, more neutral grey). - Teal over blue for keywords. Blue is the universal default in syntax highlighting (every editor since Borland Turbo Pascal has done blue keywords). Teal sits in the same cool family but reads as deliberate rather than default. Swappable for a conventional ink-blue (
#2c4a6e/#9bb4c7) if "deliberate" feels too try-hard once we see it in real docs. - Light mode is mid-saturation; dark mode is lifted and slightly desaturated. Pure inversions of the light hues read as too saturated against the warm-dark surface — the dark variants pull a step toward muted to keep code visually calm in dark mode.
- Verify against real code, not single tokens. A palette that's beautifully balanced on a hand-curated snippet can read as noisy when half the screen is strings or half is comments. Implementation should include a visual pass on at least one substantial block per popular language (TS, Python, Markdown, JSON, HTML, Bash) before locking exact values.
The Status Palette
The four sentiment colours (info, warning, danger, success) are the other chromatic surface in the theme — used for callouts, hint runes, form validation messages, banner notifications. Like syntax highlighting, their colour is functional (it communicates state), so colour is earned. Unlike syntax highlighting, they appear in chrome and content equally, so they need to read clearly at a glance without competing with surrounding text.
The four hues are tuned to a single saturation/lightness band so they form a visual family — none more aggressive than another. Each sentiment uses three tokens per SPEC-048's contract: base (text and accent line), bg (filled surface), border.
| Token | Light | Dark |
|---|---|---|
info.base | #34547a | #9bb4c7 |
info.bg | #e8edf4 | #1f2530 |
info.border | #c5d2e0 | #3d4655 |
warning.base | #9c5a18 | #d4a868 |
warning.bg | #f5ebd9 | #2a2519 |
warning.border | #e0c9a3 | #4a3f2a |
danger.base | #a83232 | #d48888 |
danger.bg | #f5e0e0 | #2a1818 |
danger.border | #e0b8b8 | #4a2a2a |
success.base | #2d6a3e | #7eb398 |
success.bg | #e0eee4 | #1a2a1f |
success.border | #b8d4be | #2a4a35 |
Hue map: info is deep ink blue (cool, structural), warning is deep amber (orange-leaning so it doesn't collide with syntax.number ochre), danger is brick red (saturated enough to read as alert without going fire-engine), success is forest green (saturated enough to be distinct from syntax.type sage). Across all four, the light bases sit at roughly the same darkness as body text; the dark bases sit at roughly the same lightness. Bg tints are very pale washes of the same hue; borders are one step deeper than the bg.
Light theme:
Dark theme:
A few notes on the choices:
- All four hues sit at the same saturation/lightness band. This is the "family" property — no single sentiment should look more aggressive than another. If
dangerever needs to feel more alarming, the move is to animate it (pulse the border), not to push its red louder. The base palette stays calm. - The bg tints are very pale. Around 4–6% saturation of the base hue. Strong enough to identify the sentiment at a glance, weak enough that body text on top reads in
color.textwithout needing colour adjustment. Same in reverse for dark mode. - Deliberate distance from syntax colours.
warningis orange-amber, not yellow-ochre (vs.syntax.number).dangeris fuller red, not warm rust (vs.syntax.string).successis fuller green, not sage moss (vs.syntax.type). The two palettes coexist in the same docs page without anyone reading a callout as code or vice versa. - No "neutral" or "tip" sentiment in the contract. SPEC-048's contract is exactly these four. A "tip" or "note" hint without sentiment colour falls back to a neutral chrome (default
border,surface.base, body text) — no chromatic accent. That's intentional: if it doesn't communicate state, it doesn't need colour.
The Tideline Preset
Everything currently in packages/lumina/tokens/base.css and packages/lumina/tokens/dark.css becomes the content of a new preset module:
packages/lumina/presets/tideline/
index.ts — exports a ThemeTokensConfig with base + dark mode overlays
README.md — what this preset is, when to use it
Opt-in:
{
"theme": {
"package": "@refrakt-md/lumina",
"presets": ["@refrakt-md/lumina/presets/tideline"]
}
}
That single line restores today's colour appearance exactly — same cream, same navy, same primary-700, same surface colours, same dark-mode treatment. Colour values are a verbatim transcription of the previous Lumina defaults into SPEC-048's ThemeTokensConfig shape.
Typography is upgraded as part of the tideline bundle. Tideline ships font.sans = IBM Plex Sans and font.mono = IBM Plex Mono, rather than the previous Outfit. Plex Sans is humanist and slightly warm — pairs more naturally with the cream-paper / maritime-navy palette than Outfit's geometric leanings did — and the matching Plex Mono gives tideline a cohesive Plex-family identity (Sans + Mono designed to work together). Both are free, open-source, and available via Google Fonts or self-hosting. Sites that depended specifically on Outfit can pin it back via theme.tokens.font.sans after applying the tideline preset.
Naming: tideline evokes the boundary where land meets water — which is exactly the warm/cool boundary the palette walks (sand-cream paper against deep maritime navy). Alternatives considered: seaside, marin, mendocino, cove. Tideline wins on poetic specificity — it names a line, not a place, which fits the palette's two-element character.
The Niwaki Preset (syntax-only, committed to v1 launch)
The second of two v1 presets — a Japanese-garden-inspired syntax palette. Where tideline is a full identity overhaul (body, chrome, syntax, primary, typography), niwaki is deliberately scoped to syntax tokens only. It overrides how code blocks render and nothing else, letting page chrome — and fonts — inherit from whichever theme or preset is layered above. The result composes: presets: ["niwaki"] gives Japanese garden code against neutral chrome in JetBrains Mono; presets: ["tideline", "niwaki"] gives tideline chrome in Plex Sans with niwaki-coloured code in Plex Mono. The preset demonstrates that opinionated identity doesn't require overhauling the whole surface — sometimes the strongest move is to do one thing very well. The refrakt site itself uses presets: ["niwaki"] (see Site & Scaffold Implications).
The six elements each map to a structural role in code:
| Element | Japanese | Syntax role | Why |
|---|---|---|---|
| Pine | matsu | keyword | year-round structural backbone of the garden |
| Cherry blossom | sakura | function | eye-catching, the moment you act |
| Maple leaf | momiji | string | warm seasonal content |
| Young leaf | wakaba | type | fresh growth, generative |
| Amber / dried bamboo | kuri | number | earthy, plain, weight-bearing |
| Stone | ishi | comment, muted | quiet background presence |
The colour-to-role assignments aren't decorative — pine as keyword maps the most stable garden element to the most structural code element; sakura as function maps the eye-catching seasonal moment to the eye-catching call-to-action. The metaphor does real work.
Naming: niwaki (庭木, Japanese for "garden tree") refers to the deliberately pruned and shaped trees — most often pines — that form the sculptural backbone of a Japanese garden. The art of niwaki is patient, multi-decade shaping: pruning what's there to reveal structural form. That maps remarkably well to what a syntax preset does — it doesn't generate code, it shapes how code's existing structure becomes legible. The name also mirrors the preset's scope: niwa (garden) is the whole; niwaki (garden tree) is one element within it — paralleling how the preset overrides one layer of the theme rather than the whole. The pine reference also resonates with the matsu-as-keyword mapping (most niwaki are pines; pine is our structural backbone token). Alternatives considered: niwa (whole garden, broader but less specific), hanami (cherry-blossom viewing, spring-leaning), momiji (autumn maple, orange-leaning), kyoto (geographic).
Palette
Niwaki overrides only the syntax tokens. Body, chrome, surfaces, primary, status — all inherit from whichever theme or preset sits beneath it. Used on its own against the neutral default, the Japanese garden colours read against a calm neutral chrome (the colours pop more this way — no warm-paper surface competing for the eye).
| Token | Light | Dark |
|---|---|---|
syntax.keyword | #2d5230 matsu | #8ab589 |
syntax.function | #b35070 sakura | #e89db0 |
syntax.string | #c4501c momiji | #e87a3a |
syntax.number | #9c721a kuri | #d4a85a |
syntax.type | #6b8a35 wakaba | #b4c97a |
syntax.comment | #7d7062 (italic) | #7d7062 (italic) |
syntax.punctuation | #8a7c6e | #7d7062 |
That's it. Seven tokens (six chromatic plus punctuation), light and dark variants, nothing else. Everything outside code blocks looks exactly like the layer beneath — the neutral default if used alone, tideline if layered as ["tideline", "niwaki"].
Live preview
Notes
- Why syntax-only. The neutral default's chrome is already calm, deliberate, and pleasant to read against. A "full Japanese garden" preset would override good chrome with merely-different chrome, dilute the focus, and lock users into an all-or-nothing choice. Scoping niwaki to syntax keeps the strongest part of the metaphor (the colours on code) and lets users compose freely — niwaki on neutral, niwaki on tideline, niwaki on a future custom theme. The preset earns its name through what it commits to most clearly: the code surface.
- Sakura is deliberately more saturated than literal cherry blossom. Real sakura is
#ffd1dc-pale — beautiful in a garden, unreadable in code. The preset's#b35070is closer to a "depicted sakura" — what an ink painter would paint to suggest sakura, not what a camera would capture. Same instinct applies to momiji and the other elements: stylised for readability, not literal. - Pine and wakaba differentiate generations. Mature deep pine for structural elements (keywords); brighter young-leaf green for fresh entities (types). The age difference reads visually and conceptually — static framework vs. generative declaration.
- The colours pop more against neutral than they would against warm paper. The previous draft put niwaki code on a
#ebe5dcwarm code surface; the result felt "brown" and competed with the syntax colours. With the code surface inheriting the neutral default's#ebeae8, the garden palette has the chromatic stage to itself — pine reads as pine, sakura reads as sakura, without the warm bg pulling them toward "all earth tones." - Cultural sensitivity. Naming a preset after a non-Western aesthetic warrants intentionality. Using Japanese terms (niwaki, matsu, sakura) rather than translated descriptors reads as homage rather than exoticism, but the preset's README should briefly credit the visual tradition it draws from.
Site & Scaffold Implications
The refrakt documentation site rebuilds against base + niwaki — the neutral default carrying chrome (body, surfaces, status, primary), niwaki carrying syntax. This composition does two jobs at once: chrome stays identical to a fresh create-refrakt project, so every callout, rune, and component example a visitor sees is an honest demonstration of "what refrakt looks like by default"; and the syntax surface carries niwaki's Japanese garden identity, making code blocks the site's brand carrier — refrakt's voice on the one surface most uniquely refrakt's (the code rendering). Marketing pages, blog, docs — all render the same way; the homepage doesn't quietly use a different palette than the docs. The site also becomes a live demo of the scoped-preset pattern: visitors see presets: ["niwaki"] doing real work, not just described as architecture.
Signal what the site is using. A small "this site uses the niwaki preset" note in the footer, a theme indicator in the docs sidebar, or a deliberate mention on the homepage — something to help visitors mentally separate "default refrakt" (the chrome they're seeing) from "this site's choice" (the syntax they're seeing). The signal doesn't need to be loud, just present. Otherwise visitors may attribute the garden code colours to the default and feel surprised when their own scaffolded project renders neutral syntax.
Preset documentation pages use design-plugin runes. Each of the neutral default, tideline, and niwaki gets a dedicated docs page (/docs/themes/lumina/neutral-default, /docs/themes/lumina/presets/tideline, /docs/themes/lumina/presets/niwaki). These pages use runes from @refrakt-md/design — palette, swatch, typography, spacing — to render token values visually rather than describing them in prose. Combined with preview and sandbox runes for live composition examples, the result is a closed self-demonstration loop: the niwaki page's palette rune shows the seven syntax colours via swatches; the code blocks elsewhere on the same page already render with niwaki syntax (because the site uses it); the colours match exactly. It also gives the @refrakt-md/design plugin its canonical use case — refrakt's own theme docs become the reference example of what design runes are for.
create-refrakt scaffolding defaults to the neutral palette. Two options for how to surface presets during scaffolding:
- Neutral always, prompt to add a preset.
npm create refraktproduces a neutral project; the post-init message mentionslumina/presets/tidelineand other future presets as one-line opt-ins. - Prompt during scaffolding. Ask "use a preset? (neutral / tideline / no)" with neutral as default. The chosen preset gets baked into the generated
refrakt.config.json.
Option 2 is slightly more discoverable but adds prompt friction. Recommend option 1 for v1; revisit if users report not finding the preset surface.
Existing refrakt sites that depend on the current cream-and-navy appearance get a one-line migration: add "presets": ["@refrakt-md/lumina/presets/tideline"] to refrakt.config.json. Document this prominently in the v1.0 release notes and the migration guide. Sites that have already customised the palette via theme.tokens are unaffected — their overrides still win against whatever Lumina ships as defaults.
Implementation
- Implement SPEC-048 first. This spec depends on the typed token contract, the preset merge order, and the
ThemeTokensConfigshape landed there. If SPEC-048 isn't merged, this spec is blocked. - Write the neutral default palette into
packages/lumina/tokens/base.css(or wherever SPEC-048's implementation parks the theme's base values). Cover both light and dark modes, including the five-hue syntax palette, the four-sentiment status palette, and the typography pair (font.sans= Inter,font.mono= JetBrains Mono) with appropriate fallback stacks. - Extract the current colour values into
packages/lumina/presets/tideline/as aThemeTokensConfigmodule exporting base and dark mode overlays. Add tideline's typography overrides (font.sans= IBM Plex Sans,font.mono= IBM Plex Mono) with fallback stacks. Verify against a visual diff that a site withpresets: ["@refrakt-md/lumina/presets/tideline"]renders colour-identical to today's Lumina (font change is expected and documented). - Author
packages/lumina/presets/niwaki/as aThemeTokensConfigoverriding only the seven syntax tokens (light + dark). Niwaki does not touchfont.*. Write the README crediting the Japanese visual tradition the palette draws from. - Configure font loading — Lumina ships with the typography assets needed (Inter, JetBrains Mono, Plex Sans, Plex Mono) either via a
<link>to Google Fonts in the document head, self-hosted inpackages/lumina/assets/fonts/, or both with an opt-out. Decide during implementation; the spec is agnostic between approaches. - Update the refrakt site config to use
presets: ["niwaki"]against the neutral default — neutral chrome, niwaki syntax. Add the visible "this site uses niwaki" signal to footer/sidebar/homepage. - Update
create-refrakttemplate sorefrakt.config.jsonships without apresetsarray, and update the post-init message to surface bothtidelineandniwakiwith config snippets. - Author preset documentation pages at
/docs/themes/lumina/neutral-default,/docs/themes/lumina/presets/tideline, and/docs/themes/lumina/presets/niwaki, using design-plugin runes (palette,swatch,typography,spacing) for visual token rendering andpreview/sandboxrunes for live composition examples. - Re-shoot site screenshots against the new appearance (neutral chrome + niwaki syntax).
- Write the v1.0 migration note: a one-line config snippet for users who want the old look back, including the font pin if needed.
Marketing & Positioning
This shift gives refrakt a stronger story to tell:
Lumina is refrakt's flagship theme. It ships with a quiet warm-neutral palette so your content is what your readers see — not refrakt's brand sitting on top of it. Two presets demonstrate the architecture:
tideline(a full preset — cream and deep maritime navy across chrome, syntax, and primary) andniwaki(a syntax-only preset that overlays Japanese garden colours on code blocks without touching chrome). Compose them —presets: ["tideline", "niwaki"]gives tideline chrome with niwaki code. Layer your own on top, or override tokens directly. The refrakt site itself usesniwakion the neutral default, so what you read here is the architecture doing real work.
This story sells SPEC-048's architecture in the same breath as it sells Lumina. The current palette doesn't disappear — it gets a name, becomes shareable, and joins niwaki to demonstrate the two preset patterns the system supports (full and scoped) at the moment refrakt v1.0 ships.
It also subtly reframes refrakt's category. "Themed SSG" implies you adopt the theme. "Content pipeline + flagship theme with optional presets" implies you adopt the pipeline and dress it however you want. The second framing is closer to refrakt's actual shape.
Acceptance Criteria
- SPEC-048 is implemented and merged
- Lumina's default token values shift to the neutral palette described above (light + dark), including the five-hue syntax highlighting palette and the four-sentiment status palette
packages/lumina/presets/tideline/exists, exports aThemeTokensConfig, and contains verbatim values from the previous Lumina defaultspackages/lumina/presets/niwaki/exists, exports aThemeTokensConfigoverriding only the seven syntax tokens (light + dark), inherits everything else (including fonts)- Fonts ship with Lumina — Inter and JetBrains Mono for the neutral default, IBM Plex Sans and IBM Plex Mono for the tideline preset — with fallback stacks and a documented loading approach (Google Fonts link, self-host, or both)
- A test site with
presets: ["@refrakt-md/lumina/presets/tideline"]renders pixel-identical to the previous Lumina default (visual regression test, or curated diff review) - A test site with
presets: ["@refrakt-md/lumina/presets/niwaki"]renders neutral chrome with niwaki syntax colours; layering aspresets: ["tideline", "niwaki"]produces tideline chrome with niwaki syntax (composition test) - The refrakt documentation site renders with
presets: ["niwaki"]against the neutral default chrome - The refrakt site has visible signal of which preset(s) it uses (footer note, theme indicator, or homepage mention)
- Each of
neutral-default,tideline, andniwakihas a dedicated documentation page on the refrakt site that uses design-plugin runes (palette,swatch,typography,spacing) for visual token rendering, paired withpreview/sandboxrunes for live composition examples create-refraktscaffolds projects with nopresetsarray, producing the neutral default appearance out of the boxcreate-refraktpost-init message surfaces bothtidelineandniwakias one-line opt-ins with config snippets- v1.0 release notes include the one-line migration snippet for existing sites that want the old look
- Both
tidelineandniwakipresets have READMEs explaining what they are and when to use them; niwaki's README credits the Japanese visual tradition it draws from
Out of Scope
- Additional presets beyond
tideline(e.g.,midnight,slate,graphite). Future work, possibly community-contributed. The architecture supports any number; we just don't ship them at v1. - A coloured
primarytoken in the neutral default. Explicitly chosen as monochrome — see Design Principles. If user feedback after v1 says the neutral default feels too austere, revisit then with data. - Brand wordmark, typeface selection, marketing-site refresh. Adjacent but distinct work. Worth a follow-up spec once the neutral palette and the new prism logo (SPEC-050) are both landed.
- Migration tooling for sites already using the cream-and-navy palette. Manual one-line config edit is the migration; we don't need a codemod for that.
- Renaming Lumina. Out of scope. The theme keeps its name. Only its default colour values change.
Open Questions
- Final syntax palette values. The proposed teal / violet / rust / ochre / sage walk is a starting point; the implementation step needs a visual pass on real code in multiple languages before locking. Teal vs. conventional ink-blue for keywords is the most likely place to revisit.
- Status palette saturation check at scale. The proposed values look calm in isolation but need verification on a page with multiple stacked callouts, on a form with several validation states visible at once, and at small sizes (toast notifications, inline badges). If any of the four bgs feel too saturated when several are visible together, dial the bg tints down further; the
baseandbordervalues are more stable. - Final syntax-palette tuning for niwaki. The proposed values look right in the spec's sandbox, but need a multi-language visual pass (TS, Python, Markdown, JSON, HTML, Bash) before locking — same standard as the neutral default's syntax palette. Sakura saturation against the neutral surface is the most likely place to tweak.
font.serifslot in SPEC-048. The token contract currently hasfont.sansandfont.monoonly. A future editorial-leaning preset (Plex Serif or Newsreader body) would need afont.serifslot in the contract. This is a SPEC-048 amendment, not a SPEC-051 decision, but flagging it here so it doesn't get lost — and so we don't have to fudge thesansslot to hold a serif value when the time comes.- Font loading strategy. Self-hosted vs. Google Fonts link vs. both-with-opt-out is an implementation choice with real perf and privacy implications. Self-hosted is more private and avoids a CDN dependency; Google Fonts is one line and gets cache benefits. Decide once the rest is in place; mention the tradeoff in the lumina docs either way.
primary-scaleramp values. Eleven stops along the warm-neutral axis from near-bg to near-text. Generated mathematically or hand-picked? Lean hand-picked for visual rightness; tooling can verify monotonicity.- Should marketing pages on the refrakt site opt into
tidelineas an example? Lean no for message coherence. But it's a defensible choice if we feel the homepage needs more visual distinctiveness — would need to be deliberate, framed as "here's a preset in action," with a way for visitors to see the neutral default too. - Preset naming convention going forward.
tidelineis geographic/atmospheric. Future presets follow the same axis (midnight,mendocino,driftwood) or open it up to anything (graphite,inkwell)? Worth deciding before the second preset lands so the catalog doesn't feel haphazard.
Per-page and per-subtree tint cascade
Per-page and per-subtree tint cascade
Extend the existing per-rune tint and tint-mode attributes to cascade through layout files and individual pages, so authors can express "lock all marketing pages to dark mode" or "this single page uses the hero-brand tint preset" without writing per-rune attributes everywhere. Reuses the layout cascade refrakt authors already work with, rather than introducing a separate route-rules config surface.
Depends on SPEC-048 (typed token contract, tint system, theme.colorScheme field at site level).
- Released in
- v0.14.0
Problem
Today tint (named colour) and tint-mode (light / dark / auto) exist only as per-rune attributes. A hero can be dark; a callout can use a named tint. But there's no way for an author to express:
- "This entire marketing subtree is dark, no toggle, no system preference." (Linear's homepage, Stripe's product pages)
- "Docs respect user preference; marketing doesn't." (Vercel, Stripe, Linear all do this)
- "This one launch announcement uses the
brand-warmtint preset for the day it's live."
These are real patterns from production sites. Today refrakt requires authors to either (a) apply tint and tint-mode to every top-level rune on the page individually (verbose, error-prone, easy to miss), or (b) write CSS that sets data-theme on the page root by hand (works but bypasses the token system the rest of refrakt uses).
Why not just promote theme.colorScheme to per-page? SPEC-048 added theme.colorScheme: auto | light | dark at the site level. Extending that field to per-page would solve the dark/light axis but miss the more general case — named tint presets (brand-warm, seasonal-autumn, etc.) that aren't on the dark/light axis at all. The existing tint attribute already supports named tints; extending its cascade is strictly more powerful than promoting colorScheme.
Design Principles
Cascade through what's already there. Refrakt already cascades via _layout.md files. This feature uses that same mechanism — no routes array in config, no separate per-page registration. tint and tint-mode become frontmatter fields that cascade with the same precedence rules as layout content. If you already understand how _layout.md cascades, you already understand how tint cascades.
Field names match existing rune attributes exactly. The fields are tint and tint-mode, identical to the existing rune-level attributes. Same value semantics — tint accepts a named tint preset name or empty; tint-mode accepts auto / light / dark. Learning the page-level cascade is reusing knowledge from the rune-attribute level, not learning a new system.
Locked vs. preferred is explicit, not inferred. tint-mode: dark alone means "default to dark but user can toggle"; combine with tint-lock: true to mean "always dark, the toggle does not apply here, user preference is ignored." Without explicit locking, the user's runtime choice always wins for the unlocked axes. This mirrors how Linear marketing locks while Linear docs default-but-allow.
Resolution at SSR, not on the client. The effective (tint, tintMode, locked) tuple for each page is resolved at build time and emitted as data-theme, data-tint, and data-tint-lock attributes on <html>. No flash of incorrect theme on dark-locked marketing pages. Client JS only acts when a page is unlocked.
Cascade order is global → layout → page → rune. Last writer wins. Each level can override the next-narrower one. A rune with tint-mode="dark" always wins over its page setting, which wins over its containing layout's setting, which wins over the global config. This is the same precedence model the existing layout cascade uses.
The Cascade
Four levels, narrowest wins:
Level 1 — Site config
{
"theme": {
"colorScheme": "auto"
}
}
Site-wide default. SPEC-048's existing field; this spec reuses it as the cascade's root.
Level 2 — Layout (_layout.md frontmatter)
---
layout: marketing
tint-mode: dark
tint-lock: true
---
Applies to every page under this directory unless overridden. The natural place to express "all of /marketing is dark."
Level 3 — Page frontmatter
---
title: Launch Day
tint: brand-warm
tint-mode: light
---
Overrides layout for this page only. Useful for one-off announcements, feature pages, or breaking out of a subtree default.
Level 4 — Rune attribute (existing)
{% hero tint-mode="dark" %}
...
{% /hero %}
Overrides page for this rune only. Already works today; this spec doesn't change rune-level semantics, only adds the page and layout levels above them.
Worked example
site/content/
_layout.md # tint-mode: dark, tint-lock: true (marketing default)
index.md # → locked dark
about.md # → locked dark
blog/
_layout.md # (inherits — locked dark)
announcement.md # → locked dark
docs/
_layout.md # tint-mode: auto, tint-lock: false (override the lock)
getting-started.md # → auto, respects user preference
runes/
_layout.md # tint-mode: auto, tint-lock: false
hint.md # → auto
plan/
docs/
_layout.md # tint-mode: auto, tint-lock: false
overview.md # → auto
The root _layout.md sets a dark-locked default; documentation subtrees (docs/, runes/, plan/docs/) explicitly unlock and switch to auto. Individual pages can still override either way.
Refrakt Site Adoption
The refrakt site itself adopts this cascade as part of this spec. The structure above isn't hypothetical — it's the configuration the refrakt site ships with at v1.0.
Rationale. Marketing surfaces (/, /about, /blog/*) are brand statements — they should look the same to every visitor regardless of system preference, the way Linear's homepage and Stripe's product pages do. Documentation surfaces (/docs/*, /runes/*, /plan/docs/*) are reading-for-hours surfaces — the user's eyes win over the brand's preference, the way Linear's and Vercel's docs do. The cascade lets us express both stances declaratively without per-page repetition.
Configuration:
| Subtree | tint-mode | tint-lock | Rationale |
|---|---|---|---|
/ (root, default) | dark | true | Marketing default — homepage, about, anything not under a docs subtree |
/blog/* | inherited | inherited | Inherits root → locked dark; blog reads as marketing-adjacent for refrakt |
/docs/* | auto | false | Reading surface — respect user/system preference |
/runes/* | auto | false | Rune reference docs — same treatment as docs |
/plan/docs/* | auto | false | Plan documentation surface — same treatment as docs |
tint-mode: auto is the documented value for "follow the user's saved preference, falling back to system prefers-color-scheme" — semantically what every reading surface wants by default.
Layout files affected: site/content/_layout.md, site/content/docs/_layout.md, site/content/runes/_layout.md, site/content/plan/docs/_layout.md all gain the relevant frontmatter. site/content/blog/_layout.md doesn't need a tint change (inherits the root). Existing layout settings (region definitions, etc.) are unchanged.
Edge cases worth flagging during implementation:
/plan/*is marketing for refrakt's planning system (the@refrakt-md/planplugin and CLI), not the repo's own dev-timeplan/directory. So/plan/*correctly inherits the root's locked dark; only/plan/docs/*(the plan system's documentation) flips to auto. The repo-rootplan/content is dev-only and never rendered on the site.- The toggle UI behaviour matters more now — on locked pages it should hide entirely (per spec); on auto pages it should be visible and functional. Verify Lumina's toggle implementation honours the contract before committing to this configuration site-wide.
- Pre-paint script must read the resolved tuple correctly for the locked-vs-unlocked distinction. A flash of light content on a dark-locked marketing page would be visible and embarrassing; worth a dedicated visual test before launch.
Frontmatter Authoring Surface
| Field | Type | Default | Where |
|---|---|---|---|
tint | string | null | inherited | layout, page |
tint-mode | "auto" | "light" | "dark" | inherited (defaults to "auto" at the root) | layout, page |
tint-lock | boolean | false (inherited) | layout, page |
All three fields are optional at every level. Missing means inherit from the next outer level. To explicitly break out of a layout setting and revert to inherit-from-further-up, set the field to null (e.g., tint: null to remove an inherited named tint without applying a new one). Setting tint-lock: false explicitly in a page can unlock a page that lives under a locked layout.
The rune-level attributes (tint, tint-mode on individual runes) remain unchanged. They override page-level resolution for the scope of that rune's subtree. Rune-level tint-lock is not introduced — locking is a page concern, not a rune concern.
SSR & Rendering
At build time, the page transform pipeline runs cascade resolution alongside other frontmatter resolution. For each page it produces a single tuple:
type ResolvedTint = {
tint: string | null; // named tint preset, or null
tintMode: 'auto' | 'light' | 'dark';
locked: boolean;
};
The renderer uses this tuple to:
- Emit attributes on
<html>:data-theme="dark",data-tint="brand-warm",data-tint-lock="true". CSS cascade reacts to these. - Emit
<meta name="color-scheme">matching the resolved mode (when locked) orlight dark(when unlocked, letting the browser hint UI). - Set initial state for the client. When unlocked, the client may swap
data-themebased on user preference or saved choice. When locked, the client must not. - Inject a small inline script that runs before paint, checks saved user preference, and applies it — only when the page is unlocked. Same anti-FOIT pattern most theme-toggle implementations use.
The cascade resolution is pure: same inputs → same output. No runtime state. Build-time caching is straightforward.
Toggle UI
The theme toggle (if the site has one — typically supplied by Lumina or user-added) reads data-tint-lock on <html>:
- Locked page: toggle hides entirely. (Alternative: render disabled with a tooltip — author preference; spec is agnostic.)
- Unlocked page: toggle works as today. User preference persists across unlocked pages via
localStorage; locked pages temporarily display their locked mode without disturbing the saved preference.
When a user is on a saved-light preference and navigates from an unlocked docs page to a locked-dark marketing page, the marketing page renders dark, the toggle disappears, the saved preference is untouched. When they navigate back to docs, the toggle returns and the page renders light again.
Implementation
- Resolution function. Given a page path, walk up the layout chain accumulating frontmatter values (last-write-wins per field), fall back to
theme.colorSchemeat the root, return the resolved(tint, tintMode, locked)tuple. Lives inpackages/content/. - Frontmatter schema. Add
tint,tint-mode,tint-lockto the validated frontmatter shape for both layout and page frontmatter. Reject invalidtint-modevalues at parse time. - Renderer integration. Surface the resolved tuple to whichever component renders
<html>(currently in@refrakt-md/svelteThemeShell). Emitdata-*attributes and<meta name="color-scheme">accordingly. - Inline pre-paint script. Add the standard "read user preference from localStorage, apply data-theme before first paint" snippet. Skip if
data-tint-lock="true". - Toggle component contract. Lumina's theme toggle (or whatever the host theme uses) inspects
data-tint-lockand hides itself when locked. Document the contract for theme authors. - Adopt the cascade on the refrakt site. Add
tint-mode: dark, tint-lock: truetosite/content/_layout.md. Addtint-mode: auto, tint-lock: falsetosite/content/docs/_layout.md,site/content/runes/_layout.md, andsite/content/plan/docs/_layout.md. Verify rendering across one page in each subtree. - Documentation page.
/docs/themes/tint-cascadewith worked examples, the precedence table, and the locked-vs-preferred distinction explained. Use the design plugin'spalette/swatchrunes where relevant to show tints visually. Use the refrakt site's own configuration as the canonical example.
Acceptance Criteria
- SPEC-048 is implemented and merged
tint,tint-mode,tint-lockaccepted as fields in both_layout.mdand page frontmatter, validated against a schema, rejected with a clear error on invalid values- Page transform produces a resolved
(tint, tintMode, locked)tuple per page, deterministic from layout chain + page frontmatter + site config - SSR emits
data-theme,data-tint,data-tint-lockon<html>matching the resolved values, and<meta name="color-scheme">matching the locked mode (orlight darkwhen unlocked) - Inline pre-paint script applies saved user preference on unlocked pages, no-ops on locked pages
- Theme toggle hides on locked pages (Lumina's implementation; theme authors document the same contract)
- A test site demonstrates: marketing subtree locked dark, docs subtree auto, individual blog post locked dark, named tint applied to a single page, rune-level override beating page-level
- The refrakt site itself adopts the cascade per the Refrakt Site Adoption section: root layout locked dark;
docs/,runes/, andplan/docs/layouts switched toauto+tint-lock: false - Visual regression / SSR snapshot confirms no flash of incorrect theme on locked marketing pages
/docs/themes/tint-cascadeexists and documents the cascade with worked examples, using the refrakt site's own configuration as the canonical example
Out of Scope
- Toggle UI design. The toggle's visual treatment is a theme concern (Lumina's job), not this spec's.
- Defining new tint presets. The mechanism for declaring named tints (
brand-warm, etc.) lives in SPEC-048's tint config; this spec only consumes them. - Cascading non-tint frontmatter. The cascade pattern could in principle generalise to other fields (presets, fonts, layout-specific options), but this spec only covers
tint/tint-mode/tint-lock. Generalisation is a future decision worth making deliberately when there's a second use case. - Animated transitions between tint changes when the user navigates from unlocked to locked. Defer; raw cuts are fine for v1.
- A user-facing theme picker showing multiple tints to choose from. Separate UX surface; not this spec.
- Per-tint dark-mode pairing rules (e.g., "the
brand-warmtint has both a light and a dark variant; which renders depends ontint-mode"). SPEC-048's tint config handles this; this spec just surfaces whichever variant the cascade resolves to.
Open Questions
tint-lockfield name. Alternatives considered:tint-locked,tint-strict,tint-fixed,lock-tint.tint-lockreads as "the lock for the tint," which is fine;tint-lockedreads as "the tint is locked" which is also fine. Bikeshed-able. Recommendtint-lockfor parallel withtintandtint-mode(all three start with the same prefix), but worth a quick decision before implementation.- Should
nulland missing be distinguishable? Settingtint: nullto break out of an inherited named tint is a real authoring need; missingtintmeans inherit. YAML parsers handle this differently — needs explicit handling in the frontmatter schema so authors get predictable behaviour. Probably surface atint: ~syntax in docs as the canonical "reset to inherit-up" idiom. - Should runes be able to read the resolved cascade? A rune that needs to know its current tint context (e.g., an
<img>swappingsrcbetween light and dark variants) might need access. Could expose via the rune transform'sconfigargument, or as a CSS-only contract via attribute selectors on<html>. Lean CSS-only for v1 — keeps the rune API smaller — but worth flagging. - What about the
index.mdat site root? It's neither under/marketing/nor/docs/— does it inherit only from the global config, or should there be a top-level_layout.mdto set marketing-style defaults? Probably the latter; worth confirming the docs example shows this pattern. - Persistence model for unlocked-page user preference. Today: localStorage. With locked pages in the mix, does that change? Lean no — locked pages don't write to localStorage; they only read it on unlock. But worth a behavioural test in the acceptance suite to make sure it doesn't drift.