From c5cd22ee98ddbaa4839dbf4ef45c52efc5beea94 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Thu, 8 Oct 2026 03:56:31 +0700 Subject: [PATCH 1/3] feat(motion): motion tokens module with duration and ease-in checks Add a motion module: duration and easing groups emitted as --motion-duration-- and --motion-easing--, resolvable from primitives as motion.... Durations and easing functions are validated at the boundary. A duration over 300ms (500ms with settings.long) and an ease-in shaped curve warn through the diagnostics channel. goodCssEasings exports the two recommended curves as an opt-in preset. Closes #83 Co-Authored-By: Claude --- .changeset/issue-83-motion.md | 10 + README.md | 95 ++++- packages/cssforge/README.md | 95 ++++- packages/cssforge/scripts/update-readme.ts | 2 +- packages/cssforge/src/config.ts | 5 + packages/cssforge/src/generator.ts | 12 + packages/cssforge/src/lib.ts | 30 +- packages/cssforge/src/mod.ts | 19 +- packages/cssforge/src/modules/motion.ts | 308 ++++++++++++++ packages/cssforge/src/modules/primitive.ts | 7 +- packages/cssforge/tests/motion.test.ts | 380 ++++++++++++++++++ skills/cssforge/SKILL.md | 2 +- skills/cssforge/references/docs-index.md | 1 + .../cssforge/references/source-schema-map.md | 24 +- skills/cssforge/references/token-patterns.md | 9 + 15 files changed, 982 insertions(+), 17 deletions(-) create mode 100644 .changeset/issue-83-motion.md create mode 100644 packages/cssforge/src/modules/motion.ts create mode 100644 packages/cssforge/tests/motion.test.ts diff --git a/.changeset/issue-83-motion.md b/.changeset/issue-83-motion.md new file mode 100644 index 0000000..2e43efe --- /dev/null +++ b/.changeset/issue-83-motion.md @@ -0,0 +1,10 @@ +--- +"@hebilicious/cssforge": minor +--- + +Add a `motion` module for transition durations and easing curves. + +- `motion.duration..value` and `motion.easing..value` emit `--motion-duration--` and `--motion-easing--`, referenced from primitives as `motion.duration..` and `motion.easing..`. +- A duration must be a non-negative number with `ms` or `s`, and an easing a CSS keyword, `cubic-bezier()` with x1 and x2 in [0, 1], `steps()` or `linear()`. Anything else fails generation with the token's path. +- A duration over 300ms warns (`motion-long-duration`), or over 500ms in a group with `settings.long: true` for modals and drawers. `ease-in` and ease-in shaped `cubic-bezier()` curves warn (`motion-ease-in`). Both go through `getDiagnostics` and the CLI's `cssforge: warning:` output. +- `goodCssEasings` exports the two recommended curves, `out` and `inOut`, to spread into an easing group. Nothing is added unless a config spreads it. diff --git a/README.md b/README.md index b065870..0dc3330 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ In the future, CSSforge will try to integrate with popular design tools such as - 🎨 **Colors**: Create palettes, gradients and themes. Automatically convert to OKLCH. - πŸ“ **Typography**: Generate fluid typography - πŸ“ **Spacing**: Organise spacing utilities +- 🎞️ **Motion**: Durations and easing curves, checked against UI motion guidelines - πŸ“¦ **Primitives**: Define custom design tokens - 🎯 **Zero Runtime**: All processing happens at build time - πŸ”„ **Watch Mode**: Auto-regenerate when your config changes @@ -369,13 +370,13 @@ const coral = tokens.palette.coral["100"].value; // "oklch(73.511% 0.16799 40.24 ### Values are CSS strings -Palette, spacing and typography tokens hold final values, because CSS Forge converts colors to +Palette, spacing, typography and motion tokens hold final values, because CSS Forge converts colors to OKLCH and computes fluid scales at build time. Theme, gradient and primitive tokens keep their `var(--other-token)` reference, because only the CSS cascade knows which value is active: | Token | `value` | | --- | --- | -| Palette, spacing, typography | The final value, such as `oklch(...)`, `0.5rem`, or `clamp(...)` | +| Palette, spacing, typography, motion | The final value, such as `oklch(...)`, `0.5rem`, or `clamp(...)` | | Theme, gradient, primitive | `var(--token)`, resolved by the browser at paint time | ```typescript @@ -1680,6 +1681,91 @@ This will generate the following CSS : +### Motion + +Define transition durations and easing curves once, then reference them from primitives. +Durations are grouped under `motion.duration` and easings under `motion.easing`. Each group +holds its tokens under `value`, emitted as `--motion-duration--` and +`--motion-easing--`, and referenced as `motion.duration..` and +`motion.easing..`. + +- A duration is a non-negative number with `ms` or `s`, such as `120ms` or `0.2s`. CSS needs + a unit on a time, so write `0ms` rather than `0`. +- An easing is `linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out`, `step-start`, + `step-end`, `cubic-bezier(x1, y1, x2, y2)` with `x1` and `x2` between 0 and 1, `steps()` or + `linear()`. + +Anything else fails the build with the token's configuration path. + +`goodCssEasings`, exported from `@hebilicious/cssforge`, holds the two curves recommended for UI motion: `out`, +`cubic-bezier(0.23, 1, 0.32, 1)`, for things entering or reacting, and `inOut`, +`cubic-bezier(0.77, 0, 0.175, 1)`, for things moving on screen. Spread it into an easing +group; nothing is added unless you do. + + + +```typescript +export default defineConfig({ + motion: { + duration: { + ui: { value: { press: "120ms", tooltip: "150ms", dropdown: "200ms" } }, + overlay: { value: { drawer: "400ms" }, settings: { long: true } }, + }, + easing: { + ui: { value: { ...goodCssEasings } }, + }, + }, +}); +``` + +This will generate the following CSS : + +```css +/*____ CSSForge ____*/ +:root { +/*____ Motion ____*/ +--motion-duration-ui-press: 120ms; +--motion-duration-ui-tooltip: 150ms; +--motion-duration-ui-dropdown: 200ms; +--motion-duration-overlay-drawer: 400ms; +--motion-easing-ui-out: cubic-bezier(0.23, 1, 0.32, 1); +--motion-easing-ui-inOut: cubic-bezier(0.77, 0, 0.175, 1); +} +``` + + + +#### Motion Checks + +These checks follow the motion guidance in good-css. They only report warnings: the tokens and +the CSS stay the same. + +- **Warning: a duration over 300ms** (`motion-long-duration`). A UI transition should take + 300ms or less. A modal or drawer may take longer: set `settings.long: true` on its duration + group to raise the limit to 500ms. Exactly 300ms, or 500ms in a long group, passes. +- **Warning: an ease-in curve** (`motion-ease-in`). A curve that starts slow and ends fast + reads as lag on UI. `ease-in` warns, and so does a `cubic-bezier()` whose slope at the start + is below 1 and whose slope at the end is above 1. Each slope is taken toward the nearest + control point that does not sit on that end: `y1 / x1` at the start and + `(1 - y2) / (1 - x2)` at the end. `ease`, `ease-out`, `ease-in-out` and `linear` do not + warn, and neither do `steps()` and `linear()`. + +`settings.long` is the only motion setting. It is read on duration groups, and an easing group +rejects any setting. CSS Forge only emits the tokens: whether something animates, and the +`prefers-reduced-motion: no-preference` query around the motion, stay in your stylesheet. + ### Primitives More flexible than other types, primitives allow you to define any type of token by @@ -1860,6 +1946,7 @@ For example, if an object has the following structure : ``` The reference would be : `spacing.custom.size.1`, not `spacing.custom.size.value.1`. +Motion tokens follow the same rule: `motion.duration.ui.press` and `motion.easing.ui.out`. #### Referencing Fluid Spacing @@ -1961,8 +2048,8 @@ for (const { code, severity, path, message } of getDiagnostics(config)) { Warnings never change the generated output, and the `generate*` functions do not report them. A configuration that cannot be generated, such as a fluid step past 2.5Γ— growth, throws -from `getDiagnostics` with the same error as from the generators. `processTypography` also -returns its warnings as `diagnostics` beside `css` and `resolveMap`. +from `getDiagnostics` with the same error as from the generators. `processTypography` and +`processMotion` also return their warnings as `diagnostics` beside `css` and `resolveMap`. ## Style Dictionary JSON diff --git a/packages/cssforge/README.md b/packages/cssforge/README.md index b065870..0dc3330 100644 --- a/packages/cssforge/README.md +++ b/packages/cssforge/README.md @@ -26,6 +26,7 @@ In the future, CSSforge will try to integrate with popular design tools such as - 🎨 **Colors**: Create palettes, gradients and themes. Automatically convert to OKLCH. - πŸ“ **Typography**: Generate fluid typography - πŸ“ **Spacing**: Organise spacing utilities +- 🎞️ **Motion**: Durations and easing curves, checked against UI motion guidelines - πŸ“¦ **Primitives**: Define custom design tokens - 🎯 **Zero Runtime**: All processing happens at build time - πŸ”„ **Watch Mode**: Auto-regenerate when your config changes @@ -369,13 +370,13 @@ const coral = tokens.palette.coral["100"].value; // "oklch(73.511% 0.16799 40.24 ### Values are CSS strings -Palette, spacing and typography tokens hold final values, because CSS Forge converts colors to +Palette, spacing, typography and motion tokens hold final values, because CSS Forge converts colors to OKLCH and computes fluid scales at build time. Theme, gradient and primitive tokens keep their `var(--other-token)` reference, because only the CSS cascade knows which value is active: | Token | `value` | | --- | --- | -| Palette, spacing, typography | The final value, such as `oklch(...)`, `0.5rem`, or `clamp(...)` | +| Palette, spacing, typography, motion | The final value, such as `oklch(...)`, `0.5rem`, or `clamp(...)` | | Theme, gradient, primitive | `var(--token)`, resolved by the browser at paint time | ```typescript @@ -1680,6 +1681,91 @@ This will generate the following CSS : +### Motion + +Define transition durations and easing curves once, then reference them from primitives. +Durations are grouped under `motion.duration` and easings under `motion.easing`. Each group +holds its tokens under `value`, emitted as `--motion-duration--` and +`--motion-easing--`, and referenced as `motion.duration..` and +`motion.easing..`. + +- A duration is a non-negative number with `ms` or `s`, such as `120ms` or `0.2s`. CSS needs + a unit on a time, so write `0ms` rather than `0`. +- An easing is `linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out`, `step-start`, + `step-end`, `cubic-bezier(x1, y1, x2, y2)` with `x1` and `x2` between 0 and 1, `steps()` or + `linear()`. + +Anything else fails the build with the token's configuration path. + +`goodCssEasings`, exported from `@hebilicious/cssforge`, holds the two curves recommended for UI motion: `out`, +`cubic-bezier(0.23, 1, 0.32, 1)`, for things entering or reacting, and `inOut`, +`cubic-bezier(0.77, 0, 0.175, 1)`, for things moving on screen. Spread it into an easing +group; nothing is added unless you do. + + + +```typescript +export default defineConfig({ + motion: { + duration: { + ui: { value: { press: "120ms", tooltip: "150ms", dropdown: "200ms" } }, + overlay: { value: { drawer: "400ms" }, settings: { long: true } }, + }, + easing: { + ui: { value: { ...goodCssEasings } }, + }, + }, +}); +``` + +This will generate the following CSS : + +```css +/*____ CSSForge ____*/ +:root { +/*____ Motion ____*/ +--motion-duration-ui-press: 120ms; +--motion-duration-ui-tooltip: 150ms; +--motion-duration-ui-dropdown: 200ms; +--motion-duration-overlay-drawer: 400ms; +--motion-easing-ui-out: cubic-bezier(0.23, 1, 0.32, 1); +--motion-easing-ui-inOut: cubic-bezier(0.77, 0, 0.175, 1); +} +``` + + + +#### Motion Checks + +These checks follow the motion guidance in good-css. They only report warnings: the tokens and +the CSS stay the same. + +- **Warning: a duration over 300ms** (`motion-long-duration`). A UI transition should take + 300ms or less. A modal or drawer may take longer: set `settings.long: true` on its duration + group to raise the limit to 500ms. Exactly 300ms, or 500ms in a long group, passes. +- **Warning: an ease-in curve** (`motion-ease-in`). A curve that starts slow and ends fast + reads as lag on UI. `ease-in` warns, and so does a `cubic-bezier()` whose slope at the start + is below 1 and whose slope at the end is above 1. Each slope is taken toward the nearest + control point that does not sit on that end: `y1 / x1` at the start and + `(1 - y2) / (1 - x2)` at the end. `ease`, `ease-out`, `ease-in-out` and `linear` do not + warn, and neither do `steps()` and `linear()`. + +`settings.long` is the only motion setting. It is read on duration groups, and an easing group +rejects any setting. CSS Forge only emits the tokens: whether something animates, and the +`prefers-reduced-motion: no-preference` query around the motion, stay in your stylesheet. + ### Primitives More flexible than other types, primitives allow you to define any type of token by @@ -1860,6 +1946,7 @@ For example, if an object has the following structure : ``` The reference would be : `spacing.custom.size.1`, not `spacing.custom.size.value.1`. +Motion tokens follow the same rule: `motion.duration.ui.press` and `motion.easing.ui.out`. #### Referencing Fluid Spacing @@ -1961,8 +2048,8 @@ for (const { code, severity, path, message } of getDiagnostics(config)) { Warnings never change the generated output, and the `generate*` functions do not report them. A configuration that cannot be generated, such as a fluid step past 2.5Γ— growth, throws -from `getDiagnostics` with the same error as from the generators. `processTypography` also -returns its warnings as `diagnostics` beside `css` and `resolveMap`. +from `getDiagnostics` with the same error as from the generators. `processTypography` and +`processMotion` also return their warnings as `diagnostics` beside `css` and `resolveMap`. ## Style Dictionary JSON diff --git a/packages/cssforge/scripts/update-readme.ts b/packages/cssforge/scripts/update-readme.ts index f2a8095..f81e49a 100644 --- a/packages/cssforge/scripts/update-readme.ts +++ b/packages/cssforge/scripts/update-readme.ts @@ -48,7 +48,7 @@ function wrapSource(tsSource: string): string { tsSource, ); const srcModUrl = pathToFileURL(join(PACKAGE_ROOT, "src", "mod.ts")).href; - const importLine = `import { defineConfig } from "${srcModUrl}";\n`; + const importLine = `import { defineConfig, goodCssEasings } from "${srcModUrl}";\n`; let normalized = tsSource.trim(); if (!hasDefaultExport) { diff --git a/packages/cssforge/src/config.ts b/packages/cssforge/src/config.ts index e25b420..7c68f85 100644 --- a/packages/cssforge/src/config.ts +++ b/packages/cssforge/src/config.ts @@ -1,4 +1,5 @@ import type { ColorConfig } from "./modules/colors.ts"; +import type { MotionConfig } from "./modules/motion.ts"; import type { PrimitiveConfig } from "./modules/primitive.ts"; import type { SpacingConfig } from "./modules/spacing.ts"; import type { TypographyConfig } from "./modules/typography.ts"; @@ -19,6 +20,10 @@ export interface CSSForgeConfig { * Spacing configuration for creating spacing scales. */ spacing: SpacingConfig; + /** + * Motion configuration for transition durations and easing functions. + */ + motion: MotionConfig; /** * Primitive configuration for creating custom design tokens. */ diff --git a/packages/cssforge/src/generator.ts b/packages/cssforge/src/generator.ts index b75ffd1..94a0677 100644 --- a/packages/cssforge/src/generator.ts +++ b/packages/cssforge/src/generator.ts @@ -15,6 +15,7 @@ import { type TokenType, } from "./lib.ts"; import { processColors } from "./modules/colors.ts"; +import { processMotion } from "./modules/motion.ts"; import { processPrimitives } from "./modules/primitive.ts"; import { processSpacing } from "./modules/spacing.ts"; import { processTypography } from "./modules/typography.ts"; @@ -173,12 +174,14 @@ const processModules = ( colors: config.colors ? processColors(config.colors, options) : undefined, spacing: config.spacing ? processSpacing(config.spacing) : undefined, typography: config.typography ? processTypography(config.typography) : undefined, + motion: config.motion ? processMotion(config.motion) : undefined, primitives: config.primitives ? processPrimitives({ primitives: config.primitives, colors: config.colors, typography: config.typography, spacing: config.spacing, + motion: config.motion, }) : undefined, }; @@ -606,12 +609,21 @@ export function generateCSS( } } + if (config.motion) { + processedConfig.motion = processMotion(config.motion); + if (processedConfig.motion.css.root) { + chunks.push("/*____ Motion ____*/"); + chunks.push(processedConfig.motion.css.root); + } + } + if (config.primitives) { const primitiveVars = processPrimitives({ primitives: config.primitives, colors: config.colors, typography: config.typography, spacing: config.spacing, + motion: config.motion, }); processedConfig.primitives = primitiveVars; if (primitiveVars) { diff --git a/packages/cssforge/src/lib.ts b/packages/cssforge/src/lib.ts index cde30b9..61d7c7b 100644 --- a/packages/cssforge/src/lib.ts +++ b/packages/cssforge/src/lib.ts @@ -3,6 +3,8 @@ export type TokenType = | "gradient" | "spacing" | "typography" + | "duration" + | "easing" | "primitive" | "component"; @@ -123,7 +125,9 @@ export type ResolveMap = Map; /** A stable identifier for each kind of build diagnostic. */ export type DiagnosticCode = | "typography-static-scale" - | "typography-below-legibility-floor"; + | "typography-below-legibility-floor" + | "motion-long-duration" + | "motion-ease-in"; /** * A build warning about a configuration that generates, but likely not as @@ -339,6 +343,7 @@ interface Modules { colors?: Output | null; typography?: Output | null; spacing?: Output | null; + motion?: Output | null; } interface ResolveVariableParams extends Modules { @@ -369,7 +374,12 @@ export const normalizeTokenPath = (varPath: string): string => { return [module, parts[0], parts[1], ...parts.slice(3)].join("."); } - if (module === "typography" && parts[0] === "weight" && parts[2] === "value") { + if ( + (module === "typography" && parts[0] === "weight" && parts[2] === "value") || + (module === "motion" && + (parts[0] === "duration" || parts[0] === "easing") && + parts[2] === "value") + ) { return [module, parts[0], parts[1], ...parts.slice(3)].join("."); } @@ -395,6 +405,7 @@ export function resolveVariable({ colors, typography, spacing, + motion, }: ResolveVariableParams) { const normalizedPath = normalizeTokenPath(varPath); const path = normalizedPath.split("."); @@ -408,6 +419,7 @@ export function resolveVariable({ typography_fluid: "typography_fluid", spacing: "spacing", spacing_fluid: "spacing_fluid", + motion: "motion", }; switch (module) { case keyMap.palette: @@ -450,6 +462,18 @@ export function resolveVariable({ } return result.key; } + case keyMap.motion: { + if (!motion) throw new Error("The motion object must be passed."); + const result = motion.resolveMap.get(normalizedPath); + if (!result) { + throw new Error( + `The motion path ${varPath} could not be resolved. Map contains ${Array.from( + motion.resolveMap.keys(), + ).map((key) => `\n${key}`)}`, + ); + } + return result.key; + } default: throw new Error(`${module} is not implemented and can't be resolved.`); } @@ -457,7 +481,7 @@ export function resolveVariable({ /** * Generates a map of resolved CSS variable names from a variables object. - * It uses the provided modules (colors, typography, spacing) to resolve the paths. + * It uses the provided modules (colors, typography, spacing, motion) to resolve the paths. */ export const getResolvedVariablesMap = ({ variables, diff --git a/packages/cssforge/src/mod.ts b/packages/cssforge/src/mod.ts index 6f2f411..829d306 100644 --- a/packages/cssforge/src/mod.ts +++ b/packages/cssforge/src/mod.ts @@ -1,7 +1,7 @@ /** * This module provides the core functionalities of CSSForge, a tool for generating * CSS variables from a configuration file. It exports functions for processing - * different aspects of a design system, such as colors, typography, and spacing, + * different aspects of a design system, such as colors, typography, spacing, and motion, * as well as the main function to generate the final CSS. * * @module @@ -13,6 +13,7 @@ import { generateCSS, generateStyleDictionaryJSON, getDiagnostics } from "./gene import { InvalidNameError } from "./helpers.ts"; import { loadConfig } from "./loader.ts"; import { processColors } from "./modules/colors.ts"; +import { goodCssEasings, processMotion } from "./modules/motion.ts"; import { processPrimitives } from "./modules/primitive.ts"; import { processSpacing } from "./modules/spacing.ts"; import { processTypography } from "./modules/typography.ts"; @@ -42,6 +43,7 @@ export type { PaletteColorSettings, RgbFormatOutputs, } from "./modules/colors.ts"; +export type { MotionConfig, MotionDurationSettings } from "./modules/motion.ts"; export { /** @@ -70,6 +72,15 @@ export { * @returns The diagnostics, in module order. */ getDiagnostics, + /** + * The two easing curves good-css recommends for UI motion, to spread into an + * easing group's `value`. They are not added unless a config spreads them. + * @example + * ```ts + * defineConfig({ motion: { easing: { ui: { value: { ...goodCssEasings } } } } }); + * ``` + */ + goodCssEasings, /** * Thrown when a configuration name cannot produce a valid CSS custom property * name or reference. Catch it with `instanceof` to handle a name validation @@ -99,6 +110,12 @@ export { * @returns A map of CSS variables for colors. */ processColors, + /** + * Processes the motion section of the configuration. + * @param motion The motion configuration. + * @returns A map of CSS variables for motion, and its build warnings. + */ + processMotion, /** * Processes the primitives section of the configuration. * @param primitives The primitives configuration. diff --git a/packages/cssforge/src/modules/motion.ts b/packages/cssforge/src/modules/motion.ts new file mode 100644 index 0000000..4f4e0e0 --- /dev/null +++ b/packages/cssforge/src/modules/motion.ts @@ -0,0 +1,308 @@ +import { assertSettingsKeys, validateName } from "../helpers.ts"; +import type { Diagnostic, DiagnosticCode, Output, ResolveMap } from "../lib.ts"; + +/** + * Settings for a group of durations. + */ +export interface MotionDurationSettings { + /** + * Marks a group of modal and drawer transitions, which may take up to 500ms + * instead of the 300ms a UI transition should stay under. It only changes the + * build warnings: the tokens and the CSS stay the same. + * @default false + */ + long?: boolean; +} + +/** + * The motion configuration object. + */ +export interface MotionConfig { + /** + * Groups of durations. Each `value` maps a name to a non-negative time in `ms` + * or `s`, such as `"120ms"` or `"0.2s"`. + */ + duration?: { + [group: string]: { + value: { [name: string]: string }; + settings?: MotionDurationSettings; + }; + }; + /** + * Groups of easing functions. Each `value` maps a name to a CSS easing + * function: a keyword, `cubic-bezier()`, `steps()` or `linear()`. + */ + easing?: { + [group: string]: { value: { [name: string]: string } }; + }; +} + +/** + * The two easing curves good-css defines for UI motion, to spread into an + * easing group's `value`. They are not added unless a config spreads them. + * @example + * ```ts + * defineConfig({ motion: { easing: { ui: { value: { ...goodCssEasings } } } } }); + * ``` + */ +export const goodCssEasings = Object.freeze({ + out: "cubic-bezier(0.23, 1, 0.32, 1)", + inOut: "cubic-bezier(0.77, 0, 0.175, 1)", +} as const); + +const MAX_UI_DURATION_MS = 300; +const MAX_LONG_DURATION_MS = 500; + +const NUMBER = String.raw`[+-]?(?:\d+(?:\.\d+)?|\.\d+)`; +const DURATION_PATTERN = /^(\d+(?:\.\d+)?|\.\d+)(ms|s)$/; +const NUMBER_PATTERN = new RegExp(`^${NUMBER}$`); +const PERCENTAGE_PATTERN = new RegExp(`^${NUMBER}%$`); +const EASING_KEYWORDS = [ + "linear", + "ease", + "ease-in", + "ease-out", + "ease-in-out", + "step-start", + "step-end", +]; +const STEP_POSITIONS = [ + "jump-start", + "jump-end", + "jump-none", + "jump-both", + "start", + "end", +]; +/** The control points of each keyword that is a cubic BΓ©zier curve. */ +const KEYWORD_CURVES: Record = { + linear: [0, 0, 1, 1], + ease: [0.25, 0.1, 0.25, 1], + "ease-in": [0.42, 0, 1, 1], + "ease-out": [0, 0, 0.58, 1], + "ease-in-out": [0.42, 0, 0.58, 1], +}; + +type BezierPoints = [x1: number, y1: number, x2: number, y2: number]; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const invalid = (path: string, expected: string, value: unknown) => + new Error( + `Invalid configuration at "${path}": expected ${expected}, received ${JSON.stringify(value)}.`, + ); + +const readRecord = (value: unknown, path: string, expected: string) => { + if (!isRecord(value)) throw invalid(path, expected, value); + return value; +}; + +const assertKeys = (value: Record, allowed: string[], path: string) => { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length === 0) return; + throw new Error( + `Invalid configuration at "${path}": unknown key ${unknown.map((key) => `"${key}"`).join(", ")}. Use ${allowed.map((key) => `"${key}"`).join(", ")}.`, + ); +}; + +/** Reads a duration in ms. CSS needs a unit on a time, so a bare `0` is rejected. */ +const readDuration = (value: unknown, path: string): number => { + const match = typeof value === "string" ? DURATION_PATTERN.exec(value) : null; + if (!match) { + throw invalid( + path, + 'a non-negative time in "ms" or "s", such as "120ms" or "0.2s"', + value, + ); + } + const amount = Number(match[1]); + return match[2] === "s" ? Math.round(amount * 1e6) / 1e3 : amount; +}; + +const readArguments = (value: string, name: string): string[] | undefined => { + if (!value.startsWith(`${name}(`) || !value.endsWith(")")) return undefined; + return value + .slice(name.length + 1, -1) + .split(",") + .map((argument) => argument.trim()); +}; + +const readCubicBezier = (args: string[]): BezierPoints | undefined => { + if (args.length !== 4 || !args.every((arg) => NUMBER_PATTERN.test(arg))) + return undefined; + const [x1, y1, x2, y2] = args.map(Number) as BezierPoints; + if (x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1) return undefined; + return [x1, y1, x2, y2]; +}; + +const isSteps = (args: string[]) => { + const [count, position, ...rest] = args; + if (rest.length > 0 || !count || !/^\d+$/.test(count)) return false; + if (position === undefined) return Number(count) >= 1; + if (!STEP_POSITIONS.includes(position)) return false; + return Number(count) >= (position === "jump-none" ? 2 : 1); +}; + +const isPercentages = (parts: string[]) => + parts.length <= 2 && parts.every((part) => PERCENTAGE_PATTERN.test(part)); + +/** A number before or after up to two percentages. */ +const isLinearStop = (stop: string) => { + const parts = stop.split(/\s+/); + if (NUMBER_PATTERN.test(parts[0] ?? "")) return isPercentages(parts.slice(1)); + return NUMBER_PATTERN.test(parts.at(-1) ?? "") && isPercentages(parts.slice(0, -1)); +}; + +const isLinear = (args: string[]) => args.length >= 2 && args.every(isLinearStop); + +/** + * Reads an easing function and returns its BΓ©zier control points, or `null` + * for `steps()`, `linear()` and the step keywords, which have none. + */ +const readEasing = (value: unknown, path: string): BezierPoints | null => { + const fail = () => + invalid( + path, + `a CSS easing function: ${EASING_KEYWORDS.join(", ")}, cubic-bezier(x1, y1, x2, y2) with x1 and x2 between 0 and 1, steps() or linear()`, + value, + ); + if (typeof value !== "string") throw fail(); + if (EASING_KEYWORDS.includes(value)) return KEYWORD_CURVES[value] ?? null; + + const bezierArgs = readArguments(value, "cubic-bezier"); + if (bezierArgs) { + const points = readCubicBezier(bezierArgs); + if (!points) throw fail(); + return points; + } + const stepArgs = readArguments(value, "steps"); + if (stepArgs && isSteps(stepArgs)) return null; + const linearArgs = readArguments(value, "linear"); + if (linearArgs && isLinear(linearArgs)) return null; + throw fail(); +}; + +type Point = [x: number, y: number]; + +const slope = ([ax, ay]: Point, [bx, by]: Point) => (by - ay) / (bx - ax); + +/** The first control point that does not sit on `end`, or the other end. */ +const nearestDistinct = (end: Point, points: Point[]): Point => + points.find(([x, y]) => x !== end[0] || y !== end[1]) ?? + (end[0] === 0 ? [1, 1] : [0, 0]); + +/** + * A curve is ease-in shaped when it starts slower than linear and ends faster: + * its slope at 0 is below 1 and its slope at 1 is above 1. The slope at an end + * is taken toward the nearest control point that does not sit on that end. + * This keeps `ease-in-out`, whose slope is 0 at both ends, and `ease`, which + * ends at 0, from warning. + */ +const isEaseInShaped = ([x1, y1, x2, y2]: BezierPoints) => { + const p1: Point = [x1, y1]; + const p2: Point = [x2, y2]; + const start = slope([0, 0], nearestDistinct([0, 0], [p1, p2])); + const end = slope(nearestDistinct([1, 1], [p2, p1]), [1, 1]); + return start < 1 && end > 1; +}; + +const formatMs = (ms: number) => `${Number(ms.toFixed(3))}ms`; + +/** + * Generate CSS custom properties for motion durations and easing functions. + * + * Generated variable naming: + * - Duration: `--motion-duration-{group}-{name}`, resolved as `motion.duration.{group}.{name}` + * - Easing: `--motion-easing-{group}-{name}`, resolved as `motion.easing.{group}.{name}` + * + * @example + * ```ts + * const { css, diagnostics } = processMotion({ + * duration: { + * ui: { value: { press: "120ms", dropdown: "200ms" } }, + * overlay: { value: { modal: "400ms" }, settings: { long: true } }, + * }, + * easing: { ui: { value: { ...goodCssEasings } } }, + * }); + * ``` + */ +export function processMotion(motion: MotionConfig): Output { + const cssOutput: string[] = []; + const resolveMap: ResolveMap = new Map(); + const diagnostics: Diagnostic[] = []; + const moduleKey = "motion"; + + const config = readRecord(motion, moduleKey, "an object"); + assertKeys(config, ["duration", "easing"], moduleKey); + + const readGroups = ( + kind: "duration" | "easing", + code: DiagnosticCode, + readSettings: (group: Record, path: string) => S, + check: (value: unknown, path: string, settings: S) => string | undefined, + ) => { + if (config[kind] === undefined) return; + const kindPath = `${moduleKey}.${kind}`; + const groups = readRecord(config[kind], kindPath, "an object of groups"); + for (const [groupName, groupConfig] of Object.entries(groups)) { + const groupPath = `${kindPath}.${groupName}`; + validateName(groupName, groupPath); + const group = readRecord(groupConfig, groupPath, "an object with a value"); + assertKeys(group, ["value", "settings"], groupPath); + const settings = readSettings(group, `${groupPath}.settings`); + const tokens = readRecord(group.value, `${groupPath}.value`, "an object of tokens"); + for (const [name, value] of Object.entries(tokens)) { + const path = `${groupPath}.${name}`; + validateName(name, path); + const message = check(value, path, settings); + if (message) diagnostics.push({ code, severity: "warning", path, message }); + const key = `--${moduleKey}-${kind}-${groupName}-${name}`; + const variable = `${key}: ${value};`; + cssOutput.push(variable); + resolveMap.set(path, { + variable, + key, + value: String(value), + sourcePath: path, + type: kind, + tier: "primitive", + }); + } + } + }; + + readGroups( + "duration", + "motion-long-duration", + (group, path) => { + assertSettingsKeys(group.settings, ["long"], path); + const long = isRecord(group.settings) ? group.settings.long : undefined; + if (long !== undefined && typeof long !== "boolean") { + throw invalid(`${path}.long`, "true or false", long); + } + return long === true; + }, + (value, path, long) => { + const ms = readDuration(value, path); + const limit = long ? MAX_LONG_DURATION_MS : MAX_UI_DURATION_MS; + if (ms <= limit) return undefined; + return long + ? `Motion duration ${path} is ${formatMs(ms)}, over the ${limit}ms a modal or drawer transition should take, even with settings.long. Shorten it.` + : `Motion duration ${path} is ${formatMs(ms)}, over the ${limit}ms a UI transition should take. Shorten it, or set the group's settings.long for a modal or drawer, which allows up to ${MAX_LONG_DURATION_MS}ms.`; + }, + ); + + readGroups( + "easing", + "motion-ease-in", + (group, path) => assertSettingsKeys(group.settings, [], path), + (value, path) => { + const points = readEasing(value, path); + if (!points || !isEaseInShaped(points)) return undefined; + return `Motion easing ${path} is ease-in shaped: it starts slow and ends fast, which reads as lag on UI. Use an ease-out curve such as ${goodCssEasings.out}, or ${goodCssEasings.inOut} for movement on screen.`; + }, + ); + + return { css: { root: cssOutput.join("\n") }, resolveMap, diagnostics }; +} diff --git a/packages/cssforge/src/modules/primitive.ts b/packages/cssforge/src/modules/primitive.ts index f70d660..a151da1 100644 --- a/packages/cssforge/src/modules/primitive.ts +++ b/packages/cssforge/src/modules/primitive.ts @@ -14,6 +14,8 @@ import { } from "../lib.ts"; import type { ColorConfig } from "./colors.ts"; import { processColors } from "./colors.ts"; +import type { MotionConfig } from "./motion.ts"; +import { processMotion } from "./motion.ts"; import type { PixelSettings, SpacingConfig } from "./spacing.ts"; import { processSpacing } from "./spacing.ts"; import type { TypographyConfig } from "./typography.ts"; @@ -49,7 +51,7 @@ export interface PrimitiveConfig { /** * Processes the primitive configuration to generate CSS variables. - * Primitives can reference other design tokens from colors, typography, and spacing. + * Primitives can reference other design tokens from colors, typography, spacing, and motion. * @example * ```ts * const config = { @@ -86,6 +88,7 @@ export function processPrimitives(config: { colors?: ColorConfig; typography?: TypographyConfig; spacing?: SpacingConfig; + motion?: MotionConfig; }): Output { const cssOutput: string[] = []; const resolveMap = new Map(); @@ -95,6 +98,7 @@ export function processPrimitives(config: { ? processTypography(config.typography) : null; const spacingVariables = config.spacing ? processSpacing(config.spacing) : null; + const motionVariables = config.motion ? processMotion(config.motion) : null; for (const [primitiveName, primitive] of Object.entries(config.primitives)) { validateName(primitiveName, `${moduleKey}.${primitiveName}`); assertSettingsKeys( @@ -123,6 +127,7 @@ export function processPrimitives(config: { colors: colorVariables, typography: typographyVariables, spacing: spacingVariables, + motion: motionVariables, }); for (const [propName, propValue] of Object.entries(properties)) { diff --git a/packages/cssforge/tests/motion.test.ts b/packages/cssforge/tests/motion.test.ts new file mode 100644 index 0000000..04d22e9 --- /dev/null +++ b/packages/cssforge/tests/motion.test.ts @@ -0,0 +1,380 @@ +import { generateJSON, generateTS } from "../src/generator.ts"; +import type { CSSForgeConfig, Diagnostic } from "../src/mod.ts"; +import { + generateCSS, + generateStyleDictionaryJSON, + getDiagnostics, + goodCssEasings, + processMotion, +} from "../src/mod.ts"; +import { assert, assertEquals, assertThrows, Deno } from "./vitest-compat.ts"; + +const motion = (config: CSSForgeConfig["motion"]): Partial => ({ + motion: config, +}); + +const duration = (value: string, settings?: { long?: boolean }) => + motion({ + duration: { ui: { value: { press: value }, ...(settings ? { settings } : {}) } }, + }); + +const easing = (value: string) => motion({ easing: { ui: { value: { enter: value } } } }); + +const codes = (diagnostics: Diagnostic[]) => diagnostics.map(({ code }) => code); + +const assertRejected = (config: unknown, parts: string[]) => { + const error = assertThrows(() => generateCSS(config as Partial)); + for (const part of parts) { + assert(error.message.includes(part), `Expected "${part}" in: ${error.message}`); + } +}; + +Deno.test("motion - emits duration and easing tokens per group", () => { + const css = generateCSS( + motion({ + duration: { + ui: { value: { press: "120ms", dropdown: "0.2s" } }, + overlay: { value: { modal: "400ms" }, settings: { long: true } }, + }, + easing: { + ui: { value: { out: "cubic-bezier(0.23, 1, 0.32, 1)", flat: "linear" } }, + }, + }), + ); + + assertEquals( + css, + [ + "/*____ CSSForge ____*/", + ":root {", + "/*____ Motion ____*/", + "--motion-duration-ui-press: 120ms;", + "--motion-duration-ui-dropdown: 0.2s;", + "--motion-duration-overlay-modal: 400ms;", + "--motion-easing-ui-out: cubic-bezier(0.23, 1, 0.32, 1);", + "--motion-easing-ui-flat: linear;", + "}", + ].join("\n"), + ); +}); + +Deno.test("motion - processMotion resolves motion... paths", () => { + const { resolveMap } = processMotion({ + duration: { ui: { value: { press: "120ms" } } }, + easing: { ui: { value: { out: "ease-out" } } }, + }); + + assertEquals(Object.fromEntries(resolveMap), { + "motion.duration.ui.press": { + key: "--motion-duration-ui-press", + value: "120ms", + variable: "--motion-duration-ui-press: 120ms;", + sourcePath: "motion.duration.ui.press", + type: "duration", + tier: "primitive", + }, + "motion.easing.ui.out": { + key: "--motion-easing-ui-out", + value: "ease-out", + variable: "--motion-easing-ui-out: ease-out;", + sourcePath: "motion.easing.ui.out", + type: "easing", + tier: "primitive", + }, + }); +}); + +Deno.test("motion - primitives reference motion tokens through variables", () => { + const css = generateCSS({ + ...motion({ + duration: { ui: { value: { press: "120ms" } } }, + easing: { ui: { value: { out: "ease-out" } } }, + }), + primitives: { + button: { + value: { + default: { + value: { transition: "transform var(--press) var(--out)" }, + variables: { press: "motion.duration.ui.press", out: "motion.easing.ui.out" }, + }, + }, + }, + }, + }); + + assert( + css.includes( + "--button-default-transition: transform var(--motion-duration-ui-press) var(--motion-easing-ui-out);", + ), + css, + ); +}); + +Deno.test("motion - a reference may keep the value segment, as spacing allows", () => { + const css = generateCSS({ + ...motion({ duration: { ui: { value: { press: "120ms" } } } }), + primitives: { + button: { + value: { + default: { + value: { duration: "var(--d)" }, + variables: { d: "motion.duration.ui.value.press" }, + }, + }, + }, + }, + }); + assert( + css.includes("--button-default-duration: var(--motion-duration-ui-press);"), + css, + ); +}); + +Deno.test("motion - an unknown motion reference names the path", () => { + const error = assertThrows(() => + generateCSS({ + ...motion({ duration: { ui: { value: { press: "120ms" } } } }), + primitives: { + button: { + value: { + default: { + value: { duration: "var(--d)" }, + variables: { d: "motion.duration.ui.missing" }, + }, + }, + }, + }, + }), + ); + assert(error.message.includes("motion.duration.ui.missing"), error.message); +}); + +Deno.test("motion - JSON, TypeScript and Style Dictionary outputs carry the tokens", () => { + const config = motion({ + duration: { ui: { value: { press: "120ms" } } }, + easing: { ui: { value: { out: "ease-out" } } }, + }); + + const json = JSON.parse(generateJSON(config)); + assertEquals(json.motion.duration.ui.press, { + key: "--motion-duration-ui-press", + value: "120ms", + variable: "--motion-duration-ui-press: 120ms;", + }); + assert(generateTS(config).includes('"key": "--motion-easing-ui-out"')); + + const tokens = JSON.parse(generateStyleDictionaryJSON(config)); + assertEquals(tokens.motion.duration.ui.press.value, "120ms"); + assertEquals(tokens.motion.duration.ui.press.type, "duration"); + assertEquals(tokens.motion.easing.ui.out.type, "easing"); + assertEquals( + tokens.motion.easing.ui.out.attributes.cssVariable, + "--motion-easing-ui-out", + ); +}); + +Deno.test("motion - a token colliding with a primitive key is rejected", () => { + assertRejected( + { + ...motion({ duration: { ui: { value: { press: "120ms" } } } }), + primitives: { motion: { value: { duration: { value: { "ui-press": "1s" } } } } }, + }, + [ + "motion.duration.ui.press", + "primitives.motion.duration.ui-press", + "--motion-duration-ui-press", + ], + ); +}); + +Deno.test("motion - accepts durations in ms or s, including zero", () => { + for (const value of ["0ms", "0s", "120ms", "0.2s", ".15s", "1.5ms"]) { + assertEquals(getDiagnostics(duration(value)), [], value); + } +}); + +Deno.test("motion - rejects a duration that is not a non-negative ms or s value", () => { + for (const value of [ + "120", + "0", + "-10ms", + "1sec", + "100px", + "120 ms", + "1.ms", + "2.s", + "var(--d)", + "", + ]) { + assertRejected(duration(value), [ + '"motion.duration.ui.press"', + JSON.stringify(value), + "ms", + ]); + } + assertRejected(motion({ duration: { ui: { value: { press: 120 } } } } as never), [ + '"motion.duration.ui.press"', + ]); +}); + +Deno.test("motion - accepts CSS easing functions", () => { + for (const value of [ + "linear", + "ease", + "ease-out", + "ease-in-out", + "step-start", + "step-end", + "cubic-bezier(0.23, 1, 0.32, 1)", + "cubic-bezier(0,0,1,1)", + "cubic-bezier(0.175, 0.885, 0.32, 1.275)", + "cubic-bezier(.5, -2, .5, 3)", + "steps(4)", + "steps(4, jump-end)", + "steps(2, jump-none)", + "steps(1, start)", + "linear(0, 1)", + "linear(0, 0.25 75%, 1)", + "linear(0, 0.5 25% 75%, 1)", + "linear(0, 25% 75% 0.5, 1)", + ]) { + assertEquals(codes(getDiagnostics(easing(value))), [], value); + } +}); + +Deno.test("motion - rejects values that are not CSS easing functions", () => { + for (const value of [ + "", + "ease-inn", + "EASE", + "cubic-bezier(1.1, 0, 0.5, 1)", + "cubic-bezier(0.5, 0, -0.1, 1)", + "cubic-bezier(0.5, 0, 0.5)", + "cubic-bezier(0.5, 0, 0.5, 1, 1)", + "cubic-bezier(a, 0, 0.5, 1)", + "cubic-bezier(0.5 0 0.5 1)", + "cubic-bezier(1., 0, 1, 1)", + "steps(0)", + "steps(1.5)", + "steps(4, sideways)", + "steps(1, jump-none)", + "linear()", + "linear(fast)", + "linear(0)", + "linear(0, 25% 0.5 50%, 1)", + "linear(0, 50% 60% 70%, 1)", + "spring(1, 100, 10, 0)", + ]) { + assertRejected(easing(value), ['"motion.easing.ui.enter"', JSON.stringify(value)]); + } +}); + +Deno.test("motion - rejects malformed groups and settings", () => { + assertRejected(motion({ durations: {} } as never), ['"motion"', '"durations"']); + assertRejected(motion({ duration: { ui: { press: "120ms" } } } as never), [ + '"motion.duration.ui"', + '"press"', + ]); + assertRejected(motion({ easing: { ui: {} } } as never), ['"motion.easing.ui.value"']); + assertRejected(duration("120ms", { lng: true } as never), [ + '"motion.duration.ui.settings"', + '"lng"', + ]); + assertRejected(duration("120ms", { long: "yes" } as never), [ + '"motion.duration.ui.settings.long"', + ]); + assertRejected( + motion({ + easing: { ui: { value: { out: "ease-out" }, settings: { long: true } } }, + } as never), + ['"motion.easing.ui.settings"', '"long"'], + ); + assertRejected(motion({ duration: { "ui group": { value: { press: "120ms" } } } }), [ + "motion.duration.ui group", + ]); +}); + +Deno.test("motion - a UI duration over 300ms warns, 300ms does not", () => { + assertEquals(getDiagnostics(duration("300ms")), []); + assertEquals(getDiagnostics(duration("0.3s")), []); + + const [diagnostic, ...rest] = getDiagnostics(duration("0.301s")); + assertEquals(rest, []); + assertEquals(diagnostic?.code, "motion-long-duration"); + assertEquals(diagnostic?.severity, "warning"); + assertEquals(diagnostic?.path, "motion.duration.ui.press"); + for (const part of ["motion.duration.ui.press", "301ms", "300ms", "settings.long"]) { + assert( + diagnostic?.message.includes(part), + `Expected "${part}" in: ${diagnostic?.message}`, + ); + } +}); + +Deno.test("motion - a long group allows up to 500ms and warns above it", () => { + assertEquals(getDiagnostics(duration("400ms", { long: true })), []); + assertEquals(getDiagnostics(duration("500ms", { long: true })), []); + + const [diagnostic] = getDiagnostics(duration("501ms", { long: true })); + assertEquals(diagnostic?.code, "motion-long-duration"); + assert(diagnostic?.message.includes("500ms"), diagnostic?.message); + + assertEquals(codes(getDiagnostics(duration("400ms", { long: false }))), [ + "motion-long-duration", + ]); +}); + +Deno.test("motion - ease-in and ease-in shaped curves warn", () => { + for (const value of [ + "ease-in", + "cubic-bezier(0.42, 0, 1, 1)", + "cubic-bezier(0.55, 0.055, 0.675, 0.19)", + "cubic-bezier(0.55, 0.085, 0.68, 0.53)", + "cubic-bezier(0.6, -0.28, 0.735, 0.045)", + ]) { + const diagnostics = getDiagnostics(easing(value)); + assertEquals(codes(diagnostics), ["motion-ease-in"], value); + assertEquals(diagnostics[0]?.path, "motion.easing.ui.enter"); + assert( + diagnostics[0]?.message.includes("motion.easing.ui.enter"), + diagnostics[0]?.message, + ); + } +}); + +Deno.test("motion - ease-out, ease-in-out and linear curves do not warn", () => { + for (const value of [ + "ease", + "ease-out", + "ease-in-out", + "linear", + "cubic-bezier(0, 0, 1, 1)", + "cubic-bezier(0.42, 0, 0.58, 1)", + "cubic-bezier(0.23, 1, 0.32, 1)", + "cubic-bezier(0.77, 0, 0.175, 1)", + "cubic-bezier(0.175, 0.885, 0.32, 1.275)", + "steps(4)", + "linear(0, 0.1 50%, 1)", + ]) { + assertEquals(getDiagnostics(easing(value)), [], value); + } +}); + +Deno.test("motion - goodCssEasings is a preset spread into an easing group", () => { + assertEquals( + { ...goodCssEasings }, + { out: "cubic-bezier(0.23, 1, 0.32, 1)", inOut: "cubic-bezier(0.77, 0, 0.175, 1)" }, + ); + + const config = motion({ easing: { ui: { value: { ...goodCssEasings } } } }); + const css = generateCSS(config); + assert(css.includes("--motion-easing-ui-out: cubic-bezier(0.23, 1, 0.32, 1);"), css); + assert(css.includes("--motion-easing-ui-inOut: cubic-bezier(0.77, 0, 0.175, 1);"), css); + assertEquals(getDiagnostics(config), []); +}); + +Deno.test("motion - the preset is not injected without an easing group", () => { + const css = generateCSS(duration("120ms")); + assert(!css.includes("--motion-easing"), css); + assert(!css.includes("cubic-bezier"), css); +}); diff --git a/skills/cssforge/SKILL.md b/skills/cssforge/SKILL.md index b432b47..d0ba496 100644 --- a/skills/cssforge/SKILL.md +++ b/skills/cssforge/SKILL.md @@ -24,7 +24,7 @@ Do not invent schema fields that are not present in docs or source types. 1. Confirm the user goal - New config from scratch -- Extend existing config (colors, spacing, typography, primitives) +- Extend existing config (colors, spacing, typography, motion, primitives) - Fix references / generation errors - Wire CLI scripts and output paths diff --git a/skills/cssforge/references/docs-index.md b/skills/cssforge/references/docs-index.md index ebbf92f..ea563d9 100644 --- a/skills/cssforge/references/docs-index.md +++ b/skills/cssforge/references/docs-index.md @@ -16,6 +16,7 @@ This file maps user requests to official CSS Forge documentation. - `### Colors` - `### Spacing` - `### Typography` + - `### Motion` - `### Primitives` - Variable references inside config: - `README.md` -> `## Referencing Variables` diff --git a/skills/cssforge/references/source-schema-map.md b/skills/cssforge/references/source-schema-map.md index ddcdc29..4a4b82e 100644 --- a/skills/cssforge/references/source-schema-map.md +++ b/skills/cssforge/references/source-schema-map.md @@ -9,6 +9,7 @@ When README examples are not enough, use source types. - `colors: ColorConfig` - `typography: TypographyConfig` - `spacing: SpacingConfig` + - `motion: MotionConfig` - `primitives: PrimitiveConfig` ## Module type sources @@ -16,6 +17,7 @@ When README examples are not enough, use source types. - Colors schema: `packages/cssforge/src/modules/colors.ts` - Spacing schema: `packages/cssforge/src/modules/spacing.ts` - Typography schema: `packages/cssforge/src/modules/typography.ts` +- Motion schema: `packages/cssforge/src/modules/motion.ts` - Primitives schema: `packages/cssforge/src/modules/primitive.ts` ## Settings @@ -70,6 +72,24 @@ color output settings. - `value.relativeTo` maps `viewport-width` (default) to `vw`, `viewport` to `vi` and `container` to `cqi` in both representations; anything else is rejected. +## Motion + +- `MotionConfig` and `MotionDurationSettings` in `packages/cssforge/src/modules/motion.ts`, + re-exported from `mod.ts`: `motion.duration..value.` and + `motion.easing..value.`, emitted as `--motion-duration--` and + `--motion-easing--`, with resolve paths `motion.duration..` and + `motion.easing..`. +- A duration is a non-negative number with `ms` or `s` (`0ms` passes, a bare `0` is rejected). + An easing is `linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out`, `step-start`, + `step-end`, `cubic-bezier()` with x1 and x2 in [0, 1], `steps()` or `linear()`. +- `settings.long` (boolean, duration groups only; easing groups take no settings) raises the + duration warning threshold from 300ms to 500ms for modals and drawers. +- Warnings: `motion-long-duration` per duration over the threshold and `motion-ease-in` per + `ease-in` or ease-in shaped `cubic-bezier()` (slope at the start below 1 and at the end + above 1), with the token path as `path`. +- `goodCssEasings` (`out`, `inOut`) is an exported preset to spread into an easing group's + `value`; nothing is added unless spread. + ## Derived colors - `ColorMix` in `packages/cssforge/src/modules/colors.ts`, re-exported from `mod.ts`: @@ -96,8 +116,8 @@ color output settings. ## Exposed API entrypoint -- `packages/cssforge/src/mod.ts` exports `defineConfig`, `generateCSS`, `getDiagnostics`, the processing helpers, - and the config and token types (`CSSForgeConfig`, `ColorFormatConfig`, `ColorMix`, `HexFormatOutputs`, +- `packages/cssforge/src/mod.ts` exports `defineConfig`, `generateCSS`, `getDiagnostics`, `goodCssEasings`, the processing helpers, + and the config and token types (`CSSForgeConfig`, `MotionConfig`, `MotionDurationSettings`, `ColorFormatConfig`, `ColorMix`, `HexFormatOutputs`, `RgbFormatOutputs`, `ColorSettings`, `ColorThemesSettings`, `LightDarkSettings`, `LightDarkColorScheme`, `ColorFormat`, `HexColorValues`, `RgbColorValues`, `TokenColorFormats`, `Diagnostic`, `DiagnosticCode`, `GenerateOptions`, `StyleDictionaryJSONOptions`). diff --git a/skills/cssforge/references/token-patterns.md b/skills/cssforge/references/token-patterns.md index f673ca2..efacc3a 100644 --- a/skills/cssforge/references/token-patterns.md +++ b/skills/cssforge/references/token-patterns.md @@ -28,6 +28,14 @@ All patterns below are derived from README configuration examples. - Weights under `typography.weight`. - Fluid type scales under `typography.fluid` with optional custom labels/prefix. +## Motion + +- Durations under `motion.duration..value` in `ms` or `s`; easings under + `motion.easing..value`. Spread `goodCssEasings` into an easing group for the two + recommended curves. +- Durations over 300ms warn unless the group sets `settings.long` (modals, drawers: 500ms). + `ease-in` and ease-in shaped `cubic-bezier()` curves warn. + ## Primitives - Use `primitives..value.`. @@ -39,3 +47,4 @@ All patterns below are derived from README configuration examples. - Fluid references use `@`, for example: - spacing: `spacing_fluid.base@xs` - typography: `typography_fluid.comicsans@a` +- Motion references: `motion.duration.ui.press`, `motion.easing.ui.out` From 7afb2ef2570f5c4cd7abd34cca9977127c3ca237 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Thu, 8 Oct 2026 11:35:13 +0700 Subject: [PATCH 2/3] refactor(motion): rename the easing preset to uiEasings Refs #83 Co-Authored-By: Claude Opus 5.5 --- .changeset/issue-83-motion.md | 2 +- README.md | 8 ++++---- packages/cssforge/README.md | 8 ++++---- packages/cssforge/scripts/update-readme.ts | 2 +- packages/cssforge/src/mod.ts | 8 ++++---- packages/cssforge/src/modules/motion.ts | 10 +++++----- packages/cssforge/tests/motion.test.ts | 8 ++++---- skills/cssforge/references/source-schema-map.md | 4 ++-- skills/cssforge/references/token-patterns.md | 2 +- 9 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.changeset/issue-83-motion.md b/.changeset/issue-83-motion.md index 2e43efe..18324ac 100644 --- a/.changeset/issue-83-motion.md +++ b/.changeset/issue-83-motion.md @@ -7,4 +7,4 @@ Add a `motion` module for transition durations and easing curves. - `motion.duration..value` and `motion.easing..value` emit `--motion-duration--` and `--motion-easing--`, referenced from primitives as `motion.duration..` and `motion.easing..`. - A duration must be a non-negative number with `ms` or `s`, and an easing a CSS keyword, `cubic-bezier()` with x1 and x2 in [0, 1], `steps()` or `linear()`. Anything else fails generation with the token's path. - A duration over 300ms warns (`motion-long-duration`), or over 500ms in a group with `settings.long: true` for modals and drawers. `ease-in` and ease-in shaped `cubic-bezier()` curves warn (`motion-ease-in`). Both go through `getDiagnostics` and the CLI's `cssforge: warning:` output. -- `goodCssEasings` exports the two recommended curves, `out` and `inOut`, to spread into an easing group. Nothing is added unless a config spreads it. +- `uiEasings` exports the two recommended curves, `out` and `inOut`, to spread into an easing group. Nothing is added unless a config spreads it. diff --git a/README.md b/README.md index 0dc3330..12c0671 100644 --- a/README.md +++ b/README.md @@ -1697,7 +1697,7 @@ holds its tokens under `value`, emitted as `--motion-duration--` an Anything else fails the build with the token's configuration path. -`goodCssEasings`, exported from `@hebilicious/cssforge`, holds the two curves recommended for UI motion: `out`, +`uiEasings`, exported from `@hebilicious/cssforge`, holds the two curves recommended for UI motion: `out`, `cubic-bezier(0.23, 1, 0.32, 1)`, for things entering or reacting, and `inOut`, `cubic-bezier(0.77, 0, 0.175, 1)`, for things moving on screen. Spread it into an easing group; nothing is added unless you do. @@ -1710,7 +1710,7 @@ export default defineConfig({ overlay: { value: { drawer: "400ms" }, settings: { long: true } }, }, easing: { - ui: { value: { ...goodCssEasings } }, + ui: { value: { ...uiEasings } }, }, }, }); @@ -1724,7 +1724,7 @@ export default defineConfig({ overlay: { value: { drawer: "400ms" }, settings: { long: true } }, }, easing: { - ui: { value: { ...goodCssEasings } }, + ui: { value: { ...uiEasings } }, }, }, }); @@ -1749,7 +1749,7 @@ This will generate the following CSS : #### Motion Checks -These checks follow the motion guidance in good-css. They only report warnings: the tokens and +These checks only report warnings: the tokens and the CSS stay the same. - **Warning: a duration over 300ms** (`motion-long-duration`). A UI transition should take diff --git a/packages/cssforge/README.md b/packages/cssforge/README.md index 0dc3330..12c0671 100644 --- a/packages/cssforge/README.md +++ b/packages/cssforge/README.md @@ -1697,7 +1697,7 @@ holds its tokens under `value`, emitted as `--motion-duration--` an Anything else fails the build with the token's configuration path. -`goodCssEasings`, exported from `@hebilicious/cssforge`, holds the two curves recommended for UI motion: `out`, +`uiEasings`, exported from `@hebilicious/cssforge`, holds the two curves recommended for UI motion: `out`, `cubic-bezier(0.23, 1, 0.32, 1)`, for things entering or reacting, and `inOut`, `cubic-bezier(0.77, 0, 0.175, 1)`, for things moving on screen. Spread it into an easing group; nothing is added unless you do. @@ -1710,7 +1710,7 @@ export default defineConfig({ overlay: { value: { drawer: "400ms" }, settings: { long: true } }, }, easing: { - ui: { value: { ...goodCssEasings } }, + ui: { value: { ...uiEasings } }, }, }, }); @@ -1724,7 +1724,7 @@ export default defineConfig({ overlay: { value: { drawer: "400ms" }, settings: { long: true } }, }, easing: { - ui: { value: { ...goodCssEasings } }, + ui: { value: { ...uiEasings } }, }, }, }); @@ -1749,7 +1749,7 @@ This will generate the following CSS : #### Motion Checks -These checks follow the motion guidance in good-css. They only report warnings: the tokens and +These checks only report warnings: the tokens and the CSS stay the same. - **Warning: a duration over 300ms** (`motion-long-duration`). A UI transition should take diff --git a/packages/cssforge/scripts/update-readme.ts b/packages/cssforge/scripts/update-readme.ts index f81e49a..8f9186a 100644 --- a/packages/cssforge/scripts/update-readme.ts +++ b/packages/cssforge/scripts/update-readme.ts @@ -48,7 +48,7 @@ function wrapSource(tsSource: string): string { tsSource, ); const srcModUrl = pathToFileURL(join(PACKAGE_ROOT, "src", "mod.ts")).href; - const importLine = `import { defineConfig, goodCssEasings } from "${srcModUrl}";\n`; + const importLine = `import { defineConfig, uiEasings } from "${srcModUrl}";\n`; let normalized = tsSource.trim(); if (!hasDefaultExport) { diff --git a/packages/cssforge/src/mod.ts b/packages/cssforge/src/mod.ts index 829d306..9fcd076 100644 --- a/packages/cssforge/src/mod.ts +++ b/packages/cssforge/src/mod.ts @@ -13,7 +13,7 @@ import { generateCSS, generateStyleDictionaryJSON, getDiagnostics } from "./gene import { InvalidNameError } from "./helpers.ts"; import { loadConfig } from "./loader.ts"; import { processColors } from "./modules/colors.ts"; -import { goodCssEasings, processMotion } from "./modules/motion.ts"; +import { processMotion, uiEasings } from "./modules/motion.ts"; import { processPrimitives } from "./modules/primitive.ts"; import { processSpacing } from "./modules/spacing.ts"; import { processTypography } from "./modules/typography.ts"; @@ -73,14 +73,14 @@ export { */ getDiagnostics, /** - * The two easing curves good-css recommends for UI motion, to spread into an + * The two easing curves recommended for UI motion, to spread into an * easing group's `value`. They are not added unless a config spreads them. * @example * ```ts - * defineConfig({ motion: { easing: { ui: { value: { ...goodCssEasings } } } } }); + * defineConfig({ motion: { easing: { ui: { value: { ...uiEasings } } } } }); * ``` */ - goodCssEasings, + uiEasings, /** * Thrown when a configuration name cannot produce a valid CSS custom property * name or reference. Catch it with `instanceof` to handle a name validation diff --git a/packages/cssforge/src/modules/motion.ts b/packages/cssforge/src/modules/motion.ts index 4f4e0e0..19e2f0a 100644 --- a/packages/cssforge/src/modules/motion.ts +++ b/packages/cssforge/src/modules/motion.ts @@ -38,14 +38,14 @@ export interface MotionConfig { } /** - * The two easing curves good-css defines for UI motion, to spread into an + * The two easing curves recommended for UI motion, to spread into an * easing group's `value`. They are not added unless a config spreads them. * @example * ```ts - * defineConfig({ motion: { easing: { ui: { value: { ...goodCssEasings } } } } }); + * defineConfig({ motion: { easing: { ui: { value: { ...uiEasings } } } } }); * ``` */ -export const goodCssEasings = Object.freeze({ +export const uiEasings = Object.freeze({ out: "cubic-bezier(0.23, 1, 0.32, 1)", inOut: "cubic-bezier(0.77, 0, 0.175, 1)", } as const); @@ -223,7 +223,7 @@ const formatMs = (ms: number) => `${Number(ms.toFixed(3))}ms`; * ui: { value: { press: "120ms", dropdown: "200ms" } }, * overlay: { value: { modal: "400ms" }, settings: { long: true } }, * }, - * easing: { ui: { value: { ...goodCssEasings } } }, + * easing: { ui: { value: { ...uiEasings } } }, * }); * ``` */ @@ -300,7 +300,7 @@ export function processMotion(motion: MotionConfig): Output { (value, path) => { const points = readEasing(value, path); if (!points || !isEaseInShaped(points)) return undefined; - return `Motion easing ${path} is ease-in shaped: it starts slow and ends fast, which reads as lag on UI. Use an ease-out curve such as ${goodCssEasings.out}, or ${goodCssEasings.inOut} for movement on screen.`; + return `Motion easing ${path} is ease-in shaped: it starts slow and ends fast, which reads as lag on UI. Use an ease-out curve such as ${uiEasings.out}, or ${uiEasings.inOut} for movement on screen.`; }, ); diff --git a/packages/cssforge/tests/motion.test.ts b/packages/cssforge/tests/motion.test.ts index 04d22e9..8b94773 100644 --- a/packages/cssforge/tests/motion.test.ts +++ b/packages/cssforge/tests/motion.test.ts @@ -4,8 +4,8 @@ import { generateCSS, generateStyleDictionaryJSON, getDiagnostics, - goodCssEasings, processMotion, + uiEasings, } from "../src/mod.ts"; import { assert, assertEquals, assertThrows, Deno } from "./vitest-compat.ts"; @@ -360,13 +360,13 @@ Deno.test("motion - ease-out, ease-in-out and linear curves do not warn", () => } }); -Deno.test("motion - goodCssEasings is a preset spread into an easing group", () => { +Deno.test("motion - uiEasings is a preset spread into an easing group", () => { assertEquals( - { ...goodCssEasings }, + { ...uiEasings }, { out: "cubic-bezier(0.23, 1, 0.32, 1)", inOut: "cubic-bezier(0.77, 0, 0.175, 1)" }, ); - const config = motion({ easing: { ui: { value: { ...goodCssEasings } } } }); + const config = motion({ easing: { ui: { value: { ...uiEasings } } } }); const css = generateCSS(config); assert(css.includes("--motion-easing-ui-out: cubic-bezier(0.23, 1, 0.32, 1);"), css); assert(css.includes("--motion-easing-ui-inOut: cubic-bezier(0.77, 0, 0.175, 1);"), css); diff --git a/skills/cssforge/references/source-schema-map.md b/skills/cssforge/references/source-schema-map.md index 4a4b82e..8ae1c7c 100644 --- a/skills/cssforge/references/source-schema-map.md +++ b/skills/cssforge/references/source-schema-map.md @@ -87,7 +87,7 @@ color output settings. - Warnings: `motion-long-duration` per duration over the threshold and `motion-ease-in` per `ease-in` or ease-in shaped `cubic-bezier()` (slope at the start below 1 and at the end above 1), with the token path as `path`. -- `goodCssEasings` (`out`, `inOut`) is an exported preset to spread into an easing group's +- `uiEasings` (`out`, `inOut`) is an exported preset to spread into an easing group's `value`; nothing is added unless spread. ## Derived colors @@ -116,7 +116,7 @@ color output settings. ## Exposed API entrypoint -- `packages/cssforge/src/mod.ts` exports `defineConfig`, `generateCSS`, `getDiagnostics`, `goodCssEasings`, the processing helpers, +- `packages/cssforge/src/mod.ts` exports `defineConfig`, `generateCSS`, `getDiagnostics`, `uiEasings`, the processing helpers, and the config and token types (`CSSForgeConfig`, `MotionConfig`, `MotionDurationSettings`, `ColorFormatConfig`, `ColorMix`, `HexFormatOutputs`, `RgbFormatOutputs`, `ColorSettings`, `ColorThemesSettings`, `LightDarkSettings`, `LightDarkColorScheme`, `ColorFormat`, `HexColorValues`, `RgbColorValues`, diff --git a/skills/cssforge/references/token-patterns.md b/skills/cssforge/references/token-patterns.md index efacc3a..2a19cb8 100644 --- a/skills/cssforge/references/token-patterns.md +++ b/skills/cssforge/references/token-patterns.md @@ -31,7 +31,7 @@ All patterns below are derived from README configuration examples. ## Motion - Durations under `motion.duration..value` in `ms` or `s`; easings under - `motion.easing..value`. Spread `goodCssEasings` into an easing group for the two + `motion.easing..value`. Spread `uiEasings` into an easing group for the two recommended curves. - Durations over 300ms warn unless the group sets `settings.long` (modals, drawers: 500ms). `ease-in` and ease-in shaped `cubic-bezier()` curves warn. From 5ce98af7e14cf10cdf4435f280b0d12fcaa141b6 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Thu, 8 Oct 2026 11:41:37 +0700 Subject: [PATCH 3/3] refactor(motion): drop the exported easing preset CSS Forge ships no other preset constants. The README shows the two curves written out in a motion config instead. Refs #83 Co-Authored-By: Claude Opus 5.5 --- .changeset/issue-83-motion.md | 1 - README.md | 21 +++++++++++++------ packages/cssforge/README.md | 21 +++++++++++++------ packages/cssforge/scripts/update-readme.ts | 2 +- packages/cssforge/src/mod.ts | 11 +--------- packages/cssforge/src/modules/motion.ts | 17 ++------------- packages/cssforge/tests/motion.test.ts | 20 ------------------ .../cssforge/references/source-schema-map.md | 4 +--- skills/cssforge/references/token-patterns.md | 3 +-- 9 files changed, 36 insertions(+), 64 deletions(-) diff --git a/.changeset/issue-83-motion.md b/.changeset/issue-83-motion.md index 18324ac..0542fc1 100644 --- a/.changeset/issue-83-motion.md +++ b/.changeset/issue-83-motion.md @@ -7,4 +7,3 @@ Add a `motion` module for transition durations and easing curves. - `motion.duration..value` and `motion.easing..value` emit `--motion-duration--` and `--motion-easing--`, referenced from primitives as `motion.duration..` and `motion.easing..`. - A duration must be a non-negative number with `ms` or `s`, and an easing a CSS keyword, `cubic-bezier()` with x1 and x2 in [0, 1], `steps()` or `linear()`. Anything else fails generation with the token's path. - A duration over 300ms warns (`motion-long-duration`), or over 500ms in a group with `settings.long: true` for modals and drawers. `ease-in` and ease-in shaped `cubic-bezier()` curves warn (`motion-ease-in`). Both go through `getDiagnostics` and the CLI's `cssforge: warning:` output. -- `uiEasings` exports the two recommended curves, `out` and `inOut`, to spread into an easing group. Nothing is added unless a config spreads it. diff --git a/README.md b/README.md index 12c0671..c696bc6 100644 --- a/README.md +++ b/README.md @@ -1697,10 +1697,9 @@ holds its tokens under `value`, emitted as `--motion-duration--` an Anything else fails the build with the token's configuration path. -`uiEasings`, exported from `@hebilicious/cssforge`, holds the two curves recommended for UI motion: `out`, -`cubic-bezier(0.23, 1, 0.32, 1)`, for things entering or reacting, and `inOut`, -`cubic-bezier(0.77, 0, 0.175, 1)`, for things moving on screen. Spread it into an easing -group; nothing is added unless you do. +Two curves work well for UI motion: an ease-out, `cubic-bezier(0.23, 1, 0.32, 1)`, for things +entering or reacting, and an ease-in-out, `cubic-bezier(0.77, 0, 0.175, 1)`, for things moving +on screen.