Skip to content

feat(colors): sRGB formats, gamut flag and validated settings - #74

Merged
Hebilicious merged 13 commits into
mainfrom
feat/oklch-color-fallback
Sep 30, 2026
Merged

Hebilicious merged 13 commits into
mainfrom
feat/oklch-color-fallback

Conversation

@Hebilicious

@Hebilicious Hebilicious commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

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

settings: {
  color: {
    formats: { hex: { string: true, digits: true, number: true }, rgb: { array: true } },
    fallback: "hex",
  },
}
Format Outputs Values
hex string, digits, number "#ff7f50", "ff7f50", 16744272
rgb string, array "rgb(255 127 80)", [255, 127, 80]

A format set to true generates its CSS value. Each format takes alpha: true keeps the color's alpha, a number from 0 to 1 sets it, false rejects the color. fallback names the format whose string value is the declaration, or false for none; without it, the first format with a CSS value is used. A color's formats merge into the palette's per format, and false removes one.

CSS

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

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 atRule and selector. 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 by attributes.color and $color in Style Dictionary. Both color and gamutMapped are present when formats come from the config, colorFormats, or --color-formats. --color-formats hex,rgb adds formats for a run. A palette color outside sRGB carries gamutMapped: 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 formats that is not an object, an unusable fallback, and settings on a level that reads none throw with the configuration path. Spacing and primitive variants accept pxToRem and rem, typography fluid scales accept customLabel. The settings types are exported from the package entry, and AGENTS.md records 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 ci runs cssforge:test (22 files, 154 tests), cssforge:typecheck, cssforge:format, unplugin:test, unplugin:typecheck, docs:build, cssforge:readme-check, cssforge:pack and cssforge:smoke-test, with version-check and jsr-dry-run as forced steps. The browser job runs vanilla-react-css:e2e and 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) are runInCI: false and no job invokes them, so a regression that only shows through those integrations would not fail CI. Every lint task is outside CI too, including vanilla-react-css:lint, which fails on main on 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 literal oklch() for a property the palette also formats gets no declaration of its own. Pre-existing limitation: an atRule-only color is nested inside :root, so a browser with oklch() but without CSS nesting drops it.

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: 97ae67c

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 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.
@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.
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.
@Hebilicious Hebilicious changed the title feat(colors): generate palette colors in extra sRGB formats feat(colors): sRGB formats, gamut flag and validated settings Sep 30, 2026
@Hebilicious
Hebilicious merged commit 2561cc8 into main Sep 30, 2026
5 checks passed
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