Skip to content

fix(palette): tints/shades mix with theme-aware tokens instead of literal white/black #24

Description

@jackgranatowski

Problem

optional/tokens.palette.css generates the numeric scale (-100..-900) by mixing brand colors with literal white (tints) and black (shades):

--sf-color-primary-200: color-mix(in oklch, var(--sf-color-primary) 20%, white);
--sf-color-primary-900: color-mix(in oklch, var(--sf-color-primary) 18%, black);

white and black are constants — they don't know anything about the active theme. In dark mode this means:

  • -200 resolves to a near-white color regardless of theme
  • -900 resolves to a near-black color regardless of theme

So a component using background: var(--sf-color-primary-200); color: var(--sf-color-primary-900) looks correct in light mode but produces a bright white patch on a dark page in dark mode.

Fix

Replace the literal anchors with theme-aware tokens:

  • Tints (-100..-400): mix with var(--sf-color-base) instead of white
  • Shades (-600..-900): mix with var(--sf-color-text) instead of black
--sf-color-primary-200: color-mix(in oklch, var(--sf-color-primary) 20%, var(--sf-color-base));
--sf-color-primary-900: color-mix(in oklch, var(--sf-color-primary) 18%, var(--sf-color-text));

Both --sf-color-base and --sf-color-text are already theme-aware via light-dark() in core/tokens.css, so the entire numeric scale automatically adapts to the active theme without any additional code.

Result

The same component rule works correctly in both modes:

.badge {
  background: var(--sf-color-primary-200); /* dark surface in dark mode, light in light mode */
  color:      var(--sf-color-primary-900); /* light text in dark mode, dark in light mode */
}

No manual dark mode overrides needed.

Additional

The --sf-color-base-* scale requires special treatment — mixing base with itself is meaningless. Instead it should ramp from base toward text, creating a theme-aware surface→foreground scale useful for stripes, dividers, and elevated panels.

Optionally, add Material 3-style semantic aliases as a convenience:

--sf-color-primary-container:    var(--sf-color-primary-100);
--sf-color-primary-on-container: var(--sf-color-primary-900);

These make the recommended idiom explicit and self-documenting.

Trade-off to be aware of

The numeric scale (-100 lightest, -900 darkest) holds in light mode but is inverted in dark mode-100 will be the darkest surface and -900 the lightest. This is a known trade-off of a single dynamic scale vs. two separate static palettes (as used by Tailwind, Radix, etc.). Worth documenting clearly for consumers.

Related

Identified while reviewing PR #18 — the core light-dark() architecture from that PR is already in main; this is the remaining palette fix worth applying independently.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions