Skip to content

Repository files navigation

colorhash

Colorhash is a deterministic string→color identity system: hash a workspace name to a palette cell so the same name renders the same color in the tmux status bar, on the desktop, and on the MacroPad's RGB LEDs. It replaces the cksum%7/Okabe-Ito system with a CVD-honest palette of 10 cells and a single reference implementation.

See docs/design.md for the design spec, research provenance, and full contract definition (a point-in-time document; paths it names are the author's, not this repo's).

Install

Consumers read one file: ~/.config/colorhash/palette.json. Point it at the clone:

git clone https://github.com/interslice-systems/colorhash ~/src/colorhash
mkdir -p ~/.config/colorhash
ln -sfn ~/src/colorhash/palette.json ~/.config/colorhash/palette.json

palette.json is the frozen contract; everything else here is the lab that produced it and the checks that keep it honest. Built for one desk. Shared in case it's useful on yours. No warranty, no promises, no roadmap.

Why 10 bins, and why collisions are fine

This comes up every few weeks, so here is the durable answer.

Every string→color scheme is a binning scheme. Continuous-output libraries (e.g. zenozeng/color-hash, where "nexus" and "oracle" both emit #A4C587) just hide the bins: hue is hash-mod-~360 and S/L come from 3-value sets, so codomain collisions still happen — and perceptual collisions are governed by the observer, not the codomain. For a deutan observer reading small chips in a status bar, the space holds roughly 10–15 mutually distinguishable colors. That is the real palette size no matter how many hexes the formula can emit.

Given that, a small designed palette beats a big accidental one twice over:

  • Identical is honest; near-identical is deceptive. Color here is a recognition aid keyed to the name, never an identity channel. Two chips in the exact same cell say "read the name"; two chips 95% alike imply a relationship that may not exist.
  • A finite palette is auditable. Every cell is vetted for 3:1 contrast on both theme grounds, WCAG text polarity, deutan-checked pairwise distance, and an LED rendering. A formula's output can be checked for none of that.

The math: with k live names, expected colliding pairs ≈ k(k−1)/2N. Doubling N only halves collisions while squeezing every pairwise distance in a space that does not grow. Petroff 10 sits at the knee: the largest set where every pair stays comfortably distinct for the author's red-green colour vision deficiency. Collisions at n=10 are expected behavior, not defects.

The shipping palette: Petroff 10

palette.json in this directory is the frozen contract, and it is Petroff 10 (arXiv:2107.02270) — not lab-optimizer output. Regenerate it reproducibly with:

node make-petroff-palette.mjs > palette.json && python3 check_palette.py palette.json

Two things about that file are worth knowing before you read it:

  • bg is Petroff verbatim; the other three surfaces are not. Petroff designed fill colors for plots on white. Used directly as tmux status foreground they fail the 3:1 floor — 4 of 10 on nord #2e3440, 5 of 10 on catppuccin-latte #eff1f5. So lightFg, darkFg, and led are BANDS renderings of the same hues, which re-lighten each into its ground's usable band. All ten then clear 3:1 on both.
  • observer.measured is false. No quiz was taken. sevWeights is the declared deutan prior at population prevalence, and observer.S is null rather than the flat prior's mean, so nothing downstream can mistake an untouched prior for a measurement.

The quiz and the personally-optimized palette are parked, not deleted — the lab still runs, and revisiting it only changes which cells array lands in palette.json.

Running the lab

Open lab.html directly from the file system (no server needed):

file:///path/to/colorhash/lab.html

The lab is fully offline. A self-test panel runs on load, verifying the hash implementation and palette consistency.

Quiz flow

  1. Start from a prior: Click one of the four prior buttons — none, protan, deutan, tritan — to seed where the quiz starts, or paste a saved calibration code into the restore field to resume an earlier session. There is no severity number or slider to set here; severity is never entered directly, only fitted from your duel answers below.

  2. Take the quiz: Answer duel questions about which color you prefer. The quiz adapts based on your answers and converges in 10–15 questions.

  3. Undo & Restart: One mis-click? Hit Undo to pop the last judgment and refit. Or Restart to clear the quiz completely and begin fresh (useful if you run the quiz twice — state does not persist across runs).

  4. Find your maximum N: Click Find my max N. The lab searches N=6…24 itself and adopts the largest count that clears 1.15× your fitted threshold — you don't set N by hand. Moving the N, colorfulness, or rotation controls after that is deliberate: each one ends the current frontier streak (the pack changed, so the old judgments no longer prove anything about it) and drops you back to 0/6. If you've earned the 6/6 "validated ✓" badge, don't fiddle with those sliders unless you mean to re-earn it.

  5. Taste-tune: Tweak rotation and vibrancy to adjust the palette flavor while keeping capacity honest. The capacity bar shows your safety margin.

  6. Download palette.json: Once satisfied, download your palette. The file includes your observer profile, the generator parameters, capacity scores for all four color surfaces (identity, light foreground, dark foreground, LED), and the 12 frozen hex cells that will be deployed.

Configuration

The live palette is ~/.config/colorhash/palette.json. Known consumers: workspaces (tmux bar, window names, the Omarchy bar widget) and operator (pad LEDs), plus the lab. Replacing the file recolors every surface at once; there is no other sync mechanism, by design.

Verification

To verify a palette.json against the contract rules — golden hash vectors, hash.n matching the cell count, hex format/schema on all five color fields, per-surface scores recomputed from the hex and compared to the printed values (±0.15), and bgText's WCAG polarity — plus a warning-only check for BANDS re-derivation drift:

python3 check_palette.py ~/.config/colorhash/palette.json

Exit code 0 means the palette is valid. The script recomputes per-surface scores and bgText WCAG contrast independently, so you can trust its output without re-running the lab. It does not check the hex values for gamut validity — that's guaranteed by construction (the lab's optimizer only ever emits gamut-clamped colors), so re-checking it here would be a dead check.

