Variable layers

Krysalicss separates design values into three strict layers. Knowing which layer a value belongs to is the difference between a token that themes can re-tune and a hardcoded literal that locks every consumer into one look.

The three layers

LayerWhat lives hereWhere it livesWho sets it
DeclarativeRaw values: hex codes, brand-specific pixel constants, named font stackssrc/theme/<name>.scss (and the consumer's own theme files); src/variables/_color.scss for the shipped reference paletteThe theme author
SemanticNamed, role-bearing tokens: $global-border-radius, $global-min-touch-target, $size-normal, $color-combination-defaultsrc/variables/_global.scss, _size.scss, _color.scss, _selector.scssThe framework
Runtime--kc-* custom properties: --kc-bg, --kc-fg, --kc-primary-bgEmitted by src/theme/_create.scss under :root / @media / class selectorsThe framework, populated by the active theme

Each layer depends on the one above it. Declarative feeds semantic; semantic feeds runtime; runtime drives every component. The dependency is one-way; a lower layer never reaches up.

Declarative variables (theme-local)

A declarative variable is a raw value with no context. #247d59, the hex of Krysalicss's brand emerald, doesn't mean anything on its own. It gets meaning when a theme assigns it to --kc-primary-bg. That assignment is the entire job of the theme layer.

// src/variables/_color.scss — the shipped palette (theme material).
$palette: (
  sage: #1a6347,
  moss: #525a22,
  emerald: #247d59,
  amber: #7a4f10,
  coral: #8c351a,
  white: #ffffff,
  black: #27282b,
) !default;
// src/theme/default.scss — wires declarative values to the runtime contract.
$variables: (
  bg: map.get($_palette, white),
  fg: map.get($_palette, black),
  link: map.get($_palette, sage),
  focus-ring: map.get($_palette, sage),
  // …
);

Where declarative values must NOT live: anywhere under src/<category>/<name>/_variables.scss. Hardcoding #247d59 inside a button's variables file locks every theme into the brand green. Even a borderless gray like #ccc is a declarative value and belongs in a theme; if a component needs "border in the page foreground tinted at 35%", that's a semantic concern (see below).

Semantic variables (framework-wide)

A semantic variable describes what a value is for, not which value:

  • $global-border-radius — the default rounding of any rectangular surface.
  • $global-min-touch-target — the AAA-compliant minimum tap area (2.75rem).
  • $global-gap — the cross-cutting spacing primitive between siblings.
  • $global-hover-brightness — the multiplier applied to interactive surfaces on hover.
  • $size-normal, $size-small, $size-large — the size tier scale.
  • $color-combination-default, $color-combination-primary — the (background, foreground) tuples per named role.
  • $selector-prefix-is, $selector-prefix-has — the modifier prefixes every component interpolates.

Plugins consume these tokens by name. Re-tuning a single semantic value cascades to every component that derives from it. If a consumer wants fatter buttons everywhere, they bump $global-min-touch-target; if they want square corners, they bump $global-border-radius to 0. They do not edit each component's _variables.scss.

This layer is the cornerstone of the framework's tunability. Themes can override semantic tokens too (a "compact" theme can ship a smaller $global-min-touch-target), but the typical pattern is theme = declarative, framework = semantic.

Runtime tokens (--kc-* custom properties)

The third layer crosses from compile-time to runtime. Themes emit a block of --kc-* custom properties under :root (or a media query, or a class selector). Components reference those properties via three mixins from src/variables/_default.scss:

  • textColor($key, $fallback) — paints color: var(--kc-<key>, <fallback>).
  • backgroundColor($key, $fallback) — paints background-color: var(--kc-<key>, <fallback>).
  • defaultProperty($prop, $key, $fallback) — the general case.

The fallback is the SCSS-time value; the var() call is the runtime value. When the active theme changes (class swap, media query flip), every --kc-* reference updates simultaneously. The plugin doesn't re-run; the browser just resolves the new value.

// Good — theme-switchable
.is-primary {
  @include variables.backgroundColor(primary-bg, list.nth($combo, 1));
  @include variables.textColor(primary-fg, list.nth($combo, 2));
}

// Bad — bakes the colour at SCSS compile time, no theme can override
.is-primary {
  background: #247d59;
  color: #ffffff;
}

Authoring discipline

Three rules keep the layers apart, and breaking them is the most common authoring mistake:

1. Per-component variables derive from globals

A _variables.scss file under src/<category>/<name>/ should look like a translation table from per-component knobs to global tokens. Literals appear only when no global covers the concept.

// Good — every value derives from the framework
@use "../../variables";

$padding-x: variables.$size-normal !default;
$border-radius: variables.$global-border-radius !default;
$focus-outline-width: variables.$global-focus-outline-width !default;
$default-combination: variables.$color-combination-default !default;
// Bad — magic numbers that drift from the framework
$padding-x: 1rem !default;
$border-radius: 5px !default;
$focus-outline-width: 2px !default;

If three components hardcode the same literal, that literal is a missing global. Hoist it.

2. Calculation over duplication

When two values are mathematically related, write the relationship. Krysalicss's button is the canonical example — its vertical padding is derived from the touch-target floor minus the line-height, so the AAA invariant holds even after consumers re-tune the inputs:

@use "sass:math";
@use "../../variables";

@function _touch-target-padding($target, $line-height-rem) {
  @return math.max(0rem, math.div($target - $line-height-rem, 2));
}

$padding-y: _touch-target-padding(
  variables.$global-min-touch-target,
  1rem * variables.$global-body-line-height
) !default;

Hand-computed magic numbers ($padding-y: 12px; written next to a comment "value that hits 44 px height") are a smell — the comment will rot when someone retunes the inputs.

3. Theme-switchable values go through the runtime mixins

The default theme works fine when you hardcode color: #fff. The dark theme doesn't. Routing every theme-switchable value through textColor / backgroundColor / defaultProperty is the only way the component stays correct under every shipped and consumer-authored theme.

Why this matters

The reward for keeping the layers separate is one tunable framework that serves every brand. A consumer can:

  • Re-tune the entire visual identity by editing one theme file (declarative layer).
  • Re-shape the framework's geometry (padding scale, radii, touch-target floor) by editing one semantic file.
  • Ship a runtime theme toggle (light / dark / brand variants) with no recompile.

Conflate the layers — hardcode a hex in a component's variables, bake a literal into a theme, hardcode a CSS class name — and one of those abilities breaks for every downstream user.

Further reading