
Methodology
How Hue Codex generates palettes and scales
Palette tools turn one or more colors into candidate systems. Hue Codex keeps those systems deterministic and role-aware, accepts exact user-defined colors in every role, and perceptually fits the remaining unlocked pairings to explicit contrast targets.
Short answer
Hue Codex palette generation combines documented HSL style formulas and hue anchors with a lock-aware OKLCH correction pass. Editing any role creates an exact custom anchor and locks it automatically; Rebuild unlocked roles then fits the rest of the palette around every locked color. Unlocked body text is fitted to 7:1 on the surface; meaningful action, support, boundary, and status colors are fitted to 3:1; and black or white primary-action text is selected at 4.5:1 or better. Those are pair-specific guarantees, not a claim that every possible component state is accessible.
- Harmony tools rotate hue angles around a color wheel and then apply the listed saturation/lightness tone formulas.
- Role palettes assign jobs such as primary, secondary, accent, surface, text, border, success, warning, and danger.
- Tint, shade, tone, and ladder tools can use OKLCH, HSL, or simple RGB-style approaches depending on the selected mode.
- Random seed selection is not itself reproducible, but regeneration is deterministic once the resulting HEX seed, style, locks, and previous locked colors are known.
- An exact role edit locks that color immediately and puts the palette into a pending state until the unlocked roles are rebuilt and all nine contracts are evaluated again.
- Unlocked named role pairings pass their documented targets; incompatible locked colors remain unchanged and are reported as exact conflicts.
Standards status
These badges identify which parts of this methodology are standards-backed, draft-track, source-origin references, Hue Codex-specific, approximate, or dependent on browser behavior.
Formulas, choices, heuristics, and limits
This separates standards-based formulas from Hue Codex implementation decisions, product heuristics, and known limitations for this methodology.
Standards-based formulas
Formula, threshold, syntax, or data behavior taken from a cited standard. Primary and model sources can inform implementation, but they are not automatically standards.
- WCAG contrast formulas are used for palette readability metadata.
- CSS Color 4 color-model behavior supports the conversions behind HSL, Lab/LCH, and OKLab/OKLCH views.
Implementation choices
How Hue Codex chooses to parse, normalize, round, export, or sequence calculations.
- Seeds and pasted colors are normalized before Hue Codex builds roles, harmonies, scales, and exports.
- Locked roles keep their previous values during regeneration.
- The unlocked surface searches for the nearest OKLCH lightness that satisfies locked foreground contracts when a shared solution exists.
- Unlocked contracted roles keep hue while lightness moves toward the higher-contrast endpoint; chroma is reduced only when needed for sRGB output.
- Scale mode selection determines whether Hue Codex uses OKLCH, HSL, or simple RGB-style mixing.
- Style preset formulas, role hue anchors, semantic hue pulls, saturation/lightness clamps, and scale weights are fixed constants listed in the pseudocode.
- The Random seed action uses the browser JavaScript Math.random() source to choose a new HSL seed, then deterministic palette formulas run from that resulting HEX value.
- Palette share URLs use versioned fragment state to serialize the concrete seed, selected style, lock flags, resolved role colors, and supported export settings. They do not serialize an internal random generator state.
- Palette accessibility metadata is recomputed after fitting from the resolved surface and text roles; the dashboard, specimen, role cards, and exports use the same pair values.
Hue Codex heuristics
Product rankings, bands, labels, suggestions, or role hints that are useful guidance but not external standards.
- Role assignment, semantic state color shifts, tone presets, style presets, and role hints are Hue Codex product heuristics with deterministic constants.
- Harmony tuning applies explicit saturation and lightness formulas that favor practical swatches over purely geometric hue rotations.
- The 7:1 text contract, 3:1 functional-role contract, 0.05 solver margin, and decision to preserve OKLCH hue before reducing chroma are Hue Codex product policies built on WCAG contrast math.
- Accessible pair counts and best text suggestions outside the declared role pairings remain screening aids, not design-system approval.
Known limitations
Caveats, edge cases, browser dependencies, approximations, or contexts the method does not prove.
- Passing named role contracts do not guarantee a complete brand system or every component, state, overlay, gradient, opacity, or adjacent-color pairing.
- High-chroma or very neutral seeds can produce constrained, clipped, or low-chroma companions.
- Final UI roles still need component-level contrast, color-vision, and state review.
- A random seed cannot be regenerated from an internal PRNG seed because Hue Codex does not expose or store a seeded PRNG state.
Representative automated fixture coverage
Hue Codex imports versioned JSON fixtures and tests the corresponding pure production functions directly. The groups below are the representative regression evidence most relevant to this methodology.
- Palette fixtures: Deterministic role output, locked-color preservation, readable-pair metadata, and export consistency. View the JSON fixtures.
- Contrast fixtures: Known ratios, threshold boundaries, symmetry, and unrounded pass/fail behavior. View the JSON fixtures.
- Export fixtures: Displayed-versus-exported values, parseable CSS and JSON, and safe allowlisted SVG output. View the JSON fixtures.
What a pass means. A passing group confirms the named vectors and tested properties still match the documented implementation. It does not prove every possible input, final browser rendering, full accessibility conformance, profile-aware print output, or every downstream export consumer.
Inputs and role assignment
Palette generation starts with a seed color or pasted color list. The seed is normalized to HEX, converted to HSL and OKLCH when needed, and used to build predictable relationships.
Role palettes first compute an expressive candidate from the seed HSL value and the selected style. Balanced keeps the seed as the initial primary candidate; other styles rebuild primary from the seed hue plus style saturation and lightness. Hue Codex then fits unlocked functional roles in OKLCH, moving lightness first and reducing chroma only when needed for an sRGB-safe result.
Generation-time validation pipeline
The Palette Generator validates during generation rather than emitting an unchecked draft and a repair list. For an unlocked palette, the result is not presented until all nine built-in pair checks pass. The same resolved values drive the Role Board, live specimen, validation details, share state, and exports.
Locks are absolute. A locked role keeps its exact HEX. If the locked values make a contract impossible, Hue Codex does not silently change them; it preserves the requested palette and reports the exact affected roles, current raw ratio, target, and number of checks blocked by locks.
| Step | Operation | Deterministic rule |
|---|---|---|
| 1 | Normalize the seed | Parse the input, reduce it to opaque sRGB HEX for palette math, and retain the normalized seed for fidelity reporting. |
| 2 | Build the style candidate | Apply the selected one of 17 documented HSL role formulas and semantic hue anchors. |
| 3 | Restore locks | Copy every locked role HEX from the previous or shared palette before any contrast correction. |
| 4 | Resolve Surface around locks | When Surface is unlocked, scan sRGB-safe OKLCH lightness candidates and choose the least-distorted one that satisfies all compatible locked role-on-Surface contracts. Equal scores keep the first stable scan result. |
| 5 | Fit unlocked contracted roles | In role order, keep OKLCH hue stable, move lightness toward the higher-contrast black or white endpoint, and reduce chroma only when required for a clean sRGB round trip. |
| 6 | Derive filled-role foregrounds | For Primary, Secondary, Accent, Success, Warning, and Danger, choose whichever of #000000 or #FFFFFF has the higher unrounded contrast. |
| 7 | Evaluate all nine checks | Evaluate Text/Surface at 7:1; Primary, Secondary, Accent, Border, Success, Warning, and Danger on Surface at 3:1; and On Primary/Primary at 4.5:1. Threshold decisions use raw ratios. |
| 8 | Attach validation metadata | Record pass and blocked counts, seed-to-Primary OKLab distance, exact before/after adjustments, readable on-colors, lock state, style, and sRGB output gamut. |
Custom role editing and explicit rebuild
Every named role exposes a text input and native color picker. A supported opaque CSS color is normalized to uppercase sRGB HEX, applied to the current palette, and locked automatically as a user-defined anchor. Editing Primary also updates the normalized seed so the next rebuild grows from that exact primary color.
A direct edit updates the visible role, specimen, and exports immediately, but Hue Codex deliberately labels the palette as awaiting rebuild. Rebuild unlocked roles starts from the selected seed and style, restores every locked HEX without modification, resolves a compatible unlocked Surface when possible, fits the other unlocked roles, and reruns the nine built-in checks. An incompatible set of locked colors is preserved and reported instead of being silently altered. Unlocking a role after that conflict creates a new pending regeneration state and re-enables Rebuild unlocked roles, so the released value can be replaced without changing the anchors that remain locked.
| Interaction | Documented behavior |
|---|---|
| Role text input or color picker | Accept a supported opaque CSS color, normalize it to sRGB HEX, replace that exact role, mark it user-defined, and lock it automatically. |
| Edit Primary | Use the exact new Primary as both the locked role value and the seed for subsequent unlocked-role generation. |
| Pending state | Show the custom values immediately, identify which anchors await rebuilding, enable Rebuild unlocked roles, and label JSON validation as customization-pending rather than approved. |
| Rebuild unlocked roles | Preserve every locked HEX exactly, regenerate only unlocked roles from the current seed/style candidate, fit the named contrast contracts, and recompute all metadata and exports. |
| Unlock before or after rebuilding | The role is no longer an anchor, is marked as ready to regenerate, and immediately re-enables Rebuild unlocked roles. The next rebuild or seed/style regeneration replaces that released value while preserving roles that remain locked. |
| Incompatible locks | Preserve the requested locked colors and report the exact failed pair, raw ratio, target, and lock-blocked count. |
Deterministic regeneration and random seeds
Hue Codex does not use a seeded, replayable palette PRNG. The Palette Generator Random seed action calls the browser JavaScript Math.random() source three times to choose h = floor(random * 360), s = 62 + floor(random * 24), and l = 42 + floor(random * 20), then converts that HSL color to a normalized HEX seed.
After a concrete HEX seed exists, palette regeneration is deterministic. The same normalized seed, selected style, lock vector, and previous locked role values produce the same role palette; without locks, the same seed and style produce the same nine generated role colors.
| Area | Documented behavior |
|---|---|
| PRNG source | Browser JavaScript Math.random(); Hue Codex does not expose, store, or serialize an internal PRNG seed. |
| Seed source | The generator reads a validated fragment state when present; otherwise it starts from #4169E1. Legacy route and state-query inputs permanently redirect to fragment state. The Random seed button replaces the seed input with the new random HEX without rewriting the current URL. |
| Deterministic regeneration | Regeneration calls the role formulas using normalize_hex(seed), selected style, current locks, and the previous palette for locked roles. No random value is used after the seed HEX is chosen. |
| Share URL | Copy Link creates /palette-generator#s={versioned-state}. The fragment preserves the current seed, style, lock flags, resolved role palette, and supported export setting; it does not preserve internal Math.random() draws or custom-marker and pending-rebuild provenance. Rebuild before handoff when a local edit is still pending. |
| Lock behavior | A locked role keeps its previous HEX when seed or style changes or an explicit rebuild runs. Multiple locked roles are treated as simultaneous anchors. The unlocked surface can move to satisfy their compatible role contracts; other unlocked roles are then fitted around the resolved surface. Current lock flags are encoded in an explicit Share URL. |
Visual example with expected output
This example uses one seed so visual output can be compared against the formulas and test vectors on this page. Inputs are seed #336699, Balanced style, no locked roles, and the default OKLCH scale mode with 3 generated steps. Border and warning show the post-generation contrast fit.
Seed #336699, Balanced style
Balanced keeps the seed as Primary, generates the remaining role candidates, and then fits functional roles. The scale rows use OKLCH weights 0.25, 0.5, and 0.75.
Expected role palette
Expected OKLCH tints, 3 steps
Expected OKLCH shades, 3 steps
Expected OKLCH tones, 3 steps
Role generation rules
Role generation starts with base = rgb_to_hsl(seed) and settings = palette_style_settings(base, style). Hue Codex converts the formulas below to HEX after normalizing hue and clamping HSL channels. These values are candidates: unlocked contracted roles may receive a minimal OKLCH lightness/chroma correction, while locked roles keep their previous HEX.
role_hue(key, fallback_offset) uses an absolute style anchor when settings[key + Hue] exists: harmonized_hue(base.h, anchor, settings.roleShift or 0.2, settings.roleMaxShift or 36). Otherwise it uses normalize_degrees(base.h + fallback_offset).
| Role | Hue formula | Saturation formula | Lightness formula |
|---|---|---|---|
| primary | base.h; balanced style keeps the original seed HEX instead of regenerating HSL | clamp(settings.s) | clamp(settings.l, 4, 97) |
| secondary | role_hue(secondary, settings.secondary) | settings.s * 0.86 | settings.l - 7 |
| accent | role_hue(accent, settings.accent) | min(94, settings.s + 10) | settings.l + 5 |
| surface | role_hue(surface, settings.surface) | settings.surfaceS ?? max(12, settings.s * 0.28) | settings.surfaceL ?? 96 |
| text | role_hue(text, settings.text) | settings.textS ?? max(24, settings.s * 0.42) | settings.textL ?? 12 |
| border | role_hue(surface, settings.surface) | settings.borderS ?? max(10, settings.s * 0.24) | settings.borderL ?? 78 |
| success | semantic_hue(145, 0.20, 18) | semantic_s(48, 34, 88) | semantic_l(36, 28, 52) |
| warning | semantic_hue(44, 0.16, 14) | semantic_s(68, 44, 94) | semantic_l(62, 48, 74) |
| danger | semantic_hue(350, 0.14, 12) | semantic_s(64, 42, 92) | semantic_l(56, 42, 68) |
Semantic hue targets
Success, warning, and danger are nudged toward familiar status-color hue regions, then pulled toward the seed hue. The pull is shortestHueDistance(anchor, seed) * strength * settings.semanticShift, clamped to plus or minus max_shift. Monochrome style sets semanticMode to seed, so semantic hues use base.h instead of the status anchors.
| Role | Anchor | Target range after cap | Seed weighting | S and L formulas |
|---|---|---|---|---|
| success | 145deg | 127..163deg | shortestHueDistance(145, base.h) * 0.20 * settings.semanticShift, capped to +/-18deg | s = clamp((48 + base.s * 0.18) * settings.semanticS, 34, 88); l = clamp(36 + settings.semanticL, 28, 52) |
| warning | 44deg | 30..58deg | shortestHueDistance(44, base.h) * 0.16 * settings.semanticShift, capped to +/-14deg | s = clamp((68 + base.s * 0.18) * settings.semanticS, 44, 94); l = clamp(62 + settings.semanticL, 48, 74) |
| danger | 350deg | 338..360deg or 0..2deg | shortestHueDistance(350, base.h) * 0.14 * settings.semanticShift, capped to +/-12deg | s = clamp((64 + base.s * 0.18) * settings.semanticS, 42, 92); l = clamp(56 + settings.semanticL, 42, 68) |
Harmony and scale logic
Harmony generation uses conventional hue offsets such as complementary, analogous, triadic, tetradic, split-complementary, and monochromatic relationships. Preserve, balanced, vivid, and muted tone presets use the exact saturation/lightness equations in the pseudocode.
Scale generation clamps requested steps to 3 through 11, then uses weights (index + 1) / (count + 1). The seed is the anchor color for each formula and is not inserted as one of the generated tint, shade, or tone rows. OKLCH, HSL, and simple RGB modes apply different formulas, so the chosen mode changes the resulting HEX values.
Scale formula specifications
Scale formulas are Hue Codex generation heuristics, not standards-defined palettes. All modes derive generated values from the normalized seed HEX, clamp requested step count to count = min(11, max(3, requested_steps)), and assign weight_i = (i + 1) / (count + 1) for i = 0..count-1.
The generated scale groups contain only derived colors. The original seed is shown or exported separately when the tool presents the base color, which keeps the seed anchored without duplicating it inside every tint, shade, or tone list.
| Mode | Seed anchor | Tint trajectory | Shade trajectory | Tone trajectory | Clamping and output |
|---|---|---|---|---|---|
| OKLCH | Convert seed to L0, C0, H0 with rgb_to_oklch(seed). Keep H0 for all generated swatches. | L = L0 + (0.98 - L0) * w; C = C0 * (1 - 0.18w) | L = L0 * (1 - 0.82w); C = C0 * (1 - 0.08w) | L = L0 + (0.62 - L0) * 0.32w; C = C0 * (1 - 0.78w) | Clamp L to 0..1 and C to >= 0, convert OKLCH to sRGB, then clamp and round encoded RGB channels to 0..255 before HEX output. |
| HSL | Convert seed to H0, S0, L0 with rgb_to_hsl(seed). Keep H0 for all generated swatches. | S = max(0, S0 - 10w); L = L0 + (98 - L0) * w | S = S0; L = max(4, L0 * (1 - 0.82w)) | S = max(0, S0 * (1 - 0.75w)); L = L0 + (50 - L0) * 0.45w | H is normalized to 0..360, S/L are interpreted as percentages, and final RGB channels are clamped and rounded to 0..255. |
| Simple RGB | Use the seed encoded sRGB 8-bit channels as R0, G0, B0. No linear-light conversion is applied. | mix_srgb(seed, #FFFFFF, w) | mix_srgb(seed, #000000, w) | mix_srgb(seed, #808080, 0.82w) | mix_srgb(a,b,w) clamps w to 0..1 and returns round(a_channel + (b_channel - a_channel) * w) for each encoded sRGB channel. |
Harmony specifications
Harmony swatches are generated in HSL. Hue is calculated as normalize_degrees(base.h + offset), where normalize_degrees returns a value in [0, 360). Non-monochromatic harmonies use the selected tone preset for saturation and lightness; monochromatic harmonies keep the same hue and use the listed saturation-scale and lightness-delta arrays.
Hue Codex does not adjust OKLCH chroma during harmony generation. OKLCH chroma adjustments belong to the separate tint, shade, and tone scale formulas.
| Harmony | Swatches | Offsets | Saturation/lightness/chroma adjustment | Neutral handling |
|---|---|---|---|---|
| Complementary | 2 | [0, 180] | Base swatch remains seed. Complement uses tone_values(base, tone, index 1). No OKLCH chroma adjustment. | Preserve tone keeps neutral support gray; balanced/vivid/muted can introduce saturation from the fallback hue. |
| Analogous | 3 | [0, -30, 30] | Base remains seed. Neighbors use tone_values at indexes 1 and 2. Negative offset wraps through normalize_degrees. | Achromatic seed uses fallback hue 0; -30 wraps to 330 and +30 to 30 if tone clamps add saturation. |
| Triadic | 3 | [0, 120, 240] | Base remains seed. Triad swatches use tone_values at indexes 1 and 2. No OKLCH chroma adjustment. | Neutral seed behavior follows selected tone: preserve stays gray; other tones may create low/medium-saturation fallback-hue swatches. |
| Tetradic | 4 | [0, 90, 180, 270] | Base remains seed. Square/complement swatches use tone_values at indexes 1, 2, and 3. No OKLCH chroma adjustment. | Neutral seed can generate fallback-hue square colors when tone saturation minimums apply. |
| Split complementary | 3 | [0, 150, 210] | Base remains seed. Split swatches use tone_values at indexes 1 and 2. No OKLCH chroma adjustment. | Neutral seed uses fallback hue 0, so split hues are 150 and 210 only if the tone preset adds saturation. |
| Monochromatic | 5 | [0, 0, 0, 0, 0] | Base remains seed. Other swatches use saturationScale [0.72, 0.82, 0.62, 0.38] with lightnessDelta [-28, -14, 16, 32], clamped to s 18..92 and l 10..94. No OKLCH chroma adjustment. | Neutral base remains the seed; non-base swatches use the fallback hue and minimum saturation 18 because of the monochromatic clamp. |
Accessibility metadata
Palette accessibility metadata uses the WCAG contrast formula, then Hue Codex applies role-specific product checks. The formula and thresholds are standards-based; the palette recommendation title, readiness status, best-text suggestions, and next-step copy are Hue Codex hints.
Counts ordered non-identical palette color pairs whose raw contrast ratio is at least 4.5:1. Display values may round to two decimals, but the count uses the unrounded ratio.
The generator also records seed fidelity as Euclidean distance in OKLab multiplied by 100, plus every role adjustment as before HEX, after HEX, OKLab distance, and reason. This is provenance metadata, not a perceptual acceptability standard.
| Field | Exact pair or candidate pool | Threshold or use |
|---|---|---|
| contrastOnSurface | current role color on the surface role, falling back to #FFFFFF if no surface role exists | Rounded ratio for display/export metadata |
| wcagOnSurface | current role color on the surface role | Raw-ratio booleans: AA normal >= 4.5, AAA normal >= 7, AA large/UI >= 3, AAA large >= 4.5 |
| contrastWithText | current role color against the text role, falling back to #10212B if no text role exists | Rounded ratio used as metadata; it is not a universal approval for using that role as text |
| bestText | highest raw contrast on the current role color from [text role, #FFFFFF, #000000] | A swatch-label and copy hint only; final text choices must be checked in the actual foreground/background context |
Palette contrast contracts
The Palette Generator uses named role contracts both to fit unlocked colors and to report locked conflicts. The dashboard, live specimen, handoff summary, and contrast report all read the same resolved role values. These contracts do not test every possible UI state.
| Pair | Where it appears | Threshold interpretation |
|---|---|---|
| text on surface | Fit pass, recommendation, specimen, role cards, exports | Generated target >= 7.0 for AAA normal text |
| primary on surface | Fit pass, specimen, recommendation, decision grid, report | Generated target >= 3.0 as a meaningful action/UI color |
| black or white on primary | Specimen, recommendation, readiness, handoff, report, and all developer exports | Higher raw-contrast candidate; AA normal text target >= 4.5 |
| secondary and accent on surface | Fit pass, specimen, recommendation, decision grid, report | Generated target >= 3.0 as meaningful support/UI or graphical colors |
| border on surface | Fit pass, specimen, recommendation, role cards, exports | Generated target >= 3.0 for a meaningful control boundary; decorative dividers are a separate use |
| success, warning, danger on surface | Fit pass, specimen, recommendation, role cards, exports | Generated target >= 3.0 for status icons or indicators; status meaning still needs text, an icon, shape, or another non-color cue |
| every contracted role on surface | Role cards and the Role on surface block in the contrast report | Each raw ratio is evaluated before display rounding |
| all ordered color pairs | Shared palette readable-pairs count | Counts ordered non-identical palette color pairs whose raw WCAG contrast ratio is at least 4.5:1. Display values may round to two decimals, but the count uses the unrounded ratio. |
Pair guarantees and limits
An unlocked generated palette reports all contracts passing only when body text reaches 7:1 on surface, primary/secondary/accent/border/status roles reach 3:1 on surface, and the selected black-or-white primary foreground reaches 4.5:1. The generator uses a 0.05 internal safety margin when moving unlocked colors, but conformance booleans use the exact unrounded thresholds.
The contract does not account for arbitrary role combinations, font rendering, disabled states, focus appearance size, multiple adjacent colors, imagery, gradients, opacity, or whether color is the only status cue. The live specimen demonstrates the intended pairings; it is not a universal UI certification.
The live specimen uses only checked relationships: Text on Surface; Primary on Surface; On Primary on Primary; Secondary, Accent, Border, Success, Warning, and Danger on Surface. Its optional role-mapping inspector exposes exact foreground and background HEX values, the ratio, target, and pass status for the selected component.
Locked colors and edge cases
- Locked colors keep their previous HEX value when a palette is regenerated. If the surface is flexible, it searches for the nearest lightness that satisfies every locked role contract before other unlocked roles are fitted.
- A custom role edit accepts a supported opaque CSS color, normalizes it to uppercase sRGB HEX, and locks the role. Editing Primary also changes the seed used for the next rebuild.
- If a custom role is unlocked before rebuild, the next rebuild or seed/style regeneration may replace it; only locked colors are exact generation constraints.
- When a locked surface and locked foreground conflict, or several locked foregrounds have no shared surface solution, Hue Codex preserves the locks and reports the exact failed contract.
- Highly neutral seeds can produce low-chroma companion roles; Hue Codex clamps role saturation to 0..100 and role lightness to 4..97 before converting HSL roles to HEX.
- During the contrast-fit pass, OKLCH hue is held stable, lightness moves first, and chroma is reduced only when the candidate would not round-trip cleanly through sRGB.
- Semantic colors use anchors 145deg, 44deg, and 350deg, then pull toward the seed hue by shortestHueDistance(anchor, seed) * strength * semanticShift, capped by maxShift.
Validation checks
| Check | Expected behavior |
|---|---|
| #336699 Balanced fitted role fixture | primary #336699; secondary #353078; accent #B8732E; surface #F3F4F6; text #262517; border #8A8B97; success #27906A; warning #958F00; danger #E13D79 |
| #336699 Balanced role checksum | Serialize the preceding fixture as role:#HEX pairs in the listed order, joined by | with no spaces. Expected SHA-256, grouped for readability: a5b6fdfb 1fd9edc8 340946b0 eafaeb8a a83b34a2 07ef73c0 3fdee079 9a16130e. |
| Seed #0EA5E9 with Calm style | Primary #2B96C3, surface #F3F4F7, text #272116, border #868C97, and warning #958F00 after constraint fitting |
| Locked role | Locked HEX value remains unchanged after regeneration |
| Exact two-anchor brand palette | Starting from #ED1C24 Balanced, editing Secondary to #FFF200 locks Primary and Secondary exactly; rebuilding preserves both values and all nine declared contracts pass. |
| Pending custom validation | Before explicit rebuild, the dashboard warns that custom anchors are pending and JSON exports validation.status = customization-pending with the affected role names. |
| Editable role controls | Every role text input and native color picker has an accessible label; Enter applies a text value, Escape restores the current value, and a successful edit locks the role. |
| Locked accent during regeneration | If the accent role is locked, its previous HEX remains unchanged while unlocked roles recalculate from the new seed/style |
| Contrast metadata | Best text and pair counts update after every palette change |
| Text role changed after generation | contrastWithText, best-text suggestions, and accessible pair counts are recalculated from the new text role |
| Scale step count | Requested steps are clamped to a practical range for readable output |
| Random seed action | The chosen HEX is random because it comes from Math.random(); after that HEX is set, regeneration follows deterministic formulas |
| Share palette URL | The copied /palette-generator#s={versioned-state} URL restores the supported current seed, style, locks, resolved roles, and export setting, but not an internal PRNG state |
| Dynamic role inputs and sharing | Per-role editor fields are excluded from generic form serialization so exact palette roles are carried once in the resolved palette state and SVG/share actions do not reject them as unsupported fields. |
| Locked role plus new random seed | Locked roles keep their prior HEX while unlocked roles regenerate from the new random seed and selected style |
| Unlocked style/seed invariant | For every supported style and tested boundary or arbitrary sRGB seed, all declared role contracts pass after fitting |
| All 17 styles stay distinct | For representative chromatic and neutral seeds, each supported style has a stable role signature and the intended style direction remains visible after fitting |
| Palette text/surface contract | text on surface is fitted to AAA normal 7:1 using the raw contrast ratio |
| Palette action/border/status contracts | primary, secondary, accent, border, success, warning, and danger are fitted to UI/non-text 3:1 on surface |
| Primary action text | Higher raw contrast from [#FFFFFF, #000000] is used and must reach AA normal 4.5:1 |
| Readable filled-role foregrounds | on-primary, on-secondary, on-accent, on-success, on-warning, and on-danger each use the higher-contrast black-or-white candidate and appear in CSS, JSON, token, role-map, Tailwind, Sass, contrast, and handoff exports |
| Validation metadata | Pass count, blocked count, seed/Primary OKLab distance, exact adjustment history, locks, style, on-colors, and sRGB output are recalculated after generation |
| Live role mapping | The optional inspector is off by default, makes mapped specimen elements keyboard-focusable only while active, and reports the exact pair and target used by the generator |
| Locked conflict | Incompatible locked HEX values remain unchanged and the failed role contract is returned as a locked failure |
| Shared readable pairs count | Counts ordered non-identical palette color pairs whose raw WCAG contrast ratio is at least 4.5:1; a pair that only rounds up to 4.50 is not counted |
Palette generation pseudocode
Harmony math and role palettes are deterministic Hue Codex heuristics. WCAG contrast math and thresholds are standards-based; the style formulas, role contracts, OKLCH repair strategy, and safety margin are documented product choices.
scheme_offsets:
complementary: count = 2, offsets = [0, 180]
analogous: count = 3, offsets = [0, -30, 30]
triadic: count = 3, offsets = [0, 120, 240]
tetradic: count = 4, offsets = [0, 90, 180, 270]
split_complementary: count = 3, offsets = [0, 150, 210]
monochromatic: count = 5, offsets = [0, 0, 0, 0, 0]
monochromatic_variants:
index 0 Base: lightnessDelta = 0, saturationScale = 1
index 1 Deep tone: lightnessDelta = -28, saturationScale = 0.72
index 2 Shade: lightnessDelta = -14, saturationScale = 0.82
index 3 Tint: lightnessDelta = 16, saturationScale = 0.62
index 4 Pale tint: lightnessDelta = 32, saturationScale = 0.38
tone_values(base_hsl, tone, index):
if tone == preserve:
return s = base.s, l = base.l
if tone == vivid:
return s = clamp(base.s + 16, 64, 94),
l = clamp(base.l + (index even ? -2 : 4), 42, 62)
if tone == muted:
return s = clamp(base.s * 0.52, 22, 56),
l = clamp(base.l + (index even ? 6 : 10), 46, 74)
# balanced
return s = clamp(base.s * 0.86, 40, 78),
l = clamp(base.l + (index even ? -4 : 6), 40, 68)
generate_harmony(seed, scheme, tone):
base = rgb_to_hsl(hex_to_rgb(normalize_hex(seed)))
# Hue wrapping uses normalize_degrees(h), returning [0, 360).
# Example: base.h + -30 becomes 330 when base.h is 0.
for each offset at index:
h = normalize_degrees(base.h + offset)
if scheme == monochromatic:
s = clamp(base.s * saturationScale[index], 18, 92)
l = clamp(base.l + lightnessDelta[index], 10, 94)
else:
s, l = tone_values(base, tone, index)
hex = index == 0 ? seed : rgb_to_hex(hsl_to_rgb(h, s, l))
attach WCAG best-text and pair metadata
neutral_seed_policy:
# Achromatic HSL seeds have base.s = 0 and a fallback hue of 0.
# Preserve tone keeps generated non-base hues achromatic because s = 0.
# Balanced, vivid, muted, and monochromatic modes can introduce saturation
# through their minimum s clamps; that hue is a Hue Codex fallback policy,
# not a recovered meaningful source hue.
# Harmony generation does not adjust OKLCH chroma; OKLCH chroma changes
# are only part of the tint/shade/tone scale formulas.
clamp(value, low, high):
return max(low, min(high, value))
palette_style_settings(base_hsl, style):
balanced = {
s: base.s, l: base.l,
secondary: 34, accent: 180, surface: 24, text: 205,
semanticS: 1, semanticL: 0, semanticShift: 1
}
settings = {
balanced: balanced,
vivid: {
s: min(94, base.s + 18), l: min(62, base.l + 3),
secondary: 42, accent: 174, surface: 28, text: 212,
semanticS: 1.12, semanticL: -2, semanticShift: 1.2
},
calm: {
s: max(34, base.s - 22), l: min(68, base.l + 8),
secondary: 24, accent: 156, surface: 18, text: 200,
semanticS: 0.84, semanticL: 4, semanticShift: 0.75
},
neutral: {
s: max(18, base.s - 38), l: min(64, base.l + 4),
secondary: 18, accent: 144, surface: 12, text: 196,
semanticS: 0.64, semanticL: 8, semanticShift: 0.55,
surfaceS: 12, borderS: 10
},
warm: {
s: max(46, min(86, base.s * 0.82 + 14)), l: clamp(base.l + 2, 38, 62),
secondaryHue: 28, accentHue: 54, surfaceHue: 36, textHue: 24,
roleShift: 0.18, roleMaxShift: 34,
semanticS: 0.96, semanticL: 2, semanticShift: 0.85
},
cool: {
s: max(38, min(82, base.s * 0.82 + 8)), l: clamp(base.l + 1, 38, 62),
secondaryHue: 205, accentHue: 268, surfaceHue: 206, textHue: 220,
roleShift: 0.2, roleMaxShift: 38,
semanticS: 0.86, semanticL: 2, semanticShift: 0.7
},
muted: {
s: max(20, base.s - 38), l: clamp(base.l + 2, 34, 62),
secondary: 28, accent: 160, surface: 20, text: 205,
semanticS: 0.58, semanticL: 6, semanticShift: 0.5
},
pastel: {
s: max(30, min(58, base.s * 0.48 + 18)), l: clamp(base.l + 22, 68, 82),
secondary: 26, accent: 156, surface: 18, text: 205,
semanticS: 0.48, semanticL: 14, semanticShift: 0.42,
surfaceL: 97, borderL: 86
},
earthy: {
s: max(26, min(58, base.s * 0.55 + 12)), l: clamp(base.l - 3, 34, 54),
secondaryHue: 36, accentHue: 96, surfaceHue: 42, textHue: 30,
roleShift: 0.16, roleMaxShift: 30,
semanticS: 0.62, semanticL: 4, semanticShift: 0.45,
surfaceS: 24, borderS: 18
},
monochrome: {
s: max(18, base.s * 0.68), l: clamp(base.l, 34, 62),
secondary: 0, accent: 0, surface: 0, text: 0,
semanticS: 0.5, semanticL: 8, semanticShift: 0,
semanticMode: seed,
surfaceS: max(8, base.s * 0.16),
textS: max(12, base.s * 0.32),
borderS: max(8, base.s * 0.2)
},
high-contrast: {
s: min(96, max(58, base.s + 12)), l: base.l >= 56 ? 44 : 58,
secondary: 180, accent: 60, surface: 0, text: 205,
semanticS: 1.16, semanticL: -6, semanticShift: 1.15,
surfaceL: 98, textL: 7, borderL: 18
},
dark: {
s: max(36, min(72, base.s * 0.72 + 10)), l: clamp(base.l - 22, 18, 38),
secondary: 28, accent: 180, surface: 210, text: 42,
semanticS: 0.92, semanticL: 4, semanticShift: 0.8,
surfaceL: 10, textL: 92, borderL: 30,
surfaceS: 18, textS: 18, borderS: 22
},
light: {
s: max(24, min(58, base.s * 0.52 + 12)), l: clamp(base.l + 24, 68, 84),
secondary: 22, accent: 150, surface: 18, text: 205,
semanticS: 0.62, semanticL: 12, semanticShift: 0.55,
surfaceL: 98, textL: 16, borderL: 88
},
playful: {
s: min(96, max(62, base.s + 16)), l: clamp(base.l + 9, 54, 72),
secondary: 54, accent: 205, surface: 42, text: 224,
semanticS: 1.05, semanticL: 5, semanticShift: 1.05
},
elegant: {
s: max(18, min(48, base.s * 0.42 + 10)), l: clamp(base.l - 8, 28, 50),
secondary: 28, accent: 132, surface: 18, text: 210,
semanticS: 0.5, semanticL: 1, semanticShift: 0.38,
surfaceS: 10, textS: 20, borderS: 12, borderL: 70
},
retro: {
s: max(42, min(78, base.s * 0.6 + 22)), l: clamp(base.l + 1, 42, 62),
secondaryHue: 32, accentHue: 176, surfaceHue: 46, textHue: 24,
roleShift: 0.12, roleMaxShift: 28,
semanticS: 0.82, semanticL: 5, semanticShift: 0.62,
surfaceS: 28, borderS: 22, borderL: 72
},
futuristic: {
s: min(98, max(54, base.s + 20)), l: clamp(base.l + 2, 42, 64),
secondaryHue: 210, accentHue: 292, surfaceHue: 222, textHue: 188,
roleShift: 0.24, roleMaxShift: 44,
semanticS: 1.08, semanticL: -1, semanticShift: 0.92,
surfaceL: 8, textL: 90, borderL: 34,
surfaceS: 22, borderS: 34
}
}
return settings[style] or balanced
harmonized_hue(seed_hue, anchor_hue, strength, max_shift):
shortest = ((seed_hue - anchor_hue + 540) % 360) - 180
shift = clamp(shortest * strength, -max_shift, max_shift)
return normalize_degrees(anchor_hue + shift)
build_role_palette(seed, style, locks, previous_palette):
base = rgb_to_hsl(hex_to_rgb(seed))
settings = palette_style_settings(base, style)
role_hue(key, fallback_offset):
if settings has key + "Hue":
return harmonized_hue(base.h, settings[key + "Hue"],
settings.roleShift or 0.2,
settings.roleMaxShift or 36)
return normalize_degrees(base.h + fallback_offset)
semantic_hue(anchor, strength, max_shift):
if settings.semanticMode == seed:
return base.h
return harmonized_hue(base.h, anchor,
strength * settings.semanticShift,
max_shift)
semantic_targets:
success: anchor = 145deg, strength = 0.20, max_shift = 18deg, range = 127..163deg
warning: anchor = 44deg, strength = 0.16, max_shift = 14deg, range = 30..58deg
danger: anchor = 350deg, strength = 0.14, max_shift = 12deg, range = 338..360deg or 0..2deg
semantic_s(base_amount, min_s, max_s):
return clamp((base_amount + base.s * 0.18) * settings.semanticS, min_s, max_s)
semantic_l(base_amount, min_l, max_l):
return clamp(base_amount + settings.semanticL, min_l, max_l)
roles = [
primary: style == balanced ? seed : hsl(base.h, clamp(settings.s), clamp(settings.l, 4, 97)),
secondary: hsl(role_hue(secondary, settings.secondary), settings.s * 0.86, settings.l - 7),
accent: hsl(role_hue(accent, settings.accent), min(94, settings.s + 10), settings.l + 5),
surface: hsl(role_hue(surface, settings.surface), settings.surfaceS ?? max(12, settings.s * 0.28), settings.surfaceL ?? 96),
text: hsl(role_hue(text, settings.text), settings.textS ?? max(24, settings.s * 0.42), settings.textL ?? 12),
border: hsl(role_hue(surface, settings.surface), settings.borderS ?? max(10, settings.s * 0.24), settings.borderL ?? 78),
success: hsl(semantic_hue(145, 0.2, 18), semantic_s(48,34,88), semantic_l(36,28,52)),
warning: hsl(semantic_hue(44, 0.16, 14), semantic_s(68,44,94), semantic_l(62,48,74)),
danger: hsl(semantic_hue(350, 0.14, 12), semantic_s(64,42,92), semantic_l(56,42,68))
]
for each role at index:
if locks[index] and previous_palette has color at index:
keep previous hex
else:
convert HSL role to HEX with h normalized, s clamped 0..100, l clamped 4..97
role_contracts:
text on surface: minimum 7.0
primary, secondary, accent, border, success, warning, danger on surface: minimum 3.0
if surface is unlocked and a locked role misses its contract:
search the surface OKLCH lightness range for the nearest surface that fits every locked role
for each unlocked contracted role:
if raw WCAG contrast is below target + 0.05:
keep OKLCH hue, move lightness toward the higher-contrast black/white endpoint,
binary-search the smallest passing move, and reduce chroma only when needed for sRGB
locked roles never move; if locked colors make the system unsatisfiable, report the exact failed pair
random_seed():
# This is not a seedable Hue Codex PRNG.
# It uses the browser JavaScript Math.random() source.
h = floor(Math.random() * 360)
s = 62 + floor(Math.random() * 24) # 62..85 percent
l = 42 + floor(Math.random() * 20) # 42..61 percent
return rgb_to_hex(hsl_to_rgb(h, s, l))
initial_palette(location):
state = decode_versioned_fragment_state(location.hash)
seed = normalize_hex(state.seed or #4169E1)
style = state.style or balanced
locks = state.locks or [false, false, false, false, false, false, false, false, false]
if state contains resolved role colors:
palette = create_palette_from_role_colors(state.palette)
return palette, seed, style, locks
return build_role_palette(seed, style, locks, previous = null)
regenerate(seed, style, locks, previous_palette):
# Deterministic after seed, style, locks, and previous locked values are known.
seed = normalize_hex(seed)
return build_role_palette(seed, style, locks, previous_palette)
share_palette(seed, style, locks, palette, export_mode):
state = canonical_versioned_state(seed, style, locks, palette, export_mode)
return "/palette-generator#s=" + base64url_encode(state)
shared_palette_fragment_behavior:
# The fragment stores the supported current settings and resolved role colors.
# It does not store an internal PRNG state or the original Math.random() draws.
validate version, tool, fields, locks, roles, names, colors, and size bounds
wcag_score(foreground, background):
raw = wcag_contrast_ratio(foreground, background)
return {
ratio: round(raw, 2),
rawRatio: raw,
normalAA: raw >= 4.5,
normalAAA: raw >= 7,
largeAA: raw >= 3,
largeAAA: raw >= 4.5,
uiAA: raw >= 3
}
with_accessibility_metadata(color, palette):
surface = palette.role(surface) or #FFFFFF
text = palette.role(text) or #10212B
return {
contrastOnSurface: round(wcag_contrast_ratio(color.hex, surface), 2),
contrastWithText: round(wcag_contrast_ratio(color.hex, text), 2),
bestText: highest_raw_contrast(color.hex, [text, #FFFFFF, #000000]),
wcagOnSurface: wcag_score(color.hex, surface)
}
palette_primary_checks(palette):
primary = palette.role(primary)
surface = palette.role(surface)
text = palette.role(text)
border = palette.role(border)
success = palette.role(success)
warning = palette.role(warning)
danger = palette.role(danger)
secondary = palette.role(secondary)
accent = palette.role(accent)
bestOnPrimary = highest_raw_contrast(primary.hex, [#FFFFFF, #000000])
return {
textOnSurface: wcag_score(text.hex, surface.hex),
primaryOnSurface: wcag_score(primary.hex, surface.hex),
secondaryOnSurface: wcag_score(secondary.hex, surface.hex),
accentOnSurface: wcag_score(accent.hex, surface.hex),
whiteOnPrimary: wcag_score(#FFFFFF, primary.hex),
blackOnPrimary: wcag_score(#000000, primary.hex),
bestOnPrimaryScore: wcag_score(bestOnPrimary.hex, primary.hex),
borderOnSurface: wcag_score(border.hex, surface.hex),
successOnSurface: wcag_score(success.hex, surface.hex),
warningOnSurface: wcag_score(warning.hex, surface.hex),
dangerOnSurface: wcag_score(danger.hex, surface.hex)
}
readable_pair_count(palette):
count = 0
for each foreground in palette.colors:
for each background in palette.colors:
if foreground.hex != background.hex:
raw = wcag_contrast_ratio(foreground.hex, background.hex)
if raw >= 4.5:
count += 1
return count
build_scales(seed, requested_steps, mode):
count = clamp(requested_steps, 3, 11)
weights = [(index + 1) / (count + 1) for index in 0..count-1]
if mode == simple:
Tints = [mix_srgb(seed, #FFFFFF, weight) for weight in weights]
Shades = [mix_srgb(seed, #000000, weight) for weight in weights]
Tones = [mix_srgb(seed, #808080, weight * 0.82) for weight in weights]
return Tints, Shades, Tones
if mode == hsl:
base = rgb_to_hsl(hex_to_rgb(seed))
Tints = [
hsl(base.h, max(0, base.s - weight * 10),
base.l + (98 - base.l) * weight)
for weight in weights
]
Shades = [
hsl(base.h, base.s,
max(4, base.l * (1 - weight * 0.82)))
for weight in weights
]
Tones = [
hsl(base.h, max(0, base.s * (1 - weight * 0.75)),
base.l + (50 - base.l) * weight * 0.45)
for weight in weights
]
return Tints, Shades, Tones
# default mode: OKLCH perceptual
base = rgb_to_oklch(hex_to_rgb(seed))
oklch_hex(l, c, h = base.h):
return rgb_to_hex(oklch_to_rgb(
l = clamp(l, 0, 1),
c = max(0, c),
h = h
))
Tints = [
oklch_hex(base.l + (0.98 - base.l) * weight,
base.c * (1 - weight * 0.18))
for weight in weights
]
Shades = [
oklch_hex(base.l * (1 - weight * 0.82),
base.c * (1 - weight * 0.08))
for weight in weights
]
Tones = [
oklch_hex(base.l + (0.62 - base.l) * weight * 0.32,
base.c * (1 - weight * 0.78))
for weight in weights
]
return Tints, Shades, Tones
Reproducible test vectors
These vectors isolate deterministic palette math from product heuristics such as role labels or palette ranking.
| Input | Expected output | Notes |
|---|---|---|
| Seed #336699 to HSL | h=210, s=50%, l=40% | Normalized seed values used for hue rotations |
| Balanced fitted role palette from #336699 | primary #336699; secondary #353078; accent #B8732E; surface #F3F4F6; text #262517; border #8A8B97; success #27906A; warning #958F00; danger #E13D79 | Uses balanced style candidates followed by the OKLCH role-contract fit |
| Calm fitted role palette from #0EA5E9 | primary #2B96C3; surface #F3F4F7; text #272116; border #868C97; warning #958F00 | Uses Calm style candidates followed by action, boundary, and status fitting against the surface |
| #336699 complementary hue +180deg | #996633 | Same saturation and lightness, hue normalized to 30deg |
| #336699 analogous hue -30deg | #339999 | Same saturation and lightness, hue 180deg |
| #336699 analogous hue +30deg | #333399 | Same saturation and lightness, hue 240deg |
| Base hue 0 analogous offset -30deg | 330deg | Hue wrapping uses normalize_degrees(base.h + offset) into [0, 360) |
| #336699 mixed 50% toward #FFFFFF in sRGB | #99B3CC | RGB tint vector |
| #336699 mixed 50% toward #000000 in sRGB | #1A334D | RGB shade vector |
| #336699 OKLCH scale, 3 steps | Tints #598ABD #7FAFE2 #A7D6FF; Shades #154978 #002D59 #00123B; Tones #416992 #4E6B8A #5A6D83 | Weights are 0.25, 0.5, and 0.75 with the default OKLCH formulas |
| #336699 HSL scale, 3 steps | Tints #548BC2 #8CB0D4 #C3D5E7; Shades #29517A #1E3C5A #14273B; Tones #3E6993 #4A6C8D #566F87 | Weights are 0.25, 0.5, and 0.75 with HSL hue preserved at 210deg |
| #336699 simple RGB scale, 3 steps | Tints #668CB3 #99B3CC #CCD9E6; Shades #264D73 #1A334D #0D1A26; Tones #436B94 #53718F #62768A | Weights are 0.25, 0.5, and 0.75; tone mix uses 0.82 times the weight toward #808080 |
| Neutral #808080 complementary, balanced tone | #808080 and #63BCBC | Base remains seed; support swatch uses fallback hue 180 with balanced tone s=40 and l=56.196078 |
| Neutral #808080 monochromatic | #808080 #432E2E #6D4C4C #B89999 #DAC9C9 | Non-base swatches use fallback hue 0 and minimum saturation 18 after monochromatic clamps |
Sources and standards
These references anchor the public standards, model origins, source records, and formats used by Hue Codex. Status badges distinguish stable standards, drafts, primary sources, model sources, Hue Codex heuristics, approximations, and browser-dependent behavior.
Tools using this methodology
These Hue Codex tools link to this methodology because they depend on the formulas, assumptions, limits, or data policy described here.
Report a calculation issue
If a result does not match the documented formula or fixture, send the exact input, tool settings, displayed result, expected result, precision mode, and browser details. Please do not include private images, personal information, or secrets.