Skip to content

feat(colors): generate palette colors in extra sRGB formats - #74

Open
Hebilicious wants to merge 7 commits into
mainfrom
feat/oklch-color-fallback
Open

Hebilicious wants to merge 7 commits into
mainfrom
feat/oklch-color-fallback

Conversation

@Hebilicious

@Hebilicious Hebilicious commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

Palette colors are generated in OKLCH, and a custom property accepts any token stream, so a browser without oklch() support still parses --palette-coral-100: oklch(...) and fails only when the value is used as a color. This generates the same colors in sRGB formats, exposes them to non-CSS consumers, and lets you pick which one the CSS declaration uses.

Config

colors: {
  palette: {
    value: {
      coral: { 100: { hex: "#FF7F50" } },
    },
    settings: {
      color: {
        formats: {
          hex: { string: true, digits: true, number: true, alpha: false },
          rgb: { string: true, array: true, alpha: 0.5 },
        },
        fallback: "hex",
      },
    },
  },
}
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 produces its CSS value only. A color with alpha carries it in every output: #ff7f50aa, ff7f50aa, 0xff7f50aa, rgb(255 127 80 / 0.667), [255, 127, 80, 0.667].

Each format takes its own 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 that format. The oklch() value keeps the alpha the color carries.

fallback names the format whose string value, including its alpha policy, becomes the CSS declaration. It defaults to the first generated format, and false emits no declaration so the formats only reach the tokens.

The palette settings cover every color and a color replaces them in its own settings, per field, so a color that only names a fallback keeps the palette's formats.

Generated CSS

:root {
  --palette-coral-100: oklch(73.511% 0.16799 40.24666);
}
@supports not (color: oklch(0% 0 0)) {
  :root {
    --palette-coral-100: #ff7f50;
  }
}

A duplicate declaration in the root block would not work: a custom property accepts any token stream, so neither declaration is dropped and the modern value wins everywhere. The declaration is emitted after the root block, gated by @supports not (color: oklch(0% 0 0)), and wrapped in the color's own atRule and selector, so it only overrides the declaration it stands in for. The block is not nested inside the root rule, because a browser without oklch() support predates CSS nesting and would drop a nested at-rule along with it.

A color outside sRGB uses the CSS gamut mapping algorithm, which is how a browser maps a color its display cannot show, instead of clipping each channel.

Token outputs

{
  "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] }
  }
}

Style Dictionary carries the same object in attributes.color and $color. generateCSS, generateJSON, generateTS and generateStyleDictionaryJSON accept { colorFormats }, following the existing generateStyleDictionaryJSON(config, { valueMode }) shape.

CLI

cssforge --color-formats hex,rgb

Appends formats to the configuration's, each generating its CSS value, without editing the config. The CLI reports an unknown format with the accepted ones.

Validation

An unknown format, an output a format does not produce, a format with no output enabled, a fallback that is not generated or has no string output, an alpha that is not a boolean or an opacity, and a color setting written outside settings.color all throw with the configuration path. Misplaced keys are rejected rather than ignored: color in the wrong place, formats outside color, and so on.

Scope

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 use the palette value through the var(--palette-...) references they compose with.

Verification

  • moon ci (format, typecheck, tests, docs build and sync, README check, JSR smoke, pack, package smoke test)
  • moon run cssforge:test --force: 20 files, 128 tests
  • moon run vanilla-react-css:e2e: 7 specs, including the browser spec that reads the gate from the served stylesheet through the CSSOM, asserts it stays inert in Chromium, which supports oklch(), and opens the gate on the generated rule to observe the fallback winning the cascade
  • The built dist/cli.js was exercised end to end with a config that generates all five outputs and selects rgb as the fallback
  • Mutation checks: each retained test fails when its behavior is broken (no block emitted, alpha dropped from a value or an array, hex outputs dropped, named fallback ignored, fallback: false ignored, a format alpha not read, a format alpha not applied, alpha: false not enforced outside a format, the declaration ignoring the fallback format's alpha, a foreign output accepted, an empty format accepted, settings not inherited, and the earlier rounds' checks)

Review

Both review axes ran independently on the change set: 0 blockers. Their findings were addressed in follow-up commits, and the API was redesigned after review feedback: the format list became per-format outputs, the fallback became an explicit choice instead of list order, and alpha became a policy.

Known pre-existing limitation, not a regression: an atRule-only palette color is emitted nested inside the generated :root block, so a browser with oklch() but without CSS nesting (Chrome 111-119, Safari 15.4-16.4, Firefox 113-116) drops that declaration, and the fallback gate is false there. Changing it means changing where those declarations are emitted, with its own snapshot update.

Palette colors are generated in OKLCH, and a custom property accepts any token
stream, so a browser without `oklch()` support still parses the declaration and
fails only when the value is used as a color. A duplicate declaration in the root
block cannot help, because the modern value keeps winning there.

Add `fallback` to the palette settings, and to a single color's settings:

- `"hex"` writes `#rrggbb`, or `#rrggbbaa` for a color with alpha;
- `"rgb"` writes `rgb(r g b)`, or `rgb(r g b / a)` for a color with alpha;
- `false` opts a color out of a fallback inherited from the palette.

Each fallback is emitted after the generated root block, gated by
`@supports not (color: oklch(0% 0 0))` and wrapped in the color's own `atRule`
and `selector`, so it only overrides the declaration it stands in for. The block
is not nested in the root rule, because a browser without `oklch()` support
predates CSS nesting and would drop it. A color outside sRGB uses the CSS gamut
mapping algorithm, which is how a browser maps a color its display cannot show.

The JSON and TypeScript token objects gain a `fallback` field, and the Style
Dictionary output gains `attributes.fallback` and `$fallback`, so a non-CSS
consumer reads the sRGB value without converting the color itself.

