Skip to content

Latest commit

 

History

History
330 lines (232 loc) · 19.3 KB

File metadata and controls

330 lines (232 loc) · 19.3 KB

Methodology

A practical guide for designing a real, production-grade Glaze palette. Recipe-ordered: the sections follow the same sequence you'd actually build a palette in, and each one ties the choice back to a Glaze behavior.

The mental model

A Glaze palette is a default neutral theme plus a small fan of colored sibling themes (success, danger, warning, note, …) created via extend(). Most colors live in the default theme as neutrals; brand-tinted colors come from extend() swapping the hue.

The default theme is what most components consume — its tokens are emitted unprefixed (#surface, #border). Colored themes are scoped to status surfaces and accent variants, emitted with a theme-name prefix (#success-surface, #danger-accent-surface).

You design the default theme once, and extend() propagates that design across every status hue.

Hue / saturation seeds

Declare hues as named constants up top, plus a single shared seed saturation:

const PURPLE_HUE  = 280.3;
const SUCCESS_HUE = 156.9;
const DANGER_HUE  = 23.1;
const WARNING_HUE = 84.3;
const NOTE_HUE    = 302.3;

const SEED_SATURATION = 80;

Hues are design tokens too — keeping them named in one place beats burying numbers in extend() calls. The shared SEED_SATURATION keeps every status theme on the same saturation budget; per-color saturation factors below are 0–1 of this seed, not absolute.

Global glaze.configure()

Configure state aliases and output modes once at module load:

glaze.configure({
  states: { dark: '@dark', highContrast: '@hc' },
  modes:  { dark: true,    highContrast: true },
});

Match the state alias names to whatever your app wires into the global predefined states (@dark / @hc is what Tasty expects). Setting modes.highContrast: true makes every export emit four variants — HC tokens are then available globally without per-call overrides.

Naming conventions

A tight, predictable vocabulary that the rest of the doc relies on:

Pattern Tokens
Surface ladder surface, surface-2, surface-3
Text on surface (decreasing prominence) <surface>-text, <surface>-text-soft, <surface>-text-soft-2
Misc neutral primitives border, placeholder, focus, disabled
Neutral disabled chip disabled-surface, disabled-surface-text
Fixed-mode dark surface surface-inverse
Brand fills accent-surface, accent-surface-2, accent-surface-3, accent-surface-hover
Brand fill anchor accent-surface-text (the fixed white token everything anchors to)
Brand foregrounds on neutrals accent-text, accent-text-soft, accent-icon
Brand-tinted disabled accent-disabled-surface, accent-disabled-surface-text
Code syntax highlighting code-comment, code-keyword, code-string, code-number, …
Loading animation loading-face-1, loading-face-2, loading-face-3
Shadows shadow-sm, shadow-md, shadow-lg
Backdrop overlay

Rule of thumb: purpose-name first, variant suffix last (-2, -text, -soft, -hover, -disabled).

Surfaces (root colors)

surface is a root color (absolute lightness, no base) with a low saturation factor. The ladder chains off it via small relative offsets:

defaultTheme.colors({
  surface:     { lightness: 100, saturation: 0.11 },
  'surface-2': { base: 'surface', lightness: '-2', saturation: 0.15, inherit: false },
  'surface-3': { base: 'surface', lightness: '-4', saturation: 0.19, inherit: false },
});

A factor of 0.11 of the seed gives a barely-noticeable hue shift — enough that light/dark surfaces feel branded, not enough to look tinted. The slight saturation bump on -2 / -3 compensates for perceived saturation dropping as lightness drops, so the ladder reads as one consistent surface family.

mode: 'auto' (the default) feeds these through Glaze's Möbius dark inversion, so an L=100 light-mode surface lands near L≈15 in dark mode with proportional deltas across the ladder preserved. inherit: false on -2 / -3 keeps colored sibling themes lean — they only need a single tinted surface, not the whole ladder.

Text on surfaces (anchor at the edge)

The headline trick of the whole methodology. Strong text uses an absolute lightness near the edge of the window; soft variants use a directional relative hint plus a numeric contrast.

'surface-text': {
  base: 'surface', lightness: 2, saturation: 0.475,
},
'surface-text-soft': {
  base: 'surface', lightness: '-1', saturation: 0.375,
  contrast: [9, 11], inherit: false,
},
'surface-text-soft-2': {
  base: 'surface', lightness: '-1', saturation: 0.24,
  contrast: [4.5, 5.5], inherit: false,
},

Repeat the same triple anchored to each subordinate surface (surface-2-text, surface-2-text-soft, surface-3-text, …) so the ladder stays self-consistent.

The strong-text lightness: 2 pins the light-mode resolved value to L≈11.8 (mapped through the default [10, 100] window) and inverts to L≈94 in dark mode (cr≈13.7 vs the dark surface). A contrast: 'AAA' solver pass would have stopped at L≈21 — meeting the AAA floor and no further. Anchoring at the edge beats the contrast solver because the solver only needs to meet the floor, not exceed it.

The soft variants use lightness: '-1' only as a directional hint — the real positioning comes from the numeric contrast. Numeric ratios give designers precise perceived weight where presets would only guarantee the AA/AAA floor.

In high-contrast mode the lightness window is bypassed entirely, so lightness: 2 resolves to L=2 in light HC and L≈99 in dark HC (cr≈20.8 / 20.5).

Other neutral primitives

Borders, placeholders, focus rings, and the floating "muted text" lightness — all default-only:

border:      { base: 'surface', lightness: ['-10', '-20'], saturation: 0.175,  inherit: false },
placeholder: { base: 'surface', lightness: 67,             saturation: 0.175,  inherit: false },
focus:       { base: 'surface', lightness: 71,             saturation: 0.8625, inherit: false },
disabled:    { lightness: 80.8, saturation: 0.4,                                inherit: false },

border uses an HC pair — the border darkens twice as much in high-contrast mode for visibility. placeholder and focus give a base for namespacing but use absolute lightness independently. disabled is a root color (no base) — it's used as a plain "muted text" token in some places, free of the surface chain.

Disabled chip (contrast-driven for scheme symmetry)

The disabled chip + label pair uses mode: 'auto' and explicit numeric contrast against surface, not preset 'AA' / 'AAA':

'disabled-surface': {
  base: 'surface', lightness: '-1', saturation: 0.2,
  contrast: [1.1, 1.2], inherit: false,
},
'disabled-surface-text': {
  base: 'surface', lightness: '-1', saturation: 0.3,
  contrast: [2, 2.5], inherit: false,
},

These tokens anchor a perceived ratio against surface, so the disabled state resolves to the same numbers in light, dark, and HC (chip ≈ 1.4 vs surface, label ≈ 2.0, text-on-chip ≈ 1.4). An alpha-tinted overlay would have asymmetric behavior — composited alpha against a near-white light surface produces a much weaker chip than the same overlay against a near-dark dark surface, and the disabled state would stop looking disabled in one of the schemes.

The general rule: when a color needs to feel the same across schemes, anchor it with mode: 'auto' + a numeric contrast against a surface, not with a preset.

surface-inverse (the fixed-mode escape hatch)

'surface-inverse': {
  lightness: 12, saturation: 0.475, mode: 'fixed', inherit: false,
},

mode: 'fixed' skips the dark-scheme Möbius inversion and only does a linear window mapping, so surface-inverse reads as a dark surface in every scheme — light, dark, and HC. Use it for tooltips, code blocks, popovers with their own dark theme. Pair with #white for foreground text.

This is the canonical "I want this color to stay recognizable" pattern. The other mode: 'fixed' use is the entire accent system below.

Accent system (anchor pattern)

The load-bearing trick. Define a single fixed white anchor accent-surface-text, then derive every accent surface from it with a small relative lightness offset and a numeric contrast under mode: 'fixed':

'accent-surface-text': { lightness: 100, mode: 'fixed' },

'accent-surface':       { base: 'accent-surface-text', lightness: '-1', contrast: [4.5, 7],   mode: 'fixed' },
'accent-surface-2':     { base: 'accent-surface-text', lightness: '-1', contrast: [4.8, 7.5], mode: 'fixed' },
'accent-surface-3':     { base: 'accent-surface-text', lightness: '-1', contrast: [5.2, 8],   mode: 'fixed' },
'accent-surface-hover': { base: 'accent-surface-text', lightness: '-1', contrast: [6,   8.5], mode: 'fixed' },

Three things make this work:

  • One anchor, one chain. All accent surfaces stay in the same hue family because they all derive from accent-surface-text.
  • mode: 'fixed' keeps the brand recognizable. Without it, the dark-scheme Möbius inversion would turn the brand fill into a lightness-inverted counterpart that may no longer read as the intended brand surface. Fixed maps lightness linearly into the dark window, so a L=52 brand color resolves to ~L=51.6 in dark mode — still recognizably the same color.
  • Numeric contrasts, not presets. 'AA' / 'AAA' would let the solver push the color far away from its anchor in dark schemes, breaking the relationship between accent-surface and its neighbors. Numeric ratios make the darkening between accent-surface (4.5/7), -2 (4.8/7.5), -3 (5.2/8), and -hover (6/8.5) a tight, designed sequence — a stepped gradient rather than four solver-generated outliers.

The hover variant is a dedicated fixed token. Reusing accent-text (which is mode: 'auto' and inverts direction in dark) would break the hover feel.

Adaptive accent foregrounds

The opposite of the fills. Brand-colored foregrounds are anchored to surface, not accent-surface, with mode: 'auto' (default) and full saturation:

'accent-text':      { base: 'surface', lightness: '-1', saturation: 1,      contrast: [6.4, 10] },
'accent-text-soft': { base: 'surface', lightness: '-1', saturation: 1,      contrast: [4.5, 7]  },
'accent-icon':      { base: 'surface', lightness: '-1', saturation: 0.9375, contrast: [3.2, 5]  },

Foregrounds need to stay readable on the surface they actually sit on — anchoring to the brand fill would only enforce contrast against that fill, leaving the dark-mode color washed out against the actual surface (e.g. SECONDARY button labels sit on surface, not on the brand fill). Anchoring to surface + mode: 'auto' lets the solver lift the lightness in dark mode so the contrast floor holds in both schemes.

accent-text-soft shares the anchor and saturation but relaxes the contrast floor for a visibly less prominent secondary foreground (link base color, subdued labels). Critically, it stays mode: 'auto' — a fixed version would collapse to cr≈3 against the dark surface and break AA.

Brand-tinted disabled

Mirrors the neutral disabled pair from above but with higher saturation so the chip reads as a muted brand color rather than fully neutral grey:

'accent-disabled-surface': {
  base: 'surface', lightness: '-1', saturation: 0.5,
  contrast: [1.4, 1.3],
},
'accent-disabled-surface-text': {
  base: 'accent-disabled-surface', lightness: '+1', saturation: 0.4,
  contrast: 1.51, mode: 'fixed',
},

These are inherited (no inherit: false), so each colored sibling theme automatically emits <theme>-accent-disabled-surface and <theme>-accent-disabled-surface-text. PRIMARY-style disabled buttons stay tinted with the active theme's hue (danger-tinted danger button, success-tinted success button), preserving brand identity even in the disabled state.

Per-color hue overrides (code highlighting)

The code-* tokens use absolute hue numbers regardless of the seed. Each is base: 'surface' with mode: 'auto', a per-token saturation, and a numeric contrast floor:

'code-comment': { base: 'surface', hue: 280,        saturation: 0.1, lightness: '-1', contrast: [4.5, 7], inherit: false },
'code-keyword': { base: 'surface', hue: 348,        saturation: 1,   lightness: '-1', contrast: [5, 7.5], inherit: false },
'code-string':  { base: 'surface', hue: PURPLE_HUE, saturation: 1,   lightness: '-1', contrast: [4.5, 7], inherit: false },
// …code-punctuation, code-number, code-function, code-attribute follow the same shape

The canonical pattern for "I want a color from a different hue family but the same adaptive behavior". Absolute hue overrides the theme seed for a single color; everything else (contrast against surface, dark adaptation, HC tightening) still works. inherit: false because syntax highlighting is a default-only concern.

Loading-animation faces

A 3-step ramp using absolute lightnesses with high saturation factors and tight numeric contrasts:

'loading-face-1': { base: 'surface', lightness: 98, saturation: 0.3,  contrast: [1.04, 1.5], inherit: false },
'loading-face-2': { base: 'surface', lightness: 91, saturation: 0.62, contrast: [1.24, 2.5], inherit: false },
'loading-face-3': { base: 'surface', lightness: 79, saturation: 0.66, contrast: [1.75, 4],   inherit: false },

Combines absolute lightness positioning (so the ramp is deterministic in light mode) with a numeric contrast floor (so the ramp still reads in dark and HC). The HC contrast jumps significantly (1.04 → 1.5, 1.24 → 2.5, 1.75 → 4) so the animation stays perceivable for low-vision users.

Shadows

Three sizes, all sharing bg: 'surface' and fg: 'surface-text', varying only intensity:

'shadow-sm': { type: 'shadow', bg: 'surface', fg: 'surface-text', intensity: 5,  inherit: false },
'shadow-md': { type: 'shadow', bg: 'surface', fg: 'surface-text', intensity: 10, inherit: false },
'shadow-lg': { type: 'shadow', bg: 'surface', fg: 'surface-text', intensity: 15, inherit: false },

Including fg matters: shadow strength scales with |l_bg − l_fg|, so anchoring fg to surface-text (which is anchored at the edge of the window) makes shadows automatically deeper in dark mode where the bg/fg gap is larger. All shadows are inherit: false — there's only one shadow system for the whole UI, and colored sibling themes don't carry their own.

For HC, pass intensity: [normal, hc] (e.g. [10, 20]) to deepen shadows in high-contrast mode. The full algorithm and tuning knobs are in api.md → Shadows.

Overlay (fixed opacity)

overlay: { lightness: 10, opacity: 0.5, inherit: false },

The shortcut for one solid color with a fixed alpha — no shadow algorithm, no mix. opacity on a regular color attaches an alpha component to every variant. Use it for backdrops, scrims, modal overlays. (Combining opacity with contrast is not recommended — perceived lightness becomes unpredictable when alpha is fixed; Glaze emits a console.warn.)

Mixes for hover / tint

Reach for mix tokens when you want one color to "tint through" another:

hover: {
  type: 'mix', base: 'surface', target: 'accent-surface',
  value: 8, blend: 'transparent',
},
// hover → accent-surface with alpha = 0.08

tint: {
  type: 'mix', base: 'surface', target: 'accent-surface',
  value: 20,
},
  • Transparent mixthe target color with controlled alpha. Useful for hover overlays.
  • Opaque mix — solid blend of two colors. Good for subtle tints.

Choose space: 'okhsl' (default) for design tokens — perceptually uniform, consistent with the rest of Glaze. Choose space: 'srgb' to match what the browser would render with a plain CSS overlay. Mix colors support the same contrast prop as regular colors; the solver adjusts the mix ratio (opaque) or opacity (transparent) to meet the target.

Colored sibling themes via extend()

One shared TINTED_SURFACE_OVERRIDE, applied to every colored theme, with only the hue changing per status:

const TINTED_SURFACE_OVERRIDE: ColorMap = {
  surface: { lightness: 96, saturation: 0.8 },
};

const primaryTheme = defaultTheme.extend({                       colors: TINTED_SURFACE_OVERRIDE });
const successTheme = defaultTheme.extend({ hue: SUCCESS_HUE,     colors: TINTED_SURFACE_OVERRIDE });
const dangerTheme  = defaultTheme.extend({ hue: DANGER_HUE,      colors: TINTED_SURFACE_OVERRIDE });
const warningTheme = defaultTheme.extend({ hue: WARNING_HUE,     colors: TINTED_SURFACE_OVERRIDE });
const noteTheme    = defaultTheme.extend({ hue: NOTE_HUE,        colors: TINTED_SURFACE_OVERRIDE });

Colored themes need a visibly tinted surface for status banners — saturation jumps from the neutral 0.11 (default theme) to 0.8. The inherit: false discipline pays off here: because most neutrals (surface-2, surface-3, border, placeholder, disabled-*, code-*, loading-*, shadow-*) are flagged default-only, each colored theme inherits only the accent + tinted surface chain and emits a small, focused token set.

primaryTheme keeps the default hue but gets the tinted surface — useful for places that want a brand-tinted banner without semantic status meaning.

Palette composition

Compose all themes into a palette so they can be exported as one token set:

const palette = glaze.palette({
  default: defaultTheme,
  primary: primaryTheme,
  success: successTheme,
  danger:  dangerTheme,
  warning: warningTheme,
  note:    noteTheme,
});

The default theme is conventionally exported unprefixed (its tokens land as #surface, #border); colored themes are prefixed with their name. See migration.md for the prefix map shape, alias patterns, and how to wire the resulting tokens into Tasty / CSS / framework-agnostic JSON.

High-contrast strategy

Glaze's high-contrast mode is opt-in per token: anywhere lightness, contrast, intensity, or value accepts an HC pair, you can pass [normal, hc] to tighten the HC variant. The heuristic is to pair anything that's already contrast-driven:

  • Text-against-surface contrasts ([9, 11], [4.5, 5.5], [6.4, 10]).
  • The accent surface ladder ([4.5, 7][5.2, 8][6, 8.5]).
  • The loading ramp's contrasts.
  • Shadow intensity (e.g. intensity: [10, 20]).
  • border lightness (e.g. lightness: ['-10', '-20']).

In HC the lightness window is bypassed entirely — light HC and dark HC operate on the full [0, 100] range. That's why edge-anchored absolute lightnesses like surface-text: { lightness: 2 } blow out to L=2 in light HC and L≈99 in dark HC, exactly what you want for maximum contrast.

Closing checklist

Before shipping a palette, verify:

  • Every text token has an explicit contrast or an edge-anchored absolute lightness.
  • Every accent surface uses mode: 'fixed' + numeric contrast (not preset 'AA' / 'AAA').
  • Every brand foreground (accent-text*, accent-icon) is anchored to surface, not to accent-surface.
  • Every inherit: false is intentional — colored sibling themes only carry the tokens they actually need.
  • HC pairs are present on every contrast-driven token, not just the strong ones.
  • Shadow fg is set when you want shadows to deepen in dark mode.
  • glaze.configure({ states, modes }) matches the global predefined states wired in your app's root.