agentleFS
HugoLopes45HugoLopes45/design-skills/skills/color/SKILL.md

color

Skill1 starsChanged 3 months agoMITView on GitHub

Build a full palette from one brand hue in OKLCH — tinted neutrals, surface hierarchy, status colors, semantic scales, dark mode. Use when picking colors, building color tokens, fixing a palette that feels assembled from defaults, or converting a palette to dark mode.

8 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

7 rules. 0 are common, 6 are distinctive, and 1 is very specific to this project.

  • 0

    Common

    Most of the 8 edited versions we read keep these.

  • 6

    Distinctive

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use OKLCH (now supported in all major browsers) for perceptual uniformity:

    line 27
  2. Distinctive:

    When building semantic color scales (danger-100 through danger-900), use OKLCH with fixed L steps.

    line 39
  3. Distinctive:

    Test color combinations together, not in isolation.

    line 46
  4. Distinctive:

    Every neutral shares H=250 (the brand hue) with very low chroma — this tints them toward blue so the palette reads as a family, not parts …

    line 69
  5. Distinctive:

    Verify 500 against your brand: it should read as "the" color.

    line 191
  6. Distinctive:

    Verify 100 as a tinted background and 900 as near-black text — both should still read as belonging to the hue family.

    line 192
  7. Very specific:

    If light mode --surface-base is oklch(97% ...), a naive inversion gives oklch(3% ...) — pure black.

    line 202

“Common” is measured: kept by at least 6 in 10 of the 8 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Color

Color systems are not palettes. They are structured relationships: construct the palette from one hue, then verify it against perceptual properties.

An existing product's palette is the origin — derive missing pieces from its brand hue rather than starting a new palette. Full construction is for new products, or when you are explicitly reopening visual direction.

Palette construction from a single hue

The best palettes are derived, not assembled. Pick one hue. The entire palette grows from it.

  1. Brand hue — the origin. Define it in OKLCH: oklch(55% 0.18 250) — a medium-saturated blue.
  2. Neutrals are tinted — do not use pure gray (C=0). Tint the neutrals toward the brand hue at low chroma (C 0.01–0.02). This makes the palette feel cohesive rather than pasted-together.
  3. Surface hierarchy from the same hue — surfaces share the brand's H value with very low C. Surfaces that share origin hue feel like they belong to the same material.
  4. Status colors are neighbors, not defaults — derive them by hue-rotating from the brand. Never import a design system's status palette wholesale.
  5. Accent — one color. The brand hue at high chroma and appropriate lightness for the surface. One accent. No secondary accent. The moment you add a second accent, you have competing CTAs.
  6. Contrast, checked now — verify every text-on-surface and status-on-surface pair against WCAG AA while choosing values, not after the palette ships. Fixing contrast later means redesigning the palette.

Perceptual uniformity — why HSL is not enough

HSL is not perceptually uniform. hsl(60, 80%, 50%) (yellow) is visually much brighter than hsl(240, 80%, 50%) (blue) at the same lightness value. This matters when constructing multi-color status systems or data viz palettes.

Use OKLCH (now supported in all major browsers) for perceptual uniformity:

/* hsl yellow and blue at same lightness — VERY different visual weight */
color: hsl(60, 80%, 50%);   /* appears bright, almost white */
color: hsl(240, 80%, 50%);  /* appears much darker */

/* oklch — L is perceptually even */
color: oklch(65% 0.18 85);   /* yellow at true L=65 */
color: oklch(65% 0.18 265);  /* blue at same visual L */

When building semantic color scales (danger-100 through danger-900), use OKLCH with fixed L steps. The result is a scale where every step has genuinely equal perceived contrast.

Simultaneous contrast — what colors do to each other

A neutral gray placed on a warm orange background will appear slightly blue. The same gray on a cool blue background will appear slightly orange. This is simultaneous contrast — adjacent hues shift each other's apparent temperature.

Practical rules:

  • Test color combinations together, not in isolation. A token that passes contrast in a white mockup may fail perceptually on a tinted surface.
  • Warm accent on a cool neutral background has maximum pop. Warm on warm competes.
  • Muted colors on white look desaturated. The same muted color on a slightly dark surface looks rich. Design for the actual background.
  • Status green on a dark blue background needs higher chroma than status green on a white background to read as clearly "positive."

Hue, lightness, chroma — three levers, not one

