No description
  • Rust 96.5%
  • Python 1.9%
  • Nix 1.6%
Find a file
AI User d8273b065f Pick the page background out of Wallrust input too
Wallrust supplied every colour in the theme except the largest area of it. The
ground now comes from the same array, and from the opposite end: a signal is
chosen for being distinguishable and a page for being ignorable, so the
families that make good grounds are the muted ones the seed roles cannot use —
including the ones this adapter discarded outright for having too little chroma
to be a seed.

A family is a candidate when its chroma is between PERCEPTIBLE_TINT and
MAX_TINT. Below that its hue is a rumour and the page could not read as
anything; above it the colour has stopped being plausibly a background, and a
vivid image with nothing muted in it has no page in it. Among candidates, in
Wallrust's own order — prominence is a tie-break this file already trusts, since
the brand is "the first usable chromatic primary" — it prefers one no seed role
took, then one taken by anything other than the brand, then the brand's.

**The brand being the last resort rather than one option is the part that
needed measuring.** An image is usually mined dry, so preferring an unused
family decides nothing and the order of the rest decides everything: taking the
first in-band family put the brand's own colour under the page, which improves
on nothing, because a ground at the brand's angle is what hue = "brand" already
gives for free and escaping it is the reason the knob exists.

Only the hue and the chroma are taken, never the lightness, and the chroma is
clamped to TINT_STRONG — an automatic choice held to a tighter ceiling than a
number somebody typed, MAX_TINT being the ceiling on the latter. So a slate at
0.062 becomes a slate page rather than being rejected for being one. The tint is
written into the configuration explicitly rather than left to be re-read from
the hex, because resolving a colour source takes its chroma verbatim and this
one has been clamped: writing only the hue would put a number in the
reproducible configuration that is not the number the run used.

A configured ground is replaced when the image has one, on the same reasoning
the seeds are — Wallrust replaces the colour source as a whole, and a leftover
[neutral] would mean the import silently did not take effect on the largest area
of colour in the theme. When the image has none, the configured ground stands
and the run says which happened, because "the ground did not change" and "the
ground was not considered" look identical from outside.

The manifest records it under the role `ground`, so the directory export's
import count goes from four to five.

Measured on the fixtures: warm browns give #fff9f2 and #160b00, a blue
wallpaper gives #f7faff and #010d20, and a fully saturated one gives no ground
and a note. Every gate holds on all three.

Not done: the preview cannot say "Wallrust chose this". It renders from a
resolved configuration and describes the ground accurately either way — as the
hue of #786752 at a stated chroma — but render() takes no imports, and
threading provenance through it is a larger change than this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:29:09 +02:00
src Pick the page background out of Wallrust input too 2026-08-31 12:29:09 +02:00
tests Pick the page background out of Wallrust input too 2026-08-31 12:29:09 +02:00
.gitignore Initial commit 2026-08-21 19:40:15 +02:00
AGENTS.md Initial commit 2026-08-21 19:40:15 +02:00
build.rs Initial commit 2026-08-21 19:40:15 +02:00
Cargo.lock Initial commit 2026-08-21 19:40:15 +02:00
Cargo.toml Initial commit 2026-08-21 19:40:15 +02:00
CLAUDE.md Initial commit 2026-08-21 19:40:15 +02:00
devenv-rust.nix Initial commit 2026-08-21 19:40:15 +02:00
devenv.lock Initial commit 2026-08-21 19:40:15 +02:00
devenv.nix Make the ground configurable, and give every hue a chroma floor 2026-08-31 10:39:17 +02:00
devenv.yaml Initial commit 2026-08-21 19:40:15 +02:00
flake.lock Initial commit 2026-08-21 19:40:15 +02:00
flake.nix Initial commit 2026-08-21 19:40:15 +02:00
README.md Pick the page background out of Wallrust input too 2026-08-31 12:29:09 +02:00
rust-toolchain.toml Initial commit 2026-08-21 19:40:15 +02:00

palettegen

A contrast-verified OKLCH theme generator. One or more seed colours in; a complete, contrast-verified, colourblind-safe theme system out.

The invariant the whole tool rests on: lightness is solved against contrast obligations; hue is decorative. Contrast targets are inputs to the solver, not assertions checked afterwards — which is what makes re-running with a different seed a reskin rather than an accessibility regression.

This crate is not part of the site. It ships no bytes to a browser and shares no types with the server, which is why it carries its own version rather than the workspace one.

What works today

Both themes as a stylesheet, and a gate that verifies them: seed normalisation, hue planning, the twelve-step ladder, semantic control and signal tokens, a dark theme derived rather than inverted, and palettegen check. Every stage is a pure function with a serialisable output, and any of them can be printed as JSON:

cargo run -p palettegen -- build --seed "#4A7DBE" --dump-stage normalize
cargo run -p palettegen -- build --seed "#4A7DBE" --dump-stage hue-plan
cargo run -p palettegen -- build --seed "#4A7DBE" --dump-stage ladder

Run without --dump-stage, it writes the four-block stylesheet to stdout — 8 666 bytes raw, 1 379 gzipped — and says on stderr what is still missing.

palettegen build --seed "#4A7DBE" --out ./theme   # css, json, config, manifest
palettegen check ./theme/theme.css

# and the reproducibility claim, checkable:
palettegen build --manifest ./theme/manifest.toml > regen.css
cmp ./theme/theme.css regen.css

An output directory contains four files:

  • theme.css is the generated light and dark CSS palette.
  • tokens.json is the same palette as structured design-token data.
  • palette.toml is the resolved, reusable configuration. It can be edited or passed directly to build, preview, show, and map with --config.
  • manifest.toml records the configuration plus derived hue, gamut-mapping, solver, and external-import decisions needed to audit and reproduce the run exactly. The same four commands accept it with --manifest.

For example, a generated palette can be previewed without finding its original input file:

palettegen preview --config ./theme/palette.toml --out ./theme/preview.html
palettegen preview --manifest ./theme/manifest.toml --out ./theme/preview.html

The preview places light and dark themes side by side and demonstrates every emitted colour role. Its ornament bars include primary, secondary, tertiary, and all decorative accents; a separate showcase renders each role as a mixed-bar segment, solid banner, subtle banner, and button. A family that exports its own solved ink uses it. A decorative family — an accent, or a secondary or tertiary declared as ornament — gets a labelled visual mockup using preview-only ink, because it exports no text, hover, or active token. Which family is which is decided by asking whether the ink token exists rather than by name, so a role added later cannot quietly present invented ink as exported.

It also shows the ground: what each theme's page background came from, the tint it asked for against the chroma sRGB actually gave it, and the fact that the canvas lightness is fixed. That section exists because the ground is the one part of a palette whose request and result can differ with nothing being wrong — clay on a light page is 0.045 asked for and 0.0098 delivered — and this page is where a person decides whether a palette is any good.

Importing a Wallrust palette

build --wallrust reads Wallrust's JSON export from a file, or from stdin when the path is -:

palettegen build --wallrust wallrust.json --out ./theme
cat wallrust.json | palettegen build --wallrust - --out ./theme

The first usable chromatic entry in Wallrust's primary array becomes the unchanged brand seed; leading grays are skipped. Palettegen jointly searches all later primary families for a secondary and tertiary that can serve as distinct functional control colours. Each candidate may move up to 15° in hue while lightness and chroma stay fixed. The valid pair requiring the least total movement wins. If no complete pair exists, Palettegen keeps the best valid secondary and omits the tertiary; if no secondary is valid, the secondary remains derived.

The remaining usable families provide up to three decorative accents. Palettegen first requires each accent to remain at least 20° from the brand, secondary, tertiary, and dynamically placed status signals. Among those valid sets it maximizes the smallest accent-to-accent distance, then assigns Wallrust source families to the chosen slots with the least total hue movement. A small internal guard keeps serialized colors from slipping across a status-range boundary. This deliberately trades source-hue fidelity for accents that read as separate roles; the original and resolved colours remain visible in the manifest.

The page background comes from the same array, and from the opposite end of it. A signal is chosen for being distinguishable; a ground is chosen for being ignorable, so the families that make good pages are the muted ones the seed roles cannot use — including those previously discarded outright for having too little chroma to be a seed.

A family is a candidate when its chroma sits between 0.012 and 0.08: below that its hue is a rumour and the page could not read as anything, above it the colour has stopped being plausibly a background. Among candidates, in Wallrust's own order, palettegen prefers one no seed role took, then one taken by anything other than the brand, then the brand's. The brand is the last resort rather than one option among several: an image is usually mined dry, so "prefer an unused family" decides nothing and the order of the rest decides everything, and a ground at the brand's angle is what hue = "brand" already gives for free.

Only the hue and the chroma are taken, never the lightness — and the chroma is clamped to 0.045, where a neutral stops being a neutral, because an automatic choice should be held to a tighter ceiling than a number somebody typed. A wallpaper of warm browns therefore yields a warm cream page and a warm near-black one; a wallpaper with nothing muted in it yields no ground at all, and the run says so rather than inventing one. A ground already in the configuration is replaced when the image has one and left alone when it does not.

The generated palette.toml contains the resolved secondary, tertiary, accents, and ground. The manifest additionally records every imported family's original colour, resolved colour, assigned role, and signed hue movement — the ground included, under the role ground.

Wallrust's accent ramps are not copied directly. They are tonal variations of its extracted colours, while palettegen accents are separate decorative hue families that must not resemble status signals. Wallrust supplies the candidate families and accent count; palettegen resolves their hues and still solves the lightness/chroma ladders and tokens.

check verifies every contrast obligation and that the four status colours stay far enough apart in lightness to survive red–green colour blindness. It exits non-zero on any failure, and it parses the stylesheet rather than reading the generator's memory, which is what makes it independent — and it earned that on its first run, catching a token at 4.494:1 against a required 4.5 that every in-memory test had passed.

Configuration

palette.toml beside you, or --config <path>. Named explicitly, a missing file is an error; left out, the file is used if it exists and the built-in defaults apply if it does not — so --seed alone is a complete invocation.

[seed]
primary        = "#141B23"                     # required, any CSS colour syntax
secondary      = "#32452C"                     # optional functional control family
tertiary       = "oklch(0.929 0.0116 85.08)"   # optional; another control family
secondary_role = "functional"                  # functional | decorative
tertiary_role  = "functional"                  # functional | decorative

[hues]
mode = "derive"              # derive | explicit | distinct
count = 5                    # 3..=6, and see below
accents = ["#786752"]        # optional explicit decorative accent hue sources

[neutral]
hue  = "bone"                # brand | complement | preset | degrees | any colour
tint = "soft"                # none | whisper | soft | medium | strong | a chroma

[neutral.dark]               # optional; the dark theme's own ground
hue  = "ink"

[ladder]
steps = 12
solver = "hybrid"            # fixed | contrast | hybrid
tint = "strong"              # chroma floor under every chromatic hue's ladder

[targets]
standard = "wcag22-aa"       # wcag22-aa | wcag22-aaa
apca_advisory = true

[output]
gamut = "srgb"               # srgb | p3
emit_primitives = false
emit_hex_fallback = true

--seed, --secondary, --tertiary, --background, --tint, --dark-background and --dark-tint override the file. An unknown key is an error rather than a silent default.

hues.count retains its original non-status accent semantics. The four status hues — danger, warning, success, info — are always present, because the reason their angles are snapped to convention in the first place is that people rely on them meaning what they mean. So 3 is neutral, brand and secondary, and 4..=6 add evenly-spaced accents. A supplied tertiary is additive: it adds a solved role without consuming one of those accent slots.

The ground

The page background is not derived from the brand unless you ask for it to be.

By default it is: hue = "brand", tint = "whisper" gives the brand's angle at chroma 0.004, which is what this tool has always emitted. The two knobs exist because that is one ground and not every ground.

