diff --git a/.changeset/oklch-color-fallback.md b/.changeset/oklch-color-fallback.md new file mode 100644 index 0000000..b8ce455 --- /dev/null +++ b/.changeset/oklch-color-fallback.md @@ -0,0 +1,32 @@ +--- +"@hebilicious/cssforge": minor +--- + +Generate palette colors in extra sRGB formats alongside `oklch()`, so a browser without +`oklch()` support still renders them, and so non-CSS consumers read the value they need. + +`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. + +`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. + +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 +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 a8f3a05..ae1a1d3 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 | +| `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: @@ -658,6 +659,146 @@ 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. +#### 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 `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: + + + +```typescript +export default defineConfig({ + colors: { + palette: { + value: { + coral: { 100: { hex: "#FF7F50" } }, + coralDark: { + value: { 100: { hex: "#FF6347" } }, + settings: { atRule: "@media (prefers-color-scheme: dark)" }, + }, + }, + settings: { + color: { + formats: { + hex: { string: true, digits: true, number: true }, + rgb: { string: true, array: true }, + }, + 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; + } + } +} +``` + + + +| 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]`. + +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 +Dictionary tokens carry the generated values in a `color` object, keyed by format and output: + +```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 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 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 You can conditionnally apply colors, gradients or themes by setting the `atRule` or the @@ -1252,6 +1393,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 + +# Generate sRGB formats next to oklch for every palette color, added to the config's formats +cssforge --color-formats hex,rgb ``` ## Programmatic Usage @@ -1326,7 +1470,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 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 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/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..98b2c43 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 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. + color: { formats: { hex: true } }, + }, }, 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..ae1a1d3 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 | +| `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: @@ -658,6 +659,146 @@ 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. +#### 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 `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: + + + +```typescript +export default defineConfig({ + colors: { + palette: { + value: { + coral: { 100: { hex: "#FF7F50" } }, + coralDark: { + value: { 100: { hex: "#FF6347" } }, + settings: { atRule: "@media (prefers-color-scheme: dark)" }, + }, + }, + settings: { + color: { + formats: { + hex: { string: true, digits: true, number: true }, + rgb: { string: true, array: true }, + }, + 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; + } + } +} +``` + + + +| 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]`. + +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 +Dictionary tokens carry the generated values in a `color` object, keyed by format and output: + +```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 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 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 You can conditionnally apply colors, gradients or themes by setting the `atRule` or the @@ -1252,6 +1393,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 + +# Generate sRGB formats next to oklch for every palette color, added to the config's formats +cssforge --color-formats hex,rgb ``` ## Programmatic Usage @@ -1326,7 +1470,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 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 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/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 0a3fae3..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,8 +19,17 @@ type CssValue = { value: string; key: string; variable: 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; }; @@ -56,12 +67,15 @@ type StyleDictionaryToken = { */ tailwindVariable: string; resolvedValue: string; + /** The extra color values generated alongside `oklch()`, keyed by format. */ + color?: TokenColorFormats; sourcePath: string; referencePaths?: string[]; }; $tier: TokenTier; $reference?: string; $resolvedValue: string; + $color?: TokenColorFormats; }; type StyleDictionaryValue = { @@ -149,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 @@ -172,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]; /** @@ -226,11 +247,16 @@ export function createForgeValues(config: Partial) { }, {} as ForgeValue); } - const jsonKeys = [...collectResolveMap(config).entries()].map( + const jsonKeys = [...collectResolveMap(config, options).entries()].map( ([path, token]) => [ path, - { key: token.key, value: token.value, variable: token.variable }, + { + key: token.key, + value: token.value, + variable: token.variable, + ...(token.color ? { color: token.color } : {}), + }, ] satisfies Input, ); const forgeValues = createForgeValuesFromKeys(jsonKeys); @@ -394,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]), ); @@ -427,12 +453,14 @@ export function generateStyleDictionaryJSON( cssVariableReference, tailwindVariable: token.key, resolvedValue, + ...(token.color ? { color: token.color } : {}), sourcePath: toStyleDictionaryPath(token.sourcePath), ...(referencePaths ? { referencePaths } : {}), }, $tier: tier, ...(referencePaths?.[0] ? { $reference: referencePaths[0] } : {}), $resolvedValue: resolvedValue, + ...(token.color ? { $color: token.color } : {}), }; const nestedObject = createNestedStyleDictionaryObject( outputPath.split("."), @@ -454,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); } @@ -469,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;`; } @@ -485,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: { @@ -496,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 9065e19..4e5bc69 100644 --- a/packages/cssforge/src/lib.ts +++ b/packages/cssforge/src/lib.ts @@ -8,6 +8,40 @@ 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 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 color values a token carries alongside `oklch()`, keyed by format. Only + * the formats and representations the configuration asked for are present. + */ +export interface TokenColorFormats { + hex?: HexColorValues; + rgb?: RgbColorValues; +} + /** * Metadata carried through generation so alternate outputs can preserve token * provenance without changing the generated CSS. @@ -38,6 +72,11 @@ export interface ResolvedToken extends TokenMetadata { value: string; /** The full CSS declaration. */ variable: string; + /** + * The sRGB values generated for this token alongside `oklch()`, keyed by + * format, when the color asked for them. + */ + 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..0c89544 100644 --- a/packages/cssforge/src/mod.ts +++ b/packages/cssforge/src/mod.ts @@ -20,7 +20,13 @@ 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, + 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 51a30b5..5061c82 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 { Variables } from "../lib.ts"; +import type { + ColorFormat, + HexColorValues, + RgbColorValues, + TokenColorFormats, + Variables, +} from "../lib.ts"; import { getReferencePaths, getResolvedVariablesMap, @@ -11,6 +17,18 @@ import { withTokenScope, } from "../lib.ts"; +/** + * 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 readCondition = (settings: WithCondition | undefined) => ({ + atRule: settings?.atRule?.trim() ?? "", + selector: settings?.selector?.trim() ?? "", +}); + /** * Describes the wrapper chain a declaration is emitted into. `selector` and * `atRule` are both part of the identity, because two declarations only @@ -23,15 +41,11 @@ 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; - - if (!atRule && !selector) return ROOT_SCOPE; - return `${atRule}|${selector}`; + const { atRule, selector } = readCondition(settings); + const scopedSelector = selector === ROOT_SCOPE ? "" : selector; + + if (!atRule && !scopedSelector) return ROOT_SCOPE; + return `${atRule}|${scopedSelector}`; }; type ExactlyOne = { @@ -67,10 +81,109 @@ export interface WithCondition { */ atRule?: string; } +/** + * 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 { ColorFormat } from "../lib.ts"; + +/** + * 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; + /** + * 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. */ +export interface RgbFormatOutputs { + /** The CSS value, such as `"rgb(255 127 80)"`. */ + 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; +} + +/** + * Settings for the color values generated next to `oklch()`, shared by the + * palette and its colors. + */ +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"`, and + * `alpha` controls the alpha of that format alone. + * + * 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: { string: true, digits: true }, rgb: true }, + * fallback: "hex", + * }, + * }, + * }, + * } + * ``` + */ + 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; +} + +/** + * 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 {} +export interface PaletteColorSettings extends WithCondition, ColorSettings {} /** * Settings for gradients, including optional conditions like media queries. @@ -160,7 +273,11 @@ export interface ColorConfig { */ palette: { value: ColorPalette; - settings?: unknown; + /** + * Settings shared by every palette color. A color's own settings take + * precedence. + */ + settings?: ColorSettings; }; /** * A collection of gradients. @@ -190,13 +307,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; @@ -245,19 +364,161 @@ 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))"; + +/** + * 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 a palette value resolves to. */ +const readColor = (value: ColorValueOrString): Color => + new Color(typeof value === "string" ? value : getColorString(value)); + +/** + * 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 colorForFormat = ( + color: Color, + { format, alpha }: GeneratedFormat, + path: string, +): Color => { + if (alpha === true || color.alpha === alpha) return color; + + 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 the "${format}" format sets "alpha" to false.`, + ); + } + + const generated = color.clone(); + generated.alpha = alpha; + return generated; +}; + +/** 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; + } +}; + /** - * Converts a color value to the OKLCH color space. + * 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) continue; + + throw new Error( + `Invalid color at "${path}.${variantId}": the color carries alpha, but the "${opaque[0].format}" format sets "alpha" to 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, ); @@ -269,6 +530,331 @@ function colorValueToOklch(value: ColorValueOrString): string { return `oklch(${Number((l * 100).toFixed(3))}% ${c} ${h}${alpha})`; } +/** One generated format: the format, the representations it produces, and its alpha policy. */ +interface GeneratedFormat { + format: ColorFormat; + outputs: readonly ColorFormatOutput[]; + alpha: boolean | number; +} + +/** + * Converts a color to every requested representation of every requested format, + * in the order they are generated. + */ +const colorToFormats = ( + color: Color, + formats: readonly GeneratedFormat[], + path: string, +): TokenColorFormats => { + const values: TokenColorFormats = {}; + + for (const entry of formats) { + const generated: Record = {}; + const formatColor = colorForFormat(color, entry, path); + for (const output of entry.outputs) { + generated[output] = colorFormatOutputs[entry.format][output](formatColor); + } + if (entry.format === "hex") values.hex = generated as HexColorValues; + else values.rgb = generated as RgbColorValues; + } + + return values; +}; + +/** The CSS value of one format, which is the declaration a browser without `oklch()` support reads. */ +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 + * inherited from the level above it, so a color that only names a fallback keeps + * the palette's formats. + */ +interface ColorFormatSettings { + formats?: GeneratedFormat[]; + fallback?: ColorFormat | false; +} + +/** The settings of one generated color, with every field resolved. */ +interface ResolvedColorFormatSettings { + formats: GeneratedFormat[]; + fallback?: ColorFormat | false; +} + +const defaultFormats = (format: ColorFormat): GeneratedFormat => ({ + format, + outputs: ["string"], + alpha: true, +}); + +/** + * 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 readColorFormatSettings( + entry: object, + settings: unknown, + path: string, +): 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": 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 undefined; + if (!isRecord(color)) { + throw new Error( + `Invalid configuration at "${settingsPath}.color": color must be an object.`, + ); + } + + 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`); + } + if (color.fallback !== undefined) { + colorSettings.fallback = readFallback( + color.fallback, + `${settingsPath}.color.fallback`, + ); + } + + return colorSettings; +} + +const readFormats = (value: unknown, path: string): GeneratedFormat[] => { + if (value === undefined || value === false) return []; + if (!isRecord(value)) { + throw new Error( + `Invalid configuration at "${path}": expected formats such as { hex: true }, or omit it.`, + ); + } + + const formats: GeneratedFormat[] = []; + for (const [name, outputs] of Object.entries(value)) { + if (!isColorFormat(name)) { + throw new Error( + `Invalid color format at configuration path "${path}": ${JSON.stringify( + name, + )}. Use ${colorFormatList}.`, + ); + } + + formats.push(readFormat(name, outputs, `${path}.${name}`)); + } + + return formats; +}; + +/** + * Reads one format entry: the outputs it produces, and its alpha policy. + */ +const readFormat = ( + format: ColorFormat, + value: unknown, + path: 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 }.`, + ); + } + + 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, "alpha"].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 }.`, + ); + } + + return { + format, + outputs, + alpha: "alpha" in value ? readFormatAlpha(value.alpha, `${path}.alpha`) : alpha, + }; +}; + +const readFallback = (value: unknown, path: string): ColorFormat | false | undefined => { + if (value === undefined || value === false) return value; + if (!isColorFormat(value)) { + throw new Error( + `Invalid fallback format at configuration path "${path}": ${JSON.stringify( + value, + )}. Use ${colorFormatList}, or false.`, + ); + } + + return value; +}; + +/** 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( + value, + )}. Use true, false, or a number between 0 and 1.`, + ); + } + + return value; +}; + +/** + * 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 resolveColorFormatSettings = ( + settings: ColorFormatSettings | undefined, + paletteSettings: ColorFormatSettings | undefined, + extraFormats: readonly ColorFormat[] = [], +): 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, + }; +}; + +/** + * 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): GeneratedFormat | undefined => { + if (fallback === false) return undefined; + + const generated = + fallback === undefined + ? formats.at(0) + : formats.find((entry) => entry.format === fallback); + + if (fallback !== undefined && !generated) { + throw new Error( + `Invalid fallback format: "${fallback}" is not generated. Add it to "settings.color.formats", or use false.`, + ); + } + if (generated && !generated.outputs.includes("string")) { + throw new Error( + `Invalid fallback format: "${generated.format}" does not generate its "string" output. Enable it, or use false.`, + ); + } + + return generated; +}; + +/** + * 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, selector } = readCondition(settings); + + return [...(atRule ? [atRule] : []), OKLCH_SUPPORT_CONDITION, 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; +}; + +/** + * 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. @@ -287,12 +873,32 @@ function colorValueToOklch(value: ColorValueOrString): 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(); 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, @@ -301,8 +907,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); @@ -325,11 +932,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(` }`); @@ -339,7 +945,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(`}`); @@ -348,7 +954,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(`}`); @@ -358,11 +964,44 @@ export function processColors(colors: ColorConfig): Output { }; } + 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); + // 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, + ); + assertOpaqueVariants( + normalizedColorConfig.value, + colorSettings.formats, + `palette.${colorName}`, + ); + + const declarationFormat = resolveDeclarationFormat(colorSettings); + const fallback = declarationFormat + ? { declarationFormat, group: getFallbackGroup(normalizedColorConfig.settings) } + : undefined; + if (fallback) fallback.group.declarations.push(`/* ${colorName} */`); + try { - const normalizedColorConfig = getPaletteColorConfig(colorConfig); const handler = conditionalBuilder( normalizedColorConfig.settings, `/* ${colorName} */`, @@ -371,8 +1010,18 @@ export function processColors(colors: ColorConfig): Output { for (const [variantId, colorValue] of Object.entries(normalizedColorConfig.value)) { validateName(variantId, `palette.${colorName}.${variantId}`); const key = `--${moduleKey}-${colorName}-${variantId}`; - const value = colorValueToOklch(colorValue); + 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, path) + : undefined; + if (fallback) { + const cssValue = colorToDeclaration(color, fallback.declarationFormat, path); + fallback.group.declarations.push(`${key}: ${cssValue};`); + } handler.pushVariable(variable); @@ -383,6 +1032,7 @@ export function processColors(colors: ColorConfig): Output { key, value, variable, + ...(colorValues ? { color: colorValues } : {}), sourcePath: `${moduleKey}.${colorName}.${variantId}`, type: "color", tier: "primitive", @@ -538,6 +1188,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/cli-color-formats.test.ts b/packages/cssforge/tests/cli-color-formats.test.ts new file mode 100644 index 0000000..52d82d6 --- /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: { string: "#ff7f50" }, + rgb: { string: "rgb(255 127 80)" }, + }); + assertEquals(ts.includes('"rgb": {'), true); + assertEquals(styleDictionary.palette.coral["100"].$color, { + hex: { string: "#ff7f50" }, + rgb: { string: "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/color-formats.test.ts b/packages/cssforge/tests/color-formats.test.ts new file mode 100644 index 0000000..4d620fc --- /dev/null +++ b/packages/cssforge/tests/color-formats.test.ts @@ -0,0 +1,686 @@ +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 - a format generates at the alpha it sets", () => { + const config = defineConfig({ + colors: { + palette: { + value: { coral: { 100: { hex: "#000000" } } }, + settings: { + color: { + formats: { + hex: { string: true, digits: true, alpha: 0.5 }, + rgb: { string: true, array: true }, + }, + }, + }, + }, + }, + }); + + const token = JSON.parse(generateJSON(config)).palette.coral["100"]; + + // 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)", array: [0, 0, 0] }, + }); +}); + +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: { + value: { + coral: { 100: "rgb(0 0 0 / 12%)" }, + opaque: { 100: { hex: "#FF7F50" } }, + }, + settings: { color: { formats: { hex: { string: true, alpha: false } } } }, + }, + }, + }); + + const error = assertThrows(() => generateCSS(config)); + + assert( + error.message.includes('"palette.coral.100"') && error.message.includes('"hex"'), + `Expected the error to name the variant and the format. 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: { string: true, alpha: false }, + 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, alpha: 0.5 } }, + fallback: false, + }, + }, + }, + }, + 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: { string: true, alpha: 2 } } } }, + }, + }, + } as unknown as CSSForgeConfig; + + const error = assertThrows(() => generateCSS(notAnOpacity)); + + assert( + 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}`, + ); +}); + +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/skills/cssforge/references/cli-reference.md b/skills/cssforge/references/cli-reference.md index 4f52a88..538878e 100644 --- a/skills/cssforge/references/cli-reference.md +++ b/skills/cssforge/references/cli-reference.md @@ -11,6 +11,11 @@ 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. Each added format generates its + CSS value, and `settings.color.fallback` still picks the declaration ## Typical commands from docs @@ -21,6 +26,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 +39,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 ff817f4..1818d39 100644 --- a/skills/cssforge/references/token-patterns.md +++ b/skills/cssforge/references/token-patterns.md @@ -7,6 +7,18 @@ 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` 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. +- 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. 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