Most LLM-generated palettes only move one lever (lightness) when building a scale. A designed scale moves all three:

  • Lightness: primary scale lever — light to dark
  • Chroma (saturation): high at mid-lightness, lower at extremes. The purest version of any hue is never at its lightest or darkest. It's at the midpoint. (oklch(55% 0.22 H) is more vivid than oklch(90% 0.22 H))
  • Hue: slight shift along the scale is acceptable and often desirable — a scale that "twists" slightly toward a neighboring hue at extremes is richer than a pure-hue scale.

Worked example — full palette from one hue

Brand origin: oklch(55% 0.18 250) — a medium-saturated blue.

Why this origin? L=55% sits at the perceptual midpoint where chroma peaks. C=0.18 is vivid but not neon. H=250 is a true blue that avoids the purple lean of H=265+ and the cyan lean of H=230−.

Neutrals

Every neutral shares H=250 (the brand hue) with very low chroma — this tints them toward blue so the palette reads as a family, not parts from different kits.

TokenOKLCHReasoning
--neutral-950oklch(12% 0.015 250)Near-black. L=12% is dark enough for text on light. C=0.015 gives a subtle blue tint without looking colored.
--neutral-800oklch(25% 0.015 250)Dark. Secondary text, heavy borders.
--neutral-600oklch(45% 0.012 250)Mid. Placeholder text, disabled states. C drops slightly — high chroma at mid-L would look like a blue, not a gray.
--neutral-400oklch(65% 0.010 250)Light. Dividers, subtle borders.
--neutral-200oklch(85% 0.008 250)Near-white. Hover backgrounds, secondary surfaces. C continues to drop — at high L even small chroma is perceptible.
--neutral-50oklch(97% 0.005 250)Page background. Nearly white, barely blue.

Key pattern: as L increases, C decreases. High-L + high-C reads as a pastel blue, not a tinted white.

Surface hierarchy

Surfaces use the same H=250 with minimal chroma. The L values create depth through lightness contrast.

TokenOKLCHRole
--surface-baseoklch(97% 0.005 250)Default page background.
--surface-elevatedoklch(99% 0.003 250)Cards, popovers — floats above base.
--surface-depressedoklch(93% 0.008 250)Input fields, wells — sits below base. Slightly higher C to feel recessed.
--surface-overlayoklch(99% 0.003 250 / 0.9)Modal/dialog backdrop with transparency.

Status colors — hue-rotated neighbors

Starting from brand H=250, rotate to semantically meaningful hues. Keep L≈55% so all status colors have equal visual weight. Adjust C per hue — some hues (green, yellow) have wider gamut at their peak and can carry more chroma.

TokenOKLCHHue math
--status-successoklch(55% 0.17 155)H=250 → −95° = 155. Green. C=0.17 (green peaks wide, can stay high).
--status-warningoklch(55% 0.16 85)H=250 → −165° = 85. Warm amber. C=0.16 (yellow-range hues are perceptually loud, slightly lower C).
--status-erroroklch(55% 0.20 25)H=250 → −225° = 25. Red-orange. C=0.20 (red is the alarm signal, maximum chroma).
--status-infooklch(55% 0.14 220)H=250 → −30° = 220. Slightly cooler blue. C=0.14 (lower than brand — info should not compete with actions).

Why not H=0 for error? Pure red in OKLCH (H=25–30) reads more urgently than H=0 which leans pink. The +5° shift toward orange increases alarm perception.

Brand accent

TokenOKLCHRole
--accent-actionoklch(55% 0.22 250)Primary CTA. Same L and H as brand, but C pushed to 0.22 — near the sRGB gamut edge for this hue; verify it renders in-gamut.
--accent-hoveroklch(48% 0.22 250)Hover state. Darken by dropping L, keep C. Never brighten on hover — darkening signals "pressing in."
--accent-subtleoklch(90% 0.06 250)Tinted background for selected/active states. High L, low C — a whisper of the brand.

Copy-ready CSS custom properties

:root {
  /* --- Neutrals --- */
  --neutral-950: oklch(12% 0.015 250);
  --neutral-800: oklch(25% 0.015 250);
  --neutral-600: oklch(45% 0.012 250);
  --neutral-400: oklch(65% 0.010 250);
  --neutral-200: oklch(85% 0.008 250);
  --neutral-50:  oklch(97% 0.005 250);

  /* --- Surfaces --- */
  --surface-base:      oklch(97% 0.005 250);
  --surface-elevated:  oklch(99% 0.003 250);
  --surface-depressed: oklch(93% 0.008 250);
  --surface-overlay:   oklch(99% 0.003 250 / 0.9);

  /* --- Status --- */
  --status-success: oklch(55% 0.17 155);
  --status-warning: oklch(55% 0.16 85);
  --status-error:   oklch(55% 0.20 25);
  --status-info:    oklch(55% 0.14 220);

  /* --- Brand accent --- */
  --accent-action:  oklch(55% 0.22 250);
  --accent-hover:   oklch(48% 0.22 250);
  --accent-subtle:  oklch(90% 0.06 250);
}

Semantic color scale generation (100–900)

A 9-step semantic scale encodes severity/weight. The formula uses OKLCH with fixed L steps, a C curve that peaks at 500, and optional H twist.

The formula

For step N (100, 200, ... 900):
  L = 95% − (N/100 − 1) × 9.375%     → L₁₀₀=95%, L₅₀₀=57.5%, L₉₀₀=20%
  C = C_max × bell(N)                  → peaks at 500, tapers at extremes
  H = H_base ± (N − 500)/100 × twist  → optional ±5° drift across the scale

Why bell-curve chroma? At high L (100–200), high C produces pastels that look weak. At low L (800–900), high C looks neon-in-darkness. The strongest expression of any hue is at perceptual midpoint (L≈55%), so C should peak there.

Why H twist? A slight hue shift across a scale adds richness. For danger/red: light steps can lean slightly orange (warmer, less severe), dark steps can lean slightly crimson (cooler, more intense). Keep the twist under ±5° or the scale loses coherence.

Full example: danger/red scale (H=25)

:root {
  /* Danger scale — base hue H=25 (red-orange), twist ±3° */
  --danger-100: oklch(95.0% 0.04 28);  /* faint blush background    */
  --danger-200: oklch(85.6% 0.08 27);  /* subtle highlight          */
  --danger-300: oklch(76.2% 0.13 26);  /* light badge background    */
  --danger-400: oklch(66.9% 0.17 25);  /* approaching full strength */
  --danger-500: oklch(57.5% 0.20 25);  /* THE danger color — peak C */
  --danger-600: oklch(48.1% 0.18 25);  /* text on light surfaces    */
  --danger-700: oklch(38.8% 0.14 24);  /* heavy emphasis            */
  --danger-800: oklch(29.4% 0.09 23);  /* dark badge, dark text     */
  --danger-900: oklch(20.0% 0.05 22);  /* near-black danger accent  */
}

Reading the scale:

  • L drops linearly from 95% to 20% — each step is ≈9.4% darker.
  • C: 0.04 → peaks at 0.20 (step 500) → 0.05. The exact values are hand-tuned per hue, not computed — verify each step renders in-gamut.
  • H: 28 → 22, drifting −3° from warm-side to cool-side. Light danger leans orange (approachable); dark danger leans crimson (severe).

Applying the formula to any hue

To generate a scale for any semantic color:

  1. Pick the base hue H and peak chroma C_max (test in browser — some hues clip at high C in sRGB).
  2. Apply the L/C/H formula above.
  3. Verify 500 against your brand: it should read as "the" color.
  4. Verify 100 as a tinted background and 900 as near-black text — both should still read as belonging to the hue family.

Dark mode — OKLCH lightness inversion

Dark mode is not "invert lightness." It is a deliberate re-mapping of the L axis with chroma compensation.

Why mechanical inversion fails

If light mode --surface-base is oklch(97% ...), a naive inversion gives oklch(3% ...) — pure black. This is wrong for three reasons:

  1. Contrast asymmetry: The eye adapts differently to dark backgrounds. Dark surfaces need L=12–18%, not L=3–5%. True black (L<5%) causes halation — bright text on pure black appears to bleed/glow.
  2. Chroma hunger: Dark surfaces absorb color. A subtle blue tint (C=0.005) that reads clearly at L=97% becomes invisible at L=12%. Boost C by 1.5–2× in dark mode to maintain the same perceived tint.
  3. Weber's law: Perceived contrast is a ratio, not a difference. Light-on-dark needs wider absolute L gaps between surface levels than dark-on-light.

The transformation rules

