feat(colors): generate palette colors in extra sRGB formats - #74
Open
Hebilicious wants to merge 7 commits into
Open
Hebilicious wants to merge 7 commits into
Hebilicious wants to merge 7 commits into
Conversation
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 detectedLatest commit: 9b875ff The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
Deploying with
|
| 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
hexstring"#ff7f50"hexdigits"ff7f50"hexnumber16744272(0xff7f50)rgbstring"rgb(255 127 80)"rgbarray[255, 127, 80]A format set to
trueproduces 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
alphapolicy:true(the default) keeps the alpha the color carries, a number between 0 and 1 generates that format at that opacity, andfalserejects a color that carries alpha and drops the alpha from that format. Theoklch()value keeps the alpha the color carries.fallbacknames the format whosestringvalue, including its alpha policy, becomes the CSS declaration. It defaults to the first generated format, andfalseemits 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
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 ownatRuleandselector, so it only overrides the declaration it stands in for. The block is not nested inside the root rule, because a browser withoutoklch()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.colorand$color.generateCSS,generateJSON,generateTSandgenerateStyleDictionaryJSONaccept{ colorFormats }, following the existinggenerateStyleDictionaryJSON(config, { valueMode })shape.CLI
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
stringoutput, an alpha that is not a boolean or an opacity, and a color setting written outsidesettings.colorall throw with the configuration path. Misplaced keys are rejected rather than ignored:colorin the wrong place,formatsoutsidecolor, 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 testsmoon 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 supportsoklch(), and opens the gate on the generated rule to observe the fallback winning the cascadedist/cli.jswas exercised end to end with a config that generates all five outputs and selectsrgbas the fallbackfallback: falseignored, a format alpha not read, a format alpha not applied,alpha: falsenot 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:rootblock, so a browser withoklch()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.