An unsupported format now throws with the configuration path instead of
generating no fallback silently.

Tests cover the generated CSS, the wrapper mirroring, the alpha forms, the
output fields and the rejection, and the browser suite reads the gate from the
served stylesheet, checks it stays inert where `oklch()` is supported, and opens
it to observe the fallback winning the cascade.
Themes, gradients and primitives keep their authored values, and they use the
palette fallback through the var(--palette-...) references they compose with,
so the setting only exists on the palette and on a single palette color.
Standards review findings:

- One table now owns the fallback formats: the serializers are a
  `Record<ColorFallbackFormat, ...>`, and `isFallbackFormat` reads its keys, so
  a new format cannot be accepted by validation without a serializer to emit it
  and the `hex`/`rgb` dispatch no longer falls through.
- `resolveWrapperChain` is the single normalization of `atRule` and `selector`.
  `describeScope` and the fallback emitter both consume it, so the scope that
  feeds collision detection and the chain a fallback mirrors cannot drift apart.
- The README tables said the token `fallback` appears when the palette sets it,
  but a per-color setting emits it on its own. Both rows now say the field is
  present when `fallback` is configured on the palette or on the color.
@changeset-bot

changeset-bot Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9b875ff

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@hebilicious/cssforge Minor
@hebilicious/cssforge-unplugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
cssforge 9b875ff Commit Preview URL

Branch Preview URL
Sep 25 2026, 08:46 AM

Standards review findings on the wrapper chain and the runtime validation:

- `readCondition` is now the single reading of `atRule` and `selector`, and
  `conditionalBuilder` consumes it for presence and emission. A whitespace-only
  selector previously produced an invalid selector block holding the modern
  declaration while the fallback landed in `:root`; both now agree, and a padded
  wrapper is normalized in both places.
- The fallback validation reads the palette or the color entry as a whole, so a
  `fallback` placed beside `value` instead of inside `settings`, and a `settings`
  that is not an object, are rejected with the configuration path instead of
  generating no fallback silently. A color written in the shorthand form is left
  alone, because its keys are variant names and `fallback` is a legal one.
- The accepted formats in the error message come from the serializer table, so
  the message cannot list fewer formats than the implementation accepts.
Review of the setting name and a request to generate the formats from the CLI
without editing the configuration:

- `settings: { fallback: "hex" }` becomes `settings: { color: { formats: [...] } }`,
  which says what the setting controls. A color overrides the palette list with the
  same key, and `[]` or `false` opts out of an inherited one.
- A CSS declaration holds one value, so the first configured format is the
  declaration emitted under `@supports not (color: oklch(0% 0 0))`; every
  requested format reaches the JSON, TypeScript and Style Dictionary tokens as a
  `color` object keyed by format (`attributes.color` and `$color` in Style
  Dictionary).
- `cssforge --color-formats hex,rgb` adds formats for a run, appended to the
  configuration's list, so a caller can enrich the token outputs without
  touching the config. The CLI validates the list and reports an unknown format
  with the accepted ones.
- `generateCSS`, `generateJSON`, `generateTS` and `generateStyleDictionaryJSON`
  accept `{ colorFormats }`, following the existing
  `generateStyleDictionaryJSON(config, { valueMode })` shape.

The nested setting is validated as a whole: an unknown format, a `formats` that
is not an array, and a `color` or `formats` written outside `settings` are
rejected with the configuration path instead of generating nothing silently. A
shorthand color entry is left alone, because its keys are variant names and
`color` is a legal one.
@Hebilicious Hebilicious changed the title feat(colors): emit an sRGB fallback for palette colors feat(colors): generate palette colors in extra sRGB formats Sep 24, 2026
The format list was too coarse: it generated one representation per format and
picked the CSS declaration by list order. `settings.color` now describes the
outputs themselves.

- `formats` is keyed by format. `hex` produces `string` ("#ff7f50"), `digits`
  ("ff7f50") and `number` (0xff7f50, in RGBA byte order when the color has
  alpha); `rgb` produces `string` ("rgb(255 127 80)") and `array`
  ([255, 127, 80], with a fourth element holding the alpha). A format set to
  `true` produces its CSS value only, so the short form stays short.
- `fallback` names the format whose `string` value becomes the CSS declaration,
  defaults to the first generated format, and `false` emits no declaration so
  the formats only reach the token outputs.
- `alpha` keeps the color's alpha (`true`), replaces it (`0`-`1`, everywhere
  including the `oklch()` value, so a declaration and its fallback cannot
  disagree), or rejects a color that carries one (`false`).

A color's `settings.color` replaces the palette's per field, so a color that
only names a fallback keeps the palette's formats.

The token outputs carry the generated values in a `color` object keyed by format
and output, for example
`{ hex: { string: "#ff7f50", number: 16744272 }, rgb: { array: [255, 127, 80] } }`,
and the Style Dictionary output mirrors it in `attributes.color` and `$color`.

Validation rejects an unknown format, an output a format does not produce, a
format with no output enabled, a fallback that is not generated or has no string
output, an alpha that is not a boolean or an opacity, and a color setting written
outside `settings.color`.
`alpha` was a color setting, so one policy applied to every format and to the
`oklch()` value. It belongs to the format whose alpha it describes:

- `formats.hex.alpha` and `formats.rgb.alpha` take `true` (the default, keeping
  the alpha the color carries), a number between 0 and 1 to generate that format
  at that opacity, or `false` to reject a color that carries alpha and drop the
  alpha from that format.
- The `oklch()` value keeps the alpha the color carries, and the CSS declaration
  uses the alpha of the format it falls back to.
- An `alpha` written next to `formats` is rejected with the path that holds it,
  and a format that cannot represent a color's alpha fails before generation
  instead of skipping the color with a log line.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant