refract v0.1 · beta
Subsystems / Colors

Colors

Author each palette's base; refract synthesizes the full tonal scale — named steps or a numeric ramp — and every recipe references a step by path, never a frozen hex.

raw slice the input
live render theme.resolveToken()
refracted: ⟳ steps are synthesized from the base in OKLCH (a relative lighten / darken, or an absolute-lightness rung), not authored. Every value here came from theme.resolveToken() at runtime.

Overview

The colors subsystem owns the palette. Unlike the other property subsystems, it synthesizes tonal steps from each base colour — so you author one base and get a whole ramp (light, lighter, dark, darker, or numeric 100…900). Each colour is either a bare hex string or a full object; the keys are your own palette names (brand, accent, neutral, …).

Authoring a colour

FieldTypeRequiredDefaultDescription
baseColorInputRequiredThe colour — a hex string ("#4dabf7" or 3-digit "#4af"), an [r, g, b] tuple (0–255), or any CSS colour: oklch(), hsl()/hsla(), rgb()/rgba(), or a named keyword (rebeccapurple). Everything is normalized to a canonical rgb()/rgba(). Only var(--…), currentColor and transparent are rejected — they can't be tonally derived at build time. A bare value — success: "#40c057" — is shorthand for { base }.
textColorInputOptionalThe on-colour foreground (any ColorInput, same as base) → --dt-colors-<name>-text.
variantsRecord<string, VariantInput>OptionalNamed variants — each a literal colour, a derivation spec ({ lighten | darken | alpha }, see below), or an extended { base, … }. Author-declared variants also seed the auto tonal steps.
stepsnumber[]Optionalauto: light…darkerNumeric tonal steps (e.g. [100, …, 900]) — an absolute-lightness ladder: each label maps to a fixed OKLCH lightness via L = (1000 − label) / 10 (50→L95, 500→L50, 950→L5), so the same label reads at the same lightness across every palette. When omitted, the default named set (light / lighter / dark / darker) is auto-generated instead.
lightenBynumberOptional10OKLCH lightness Δ (points, 0–100) per step along the lighter chain. Named set only — numeric steps use absolute lightness, not Δ.
darkenBynumberOptional10OKLCH lightness Δ (points, 0–100) per step along the darker chain. Named set only.
harmonyscheme | { scheme: [names] }OptionalAuto-generate related colours by hue rotation (complement / analogous / split-complement / triadic / tetradic) — see Harmony below.
responsiveResponsiveEntry[]Optional@media overrides (see the Responsive concept page).

Derivation — synthesized steps & variants

From one base, refract builds tonal steps in OKLCH — a perceptual space, so equal lightness moves look even and one lightness reads the same across hues. There are three ways to get variants:

  • Auto tonal steps (relative) — with no steps, the default set light / lighter / dark / darker is generated, each step compounding from the previous by an OKLCH lightness Δ. Authored variants.light / dark seed the chains, and lightenBy / darkenBy (default 10 points) set the Δ.
  • Numeric steps (absolute)steps: [100…900] is an absolute-lightness ladder: each label is placed at L = (1000 − label) / 10 holding the base's hue and chroma. No pivot, no base alias — the exact authored colour stays at the unnumbered colors.<name> token, and the same label lands at the same lightness in every palette.
  • Derivation-spec variants — author a named variant as a colour-fn derivation (below).

Every synthesized variant is stored as a derived ref{ ref, fn, arg } (a named step as lighten/darken with a Δ; a numeric rung as setL with its absolute lightness). Because it points at its source, theme.override() of a base re-derives the whole ramp for free. The lightness maths runs in OKLCH but round-trips through rgb at the boundary — which is why a colour must be tonally derivable: any hex, [r,g,b], oklch(), hsl(), rgb() or named keyword is parsed and works; only a var(--…) (unknown at build) can't. Each value is stored in one canonical form: rgb(r,g,b) when opaque, or rgba(r,g,b,a) once it carries alpha. Gamut: when a target lightness can't hold the base's full chroma in sRGB, refract keeps lightness and hue and eases chroma down — so a rung stays on-hue and on-lightness, only a touch less saturated.

Two lightness scales — mind the direction. A numeric steps label runs opposite to the lightness it maps to: L = (1000 − label) / 10, so a low label is light and a high label is dark — label 400L60, label 700 → L30. An adjust variant's l dial (below) is the raw OKLCH lightness instead (0–100, low = dark). So to place an adjust variant at the same lightness as the 400 rung you write adjust: { l: 60 } — not 400.

