Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/oklch-color-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@hebilicious/cssforge": minor
---

Generate palette colors in extra sRGB formats alongside `oklch()`, so a browser without
`oklch()` support still renders them, and so non-CSS consumers read the value they need.

`settings.color.formats` selects the formats and the exact outputs to generate. `hex`
produces its CSS value `"#ff7f50"`, the digits `"ff7f50"`, and the number `0xff7f50`; `rgb`
produces `"rgb(255 127 80)"` and `[255, 127, 80]`. A format set to `true` produces its CSS
value only, so `{ hex: true }` stays the short form.

`settings.color.fallback` names the format whose `string` value is the declaration emitted
for browsers without `oklch()` support, after the root block and inside
`@supports not (color: oklch(0% 0 0))`, mirroring the color's `atRule` and `selector`. It
defaults to the first generated format, and `false` emits no declaration. A duplicate
declaration in the same block would not have worked: a custom property accepts any token
stream, so the modern value wins everywhere, including where `oklch()` cannot be used. A
color outside sRGB uses the CSS gamut mapping algorithm, which is how a browser maps a color
its display cannot show.

Each format also takes an `alpha` policy: `true` (the default) keeps the alpha the color
carries, a number between 0 and 1 generates that format at that opacity, and `false` rejects
a color that carries alpha and drops the alpha from the output. The `oklch()` value keeps the
alpha the color carries, so a format that sets an alpha is generated at that opacity alone.

The JSON and TypeScript token objects gain a `color` object keyed by format and output, such
as `{ hex: { string: "#ff7f50", number: 16744272 }, rgb: { array: [255, 127, 80] } }`, and
the Style Dictionary output gains `attributes.color` and `$color`.

`cssforge --color-formats hex,rgb` generates the formats for a run at the CLI level, appended
to the ones the configuration declares.
146 changes: 146 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ The TypeScript and JSON outputs hold the same nested tree. Every leaf is one tok
| `key` | The CSS custom property, such as `--palette-coral-100` | Building a `var()` string, or looking a token up by name |
| `value` | The CSS value, such as `oklch(...)`, `0.5rem`, or `clamp(...)` | Passing a color, a length, or a font size to anything that accepts CSS |
| `variable` | The full declaration, such as `--palette-coral-100: oklch(...);` | Injecting a declaration into a style tag or a shadow root |
| `color` | The generated formats, such as `{ "hex": { "string": "#ff7f50" }, "rgb": { "array": [255, 127, 80] } }` | Reading a palette color as a legacy value without converting it. Present only when `settings.color.formats` is configured, on the palette or on the color |

A level with one child is collapsed, so `palette: { value: { coral: ... } }` becomes
`cssForge.palette.coral`. Numeric and `@` keys stay strings:
Expand Down Expand Up @@ -658,6 +659,146 @@ Custom property references are substituted when the alias is computed, before in
leaves `--primary` invalid at computed-value time, and every `var(--primary, fallback)`
reference uses its fallback.

#### Color formats for browsers without oklch

Palette colors are generated in OKLCH. A custom property accepts any token stream, so a
browser without `oklch()` support still parses `--palette-coral-100: oklch(...)` and only
fails when the value is used as a color. Set `formats` to generate the same color in sRGB
formats, so the unsupported browser keeps a usable color and non-CSS consumers read the
value they need:

<!-- md:generate defineConfig
export default defineConfig({
colors: {
palette: {
value: {
coral: { 100: { hex: "#FF7F50" } },
coralDark: {
value: { 100: { hex: "#FF6347" } },
settings: { atRule: "@media (prefers-color-scheme: dark)" },
},
},
settings: {
color: {
formats: {
hex: { string: true, digits: true, number: true },
rgb: { string: true, array: true },
},
fallback: "hex",
},
},
},
},
});
-->

```typescript
export default defineConfig({
colors: {
palette: {
value: {
coral: { 100: { hex: "#FF7F50" } },
coralDark: {
value: { 100: { hex: "#FF6347" } },
settings: { atRule: "@media (prefers-color-scheme: dark)" },
},
},
settings: {
color: {
formats: {
hex: { string: true, digits: true, number: true },
rgb: { string: true, array: true },
},
fallback: "hex",
},
},
},
},
});
```

This will generate the following CSS :

```css
/*____ CSSForge ____*/
:root {
/*____ Colors ____*/
/* Palette */
/* coral */
--palette-coral-100: oklch(73.511% 0.16799 40.24666);
/* coralDark */
@media (prefers-color-scheme: dark) {
--palette-coralDark-100: oklch(69.622% 0.19552 32.32143);
}
}
@supports not (color: oklch(0% 0 0)) {
:root {
/* coral */
--palette-coral-100: #ff7f50;
}
}
@media (prefers-color-scheme: dark) {
@supports not (color: oklch(0% 0 0)) {
:root {
/* coralDark */
--palette-coralDark-100: #ff6347;
}
}
}
```

<!-- /md:generate -->

