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.
Problem
optional/tokens.palette.cssgenerates the numeric scale (-100..-900) by mixing brand colors with literalwhite(tints) andblack(shades):whiteandblackare constants — they don't know anything about the active theme. In dark mode this means:-200resolves to a near-white color regardless of theme-900resolves to a near-black color regardless of themeSo 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:
-100..-400): mix withvar(--sf-color-base)instead ofwhite-600..-900): mix withvar(--sf-color-text)instead ofblackBoth
--sf-color-baseand--sf-color-textare already theme-aware vialight-dark()incore/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:
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 frombasetowardtext, creating a theme-aware surface→foreground scale useful for stripes, dividers, and elevated panels.Optionally, add Material 3-style semantic aliases as a convenience:
These make the recommended idiom explicit and self-documenting.
Trade-off to be aware of
The numeric scale (
-100lightest,-900darkest) holds in light mode but is inverted in dark mode —-100will be the darkest surface and-900the 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 inmain; this is the remaining palette fix worth applying independently.