The step ladder, visualized

The active preset's brand as a numeric ladder — each rung is a fixed OKLCH lightness (50 → L95 … 950 → L5). Flip the preset to re-synthesize it live.

Contrast & accessibility

Synthesis is perceptual (OKLCH), which makes contrast predictable — but predictable is not the same as guaranteed: refract emits your palette, it doesn't audit it. OKLCH lightness (L, 0–100) tracks perceived lightness closely, so a wide enough L gap between two colours is a reliable proxy for legible contrast. Ways to hit a target:

  • Pick rungs with a large L gap. On the numeric ladder each label is an absolute lightness (500→L50, 900→L10), so pairing a light rung against a dark one with a generous L gap gives dependable contrast — e.g. text on scale.100 reads well against scale.800.
  • Author the text pairing. Set the on-colour explicitly to a value you've checked against its base, rather than trusting a derived one — that pairing is the one that decides legibility.
  • Author the steps. Declare the specific step values (or variants) you've verified, instead of relying on the default Δ.
Opaque pairings only. The L-gap heuristic assumes both colours are opaque. An alpha variant composites against whatever is behind it, so its effective contrast depends on the backdrop — check those pairings against the real background. Verify with a contrast checker (or in CI). A future declarative ladder curve could desaturate / hue-shift the extremes for even more control — it would re-resolve and replay like any derivation, never a baked-colour hook.

Derivation-spec variants

Author a named variant as a derivation from another token by a colour fn. Give a modifiers chain of one or more single-key dials — { lighten } / { darken } / { alpha } / { adjust }, applied left-to-right — plus an optional ref (defaults to the own base; may name another variant/step or a cross-property token path):

KeyValueEffect
lightennumber (0–100)Raise the source's OKLCH lightness by N points (hue, chroma & alpha preserved).
darkennumber (0–100)Lower the source's OKLCH lightness by N points (hue, chroma & alpha preserved).
alphanumber (0–100)Set absolute opacity — alpha: 40 ⇒ 40% opaque; colour untouched. Serializes to rgba(…). Opacity is applied last and never enters the lightness maths.
adjust{ l?, c?, h? }One-shot OKLCH placement (any dial optional): l = absolute lightness 0–100 (see the two-lightness note); c = chroma multiplier (1 keep, 0 grey); h = signed hue rotation in degrees. Numbers only.
refstringOptional source token; defaults to the colour's own base. May point at another variant / step.

Reach for adjust when you want one-off exact placement: where lighten/darken nudge lightness relative to the source, adjust sets an absolute lightness and/or scales saturation and/or turns hue in a single derivation. It's still an ordinary derived variant ({ ref, modifiers: [{ adjust }] }), so it re-resolves on override() like any other variant.

Harmony — auto-generated related colours

Set harmony on a palette to synthesize related colours by rotating the base's hue around the perceptual wheel. Each generated variant holds the base's lightness and chroma — only the hue turns — so the set stays balanced. The string form uses each scheme's default names; the object form renames the members positionally.

harmony.ts
primary: { base: "#4dabf7", harmony: "triadic" }
  // → colors.primary.triadic1, colors.primary.triadic2
primary: { base: "#4dabf7", harmony: { triadic: ["mint", "coral"] } }
  // → colors.primary.mint, colors.primary.coral
SchemeMembers (hue rotation off the base)
complementcomplement (180°)
analogousanalogous1 (−30°), analogous2 (30°)
split-complementsplit1 (150°), split2 (210°)
triadictriadic1 (120°), triadic2 (240°)
tetradictetradic1 (90°), complement (180°), tetradic2 (270°)
Symmetric pairs default to bare-numbered names — a warm/cool label would lie, since the temperature↔direction mapping flips with the base hue; rename them via the object form since only you know each colour's role. Two caveats: a perceptual complement of blue is a warm amber at the same lightness (cleaner than HSL, but it can differ from a paint-wheel expectation); and where a rotated hue can't hold the base's chroma in sRGB, the harmony colour carries slightly less chroma (lightness and hue are always held).

Harmony, on the wheel

The active brand (ringed) plus a scheme's members, plotted by hue on the perceptual wheel — each dot is the real synthesized colour. Pick a scheme:

Emitted variables