neutral.hue takes brand, complement, a preset name, an angle in degrees, or any CSS colour. A colour donates its hue and its chroma and not its lightness — the same discipline hues.accents is held to, and the reason a hand-picked background cannot walk past a contrast obligation.

neutral.tint is how much colour is in it. This is the knob that matters, and the reason is worth stating: §5.3's chroma envelope peaks at L = 0.55 and tapers to nothing at both ends, so at the two lightnesses a ground actually occupies — L 0.985 and L 0.16 — it yields 0.0006 and 0.0019. The envelope has never governed a background. What the page has always really been is the floor underneath it, and the floor was 0.004.

Tint Chroma What it is
none 0 a true gray, midtones included
whisper 0.004 the default; a trace, not a colour
soft 0.013 warm paper — #F5F1E8 is 0.013
medium 0.025 unmistakable — #0E1420 reads blue, not black, at 0.026
strong 0.045 sand, taupe, clay

The presets pair the two, because either alone is useless — bone at whisper is a white page at an angle nobody can see:

Preset Hue Tint
ash the brand's none
bone 86° soft
sand 78° medium
clay 71° strong
slate 257° soft
ink 264° medium

[neutral.light] and [neutral.dark] override either field for one theme, which is how a palette gets warm paper by day and cool ink by night:

palettegen build --seed "#4A7DBE" --background bone --dark-background ink
#  light --bg-canvas  #fefaf0     dark --bg-canvas  #080d18
#  the default gives #f8fafd and #0c0d0f — a black page for a blue brand

Three things the ground deliberately cannot do:

  • It cannot move the canvas lightness. L 0.985 and L 0.16 are what every obligation in the palette is measured against, so the knob does not reach them. That is the structural reason this cannot cost a contrast ratio, and no_ground_can_move_the_canvas_lightness is the gate on it.
  • It cannot exceed chroma 0.08, named or numeric. Refused rather than clamped: past that the ground competes with the signals sitting on it, and a clamp would emit a palette nobody asked for and say nothing.
  • It cannot quietly lose its colour to the gamut. sRGB is narrow at L 0.985 — a warm hue holds about 0.010 of chroma there and no more — so clay on a light page is 0.045 asked for and 0.0098 delivered. The mapper reduces it silently because that is its job; the tool says so, because the difference between clay and ivory is otherwise invisible.

A ground that carries real colour also takes part in hue planning. Below chroma 0.012 the neutral is skipped, exactly as it always was: its angle is a rumour and asking a status hue to avoid it would move a colour away from one nobody can see. At or above it the ground is a colour on the page like any other, and a sand page at 78° with a warning badge at 75° on top of it is the collision the whole stage exists to prevent.

What the seeds have to satisfy

Two kinds of refusal protect inputs that cannot produce a usable palette:

  • A seed with chroma below 0.01 is a gray, not a hue, and every hue relationship derived from it would be arbitrary.
  • Functional seeds within 20° of each other will not read as distinct roles. The distance is the shorter arc, so 5° and 350° are 15° apart and are refused.

Both name which seed was at fault and what the measured value was.

Hue planning

The brand hue is the primary seed's, unchanged. The neutral takes it too by default — the shared angle is what makes the grays feel authored rather than assembled — and the ground is where that stops being compulsory. An absent secondary is derived at primary + 150°, analogous-complementary rather than the exact complement, and marked as derived so a reader can tell which choices were the tool's.

A supplied secondary emits a seven-token functional control family. A supplied tertiary emits the same seven solved jobs under tertiary-* and text-on-tertiary: solid, hover, active, subtle background, text for the subtle background, border, and text on the solid. Both are checked against each other, the primary, and the status signals before token generation.

Either can be declared decorative instead, on the same terms and for the same reason: WCAG 1.4.1 is about colour being the only means of conveying information, and decoration conveys none. A decorative family emits three tokens rather than seven — the four it drops exist solely to discharge an obligation it does not have — and its solid keeps the seed's own ladder position instead of being dragged darker to make room for text. On an orange that difference is 0.097 of lightness, which is the difference between an orange and a burnt brown. The two roles are independent; the gate finds a decorative token used as ink or as a control boundary regardless of which slot it came from.