| Format | Output | Value |
| --- | --- | --- |
| `hex` | `string` | `"#ff7f50"` |
| `hex` | `digits` | `"ff7f50"` |
| `hex` | `number` | `16744272` (`0xff7f50`) |
| `rgb` | `string` | `"rgb(255 127 80)"` |
| `rgb` | `array` | `[255, 127, 80]` |

A format set to `true` generates its CSS value (`string`). A color with alpha carries it in
every output: `#ff7f50aa`, `ff7f50aa`, `0xff7f50aa`, `rgb(255 127 80 / 0.667)` and
`[255, 127, 80, 0.667]`.

Every format also takes an `alpha` policy: `true` (the default) keeps the alpha the color
carries, a number between 0 and 1 generates that format at that opacity, and `false` rejects
a color that carries alpha and drops the alpha from the output. The `oklch()` value keeps the
alpha the color carries, so a format that sets an alpha is generated at that opacity alone.

`fallback` names the format whose `string` value, including its alpha policy, becomes the
declaration for browsers without `oklch()` support. It defaults to the first generated
format, has to be generated with its `string` output, and `false` emits no declaration so the
formats only reach the tokens. The declaration is gated by
`@supports not (color: oklch(0% 0 0))` and emitted after the root block, because a custom
property accepts any token stream and the later declaration wins wherever the modern value is
unsupported. It mirrors the color's `atRule` and `selector`, so it only overrides the
declaration it stands in for.

The palette settings cover every color, and a color replaces them in its own `settings`, so a
color that only sets `fallback` keeps the palette's formats. The JSON, TypeScript and Style
Dictionary tokens carry the generated values in a `color` object, keyed by format and output:

```json
{
"key": "--palette-coral-100",
"value": "oklch(73.511% 0.16799 40.24666)",
"variable": "--palette-coral-100: oklch(73.511% 0.16799 40.24666);",
"color": {
"hex": { "string": "#ff7f50", "digits": "ff7f50", "number": 16744272 },
"rgb": { "string": "rgb(255 127 80)", "array": [255, 127, 80] }
}
}
```

Setting the palette formats needs the `settings` key next to `value`; a color that carries
settings is written with the `value` wrapper.

The palette is the only family that converts the colors it is given, so it is the only one
that generates these formats. Themes, gradients and primitives keep their authored values,
and they use the palette value through the `var(--palette-...)` references they already
compose with. An `oklch()` written directly into a theme or gradient value stays as it is.

#### Condition

You can conditionnally apply colors, gradients or themes by setting the `atRule` or the
Expand Down Expand Up @@ -1252,6 +1393,9 @@ cssforge --mode style-dictionary --style-dictionary ./dist/design-tokens.sd.json

# Keep CSS variables as values for usage matching
cssforge --mode style-dictionary --style-dictionary ./dist/design-tokens.sd.json --style-dictionary-value-mode css-reference

# Generate sRGB formats next to oklch for every palette color, added to the config's formats
cssforge --color-formats hex,rgb
```

## Programmatic Usage
Expand Down Expand Up @@ -1326,7 +1470,9 @@ the keys in the generated file, so consumers can connect a semantic token to its
| `attributes.cssVariable` | The token's CSS custom property, such as `--palette-neutral-900` | Declaring or overriding the token in CSS |
| `attributes.tailwindVariable` | The same custom property name, without the `var()` wrapper | Tools that match authored `var(--token)` usage to tokens |
| `attributes.resolvedValue` | The final value, even in `css-reference` mode | Showing a value without following references |
| `attributes.color` | The token's generated formats, when `settings.color.formats` is configured | Emitting a legacy-safe color for a token |
| `$resolvedValue` | The same final value as a top-level DTCG-style field | Tools that read `$resolvedValue` before falling back to `value` |
| `$color` | The same per-format values as a top-level field | Tools that read `$color` before converting the color themselves |

`type` narrows `fontSize`, `lineHeight`, `fontWeight`, `fontFamily`, `borderRadius`,
`letterSpacing`, `shadow`, `opacity`, `zIndex`, and `number` when the token's name and value
Expand Down
11 changes: 7 additions & 4 deletions example/vanilla-react-css/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,14 @@ them from `virtual:cssforge.css`. There is no pre-generation step to run.
moon run vanilla-react-css:e2e
```

