better-colors
OKLCH color space for web projects. Convert hex/rgb/hsl to oklch, generate palettes, check contrast, handle gamut boundaries, and theme with Tailwind v4. Triggers on oklch, color conversion, palette generation, contrast ratio, gamut, display p3, design tokens, hue drift, chroma, dark mode colors.
Install
npx skills add https://github.com/jakubkrehel/skills --skill better-colorsToken naming
Naming is what makes a palette usable by anyone who did not build it. For which ramps exist and what each step does, see palette-structure.md [blocked].
Two tiers
Primitives name a value. They are the ramp, named by hue and step, as in --blue-600 or --neutral-200. A primitive describes what the color is, so it never changes meaning between themes and is never applied directly in a component.
Semantics name a job. They point at a primitive and take the name of the role they fill, as in --color-text-secondary or --color-border-subtle. Components only ever reference this tier.
The tiering is what makes theming possible. Dark mode, a white-label theme and an increased-contrast variant all repoint the semantic tier, leaving the primitives and every component untouched. A codebase applying --blue-600 directly in components has no theming seam. Adding one later means auditing every usage to work out which meant "the accent" and which just wanted blue.
Add a third, component-level tier (--color-button-danger-bg) only where a component genuinely and intentionally diverges from the system. One component token is a documented exception; twenty mean the semantic tier is missing roles.
The role inventory
A system is complete when every role below has a token. Build against this list rather than adding tokens as components demand them, or the palette ends up shaped like whichever screen came first.
Separator and border are separate roles even when they share a value today. A separator divides content; a border encloses a control. They diverge the first time someone restyles inputs, and a system that conflated them gets untangled at that moment.
Naming grammar
Use one shape and never deviate. Neutral roles lead with the property, as --color-{bg|text|border}-{variant}-{state}. Accent and status roles lead with the role, as --color-{accent|danger|…}-{subtle|border|solid|text}-{state}.
Pick one word per concept and use only that word. Consistency matters more than the vocabulary. A reader who has seen --color-text-primary must be able to guess --color-text-disabled without looking:
Reserve primary for exactly one meaning. --color-text-primary for body text beside --color-primary for the brand is the most common naming collision there is, and it makes every primary token ambiguous until you open its definition. Use accent for the brand and let primary mean "the most prominent of its group".
Anti-patterns
In Tailwind projects
Tailwind v4 generates utilities from @theme, so names declared there become the API. Declare primitives and semantics in the same block; the --color-* namespace is what produces bg-*, text-* and border-*:
That yields bg-accent-solid and text-text-secondary alongside bg-blue-600. Tailwind prefixes every --color-* name with the utility, so a text- token doubles. Accept the doubling, or follow the convention the project already uses. Both tiers are reachable, so the discipline is a convention rather than a constraint. Templates use the semantic utilities, and a raw bg-blue-600 in a component is the thing to flag.
Repoint semantics for dark mode in a .dark block outside @theme. Leave the block without inline, so utilities read the semantic variable at render time.
Opacity modifiers work on either tier, as in bg-accent-solid/50. But a color carrying alpha cannot be contrast-checked against a static background, because what it renders depends on what sits behind it. Use solid tokens for anything with text on it.