feat(colors): sRGB formats, gamut flag and validated settings - #74
Merged
Merged
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: 97ae67c 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 | 97ae67c | Commit Preview URL Branch Preview URL |
Sep 30 2026, 02:23 PM |
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.
Evaluation of the settings API found four gaps; this closes them. - Unknown keys are rejected wherever a schema is closed: the palette, a color entry, `settings`, `settings.color` and each format entry. TypeScript misses some of these shapes, so before this a misspelled key generated nothing quietly. Color format settings written on a gradient or a theme are rejected too, with the path and a pointer at the palette. - A color's `formats` merge into the palette's per format instead of replacing them, so adding an output to one format no longer has to restate the others, and `false` removes an inherited format. A disabled format no longer reaches a token as an empty object. - The CSS declaration defaults to the first format that produces a CSS value. The old rule took the first generated format, so a config whose first format only produced digits or a number threw and needed `fallback: false` to work at all. A generation with no CSS value now emits no declaration, and a named fallback that cannot be used reports the color it was resolved for. - The settings types are exported from the package entry, so a config author or an agent can name `ColorFormatConfig`, `HexFormatOutputs`, `RgbFormatOutputs`, `ColorSettings` and `PaletteColorSettings`. Validation tests moved to their own file, and AGENTS.md now records the settings convention: a closed schema per level, runtime validation with the configuration path, per-key merging for list settings, and one resolution shared by the CLI.
Comments and documentation restated the design instead of documenting the interface. JSDoc is one line per type or field with the defaults and the ranges the types cannot express, and the rationale paragraphs are gone. The README section keeps the generated example, the output table and the behavior a reader cannot infer: the alpha policy, the fallback choice and the merge rule. AGENTS.md states the settings convention in four bullets, and the skill references and changeset are trimmed the same way.
An out-of-sRGB color was gamut mapped silently, so the author only saw the oklch they wrote while the browser painted the mapped value. `gamutMapped: true` now sits beside the generated `color` object on the token, in the JSON, TypeScript and Style Dictionary outputs. It is absent for a color inside sRGB, and absent when no format is generated, because nothing is approximated then. The check uses colorjs's default gamut tolerance, so float noise on a boundary color such as white is not reported. The format serializers now convert to sRGB once per format and carry the mapped flag out, instead of converting again for every output.
`0.549 * 100` is not exact in binary, so a color at 54.9% alpha generated `oklch(... / 54.900000000000006%)`. The percentage is now rounded to the one decimal the source alpha keeps, so 0.549 writes `54.9%` and 0.12 keeps `12%`.
Blocker: the theme color-entry check sat inside the theme try block, which logs
and drops the theme, so the promised throw never reached the caller and the CLI
reported success while the theme was missing. The check now runs beside the
theme-level one, before the try.
Standards findings:
- The remaining levels validate their settings: gradients and themes accept
`selector` and `atRule`, a theme color accepts `variantNameOnly`, and a level
that reads no settings rejects every key. A misspelled key there used to
generate nothing, quietly.
- `formats` has to be an object; `false` now reports the path instead of being
read as "no override" and inheriting the palette's formats.
- The output registry is typed from the token value types, so the declared
outputs and the implemented ones cannot drift, and the CSS value of a format
comes from the same entry as its token outputs.
- The default format entry is defined once, so `{ hex: true }` and
`--color-formats hex` cannot diverge.
- One name for the generation options (`GenerateOptions`, exported from the
package entry), replacing the alias and a re-export nothing imported.
- The hex alpha byte rounds from the exact alpha. Rounding to three decimals
first moved a 12.35% color off the byte a browser paints.
- The CLI flag has spawned-CLI evidence for the citty wiring, next to the
library tests for the resolution semantics.
Docs: the token rows say when `color` and `gamutMapped` appear, the programmatic
example shows `colorFormats`, and the changeset scopes the closed-schema claim
to the color settings.
…he palette Two follow-ups from the PR review. The compatibility declaration was emitted after gradient and theme blocks, so a theme that re-declares a palette property lost to the shim in a browser without `oklch()`: equal specificity, later wins. It is now emitted with the palette, right after the declaration it mirrors, and the theme wins. A regression test pins the order. Settings were only validated in colors. Everywhere else an unknown 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, emitting `8px` where `0.5rem` was meant. `assertKnownKeys` and `assertSettingsKeys` now live in helpers.ts and every module reads its settings through them: - spacing and primitive variants accept `pxToRem` and `rem` - typography fluid scales accept `customLabel` - a primitive group, a gradient variant and a gradient module keep no settings, so any key there is rejected - `Primitive.settings` and the gradient variant `settings` are gone from the types; `unreadSettings` reads one a JavaScript config can still write, so the runtime reports it instead of ignoring it Tests for each module live in settings-validation.test.ts, with the gradient variant case beside the other color-level ones. AGENTS.md no longer needs the "color settings are the reference" qualifier, and a patch changeset records the tightening.
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.
What
Palette colors are generated in OKLCH, which a browser without
oklch()support cannot render. This generates sRGB values alongside and declares one of them for those browsers.Config
hexstring,digits,number"#ff7f50","ff7f50",16744272rgbstring,array"rgb(255 127 80)",[255, 127, 80]A format set to
truegenerates its CSS value. Each format takesalpha:truekeeps the color's alpha, a number from 0 to 1 sets it,falserejects the color.fallbacknames the format whosestringvalue is the declaration, orfalsefor none; without it, the first format with a CSS value is used. A color'sformatsmerge into the palette's per format, andfalseremoves one.CSS
Emitted with the palette, right after the declaration it mirrors and before gradient and theme blocks, so a later declaration still wins. It mirrors the color's
atRuleandselector. A duplicate declaration in the root block would never lose, because a custom property accepts any token stream. Colors outside sRGB go through the CSS gamut mapping algorithm.Tokens
color, keyed by format and output:{ "hex": { "string": "#ff7f50" }, "rgb": { "array": [255, 127, 80] } }, mirrored byattributes.colorand$colorin Style Dictionary. BothcolorandgamutMappedare present when formats come from the config,colorFormats, or--color-formats.--color-formats hex,rgbadds formats for a run. A palette color outside sRGB carriesgamutMapped: true, so the mapped value a browser paints is visible; it is absent inside sRGB, and absent when no format is generated.Contract
Settings are a closed schema in every module: unknown keys, wrong shapes, misplaced settings, a
formatsthat is not an object, an unusable fallback, and settings on a level that reads none throw with the configuration path. Spacing and primitive variants acceptpxToRemandrem, typography fluid scales acceptcustomLabel. The settings types are exported from the package entry, andAGENTS.mdrecords the convention.Verification
moon ci; 21 files and 137 tests; the browser suite; a CLI run end to end; mutation checks on each behavior, including the newly closed settings levels and the theme-color rejection. Two review axes ran on the final shape. One reported no blockers; the other found a swallowed throw for a misplaced theme color setting, which is fixed along with the schema, options-naming, alpha-precision and test-seam findings.CI coverage
Gated on this branch, from the workflow and the run log:
moon cirunscssforge:test(22 files, 154 tests),cssforge:typecheck,cssforge:format,unplugin:test,unplugin:typecheck,docs:build,cssforge:readme-check,cssforge:packandcssforge:smoke-test, withversion-checkandjsr-dry-runas forced steps. The browser job runsvanilla-react-css:e2eand its production build; two further jobs run the packed smoke test on Node 24.0.0 and the JSR entries with Deno.Not gated, and pre-existing: the e2e suites of the other seven example projects (
tailwind-nextjs,tailwind-nuxt,tailwind-solidstart,tailwind-sveltekit,vanilla-vue-css,vanilla-svelte-css,vanilla-solid-css) arerunInCI: falseand no job invokes them, so a regression that only shows through those integrations would not fail CI. Everylinttask is outside CI too, includingvanilla-react-css:lint, which fails onmainon an unused import this branch does not touch.Not in scope
The palette only: themes, gradients and primitives keep authored values. Derived colors, such as a
color-mix(in srgb, X 54.9%, transparent)role, are out of scope: the mix is a value concern rather than a setting, and declaring the derived color as its own palette entry covers it today. A theme that holds a literaloklch()for a property the palette also formats gets no declaration of its own. Pre-existing limitation: anatRule-only color is nested inside:root, so a browser withoklch()but without CSS nesting drops it.