OutputCSS variableToken path
base--dt-colors-<name>colors.<name>
base alias--dt-colors-<name>-main
text--dt-colors-<name>-textcolors.<name>.text
named step--dt-colors-<name>-<step>colors.<name>.<step>
numeric step--dt-colors-<name>-<0…1000>colors.<name>.<n>
colors.ts
colors: {
  // full object — literal variants seed the auto tonal steps
  brand:   { base: "#4c6ef5", text: "#fff", lightenBy: 10, darkenBy: 12,
             variants: { light: "#91a7ff", dark: "#3b5bdb" } },
  // [r,g,b] tuple base + derivation-spec variants
  accent:  { base: [230, 73, 128], text: "#fff",
             variants: { hover: { modifiers: [{ darken: 12 }] }, ghost: { modifiers: [{ alpha: 12 }] } } },
  // absolute-L ladder — 500 → L50, same label = same lightness across palettes
  scale:   { base: "#7c8db5", steps: [100, 200, , 900] },
  // named set with a custom Δ (OKLCH lightness points)
  neutral: { base: "#868e96", lightenBy: 8, darkenBy: 10 },
  // bare value → base only
  success: "#40c057",
}

Recipes — solid / outline

A colour recipe composes palette refs into one class. Each value either names a palette reference"brand" (base), "brand.text", "brand.dark" (a step) — or is a literal passed straight through (e.g. cursor: "pointer", backgroundColor: "transparent").

Recipe propCSS declarationValue
backgroundbackgrounda colour ref or literal
backgroundColorbackground-colora colour ref or literal
colorcolora colour ref or literal
borderColorborder-colora colour ref or literal
outlineColoroutline-colora colour ref or literal
any otherpassed througha literal declaration (arbitrary CSS property)

Recipes also take states (:hover, [disabled], …), a responsive list, and variant: / target: swaps — see the Concepts pages.

colors.recipes.ts
colors.recipes: {
  solid: {
    brand: {
      background: "brand", color: "brand.text",
      states: [
        { state: "hover",    background: "brand.dark" },
        { state: "disabled", background: "brand.lighter", color: "brand.dark" },
      ],
    },
  },
  outline: {
    brand: { backgroundColor: "transparent", color: "brand",
             borderColor: "brand", cursor: "pointer" },
  },
}
dist/theme.css · emitted (colorFormat: "hex")
:root {
  --dt-colors-brand: #4c6ef5;
  --dt-colors-brand-text: #ffffff;
  --dt-colors-brand-light: #91a7ff;   /* authored seed */
  --dt-colors-brand-lighter: #bccbff; /* synth: lighten(light, 10) */
  --dt-colors-brand-dark: #3b5bdb;
  --dt-colors-brand-darker: #2033b2;  /* synth: darken(dark, 12) */
  --dt-colors-accent: #e64980;         /* [230,73,128] tuple → hex */
  --dt-colors-accent-ghost: rgba(230, 73, 128, 0.12);  /* alpha: 12 → rgba() */
}
.dt-colors-solid-brand { background: var(--dt-colors-brand); color: var(--dt-colors-brand-text); }
.dt-colors-solid-brand:hover { background: var(--dt-colors-brand-dark); }

Contrast audit

Colours are the flagship, so refract ships an opt-in contrast audit. It scores every palette basetext pairing and every recipe foreground↔background pairing (across all subsystems, including state overrides like :hover) against WCAG 2 contrast ratio, alongside an advisory APCA Lc reading. It reports — it never rewrites a colour. A side that isn't a derivable colour (transparent, a var(), a keyword) is skipped, not failed.

audit
# CLI — reports by default (exit 0); --strict fails the run (exit 1)
$ refract audit
   colors.ink — 21:1 AAA · APCA Lc -107.9
   colors.brand — 4.32:1 AA-large · APCA Lc -75
1/2 pass, 1 fail, 0 skipped

// …or programmatically — the same scores as structured data
import { audit } from "@theme-registry/refract";
const { pairings, summary, ok } = audit(theme, { minWcag: "AA" });

WCAG levels: AAA ≥ 7:1, AA ≥ 4.5:1, and AA-large ≥ 3:1 (passes only for large text). Options: --strict (throw / non-zero exit on any failure), --min-wcag <AA|AAA|AA-large> (the pass bar), and --large (relax to large-text thresholds). APCA is advisory — a perceptual model still in draft — so it is reported, never gated.

Technical documentation for @theme-registry/refract · all output is compiled client-side by the real library (CSS adapter). The chrome is theme-aware; the render panes carry each preset's own world. · MIT licensed.