Skip to content
Merged
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
26 changes: 26 additions & 0 deletions .changeset/oklch-color-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@hebilicious/cssforge": minor
---

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

`settings.color.formats` selects the formats and outputs: `hex` produces `"#ff7f50"`,
`"ff7f50"` and `0xff7f50`, `rgb` produces `"rgb(255 127 80)"` and `[255, 127, 80]`, and a
format set to `true` produces its CSS value only. Each format takes `alpha`: `true` keeps the
color's alpha, a number from 0 to 1 sets it, `false` rejects the color.

`settings.color.fallback` names the format whose `string` value becomes the declaration for
browsers without `oklch()` support, emitted after the root block inside
`@supports not (color: oklch(0% 0 0))` and mirroring the color's `atRule` and `selector`.
Without it, the first format that produces a CSS value is used, and `false` declares nothing.

A palette color's settings override the palette's per setting, and its `formats` merge per
format with `false` to remove one. The color settings are a closed schema: unknown keys, a
`formats` that is not an object, and color format settings on a gradient, a theme, or a level
that reads no settings throw with the configuration path. The settings types are exported
from the package entry.

Tokens gain a `color` object keyed by format and output, plus `gamutMapped: true` when a
format is generated for a color outside sRGB, mirrored by `attributes.color`, `$color` and
`$gamutMapped` in Style Dictionary. `cssforge --color-formats hex,rgb` adds formats for a run.
12 changes: 12 additions & 0 deletions .changeset/settings-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@hebilicious/cssforge": patch
---

Reject unknown, misplaced, and wrongly shaped settings in every module, not just in colors.

A setting no schema accepts generated nothing quietly: a misspelled key was ignored, a
`settings` on a primitive group or a gradient variant did nothing, and a `settings` that was
not an object silently disabled the px-to-rem conversion, so `8px` was emitted where `0.5rem`
was meant. Spacing, typography, primitives, gradients and themes now report the configuration
path, the unknown key, and the keys the level accepts. The `settings` fields no module reads
are gone from `Primitive` and the gradient variant type.
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ standard library or writing code from first principles.
- Write new core package, test, and tooling code in TypeScript. Avoid `any` and `@ts-ignore`;
framework examples may retain configuration formats required by their ecosystems.

## Configuration settings

Every module takes a `settings` object beside its `value`.

- A grouped topic key holds settings that belong together, as `settings.color` does. A single
concern may stay flat, as spacing does with `settings.pxToRem`.
- A setting is added to the exported type with the JSDoc the README renders, and read at
runtime with the path it was written at. Unknown keys, wrong value shapes, and settings
written on a level that does not read them are rejected: a JavaScript config is not
protected by the types.
- A color's settings override the palette's per setting. A list setting such as `formats`
merges per key and takes `false` to remove an entry.
- The README section for the module says whether a setting reaches the tokens or the CSS. A
CLI flag is an adapter over the same resolution, not a second implementation.

## Updating the README.md

To update the README, run `moon run cssforge:readme-update` from the workspace root.
Expand Down
137 changes: 137 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ 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 when formats are configured, on the palette or the color, or added by `colorFormats` |
| `gamutMapped` | `true` when the color is outside sRGB | Knowing which colors the browser paints differently from the authored value. Absent inside sRGB, and absent when no format is generated |

A level with one child is collapsed, so `palette: { value: { coral: ... } }` becomes
`cssForge.palette.coral`. Numeric and `@` keys stay strings:
Expand Down Expand Up @@ -387,6 +389,10 @@ Both are valid CSS. The first follows the active theme; the second is a snapshot

## Configuration

Every module holds its tokens under `value` and its options under `settings`. A setting no
schema accepts is rejected with its configuration path, so a misspelled key cannot quietly
generate nothing.

### Colors

Define colors in any format - they'll be automatically converted to OKLCH. You can compose
Expand Down Expand Up @@ -658,6 +664,127 @@ 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, which a browser without `oklch()` support cannot
render. Set `formats` to generate sRGB values alongside it, and `fallback` to declare one of
them for those browsers:

<!-- 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. A color with alpha carries it
(`#ff7f50aa`, `0xff7f50aa`, `rgb(255 127 80 / 0.667)`, `[255, 127, 80, 0.667]`) unless the
format sets `alpha`: `true` keeps it, a number from 0 to 1 sets it, `false` rejects the color.

The declaration is the `string` value of `fallback`, or of the first format that has one,
gated by `@supports not (color: oklch(0% 0 0))` and mirroring the color's `atRule` and
`selector`. `fallback: false` declares nothing. It is emitted with the palette, before
gradient and theme blocks, so a later declaration still wins.

A color's `formats` merge into the palette's per format, and `false` removes one. Tokens carry
every generated value in `color`, under their format and output:

```json
"color": {
"hex": { "string": "#ff7f50", "number": 16744272 },
"rgb": { "array": [255, 127, 80] }
}
```

A color outside sRGB is gamut mapped for its sRGB values, and its token carries
`gamutMapped: true` so the mapping is visible.

The palette is the only family that converts the colors it is given, so it is the only one
that generates formats. Themes and gradients keep their authored values.

#### Condition

You can conditionnally apply colors, gradients or themes by setting the `atRule` or the
Expand Down Expand Up @@ -1252,6 +1379,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 All @@ -1264,6 +1394,9 @@ import { generateCSS, generateStyleDictionaryJSON } from "@hebilicious/cssforge"
// Generate CSS string
const css = generateCSS(config);

// Add sRGB formats for this run instead of editing the config
const withFormats = generateCSS(config, { colorFormats: ["hex", "rgb"] });

// Write final values for Style Dictionary
const resolvedTokens = generateStyleDictionaryJSON(config);

Expand Down Expand Up @@ -1326,7 +1459,11 @@ 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 |
| `attributes.gamutMapped` | `true` when the color is outside sRGB and a format is generated | Knowing which tokens were gamut mapped |
| `$gamutMapped` | The same flag as a top-level field | Tools that read `$gamutMapped` |

`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.
4 changes: 4 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,10 @@ export default defineConfig({
},
},
},
settings: {
// A hex value for browsers without oklch(); see tests/oklch-fallback.spec.ts.
color: { formats: { hex: true } },
},
},
theme: {
light: {
Expand Down
98 changes: 98 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,98 @@
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";

/** The generated `@supports` block, read through the browser's own CSSOM. */
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 served 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.
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: same declarations, selector and cascade position.
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