Functional secondary and tertiary borders are solved and independently verified at 3:1 against --bg-surface, so they can identify an outlined control.

Status hues are snapped, not discovered. Each is anchored to its canonical region and may move only inside a bounded range:

Role Anchor Permitted
danger 25° 15–35°
warning 75° 65–90°
success 145° 135–160°
info 245° 230–260°

When the brand hue falls inside one of those ranges, that status hue moves to the endpoint further from the brand and the palette buys the rest of its separation in lightness instead. It never leaves its range. Nothing else moves.

The ladder

Twelve steps per hue. Steps 1–8 are a fixed table of lightnesses; steps 9–12 are solved against contrast obligations, because equal OKLCH lightness does not produce equal WCAG contrast — at L = 0.55 a cyan at hue 192 reaches 4.10:1 against white while a red at hue 351 reaches 5.30:1, one failing AA and one passing.

The four solved steps carry only three distinct obligations between them, so solving each independently produces two colours with four names. Each is separated from its partner by a fixed lightness, always away from the canvas — which can only raise contrast, so a displaced step still clears its obligation with nothing to re-check. It also fixes the order of the text pair: a 4.5:1 obligation is a ceiling, so secondary text is the lighter and primary the darker.

Chroma follows a triangular envelope peaking at L = 0.55, with a floor under it — neutral.tint for the grays and ladder.tint for every chromatic hue. The two guard different failures at opposite ends. The envelope tapers because a chromatic hue at an extreme lightness leaves the gamut and reads as muddy; the floor holds because a colour has to keep its identity precisely where the envelope has tapered to nothing, and "do not go muddy" and "keep some of your hue" are not the same instruction.

ladder.tint reaches only the steps the envelope has abandoned, so none through medium are measurably inert under a peak of 0.16 — the chromatic envelope does not fall below 0.025 anywhere sRGB has room for it. strong, the default, reaches the *-subtle-bg family and the dark theme's --text-link: a subtle background at L 0.925 goes from 0.0267 to 0.045, #dbe8f8 to #d6e8ff on a blue brand. Those are the tokens that are supposed to be a surface wearing a hue, and at the envelope's own value they were very nearly gray. tint = "soft" restores the previous output token for token.

Both the envelope and the floor are evaluated inside every solve rather than picked once — chroma depends on lightness, lightness on contrast, and contrast on chroma — which is why a floor cannot cost a ratio. Anything outside sRGB is brought back by reducing chroma with lightness and hue held, never by clipping channels, and the reduced value is checked against the precision it will be printed at: a chroma that rounds back out of the gamut is stepped down again.

The token layer

Roughly thirty-five of the forty names are a ladder step renamed. The rest are solved, because their ground is another token rather than the page canvas, and every one of them fails if looked up:

Token Obligation Straight from the ladder
--border-default 3:1 vs --bg-surface 2.290
--{status}-text 4.5:1 vs --{status}-subtle-bg 3.754–3.815
--text-on-accent 4.5:1 vs --accent-solid 3.004 — unreachable

The last is a contradiction, not a shortfall: a fill at exactly 3:1 from a near-white canvas is by that very fact about 3:1 from near-white text, so no text colour clears 4.5:1 on it. --accent-solid is therefore solved against the text that will sit on it, and its own 3:1 falls out for free — the stricter obligation drives. The visible cost is a darker brand fill than most designers expect.

The dark theme

The same solver against a different canvas: L 0.16 rather than 0.985, chroma damped to 78%, text capped at L 0.92. Neither extreme is pure — a large black surface halates against near-white text.

Surfaces keep their ratios rather than their values, which puts the dark surface at L 0.203 against a canvas at 0.160. --text-on-accent genuinely flips: near-white in the light theme, dark in the dark one, because light text never clears 4.5:1 on a dark-mode accent fill.