The Playwright suite has three specs. `tests/e2e.spec.ts` validates CSS Forge variables on
The Playwright suite has four specs. `tests/e2e.spec.ts` validates CSS Forge variables on
`:root` and computed styles on `[data-testid="token-card"]`. The
`tests/theme-alias-scoping.spec.ts` spec covers theme alias scoping: with the `Another`
theme class on the root element the probe resolves to the `:root.Another` palette color,
while a descendant-only class leaves `--primary` invalid at computed-value time on `:root`
and the probe uses its fallback. The `tests/hmr.spec.ts` spec edits `cssforge.config.ts`
against the running dev server and asserts the browser picks up the new token value without
reloading the page.
and the probe uses its fallback. The `tests/oklch-fallback.spec.ts` spec reads the generated
stylesheet through the CSSOM and asserts the `@supports not (color: oklch(0% 0 0))` block
carries the sRGB fallback, stays inert in Chromium, which supports `oklch()`, and wins the
cascade once its gate is opened on the generated rule. The
`tests/hmr.spec.ts` spec edits `cssforge.config.ts` against the running dev server and
asserts the browser picks up the new token value without reloading the page.
7 changes: 7 additions & 0 deletions example/vanilla-react-css/cssforge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export default defineConfig({
},
},
},
settings: {
// Generate a hex value for every palette color, gated by
// `@supports not (color: oklch(0% 0 0))`, so the example keeps working in
// a browser without `oklch()` support. `tests/oklch-fallback.spec.ts`
// asserts the gate from the browser.
color: { formats: { hex: true } },
},
},
theme: {
light: {
Expand Down
105 changes: 105 additions & 0 deletions example/vanilla-react-css/tests/oklch-fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { expect, test } from "@playwright/test";

/** Written as `{ hex: "#1d4ed8" }` in `cssforge.config.ts`. */
const BRAND_PRIMARY_HEX = "#1d4ed8";

/** What Chromium computes for the fallback `#1d4ed8`, and not for the oklch value. */
const BRAND_PRIMARY_FALLBACK_RGB = "rgb(29, 78, 216)";

const FALLBACK_RULE = "--palette-brand-primary: #1d4ed8";

/** The generated condition, and the open condition that stands in for a browser without oklch. */
const GATED_CONDITION = "not (color: oklch";
const OPEN_CONDITION = "(color: oklch";

/**
* Reads the generated `@supports` block from the served stylesheet, so the
* promise is observed through the browser's own CSSOM rather than the source
* string the generator produced.
*/
const readFallbackRule = (page: import("@playwright/test").Page) =>
page.evaluate(() => {
const supportsRules = [...document.styleSheets].flatMap((styleSheet) =>
[...styleSheet.cssRules].filter(
(rule): rule is CSSSupportsRule => rule instanceof CSSSupportsRule,
),
);
const oklchRule = supportsRules.find((rule) => rule.conditionText.includes("oklch"));

return oklchRule
? { conditionText: oklchRule.conditionText, cssText: oklchRule.cssText }
: null;
});

const readBrandToken = (page: import("@playwright/test").Page) =>
page.evaluate(() =>
getComputedStyle(document.documentElement)
.getPropertyValue("--palette-brand-primary")
.trim(),
);

test("the oklch fallback is gated on missing support and stays inert where it is supported", async ({
page,
}) => {
await page.goto("/");

const fallbackRule = await readFallbackRule(page);
const browser = await page.evaluate(() => ({
supportsOklch: CSS.supports("color", "oklch(0% 0 0)"),
brandToken: getComputedStyle(document.documentElement)
.getPropertyValue("--palette-brand-primary")
.trim(),
brandColor: getComputedStyle(
document.querySelector('[data-testid="brand-probe"]') as Element,
).color,
}));

// The generated stylesheet carries the fallback for this color.
expect(fallbackRule?.conditionText).toBe("not (color: oklch(0% 0 0))");
expect(fallbackRule?.cssText).toContain(FALLBACK_RULE);

// Chromium supports oklch, so the negated condition keeps the block inert:
// the token still computes to the modern value, and the probe still renders
// in the modern color space instead of the fallback hex.
expect(browser.supportsOklch).toBe(true);
expect(browser.brandToken).not.toBe(BRAND_PRIMARY_HEX);
expect(browser.brandToken.startsWith("oklch(")).toBe(true);
expect(browser.brandColor.startsWith("oklch(")).toBe(true);
expect(browser.brandColor).not.toBe(BRAND_PRIMARY_FALLBACK_RGB);
});

test("the generated fallback applies when the gate is open", async ({ page }) => {
await page.goto("/");

const gated = await readBrandToken(page);

// Chromium cannot disable oklch support, so the gate is opened on the
// generated rule itself: the declarations, their selector and their position
// in the cascade are the ones a browser without oklch support would read.
const opened = await page.evaluate(
({ from, to }) => {
const rule = [...document.styleSheets]
.flatMap((styleSheet) => [...styleSheet.cssRules])
.find(
(candidate): candidate is CSSSupportsRule =>
candidate instanceof CSSSupportsRule &&
candidate.conditionText.includes("oklch"),
);
if (!rule) throw new Error("Missing the generated oklch @supports block");

const style = document.createElement("style");
style.textContent = rule.cssText.replace(from, to);
document.head.append(style);

return (
getComputedStyle(document.documentElement)
.getPropertyValue("--palette-brand-primary")
.trim() || null
);
},
{ from: GATED_CONDITION, to: OPEN_CONDITION },
);

expect(gated.startsWith("oklch(")).toBe(true);
expect(opened).toBe(BRAND_PRIMARY_HEX);
});
Loading
Loading