DARK MODE TRANSFORM:
  L_dark = 100% − L_light           → base inversion
  Then compress toward center:
    If L_dark < 20%  → lift to 12–18%   (no true blacks)
    If L_dark > 90%  → cap at 90–93%    (no true whites)
  C_dark = C_light × 1.5 to 2.0     → compensate chroma hunger
  H_dark = H_light                   → hue stays constant

Surface tokens: light → dark

TokenLight modeDark modeTransformation notes
--surface-baseoklch(97% 0.005 250)oklch(14% 0.010 250)L: 97→14 (not 3). C: doubled to stay visible.
--surface-elevatedoklch(99% 0.003 250)oklch(18% 0.008 250)Elevated is lighter than base in both modes. Gap widens in dark (4% vs 2%) because Weber.
--surface-depressedoklch(93% 0.008 250)oklch(10% 0.014 250)Depressed is darker than base in both modes. C bumped more — recessed surfaces in dark mode lose tint fast.
--surface-overlayoklch(99% 0.003 250 / 0.9)oklch(22% 0.010 250 / 0.95)Higher opacity in dark mode — the dark background bleeds through less at 0.9.

Key insight: elevated surfaces are always lighter than base — in both modes. The absolute L values invert, but the relative hierarchy is preserved.

Neutral tokens: light → dark

TokenLight modeDark modeNotes
--neutral-950oklch(12% 0.015 250)oklch(93% 0.008 250)Was near-black text → now near-white text. C drops at high L.
--neutral-800oklch(25% 0.015 250)oklch(82% 0.010 250)Secondary text.
--neutral-600oklch(45% 0.012 250)oklch(62% 0.012 250)Mid gray — barely moves. This is correct: mid-tones converge.
--neutral-400oklch(65% 0.010 250)oklch(40% 0.014 250)Subtle borders — need more C in dark to stay visible.
--neutral-200oklch(85% 0.008 250)oklch(22% 0.014 250)Hover backgrounds.

Status colors: light → dark

Status colors keep the same H but shift L and boost C so they read with equal urgency on dark surfaces.

TokenLight modeDark modeNotes
--status-successoklch(55% 0.17 155)oklch(62% 0.22 155)Lighter + more vivid. Green absorbs into dark surfaces — needs aggressive C boost.
--status-warningoklch(55% 0.16 85)oklch(65% 0.20 85)Yellow-range hues are already perceptually loud; moderate L lift + C boost.
--status-erroroklch(55% 0.20 25)oklch(62% 0.24 25)Red reads urgently in both modes but needs C push to avoid looking muted on dark.
--status-infooklch(55% 0.14 220)oklch(62% 0.18 220)Cool blue on dark blue surface — needs L lift to separate from background.

Pattern: Status colors shift L up by +7–10% and boost C by +0.04–0.06 in dark mode. They do NOT invert — they were already at perceptual midpoint (L≈55%), so inversion would make them light pastels, losing all urgency.

Copy-ready dark mode override

[data-theme="dark"] {
  /* --- Neutrals --- */
  --neutral-950: oklch(93% 0.008 250);
  --neutral-800: oklch(82% 0.010 250);
  --neutral-600: oklch(62% 0.012 250);
  --neutral-400: oklch(40% 0.014 250);
  --neutral-200: oklch(22% 0.014 250);
  --neutral-50:  oklch(14% 0.010 250);

  /* --- Surfaces --- */
  --surface-base:      oklch(14% 0.010 250);
  --surface-elevated:  oklch(18% 0.008 250);
  --surface-depressed: oklch(10% 0.014 250);
  --surface-overlay:   oklch(22% 0.010 250 / 0.95);

  /* --- Status --- */
  --status-success: oklch(62% 0.22 155);
  --status-warning: oklch(65% 0.20 85);
  --status-error:   oklch(62% 0.24 25);
  --status-info:    oklch(62% 0.18 220);

  /* --- Brand accent --- */
  --accent-action:  oklch(62% 0.22 250);
  --accent-hover:   oklch(68% 0.22 250);
  --accent-subtle:  oklch(20% 0.08 250);
}

Dark mode accent note: Action L lifts from 55→62% (needs to pop on dark surface). Hover goes lighter (L=68%) — on dark surfaces, lightening signals interaction (opposite of light mode where darkening does). Subtle drops to L=20% with more C to remain a perceptible tint.


Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.