The subtle part is that which constraint binds changes with the theme. In the light theme the obligation does, so the solver's answer is the lighter of the text pair. In the dark theme the ceiling does — everything from L 0.92 down to 0.60 clears AA — so the capped value is primary and secondary is pulled back toward the canvas. Solving instead of capping returns a dim grey on black: compliant and wrong.

The emitted @media (prefers-color-scheme: dark) block is guarded with :root:not([data-theme="light"]), so a reader's stated preference beats their operating system's.

Colours for a chart

palettegen chart -n 6          # the Okabe-Ito palette, attributed
palettegen chart -n 11         # generated, with weak pairs named
palettegen chart --scale sequential   # refused, with a better answer

A different problem from a theme palette, and mostly a table: for eight series or fewer this returns Okabe–Ito, which generating does not improve on. Beyond that it generates by spreading hues evenly and snapping them to a monotonic lightness ramp — the step that makes a series survive grayscale and the deficiency that flattens its hues. Clean to nine series; beyond that it emits the palette and names the weak pairs.

Checking the colour maths independently

devenv shell -- python3 tests/check_gamut.py

Runs the same values through coloraide — a different implementation, written by someone else from the specifications — and compares, through the real binary rather than through a recorded table. A fixture is a second opinion exactly once, on the day somebody generates it; after that it agrees with a regression as readily as with a fix. palettegen probe is a hidden subcommand that exists for this and nothing else.

Four questions, in both directions, across 72 grid points:

What Agrees to Tolerance
the sRGB gamut boundary 0.000000 1e-4
OKLab into linear sRGB, per channel 1.0e-08 1e-7
sRGB into OKLCH, lightness and chroma 3.7e-08 1e-6
…and hue, above chroma 0.01 2.9e-06° 1e-4
WCAG relative luminance 1.8e-05 1e-4

Both directions, because the projection check is handed an OKLCH triple and so exercises only the way back — and these two implementations agree there far more closely than they do on the way in. Hue is compared only where the crate itself holds a hue to be real: its conditioning is 1/chroma, and at chroma 0 the two answers are 90° apart, which is two implementations reporting the arctangent of nothing.

Every tolerance was set by breaking color.rs and contrast.rs on purpose and watching. A transcription slip in the fourth decimal of a matrix entry fails the run by two orders of magnitude; a slip in the eighth passes, and is meant to — it moves a channel by 2.5e-05 of one 8-bit level.

Three things about numbers in Rust that cost a day each

palette clips per channel inside its conversions, and cannot tell you it did. Converting Oklch(0.65, 0.34, 145°) yields linear sRGB (+0.0000, +0.4987, +0.0000) with is_within_bounds() returning true, where the unclamped projection gives (−0.1614, +0.4987, −0.0802). Round-tripping that colour returns L = 0.6871, C = 0.2338, H = 142.50° — the lightness moved by 0.037 and the hue by 2.5°. A gamut predicate built on IsWithinBounds therefore always says yes, and a solver built on that predicate silently solves for a different colour than the one it emits.

color::in_srgb_gamut is the answer and is the only hand-written colour maths in the crate. It is cross-checked against palette inside the gamut, where the two agree to four decimal places, and a second test asserts the defect itself — so if palette ever gains an honest predicate, that test fails and says the extra function can go.

Hue angles are signed. OklabHue::into_degrees() normalises to (−180, 180], so an info anchor of 245° arrives as −115° and #4A7DBE arrives as −104.76°. Every range test written the obvious way is false for half the circle, with no type error and no warning. Hue is a newtype with a private field whose constructor always wraps, so the trap is disarmed once instead of remembered at every comparison.

serde_json does not round-trip f64. Serialising 0.0048000000000000004 and reading it back yields a value one unit in the last place away; over 200 000 values of the kind this pipeline produces, 13 968 fail to round-trip. The manifest has to reproduce itself byte for byte, so every component and every reported number is rounded to six decimal places where it is produced — inside the solver, so the colour measured is the colour emitted. Quantised that way, none of the 200 000 fail.