From e25fe19e524fe779b4834905780bfdf54d422b54 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 25 Sep 2026 04:11:09 +0700 Subject: [PATCH 1/7] feat(colors): emit an sRGB fallback for palette colors 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. --- .changeset/oklch-color-fallback.md | 17 + README.md | 88 +++++ example/vanilla-react-css/README.md | 11 +- example/vanilla-react-css/cssforge.config.ts | 7 + .../tests/oklch-fallback.spec.ts | 105 ++++++ packages/cssforge/README.md | 88 +++++ packages/cssforge/src/generator.ts | 14 +- packages/cssforge/src/lib.ts | 5 + packages/cssforge/src/modules/colors.ts | 209 +++++++++++- .../cssforge/tests/oklch-fallback.test.ts | 309 ++++++++++++++++++ skills/cssforge/references/token-patterns.md | 4 + 11 files changed, 849 insertions(+), 8 deletions(-) create mode 100644 .changeset/oklch-color-fallback.md create mode 100644 example/vanilla-react-css/tests/oklch-fallback.spec.ts create mode 100644 packages/cssforge/tests/oklch-fallback.test.ts diff --git a/.changeset/oklch-color-fallback.md b/.changeset/oklch-color-fallback.md new file mode 100644 index 0000000..4a03237 --- /dev/null +++ b/.changeset/oklch-color-fallback.md @@ -0,0 +1,17 @@ +--- +"@hebilicious/cssforge": minor +--- + +Add an sRGB fallback for palette colors, so a browser without `oklch()` support still renders them. + +Set `fallback` on the palette to cover every color, or on a single color to override it. `"hex"` +writes `#rrggbb` (or `#rrggbbaa` with alpha) and `"rgb"` writes `rgb(r g b)` (or `rgb(r g b / a)`); +`false` opts a color out of an inherited fallback. Each fallback is emitted after the root block, +inside `@supports not (color: oklch(0% 0 0))` and mirroring the color's `atRule` and `selector`, so +it only overrides the declaration it stands in for. A custom property accepts any token stream, so +a duplicate declaration in the same block would not have worked: the modern value wins everywhere, +including where `oklch()` cannot be used. + +The JSON and TypeScript token objects gain a `fallback` field, and the Style Dictionary output +gains `attributes.fallback` and `$fallback`, so non-CSS consumers can read the sRGB value without +converting the color themselves. diff --git a/README.md b/README.md index a8f3a05..c8e6d72 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,7 @@ 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 | +| `fallback` | The sRGB value a browser without `oklch()` support falls back to, such as `#ff7f50` | Rendering a palette color where modern color syntax is unavailable. Present only when the palette sets `fallback` | A level with one child is collapsed, so `palette: { value: { coral: ... } }` becomes `cssForge.palette.coral`. Numeric and `@` keys stay strings: @@ -658,6 +659,91 @@ 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. +#### Fallback for browsers without oklch + +Palette colors are generated in OKLCH. A custom property accepts any token stream, so a +browser without `oklch()` support still parses `--palette-coral-100: oklch(...)` and only +fails when the value is used as a color. Set `fallback` to also emit an sRGB declaration +for every color, gated by `@supports not (color: oklch(0% 0 0))`, so the unsupported +browser keeps a usable color: + + + +```typescript +export default defineConfig({ + colors: { + palette: { + value: { + coral: { 100: { hex: "#FF7F50" } }, + coralDark: { + value: { 100: { hex: "#FF6347" } }, + settings: { atRule: "@media (prefers-color-scheme: dark)" }, + }, + }, + settings: { 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; + } + } +} +``` + + + +The `fallback` setting is inherited by every color, and a color overrides it in its own +`settings`. Use `"hex"` for `#rrggbb` (or `#rrggbbaa` with alpha) and `"rgb"` for +`rgb(r g b)` (or `rgb(r g b / a)`), or `false` to opt a color out. The fallback declaration +is emitted after the root block and mirrors the color's `atRule` and `selector`, so it only +overrides the declaration it stands in for. Setting it at the palette level needs the +`settings` key next to `value`; a color that carries settings is written with the `value` +wrapper. + +The JSON, TypeScript and Style Dictionary outputs carry the same sRGB value as `fallback`, +so a non-CSS consumer can read it without converting the color itself. + #### Condition You can conditionnally apply colors, gradients or themes by setting the `atRule` or the @@ -1326,7 +1412,9 @@ 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.fallback` | The sRGB value a browser without `oklch()` support falls back to, when the palette sets `fallback` | 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` | +| `$fallback` | The same sRGB value as a top-level field | Tools that read `$fallback` before converting the color themselves | `type` narrows `fontSize`, `lineHeight`, `fontWeight`, `fontFamily`, `borderRadius`, `letterSpacing`, `shadow`, `opacity`, `zIndex`, and `number` when the token's name and value diff --git a/example/vanilla-react-css/README.md b/example/vanilla-react-css/README.md index e47d1d1..5204510 100644 --- a/example/vanilla-react-css/README.md +++ b/example/vanilla-react-css/README.md @@ -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. diff --git a/example/vanilla-react-css/cssforge.config.ts b/example/vanilla-react-css/cssforge.config.ts index d4f7ddf..8a0b9c9 100644 --- a/example/vanilla-react-css/cssforge.config.ts +++ b/example/vanilla-react-css/cssforge.config.ts @@ -23,6 +23,13 @@ export default defineConfig({ }, }, }, + settings: { + // Generate an sRGB fallback for every palette color, gated by + // `@supports not (color: oklch(0% 0 0))`, so the example keeps working in + // a browser without `oklch()` support. `tests/oklch-fallback.spec.ts` + // asserts the gate from the browser. + fallback: "hex", + }, }, theme: { light: { diff --git a/example/vanilla-react-css/tests/oklch-fallback.spec.ts b/example/vanilla-react-css/tests/oklch-fallback.spec.ts new file mode 100644 index 0000000..63adedf --- /dev/null +++ b/example/vanilla-react-css/tests/oklch-fallback.spec.ts @@ -0,0 +1,105 @@ +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"; + +/** + * Reads the generated `@supports` block from the served stylesheet, so the + * promise is observed through the browser's own CSSOM rather than the source + * string the generator produced. + */ +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 generated 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: + // the token still computes to the modern value, and the probe still renders + // in the modern color space instead of the fallback hex. + 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 itself: the declarations, their selector and their position + // in the cascade are the ones a browser without oklch support would read. + 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); +}); diff --git a/packages/cssforge/README.md b/packages/cssforge/README.md index a8f3a05..c8e6d72 100644 --- a/packages/cssforge/README.md +++ b/packages/cssforge/README.md @@ -314,6 +314,7 @@ 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 | +| `fallback` | The sRGB value a browser without `oklch()` support falls back to, such as `#ff7f50` | Rendering a palette color where modern color syntax is unavailable. Present only when the palette sets `fallback` | A level with one child is collapsed, so `palette: { value: { coral: ... } }` becomes `cssForge.palette.coral`. Numeric and `@` keys stay strings: @@ -658,6 +659,91 @@ 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. +#### Fallback for browsers without oklch + +Palette colors are generated in OKLCH. A custom property accepts any token stream, so a +browser without `oklch()` support still parses `--palette-coral-100: oklch(...)` and only +fails when the value is used as a color. Set `fallback` to also emit an sRGB declaration +for every color, gated by `@supports not (color: oklch(0% 0 0))`, so the unsupported +browser keeps a usable color: + + + +```typescript +export default defineConfig({ + colors: { + palette: { + value: { + coral: { 100: { hex: "#FF7F50" } }, + coralDark: { + value: { 100: { hex: "#FF6347" } }, + settings: { atRule: "@media (prefers-color-scheme: dark)" }, + }, + }, + settings: { 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; + } + } +} +``` + + + +The `fallback` setting is inherited by every color, and a color overrides it in its own +`settings`. Use `"hex"` for `#rrggbb` (or `#rrggbbaa` with alpha) and `"rgb"` for +`rgb(r g b)` (or `rgb(r g b / a)`), or `false` to opt a color out. The fallback declaration +is emitted after the root block and mirrors the color's `atRule` and `selector`, so it only +overrides the declaration it stands in for. Setting it at the palette level needs the +`settings` key next to `value`; a color that carries settings is written with the `value` +wrapper. + +The JSON, TypeScript and Style Dictionary outputs carry the same sRGB value as `fallback`, +so a non-CSS consumer can read it without converting the color itself. + #### Condition You can conditionnally apply colors, gradients or themes by setting the `atRule` or the @@ -1326,7 +1412,9 @@ 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.fallback` | The sRGB value a browser without `oklch()` support falls back to, when the palette sets `fallback` | 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` | +| `$fallback` | The same sRGB value as a top-level field | Tools that read `$fallback` before converting the color themselves | `type` narrows `fontSize`, `lineHeight`, `fontWeight`, `fontFamily`, `borderRadius`, `letterSpacing`, `shadow`, `opacity`, `zIndex`, and `number` when the token's name and value diff --git a/packages/cssforge/src/generator.ts b/packages/cssforge/src/generator.ts index 0a3fae3..2e608f2 100644 --- a/packages/cssforge/src/generator.ts +++ b/packages/cssforge/src/generator.ts @@ -17,6 +17,8 @@ type CssValue = { value: string; key: string; variable: string; + /** The sRGB fallback for a color, when the config asked for one. */ + fallback?: string; }; type ForgeValue = { @@ -56,12 +58,15 @@ type StyleDictionaryToken = { */ tailwindVariable: string; resolvedValue: string; + /** The sRGB value a browser without `oklch()` support falls back to. */ + fallback?: string; sourcePath: string; referencePaths?: string[]; }; $tier: TokenTier; $reference?: string; $resolvedValue: string; + $fallback?: string; }; type StyleDictionaryValue = { @@ -230,7 +235,12 @@ export function createForgeValues(config: Partial) { ([path, token]) => [ path, - { key: token.key, value: token.value, variable: token.variable }, + { + key: token.key, + value: token.value, + variable: token.variable, + ...(token.fallback ? { fallback: token.fallback } : {}), + }, ] satisfies Input, ); const forgeValues = createForgeValuesFromKeys(jsonKeys); @@ -427,12 +437,14 @@ export function generateStyleDictionaryJSON( cssVariableReference, tailwindVariable: token.key, resolvedValue, + ...(token.fallback ? { fallback: token.fallback } : {}), sourcePath: toStyleDictionaryPath(token.sourcePath), ...(referencePaths ? { referencePaths } : {}), }, $tier: tier, ...(referencePaths?.[0] ? { $reference: referencePaths[0] } : {}), $resolvedValue: resolvedValue, + ...(token.fallback ? { $fallback: token.fallback } : {}), }; const nestedObject = createNestedStyleDictionaryObject( outputPath.split("."), diff --git a/packages/cssforge/src/lib.ts b/packages/cssforge/src/lib.ts index 9065e19..aad926e 100644 --- a/packages/cssforge/src/lib.ts +++ b/packages/cssforge/src/lib.ts @@ -38,6 +38,11 @@ export interface ResolvedToken extends TokenMetadata { value: string; /** The full CSS declaration. */ variable: string; + /** + * The sRGB declaration value a browser without `oklch()` support falls back + * to, when the color opted into a fallback. + */ + fallback?: string; /** * The effective wrapper chain this declaration is emitted into, recorded by * the module that emitted it. Declarations without a wrapper share diff --git a/packages/cssforge/src/modules/colors.ts b/packages/cssforge/src/modules/colors.ts index 51a30b5..e850f74 100644 --- a/packages/cssforge/src/modules/colors.ts +++ b/packages/cssforge/src/modules/colors.ts @@ -67,10 +67,45 @@ export interface WithCondition { */ atRule?: string; } +/** + * Syntax used for the fallback declaration emitted for browsers without + * `oklch()` support. `"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. + */ +export type ColorFallbackFormat = "hex" | "rgb"; + +/** + * Settings for the sRGB fallback that keeps a color usable where `oklch()` is + * not supported. + */ +export interface ColorFallbackSettings { + /** + * Emit an sRGB fallback declaration for every color, so a browser without + * `oklch()` support still renders the palette. The fallback is gated by + * `@supports not (color: oklch(0% 0 0))` and emitted after the generated + * root block, because a custom property accepts any token stream and the + * later declaration wins wherever the modern value is unsupported. + * + * Set it on the palette to cover every color, and on a single color to + * override it or opt out with `false`. + * + * @example + * ```ts + * colors: { + * palette: { + * value: { coral: { 100: { hex: "#FF7F50" } } }, + * settings: { fallback: "hex" }, + * }, + * } + * ``` + */ + fallback?: ColorFallbackFormat | false; +} + /** * Settings for palette colors, including optional conditions like media queries. */ -export interface PaletteColorSettings extends WithCondition {} +export interface PaletteColorSettings extends WithCondition, ColorFallbackSettings {} /** * Settings for gradients, including optional conditions like media queries. @@ -160,7 +195,11 @@ export interface ColorConfig { */ palette: { value: ColorPalette; - settings?: unknown; + /** + * Settings shared by every palette color. A color's own settings take + * precedence. + */ + settings?: ColorFallbackSettings; }; /** * A collection of gradients. @@ -269,6 +308,122 @@ function colorValueToOklch(value: ColorValueOrString): string { return `oklch(${Number((l * 100).toFixed(3))}% ${c} ${h}${alpha})`; } +/** + * The condition every fallback declaration is gated by. A browser without + * `oklch()` support parses the feature as unsupported, so the negation matches + * there and only there. + */ +const OKLCH_SUPPORT_CONDITION = "@supports not (color: oklch(0% 0 0))"; + +const toChannelByte = (coord: number) => + Math.round(Math.min(Math.max(Number.isNaN(coord) ? 0 : coord, 0), 1) * 255); + +const toHexByte = (byte: number) => byte.toString(16).padStart(2, "0"); + +/** + * Converts a color value to the sRGB syntax a browser without `oklch()` support + * can render. + * + * The conversion uses the CSS gamut mapping algorithm, which is how a browser + * maps an out-of-gamut `oklch()` color, so a saturated fallback stays as close + * to the modern value as sRGB allows. + * + * @example + * ```ts + * colorValueToFallback({ hex: "#ff7f50" }, "hex"); // "#ff7f50" + * colorValueToFallback({ hex: "#ff7f50" }, "rgb"); // "rgb(255 127 80)" + * ``` + */ +function colorValueToFallback( + value: ColorValueOrString, + format: ColorFallbackFormat, +): string { + const colorString = typeof value === "string" ? value : getColorString(value); + const mapped = new Color(colorString).to("srgb").toGamut(); + const alpha = Math.min(Math.max(Number.isNaN(mapped.alpha) ? 1 : mapped.alpha, 0), 1); + const [red, green, blue] = mapped.coords.map(toChannelByte); + + if (format === "rgb") { + const alphaSuffix = alpha === 1 ? "" : ` / ${Number(alpha.toFixed(3))}`; + return `rgb(${red} ${green} ${blue}${alphaSuffix})`; + } + + const alphaSuffix = alpha === 1 ? "" : toHexByte(Math.round(alpha * 255)); + return `#${toHexByte(red)}${toHexByte(green)}${toHexByte(blue)}${alphaSuffix}`; +} + +const fallbackFormats = ["hex", "rgb"] as const; + +const isFallbackFormat = (value: unknown): value is ColorFallbackFormat => + typeof value === "string" && fallbackFormats.some((format) => format === value); + +/** + * Rejects a fallback setting that is neither a supported format nor `false`. + * The value comes from a JavaScript object at runtime, so a typo would + * otherwise generate no fallback and fail silently. + */ +function validateFallbackSetting(value: unknown, path: string): void { + if (value === undefined || value === false || isFallbackFormat(value)) return; + + throw new Error( + `Invalid fallback format at configuration path "${path}": ${JSON.stringify( + value, + )}. Use "hex", "rgb", or false.`, + ); +} + +/** + * Resolves the fallback format for one palette color. A color's own setting wins + * over the palette setting, and `false` opts out of an inherited fallback. + */ +const resolveFallbackFormat = ( + settings: ColorFallbackSettings | undefined, + paletteSettings: ColorFallbackSettings | undefined, +): ColorFallbackFormat | undefined => { + const fallback = settings?.fallback ?? paletteSettings?.fallback; + return isFallbackFormat(fallback) ? fallback : undefined; +}; + +/** + * The wrapper chain a fallback declaration is emitted into. The `@supports` + * condition sits between the color's own at-rule and its selector, mirroring the + * chain of the declaration it overrides, and an unnamed selector falls back to + * `:root`, which is where the overridden declaration lives. + */ +const fallbackWrappers = (settings: WithCondition | undefined): string[] => { + const atRule = settings?.atRule?.trim(); + const selector = settings?.selector?.trim(); + return [ + ...(atRule ? [atRule] : []), + OKLCH_SUPPORT_CONDITION, + selector && selector !== ROOT_SCOPE ? selector : ROOT_SCOPE, + ]; +}; + +/** + * Renders one `@supports` block for every fallback declaration that shares a + * wrapper chain. The block is emitted at the top level rather than inside the + * root block, because a browser without `oklch()` support predates CSS nesting + * and would drop a nested at-rule together with its fallback. + */ +const renderFallbackBlock = (wrappers: string[], declarations: string[]): string[] => { + // A palette color without variants contributes only its comment, and a block + // holding no declaration would be empty output. + if (!declarations.some((line) => line.startsWith("--"))) return []; + + const lines: string[] = []; + + wrappers.forEach((wrapper, index) => { + lines.push(`${" ".repeat(index)}${wrapper} {`); + }); + lines.push(...declarations.map((line) => `${" ".repeat(wrappers.length)}${line}`)); + for (let index = wrappers.length - 1; index >= 0; index -= 1) { + lines.push(`${" ".repeat(index)}}`); + } + + return lines; +}; + /** * Processes the color configuration to generate CSS variables. * This includes palettes, gradients, and themes. @@ -293,6 +448,23 @@ export function processColors(colors: ColorConfig): Output { const resolveMap: ResolveMap = new Map(); rootOutput.push(`/* Palette */`); const moduleKey = "palette"; + // Fallback declarations are collected per wrapper chain, so colors under the + // same condition share one `@supports` block. The key is the rendered chain. + const fallbackGroups = new Map< + string, + { wrappers: string[]; declarations: string[] } + >(); + + const getFallbackGroup = (settings: WithCondition | undefined) => { + const wrappers = fallbackWrappers(settings); + const key = wrappers.join("\u0000"); + const existing = fallbackGroups.get(key); + if (existing) return existing; + + const group = { wrappers, declarations: [] as string[] }; + fallbackGroups.set(key, group); + return group; + }; function conditionalBuilder( settings: WithCondition | undefined, @@ -358,11 +530,31 @@ export function processColors(colors: ColorConfig): Output { }; } + validateFallbackSetting(colors.palette.settings?.fallback, "palette.settings"); + for (const [colorName, colorConfig] of Object.entries(colors.palette.value)) { validateName(colorName, `palette.${colorName}`); + const normalizedColorConfig = getPaletteColorConfig(colorConfig); + // Validated before the try block: a configuration mistake has to fail + // loudly instead of being logged and skipped per color. + validateFallbackSetting( + normalizedColorConfig.settings?.fallback, + `palette.${colorName}.settings`, + ); + const fallbackFormat = resolveFallbackFormat( + normalizedColorConfig.settings, + colors.palette.settings, + ); + const fallback = fallbackFormat + ? { + format: fallbackFormat, + group: getFallbackGroup(normalizedColorConfig.settings), + } + : undefined; + if (fallback) fallback.group.declarations.push(`/* ${colorName} */`); + try { - const normalizedColorConfig = getPaletteColorConfig(colorConfig); const handler = conditionalBuilder( normalizedColorConfig.settings, `/* ${colorName} */`, @@ -373,6 +565,10 @@ export function processColors(colors: ColorConfig): Output { const key = `--${moduleKey}-${colorName}-${variantId}`; const value = colorValueToOklch(colorValue); const variable = `${key}: ${value};`; + const fallbackValue = fallback + ? colorValueToFallback(colorValue, fallback.format) + : undefined; + if (fallbackValue) fallback?.group.declarations.push(`${key}: ${fallbackValue};`); handler.pushVariable(variable); @@ -383,6 +579,7 @@ export function processColors(colors: ColorConfig): Output { key, value, variable, + ...(fallbackValue ? { fallback: fallbackValue } : {}), sourcePath: `${moduleKey}.${colorName}.${variantId}`, type: "color", tier: "primitive", @@ -538,6 +735,12 @@ export function processColors(colors: ColorConfig): Output { } } + // Emitted last so the fallback overrides the modern declaration it mirrors + // wherever `oklch()` is unsupported. + for (const { wrappers, declarations } of fallbackGroups.values()) { + outsideOutput.push(...renderFallbackBlock(wrappers, declarations)); + } + const output = { css: { root: rootOutput.join("\n"), outside: outsideOutput.join("\n") }, resolveMap, diff --git a/packages/cssforge/tests/oklch-fallback.test.ts b/packages/cssforge/tests/oklch-fallback.test.ts new file mode 100644 index 0000000..ea7b14e --- /dev/null +++ b/packages/cssforge/tests/oklch-fallback.test.ts @@ -0,0 +1,309 @@ +import Color from "colorjs.io"; +import { generateJSON, generateTS } from "../src/generator.ts"; +import type { CSSForgeConfig } from "../src/mod.ts"; +import { defineConfig, generateCSS, generateStyleDictionaryJSON } from "../src/mod.ts"; +import { assert, assertEquals, assertThrows, Deno } from "./vitest-compat.ts"; + +/** + * The fallback value declared for `key` inside the generated `@supports` block, + * read from the block rather than the root declaration above it. + */ +const fallbackValue = (css: string, key: string): string => { + const block = css.slice(css.indexOf("@supports")); + const match = new RegExp(`${key}: ([^;]+);`).exec(block); + + if (!match) throw new Error(`Expected a fallback declaration for ${key} in:\n${css}`); + + return match[1]; +}; + +Deno.test("generateCSS - palette fallback emits an sRGB hex declaration under @supports", () => { + const config = defineConfig({ + colors: { + palette: { + value: { + coral: { + 100: { hex: "#FF7F50" }, + }, + }, + settings: { fallback: "hex" }, + }, + }, + }); + + assertEquals( + generateCSS(config), + [ + "/*____ CSSForge ____*/", + ":root {", + "/*____ Colors ____*/", + "/* Palette */", + "/* coral */", + "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", + "}", + "@supports not (color: oklch(0% 0 0)) {", + " :root {", + " /* coral */", + " --palette-coral-100: #ff7f50;", + " }", + "}", + ].join("\n"), + ); +}); + +Deno.test("generateCSS - a palette color without variants emits no fallback block", () => { + const config = defineConfig({ + colors: { + palette: { + value: { empty: { value: {} } }, + settings: { fallback: "hex" }, + }, + }, + }); + + assertEquals(generateCSS(config).includes("@supports"), false); +}); + +Deno.test("generateCSS - applies the palette format with per-color overrides", () => { + const config = defineConfig({ + colors: { + palette: { + value: { + coral: { 100: { hex: "#FF7F50" } }, + soft: { 100: "rgb(0 0 0 / 12%)" }, + brand: { + value: { 100: { hex: "#FF0000" } }, + settings: { fallback: "hex" }, + }, + opted: { + value: { 100: { hex: "#00FF00" } }, + settings: { fallback: false }, + }, + }, + settings: { fallback: "rgb" }, + }, + }, + }); + + const css = generateCSS(config); + const lines = css.split("\n"); + const fallbackStart = lines.indexOf("@supports not (color: oklch(0% 0 0)) {"); + + assert(fallbackStart > 0, `Expected a fallback block. Received:\n${css}`); + assertEquals(lines.slice(fallbackStart), [ + "@supports not (color: oklch(0% 0 0)) {", + " :root {", + " /* coral */", + " --palette-coral-100: rgb(255 127 80);", + " /* soft */", + " --palette-soft-100: rgb(0 0 0 / 0.12);", + " /* brand */", + " --palette-brand-100: #ff0000;", + " }", + "}", + ]); + assertEquals(css.includes("--palette-opted-100: #"), false); +}); + +Deno.test("generateCSS - a hex fallback keeps alpha as an eight digit hex", () => { + const config = defineConfig({ + colors: { + palette: { + value: { + soft: { 100: "rgb(0 0 0 / 12%)" }, + }, + settings: { fallback: "hex" }, + }, + }, + }); + + assertEquals( + generateCSS(config), + [ + "/*____ CSSForge ____*/", + ":root {", + "/*____ Colors ____*/", + "/* Palette */", + "/* soft */", + "--palette-soft-100: oklch(0% 0 0 / 12%);", + "}", + "@supports not (color: oklch(0% 0 0)) {", + " :root {", + " /* soft */", + " --palette-soft-100: #0000001f;", + " }", + "}", + ].join("\n"), + ); +}); + +Deno.test("generateCSS - a wide gamut color falls back to the CSS gamut mapped sRGB value", () => { + // `oklch(70% 0.4 20)` cannot be shown in sRGB: its conversion is + // `rgb(336 -117 10)`, and clipping each channel would give `#ff000a`. The + // fallback keeps the authored hue instead, which is how a browser maps the + // color it cannot display. + const config = defineConfig({ + colors: { + palette: { + value: { vivid: { 100: { oklch: "oklch(70% 0.4 20)" } } }, + settings: { fallback: "hex" }, + }, + }, + }); + + const fallback = fallbackValue(generateCSS(config), "--palette-vivid-100"); + + assertEquals(fallback === "#ff000a", false); + assertEquals(new Color(fallback).inGamut("srgb", { epsilon: 0 }), true); +}); + +Deno.test("generateCSS - fallback mirrors the color selector and at-rule", () => { + const config = defineConfig({ + colors: { + palette: { + value: { + dark: { + value: { 900: { hex: "#000" } }, + settings: { atRule: "@media (prefers-color-scheme: dark)" }, + }, + another: { + value: { 900: { hex: "#fff" } }, + settings: { selector: ":root.Another" }, + }, + card: { + value: { 900: { hex: "#111" } }, + settings: { atRule: "@container (min-width: 40rem)", selector: ".card" }, + }, + }, + settings: { fallback: "hex" }, + }, + }, + }); + + const css = generateCSS(config); + + assert( + css.endsWith( + [ + "@media (prefers-color-scheme: dark) {", + " @supports not (color: oklch(0% 0 0)) {", + " :root {", + " /* dark */", + " --palette-dark-900: #000000;", + " }", + " }", + "}", + "@supports not (color: oklch(0% 0 0)) {", + " :root.Another {", + " /* another */", + " --palette-another-900: #ffffff;", + " }", + "}", + "@container (min-width: 40rem) {", + " @supports not (color: oklch(0% 0 0)) {", + " .card {", + " /* card */", + " --palette-card-900: #111111;", + " }", + " }", + "}", + ].join("\n"), + ), + `Expected the fallback blocks at the end of:\n${css}`, + ); +}); + +Deno.test("generateJSON and generateTS - fallback is part of the token object", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { fallback: "hex" }, + }, + }, + }); + + assertEquals(JSON.parse(generateJSON(config)), { + palette: { + coral: { + "100": { + key: "--palette-coral-100", + value: "oklch(73.511% 0.16799 40.24666)", + variable: "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", + fallback: "#ff7f50", + }, + }, + }, + }); + assertEquals(generateTS(config).includes('"fallback": "#ff7f50"'), true); +}); + +Deno.test("generateJSON - token objects omit the fallback when none is configured", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + }, + }, + }); + + assertEquals(JSON.parse(generateJSON(config)), { + palette: { + coral: { + "100": { + key: "--palette-coral-100", + value: "oklch(73.511% 0.16799 40.24666)", + variable: "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", + }, + }, + }, + }); +}); + +Deno.test("generateStyleDictionaryJSON - exposes the fallback beside the resolved value", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { fallback: "hex" }, + }, + }, + }); + + const token = JSON.parse(generateStyleDictionaryJSON(config)).palette.coral["100"]; + + assertEquals(token.$fallback, "#ff7f50"); + assertEquals(token.attributes.fallback, "#ff7f50"); + assertEquals(token.$resolvedValue, "oklch(73.511% 0.16799 40.24666)"); +}); + +Deno.test("generateCSS - rejects an unsupported fallback format", () => { + const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; + const invalidPalette = { + colors: { palette: { ...palette, settings: { fallback: "oklch" } } }, + } as unknown as CSSForgeConfig; + const invalidColor = { + colors: { + palette: { + value: { + coral: { + value: { 100: { hex: "#FF7F50" } }, + settings: { fallback: "sqrgb" }, + }, + }, + }, + }, + } as unknown as CSSForgeConfig; + + const paletteError = assertThrows(() => generateCSS(invalidPalette)); + const colorError = assertThrows(() => generateCSS(invalidColor)); + + assert( + paletteError.message.includes('"palette.settings"'), + `Expected the error to name "palette.settings". Received: ${paletteError.message}`, + ); + assert( + colorError.message.includes('"palette.coral.settings"'), + `Expected the error to name "palette.coral.settings". Received: ${colorError.message}`, + ); +}); diff --git a/skills/cssforge/references/token-patterns.md b/skills/cssforge/references/token-patterns.md index ff817f4..d2d582e 100644 --- a/skills/cssforge/references/token-patterns.md +++ b/skills/cssforge/references/token-patterns.md @@ -7,6 +7,10 @@ All patterns below are derived from README configuration examples. - Palette tokens under `colors.palette.value`. - Supports color formats like hex/rgb/hsl/oklch and string values. - Themes and gradients can reference palette entries with `variables` maps. +- Set `fallback` to `"hex"` or `"rgb"` on `colors.palette.settings` (or on one color's + `settings`) to emit an sRGB declaration under `@supports not (color: oklch(0% 0 0))` for + browsers without `oklch()`. A color opts out with `false`. The generated JSON, TypeScript + and Style Dictionary tokens carry the same value as `fallback`. ## Spacing From 9674eb371bb3a796462d73239270f01459d4526d Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 25 Sep 2026 04:12:17 +0700 Subject: [PATCH 2/7] docs(colors): scope the oklch fallback to palette colors 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. --- README.md | 5 +++++ packages/cssforge/README.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/README.md b/README.md index c8e6d72..71849fe 100644 --- a/README.md +++ b/README.md @@ -744,6 +744,11 @@ wrapper. The JSON, TypeScript and Style Dictionary outputs carry the same sRGB value as `fallback`, so a non-CSS consumer can read it without converting the color itself. +The palette is the only family that converts the colors it is given, so it is the only one +that carries a fallback. Themes, gradients and primitives keep their authored values, and +they use the palette fallback through the `var(--palette-...)` references they already +compose with. An `oklch()` written directly into a theme or gradient value stays as it is. + #### Condition You can conditionnally apply colors, gradients or themes by setting the `atRule` or the diff --git a/packages/cssforge/README.md b/packages/cssforge/README.md index c8e6d72..71849fe 100644 --- a/packages/cssforge/README.md +++ b/packages/cssforge/README.md @@ -744,6 +744,11 @@ wrapper. The JSON, TypeScript and Style Dictionary outputs carry the same sRGB value as `fallback`, so a non-CSS consumer can read it without converting the color itself. +The palette is the only family that converts the colors it is given, so it is the only one +that carries a fallback. Themes, gradients and primitives keep their authored values, and +they use the palette fallback through the `var(--palette-...)` references they already +compose with. An `oklch()` written directly into a theme or gradient value stays as it is. + #### Condition You can conditionnally apply colors, gradients or themes by setting the `atRule` or the From e712c5b80ddb665e7f00fe4c16744f3e8a652684 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 25 Sep 2026 04:15:55 +0700 Subject: [PATCH 3/7] refactor(colors): address review on the oklch fallback Standards review findings: - One table now owns the fallback formats: the serializers are a `Record`, 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. --- README.md | 4 +- packages/cssforge/README.md | 4 +- packages/cssforge/src/modules/colors.ts | 67 ++++++++++++++++--------- 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 71849fe..33b0ebd 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ 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 | -| `fallback` | The sRGB value a browser without `oklch()` support falls back to, such as `#ff7f50` | Rendering a palette color where modern color syntax is unavailable. Present only when the palette sets `fallback` | +| `fallback` | The sRGB value a browser without `oklch()` support falls back to, such as `#ff7f50` | Rendering a palette color where modern color syntax is unavailable. Present only when `fallback` is configured, on the palette or on the color | A level with one child is collapsed, so `palette: { value: { coral: ... } }` becomes `cssForge.palette.coral`. Numeric and `@` keys stay strings: @@ -1417,7 +1417,7 @@ 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.fallback` | The sRGB value a browser without `oklch()` support falls back to, when the palette sets `fallback` | Emitting a legacy-safe color for a token | +| `attributes.fallback` | The sRGB value a browser without `oklch()` support falls back to, when `fallback` is configured on the palette or on the color | 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` | | `$fallback` | The same sRGB value as a top-level field | Tools that read `$fallback` before converting the color themselves | diff --git a/packages/cssforge/README.md b/packages/cssforge/README.md index 71849fe..33b0ebd 100644 --- a/packages/cssforge/README.md +++ b/packages/cssforge/README.md @@ -314,7 +314,7 @@ 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 | -| `fallback` | The sRGB value a browser without `oklch()` support falls back to, such as `#ff7f50` | Rendering a palette color where modern color syntax is unavailable. Present only when the palette sets `fallback` | +| `fallback` | The sRGB value a browser without `oklch()` support falls back to, such as `#ff7f50` | Rendering a palette color where modern color syntax is unavailable. Present only when `fallback` is configured, on the palette or on the color | A level with one child is collapsed, so `palette: { value: { coral: ... } }` becomes `cssForge.palette.coral`. Numeric and `@` keys stay strings: @@ -1417,7 +1417,7 @@ 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.fallback` | The sRGB value a browser without `oklch()` support falls back to, when the palette sets `fallback` | Emitting a legacy-safe color for a token | +| `attributes.fallback` | The sRGB value a browser without `oklch()` support falls back to, when `fallback` is configured on the palette or on the color | 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` | | `$fallback` | The same sRGB value as a top-level field | Tools that read `$fallback` before converting the color themselves | diff --git a/packages/cssforge/src/modules/colors.ts b/packages/cssforge/src/modules/colors.ts index e850f74..f095fe4 100644 --- a/packages/cssforge/src/modules/colors.ts +++ b/packages/cssforge/src/modules/colors.ts @@ -11,6 +11,23 @@ import { withTokenScope, } from "../lib.ts"; +/** + * The wrapper chain a declaration is emitted into, canonicalized: the color's + * at-rule and selector with surrounding whitespace removed, and an explicit + * `:root` selector dropped because a `:root` selector block and the implicit + * `:root` block declare the same properties on the same element. + * + * This is the single reading of `WithCondition` in this module: the scope + * identity that feeds collision detection and the fallback wrapper chain both + * consume it, so a fallback always mirrors a declaration from the same scope. + */ +const resolveWrapperChain = (settings: WithCondition | undefined) => { + const atRule = settings?.atRule?.trim() ?? ""; + const rawSelector = settings?.selector?.trim() ?? ""; + + return { atRule, selector: rawSelector === ROOT_SCOPE ? "" : rawSelector }; +}; + /** * Describes the wrapper chain a declaration is emitted into. `selector` and * `atRule` are both part of the identity, because two declarations only @@ -23,12 +40,7 @@ import { * the same scope as that atRule alone. */ const describeScope = (settings: WithCondition | undefined): string => { - const atRule = settings?.atRule?.trim() ?? ""; - const rawSelector = settings?.selector?.trim() ?? ""; - // A `:root` selector block and the implicit `:root` block declare the same - // properties on the same element, with or without a shared at-rule wrapper, - // so an explicit `:root` selector drops out of the scope identity. - const selector = rawSelector === ROOT_SCOPE ? "" : rawSelector; + const { atRule, selector } = resolveWrapperChain(settings); if (!atRule && !selector) return ROOT_SCOPE; return `${atRule}|${selector}`; @@ -320,6 +332,26 @@ const toChannelByte = (coord: number) => const toHexByte = (byte: number) => byte.toString(16).padStart(2, "0"); +/** + * Serializers for the supported fallback formats. The accepted formats and the + * syntax they emit come from this one table, so a format cannot be validated + * without also being implemented. + */ +const fallbackSerializers: Record< + ColorFallbackFormat, + (red: number, green: number, blue: number, alpha: number) => string +> = { + hex: (red, green, blue, alpha) => + `#${toHexByte(red)}${toHexByte(green)}${toHexByte(blue)}${ + alpha === 1 ? "" : toHexByte(Math.round(alpha * 255)) + }`, + rgb: (red, green, blue, alpha) => + `rgb(${red} ${green} ${blue}${alpha === 1 ? "" : ` / ${Number(alpha.toFixed(3))}`})`, +}; + +const isFallbackFormat = (value: unknown): value is ColorFallbackFormat => + typeof value === "string" && Object.hasOwn(fallbackSerializers, value); + /** * Converts a color value to the sRGB syntax a browser without `oklch()` support * can render. @@ -343,20 +375,9 @@ function colorValueToFallback( const alpha = Math.min(Math.max(Number.isNaN(mapped.alpha) ? 1 : mapped.alpha, 0), 1); const [red, green, blue] = mapped.coords.map(toChannelByte); - if (format === "rgb") { - const alphaSuffix = alpha === 1 ? "" : ` / ${Number(alpha.toFixed(3))}`; - return `rgb(${red} ${green} ${blue}${alphaSuffix})`; - } - - const alphaSuffix = alpha === 1 ? "" : toHexByte(Math.round(alpha * 255)); - return `#${toHexByte(red)}${toHexByte(green)}${toHexByte(blue)}${alphaSuffix}`; + return fallbackSerializers[format](red, green, blue, alpha); } -const fallbackFormats = ["hex", "rgb"] as const; - -const isFallbackFormat = (value: unknown): value is ColorFallbackFormat => - typeof value === "string" && fallbackFormats.some((format) => format === value); - /** * Rejects a fallback setting that is neither a supported format nor `false`. * The value comes from a JavaScript object at runtime, so a typo would @@ -391,13 +412,9 @@ const resolveFallbackFormat = ( * `:root`, which is where the overridden declaration lives. */ const fallbackWrappers = (settings: WithCondition | undefined): string[] => { - const atRule = settings?.atRule?.trim(); - const selector = settings?.selector?.trim(); - return [ - ...(atRule ? [atRule] : []), - OKLCH_SUPPORT_CONDITION, - selector && selector !== ROOT_SCOPE ? selector : ROOT_SCOPE, - ]; + const { atRule, selector } = resolveWrapperChain(settings); + + return [...(atRule ? [atRule] : []), OKLCH_SUPPORT_CONDITION, selector || ROOT_SCOPE]; }; /** From 925e7febae8a3c88e3d2edda081d8c5e91b5b960 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 25 Sep 2026 04:21:27 +0700 Subject: [PATCH 4/7] refactor(colors): apply the second review round on the fallback 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. --- packages/cssforge/src/modules/colors.ts | 114 +++++++++++------- .../cssforge/tests/oklch-fallback.test.ts | 86 +++++++++++++ 2 files changed, 155 insertions(+), 45 deletions(-) diff --git a/packages/cssforge/src/modules/colors.ts b/packages/cssforge/src/modules/colors.ts index f095fe4..31d35e7 100644 --- a/packages/cssforge/src/modules/colors.ts +++ b/packages/cssforge/src/modules/colors.ts @@ -12,21 +12,16 @@ import { } from "../lib.ts"; /** - * The wrapper chain a declaration is emitted into, canonicalized: the color's - * at-rule and selector with surrounding whitespace removed, and an explicit - * `:root` selector dropped because a `:root` selector block and the implicit - * `:root` block declare the same properties on the same element. - * - * This is the single reading of `WithCondition` in this module: the scope - * identity that feeds collision detection and the fallback wrapper chain both - * consume it, so a fallback always mirrors a declaration from the same scope. + * The color's own `atRule` and `selector`, with surrounding whitespace removed. + * This is the module's single reading of `WithCondition`: the declaration + * emitter, the scope identity that feeds collision detection, and the fallback + * emitter all consume it, so a fallback always mirrors a declaration emitted + * into the same chain. An empty chain means the declaration lands in `:root`. */ -const resolveWrapperChain = (settings: WithCondition | undefined) => { - const atRule = settings?.atRule?.trim() ?? ""; - const rawSelector = settings?.selector?.trim() ?? ""; - - return { atRule, selector: rawSelector === ROOT_SCOPE ? "" : rawSelector }; -}; +const readCondition = (settings: WithCondition | undefined) => ({ + atRule: settings?.atRule?.trim() ?? "", + selector: settings?.selector?.trim() ?? "", +}); /** * Describes the wrapper chain a declaration is emitted into. `selector` and @@ -40,10 +35,11 @@ const resolveWrapperChain = (settings: WithCondition | undefined) => { * the same scope as that atRule alone. */ const describeScope = (settings: WithCondition | undefined): string => { - const { atRule, selector } = resolveWrapperChain(settings); + const { atRule, selector } = readCondition(settings); + const scopedSelector = selector === ROOT_SCOPE ? "" : selector; - if (!atRule && !selector) return ROOT_SCOPE; - return `${atRule}|${selector}`; + if (!atRule && !scopedSelector) return ROOT_SCOPE; + return `${atRule}|${scopedSelector}`; }; type ExactlyOne = { @@ -241,13 +237,15 @@ const isColorValueObject = (value: unknown): value is ColorValue => isRecord(value) && ("hex" in value || "rgb" in value || "hsl" in value || "oklch" in value); -const getPaletteColorConfig = (entry: PaletteColorEntry): PaletteColorConfig => { - if (isRecord(entry) && isRecord(entry.value) && !isColorValueObject(entry.value)) { - return entry as unknown as PaletteColorConfig; - } +/** + * Whether a palette entry is written in the explicit form, with a `value` map + * and optional `settings`, rather than as a bare map of variants. + */ +const isPaletteColorConfig = (entry: PaletteColorEntry): entry is PaletteColorConfig => + isRecord(entry) && isRecord(entry.value) && !isColorValueObject(entry.value); - return { value: entry as unknown as ColorVariants }; -}; +const getPaletteColorConfig = (entry: PaletteColorEntry): PaletteColorConfig => + isPaletteColorConfig(entry) ? entry : { value: entry as unknown as ColorVariants }; const getThemeConfig = (theme: ColorConfig["theme"]): ColorTheme | undefined => { if (!theme) return undefined; @@ -378,18 +376,39 @@ function colorValueToFallback( return fallbackSerializers[format](red, green, blue, alpha); } +/** The accepted formats as the error message lists them, read from the table. */ +const fallbackFormatList = Object.keys(fallbackSerializers) + .map((format) => `"${format}"`) + .join(", "); + /** - * Rejects a fallback setting that is neither a supported format nor `false`. - * The value comes from a JavaScript object at runtime, so a typo would - * otherwise generate no fallback and fail silently. + * Validates the fallback configuration of one palette level. The values come + * from a JavaScript object at runtime, so a setting that generation would ignore + * has to fail loudly instead of silently emitting no fallback. + * + * `entry` is the palette or the palette color itself, and `settings` its + * `settings` value, which is where a fallback belongs. */ -function validateFallbackSetting(value: unknown, path: string): void { - if (value === undefined || value === false || isFallbackFormat(value)) return; +function validateFallbackConfig(entry: object, settings: unknown, path: string): void { + if (settings !== undefined && !isRecord(settings)) { + throw new Error( + `Invalid configuration at "${path}.settings": settings must be an object.`, + ); + } + + if ("fallback" in entry) { + throw new Error( + `Invalid configuration at "${path}": "fallback" belongs inside "${path}.settings".`, + ); + } + + const fallback = isRecord(settings) ? settings.fallback : undefined; + if (fallback === undefined || fallback === false || isFallbackFormat(fallback)) return; throw new Error( - `Invalid fallback format at configuration path "${path}": ${JSON.stringify( - value, - )}. Use "hex", "rgb", or false.`, + `Invalid fallback format at configuration path "${path}.settings": ${JSON.stringify( + fallback, + )}. Use ${fallbackFormatList}, or false.`, ); } @@ -412,7 +431,7 @@ const resolveFallbackFormat = ( * `:root`, which is where the overridden declaration lives. */ const fallbackWrappers = (settings: WithCondition | undefined): string[] => { - const { atRule, selector } = resolveWrapperChain(settings); + const { atRule, selector } = readCondition(settings); return [...(atRule ? [atRule] : []), OKLCH_SUPPORT_CONDITION, selector || ROOT_SCOPE]; }; @@ -490,8 +509,9 @@ export function processColors(colors: ColorConfig): Output { const innerComments: string[] = []; const vars: string[] = []; - const hasSelector = Boolean(settings?.selector); - const hasAtRule = Boolean(settings?.atRule); + const { atRule, selector } = readCondition(settings); + const hasSelector = Boolean(selector); + const hasAtRule = Boolean(atRule); // If no settings provided, emit comment immediately into root output if (!hasSelector && !hasAtRule) rootOutput.push(initialComment); @@ -514,11 +534,10 @@ export function processColors(colors: ColorConfig): Output { finalize() { if (!hasSelector && !hasAtRule) return; if (vars.length === 0 && innerComments.length === 0) return; - if (!settings) return; if (hasSelector && hasAtRule) { outsideOutput.push(initialComment); - outsideOutput.push(`${settings.atRule} {`); - outsideOutput.push(` ${settings.selector} {`); + outsideOutput.push(`${atRule} {`); + outsideOutput.push(` ${selector} {`); outsideOutput.push(...innerComments.map((c) => ` ${c}`)); outsideOutput.push(...vars.map((v) => ` ${v}`)); outsideOutput.push(` }`); @@ -528,7 +547,7 @@ export function processColors(colors: ColorConfig): Output { if (hasSelector) { outsideOutput.push(initialComment); - outsideOutput.push(`${settings.selector} {`); + outsideOutput.push(`${selector} {`); outsideOutput.push(...innerComments.map((c) => ` ${c}`)); outsideOutput.push(...vars.map((v) => ` ${v}`)); outsideOutput.push(`}`); @@ -537,7 +556,7 @@ export function processColors(colors: ColorConfig): Output { if (hasAtRule) { rootOutput.push(initialComment); - rootOutput.push(`${settings.atRule} {`); + rootOutput.push(`${atRule} {`); rootOutput.push(...innerComments.map((c) => ` ${c}`)); rootOutput.push(...vars.map((v) => ` ${v}`)); rootOutput.push(`}`); @@ -547,18 +566,23 @@ export function processColors(colors: ColorConfig): Output { }; } - validateFallbackSetting(colors.palette.settings?.fallback, "palette.settings"); + validateFallbackConfig(colors.palette, colors.palette.settings, "palette"); for (const [colorName, colorConfig] of Object.entries(colors.palette.value)) { validateName(colorName, `palette.${colorName}`); const normalizedColorConfig = getPaletteColorConfig(colorConfig); // Validated before the try block: a configuration mistake has to fail - // loudly instead of being logged and skipped per color. - validateFallbackSetting( - normalizedColorConfig.settings?.fallback, - `palette.${colorName}.settings`, - ); + // loudly instead of being logged and skipped per color. A color entry + // written in the shorthand form has no settings to validate, and its + // keys are variant names that may legitimately include "fallback". + if (isPaletteColorConfig(colorConfig)) { + validateFallbackConfig( + colorConfig, + normalizedColorConfig.settings, + `palette.${colorName}`, + ); + } const fallbackFormat = resolveFallbackFormat( normalizedColorConfig.settings, colors.palette.settings, diff --git a/packages/cssforge/tests/oklch-fallback.test.ts b/packages/cssforge/tests/oklch-fallback.test.ts index ea7b14e..92743bd 100644 --- a/packages/cssforge/tests/oklch-fallback.test.ts +++ b/packages/cssforge/tests/oklch-fallback.test.ts @@ -307,3 +307,89 @@ Deno.test("generateCSS - rejects an unsupported fallback format", () => { `Expected the error to name "palette.coral.settings". Received: ${colorError.message}`, ); }); + +Deno.test("generateCSS - rejects a fallback that is not inside settings", () => { + const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; + const misplacedPalette = { + colors: { palette: { ...palette, fallback: "hex" } }, + } as unknown as CSSForgeConfig; + const misplacedColor = { + colors: { + palette: { + value: { + coral: { value: { 100: { hex: "#FF7F50" } }, fallback: "hex" }, + }, + }, + }, + } as unknown as CSSForgeConfig; + const settingsNotAnObject = { + colors: { palette: { ...palette, settings: "hex" } }, + } as unknown as CSSForgeConfig; + + const paletteError = assertThrows(() => generateCSS(misplacedPalette)); + const colorError = assertThrows(() => generateCSS(misplacedColor)); + const settingsError = assertThrows(() => generateCSS(settingsNotAnObject)); + + assert( + paletteError.message.includes('"palette"'), + `Expected the error to name "palette". Received: ${paletteError.message}`, + ); + assert( + colorError.message.includes('"palette.coral"'), + `Expected the error to name "palette.coral". Received: ${colorError.message}`, + ); + assert( + settingsError.message.includes('"palette.settings"'), + `Expected the error to name "palette.settings". Received: ${settingsError.message}`, + ); +}); + +Deno.test("generateCSS - a whitespace-only selector is read as the root scope", () => { + const config = defineConfig({ + colors: { + palette: { + value: { + coral: { + value: { 100: { hex: "#FF7F50" } }, + settings: { selector: " " }, + }, + }, + settings: { fallback: "hex" }, + }, + }, + }); + + assertEquals( + generateCSS(config), + [ + "/*____ CSSForge ____*/", + ":root {", + "/*____ Colors ____*/", + "/* Palette */", + "/* coral */", + "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", + "}", + "@supports not (color: oklch(0% 0 0)) {", + " :root {", + " /* coral */", + " --palette-coral-100: #ff7f50;", + " }", + "}", + ].join("\n"), + ); +}); + +Deno.test("generateCSS - a shorthand color may keep a variant named fallback", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { fallback: { hex: "#FFFFFF" } } }, + }, + }, + }); + + assertEquals( + generateCSS(config).includes("--palette-coral-fallback: oklch(100% 0 0);"), + true, + ); +}); From e6050a43a5c5555b5389fa3e68bc01044f8df199 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 25 Sep 2026 05:20:38 +0700 Subject: [PATCH 5/7] feat(colors): name the color formats and generate them from the CLI 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. --- .changeset/oklch-color-fallback.md | 30 ++- README.md | 52 ++-- example/vanilla-react-css/cssforge.config.ts | 4 +- packages/cssforge/README.md | 52 ++-- packages/cssforge/src/cli.ts | 68 ++++- packages/cssforge/src/generator.ts | 65 +++-- packages/cssforge/src/lib.ts | 19 +- packages/cssforge/src/mod.ts | 3 +- packages/cssforge/src/modules/colors.ts | 239 ++++++++++++------ .../cssforge/tests/cli-color-formats.test.ts | 105 ++++++++ .../cssforge/tests/oklch-fallback.test.ts | 200 +++++++++++---- skills/cssforge/references/cli-reference.md | 8 + skills/cssforge/references/token-patterns.md | 10 +- 13 files changed, 636 insertions(+), 219 deletions(-) create mode 100644 packages/cssforge/tests/cli-color-formats.test.ts diff --git a/.changeset/oklch-color-fallback.md b/.changeset/oklch-color-fallback.md index 4a03237..5a03c45 100644 --- a/.changeset/oklch-color-fallback.md +++ b/.changeset/oklch-color-fallback.md @@ -2,16 +2,24 @@ "@hebilicious/cssforge": minor --- -Add an sRGB fallback for palette colors, so a browser without `oklch()` support still renders them. +Generate palette colors in extra sRGB formats alongside `oklch()`, so a browser without +`oklch()` support still renders them, and so non-CSS consumers can read a legacy value. -Set `fallback` on the palette to cover every color, or on a single color to override it. `"hex"` -writes `#rrggbb` (or `#rrggbbaa` with alpha) and `"rgb"` writes `rgb(r g b)` (or `rgb(r g b / a)`); -`false` opts a color out of an inherited fallback. Each fallback is emitted after the root block, -inside `@supports not (color: oklch(0% 0 0))` and mirroring the color's `atRule` and `selector`, so -it only overrides the declaration it stands in for. A custom property accepts any token stream, so -a duplicate declaration in the same block would not have worked: the modern value wins everywhere, -including where `oklch()` cannot be used. +Set `settings.color.formats` on the palette to cover every color, or on a single color to +override it. `"hex"` writes `#rrggbb` (or `#rrggbbaa` with alpha) and `"rgb"` writes +`rgb(r g b)` (or `rgb(r g b / a)`); `[]` or `false` opts a color out of an inherited value. -The JSON and TypeScript token objects gain a `fallback` field, and the Style Dictionary output -gains `attributes.fallback` and `$fallback`, so non-CSS consumers can read the sRGB value without -converting the color themselves. +A CSS declaration holds one value, so the first configured format is the one emitted for +browsers without `oklch()` support, after the root block and inside +`@supports not (color: oklch(0% 0 0))`, mirroring the color's `atRule` and `selector`. A +duplicate declaration in the same block would not have worked: a custom property accepts any +token stream, so the modern value wins everywhere, including where `oklch()` cannot be used. +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 `color` object keyed by format, such as +`{ hex: "#ff7f50", rgb: "rgb(255 127 80)" }`, and the Style Dictionary output gains +`attributes.color` and `$color`. + +`cssforge --color-formats hex,rgb` generates the formats for a run at the CLI level, appended +to the ones the configuration declares. diff --git a/README.md b/README.md index 33b0ebd..caaf2a0 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ 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 | -| `fallback` | The sRGB value a browser without `oklch()` support falls back to, such as `#ff7f50` | Rendering a palette color where modern color syntax is unavailable. Present only when `fallback` is configured, on the palette or on the color | +| `color` | The color in the requested extra formats, such as `{ "hex": "#ff7f50", "rgb": "rgb(255 127 80)" }` | Reading a palette color as a legacy value without converting it. Present only when `settings.color.formats` is configured, on the palette or on the color | A level with one child is collapsed, so `palette: { value: { coral: ... } }` becomes `cssForge.palette.coral`. Numeric and `@` keys stay strings: @@ -659,13 +659,12 @@ 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. -#### Fallback for browsers without oklch +#### Color formats for browsers without oklch Palette colors are generated in OKLCH. A custom property accepts any token stream, so a browser without `oklch()` support still parses `--palette-coral-100: oklch(...)` and only -fails when the value is used as a color. Set `fallback` to also emit an sRGB declaration -for every color, gated by `@supports not (color: oklch(0% 0 0))`, so the unsupported -browser keeps a usable color: +fails when the value is used as a color. Set `formats` to generate the color in additional +sRGB formats, so the unsupported browser keeps a usable color: -The `fallback` setting is inherited by every color, and a color overrides it in its own -`settings`. Use `"hex"` for `#rrggbb` (or `#rrggbbaa` with alpha) and `"rgb"` for -`rgb(r g b)` (or `rgb(r g b / a)`), or `false` to opt a color out. The fallback declaration -is emitted after the root block and mirrors the color's `atRule` and `selector`, so it only -overrides the declaration it stands in for. Setting it at the palette level needs the -`settings` key next to `value`; a color that carries settings is written with the `value` -wrapper. +The palette setting covers every color, and a color overrides it in its own `settings`. +`[]` or `false` opts a color out of an inherited value. `"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. -The JSON, TypeScript and Style Dictionary outputs carry the same sRGB value as `fallback`, -so a non-CSS consumer can read it without converting the color itself. +A CSS declaration holds one value, so the first format is the one emitted for browsers +without `oklch()` support. It is gated by `@supports not (color: oklch(0% 0 0))` and emitted +after the root block, because a custom property accepts any token stream and the later +declaration wins wherever the modern value is unsupported. The declaration mirrors the +color's `atRule` and `selector`, so it only overrides the declaration it stands in for. + +The JSON, TypeScript and Style Dictionary tokens carry every requested format in a `color` +object, keyed by format, so a non-CSS consumer reads the value it needs without converting +the color itself. 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. + +Setting it at the palette level needs the `settings` key next to `value`; a color that +carries settings is written with the `value` wrapper. The palette is the only family that converts the colors it is given, so it is the only one -that carries a fallback. Themes, gradients and primitives keep their authored values, and -they use the palette fallback through the `var(--palette-...)` references they already -compose with. An `oklch()` written directly into a theme or gradient value stays as it is. +that carries these formats. Themes, gradients and primitives keep their authored values, and +they use the palette value through the `var(--palette-...)` references they already compose +with. An `oklch()` written directly into a theme or gradient value stays as it is. #### Condition @@ -1343,6 +1350,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 + +# Add sRGB formats next to oklch for every palette color, on top of the config's formats +cssforge --color-formats hex,rgb ``` ## Programmatic Usage @@ -1417,9 +1427,9 @@ 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.fallback` | The sRGB value a browser without `oklch()` support falls back to, when `fallback` is configured on the palette or on the color | Emitting a legacy-safe color for a token | +| `attributes.color` | The token's color in the requested extra 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` | -| `$fallback` | The same sRGB value as a top-level field | Tools that read `$fallback` before converting the color themselves | +| `$color` | The same per-format colors as a top-level field | Tools that read `$color` before converting the color themselves | `type` narrows `fontSize`, `lineHeight`, `fontWeight`, `fontFamily`, `borderRadius`, `letterSpacing`, `shadow`, `opacity`, `zIndex`, and `number` when the token's name and value diff --git a/example/vanilla-react-css/cssforge.config.ts b/example/vanilla-react-css/cssforge.config.ts index 8a0b9c9..c2733cd 100644 --- a/example/vanilla-react-css/cssforge.config.ts +++ b/example/vanilla-react-css/cssforge.config.ts @@ -24,11 +24,11 @@ export default defineConfig({ }, }, settings: { - // Generate an sRGB fallback for every palette color, gated by + // Generate a hex value for every palette color, gated by // `@supports not (color: oklch(0% 0 0))`, so the example keeps working in // a browser without `oklch()` support. `tests/oklch-fallback.spec.ts` // asserts the gate from the browser. - fallback: "hex", + color: { formats: ["hex"] }, }, }, theme: { diff --git a/packages/cssforge/README.md b/packages/cssforge/README.md index 33b0ebd..caaf2a0 100644 --- a/packages/cssforge/README.md +++ b/packages/cssforge/README.md @@ -314,7 +314,7 @@ 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 | -| `fallback` | The sRGB value a browser without `oklch()` support falls back to, such as `#ff7f50` | Rendering a palette color where modern color syntax is unavailable. Present only when `fallback` is configured, on the palette or on the color | +| `color` | The color in the requested extra formats, such as `{ "hex": "#ff7f50", "rgb": "rgb(255 127 80)" }` | Reading a palette color as a legacy value without converting it. Present only when `settings.color.formats` is configured, on the palette or on the color | A level with one child is collapsed, so `palette: { value: { coral: ... } }` becomes `cssForge.palette.coral`. Numeric and `@` keys stay strings: @@ -659,13 +659,12 @@ 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. -#### Fallback for browsers without oklch +#### Color formats for browsers without oklch Palette colors are generated in OKLCH. A custom property accepts any token stream, so a browser without `oklch()` support still parses `--palette-coral-100: oklch(...)` and only -fails when the value is used as a color. Set `fallback` to also emit an sRGB declaration -for every color, gated by `@supports not (color: oklch(0% 0 0))`, so the unsupported -browser keeps a usable color: +fails when the value is used as a color. Set `formats` to generate the color in additional +sRGB formats, so the unsupported browser keeps a usable color: -The `fallback` setting is inherited by every color, and a color overrides it in its own -`settings`. Use `"hex"` for `#rrggbb` (or `#rrggbbaa` with alpha) and `"rgb"` for -`rgb(r g b)` (or `rgb(r g b / a)`), or `false` to opt a color out. The fallback declaration -is emitted after the root block and mirrors the color's `atRule` and `selector`, so it only -overrides the declaration it stands in for. Setting it at the palette level needs the -`settings` key next to `value`; a color that carries settings is written with the `value` -wrapper. +The palette setting covers every color, and a color overrides it in its own `settings`. +`[]` or `false` opts a color out of an inherited value. `"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. -The JSON, TypeScript and Style Dictionary outputs carry the same sRGB value as `fallback`, -so a non-CSS consumer can read it without converting the color itself. +A CSS declaration holds one value, so the first format is the one emitted for browsers +without `oklch()` support. It is gated by `@supports not (color: oklch(0% 0 0))` and emitted +after the root block, because a custom property accepts any token stream and the later +declaration wins wherever the modern value is unsupported. The declaration mirrors the +color's `atRule` and `selector`, so it only overrides the declaration it stands in for. + +The JSON, TypeScript and Style Dictionary tokens carry every requested format in a `color` +object, keyed by format, so a non-CSS consumer reads the value it needs without converting +the color itself. 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. + +Setting it at the palette level needs the `settings` key next to `value`; a color that +carries settings is written with the `value` wrapper. The palette is the only family that converts the colors it is given, so it is the only one -that carries a fallback. Themes, gradients and primitives keep their authored values, and -they use the palette fallback through the `var(--palette-...)` references they already -compose with. An `oklch()` written directly into a theme or gradient value stays as it is. +that carries these formats. Themes, gradients and primitives keep their authored values, and +they use the palette value through the `var(--palette-...)` references they already compose +with. An `oklch()` written directly into a theme or gradient value stays as it is. #### Condition @@ -1343,6 +1350,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 + +# Add sRGB formats next to oklch for every palette color, on top of the config's formats +cssforge --color-formats hex,rgb ``` ## Programmatic Usage @@ -1417,9 +1427,9 @@ 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.fallback` | The sRGB value a browser without `oklch()` support falls back to, when `fallback` is configured on the palette or on the color | Emitting a legacy-safe color for a token | +| `attributes.color` | The token's color in the requested extra 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` | -| `$fallback` | The same sRGB value as a top-level field | Tools that read `$fallback` before converting the color themselves | +| `$color` | The same per-format colors as a top-level field | Tools that read `$color` before converting the color themselves | `type` narrows `fontSize`, `lineHeight`, `fontWeight`, `fontFamily`, `borderRadius`, `letterSpacing`, `shadow`, `opacity`, `zIndex`, and `number` when the token's name and value diff --git a/packages/cssforge/src/cli.ts b/packages/cssforge/src/cli.ts index b33df6f..bd7e0f9 100644 --- a/packages/cssforge/src/cli.ts +++ b/packages/cssforge/src/cli.ts @@ -14,13 +14,16 @@ import type { CommandDef } from "citty"; * @module */ import { defineCommand, runMain } from "citty"; +import type { GenerateOptions } from "./generator.ts"; import { generateCSS, generateJSON, generateStyleDictionaryJSON, generateTS, } from "./generator.ts"; +import type { ColorFormat } from "./lib.ts"; import { loadConfig } from "./loader.ts"; +import { isColorFormat, supportedColorFormats } from "./modules/colors.ts"; import { version } from "./version.ts"; /** @@ -58,6 +61,31 @@ const invalidOutputModeMessage = (mode: unknown) => const isStyleDictionaryValueMode = (value: unknown): value is StyleDictionaryValueMode => typeof value === "string" && styleDictionaryValueModes.some((mode) => mode === value); +/** The color formats the CLI accepts, as its help and errors list them. */ +const colorFormatList = supportedColorFormats.join(", "); + +const invalidColorFormatMessage = (format: unknown) => + `Invalid color format: ${String(format)}. Accepted formats: ${colorFormatList}.`; + +/** + * Reads the extra color formats from the comma separated `--color-formats` + * value, such as `hex,rgb`. Duplicates are dropped, and an unknown format is + * rejected with the accepted ones. + */ +export function parseColorFormats(value: unknown): ColorFormat[] | undefined { + if (value === undefined) return undefined; + + const formats: ColorFormat[] = []; + for (const entry of String(value).split(",")) { + const format = entry.trim(); + if (format === "") continue; + if (!isColorFormat(format)) throw new Error(invalidColorFormatMessage(format)); + if (!formats.includes(format)) formats.push(format); + } + + return formats; +} + /** * Defines the options for the build command. */ @@ -76,6 +104,11 @@ export interface BuildOptions { styleDictionaryValueMode?: StyleDictionaryValueMode; /** Path for the TypeScript output file. */ tsOutput: string; + /** + * Extra color formats generated alongside `oklch()`, added to the formats + * the configuration declares. The `--color-formats` flag sets it. + */ + colorFormats?: readonly ColorFormat[]; } /** @@ -103,6 +136,7 @@ export async function build({ jsonOutput, styleDictionaryOutput, styleDictionaryValueMode = "resolved", + colorFormats = [], mode, }: BuildOptions): Promise { try { @@ -114,19 +148,31 @@ export async function build({ if (!isStyleDictionaryValueMode(styleDictionaryValueMode)) { throw new Error(`Invalid Style Dictionary value mode: ${styleDictionaryValueMode}`); } + for (const format of colorFormats) { + // A TypeScript cast does not validate runtime input, so JavaScript + // callers reach this check. + if (!isColorFormat(format)) throw new Error(invalidColorFormatMessage(format)); + } const absoluteCssOutput = resolve(process.cwd(), cssOutput); const absoluteJsonOutput = resolve(process.cwd(), jsonOutput); const absoluteTsOutput = resolve(process.cwd(), tsOutput); const { config: userConfig, dependencies } = await loadConfig(config); + const generateOptions: GenerateOptions = { colorFormats }; if (mode === "css" || mode === "all") { - await writeFileRecursive(absoluteCssOutput, generateCSS(userConfig)); + await writeFileRecursive( + absoluteCssOutput, + generateCSS(userConfig, generateOptions), + ); console.log(`✔ Generated CSS written to ${cssOutput}`); } if (mode === "json" || mode === "all") { - await writeFileRecursive(absoluteJsonOutput, generateJSON(userConfig)); + await writeFileRecursive( + absoluteJsonOutput, + generateJSON(userConfig, generateOptions), + ); console.log(`✔ Generated JSON written to ${jsonOutput}`); } @@ -137,13 +183,14 @@ export async function build({ absoluteStyleDictionaryOutput, generateStyleDictionaryJSON(userConfig, { valueMode: styleDictionaryValueMode, + ...generateOptions, }), ); console.log(`✔ Generated Style Dictionary JSON written to ${outputPath}`); } if (mode === "ts" || mode === "all") { - await writeFileRecursive(absoluteTsOutput, generateTS(userConfig)); + await writeFileRecursive(absoluteTsOutput, generateTS(userConfig, generateOptions)); console.log(`✔ Generated TypeScript written to ${tsOutput}`); } @@ -313,11 +360,16 @@ const mainCommand = defineCommand({ description: "Path for the output TypeScript file", default: "./.cssforge/output.ts", }, + "color-formats": { + type: "string", + description: `Extra color formats generated alongside oklch, comma separated (${colorFormatList})`, + }, }, async run({ args }) { const { watch: shouldWatch, config, css, json, ts, mode, prefix } = args; const styleDictionary = args["style-dictionary"]; const styleDictionaryValueMode = args["style-dictionary-value-mode"]; + const rawColorFormats = args["color-formats"]; if (!isOutputMode(mode)) { console.error(`Error during build: Error: ${invalidOutputModeMessage(mode)}`); process.exit(1); @@ -330,6 +382,15 @@ const mainCommand = defineCommand({ process.exit(1); return; } + let colorFormats: ColorFormat[] | undefined; + try { + colorFormats = parseColorFormats(rawColorFormats); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error during build: Error: ${message}`); + process.exit(1); + return; + } const realPath = (p: string) => resolve(prefix, p); const settings: BuildOptions = { mode, @@ -339,6 +400,7 @@ const mainCommand = defineCommand({ jsonOutput: realPath(json), styleDictionaryOutput: realPath(styleDictionary), styleDictionaryValueMode, + colorFormats, }; if (shouldWatch) { const cleanup = await watch(settings); diff --git a/packages/cssforge/src/generator.ts b/packages/cssforge/src/generator.ts index 2e608f2..046253d 100644 --- a/packages/cssforge/src/generator.ts +++ b/packages/cssforge/src/generator.ts @@ -1,4 +1,5 @@ import type { CSSForgeConfig } from "./config.ts"; +import type { ColorFormat, TokenColorFormats } from "./lib.ts"; import { getTokenScope, type Output, @@ -8,6 +9,7 @@ import { type TokenTier, type TokenType, } from "./lib.ts"; +import type { ColorFormatOptions } from "./modules/colors.ts"; import { processColors } from "./modules/colors.ts"; import { processPrimitives } from "./modules/primitive.ts"; import { processSpacing } from "./modules/spacing.ts"; @@ -17,10 +19,17 @@ type CssValue = { value: string; key: string; variable: string; - /** The sRGB fallback for a color, when the config asked for one. */ - fallback?: string; + /** The extra color values generated alongside `oklch()`, keyed by format. */ + color?: TokenColorFormats; }; +/** + * Options every generated output accepts. + */ +export type GenerateOptions = ColorFormatOptions; + +export type { ColorFormat, TokenColorFormats }; + type ForgeValue = { [key: string]: ForgeValue | CssValue; }; @@ -58,15 +67,15 @@ type StyleDictionaryToken = { */ tailwindVariable: string; resolvedValue: string; - /** The sRGB value a browser without `oklch()` support falls back to. */ - fallback?: string; + /** The extra color values generated alongside `oklch()`, keyed by format. */ + color?: TokenColorFormats; sourcePath: string; referencePaths?: string[]; }; $tier: TokenTier; $reference?: string; $resolvedValue: string; - $fallback?: string; + $color?: TokenColorFormats; }; type StyleDictionaryValue = { @@ -154,9 +163,12 @@ const mergeResolveMaps = ( return resolveMap; }; -const collectResolveMap = (config: Partial): ResolveMap => { +const collectResolveMap = ( + config: Partial, + options: GenerateOptions = {}, +): ResolveMap => { const forge = { - colors: config.colors ? processColors(config.colors) : undefined, + colors: config.colors ? processColors(config.colors, options) : undefined, spacing: config.spacing ? processSpacing(config.spacing) : undefined, typography: config.typography ? processTypography(config.typography) : undefined, primitives: config.primitives @@ -177,9 +189,13 @@ const collectResolveMap = (config: Partial): ResolveMap => { /** * Creates a nested object structure of design tokens from a configuration. * @param config The CSSForge configuration. + * @param options The generation options. * @returns A nested object representing the design tokens. */ -export function createForgeValues(config: Partial) { +export function createForgeValues( + config: Partial, + options: GenerateOptions = {}, +) { type Input = readonly [string, CssValue]; /** @@ -231,7 +247,7 @@ export function createForgeValues(config: Partial) { }, {} as ForgeValue); } - const jsonKeys = [...collectResolveMap(config).entries()].map( + const jsonKeys = [...collectResolveMap(config, options).entries()].map( ([path, token]) => [ path, @@ -239,7 +255,7 @@ export function createForgeValues(config: Partial) { key: token.key, value: token.value, variable: token.variable, - ...(token.fallback ? { fallback: token.fallback } : {}), + ...(token.color ? { color: token.color } : {}), }, ] satisfies Input, ); @@ -404,10 +420,10 @@ const inferValueKind = ( */ export function generateStyleDictionaryJSON( config: Partial, - options: StyleDictionaryJSONOptions = {}, + options: StyleDictionaryJSONOptions & GenerateOptions = {}, ): string { const valueMode = options.valueMode ?? "resolved"; - const resolveMap = collectResolveMap(config); + const resolveMap = collectResolveMap(config, options); const tokensByCssVariable = new Map( [...resolveMap.values()].map((token) => [token.key, token]), ); @@ -437,14 +453,14 @@ export function generateStyleDictionaryJSON( cssVariableReference, tailwindVariable: token.key, resolvedValue, - ...(token.fallback ? { fallback: token.fallback } : {}), + ...(token.color ? { color: token.color } : {}), sourcePath: toStyleDictionaryPath(token.sourcePath), ...(referencePaths ? { referencePaths } : {}), }, $tier: tier, ...(referencePaths?.[0] ? { $reference: referencePaths[0] } : {}), $resolvedValue: resolvedValue, - ...(token.fallback ? { $fallback: token.fallback } : {}), + ...(token.color ? { $color: token.color } : {}), }; const nestedObject = createNestedStyleDictionaryObject( outputPath.split("."), @@ -466,8 +482,11 @@ export function generateStyleDictionaryJSON( * // json: "{\n \"colors\": {\n \"palette\": {\n \"value\": {\n \"red\": {\n \"100\": {\n \"key\": \"--color-red-100\",\n \"value\": \"oklch(62.796% 0.25768 29.23388)\",\n \"variable\": \"--color-red-100: oklch(62.796% 0.25768 29.23388);\"\n }\n }\n }\n }\n }\n}" * ``` */ -export function generateJSON(config: Partial): string { - const forgeValues = createForgeValues(config); +export function generateJSON( + config: Partial, + options: GenerateOptions = {}, +): string { + const forgeValues = createForgeValues(config, options); return JSON.stringify(forgeValues, null, 2); } @@ -481,8 +500,11 @@ export function generateJSON(config: Partial): string { * // ts: "export const cssForge = {\n \"colors\": {\n \"palette\": {\n \"value\": {\n \"red\": {\n \"100\": {\n \"key\": \"--color-red-100\",\n \"value\": \"oklch(62.796% 0.25768 29.23388)\",\n \"variable\": \"--color-red-100: oklch(62.796% 0.25768 29.23388);\"\n }\n }\n }\n }\n }\n} as const;" * ``` */ -export function generateTS(config: Partial): string { - const forgeValues = createForgeValues(config); +export function generateTS( + config: Partial, + options: GenerateOptions = {}, +): string { + const forgeValues = createForgeValues(config, options); const forgeValuesString = JSON.stringify(forgeValues, null, 2); return `export const cssForge = ${forgeValuesString} as const;`; } @@ -497,7 +519,10 @@ export function generateTS(config: Partial): string { * // css: "/*____ CSSForge ____*/\n:root {\n/*____ Colors ____*/\n/* Palette */\n--color-red-100: oklch(62.796% 0.25768 29.23388);\n}" * ``` */ -export function generateCSS(config: Partial): string { +export function generateCSS( + config: Partial, + options: GenerateOptions = {}, +): string { const chunks: string[] = ["/*____ CSSForge ____*/", ":root {"]; const outsideChunks: string[] = []; const processedConfig: { @@ -508,7 +533,7 @@ export function generateCSS(config: Partial): string { // Process colors if present if (config.colors) { - processedConfig.colors = processColors(config.colors); + processedConfig.colors = processColors(config.colors, options); if (processedConfig.colors) { if (processedConfig.colors.css.root) { chunks.push("/*____ Colors ____*/"); diff --git a/packages/cssforge/src/lib.ts b/packages/cssforge/src/lib.ts index aad926e..44947c8 100644 --- a/packages/cssforge/src/lib.ts +++ b/packages/cssforge/src/lib.ts @@ -8,6 +8,19 @@ export type TokenType = export type TokenTier = "primitive" | "semantic"; +/** + * A color value format generated alongside the `oklch()` value. `"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. + */ +export type ColorFormat = "hex" | "rgb"; + +/** + * The extra color values a token carries, keyed by the format that produced + * them, for example `{ hex: "#ff7f50", rgb: "rgb(255 127 80)" }`. + */ +export type TokenColorFormats = Partial>; + /** * Metadata carried through generation so alternate outputs can preserve token * provenance without changing the generated CSS. @@ -39,10 +52,10 @@ export interface ResolvedToken extends TokenMetadata { /** The full CSS declaration. */ variable: string; /** - * The sRGB declaration value a browser without `oklch()` support falls back - * to, when the color opted into a fallback. + * The sRGB values generated for this token alongside `oklch()`, keyed by + * format, when the color asked for them. */ - fallback?: string; + color?: TokenColorFormats; /** * The effective wrapper chain this declaration is emitted into, recorded by * the module that emitted it. Declarations without a wrapper share diff --git a/packages/cssforge/src/mod.ts b/packages/cssforge/src/mod.ts index a057b9d..b04e716 100644 --- a/packages/cssforge/src/mod.ts +++ b/packages/cssforge/src/mod.ts @@ -20,7 +20,8 @@ import { processTypography } from "./modules/typography.ts"; * The main configuration object for CSSForge. */ export type { CSSForgeConfig }; -export type { StyleDictionaryJSONOptions } from "./generator.ts"; +export type { GenerateOptions, StyleDictionaryJSONOptions } from "./generator.ts"; +export type { ColorFormat, TokenColorFormats } from "./lib.ts"; export type { LoadedConfig } from "./loader.ts"; export { diff --git a/packages/cssforge/src/modules/colors.ts b/packages/cssforge/src/modules/colors.ts index 31d35e7..e7d718d 100644 --- a/packages/cssforge/src/modules/colors.ts +++ b/packages/cssforge/src/modules/colors.ts @@ -1,6 +1,6 @@ import Color from "colorjs.io"; import { InvalidNameError, validateName, validateVariableAliases } from "../helpers.ts"; -import type { Variables } from "../lib.ts"; +import type { ColorFormat, TokenColorFormats, Variables } from "../lib.ts"; import { getReferencePaths, getResolvedVariablesMap, @@ -76,44 +76,52 @@ export interface WithCondition { atRule?: string; } /** - * Syntax used for the fallback declaration emitted for browsers without - * `oklch()` support. `"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. + * A color value format emitted alongside the generated `oklch()` value. + * `"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. */ -export type ColorFallbackFormat = "hex" | "rgb"; +export type { ColorFormat } from "../lib.ts"; /** - * Settings for the sRGB fallback that keeps a color usable where `oklch()` is - * not supported. + * Settings for the extra color formats generated next to `oklch()`. */ -export interface ColorFallbackSettings { +export interface ColorFormatConfig { /** - * Emit an sRGB fallback declaration for every color, so a browser without - * `oklch()` support still renders the palette. The fallback is gated by - * `@supports not (color: oklch(0% 0 0))` and emitted after the generated - * root block, because a custom property accepts any token stream and the - * later declaration wins wherever the modern value is unsupported. + * Formats to generate alongside `oklch()`, in order. A CSS declaration + * cannot hold two values, so the first format is the one emitted for + * browsers without `oklch()` support, gated by + * `@supports not (color: oklch(0% 0 0))`. The JSON, TypeScript and Style + * Dictionary tokens carry every requested format. * - * Set it on the palette to cover every color, and on a single color to - * override it or opt out with `false`. + * The palette setting covers every color; a color overrides it in its own + * `settings`, and `[]` or `false` opts out of an inherited value. * * @example * ```ts * colors: { * palette: { * value: { coral: { 100: { hex: "#FF7F50" } } }, - * settings: { fallback: "hex" }, + * settings: { color: { formats: ["hex", "rgb"] } }, * }, * } * ``` */ - fallback?: ColorFallbackFormat | false; + formats?: readonly ColorFormat[] | false; +} + +/** + * Settings for the color values themselves, shared by the palette and its + * colors. + */ +export interface ColorSettings { + /** Extra color formats generated alongside `oklch()`. */ + color?: ColorFormatConfig; } /** * Settings for palette colors, including optional conditions like media queries. */ -export interface PaletteColorSettings extends WithCondition, ColorFallbackSettings {} +export interface PaletteColorSettings extends WithCondition, ColorSettings {} /** * Settings for gradients, including optional conditions like media queries. @@ -207,7 +215,7 @@ export interface ColorConfig { * Settings shared by every palette color. A color's own settings take * precedence. */ - settings?: ColorFallbackSettings; + settings?: ColorSettings; }; /** * A collection of gradients. @@ -331,12 +339,12 @@ const toChannelByte = (coord: number) => const toHexByte = (byte: number) => byte.toString(16).padStart(2, "0"); /** - * Serializers for the supported fallback formats. The accepted formats and the + * Serializers for the supported color formats. The accepted formats and the * syntax they emit come from this one table, so a format cannot be validated * without also being implemented. */ -const fallbackSerializers: Record< - ColorFallbackFormat, +const colorFormatSerializers: Record< + ColorFormat, (red: number, green: number, blue: number, alpha: number) => string > = { hex: (red, green, blue, alpha) => @@ -347,81 +355,133 @@ const fallbackSerializers: Record< `rgb(${red} ${green} ${blue}${alpha === 1 ? "" : ` / ${Number(alpha.toFixed(3))}`})`, }; -const isFallbackFormat = (value: unknown): value is ColorFallbackFormat => - typeof value === "string" && Object.hasOwn(fallbackSerializers, value); +/** Every accepted color format, in the order error messages and the CLI list them. */ +export const supportedColorFormats = Object.keys(colorFormatSerializers) as ColorFormat[]; + +/** Whether a runtime value is one of the accepted color formats. */ +export const isColorFormat = (value: unknown): value is ColorFormat => + typeof value === "string" && Object.hasOwn(colorFormatSerializers, value); /** - * Converts a color value to the sRGB syntax a browser without `oklch()` support - * can render. + * Converts a color value to the sRGB syntax of one format, which is what a + * browser without `oklch()` support can render. * * The conversion uses the CSS gamut mapping algorithm, which is how a browser - * maps an out-of-gamut `oklch()` color, so a saturated fallback stays as close - * to the modern value as sRGB allows. + * maps an out-of-gamut `oklch()` color, so a saturated value stays as close to + * the modern one as sRGB allows. * * @example * ```ts - * colorValueToFallback({ hex: "#ff7f50" }, "hex"); // "#ff7f50" - * colorValueToFallback({ hex: "#ff7f50" }, "rgb"); // "rgb(255 127 80)" + * colorValueToFormat({ hex: "#ff7f50" }, "hex"); // "#ff7f50" + * colorValueToFormat({ hex: "#ff7f50" }, "rgb"); // "rgb(255 127 80)" * ``` */ -function colorValueToFallback( - value: ColorValueOrString, - format: ColorFallbackFormat, -): string { +function colorValueToFormat(value: ColorValueOrString, format: ColorFormat): string { const colorString = typeof value === "string" ? value : getColorString(value); const mapped = new Color(colorString).to("srgb").toGamut(); const alpha = Math.min(Math.max(Number.isNaN(mapped.alpha) ? 1 : mapped.alpha, 0), 1); const [red, green, blue] = mapped.coords.map(toChannelByte); - return fallbackSerializers[format](red, green, blue, alpha); + return colorFormatSerializers[format](red, green, blue, alpha); } -/** The accepted formats as the error message lists them, read from the table. */ -const fallbackFormatList = Object.keys(fallbackSerializers) - .map((format) => `"${format}"`) - .join(", "); +/** + * Converts a color value to every requested format, in the requested order. + */ +const colorValueToFormats = ( + value: ColorValueOrString, + formats: readonly ColorFormat[], +): TokenColorFormats => + Object.fromEntries( + formats.map((format) => [format, colorValueToFormat(value, format)]), + ) as TokenColorFormats; + +/** The accepted formats as the configuration errors list them. */ +const colorFormatList = supportedColorFormats.map((format) => `"${format}"`).join(", "); /** - * Validates the fallback configuration of one palette level. The values come - * from a JavaScript object at runtime, so a setting that generation would ignore - * has to fail loudly instead of silently emitting no fallback. - * - * `entry` is the palette or the palette color itself, and `settings` its - * `settings` value, which is where a fallback belongs. + * Reads the configured formats from one level's `color` settings, rejecting a + * shape generation would otherwise ignore. */ -function validateFallbackConfig(entry: object, settings: unknown, path: string): void { - if (settings !== undefined && !isRecord(settings)) { +function readColorFormats( + settings: unknown, + path: string, +): readonly ColorFormat[] | false { + if (settings === undefined) return false; + if (!isRecord(settings)) { + throw new Error(`Invalid configuration at "${path}": settings must be an object.`); + } + + const color = settings.color; + if (color === undefined) return false; + if (!isRecord(color)) { + throw new Error(`Invalid configuration at "${path}.color": color must be an object.`); + } + + const formats = color.formats; + if (formats === undefined || formats === false) return false; + if (!Array.isArray(formats)) { throw new Error( - `Invalid configuration at "${path}.settings": settings must be an object.`, + `Invalid configuration at "${path}.color.formats": expected an array of formats such as ["hex"], or false.`, ); } - if ("fallback" in entry) { + for (const format of formats) { + if (!isColorFormat(format)) { + throw new Error( + `Invalid color format at configuration path "${path}.color.formats": ${JSON.stringify( + format, + )}. Use ${colorFormatList}.`, + ); + } + } + + return formats as readonly ColorFormat[]; +} + +/** + * Validates the color settings of one palette level, which is where the extra + * formats belong. The values come from a JavaScript object at runtime, so a + * setting generation would ignore has to fail loudly instead of quietly + * producing no extra format. + * + * `entry` is the palette or the palette color itself, so a format written one + * level too high is reported with the path that holds it. + */ +function validateColorSettings(entry: object, settings: unknown, path: string): void { + if ("color" in entry) { throw new Error( - `Invalid configuration at "${path}": "fallback" belongs inside "${path}.settings".`, + `Invalid configuration at "${path}": "color" belongs inside "${path}.settings".`, + ); + } + if ("formats" in entry) { + throw new Error( + `Invalid configuration at "${path}": "formats" belongs inside "${path}.settings.color".`, + ); + } + if (isRecord(settings) && "formats" in settings) { + throw new Error( + `Invalid configuration at "${path}.settings": "formats" belongs inside "${path}.settings.color".`, ); } - const fallback = isRecord(settings) ? settings.fallback : undefined; - if (fallback === undefined || fallback === false || isFallbackFormat(fallback)) return; - - throw new Error( - `Invalid fallback format at configuration path "${path}.settings": ${JSON.stringify( - fallback, - )}. Use ${fallbackFormatList}, or false.`, - ); + readColorFormats(settings, `${path}.settings`); } /** - * Resolves the fallback format for one palette color. A color's own setting wins - * over the palette setting, and `false` opts out of an inherited fallback. + * Resolves the formats generated for one palette color: the color's own list + * wins over the palette's, `false` and `[]` opt out of an inherited list, and + * the formats a caller adds are appended. */ -const resolveFallbackFormat = ( - settings: ColorFallbackSettings | undefined, - paletteSettings: ColorFallbackSettings | undefined, -): ColorFallbackFormat | undefined => { - const fallback = settings?.fallback ?? paletteSettings?.fallback; - return isFallbackFormat(fallback) ? fallback : undefined; +const resolveColorFormats = ( + settings: ColorSettings | undefined, + paletteSettings: ColorSettings | undefined, + extraFormats: readonly ColorFormat[] = [], +): ColorFormat[] => { + const configured = settings?.color?.formats ?? paletteSettings?.color?.formats; + const formats = Array.isArray(configured) ? [...configured] : []; + + return [...new Set([...formats, ...extraFormats])]; }; /** @@ -460,6 +520,17 @@ const renderFallbackBlock = (wrappers: string[], declarations: string[]): string return lines; }; +/** + * Extra color formats generated on top of the ones a configuration declares. + */ +export interface ColorFormatOptions { + /** + * Formats added to the configured ones, so a caller such as the CLI + * `--color-formats` flag generates them without editing the configuration. + */ + colorFormats?: readonly ColorFormat[]; +} + /** * Processes the color configuration to generate CSS variables. * This includes palettes, gradients, and themes. @@ -478,7 +549,10 @@ const renderFallbackBlock = (wrappers: string[], declarations: string[]): string * // output.css: "/* Palette * /;\n--color-red-100: oklch(62.796% 0.25768 29.23388);" * ``` */ -export function processColors(colors: ColorConfig): Output { +export function processColors( + colors: ColorConfig, + options: ColorFormatOptions = {}, +): Output { const rootOutput: string[] = []; const outsideOutput: string[] = []; const resolveMap: ResolveMap = new Map(); @@ -566,7 +640,7 @@ export function processColors(colors: ColorConfig): Output { }; } - validateFallbackConfig(colors.palette, colors.palette.settings, "palette"); + validateColorSettings(colors.palette, colors.palette.settings, "palette"); for (const [colorName, colorConfig] of Object.entries(colors.palette.value)) { validateName(colorName, `palette.${colorName}`); @@ -575,23 +649,24 @@ export function processColors(colors: ColorConfig): Output { // Validated before the try block: a configuration mistake has to fail // loudly instead of being logged and skipped per color. A color entry // written in the shorthand form has no settings to validate, and its - // keys are variant names that may legitimately include "fallback". + // keys are variant names that may legitimately include "color". if (isPaletteColorConfig(colorConfig)) { - validateFallbackConfig( + validateColorSettings( colorConfig, normalizedColorConfig.settings, `palette.${colorName}`, ); } - const fallbackFormat = resolveFallbackFormat( + const formats = resolveColorFormats( normalizedColorConfig.settings, colors.palette.settings, + options.colorFormats, ); - const fallback = fallbackFormat - ? { - format: fallbackFormat, - group: getFallbackGroup(normalizedColorConfig.settings), - } + // One CSS declaration cannot hold two values, so the first format is the + // one a browser without `oklch()` support reads. + const cssFormat = formats.at(0); + const fallback = cssFormat + ? { cssFormat, group: getFallbackGroup(normalizedColorConfig.settings) } : undefined; if (fallback) fallback.group.declarations.push(`/* ${colorName} */`); @@ -606,10 +681,12 @@ export function processColors(colors: ColorConfig): Output { const key = `--${moduleKey}-${colorName}-${variantId}`; const value = colorValueToOklch(colorValue); const variable = `${key}: ${value};`; - const fallbackValue = fallback - ? colorValueToFallback(colorValue, fallback.format) - : undefined; - if (fallbackValue) fallback?.group.declarations.push(`${key}: ${fallbackValue};`); + const colorValues = + formats.length > 0 ? colorValueToFormats(colorValue, formats) : undefined; + const cssValue = fallback ? colorValues?.[fallback.cssFormat] : undefined; + if (fallback && cssValue) { + fallback.group.declarations.push(`${key}: ${cssValue};`); + } handler.pushVariable(variable); @@ -620,7 +697,7 @@ export function processColors(colors: ColorConfig): Output { key, value, variable, - ...(fallbackValue ? { fallback: fallbackValue } : {}), + ...(colorValues ? { color: colorValues } : {}), sourcePath: `${moduleKey}.${colorName}.${variantId}`, type: "color", tier: "primitive", diff --git a/packages/cssforge/tests/cli-color-formats.test.ts b/packages/cssforge/tests/cli-color-formats.test.ts new file mode 100644 index 0000000..a83967e --- /dev/null +++ b/packages/cssforge/tests/cli-color-formats.test.ts @@ -0,0 +1,105 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build, parseColorFormats } from "../src/cli.ts"; +import { assert, assertEquals, assertThrows, Deno } from "./vitest-compat.ts"; + +const configSource = `export default { + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + }, + }, +};`; + +interface BuildPaths { + config: string; + css: string; + json: string; + ts: string; + styleDictionary: string; +} + +const pathsIn = (tempDir: string): BuildPaths => ({ + config: join(tempDir, "cssforge.config.ts"), + css: join(tempDir, "output.css"), + json: join(tempDir, "output.json"), + ts: join(tempDir, "output.ts"), + styleDictionary: join(tempDir, "tokens.sd.json"), +}); + +const buildWithFormats = (paths: BuildPaths, colorFormats: unknown) => + build({ + config: paths.config, + mode: "all", + cssOutput: paths.css, + jsonOutput: paths.json, + tsOutput: paths.ts, + styleDictionaryOutput: paths.styleDictionary, + colorFormats: colorFormats as never, + }); + +Deno.test("parseColorFormats - reads a comma separated list and drops duplicates", () => { + assertEquals(parseColorFormats(undefined), undefined); + assertEquals(parseColorFormats(""), []); + assertEquals(parseColorFormats("hex, rgb"), ["hex", "rgb"]); + assertEquals(parseColorFormats("rgb,hex,rgb"), ["rgb", "hex"]); +}); + +Deno.test("parseColorFormats - rejects a format the generator cannot emit", () => { + const error = assertThrows(() => parseColorFormats("hex,hsl")); + + assert( + error.message.includes("hsl") && error.message.includes("hex"), + `Expected the error to name the rejected value and the accepted formats. Received: ${error.message}`, + ); +}); + +Deno.test("build - --color-formats adds the formats to every output", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "cssforge-color-formats-")); + + try { + const paths = pathsIn(tempDir); + await writeFile(paths.config, configSource, "utf8"); + + const result = await buildWithFormats(paths, parseColorFormats("hex,rgb")); + + assertEquals(result.success, true); + + const css = await readFile(paths.css, "utf8"); + const json = JSON.parse(await readFile(paths.json, "utf8")); + const ts = await readFile(paths.ts, "utf8"); + const styleDictionary = JSON.parse(await readFile(paths.styleDictionary, "utf8")); + + assertEquals(css.includes("--palette-coral-100: #ff7f50;"), true); + assertEquals(json.palette.coral["100"].color, { + hex: "#ff7f50", + rgb: "rgb(255 127 80)", + }); + assertEquals(ts.includes('"rgb": "rgb(255 127 80)"'), true); + assertEquals(styleDictionary.palette.coral["100"].$color, { + hex: "#ff7f50", + rgb: "rgb(255 127 80)", + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +Deno.test("build - rejects an unknown color format for JavaScript callers", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "cssforge-color-formats-invalid-")); + + try { + const paths = pathsIn(tempDir); + await writeFile(paths.config, configSource, "utf8"); + + const result = await buildWithFormats(paths, ["hsl"]); + + assertEquals(result.success, false); + const message = + result.error instanceof Error ? result.error.message : String(result.error); + assertEquals(message.includes("hsl"), true); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/cssforge/tests/oklch-fallback.test.ts b/packages/cssforge/tests/oklch-fallback.test.ts index 92743bd..8531c04 100644 --- a/packages/cssforge/tests/oklch-fallback.test.ts +++ b/packages/cssforge/tests/oklch-fallback.test.ts @@ -5,19 +5,20 @@ import { defineConfig, generateCSS, generateStyleDictionaryJSON } from "../src/m import { assert, assertEquals, assertThrows, Deno } from "./vitest-compat.ts"; /** - * The fallback value declared for `key` inside the generated `@supports` block, - * read from the block rather than the root declaration above it. + * The value declared for `key` inside the generated `@supports` block, read from + * the block rather than the root declaration above it. */ -const fallbackValue = (css: string, key: string): string => { +const declaredValue = (css: string, key: string): string => { const block = css.slice(css.indexOf("@supports")); const match = new RegExp(`${key}: ([^;]+);`).exec(block); - if (!match) throw new Error(`Expected a fallback declaration for ${key} in:\n${css}`); + if (!match) + throw new Error(`Expected a color format declaration for ${key} in:\n${css}`); return match[1]; }; -Deno.test("generateCSS - palette fallback emits an sRGB hex declaration under @supports", () => { +Deno.test("generateCSS - configured formats emit an sRGB declaration under @supports", () => { const config = defineConfig({ colors: { palette: { @@ -26,7 +27,7 @@ Deno.test("generateCSS - palette fallback emits an sRGB hex declaration under @s 100: { hex: "#FF7F50" }, }, }, - settings: { fallback: "hex" }, + settings: { color: { formats: ["hex"] } }, }, }, }); @@ -51,12 +52,31 @@ Deno.test("generateCSS - palette fallback emits an sRGB hex declaration under @s ); }); +Deno.test("generateCSS - the first format is the CSS declaration and all formats reach the token", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { color: { formats: ["rgb", "hex"] } }, + }, + }, + }); + + const css = generateCSS(config); + + assertEquals(declaredValue(css, "--palette-coral-100"), "rgb(255 127 80)"); + assertEquals(JSON.parse(generateJSON(config)).palette.coral["100"].color, { + rgb: "rgb(255 127 80)", + hex: "#ff7f50", + }); +}); + Deno.test("generateCSS - a palette color without variants emits no fallback block", () => { const config = defineConfig({ colors: { palette: { value: { empty: { value: {} } }, - settings: { fallback: "hex" }, + settings: { color: { formats: ["hex"] } }, }, }, }); @@ -64,7 +84,7 @@ Deno.test("generateCSS - a palette color without variants emits no fallback bloc assertEquals(generateCSS(config).includes("@supports"), false); }); -Deno.test("generateCSS - applies the palette format with per-color overrides", () => { +Deno.test("generateCSS - applies the palette formats with per-color overrides", () => { const config = defineConfig({ colors: { palette: { @@ -73,14 +93,14 @@ Deno.test("generateCSS - applies the palette format with per-color overrides", ( soft: { 100: "rgb(0 0 0 / 12%)" }, brand: { value: { 100: { hex: "#FF0000" } }, - settings: { fallback: "hex" }, + settings: { color: { formats: ["hex"] } }, }, opted: { value: { 100: { hex: "#00FF00" } }, - settings: { fallback: false }, + settings: { color: { formats: [] } }, }, }, - settings: { fallback: "rgb" }, + settings: { color: { formats: ["rgb"] } }, }, }, }); @@ -112,7 +132,7 @@ Deno.test("generateCSS - a hex fallback keeps alpha as an eight digit hex", () = value: { soft: { 100: "rgb(0 0 0 / 12%)" }, }, - settings: { fallback: "hex" }, + settings: { color: { formats: ["hex"] } }, }, }, }); @@ -140,24 +160,24 @@ Deno.test("generateCSS - a hex fallback keeps alpha as an eight digit hex", () = Deno.test("generateCSS - a wide gamut color falls back to the CSS gamut mapped sRGB value", () => { // `oklch(70% 0.4 20)` cannot be shown in sRGB: its conversion is // `rgb(336 -117 10)`, and clipping each channel would give `#ff000a`. The - // fallback keeps the authored hue instead, which is how a browser maps the - // color it cannot display. + // declaration keeps the authored hue instead, which is how a browser maps + // the color it cannot display. const config = defineConfig({ colors: { palette: { value: { vivid: { 100: { oklch: "oklch(70% 0.4 20)" } } }, - settings: { fallback: "hex" }, + settings: { color: { formats: ["hex"] } }, }, }, }); - const fallback = fallbackValue(generateCSS(config), "--palette-vivid-100"); + const declared = declaredValue(generateCSS(config), "--palette-vivid-100"); - assertEquals(fallback === "#ff000a", false); - assertEquals(new Color(fallback).inGamut("srgb", { epsilon: 0 }), true); + assertEquals(declared === "#ff000a", false); + assertEquals(new Color(declared).inGamut("srgb", { epsilon: 0 }), true); }); -Deno.test("generateCSS - fallback mirrors the color selector and at-rule", () => { +Deno.test("generateCSS - the declaration mirrors the color selector and at-rule", () => { const config = defineConfig({ colors: { palette: { @@ -175,7 +195,7 @@ Deno.test("generateCSS - fallback mirrors the color selector and at-rule", () => settings: { atRule: "@container (min-width: 40rem)", selector: ".card" }, }, }, - settings: { fallback: "hex" }, + settings: { color: { formats: ["hex"] } }, }, }, }); @@ -213,12 +233,66 @@ Deno.test("generateCSS - fallback mirrors the color selector and at-rule", () => ); }); -Deno.test("generateJSON and generateTS - fallback is part of the token object", () => { +Deno.test("generateCSS - the colorFormats option adds formats without editing the config", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + }, + }, + }); + + const css = generateCSS(config, { colorFormats: ["hex", "rgb"] }); + + assertEquals(declaredValue(css, "--palette-coral-100"), "#ff7f50"); + assertEquals(JSON.parse(generateJSON(config, { colorFormats: ["hex", "rgb"] })), { + palette: { + coral: { + "100": { + 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: "#ff7f50", rgb: "rgb(255 127 80)" }, + }, + }, + }, + }); +}); + +Deno.test("generateCSS - the option appends to the configured formats", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { color: { formats: ["rgb"] } }, + }, + }, + }); + + // The configuration picks the CSS declaration, and the option only adds. + const css = generateCSS(config, { colorFormats: ["hex", "rgb"] }); + + assertEquals(declaredValue(css, "--palette-coral-100"), "rgb(255 127 80)"); + assertEquals(JSON.parse(generateJSON(config, { colorFormats: ["hex"] })), { + palette: { + coral: { + "100": { + key: "--palette-coral-100", + value: "oklch(73.511% 0.16799 40.24666)", + variable: "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", + color: { rgb: "rgb(255 127 80)", hex: "#ff7f50" }, + }, + }, + }, + }); +}); + +Deno.test("generateJSON and generateTS - every requested format is part of the token object", () => { const config = defineConfig({ colors: { palette: { value: { coral: { 100: { hex: "#FF7F50" } } }, - settings: { fallback: "hex" }, + settings: { color: { formats: ["hex", "rgb"] } }, }, }, }); @@ -230,15 +304,15 @@ Deno.test("generateJSON and generateTS - fallback is part of the token object", key: "--palette-coral-100", value: "oklch(73.511% 0.16799 40.24666)", variable: "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", - fallback: "#ff7f50", + color: { hex: "#ff7f50", rgb: "rgb(255 127 80)" }, }, }, }, }); - assertEquals(generateTS(config).includes('"fallback": "#ff7f50"'), true); + assertEquals(generateTS(config).includes('"rgb": "rgb(255 127 80)"'), true); }); -Deno.test("generateJSON - token objects omit the fallback when none is configured", () => { +Deno.test("generateJSON - token objects omit the color field when none is configured", () => { const config = defineConfig({ colors: { palette: { @@ -260,27 +334,27 @@ Deno.test("generateJSON - token objects omit the fallback when none is configure }); }); -Deno.test("generateStyleDictionaryJSON - exposes the fallback beside the resolved value", () => { +Deno.test("generateStyleDictionaryJSON - exposes the formats beside the resolved value", () => { const config = defineConfig({ colors: { palette: { value: { coral: { 100: { hex: "#FF7F50" } } }, - settings: { fallback: "hex" }, + settings: { color: { formats: ["hex", "rgb"] } }, }, }, }); const token = JSON.parse(generateStyleDictionaryJSON(config)).palette.coral["100"]; - assertEquals(token.$fallback, "#ff7f50"); - assertEquals(token.attributes.fallback, "#ff7f50"); + assertEquals(token.$color, { hex: "#ff7f50", rgb: "rgb(255 127 80)" }); + assertEquals(token.attributes.color, { hex: "#ff7f50", rgb: "rgb(255 127 80)" }); assertEquals(token.$resolvedValue, "oklch(73.511% 0.16799 40.24666)"); }); -Deno.test("generateCSS - rejects an unsupported fallback format", () => { +Deno.test("generateCSS - rejects an unsupported color format", () => { const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; const invalidPalette = { - colors: { palette: { ...palette, settings: { fallback: "oklch" } } }, + colors: { palette: { ...palette, settings: { color: { formats: ["oklch"] } } } }, } as unknown as CSSForgeConfig; const invalidColor = { colors: { @@ -288,7 +362,7 @@ Deno.test("generateCSS - rejects an unsupported fallback format", () => { value: { coral: { value: { 100: { hex: "#FF7F50" } }, - settings: { fallback: "sqrgb" }, + settings: { color: { formats: ["sqrgb"] } }, }, }, }, @@ -299,36 +373,58 @@ Deno.test("generateCSS - rejects an unsupported fallback format", () => { const colorError = assertThrows(() => generateCSS(invalidColor)); assert( - paletteError.message.includes('"palette.settings"'), - `Expected the error to name "palette.settings". Received: ${paletteError.message}`, + paletteError.message.includes('"palette.settings.color.formats"'), + `Expected the error to name "palette.settings.color.formats". Received: ${paletteError.message}`, ); assert( - colorError.message.includes('"palette.coral.settings"'), - `Expected the error to name "palette.coral.settings". Received: ${colorError.message}`, + colorError.message.includes('"palette.coral.settings.color.formats"'), + `Expected the error to name "palette.coral.settings.color.formats". Received: ${colorError.message}`, ); }); -Deno.test("generateCSS - rejects a fallback that is not inside settings", () => { +Deno.test("generateCSS - rejects formats that are not an array of formats", () => { const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; - const misplacedPalette = { - colors: { palette: { ...palette, fallback: "hex" } }, + const formatsNotAnArray = { + colors: { palette: { ...palette, settings: { color: { formats: "hex" } } } }, } as unknown as CSSForgeConfig; - const misplacedColor = { + const settingsNotAnObject = { + colors: { palette: { ...palette, settings: "color" } }, + } as unknown as CSSForgeConfig; + + const formatsError = assertThrows(() => generateCSS(formatsNotAnArray)); + const settingsError = assertThrows(() => generateCSS(settingsNotAnObject)); + + assert( + formatsError.message.includes('"palette.settings.color.formats"'), + `Expected the error to name "palette.settings.color.formats". Received: ${formatsError.message}`, + ); + assert( + settingsError.message.includes('"palette.settings"'), + `Expected the error to name "palette.settings". Received: ${settingsError.message}`, + ); +}); + +Deno.test("generateCSS - rejects color settings that are not inside settings", () => { + const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; + const colorOnPalette = { + colors: { palette: { ...palette, color: { formats: ["hex"] } } }, + } as unknown as CSSForgeConfig; + const colorOnColor = { colors: { palette: { value: { - coral: { value: { 100: { hex: "#FF7F50" } }, fallback: "hex" }, + coral: { value: { 100: { hex: "#FF7F50" } }, color: { formats: ["hex"] } }, }, }, }, } as unknown as CSSForgeConfig; - const settingsNotAnObject = { - colors: { palette: { ...palette, settings: "hex" } }, + const formatsInSettings = { + colors: { palette: { ...palette, settings: { formats: ["hex"] } } }, } as unknown as CSSForgeConfig; - const paletteError = assertThrows(() => generateCSS(misplacedPalette)); - const colorError = assertThrows(() => generateCSS(misplacedColor)); - const settingsError = assertThrows(() => generateCSS(settingsNotAnObject)); + const paletteError = assertThrows(() => generateCSS(colorOnPalette)); + const colorError = assertThrows(() => generateCSS(colorOnColor)); + const formatsError = assertThrows(() => generateCSS(formatsInSettings)); assert( paletteError.message.includes('"palette"'), @@ -339,8 +435,8 @@ Deno.test("generateCSS - rejects a fallback that is not inside settings", () => `Expected the error to name "palette.coral". Received: ${colorError.message}`, ); assert( - settingsError.message.includes('"palette.settings"'), - `Expected the error to name "palette.settings". Received: ${settingsError.message}`, + formatsError.message.includes('"palette.settings.color"'), + `Expected the error to name "palette.settings.color". Received: ${formatsError.message}`, ); }); @@ -354,7 +450,7 @@ Deno.test("generateCSS - a whitespace-only selector is read as the root scope", settings: { selector: " " }, }, }, - settings: { fallback: "hex" }, + settings: { color: { formats: ["hex"] } }, }, }, }); @@ -379,17 +475,17 @@ Deno.test("generateCSS - a whitespace-only selector is read as the root scope", ); }); -Deno.test("generateCSS - a shorthand color may keep a variant named fallback", () => { +Deno.test("generateCSS - a shorthand color may keep a variant named color", () => { const config = defineConfig({ colors: { palette: { - value: { coral: { fallback: { hex: "#FFFFFF" } } }, + value: { coral: { color: { hex: "#FFFFFF" } } }, }, }, }); assertEquals( - generateCSS(config).includes("--palette-coral-fallback: oklch(100% 0 0);"), + generateCSS(config).includes("--palette-coral-color: oklch(100% 0 0);"), true, ); }); diff --git a/skills/cssforge/references/cli-reference.md b/skills/cssforge/references/cli-reference.md index 4f52a88..9216db6 100644 --- a/skills/cssforge/references/cli-reference.md +++ b/skills/cssforge/references/cli-reference.md @@ -11,6 +11,10 @@ CLI implementation: `packages/cssforge/src/cli.ts` - `--css`: css output path (default `./.cssforge/output.css`) - `--json`: json output path (default `./.cssforge/output.json`) - `--ts`: ts output path (default `./.cssforge/output.ts`) +- `--style-dictionary`: Style Dictionary token JSON path (default `./.cssforge/tokens.sd.json`) +- `--style-dictionary-value-mode`: `resolved | css-reference` (default `resolved`) +- `--color-formats`: comma separated sRGB formats generated alongside oklch (`hex`, `rgb`), + appended to the formats `settings.color.formats` declares ## Typical commands from docs @@ -21,6 +25,8 @@ with npm, `pnpm cssforge` with pnpm): - Watch: `npx cssforge --watch` - Custom paths: - `npx cssforge --config ./path/cssforge.config.ts --css ./dist/tokens.css --ts ./dist/tokens.ts --json ./dist/tokens.json --mode all` +- Extra color formats: + - `npx cssforge --mode all --color-formats hex,rgb` Deno projects run the same CLI from the secondary JSR channel: @@ -32,3 +38,5 @@ From `README.md`: - `import { generateCSS } from "@hebilicious/cssforge";` - `const css = generateCSS(config);` +- `generateCSS(config, { colorFormats: ["hex", "rgb"] })` adds the extra color formats to + the ones the config declares diff --git a/skills/cssforge/references/token-patterns.md b/skills/cssforge/references/token-patterns.md index d2d582e..0ffb87c 100644 --- a/skills/cssforge/references/token-patterns.md +++ b/skills/cssforge/references/token-patterns.md @@ -7,10 +7,12 @@ All patterns below are derived from README configuration examples. - Palette tokens under `colors.palette.value`. - Supports color formats like hex/rgb/hsl/oklch and string values. - Themes and gradients can reference palette entries with `variables` maps. -- Set `fallback` to `"hex"` or `"rgb"` on `colors.palette.settings` (or on one color's - `settings`) to emit an sRGB declaration under `@supports not (color: oklch(0% 0 0))` for - browsers without `oklch()`. A color opts out with `false`. The generated JSON, TypeScript - and Style Dictionary tokens carry the same value as `fallback`. +- Set `settings.color.formats` to `["hex", "rgb"]` on `colors.palette` (or on one color's + `settings`) to generate those sRGB formats alongside `oklch()`. The first format is the + declaration emitted under `@supports not (color: oklch(0% 0 0))` for browsers without + `oklch()`; every format reaches the JSON, TypeScript and Style Dictionary tokens as the + token's `color` object. A color opts out with `[]` or `false`, and `--color-formats` + appends formats at run time. ## Spacing From 29bbaf64975dc0347960ee532af58f467b4338cf Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 25 Sep 2026 13:07:24 +0700 Subject: [PATCH 6/7] feat(colors): select the color outputs, the fallback format and alpha 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`. --- .changeset/oklch-color-fallback.md | 34 +- README.md | 96 ++- example/vanilla-react-css/cssforge.config.ts | 2 +- packages/cssforge/README.md | 96 ++- packages/cssforge/src/lib.ts | 27 +- packages/cssforge/src/mod.ts | 7 +- packages/cssforge/src/modules/colors.ts | 602 +++++++++++++---- .../cssforge/tests/cli-color-formats.test.ts | 10 +- packages/cssforge/tests/color-formats.test.ts | 637 ++++++++++++++++++ .../cssforge/tests/oklch-fallback.test.ts | 491 -------------- skills/cssforge/references/cli-reference.md | 3 +- skills/cssforge/references/token-patterns.md | 16 +- 12 files changed, 1296 insertions(+), 725 deletions(-) create mode 100644 packages/cssforge/tests/color-formats.test.ts delete mode 100644 packages/cssforge/tests/oklch-fallback.test.ts diff --git a/.changeset/oklch-color-fallback.md b/.changeset/oklch-color-fallback.md index 5a03c45..b5f83f0 100644 --- a/.changeset/oklch-color-fallback.md +++ b/.changeset/oklch-color-fallback.md @@ -3,23 +3,29 @@ --- Generate palette colors in extra sRGB formats alongside `oklch()`, so a browser without -`oklch()` support still renders them, and so non-CSS consumers can read a legacy value. +`oklch()` support still renders them, and so non-CSS consumers read the value they need. -Set `settings.color.formats` on the palette to cover every color, or on a single color to -override it. `"hex"` writes `#rrggbb` (or `#rrggbbaa` with alpha) and `"rgb"` writes -`rgb(r g b)` (or `rgb(r g b / a)`); `[]` or `false` opts a color out of an inherited value. +`settings.color.formats` selects the formats and the exact outputs to generate. `hex` +produces its CSS value `"#ff7f50"`, the digits `"ff7f50"`, and the number `0xff7f50`; `rgb` +produces `"rgb(255 127 80)"` and `[255, 127, 80]`. A format set to `true` produces its CSS +value only, so `{ hex: true }` stays the short form. -A CSS declaration holds one value, so the first configured format is the one emitted for -browsers without `oklch()` support, after the root block and inside -`@supports not (color: oklch(0% 0 0))`, mirroring the color's `atRule` and `selector`. A -duplicate declaration in the same block would not have worked: a custom property accepts any -token stream, so the modern value wins everywhere, including where `oklch()` cannot be used. -A color outside sRGB uses the CSS gamut mapping algorithm, which is how a browser maps a -color its display cannot show. +`settings.color.fallback` names the format whose `string` value is the declaration emitted +for browsers without `oklch()` support, after the root block and inside +`@supports not (color: oklch(0% 0 0))`, mirroring the color's `atRule` and `selector`. It +defaults to the first generated format, and `false` emits no declaration. A duplicate +declaration in the same block would not have worked: a custom property accepts any token +stream, so the modern value wins everywhere, including where `oklch()` cannot be used. 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 `color` object keyed by format, such as -`{ hex: "#ff7f50", rgb: "rgb(255 127 80)" }`, and the Style Dictionary output gains -`attributes.color` and `$color`. +`settings.color.alpha` controls the alpha of the generated color: `true` keeps the alpha the +color carries, a number between 0 and 1 replaces it everywhere including the `oklch()` value, +and `false` rejects a color that carries alpha. + +The JSON and TypeScript token objects gain a `color` object keyed by format and output, such +as `{ hex: { string: "#ff7f50", number: 16744272 }, rgb: { array: [255, 127, 80] } }`, and +the Style Dictionary output gains `attributes.color` and `$color`. `cssforge --color-formats hex,rgb` generates the formats for a run at the CLI level, appended to the ones the configuration declares. diff --git a/README.md b/README.md index caaf2a0..6c10b3d 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ 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 color in the requested extra formats, such as `{ "hex": "#ff7f50", "rgb": "rgb(255 127 80)" }` | Reading a palette color as a legacy value without converting it. Present only when `settings.color.formats` is configured, on the palette or on the color | +| `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 only when `settings.color.formats` is configured, on the palette or on the color | A level with one child is collapsed, so `palette: { value: { coral: ... } }` becomes `cssForge.palette.coral`. Numeric and `@` keys stay strings: @@ -663,8 +663,9 @@ reference uses its fallback. Palette colors are generated in OKLCH. A custom property accepts any token stream, so a browser without `oklch()` support still parses `--palette-coral-100: oklch(...)` and only -fails when the value is used as a color. Set `formats` to generate the color in additional -sRGB formats, so the unsupported browser keeps a usable color: +fails when the value is used as a color. Set `formats` to generate the same color in sRGB +formats, so the unsupported browser keeps a usable color and non-CSS consumers read the +value they need: -The palette setting covers every color, and a color overrides it in its own `settings`. -`[]` or `false` opts a color out of an inherited value. `"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. - -A CSS declaration holds one value, so the first format is the one emitted for browsers -without `oklch()` support. It is gated by `@supports not (color: oklch(0% 0 0))` and emitted -after the root block, because a custom property accepts any token stream and the later -declaration wins wherever the modern value is unsupported. The declaration mirrors the -color's `atRule` and `selector`, so it only overrides the declaration it stands in for. +| 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 (`string`). A color with alpha carries it in +every output: `#ff7f50aa`, `ff7f50aa`, `0xff7f50aa`, `rgb(255 127 80 / 0.667)` and +`[255, 127, 80, 0.667]`. + +`fallback` names the format whose `string` value becomes the declaration for browsers +without `oklch()` support. It defaults to the first generated format, has to be generated +with its `string` output, and `false` emits no declaration so the formats only reach the +tokens. The declaration is gated by `@supports not (color: oklch(0% 0 0))` and emitted after +the root block, because a custom property accepts any token stream and the later declaration +wins wherever the modern value is unsupported. It mirrors the color's `atRule` and +`selector`, so it only overrides the declaration it stands in for. + +`alpha` controls the alpha of the generated color. `true` (the default) keeps the alpha the +color carries. A number between 0 and 1 replaces it, which generates the color at that +opacity everywhere, including its `oklch()` value. `false` rejects a color that carries +alpha. + +The palette settings cover every color, and a color replaces them in its own `settings`, so a +color that only sets `fallback` keeps the palette's formats. The JSON, TypeScript and Style +Dictionary tokens carry the generated values in a `color` object, keyed by format and output: -The JSON, TypeScript and Style Dictionary tokens carry every requested format in a `color` -object, keyed by format, so a non-CSS consumer reads the value it needs without converting -the color itself. 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. +```json +{ + "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] } + } +} +``` -Setting it at the palette level needs the `settings` key next to `value`; a color that -carries settings is written with the `value` wrapper. +Setting the palette formats needs the `settings` key next to `value`; a color that carries +settings is written with the `value` wrapper. The palette is the only family that converts the colors it is given, so it is the only one -that carries these formats. Themes, gradients and primitives keep their authored values, and -they use the palette value through the `var(--palette-...)` references they already compose -with. An `oklch()` written directly into a theme or gradient value stays as it is. +that generates these formats. Themes, gradients and primitives keep their authored values, +and they use the palette value through the `var(--palette-...)` references they already +compose with. An `oklch()` written directly into a theme or gradient value stays as it is. #### Condition @@ -1351,7 +1393,7 @@ 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 -# Add sRGB formats next to oklch for every palette color, on top of the config's formats +# Generate sRGB formats next to oklch for every palette color, added to the config's formats cssforge --color-formats hex,rgb ``` @@ -1427,9 +1469,9 @@ 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 color in the requested extra formats, when `settings.color.formats` is configured | Emitting a legacy-safe color for a token | +| `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 colors as a top-level field | Tools that read `$color` before converting the color themselves | +| `$color` | The same per-format values as a top-level field | Tools that read `$color` before converting the color themselves | `type` narrows `fontSize`, `lineHeight`, `fontWeight`, `fontFamily`, `borderRadius`, `letterSpacing`, `shadow`, `opacity`, `zIndex`, and `number` when the token's name and value diff --git a/example/vanilla-react-css/cssforge.config.ts b/example/vanilla-react-css/cssforge.config.ts index c2733cd..98b2c43 100644 --- a/example/vanilla-react-css/cssforge.config.ts +++ b/example/vanilla-react-css/cssforge.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ // `@supports not (color: oklch(0% 0 0))`, so the example keeps working in // a browser without `oklch()` support. `tests/oklch-fallback.spec.ts` // asserts the gate from the browser. - color: { formats: ["hex"] }, + color: { formats: { hex: true } }, }, }, theme: { diff --git a/packages/cssforge/README.md b/packages/cssforge/README.md index caaf2a0..6c10b3d 100644 --- a/packages/cssforge/README.md +++ b/packages/cssforge/README.md @@ -314,7 +314,7 @@ 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 color in the requested extra formats, such as `{ "hex": "#ff7f50", "rgb": "rgb(255 127 80)" }` | Reading a palette color as a legacy value without converting it. Present only when `settings.color.formats` is configured, on the palette or on the color | +| `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 only when `settings.color.formats` is configured, on the palette or on the color | A level with one child is collapsed, so `palette: { value: { coral: ... } }` becomes `cssForge.palette.coral`. Numeric and `@` keys stay strings: @@ -663,8 +663,9 @@ reference uses its fallback. Palette colors are generated in OKLCH. A custom property accepts any token stream, so a browser without `oklch()` support still parses `--palette-coral-100: oklch(...)` and only -fails when the value is used as a color. Set `formats` to generate the color in additional -sRGB formats, so the unsupported browser keeps a usable color: +fails when the value is used as a color. Set `formats` to generate the same color in sRGB +formats, so the unsupported browser keeps a usable color and non-CSS consumers read the +value they need: -The palette setting covers every color, and a color overrides it in its own `settings`. -`[]` or `false` opts a color out of an inherited value. `"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. - -A CSS declaration holds one value, so the first format is the one emitted for browsers -without `oklch()` support. It is gated by `@supports not (color: oklch(0% 0 0))` and emitted -after the root block, because a custom property accepts any token stream and the later -declaration wins wherever the modern value is unsupported. The declaration mirrors the -color's `atRule` and `selector`, so it only overrides the declaration it stands in for. +| 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 (`string`). A color with alpha carries it in +every output: `#ff7f50aa`, `ff7f50aa`, `0xff7f50aa`, `rgb(255 127 80 / 0.667)` and +`[255, 127, 80, 0.667]`. + +`fallback` names the format whose `string` value becomes the declaration for browsers +without `oklch()` support. It defaults to the first generated format, has to be generated +with its `string` output, and `false` emits no declaration so the formats only reach the +tokens. The declaration is gated by `@supports not (color: oklch(0% 0 0))` and emitted after +the root block, because a custom property accepts any token stream and the later declaration +wins wherever the modern value is unsupported. It mirrors the color's `atRule` and +`selector`, so it only overrides the declaration it stands in for. + +`alpha` controls the alpha of the generated color. `true` (the default) keeps the alpha the +color carries. A number between 0 and 1 replaces it, which generates the color at that +opacity everywhere, including its `oklch()` value. `false` rejects a color that carries +alpha. + +The palette settings cover every color, and a color replaces them in its own `settings`, so a +color that only sets `fallback` keeps the palette's formats. The JSON, TypeScript and Style +Dictionary tokens carry the generated values in a `color` object, keyed by format and output: -The JSON, TypeScript and Style Dictionary tokens carry every requested format in a `color` -object, keyed by format, so a non-CSS consumer reads the value it needs without converting -the color itself. 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. +```json +{ + "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] } + } +} +``` -Setting it at the palette level needs the `settings` key next to `value`; a color that -carries settings is written with the `value` wrapper. +Setting the palette formats needs the `settings` key next to `value`; a color that carries +settings is written with the `value` wrapper. The palette is the only family that converts the colors it is given, so it is the only one -that carries these formats. Themes, gradients and primitives keep their authored values, and -they use the palette value through the `var(--palette-...)` references they already compose -with. An `oklch()` written directly into a theme or gradient value stays as it is. +that generates these formats. Themes, gradients and primitives keep their authored values, +and they use the palette value through the `var(--palette-...)` references they already +compose with. An `oklch()` written directly into a theme or gradient value stays as it is. #### Condition @@ -1351,7 +1393,7 @@ 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 -# Add sRGB formats next to oklch for every palette color, on top of the config's formats +# Generate sRGB formats next to oklch for every palette color, added to the config's formats cssforge --color-formats hex,rgb ``` @@ -1427,9 +1469,9 @@ 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 color in the requested extra formats, when `settings.color.formats` is configured | Emitting a legacy-safe color for a token | +| `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 colors as a top-level field | Tools that read `$color` before converting the color themselves | +| `$color` | The same per-format values as a top-level field | Tools that read `$color` before converting the color themselves | `type` narrows `fontSize`, `lineHeight`, `fontWeight`, `fontFamily`, `borderRadius`, `letterSpacing`, `shadow`, `opacity`, `zIndex`, and `number` when the token's name and value diff --git a/packages/cssforge/src/lib.ts b/packages/cssforge/src/lib.ts index 44947c8..4e5bc69 100644 --- a/packages/cssforge/src/lib.ts +++ b/packages/cssforge/src/lib.ts @@ -15,11 +15,32 @@ export type TokenTier = "primitive" | "semantic"; */ export type ColorFormat = "hex" | "rgb"; +/** The values a token carries for the `hex` format. */ +export interface HexColorValues { + /** The CSS value, such as `"#ff7f50"`. */ + string?: string; + /** The digits without `#`, such as `"ff7f50"`. */ + digits?: string; + /** The digits as a number in RGBA byte order, such as `0xff7f50aa`. */ + number?: number; +} + +/** The values a token carries for the `rgb` format. */ +export interface RgbColorValues { + /** The CSS value, such as `"rgb(255 127 80)"`. */ + string?: string; + /** The channels, with a fourth element holding the alpha when the color has one. */ + array?: number[]; +} + /** - * The extra color values a token carries, keyed by the format that produced - * them, for example `{ hex: "#ff7f50", rgb: "rgb(255 127 80)" }`. + * The color values a token carries alongside `oklch()`, keyed by format. Only + * the formats and representations the configuration asked for are present. */ -export type TokenColorFormats = Partial>; +export interface TokenColorFormats { + hex?: HexColorValues; + rgb?: RgbColorValues; +} /** * Metadata carried through generation so alternate outputs can preserve token diff --git a/packages/cssforge/src/mod.ts b/packages/cssforge/src/mod.ts index b04e716..0c89544 100644 --- a/packages/cssforge/src/mod.ts +++ b/packages/cssforge/src/mod.ts @@ -21,7 +21,12 @@ import { processTypography } from "./modules/typography.ts"; */ export type { CSSForgeConfig }; export type { GenerateOptions, StyleDictionaryJSONOptions } from "./generator.ts"; -export type { ColorFormat, TokenColorFormats } from "./lib.ts"; +export type { + ColorFormat, + HexColorValues, + RgbColorValues, + TokenColorFormats, +} from "./lib.ts"; export type { LoadedConfig } from "./loader.ts"; export { diff --git a/packages/cssforge/src/modules/colors.ts b/packages/cssforge/src/modules/colors.ts index e7d718d..0e1e979 100644 --- a/packages/cssforge/src/modules/colors.ts +++ b/packages/cssforge/src/modules/colors.ts @@ -1,6 +1,12 @@ import Color from "colorjs.io"; import { InvalidNameError, validateName, validateVariableAliases } from "../helpers.ts"; -import type { ColorFormat, TokenColorFormats, Variables } from "../lib.ts"; +import type { + ColorFormat, + HexColorValues, + RgbColorValues, + TokenColorFormats, + Variables, +} from "../lib.ts"; import { getReferencePaths, getResolvedVariablesMap, @@ -83,30 +89,80 @@ export interface WithCondition { export type { ColorFormat } from "../lib.ts"; /** - * Settings for the extra color formats generated next to `oklch()`. + * The values one color format can produce. + * + * `hex` writes `#rrggbb` (or `#rrggbbaa` with alpha), the same digits without + * `#`, and the digits as a 32-bit number in RGBA byte order (`0xff7f50aa`). + * `rgb` writes `rgb(r g b)` (or `rgb(r g b / a)` with alpha) and the channels as + * an array, with a fourth element holding the alpha when the color has one. + */ +export interface HexFormatOutputs { + /** The CSS value, such as `"#ff7f50"`. */ + string?: boolean; + /** The digits without `#`, such as `"ff7f50"`. */ + digits?: boolean; + /** The digits as a number, such as `0xff7f50`. */ + number?: boolean; +} + +/** The values `rgb` can produce. */ +export interface RgbFormatOutputs { + /** The CSS value, such as `"rgb(255 127 80)"`. */ + string?: boolean; + /** The channels, such as `[255, 127, 80]`. */ + array?: boolean; +} + +/** + * Settings for the color values generated next to `oklch()`, shared by the + * palette and its colors. */ export interface ColorFormatConfig { /** - * Formats to generate alongside `oklch()`, in order. A CSS declaration - * cannot hold two values, so the first format is the one emitted for - * browsers without `oklch()` support, gated by - * `@supports not (color: oklch(0% 0 0))`. The JSON, TypeScript and Style - * Dictionary tokens carry every requested format. + * Formats generated alongside `oklch()`, keyed by format. A format set to + * `true` produces its CSS value; an object selects the representations to + * include, so `{ hex: { digits: true } }` produces only `"ff7f50"`. * - * The palette setting covers every color; a color overrides it in its own - * `settings`, and `[]` or `false` opts out of an inherited value. + * The palette setting covers every color, and a color replaces it in its own + * `settings`. * * @example * ```ts * colors: { * palette: { * value: { coral: { 100: { hex: "#FF7F50" } } }, - * settings: { color: { formats: ["hex", "rgb"] } }, + * settings: { + * color: { + * formats: { hex: { string: true, digits: true }, rgb: true }, + * fallback: "hex", + * }, + * }, * }, * } * ``` */ - formats?: readonly ColorFormat[] | false; + formats?: { + hex?: boolean | HexFormatOutputs; + rgb?: boolean | RgbFormatOutputs; + }; + /** + * The format emitted as the CSS declaration for browsers without `oklch()` + * support, gated by `@supports not (color: oklch(0% 0 0))`. The format has to + * be generated, and to include its `string` value. + * + * Defaults to the first generated format. `false` emits no declaration, so + * the formats only reach the JSON, TypeScript and Style Dictionary tokens. + */ + fallback?: ColorFormat | false; + /** + * Alpha handling for the generated color. + * + * `true` (the default) keeps the alpha the color carries. A number between 0 + * and 1 replaces it, which generates the color at that opacity everywhere, + * including its `oklch()` value. `false` rejects a color that carries alpha, + * and no representation then holds an alpha channel. + */ + alpha?: boolean | number; } /** @@ -302,19 +358,151 @@ function getColorString(value: ColorValue): string { throw new Error("Invalid color value"); } +/** The condition a format declaration is gated by. */ +const OKLCH_SUPPORT_CONDITION = "@supports not (color: oklch(0% 0 0))"; + /** - * Converts a color value to the OKLCH color space. + * The representations a format can produce, keyed by the name a configuration + * enables. The accepted representations and the values they generate come from + * this one table, so a representation cannot be validated without also being + * implemented. + */ +type ColorFormatValue = string | number | number[]; + +/** The CSS value of one format, which is the declaration a browser without `oklch()` support reads. */ +const colorFormatDeclarations: Record string> = { + hex: (color) => `#${hexDigits(color)}`, + rgb: (color) => `rgb(${srgbBytes(color).join(" ")}${alphaSuffix(color)})`, +}; + +const colorFormatOutputs: Record< + ColorFormat, + Record ColorFormatValue> +> = { + hex: { + string: colorFormatDeclarations.hex, + digits: (color) => hexDigits(color), + number: (color) => Number.parseInt(hexDigits(color), 16), + }, + rgb: { + string: colorFormatDeclarations.rgb, + array: (color) => + alphaValue(color) === 1 + ? srgbBytes(color) + : [...srgbBytes(color), alphaValue(color)], + }, +}; + +type ColorFormatOutput = string; + +/** Every accepted format name, in the order error messages and the CLI list them. */ +export const supportedColorFormats = Object.keys(colorFormatOutputs) as ColorFormat[]; + +/** Whether a runtime value is one of the accepted format names. */ +export const isColorFormat = (value: unknown): value is ColorFormat => + typeof value === "string" && Object.hasOwn(colorFormatOutputs, value); + +/** The accepted format names as the configuration errors list them. */ +const colorFormatList = supportedColorFormats.map((format) => `"${format}"`).join(", "); + +/** The representations `format` accepts, in the order they are generated. */ +const outputsOf = (format: ColorFormat): readonly ColorFormatOutput[] => + Object.keys(colorFormatOutputs[format]); + +const toChannelByte = (coord: number) => + Math.round(Math.min(Math.max(Number.isNaN(coord) ? 0 : coord, 0), 1) * 255); + +const toHexByte = (byte: number) => byte.toString(16).padStart(2, "0"); + +/** + * The color channels mapped into the sRGB gamut. The conversion uses the CSS + * gamut mapping algorithm, which is how a browser maps an out-of-gamut + * `oklch()` color, so a saturated value stays as close to the modern one as + * sRGB allows. + */ +const srgbBytes = (color: Color): number[] => + color.to("srgb").toGamut().coords.map(toChannelByte); + +/** The alpha the generated values carry, clamped to 0-1 and rounded to three decimals. */ +const alphaValue = (color: Color): number => + Math.round( + Math.min(Math.max(Number.isNaN(color.alpha) ? 1 : color.alpha, 0), 1) * 1000, + ) / 1000; + +/** `" / 0.12"`, or an empty string for an opaque color. */ +const alphaSuffix = (color: Color) => + alphaValue(color) === 1 ? "" : ` / ${alphaValue(color)}`; + +/** The hex digits of the color, with the alpha byte when it carries alpha. */ +const hexDigits = (color: Color) => { + const digits = srgbBytes(color).map(toHexByte).join(""); + return alphaValue(color) === 1 + ? digits + : `${digits}${toHexByte(Math.round(alphaValue(color) * 255))}`; +}; + +/** + * The color as it is generated, with the alpha policy applied: the alpha the + * color carries, a replacement, or a rejection of a color that has one. + */ +const readColor = ( + value: ColorValueOrString, + alpha: boolean | number, + path: string, +): Color => { + const colorString = typeof value === "string" ? value : getColorString(value); + const color = new Color(colorString); + + if (alpha === false && color.alpha !== 1) { + throw new Error( + `Invalid color at "${path}": the color carries alpha, but "settings.color.alpha" is false.`, + ); + } + + if (typeof alpha === "number") color.alpha = alpha; + + return color; +}; + +/** The alpha a color value carries, or undefined when the value is not a color. */ +const colorAlpha = (value: ColorValueOrString): number | undefined => { + try { + const colorString = typeof value === "string" ? value : getColorString(value); + return new Color(colorString).alpha; + } catch { + // The emission pass reports a value it cannot parse. + return undefined; + } +}; + +/** + * Rejects a variant that carries alpha under an `alpha: false` policy, before + * generation, so the mistake fails loudly instead of skipping the color with a + * log line. + */ +const assertOpaqueVariants = ( + variants: Record, + path: string, +): void => { + for (const [variantId, value] of Object.entries(variants)) { + const alpha = colorAlpha(value); + if (alpha !== undefined && alpha !== 1) { + throw new Error( + `Invalid color at "${path}.${variantId}": the color carries alpha, but "settings.color.alpha" is false.`, + ); + } + } +}; + +/** + * Converts a color to the OKLCH value of a generated token. * @example * ```ts - * colorValueToOklch({ hex: "#ff0000" }); // "oklch(62.796% 0.25768 29.23388)" - * colorValueToOklch("blue"); // "oklch(45.201% 0.31321 264.05202)" + * colorToOklch(new Color("#ff0000")); // "oklch(62.796% 0.25768 29.23388)" * ``` */ -function colorValueToOklch(value: ColorValueOrString): string { - const colorString = typeof value === "string" ? value : getColorString(value); - const color = new Color(colorString); +function colorToOklch(color: Color): string { const oklchColor = color.to("oklch"); - const parsedCoords = oklchColor.coords.map((coord) => Number.isNaN(coord) ? 0 : coord, ); @@ -326,162 +514,266 @@ function colorValueToOklch(value: ColorValueOrString): string { return `oklch(${Number((l * 100).toFixed(3))}% ${c} ${h}${alpha})`; } -/** - * The condition every fallback declaration is gated by. A browser without - * `oklch()` support parses the feature as unsupported, so the negation matches - * there and only there. - */ -const OKLCH_SUPPORT_CONDITION = "@supports not (color: oklch(0% 0 0))"; - -const toChannelByte = (coord: number) => - Math.round(Math.min(Math.max(Number.isNaN(coord) ? 0 : coord, 0), 1) * 255); - -const toHexByte = (byte: number) => byte.toString(16).padStart(2, "0"); +/** One generated format: the format, and the representations it produces. */ +interface GeneratedFormat { + format: ColorFormat; + outputs: readonly ColorFormatOutput[]; +} /** - * Serializers for the supported color formats. The accepted formats and the - * syntax they emit come from this one table, so a format cannot be validated - * without also being implemented. + * Converts a color to every requested representation of every requested format, + * in the order they are generated. */ -const colorFormatSerializers: Record< - ColorFormat, - (red: number, green: number, blue: number, alpha: number) => string -> = { - hex: (red, green, blue, alpha) => - `#${toHexByte(red)}${toHexByte(green)}${toHexByte(blue)}${ - alpha === 1 ? "" : toHexByte(Math.round(alpha * 255)) - }`, - rgb: (red, green, blue, alpha) => - `rgb(${red} ${green} ${blue}${alpha === 1 ? "" : ` / ${Number(alpha.toFixed(3))}`})`, -}; +const colorToFormats = ( + color: Color, + formats: readonly GeneratedFormat[], +): TokenColorFormats => { + const values: TokenColorFormats = {}; + + for (const { format, outputs } of formats) { + const generated: Record = {}; + for (const output of outputs) { + generated[output] = colorFormatOutputs[format][output](color); + } + if (format === "hex") values.hex = generated as HexColorValues; + else values.rgb = generated as RgbColorValues; + } -/** Every accepted color format, in the order error messages and the CLI list them. */ -export const supportedColorFormats = Object.keys(colorFormatSerializers) as ColorFormat[]; + return values; +}; -/** Whether a runtime value is one of the accepted color formats. */ -export const isColorFormat = (value: unknown): value is ColorFormat => - typeof value === "string" && Object.hasOwn(colorFormatSerializers, value); +/** The CSS value of one format, which is the declaration a browser without `oklch()` support reads. */ +const colorToDeclaration = (color: Color, format: ColorFormat): string => + colorFormatDeclarations[format](color); /** - * Converts a color value to the sRGB syntax of one format, which is what a - * browser without `oklch()` support can render. - * - * The conversion uses the CSS gamut mapping algorithm, which is how a browser - * maps an out-of-gamut `oklch()` color, so a saturated value stays as close to - * the modern one as sRGB allows. - * - * @example - * ```ts - * colorValueToFormat({ hex: "#ff7f50" }, "hex"); // "#ff7f50" - * colorValueToFormat({ hex: "#ff7f50" }, "rgb"); // "rgb(255 127 80)" - * ``` + * The color settings of one level, normalized. A field the level does not set is + * inherited from the level above it, so a color that only names a fallback keeps + * the palette's formats. */ -function colorValueToFormat(value: ColorValueOrString, format: ColorFormat): string { - const colorString = typeof value === "string" ? value : getColorString(value); - const mapped = new Color(colorString).to("srgb").toGamut(); - const alpha = Math.min(Math.max(Number.isNaN(mapped.alpha) ? 1 : mapped.alpha, 0), 1); - const [red, green, blue] = mapped.coords.map(toChannelByte); - - return colorFormatSerializers[format](red, green, blue, alpha); +interface ColorFormatSettings { + formats?: GeneratedFormat[]; + fallback?: ColorFormat | false; + alpha?: boolean | number; } -/** - * Converts a color value to every requested format, in the requested order. - */ -const colorValueToFormats = ( - value: ColorValueOrString, - formats: readonly ColorFormat[], -): TokenColorFormats => - Object.fromEntries( - formats.map((format) => [format, colorValueToFormat(value, format)]), - ) as TokenColorFormats; +/** The settings of one generated color, with every field resolved. */ +interface ResolvedColorFormatSettings { + formats: GeneratedFormat[]; + fallback?: ColorFormat | false; + alpha: boolean | number; +} -/** The accepted formats as the configuration errors list them. */ -const colorFormatList = supportedColorFormats.map((format) => `"${format}"`).join(", "); +const defaultFormats = (format: ColorFormat): GeneratedFormat => ({ + format, + outputs: ["string"], +}); /** - * Reads the configured formats from one level's `color` settings, rejecting a - * shape generation would otherwise ignore. + * Reads and validates the `color` settings of one palette level. The values come + * from a JavaScript object at runtime, so a setting generation would ignore has + * to fail loudly instead of quietly producing nothing. */ -function readColorFormats( +function readColorFormatSettings( + entry: object, settings: unknown, path: string, -): readonly ColorFormat[] | false { - if (settings === undefined) return false; +): ColorFormatSettings | undefined { + if ("color" in entry) { + throw new Error( + `Invalid configuration at "${path}": "color" belongs inside "${path}.settings".`, + ); + } + if ("formats" in entry || "fallback" in entry || "alpha" in entry) { + throw new Error( + `Invalid configuration at "${path}": color settings belong inside "${path}.settings.color".`, + ); + } + + if (settings === undefined) return undefined; if (!isRecord(settings)) { - throw new Error(`Invalid configuration at "${path}": settings must be an object.`); + throw new Error( + `Invalid configuration at "${path}.settings": settings must be an object.`, + ); + } + + const settingsPath = `${path}.settings`; + if ("formats" in settings || "fallback" in settings || "alpha" in settings) { + throw new Error( + `Invalid configuration at "${settingsPath}": color settings belong inside "${settingsPath}.color".`, + ); } const color = settings.color; - if (color === undefined) return false; + if (color === undefined) return undefined; if (!isRecord(color)) { - throw new Error(`Invalid configuration at "${path}.color": color must be an object.`); + throw new Error( + `Invalid configuration at "${settingsPath}.color": color must be an object.`, + ); + } + + const colorSettings: ColorFormatSettings = {}; + if (color.formats !== undefined) { + colorSettings.formats = readFormats(color.formats, `${settingsPath}.color.formats`); + } + if (color.fallback !== undefined) { + colorSettings.fallback = readFallback( + color.fallback, + `${settingsPath}.color.fallback`, + ); } + if (color.alpha !== undefined) { + colorSettings.alpha = readAlpha(color.alpha, `${settingsPath}.color.alpha`); + } + + return colorSettings; +} - const formats = color.formats; - if (formats === undefined || formats === false) return false; - if (!Array.isArray(formats)) { +const readFormats = (value: unknown, path: string): GeneratedFormat[] => { + if (value === undefined || value === false) return []; + if (!isRecord(value)) { throw new Error( - `Invalid configuration at "${path}.color.formats": expected an array of formats such as ["hex"], or false.`, + `Invalid configuration at "${path}": expected formats such as { hex: true }, or omit it.`, ); } - for (const format of formats) { - if (!isColorFormat(format)) { + const formats: GeneratedFormat[] = []; + for (const [name, outputs] of Object.entries(value)) { + if (!isColorFormat(name)) { throw new Error( - `Invalid color format at configuration path "${path}.color.formats": ${JSON.stringify( - format, + `Invalid color format at configuration path "${path}": ${JSON.stringify( + name, )}. Use ${colorFormatList}.`, ); } + + formats.push({ + format: name, + outputs: readOutputs(name, outputs, `${path}.${name}`), + }); } - return formats as readonly ColorFormat[]; -} + return formats; +}; -/** - * Validates the color settings of one palette level, which is where the extra - * formats belong. The values come from a JavaScript object at runtime, so a - * setting generation would ignore has to fail loudly instead of quietly - * producing no extra format. - * - * `entry` is the palette or the palette color itself, so a format written one - * level too high is reported with the path that holds it. - */ -function validateColorSettings(entry: object, settings: unknown, path: string): void { - if ("color" in entry) { +const readOutputs = ( + format: ColorFormat, + value: unknown, + path: string, +): readonly ColorFormatOutput[] => { + if (value === undefined || value === false) return []; + if (value === true) return ["string"]; + if (!isRecord(value)) { throw new Error( - `Invalid configuration at "${path}": "color" belongs inside "${path}.settings".`, + `Invalid configuration at "${path}": expected true or an object of outputs such as { string: true }.`, + ); + } + + const allowed = outputsOf(format); + const outputs: ColorFormatOutput[] = []; + for (const [name, enabled] of Object.entries(value)) { + if (!allowed.includes(name as ColorFormatOutput)) { + throw new Error( + `Invalid ${format} output at configuration path "${path}": ${JSON.stringify( + name, + )}. Use ${allowed.map((output) => `"${output}"`).join(", ")}.`, + ); + } + if (enabled !== true && enabled !== false) { + throw new Error( + `Invalid configuration at "${path}.${name}": expected true or false, received ${JSON.stringify( + enabled, + )}.`, + ); + } + if (enabled) outputs.push(name as ColorFormatOutput); + } + + if (outputs.length === 0) { + throw new Error( + `Invalid configuration at "${path}": enable at least one output, such as { string: true }.`, ); } - if ("formats" in entry) { + + return outputs; +}; + +const readFallback = (value: unknown, path: string): ColorFormat | false | undefined => { + if (value === undefined || value === false) return value; + if (!isColorFormat(value)) { throw new Error( - `Invalid configuration at "${path}": "formats" belongs inside "${path}.settings.color".`, + `Invalid fallback format at configuration path "${path}": ${JSON.stringify( + value, + )}. Use ${colorFormatList}, or false.`, ); } - if (isRecord(settings) && "formats" in settings) { + + return value; +}; + +const readAlpha = (value: unknown, path: string): boolean | number => { + if (value === undefined || typeof value === "boolean") return value ?? true; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) { throw new Error( - `Invalid configuration at "${path}.settings": "formats" belongs inside "${path}.settings.color".`, + `Invalid alpha at configuration path "${path}": ${JSON.stringify( + value, + )}. Use true, false, or a number between 0 and 1.`, ); } - readColorFormats(settings, `${path}.settings`); -} + return value; +}; /** - * Resolves the formats generated for one palette color: the color's own list - * wins over the palette's, `false` and `[]` opt out of an inherited list, and - * the formats a caller adds are appended. + * Resolves the settings of one palette color: the color's own settings replace + * the palette's, and the formats a caller adds are appended to the result. */ -const resolveColorFormats = ( - settings: ColorSettings | undefined, - paletteSettings: ColorSettings | undefined, +const resolveColorFormatSettings = ( + settings: ColorFormatSettings | undefined, + paletteSettings: ColorFormatSettings | undefined, extraFormats: readonly ColorFormat[] = [], -): ColorFormat[] => { - const configured = settings?.color?.formats ?? paletteSettings?.color?.formats; - const formats = Array.isArray(configured) ? [...configured] : []; +): ResolvedColorFormatSettings => { + const formats = [...(settings?.formats ?? paletteSettings?.formats ?? [])]; + for (const format of extraFormats) { + if (!formats.some((entry) => entry.format === format)) { + formats.push(defaultFormats(format)); + } + } + + return { + formats, + fallback: settings?.fallback ?? paletteSettings?.fallback, + alpha: settings?.alpha ?? paletteSettings?.alpha ?? true, + }; +}; + +/** + * The format whose `string` output becomes the CSS declaration. A named fallback + * has to be generated and to include that output, and the default is the first + * generated format. + */ +const resolveDeclarationFormat = ({ + formats, + fallback, +}: ResolvedColorFormatSettings): ColorFormat | undefined => { + if (fallback === false) return undefined; + + const format = fallback ?? formats.at(0)?.format; + if (format === undefined) return undefined; + + const generated = formats.find((entry) => entry.format === format); + if (!generated) { + throw new Error( + `Invalid fallback format: "${format}" is not generated. Add it to "settings.color.formats", or use false.`, + ); + } + if (!generated.outputs.includes("string")) { + throw new Error( + `Invalid fallback format: "${format}" does not generate its "string" output. Enable it, or use false.`, + ); + } - return [...new Set([...formats, ...extraFormats])]; + return format; }; /** @@ -640,33 +932,38 @@ export function processColors( }; } - validateColorSettings(colors.palette, colors.palette.settings, "palette"); + const paletteSettings = readColorFormatSettings( + colors.palette, + colors.palette.settings, + "palette", + ); for (const [colorName, colorConfig] of Object.entries(colors.palette.value)) { validateName(colorName, `palette.${colorName}`); const normalizedColorConfig = getPaletteColorConfig(colorConfig); - // Validated before the try block: a configuration mistake has to fail - // loudly instead of being logged and skipped per color. A color entry - // written in the shorthand form has no settings to validate, and its - // keys are variant names that may legitimately include "color". - if (isPaletteColorConfig(colorConfig)) { - validateColorSettings( - colorConfig, - normalizedColorConfig.settings, - `palette.${colorName}`, - ); - } - const formats = resolveColorFormats( - normalizedColorConfig.settings, - colors.palette.settings, + // Read before the try block: a configuration mistake has to fail loudly + // instead of being logged and skipped per color. A color entry written in + // the shorthand form has no settings to read, and its keys are variant + // names that may legitimately include "color". + const colorSettings = resolveColorFormatSettings( + isPaletteColorConfig(colorConfig) + ? readColorFormatSettings( + colorConfig, + normalizedColorConfig.settings, + `palette.${colorName}`, + ) + : undefined, + paletteSettings, options.colorFormats, ); - // One CSS declaration cannot hold two values, so the first format is the - // one a browser without `oklch()` support reads. - const cssFormat = formats.at(0); - const fallback = cssFormat - ? { cssFormat, group: getFallbackGroup(normalizedColorConfig.settings) } + if (colorSettings.alpha === false) { + assertOpaqueVariants(normalizedColorConfig.value, `palette.${colorName}`); + } + + const declarationFormat = resolveDeclarationFormat(colorSettings); + const fallback = declarationFormat + ? { declarationFormat, group: getFallbackGroup(normalizedColorConfig.settings) } : undefined; if (fallback) fallback.group.declarations.push(`/* ${colorName} */`); @@ -679,12 +976,19 @@ export function processColors( for (const [variantId, colorValue] of Object.entries(normalizedColorConfig.value)) { validateName(variantId, `palette.${colorName}.${variantId}`); const key = `--${moduleKey}-${colorName}-${variantId}`; - const value = colorValueToOklch(colorValue); + const color = readColor( + colorValue, + colorSettings.alpha, + `palette.${colorName}.${variantId}`, + ); + const value = colorToOklch(color); const variable = `${key}: ${value};`; const colorValues = - formats.length > 0 ? colorValueToFormats(colorValue, formats) : undefined; - const cssValue = fallback ? colorValues?.[fallback.cssFormat] : undefined; - if (fallback && cssValue) { + colorSettings.formats.length > 0 + ? colorToFormats(color, colorSettings.formats) + : undefined; + if (fallback) { + const cssValue = colorToDeclaration(color, fallback.declarationFormat); fallback.group.declarations.push(`${key}: ${cssValue};`); } diff --git a/packages/cssforge/tests/cli-color-formats.test.ts b/packages/cssforge/tests/cli-color-formats.test.ts index a83967e..52d82d6 100644 --- a/packages/cssforge/tests/cli-color-formats.test.ts +++ b/packages/cssforge/tests/cli-color-formats.test.ts @@ -73,13 +73,13 @@ Deno.test("build - --color-formats adds the formats to every output", async () = assertEquals(css.includes("--palette-coral-100: #ff7f50;"), true); assertEquals(json.palette.coral["100"].color, { - hex: "#ff7f50", - rgb: "rgb(255 127 80)", + hex: { string: "#ff7f50" }, + rgb: { string: "rgb(255 127 80)" }, }); - assertEquals(ts.includes('"rgb": "rgb(255 127 80)"'), true); + assertEquals(ts.includes('"rgb": {'), true); assertEquals(styleDictionary.palette.coral["100"].$color, { - hex: "#ff7f50", - rgb: "rgb(255 127 80)", + hex: { string: "#ff7f50" }, + rgb: { string: "rgb(255 127 80)" }, }); } finally { await rm(tempDir, { recursive: true, force: true }); diff --git a/packages/cssforge/tests/color-formats.test.ts b/packages/cssforge/tests/color-formats.test.ts new file mode 100644 index 0000000..698da34 --- /dev/null +++ b/packages/cssforge/tests/color-formats.test.ts @@ -0,0 +1,637 @@ +import Color from "colorjs.io"; +import { generateJSON, generateTS } from "../src/generator.ts"; +import type { CSSForgeConfig } from "../src/mod.ts"; +import { defineConfig, generateCSS, generateStyleDictionaryJSON } from "../src/mod.ts"; +import { assert, assertEquals, assertThrows, Deno } from "./vitest-compat.ts"; + +/** + * The value declared for `key` inside the generated `@supports` block, read from + * the block rather than the root declaration above it. + */ +const declaredValue = (css: string, key: string): string => { + const block = css.slice(css.indexOf("@supports")); + const match = new RegExp(`${key}: ([^;]+);`).exec(block); + + if (!match) + throw new Error(`Expected a color format declaration for ${key} in:\n${css}`); + + return match[1]; +}; + +const tokenColor = (config: Partial) => + JSON.parse(generateJSON(config)).palette.coral["100"].color; + +Deno.test("generateCSS - a format emits its CSS value under @supports", () => { + const config = defineConfig({ + colors: { + palette: { + value: { + coral: { + 100: { hex: "#FF7F50" }, + }, + }, + settings: { color: { formats: { hex: true } } }, + }, + }, + }); + + assertEquals( + generateCSS(config), + [ + "/*____ CSSForge ____*/", + ":root {", + "/*____ Colors ____*/", + "/* Palette */", + "/* coral */", + "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", + "}", + "@supports not (color: oklch(0% 0 0)) {", + " :root {", + " /* coral */", + " --palette-coral-100: #ff7f50;", + " }", + "}", + ].join("\n"), + ); +}); + +Deno.test("generateJSON - hex produces the string, digits and number outputs", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { + color: { formats: { hex: { string: true, digits: true, number: true } } }, + }, + }, + }, + }); + + assertEquals(tokenColor(config), { + hex: { string: "#ff7f50", digits: "ff7f50", number: 16744272 }, + }); +}); + +Deno.test("generateJSON - rgb produces the string and array outputs", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { color: { formats: { rgb: { string: true, array: true } } } }, + }, + }, + }); + + assertEquals(tokenColor(config), { + rgb: { string: "rgb(255 127 80)", array: [255, 127, 80] }, + }); +}); + +Deno.test("generateJSON - a color with alpha carries it in every output", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: "rgb(0 0 0 / 12%)" } }, + settings: { + color: { + formats: { + hex: { string: true, digits: true, number: true }, + rgb: { string: true, array: true }, + }, + }, + }, + }, + }, + }); + + assertEquals(tokenColor(config), { + hex: { string: "#0000001f", digits: "0000001f", number: 31 }, + rgb: { string: "rgb(0 0 0 / 0.12)", array: [0, 0, 0, 0.12] }, + }); +}); + +Deno.test("generateCSS - fallback selects the format of the CSS declaration", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { + color: { formats: { hex: true, rgb: true }, fallback: "rgb" }, + }, + }, + }, + }); + + const css = generateCSS(config); + + assertEquals(declaredValue(css, "--palette-coral-100"), "rgb(255 127 80)"); + assertEquals(tokenColor(config), { + hex: { string: "#ff7f50" }, + rgb: { string: "rgb(255 127 80)" }, + }); +}); + +Deno.test("generateCSS - fallback false keeps the formats out of the CSS", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { color: { formats: { hex: true }, fallback: false } }, + }, + }, + }); + + const css = generateCSS(config); + + assertEquals(css.includes("@supports"), false); + assertEquals(tokenColor(config), { hex: { string: "#ff7f50" } }); +}); + +Deno.test("generateCSS - rejects a fallback format that is not generated", () => { + const config = { + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { color: { formats: { hex: true }, fallback: "rgb" } }, + }, + }, + } as unknown as CSSForgeConfig; + + const error = assertThrows(() => generateCSS(config)); + + assert( + error.message.includes('"rgb"'), + `Expected the error to name the missing format. Received: ${error.message}`, + ); +}); + +Deno.test("generateCSS - rejects a fallback format without its string output", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { + color: { formats: { hex: { digits: true } }, fallback: "hex" }, + }, + }, + }, + }); + + const error = assertThrows(() => generateCSS(config)); + + assert( + error.message.includes('"string"'), + `Expected the error to ask for the string output. Received: ${error.message}`, + ); +}); + +Deno.test("generateJSON - alpha replaces the alpha of the color everywhere", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#000000" } } }, + settings: { + color: { + formats: { + hex: { string: true, digits: true }, + rgb: { string: true, array: true }, + }, + alpha: 0.5, + }, + }, + }, + }, + }); + + const token = JSON.parse(generateJSON(config)).palette.coral["100"]; + + assertEquals(token.value, "oklch(0% 0 0 / 50%)"); + assertEquals(token.color, { + hex: { string: "#00000080", digits: "00000080" }, + rgb: { string: "rgb(0 0 0 / 0.5)", array: [0, 0, 0, 0.5] }, + }); +}); + +Deno.test("generateCSS - alpha false rejects a color that carries alpha", () => { + const config = defineConfig({ + colors: { + palette: { + value: { + coral: { 100: "rgb(0 0 0 / 12%)" }, + opaque: { 100: { hex: "#FF7F50" } }, + }, + settings: { color: { formats: { hex: true }, alpha: false } }, + }, + }, + }); + + const error = assertThrows(() => generateCSS(config)); + + assert( + error.message.includes('"palette.coral.100"'), + `Expected the error to name the offending variant. Received: ${error.message}`, + ); +}); + +Deno.test("generateJSON - alpha false generates no alpha channel", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { + color: { formats: { hex: true, rgb: { array: true } }, alpha: false }, + }, + }, + }, + }); + + assertEquals(tokenColor(config), { + hex: { string: "#ff7f50" }, + rgb: { array: [255, 127, 80] }, + }); +}); + +Deno.test("generateJSON - a color replaces the inherited color settings", () => { + const config = defineConfig({ + colors: { + palette: { + value: { + coral: { + value: { 100: { hex: "#FF7F50" } }, + settings: { + color: { formats: { hex: { number: true } }, fallback: false, alpha: 0.5 }, + }, + }, + }, + settings: { color: { formats: { rgb: true }, fallback: "rgb" } }, + }, + }, + }); + + assertEquals(tokenColor(config), { hex: { number: 0xff7f5080 } }); + assertEquals(generateCSS(config).includes("@supports"), false); +}); + +Deno.test("generateCSS - the colorFormats option appends a default format", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + }, + }, + }); + + const css = generateCSS(config, { colorFormats: ["rgb"] }); + + assertEquals(declaredValue(css, "--palette-coral-100"), "rgb(255 127 80)"); + assertEquals( + JSON.parse(generateJSON(config, { colorFormats: ["hex", "rgb"] })).palette.coral[ + "100" + ].color, + { + hex: { string: "#ff7f50" }, + rgb: { string: "rgb(255 127 80)" }, + }, + ); +}); + +Deno.test("generateCSS - the declaration mirrors the color selector and at-rule", () => { + const config = defineConfig({ + colors: { + palette: { + value: { + dark: { + value: { 900: { hex: "#000" } }, + settings: { atRule: "@media (prefers-color-scheme: dark)" }, + }, + another: { + value: { 900: { hex: "#fff" } }, + settings: { selector: ":root.Another" }, + }, + card: { + value: { 900: { hex: "#111" } }, + settings: { atRule: "@container (min-width: 40rem)", selector: ".card" }, + }, + }, + settings: { color: { formats: { hex: true } } }, + }, + }, + }); + + const css = generateCSS(config); + + assert( + css.endsWith( + [ + "@media (prefers-color-scheme: dark) {", + " @supports not (color: oklch(0% 0 0)) {", + " :root {", + " /* dark */", + " --palette-dark-900: #000000;", + " }", + " }", + "}", + "@supports not (color: oklch(0% 0 0)) {", + " :root.Another {", + " /* another */", + " --palette-another-900: #ffffff;", + " }", + "}", + "@container (min-width: 40rem) {", + " @supports not (color: oklch(0% 0 0)) {", + " .card {", + " /* card */", + " --palette-card-900: #111111;", + " }", + " }", + "}", + ].join("\n"), + ), + `Expected the fallback blocks at the end of:\n${css}`, + ); +}); + +Deno.test("generateCSS - a palette color without variants emits no color block", () => { + const config = defineConfig({ + colors: { + palette: { + value: { empty: { value: {} } }, + settings: { color: { formats: { hex: true } } }, + }, + }, + }); + + assertEquals(generateCSS(config).includes("@supports"), false); +}); + +Deno.test("generateCSS - a wide gamut color uses the CSS gamut mapped sRGB value", () => { + // `oklch(70% 0.4 20)` cannot be shown in sRGB: its conversion is + // `rgb(336 -117 10)`, and clipping each channel would give `#ff000a`. The + // declaration keeps the authored hue instead, which is how a browser maps + // the color it cannot display. + const config = defineConfig({ + colors: { + palette: { + value: { vivid: { 100: { oklch: "oklch(70% 0.4 20)" } } }, + settings: { color: { formats: { hex: true } } }, + }, + }, + }); + + const declared = declaredValue(generateCSS(config), "--palette-vivid-100"); + + assertEquals(declared === "#ff000a", false); + assertEquals(new Color(declared).inGamut("srgb", { epsilon: 0 }), true); +}); + +Deno.test("generateStyleDictionaryJSON - exposes the color outputs beside the resolved value", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { + color: { + formats: { hex: { string: true, number: true }, rgb: { array: true } }, + }, + }, + }, + }, + }); + + const token = JSON.parse(generateStyleDictionaryJSON(config)).palette.coral["100"]; + + assertEquals(token.$color, { + hex: { string: "#ff7f50", number: 16744272 }, + rgb: { array: [255, 127, 80] }, + }); + assertEquals(token.attributes.color, token.$color); + assertEquals(token.$resolvedValue, "oklch(73.511% 0.16799 40.24666)"); +}); + +Deno.test("generateJSON and generateTS - the color outputs are part of the token object", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { color: { formats: { rgb: { string: true, array: true } } } }, + }, + }, + }); + + const expected = { + palette: { + coral: { + "100": { + key: "--palette-coral-100", + value: "oklch(73.511% 0.16799 40.24666)", + variable: "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", + color: { rgb: { string: "rgb(255 127 80)", array: [255, 127, 80] } }, + }, + }, + }, + }; + + assertEquals(generateJSON(config), JSON.stringify(expected, null, 2)); + assertEquals( + generateTS(config), + `export const cssForge = ${JSON.stringify(expected, null, 2)} as const;`, + ); +}); + +Deno.test("generateJSON - token objects omit the color field when none is configured", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + }, + }, + }); + + assertEquals(JSON.parse(generateJSON(config)), { + palette: { + coral: { + "100": { + key: "--palette-coral-100", + value: "oklch(73.511% 0.16799 40.24666)", + variable: "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", + }, + }, + }, + }); +}); + +Deno.test("generateCSS - rejects an unknown color format", () => { + const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; + const invalidPalette = { + colors: { palette: { ...palette, settings: { color: { formats: { hsl: true } } } } }, + } as unknown as CSSForgeConfig; + const invalidColor = { + colors: { + palette: { + value: { + coral: { + value: { 100: { hex: "#FF7F50" } }, + settings: { color: { formats: { sqrgb: true } } }, + }, + }, + }, + }, + } as unknown as CSSForgeConfig; + + const paletteError = assertThrows(() => generateCSS(invalidPalette)); + const colorError = assertThrows(() => generateCSS(invalidColor)); + + assert( + paletteError.message.includes('"palette.settings.color.formats"') && + paletteError.message.includes("hsl"), + `Expected the error to name the path and the value. Received: ${paletteError.message}`, + ); + assert( + colorError.message.includes('"palette.coral.settings.color.formats"'), + `Expected the error to name "palette.coral.settings.color.formats". Received: ${colorError.message}`, + ); +}); + +Deno.test("generateCSS - rejects an output a format does not produce", () => { + const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; + const arrayOnHex = { + colors: { + palette: { ...palette, settings: { color: { formats: { hex: { array: true } } } } }, + }, + } as unknown as CSSForgeConfig; + const digitsOnRgb = { + colors: { + palette: { + ...palette, + settings: { color: { formats: { rgb: { digits: true } } } }, + }, + }, + } as unknown as CSSForgeConfig; + const nothingEnabled = { + colors: { palette: { ...palette, settings: { color: { formats: { hex: {} } } } } }, + } as unknown as CSSForgeConfig; + + const hexError = assertThrows(() => generateCSS(arrayOnHex)); + const rgbError = assertThrows(() => generateCSS(digitsOnRgb)); + const emptyError = assertThrows(() => generateCSS(nothingEnabled)); + + assert( + hexError.message.includes('"palette.settings.color.formats.hex"') && + hexError.message.includes('"digits"'), + `Expected the error to list the hex outputs. Received: ${hexError.message}`, + ); + assert( + rgbError.message.includes('"palette.settings.color.formats.rgb"') && + rgbError.message.includes('"array"'), + `Expected the error to list the rgb outputs. Received: ${rgbError.message}`, + ); + assert( + emptyError.message.includes('"palette.settings.color.formats.hex"'), + `Expected the error to name the empty format. Received: ${emptyError.message}`, + ); +}); + +Deno.test("generateCSS - rejects an alpha that is not a boolean or an opacity", () => { + const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; + const notAnOpacity = { + colors: { + palette: { ...palette, settings: { color: { formats: { hex: true }, alpha: 2 } } }, + }, + } as unknown as CSSForgeConfig; + + const error = assertThrows(() => generateCSS(notAnOpacity)); + + assert( + error.message.includes('"palette.settings.color.alpha"'), + `Expected the error to name "palette.settings.color.alpha". Received: ${error.message}`, + ); +}); + +Deno.test("generateCSS - rejects color settings that are not inside settings", () => { + const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; + const colorOnPalette = { + colors: { palette: { ...palette, color: { formats: { hex: true } } } }, + } as unknown as CSSForgeConfig; + const formatsInSettings = { + colors: { palette: { ...palette, settings: { formats: { hex: true } } } }, + } as unknown as CSSForgeConfig; + const fallbackOnColor = { + colors: { + palette: { + value: { + coral: { + value: { 100: { hex: "#FF7F50" } }, + fallback: "hex", + }, + }, + }, + }, + } as unknown as CSSForgeConfig; + + const paletteError = assertThrows(() => generateCSS(colorOnPalette)); + const settingsError = assertThrows(() => generateCSS(formatsInSettings)); + const colorError = assertThrows(() => generateCSS(fallbackOnColor)); + + assert( + paletteError.message.includes('"palette"'), + `Expected the error to name "palette". Received: ${paletteError.message}`, + ); + assert( + settingsError.message.includes('"palette.settings.color"'), + `Expected the error to name "palette.settings.color". Received: ${settingsError.message}`, + ); + assert( + colorError.message.includes('"palette.coral"'), + `Expected the error to name "palette.coral". Received: ${colorError.message}`, + ); +}); + +Deno.test("generateCSS - a whitespace-only selector is read as the root scope", () => { + const config = defineConfig({ + colors: { + palette: { + value: { + coral: { + value: { 100: { hex: "#FF7F50" } }, + settings: { selector: " " }, + }, + }, + settings: { color: { formats: { hex: true } } }, + }, + }, + }); + + assertEquals( + generateCSS(config), + [ + "/*____ CSSForge ____*/", + ":root {", + "/*____ Colors ____*/", + "/* Palette */", + "/* coral */", + "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", + "}", + "@supports not (color: oklch(0% 0 0)) {", + " :root {", + " /* coral */", + " --palette-coral-100: #ff7f50;", + " }", + "}", + ].join("\n"), + ); +}); + +Deno.test("generateCSS - a shorthand color may keep a variant named color", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { color: { hex: "#FFFFFF" } } }, + }, + }, + }); + + assertEquals( + generateCSS(config).includes("--palette-coral-color: oklch(100% 0 0);"), + true, + ); +}); diff --git a/packages/cssforge/tests/oklch-fallback.test.ts b/packages/cssforge/tests/oklch-fallback.test.ts deleted file mode 100644 index 8531c04..0000000 --- a/packages/cssforge/tests/oklch-fallback.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -import Color from "colorjs.io"; -import { generateJSON, generateTS } from "../src/generator.ts"; -import type { CSSForgeConfig } from "../src/mod.ts"; -import { defineConfig, generateCSS, generateStyleDictionaryJSON } from "../src/mod.ts"; -import { assert, assertEquals, assertThrows, Deno } from "./vitest-compat.ts"; - -/** - * The value declared for `key` inside the generated `@supports` block, read from - * the block rather than the root declaration above it. - */ -const declaredValue = (css: string, key: string): string => { - const block = css.slice(css.indexOf("@supports")); - const match = new RegExp(`${key}: ([^;]+);`).exec(block); - - if (!match) - throw new Error(`Expected a color format declaration for ${key} in:\n${css}`); - - return match[1]; -}; - -Deno.test("generateCSS - configured formats emit an sRGB declaration under @supports", () => { - const config = defineConfig({ - colors: { - palette: { - value: { - coral: { - 100: { hex: "#FF7F50" }, - }, - }, - settings: { color: { formats: ["hex"] } }, - }, - }, - }); - - assertEquals( - generateCSS(config), - [ - "/*____ CSSForge ____*/", - ":root {", - "/*____ Colors ____*/", - "/* Palette */", - "/* coral */", - "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", - "}", - "@supports not (color: oklch(0% 0 0)) {", - " :root {", - " /* coral */", - " --palette-coral-100: #ff7f50;", - " }", - "}", - ].join("\n"), - ); -}); - -Deno.test("generateCSS - the first format is the CSS declaration and all formats reach the token", () => { - const config = defineConfig({ - colors: { - palette: { - value: { coral: { 100: { hex: "#FF7F50" } } }, - settings: { color: { formats: ["rgb", "hex"] } }, - }, - }, - }); - - const css = generateCSS(config); - - assertEquals(declaredValue(css, "--palette-coral-100"), "rgb(255 127 80)"); - assertEquals(JSON.parse(generateJSON(config)).palette.coral["100"].color, { - rgb: "rgb(255 127 80)", - hex: "#ff7f50", - }); -}); - -Deno.test("generateCSS - a palette color without variants emits no fallback block", () => { - const config = defineConfig({ - colors: { - palette: { - value: { empty: { value: {} } }, - settings: { color: { formats: ["hex"] } }, - }, - }, - }); - - assertEquals(generateCSS(config).includes("@supports"), false); -}); - -Deno.test("generateCSS - applies the palette formats with per-color overrides", () => { - const config = defineConfig({ - colors: { - palette: { - value: { - coral: { 100: { hex: "#FF7F50" } }, - soft: { 100: "rgb(0 0 0 / 12%)" }, - brand: { - value: { 100: { hex: "#FF0000" } }, - settings: { color: { formats: ["hex"] } }, - }, - opted: { - value: { 100: { hex: "#00FF00" } }, - settings: { color: { formats: [] } }, - }, - }, - settings: { color: { formats: ["rgb"] } }, - }, - }, - }); - - const css = generateCSS(config); - const lines = css.split("\n"); - const fallbackStart = lines.indexOf("@supports not (color: oklch(0% 0 0)) {"); - - assert(fallbackStart > 0, `Expected a fallback block. Received:\n${css}`); - assertEquals(lines.slice(fallbackStart), [ - "@supports not (color: oklch(0% 0 0)) {", - " :root {", - " /* coral */", - " --palette-coral-100: rgb(255 127 80);", - " /* soft */", - " --palette-soft-100: rgb(0 0 0 / 0.12);", - " /* brand */", - " --palette-brand-100: #ff0000;", - " }", - "}", - ]); - assertEquals(css.includes("--palette-opted-100: #"), false); -}); - -Deno.test("generateCSS - a hex fallback keeps alpha as an eight digit hex", () => { - const config = defineConfig({ - colors: { - palette: { - value: { - soft: { 100: "rgb(0 0 0 / 12%)" }, - }, - settings: { color: { formats: ["hex"] } }, - }, - }, - }); - - assertEquals( - generateCSS(config), - [ - "/*____ CSSForge ____*/", - ":root {", - "/*____ Colors ____*/", - "/* Palette */", - "/* soft */", - "--palette-soft-100: oklch(0% 0 0 / 12%);", - "}", - "@supports not (color: oklch(0% 0 0)) {", - " :root {", - " /* soft */", - " --palette-soft-100: #0000001f;", - " }", - "}", - ].join("\n"), - ); -}); - -Deno.test("generateCSS - a wide gamut color falls back to the CSS gamut mapped sRGB value", () => { - // `oklch(70% 0.4 20)` cannot be shown in sRGB: its conversion is - // `rgb(336 -117 10)`, and clipping each channel would give `#ff000a`. The - // declaration keeps the authored hue instead, which is how a browser maps - // the color it cannot display. - const config = defineConfig({ - colors: { - palette: { - value: { vivid: { 100: { oklch: "oklch(70% 0.4 20)" } } }, - settings: { color: { formats: ["hex"] } }, - }, - }, - }); - - const declared = declaredValue(generateCSS(config), "--palette-vivid-100"); - - assertEquals(declared === "#ff000a", false); - assertEquals(new Color(declared).inGamut("srgb", { epsilon: 0 }), true); -}); - -Deno.test("generateCSS - the declaration mirrors the color selector and at-rule", () => { - const config = defineConfig({ - colors: { - palette: { - value: { - dark: { - value: { 900: { hex: "#000" } }, - settings: { atRule: "@media (prefers-color-scheme: dark)" }, - }, - another: { - value: { 900: { hex: "#fff" } }, - settings: { selector: ":root.Another" }, - }, - card: { - value: { 900: { hex: "#111" } }, - settings: { atRule: "@container (min-width: 40rem)", selector: ".card" }, - }, - }, - settings: { color: { formats: ["hex"] } }, - }, - }, - }); - - const css = generateCSS(config); - - assert( - css.endsWith( - [ - "@media (prefers-color-scheme: dark) {", - " @supports not (color: oklch(0% 0 0)) {", - " :root {", - " /* dark */", - " --palette-dark-900: #000000;", - " }", - " }", - "}", - "@supports not (color: oklch(0% 0 0)) {", - " :root.Another {", - " /* another */", - " --palette-another-900: #ffffff;", - " }", - "}", - "@container (min-width: 40rem) {", - " @supports not (color: oklch(0% 0 0)) {", - " .card {", - " /* card */", - " --palette-card-900: #111111;", - " }", - " }", - "}", - ].join("\n"), - ), - `Expected the fallback blocks at the end of:\n${css}`, - ); -}); - -Deno.test("generateCSS - the colorFormats option adds formats without editing the config", () => { - const config = defineConfig({ - colors: { - palette: { - value: { coral: { 100: { hex: "#FF7F50" } } }, - }, - }, - }); - - const css = generateCSS(config, { colorFormats: ["hex", "rgb"] }); - - assertEquals(declaredValue(css, "--palette-coral-100"), "#ff7f50"); - assertEquals(JSON.parse(generateJSON(config, { colorFormats: ["hex", "rgb"] })), { - palette: { - coral: { - "100": { - 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: "#ff7f50", rgb: "rgb(255 127 80)" }, - }, - }, - }, - }); -}); - -Deno.test("generateCSS - the option appends to the configured formats", () => { - const config = defineConfig({ - colors: { - palette: { - value: { coral: { 100: { hex: "#FF7F50" } } }, - settings: { color: { formats: ["rgb"] } }, - }, - }, - }); - - // The configuration picks the CSS declaration, and the option only adds. - const css = generateCSS(config, { colorFormats: ["hex", "rgb"] }); - - assertEquals(declaredValue(css, "--palette-coral-100"), "rgb(255 127 80)"); - assertEquals(JSON.parse(generateJSON(config, { colorFormats: ["hex"] })), { - palette: { - coral: { - "100": { - key: "--palette-coral-100", - value: "oklch(73.511% 0.16799 40.24666)", - variable: "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", - color: { rgb: "rgb(255 127 80)", hex: "#ff7f50" }, - }, - }, - }, - }); -}); - -Deno.test("generateJSON and generateTS - every requested format is part of the token object", () => { - const config = defineConfig({ - colors: { - palette: { - value: { coral: { 100: { hex: "#FF7F50" } } }, - settings: { color: { formats: ["hex", "rgb"] } }, - }, - }, - }); - - assertEquals(JSON.parse(generateJSON(config)), { - palette: { - coral: { - "100": { - 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: "#ff7f50", rgb: "rgb(255 127 80)" }, - }, - }, - }, - }); - assertEquals(generateTS(config).includes('"rgb": "rgb(255 127 80)"'), true); -}); - -Deno.test("generateJSON - token objects omit the color field when none is configured", () => { - const config = defineConfig({ - colors: { - palette: { - value: { coral: { 100: { hex: "#FF7F50" } } }, - }, - }, - }); - - assertEquals(JSON.parse(generateJSON(config)), { - palette: { - coral: { - "100": { - key: "--palette-coral-100", - value: "oklch(73.511% 0.16799 40.24666)", - variable: "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", - }, - }, - }, - }); -}); - -Deno.test("generateStyleDictionaryJSON - exposes the formats beside the resolved value", () => { - const config = defineConfig({ - colors: { - palette: { - value: { coral: { 100: { hex: "#FF7F50" } } }, - settings: { color: { formats: ["hex", "rgb"] } }, - }, - }, - }); - - const token = JSON.parse(generateStyleDictionaryJSON(config)).palette.coral["100"]; - - assertEquals(token.$color, { hex: "#ff7f50", rgb: "rgb(255 127 80)" }); - assertEquals(token.attributes.color, { hex: "#ff7f50", rgb: "rgb(255 127 80)" }); - assertEquals(token.$resolvedValue, "oklch(73.511% 0.16799 40.24666)"); -}); - -Deno.test("generateCSS - rejects an unsupported color format", () => { - const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; - const invalidPalette = { - colors: { palette: { ...palette, settings: { color: { formats: ["oklch"] } } } }, - } as unknown as CSSForgeConfig; - const invalidColor = { - colors: { - palette: { - value: { - coral: { - value: { 100: { hex: "#FF7F50" } }, - settings: { color: { formats: ["sqrgb"] } }, - }, - }, - }, - }, - } as unknown as CSSForgeConfig; - - const paletteError = assertThrows(() => generateCSS(invalidPalette)); - const colorError = assertThrows(() => generateCSS(invalidColor)); - - assert( - paletteError.message.includes('"palette.settings.color.formats"'), - `Expected the error to name "palette.settings.color.formats". Received: ${paletteError.message}`, - ); - assert( - colorError.message.includes('"palette.coral.settings.color.formats"'), - `Expected the error to name "palette.coral.settings.color.formats". Received: ${colorError.message}`, - ); -}); - -Deno.test("generateCSS - rejects formats that are not an array of formats", () => { - const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; - const formatsNotAnArray = { - colors: { palette: { ...palette, settings: { color: { formats: "hex" } } } }, - } as unknown as CSSForgeConfig; - const settingsNotAnObject = { - colors: { palette: { ...palette, settings: "color" } }, - } as unknown as CSSForgeConfig; - - const formatsError = assertThrows(() => generateCSS(formatsNotAnArray)); - const settingsError = assertThrows(() => generateCSS(settingsNotAnObject)); - - assert( - formatsError.message.includes('"palette.settings.color.formats"'), - `Expected the error to name "palette.settings.color.formats". Received: ${formatsError.message}`, - ); - assert( - settingsError.message.includes('"palette.settings"'), - `Expected the error to name "palette.settings". Received: ${settingsError.message}`, - ); -}); - -Deno.test("generateCSS - rejects color settings that are not inside settings", () => { - const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; - const colorOnPalette = { - colors: { palette: { ...palette, color: { formats: ["hex"] } } }, - } as unknown as CSSForgeConfig; - const colorOnColor = { - colors: { - palette: { - value: { - coral: { value: { 100: { hex: "#FF7F50" } }, color: { formats: ["hex"] } }, - }, - }, - }, - } as unknown as CSSForgeConfig; - const formatsInSettings = { - colors: { palette: { ...palette, settings: { formats: ["hex"] } } }, - } as unknown as CSSForgeConfig; - - const paletteError = assertThrows(() => generateCSS(colorOnPalette)); - const colorError = assertThrows(() => generateCSS(colorOnColor)); - const formatsError = assertThrows(() => generateCSS(formatsInSettings)); - - assert( - paletteError.message.includes('"palette"'), - `Expected the error to name "palette". Received: ${paletteError.message}`, - ); - assert( - colorError.message.includes('"palette.coral"'), - `Expected the error to name "palette.coral". Received: ${colorError.message}`, - ); - assert( - formatsError.message.includes('"palette.settings.color"'), - `Expected the error to name "palette.settings.color". Received: ${formatsError.message}`, - ); -}); - -Deno.test("generateCSS - a whitespace-only selector is read as the root scope", () => { - const config = defineConfig({ - colors: { - palette: { - value: { - coral: { - value: { 100: { hex: "#FF7F50" } }, - settings: { selector: " " }, - }, - }, - settings: { color: { formats: ["hex"] } }, - }, - }, - }); - - assertEquals( - generateCSS(config), - [ - "/*____ CSSForge ____*/", - ":root {", - "/*____ Colors ____*/", - "/* Palette */", - "/* coral */", - "--palette-coral-100: oklch(73.511% 0.16799 40.24666);", - "}", - "@supports not (color: oklch(0% 0 0)) {", - " :root {", - " /* coral */", - " --palette-coral-100: #ff7f50;", - " }", - "}", - ].join("\n"), - ); -}); - -Deno.test("generateCSS - a shorthand color may keep a variant named color", () => { - const config = defineConfig({ - colors: { - palette: { - value: { coral: { color: { hex: "#FFFFFF" } } }, - }, - }, - }); - - assertEquals( - generateCSS(config).includes("--palette-coral-color: oklch(100% 0 0);"), - true, - ); -}); diff --git a/skills/cssforge/references/cli-reference.md b/skills/cssforge/references/cli-reference.md index 9216db6..538878e 100644 --- a/skills/cssforge/references/cli-reference.md +++ b/skills/cssforge/references/cli-reference.md @@ -14,7 +14,8 @@ CLI implementation: `packages/cssforge/src/cli.ts` - `--style-dictionary`: Style Dictionary token JSON path (default `./.cssforge/tokens.sd.json`) - `--style-dictionary-value-mode`: `resolved | css-reference` (default `resolved`) - `--color-formats`: comma separated sRGB formats generated alongside oklch (`hex`, `rgb`), - appended to the formats `settings.color.formats` declares + appended to the formats `settings.color.formats` declares. Each added format generates its + CSS value, and `settings.color.fallback` still picks the declaration ## Typical commands from docs diff --git a/skills/cssforge/references/token-patterns.md b/skills/cssforge/references/token-patterns.md index 0ffb87c..8b048df 100644 --- a/skills/cssforge/references/token-patterns.md +++ b/skills/cssforge/references/token-patterns.md @@ -7,12 +7,16 @@ All patterns below are derived from README configuration examples. - Palette tokens under `colors.palette.value`. - Supports color formats like hex/rgb/hsl/oklch and string values. - Themes and gradients can reference palette entries with `variables` maps. -- Set `settings.color.formats` to `["hex", "rgb"]` on `colors.palette` (or on one color's - `settings`) to generate those sRGB formats alongside `oklch()`. The first format is the - declaration emitted under `@supports not (color: oklch(0% 0 0))` for browsers without - `oklch()`; every format reaches the JSON, TypeScript and Style Dictionary tokens as the - token's `color` object. A color opts out with `[]` or `false`, and `--color-formats` - appends formats at run time. +- Set `settings.color.formats` on `colors.palette` (or on one color's `settings`) to generate + sRGB formats alongside `oklch()`. `hex` produces `string` (`"#ff7f50"`), `digits` + (`"ff7f50"`) and `number` (`0xff7f50`); `rgb` produces `string` (`"rgb(255 127 80)"`) and + `array` (`[255, 127, 80]`). A format set to `true` produces its CSS value only. +- `settings.color.fallback` names the format whose `string` value is the declaration emitted + under `@supports not (color: oklch(0% 0 0))`; it defaults to the first generated format and + `false` emits no declaration. `settings.color.alpha` keeps the color's alpha (`true`), sets + it (`0`-`1`), or rejects a color that carries one (`false`). Every generated value reaches + the JSON, TypeScript and Style Dictionary tokens as the token's `color` object, and + `--color-formats hex,rgb` adds formats at run time. ## Spacing From 9b875ffe0825990f5959ff22c3164c90d230ef5a Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 25 Sep 2026 15:44:50 +0700 Subject: [PATCH 7/7] refactor(colors): move the alpha policy under each format `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. --- .changeset/oklch-color-fallback.md | 7 +- README.md | 25 +-- packages/cssforge/README.md | 25 +-- packages/cssforge/src/modules/colors.ts | 179 ++++++++++-------- packages/cssforge/tests/color-formats.test.ts | 77 ++++++-- skills/cssforge/references/token-patterns.md | 10 +- 6 files changed, 204 insertions(+), 119 deletions(-) diff --git a/.changeset/oklch-color-fallback.md b/.changeset/oklch-color-fallback.md index b5f83f0..b8ce455 100644 --- a/.changeset/oklch-color-fallback.md +++ b/.changeset/oklch-color-fallback.md @@ -19,9 +19,10 @@ stream, so the modern value wins everywhere, including where `oklch()` cannot be color outside sRGB uses the CSS gamut mapping algorithm, which is how a browser maps a color its display cannot show. -`settings.color.alpha` controls the alpha of the generated color: `true` keeps the alpha the -color carries, a number between 0 and 1 replaces it everywhere including the `oklch()` value, -and `false` rejects a color that carries alpha. +Each format also takes an `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 the output. The `oklch()` value keeps the +alpha the color carries, so a format that sets an alpha is generated at that opacity alone. The JSON and TypeScript token objects gain a `color` object keyed by format and output, such as `{ hex: { string: "#ff7f50", number: 16744272 }, rgb: { array: [255, 127, 80] } }`, and diff --git a/README.md b/README.md index 6c10b3d..ae1a1d3 100644 --- a/README.md +++ b/README.md @@ -761,18 +761,19 @@ A format set to `true` generates its CSS value (`string`). A color with alpha ca every output: `#ff7f50aa`, `ff7f50aa`, `0xff7f50aa`, `rgb(255 127 80 / 0.667)` and `[255, 127, 80, 0.667]`. -`fallback` names the format whose `string` value becomes the declaration for browsers -without `oklch()` support. It defaults to the first generated format, has to be generated -with its `string` output, and `false` emits no declaration so the formats only reach the -tokens. The declaration is gated by `@supports not (color: oklch(0% 0 0))` and emitted after -the root block, because a custom property accepts any token stream and the later declaration -wins wherever the modern value is unsupported. It mirrors the color's `atRule` and -`selector`, so it only overrides the declaration it stands in for. - -`alpha` controls the alpha of the generated color. `true` (the default) keeps the alpha the -color carries. A number between 0 and 1 replaces it, which generates the color at that -opacity everywhere, including its `oklch()` value. `false` rejects a color that carries -alpha. +Every format also takes an `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 the output. The `oklch()` value keeps the +alpha the color carries, so a format that sets an alpha is generated at that opacity alone. + +`fallback` names the format whose `string` value, including its alpha policy, becomes the +declaration for browsers without `oklch()` support. It defaults to the first generated +format, has to be generated with its `string` output, and `false` emits no declaration so the +formats only reach the tokens. The declaration is gated by +`@supports not (color: oklch(0% 0 0))` and emitted after the root block, because a custom +property accepts any token stream and the later declaration wins wherever the modern value is +unsupported. It mirrors the color's `atRule` and `selector`, so it only overrides the +declaration it stands in for. The palette settings cover every color, and a color replaces them in its own `settings`, so a color that only sets `fallback` keeps the palette's formats. The JSON, TypeScript and Style diff --git a/packages/cssforge/README.md b/packages/cssforge/README.md index 6c10b3d..ae1a1d3 100644 --- a/packages/cssforge/README.md +++ b/packages/cssforge/README.md @@ -761,18 +761,19 @@ A format set to `true` generates its CSS value (`string`). A color with alpha ca every output: `#ff7f50aa`, `ff7f50aa`, `0xff7f50aa`, `rgb(255 127 80 / 0.667)` and `[255, 127, 80, 0.667]`. -`fallback` names the format whose `string` value becomes the declaration for browsers -without `oklch()` support. It defaults to the first generated format, has to be generated -with its `string` output, and `false` emits no declaration so the formats only reach the -tokens. The declaration is gated by `@supports not (color: oklch(0% 0 0))` and emitted after -the root block, because a custom property accepts any token stream and the later declaration -wins wherever the modern value is unsupported. It mirrors the color's `atRule` and -`selector`, so it only overrides the declaration it stands in for. - -`alpha` controls the alpha of the generated color. `true` (the default) keeps the alpha the -color carries. A number between 0 and 1 replaces it, which generates the color at that -opacity everywhere, including its `oklch()` value. `false` rejects a color that carries -alpha. +Every format also takes an `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 the output. The `oklch()` value keeps the +alpha the color carries, so a format that sets an alpha is generated at that opacity alone. + +`fallback` names the format whose `string` value, including its alpha policy, becomes the +declaration for browsers without `oklch()` support. It defaults to the first generated +format, has to be generated with its `string` output, and `false` emits no declaration so the +formats only reach the tokens. The declaration is gated by +`@supports not (color: oklch(0% 0 0))` and emitted after the root block, because a custom +property accepts any token stream and the later declaration wins wherever the modern value is +unsupported. It mirrors the color's `atRule` and `selector`, so it only overrides the +declaration it stands in for. The palette settings cover every color, and a color replaces them in its own `settings`, so a color that only sets `fallback` keeps the palette's formats. The JSON, TypeScript and Style diff --git a/packages/cssforge/src/modules/colors.ts b/packages/cssforge/src/modules/colors.ts index 0e1e979..5061c82 100644 --- a/packages/cssforge/src/modules/colors.ts +++ b/packages/cssforge/src/modules/colors.ts @@ -103,6 +103,13 @@ export interface HexFormatOutputs { digits?: boolean; /** The digits as a number, such as `0xff7f50`. */ number?: boolean; + /** + * Alpha handling for this format. `true` (the default) keeps the alpha the + * color carries, a number between 0 and 1 generates the format at that + * opacity, and `false` rejects a color that carries alpha, with no alpha + * byte in the digits. + */ + alpha?: boolean | number; } /** The values `rgb` can produce. */ @@ -111,6 +118,13 @@ export interface RgbFormatOutputs { string?: boolean; /** The channels, such as `[255, 127, 80]`. */ array?: boolean; + /** + * Alpha handling for this format. `true` (the default) keeps the alpha the + * color carries, a number between 0 and 1 generates the format at that + * opacity, and `false` rejects a color that carries alpha, with no alpha + * channel in the array. + */ + alpha?: boolean | number; } /** @@ -121,7 +135,8 @@ export interface ColorFormatConfig { /** * Formats generated alongside `oklch()`, keyed by format. A format set to * `true` produces its CSS value; an object selects the representations to - * include, so `{ hex: { digits: true } }` produces only `"ff7f50"`. + * include, so `{ hex: { digits: true } }` produces only `"ff7f50"`, and + * `alpha` controls the alpha of that format alone. * * The palette setting covers every color, and a color replaces it in its own * `settings`. @@ -154,15 +169,6 @@ export interface ColorFormatConfig { * the formats only reach the JSON, TypeScript and Style Dictionary tokens. */ fallback?: ColorFormat | false; - /** - * Alpha handling for the generated color. - * - * `true` (the default) keeps the alpha the color carries. A number between 0 - * and 1 replaces it, which generates the color at that opacity everywhere, - * including its `oklch()` value. `false` rejects a color that carries alpha, - * and no representation then holds an alpha channel. - */ - alpha?: boolean | number; } /** @@ -441,27 +447,33 @@ const hexDigits = (color: Color) => { : `${digits}${toHexByte(Math.round(alphaValue(color) * 255))}`; }; +/** The color a palette value resolves to. */ +const readColor = (value: ColorValueOrString): Color => + new Color(typeof value === "string" ? value : getColorString(value)); + /** - * The color as it is generated, with the alpha policy applied: the alpha the - * color carries, a replacement, or a rejection of a color that has one. + * The color as one format generates it. The `oklch()` value keeps the alpha the + * color carries, and a format applies its own alpha policy on top of it. */ -const readColor = ( - value: ColorValueOrString, - alpha: boolean | number, +const colorForFormat = ( + color: Color, + { format, alpha }: GeneratedFormat, path: string, ): Color => { - const colorString = typeof value === "string" ? value : getColorString(value); - const color = new Color(colorString); + if (alpha === true || color.alpha === alpha) return color; - if (alpha === false && color.alpha !== 1) { + if (alpha === false) { + // An opaque color has no alpha to represent. A color that carries one is + // rejected before generation, so this guard only covers a direct call. + if (color.alpha === 1) return color; throw new Error( - `Invalid color at "${path}": the color carries alpha, but "settings.color.alpha" is false.`, + `Invalid color at "${path}": the color carries alpha, but the "${format}" format sets "alpha" to false.`, ); } - if (typeof alpha === "number") color.alpha = alpha; - - return color; + const generated = color.clone(); + generated.alpha = alpha; + return generated; }; /** The alpha a color value carries, or undefined when the value is not a color. */ @@ -476,21 +488,25 @@ const colorAlpha = (value: ColorValueOrString): number | undefined => { }; /** - * Rejects a variant that carries alpha under an `alpha: false` policy, before - * generation, so the mistake fails loudly instead of skipping the color with a - * log line. + * Rejects a variant that carries alpha while one of its formats rejects alpha, + * before generation, so the mistake fails loudly instead of skipping the color + * with a log line. */ const assertOpaqueVariants = ( variants: Record, + formats: readonly GeneratedFormat[], path: string, ): void => { + const opaque = formats.filter((format) => format.alpha === false); + if (opaque.length === 0) return; + for (const [variantId, value] of Object.entries(variants)) { const alpha = colorAlpha(value); - if (alpha !== undefined && alpha !== 1) { - throw new Error( - `Invalid color at "${path}.${variantId}": the color carries alpha, but "settings.color.alpha" is false.`, - ); - } + if (alpha === undefined || alpha === 1) continue; + + throw new Error( + `Invalid color at "${path}.${variantId}": the color carries alpha, but the "${opaque[0].format}" format sets "alpha" to false.`, + ); } }; @@ -514,10 +530,11 @@ function colorToOklch(color: Color): string { return `oklch(${Number((l * 100).toFixed(3))}% ${c} ${h}${alpha})`; } -/** One generated format: the format, and the representations it produces. */ +/** One generated format: the format, the representations it produces, and its alpha policy. */ interface GeneratedFormat { format: ColorFormat; outputs: readonly ColorFormatOutput[]; + alpha: boolean | number; } /** @@ -527,15 +544,17 @@ interface GeneratedFormat { const colorToFormats = ( color: Color, formats: readonly GeneratedFormat[], + path: string, ): TokenColorFormats => { const values: TokenColorFormats = {}; - for (const { format, outputs } of formats) { + for (const entry of formats) { const generated: Record = {}; - for (const output of outputs) { - generated[output] = colorFormatOutputs[format][output](color); + const formatColor = colorForFormat(color, entry, path); + for (const output of entry.outputs) { + generated[output] = colorFormatOutputs[entry.format][output](formatColor); } - if (format === "hex") values.hex = generated as HexColorValues; + if (entry.format === "hex") values.hex = generated as HexColorValues; else values.rgb = generated as RgbColorValues; } @@ -543,8 +562,11 @@ const colorToFormats = ( }; /** The CSS value of one format, which is the declaration a browser without `oklch()` support reads. */ -const colorToDeclaration = (color: Color, format: ColorFormat): string => - colorFormatDeclarations[format](color); +const colorToDeclaration = ( + color: Color, + format: GeneratedFormat, + path: string, +): string => colorFormatDeclarations[format.format](colorForFormat(color, format, path)); /** * The color settings of one level, normalized. A field the level does not set is @@ -554,19 +576,18 @@ const colorToDeclaration = (color: Color, format: ColorFormat): string => interface ColorFormatSettings { formats?: GeneratedFormat[]; fallback?: ColorFormat | false; - alpha?: boolean | number; } /** The settings of one generated color, with every field resolved. */ interface ResolvedColorFormatSettings { formats: GeneratedFormat[]; fallback?: ColorFormat | false; - alpha: boolean | number; } const defaultFormats = (format: ColorFormat): GeneratedFormat => ({ format, outputs: ["string"], + alpha: true, }); /** @@ -612,6 +633,12 @@ function readColorFormatSettings( ); } + if ("alpha" in color) { + throw new Error( + `Invalid configuration at "${settingsPath}.color": "alpha" belongs inside a format, as in "${settingsPath}.color.formats.hex.alpha".`, + ); + } + const colorSettings: ColorFormatSettings = {}; if (color.formats !== undefined) { colorSettings.formats = readFormats(color.formats, `${settingsPath}.color.formats`); @@ -622,9 +649,6 @@ function readColorFormatSettings( `${settingsPath}.color.fallback`, ); } - if (color.alpha !== undefined) { - colorSettings.alpha = readAlpha(color.alpha, `${settingsPath}.color.alpha`); - } return colorSettings; } @@ -647,22 +671,22 @@ const readFormats = (value: unknown, path: string): GeneratedFormat[] => { ); } - formats.push({ - format: name, - outputs: readOutputs(name, outputs, `${path}.${name}`), - }); + formats.push(readFormat(name, outputs, `${path}.${name}`)); } return formats; }; -const readOutputs = ( +/** + * Reads one format entry: the outputs it produces, and its alpha policy. + */ +const readFormat = ( format: ColorFormat, value: unknown, path: string, -): readonly ColorFormatOutput[] => { - if (value === undefined || value === false) return []; - if (value === true) return ["string"]; +): GeneratedFormat => { + if (value === true) return { format, outputs: ["string"], alpha: true }; + if (value === undefined || value === false) return { format, outputs: [], alpha: true }; if (!isRecord(value)) { throw new Error( `Invalid configuration at "${path}": expected true or an object of outputs such as { string: true }.`, @@ -671,12 +695,15 @@ const readOutputs = ( const allowed = outputsOf(format); const outputs: ColorFormatOutput[] = []; + const alpha: boolean | number = true; + for (const [name, enabled] of Object.entries(value)) { + if (name === "alpha") continue; if (!allowed.includes(name as ColorFormatOutput)) { throw new Error( `Invalid ${format} output at configuration path "${path}": ${JSON.stringify( name, - )}. Use ${allowed.map((output) => `"${output}"`).join(", ")}.`, + )}. Use ${[...allowed, "alpha"].map((output) => `"${output}"`).join(", ")}.`, ); } if (enabled !== true && enabled !== false) { @@ -695,7 +722,11 @@ const readOutputs = ( ); } - return outputs; + return { + format, + outputs, + alpha: "alpha" in value ? readFormatAlpha(value.alpha, `${path}.alpha`) : alpha, + }; }; const readFallback = (value: unknown, path: string): ColorFormat | false | undefined => { @@ -711,8 +742,9 @@ const readFallback = (value: unknown, path: string): ColorFormat | false | undef return value; }; -const readAlpha = (value: unknown, path: string): boolean | number => { - if (value === undefined || typeof value === "boolean") return value ?? true; +/** Reads the alpha policy of one format. */ +const readFormatAlpha = (value: unknown, path: string): boolean | number => { + if (typeof value === "boolean") return value; if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) { throw new Error( `Invalid alpha at configuration path "${path}": ${JSON.stringify( @@ -743,7 +775,6 @@ const resolveColorFormatSettings = ( return { formats, fallback: settings?.fallback ?? paletteSettings?.fallback, - alpha: settings?.alpha ?? paletteSettings?.alpha ?? true, }; }; @@ -755,25 +786,26 @@ const resolveColorFormatSettings = ( const resolveDeclarationFormat = ({ formats, fallback, -}: ResolvedColorFormatSettings): ColorFormat | undefined => { +}: ResolvedColorFormatSettings): GeneratedFormat | undefined => { if (fallback === false) return undefined; - const format = fallback ?? formats.at(0)?.format; - if (format === undefined) return undefined; + const generated = + fallback === undefined + ? formats.at(0) + : formats.find((entry) => entry.format === fallback); - const generated = formats.find((entry) => entry.format === format); - if (!generated) { + if (fallback !== undefined && !generated) { throw new Error( - `Invalid fallback format: "${format}" is not generated. Add it to "settings.color.formats", or use false.`, + `Invalid fallback format: "${fallback}" is not generated. Add it to "settings.color.formats", or use false.`, ); } - if (!generated.outputs.includes("string")) { + if (generated && !generated.outputs.includes("string")) { throw new Error( - `Invalid fallback format: "${format}" does not generate its "string" output. Enable it, or use false.`, + `Invalid fallback format: "${generated.format}" does not generate its "string" output. Enable it, or use false.`, ); } - return format; + return generated; }; /** @@ -957,9 +989,11 @@ export function processColors( paletteSettings, options.colorFormats, ); - if (colorSettings.alpha === false) { - assertOpaqueVariants(normalizedColorConfig.value, `palette.${colorName}`); - } + assertOpaqueVariants( + normalizedColorConfig.value, + colorSettings.formats, + `palette.${colorName}`, + ); const declarationFormat = resolveDeclarationFormat(colorSettings); const fallback = declarationFormat @@ -976,19 +1010,16 @@ export function processColors( for (const [variantId, colorValue] of Object.entries(normalizedColorConfig.value)) { validateName(variantId, `palette.${colorName}.${variantId}`); const key = `--${moduleKey}-${colorName}-${variantId}`; - const color = readColor( - colorValue, - colorSettings.alpha, - `palette.${colorName}.${variantId}`, - ); + const path = `palette.${colorName}.${variantId}`; + const color = readColor(colorValue); const value = colorToOklch(color); const variable = `${key}: ${value};`; const colorValues = colorSettings.formats.length > 0 - ? colorToFormats(color, colorSettings.formats) + ? colorToFormats(color, colorSettings.formats, path) : undefined; if (fallback) { - const cssValue = colorToDeclaration(color, fallback.declarationFormat); + const cssValue = colorToDeclaration(color, fallback.declarationFormat, path); fallback.group.declarations.push(`${key}: ${cssValue};`); } diff --git a/packages/cssforge/tests/color-formats.test.ts b/packages/cssforge/tests/color-formats.test.ts index 698da34..4d620fc 100644 --- a/packages/cssforge/tests/color-formats.test.ts +++ b/packages/cssforge/tests/color-formats.test.ts @@ -185,7 +185,7 @@ Deno.test("generateCSS - rejects a fallback format without its string output", ( ); }); -Deno.test("generateJSON - alpha replaces the alpha of the color everywhere", () => { +Deno.test("generateJSON - a format generates at the alpha it sets", () => { const config = defineConfig({ colors: { palette: { @@ -193,10 +193,9 @@ Deno.test("generateJSON - alpha replaces the alpha of the color everywhere", () settings: { color: { formats: { - hex: { string: true, digits: true }, + hex: { string: true, digits: true, alpha: 0.5 }, rgb: { string: true, array: true }, }, - alpha: 0.5, }, }, }, @@ -205,14 +204,34 @@ Deno.test("generateJSON - alpha replaces the alpha of the color everywhere", () const token = JSON.parse(generateJSON(config)).palette.coral["100"]; - assertEquals(token.value, "oklch(0% 0 0 / 50%)"); + // The value keeps the alpha the color carries, and only the format that + // asked for an alpha is generated at that opacity. + assertEquals(token.value, "oklch(0% 0 0)"); assertEquals(token.color, { hex: { string: "#00000080", digits: "00000080" }, - rgb: { string: "rgb(0 0 0 / 0.5)", array: [0, 0, 0, 0.5] }, + rgb: { string: "rgb(0 0 0)", array: [0, 0, 0] }, }); }); -Deno.test("generateCSS - alpha false rejects a color that carries alpha", () => { +Deno.test("generateCSS - the declaration uses the alpha of the fallback format", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#000000" } } }, + settings: { + color: { + formats: { hex: { string: true, alpha: 0.5 }, rgb: true }, + fallback: "hex", + }, + }, + }, + }, + }); + + assertEquals(declaredValue(generateCSS(config), "--palette-coral-100"), "#00000080"); +}); + +Deno.test("generateCSS - a format rejects a color that carries alpha when it sets alpha false", () => { const config = defineConfig({ colors: { palette: { @@ -220,7 +239,7 @@ Deno.test("generateCSS - alpha false rejects a color that carries alpha", () => coral: { 100: "rgb(0 0 0 / 12%)" }, opaque: { 100: { hex: "#FF7F50" } }, }, - settings: { color: { formats: { hex: true }, alpha: false } }, + settings: { color: { formats: { hex: { string: true, alpha: false } } } }, }, }, }); @@ -228,8 +247,8 @@ Deno.test("generateCSS - alpha false rejects a color that carries alpha", () => const error = assertThrows(() => generateCSS(config)); assert( - error.message.includes('"palette.coral.100"'), - `Expected the error to name the offending variant. Received: ${error.message}`, + error.message.includes('"palette.coral.100"') && error.message.includes('"hex"'), + `Expected the error to name the variant and the format. Received: ${error.message}`, ); }); @@ -239,7 +258,12 @@ Deno.test("generateJSON - alpha false generates no alpha channel", () => { palette: { value: { coral: { 100: { hex: "#FF7F50" } } }, settings: { - color: { formats: { hex: true, rgb: { array: true } }, alpha: false }, + color: { + formats: { + hex: { string: true, alpha: false }, + rgb: { array: true, alpha: false }, + }, + }, }, }, }, @@ -259,7 +283,10 @@ Deno.test("generateJSON - a color replaces the inherited color settings", () => coral: { value: { 100: { hex: "#FF7F50" } }, settings: { - color: { formats: { hex: { number: true } }, fallback: false, alpha: 0.5 }, + color: { + formats: { hex: { number: true, alpha: 0.5 } }, + fallback: false, + }, }, }, }, @@ -535,15 +562,37 @@ Deno.test("generateCSS - rejects an alpha that is not a boolean or an opacity", const palette = { value: { coral: { 100: { hex: "#FF7F50" } } } }; const notAnOpacity = { colors: { - palette: { ...palette, settings: { color: { formats: { hex: true }, alpha: 2 } } }, + palette: { + ...palette, + settings: { color: { formats: { hex: { string: true, alpha: 2 } } } }, + }, }, } as unknown as CSSForgeConfig; const error = assertThrows(() => generateCSS(notAnOpacity)); assert( - error.message.includes('"palette.settings.color.alpha"'), - `Expected the error to name "palette.settings.color.alpha". Received: ${error.message}`, + error.message.includes('"palette.settings.color.formats.hex.alpha"'), + `Expected the error to name the format's alpha. Received: ${error.message}`, + ); +}); + +Deno.test("generateCSS - rejects an alpha outside a format", () => { + const config = { + colors: { + palette: { + value: { coral: { 100: { hex: "#FF7F50" } } }, + settings: { color: { formats: { hex: true }, alpha: 0.5 } }, + }, + }, + } as unknown as CSSForgeConfig; + + const error = assertThrows(() => generateCSS(config)); + + assert( + error.message.includes('"palette.settings.color"') && + error.message.includes("formats.hex.alpha"), + `Expected the error to point at the format. Received: ${error.message}`, ); }); diff --git a/skills/cssforge/references/token-patterns.md b/skills/cssforge/references/token-patterns.md index 8b048df..1818d39 100644 --- a/skills/cssforge/references/token-patterns.md +++ b/skills/cssforge/references/token-patterns.md @@ -11,12 +11,14 @@ All patterns below are derived from README configuration examples. sRGB formats alongside `oklch()`. `hex` produces `string` (`"#ff7f50"`), `digits` (`"ff7f50"`) and `number` (`0xff7f50`); `rgb` produces `string` (`"rgb(255 127 80)"`) and `array` (`[255, 127, 80]`). A format set to `true` produces its CSS value only. +- Each format takes `alpha`: `true` 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. + The `oklch()` value always keeps the color's own alpha. - `settings.color.fallback` names the format whose `string` value is the declaration emitted under `@supports not (color: oklch(0% 0 0))`; it defaults to the first generated format and - `false` emits no declaration. `settings.color.alpha` keeps the color's alpha (`true`), sets - it (`0`-`1`), or rejects a color that carries one (`false`). Every generated value reaches - the JSON, TypeScript and Style Dictionary tokens as the token's `color` object, and - `--color-formats hex,rgb` adds formats at run time. + `false` emits no declaration. Every generated value reaches the JSON, TypeScript and Style + Dictionary tokens as the token's `color` object, and `--color-formats hex,rgb` adds formats + at run time. ## Spacing