|

Palette

Picking colors by hand produces sets that look chosen. Picking them by rule produces sets that look related.

Palette is a list of colors with helpers for pulling entries out of it in a controlled way. It generates colors in OKLCH, so a fixed lightness reads as the same lightness across every hue.

public/lib/Palette.js, public/lib/color.js — copied from the stroke_designer project. public/lib/SchemePaletteMaker.js builds on them.

Gamut

OKLCH describes colors sRGB cannot display. maxChromaAt(L, H) binary-searches the largest chroma that stays inside the sRGB gamut at a given lightness and hue, and generation never asks for more than that. mostVibrantL(H) scans for the lightness at which a hue reaches its highest chroma. Both live in color.js.

Generating

Palette.fromHues(hues, options) returns one entry per hue × luminosity step.

  • nLum — luminosity steps per hue. Default 4.
  • lumHigh / lumLow — the lightness range the steps span. Defaults 0.85 / 0.30.
  • vibHigh / vibLow — the ends of the chroma multiplier range. Defaults 0.95 / 0.30. See Chroma below, since neither is applied directly.

Steps are not spaced evenly between lumLow and lumHigh. The generator finds the lightness at which that hue reaches its maximum chroma (mostVibrantL(H)), snaps the nearest step to it, then redistributes the remaining steps on either side. Every hue therefore contributes one entry at the lightness where it can be most chromatic. That entry is not at the hue’s maximum chroma, because the multiplier below applies to it like any other.

The first and last entries normally land exactly on lumHigh and lumLow. They do not when the snapped step is itself the first or last one, in which case that end takes the vibrant lightness and the requested bound is unused. At nLum: 5, lumHigh: 0.88, lumLow: 0.28 this happens at hues 80, 90, 140, 150, 160, and 200.

Chroma

Each entry starts from maxChromaAt(L, H) and takes a fraction of it. The fraction varies per entry, rising with how chromatic that lightness and hue could be.

t = min(1, maxC / 0.4)
C = maxC × (vibLow + (vibHigh − vibLow) × t)

0.4 is the reference for the most chromatic color the model expects to meet. vibHigh is therefore a ceiling rather than a setting: it applies only where maxC reaches 0.4, which sRGB does not do. At the defaults the largest fraction any entry takes is about 0.78, and no entry anywhere in the palette sits at its own maxC.

The multiplier rises with maxC, so the entry with the most headroom stays the most chromatic of its row. Setting vibLow above vibHigh inverts that relationship and the ordering no longer holds.

Two other constructors take colors that already exist: fromHexArray(hexes) for plain strings, and fromEntries(entries) for objects that already carry L, C, and H.

SchemePaletteMaker

SchemePaletteMaker generates a Palette from three inputs: a key hue, a color count, and a scheme. The scheme decides everything else: which hues are used, and how light and how saturated each color is. new SchemePaletteMaker({ hue, count, scheme, seed }).generate() returns a regular Palette, with the key color first.

  • mono — every color at the key hue. The first at the hue's most vibrant point, the rest dividing L as evenly as that fixed first color allows, each at the largest chroma available.
  • vivid-dark — the whole wheel divided evenly from the key hue; the half nearest the key hue vivid at each hue's representative lightness, the far half dark.
  • pastel-cluster — hues clustered around the key hue, all light and low in chroma.
  • dark-cluster — the same clustering, tighter, every color dark.
  • vivid-wheel — the whole wheel divided evenly, every color at its own hue's most vibrant point.
  • black — every color black.

Every scheme except black jitters the hue and lightness of the colors other than the key color. The jitter derives from seed, so the same settings reproduce the same palette. PALETTE_SCHEMES lists the schemes with display labels.

representativeL(H) is the lightness of a hue’s most prototypical color, the one people recognize by the simplest color term (red, green, blue, pink). That color sits at a particular lightness, not at the hue’s max-chroma point: yellow is only yellow when bright, while green and blue are their names well below their chroma peaks. Anchored per color term (red 0.58, orange 0.72, yellow 0.90, yellow-green 0.82, green 0.50, cyan 0.70, blue 0.45, purple 0.45, magenta 0.58, pink 0.78) and interpolated around the wheel. The prototypes are cultural: which terms are basic, and where their colors sit, varies between cultures, and the anchors are one such choice.

Three helpers support the demos: paperColor(hue) returns a near-white paper tint of a hue for a background, paperGradient(palette) returns a two-color paper-tint gradient spec for a board clear, and randomSchemePalette(scheme, count) returns a schemed palette at a random key hue and seed.

Selecting

  • spread(n)n entries evenly distributed across the palette. Every hue and step stays represented no matter how many entries the settings produced.
  • sample(t) — the entry at fractional position t.
  • pickPair(i) — an entry and its counterpart roughly half-way round the palette, for a two-color gradient. Deterministic when i is given.
  • pick() — a random entry.
  • toHexArray(), entries, length — the raw list.

pick() and a bare pickPair() call Math.random(). Pass an index to pickPair(i) for a deterministic result.