Eyeball test on hardware

If you have access to the MacroPad (requires the operator daemon running), test how your palette looks on the actual hardware before final sign-off:

./led-test '#ff77a7' '#d14a72' '#9a1d47' '#ff3d81' '#d14a72' '#b7356a' …

Pass 12 hex colors (one per macro key) and optionally a hold time (1–300 seconds; default 60). The pad displays all 12 colors simultaneously so you can judge their appearance on actual WS2812 LEDs under your lighting.

What the quiz measures, and what it does not

The quiz estimates two things jointly: your sensitivity (how much separation you need between two colours to tell them apart) and your red-green severity (how much of that separation the red-green axis is losing).

Severity is measured, not taken from the button you clicked. The button sets a starting belief at population prevalence — about 8% of people have red-green colour vision deficiency — and the trials move it. If you say "none" and the trials disagree, the trials win.

The measurement is honest about its limits:

  • It detects a substantial red-green deficiency reliably, and it will not invent one for a trichromat.
  • It cannot confirm or rule out a mild deficiency in the quiz's trial budget.
  • The override is partial: a strong deficiency declared as "none" reaches roughly ~37% mean confidence within the budget, not 95% — and it is highly variable run to run: across seeds the same severe observer's measured mass has landed anywhere from 3% to 86%. Two runs of the same person can legitimately disagree; that is not a bug, it is the honest shape of 16 informative trials.

That uncertainty is carried, not resolved: the palette packer consumes the whole distribution rather than a yes/no answer, so partial evidence buys partial protection.

This is a palette-tuning tool, not a colour-vision test. It does not diagnose anything, and it is not a substitute for one that does.

Contract rules (frozen for deployment)

These rules are the API that every implementation must follow:

Hash function

cell = FNV1a32( UTF8( NFC(name) ) ) mod N
  • Algorithm: FNV-1a 32-bit (offset basis 0x811c9dc5, prime 0x01000193, XOR-then-multiply per byte).
  • Input: UTF-8 encoding of the NFC-normalized string. No trim, no case-foldingOracleoracle.
  • Modulo: The result mod N, where N is the number of cells in the palette. N is always len(cells) — read it from palette.json, never hardcode it.

Test vectors (every implementation embeds these):

input expected cell (n=10)
`` (empty string) 1
a 0
mirepoix 6
oracle 1
keymaker 4
dotfiles 1
ws-3 1
dev2 6
café (NFC) 9
recipe box 6

The cell column moves whenever N does; the frozen quantity is fnv1a32, which vectors.json carries alongside it. Collisions (oracle, dotfiles, ws-3 all land on cell 1) are expected at n=10 and are not a defect: colour is a recognition aid keyed to the name, not an identity channel.

palette.json format

The file declares the palette's origin, how to regenerate it, and its safety margins:

  • Hex is canonical and frozen: Cells are optimizer output, never code. No consumer ever re-runs the optimizer or reconstructs colors from oklch values.
  • Cell order is part of the API: Changing the order reshuffles all assignments; any regenerated palette replaces the whole file atomically and is a breaking event.
  • Regenerate-together: The hash.n must equal cells.length. Consumers derive N from the array, never hardcode it.
  • Scores from hex: The scores field carries per-surface worst-case min-ΔEok×100 (the honest numbers across all CVD projections) so any future palette swap shows its real safety at a glance. These are outputs of the lab, not inputs. They are recomputed from the emitted hex values themselves (never from the optimizer's unquantized floats), so an independent checker — check_palette.py — can reproduce them from nothing but the cells array and confirm they weren't hand-edited.
  • Version stamp: The colorhash version (currently 1), generator (lab version, mode, parameters, and CH3 regeneration code), and hash (algorithm, input encoding, N) make the file self-describing.
  • Derivation: bgText is computed per cell by WCAG contrast (black or white, whichever has higher ratio) and is a derived field, not a design choice.

Golden generator output

The CH3 code in the generator.cal field (e.g., CH3;p0.00;d0.55;t0.00;T0.0600;n12;r0;v45) encodes your observer profile and palette knobs. Pasting it into the lab with default settings regenerates your exact palette bit-for-bit. The palette.json is the frozen contract; the CH3 code is its lineage.

Files

  • lab.html: The offline quiz + optimizer + exporter.
  • check_palette.py: Independent verifier for palette.json files.
  • make-petroff-palette.mjs: Generates palette.json for Petroff 10, reproducibly.
  • palette.json: The frozen shipping contract. Checked in; every consumer reads it.
  • led-test: CLI tool to display palette colors on the MacroPad (requires the operator daemon's ledtest watcher).
  • vectors.json: Golden hash vectors for testing, including the expected palette.json.
  • test/run.mjs: Node test suite (158 checks).

Testing

Run the test suite to verify the colorhash implementation:

node test/run.mjs

The suite verifies the hash function against golden vectors, palette.json round-trip consistency, CH3 code parsing, and WCAG contrast floors. It runs for a few minutes.

Known state at publication (2026-09-10): 157 of 158 pass. The one failure, "the protan component of rgSevPrior is inert in the metric", dates from the 2026-08-23 LED-band change to lab.html and concerns the parked quiz path only; the shipping palette and check_palette.py are unaffected.

About

Deterministic string->color identity on a CVD-honest 10-cell palette. One palette.json; tmux, bar, and LEDs agree by derivation.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages