diff --git a/README.md b/README.md index ed855c5..1ae437f 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ rounded: : spacing: : +motion: + : components: : : @@ -133,8 +135,10 @@ components: |:-----|:-------|:--------| | Color | Any CSS color (hex, `rgb()`, `oklch()`, named, etc.) | `"#1A1C1E"`, `"oklch(62% 0.18 250)"` | | Dimension | number + unit (`px`, `em`, `rem`) | `48px`, `-0.02em` | +| Duration | number + `ms` | `100ms`, `250ms` | | Token Reference | `{path.to.token}` | `{colors.primary}` | | Typography | object with `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, `fontFeature`, `fontVariation` | See example above | +| Motion | object with `duration`, `delay`, `timingFunction` | `duration: 100ms`, `timingFunction: ease-out` | ### Section Order @@ -148,8 +152,9 @@ Sections use `##` headings. They can be omitted, but those present must appear i | 4 | Layout | Layout & Spacing | | 5 | Elevation & Depth | Elevation | | 6 | Shapes | | -| 7 | Components | | -| 8 | Do's and Don'ts | | +| 7 | Motion & Micro-interaction | Motion & Micro-interactions, Motion | +| 8 | Components | | +| 9 | Do's and Don'ts | | ### Component Tokens @@ -177,6 +182,7 @@ Variants (hover, active, pressed) are expressed as separate component entries wi | Unknown section heading | Preserve; do not error | | Unknown color token name | Accept if value is valid | | Unknown typography token name | Accept as valid typography | +| Unknown motion token name | Accept as valid motion | | Unknown component property | Accept with warning | | Duplicate section heading | Error; reject the file | diff --git a/docs/spec.md b/docs/spec.md index 5995e54..18c39c1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -53,6 +53,8 @@ rounded: : spacing: : +motion: + : components: : : @@ -84,7 +86,15 @@ Hex notation (`#RRGGBB`) remains the recommended default for simplicity and broa **Dimension**: A dimension value is a string with a unit suffix. Valid units are: px, em, rem. -**Omitted**: An array of sections that are intentionally omitted from the design system. This suppresses linter warnings for missing sections (e.g. colors, typography, spacing, rounded, components). Each entry can be: +**Duration**: A duration value is a string with a `ms` unit suffix (e.g., `100ms`, `250ms`). + +**Motion**: A motion token is an object that maps a purpose-driven motion name to its timing properties: + +- `duration` (Duration) - The length of time a transition takes to complete (e.g., `100ms`, `250ms`). +- `delay` (Duration) - The time to wait before a transition starts (e.g., `0ms`, `50ms`). +- `timingFunction` (string) - A CSS easing function such as `linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out`, `steps(...)`, or `cubic-bezier(...)`. + +**Omitted**: An array of sections that are intentionally omitted from the design system. This suppresses linter warnings for missing sections (e.g. colors, typography, spacing, rounded, motion, components). Each entry can be: * A string representing the section name (e.g., `spacing`) * An object of the form `{ section: string, reason?: string }` mapping a section to a documented reason for its omission: @@ -109,8 +119,9 @@ Every `DESIGN.md` follows the same structure. Sections can be omitted if they're 4. **Layout** (also: "Layout & Spacing") 5. **Elevation & Depth** (also: "Elevation") 6. **Shapes** -7. **Components** -8. **Do's and Don'ts** +7. **Motion & Micro-interaction** (also: "Motion & Micro-interactions", "Motion") +8. **Components** +9. **Do's and Don'ts** ### Prose and Tokens @@ -296,6 +307,47 @@ rounded: full: 9999px ``` +## Motion & Micro-interaction + +Also known as "Motion & Micro-interactions" or "Motion". + +This section describes how the interface moves. It pairs prose choreography rules with structured `motion` tokens so transitions feel consistent and respect user motion preferences: + +* **What moves**: State changes on interactive elements, surfaces entering and leaving, and content loading, as well as what deliberately does not move. +* **Duration rhythm**: Small, local changes are faster than large structural transitions. +* **Easing intent**: Elements entering the screen decelerate, elements leaving accelerate, and elements staying on screen use a symmetric curve. +* **Restraint**: Honor `prefers-reduced-motion` and avoid animating properties that force layout recalculation (prefer `transform` and `opacity`). + +Example: + +```markdown +## Motion & Micro-interaction + +Motion is quick and understated. Interactive feedback lands within a tenth of a second so the interface feels immediate, while surfaces that enter the screen take longer and decelerate. Nothing loops, and nothing moves purely for decoration. Honor `prefers-reduced-motion`. +``` + +### Design Tokens + +The `motion` section defines purpose-named motion tokens (such as `state-change`, `surface-enter`, and `surface-exit`) rather than raw speed labels. Each entry maps a semantic motion name to its timing properties (`duration`, `delay`, and `timingFunction`). + +It is a map\. + +```yaml +motion: + state-change: + duration: 100ms + delay: 0ms + timingFunction: ease-out + surface-enter: + duration: 250ms + delay: 0ms + timingFunction: cubic-bezier(0.2, 0, 0, 1) + surface-exit: + duration: 150ms + delay: 0ms + timingFunction: cubic-bezier(0.3, 0, 1, 1) +``` + ## Components This section provides style guidance for component atoms within the design system. The following are common component types. Design systems are encouraged to define additional components relevant to their domain. @@ -363,6 +415,8 @@ The following names are commonly used across design systems. They are not requir **Rounded:** `none`, `sm`, `md`, `lg`, `xl`, `full` +**Motion:** `state-change`, `surface-enter`, `surface-exit` + # Consumer Behavior for Unknown Content When a DESIGN.md consumer encounters content not defined by this spec: @@ -372,6 +426,7 @@ When a DESIGN.md consumer encounters content not defined by this spec: | Unknown section heading | Preserve; do not error | `## Iconography` | | Unknown color token name | Accept if value is valid | `surface-container-high: '#ede7dd'` | | Unknown typography token name | Accept as valid typography | `telemetry-data` | +| Unknown motion token name | Accept as valid motion | `page-transition` | | Unknown spacing value | Accept; store as string if not a valid dimension | `grid-columns: '5'` | | Unknown component property | Accept with warning | `borderColor` | | Duplicate section heading | Error; reject the file | Two `## Colors` headings | diff --git a/packages/cli/src/linter/index.test.ts b/packages/cli/src/linter/index.test.ts index ba12e98..6e41df6 100644 --- a/packages/cli/src/linter/index.test.ts +++ b/packages/cli/src/linter/index.test.ts @@ -163,4 +163,74 @@ motion: ); expect(unknownKeyFindings).toEqual([]); }); + + it('processes a DESIGN.md with Motion & Micro-interaction section and purpose-named motion tokens', () => { + const content = `--- +name: Daylight Prestige + +colors: + primary: "#1A1C1E" + +typography: + body-md: + fontFamily: Public Sans + fontSize: 16px + fontWeight: 400 + lineHeight: 1.6 + +motion: + state-change: + duration: 100ms + delay: 0ms + timingFunction: ease-out + surface-enter: + duration: 250ms + delay: 0ms + timingFunction: cubic-bezier(0.2, 0, 0, 1) + surface-exit: + duration: 150ms + delay: 0ms + timingFunction: cubic-bezier(0.3, 0, 1, 1) +--- + +## Overview + +Editorial design system with high-contrast neutrals. + +## Colors + +Primary ink palette. + +## Typography + +Public Sans for body text. + +## Motion & Micro-interaction + +Motion is quick and understated. Interactive feedback lands within a tenth of a second so the interface feels immediate, while surfaces that enter the screen take longer and decelerate. Nothing loops, and nothing moves purely for decoration. Honor \`prefers-reduced-motion\`. + +## Components + +Core component library. +`; + + const result = lint(content); + + expect(result.summary.errors).toBe(0); + expect(result.designSystem.motion?.size).toBe(3); + expect(result.designSystem.motion?.get('state-change')).toEqual({ + type: 'motion', + duration: '100ms', + delay: '0ms', + timingFunction: 'ease-out', + }); + + expect(result.tailwindConfig.success).toBe(true); + if (result.tailwindConfig.success) { + const extend = result.tailwindConfig.data.theme.extend; + expect(extend.transitionDuration?.['surface-enter']).toBe('250ms'); + expect(extend.transitionDelay?.['surface-enter']).toBe('0ms'); + expect(extend.transitionTimingFunction?.['surface-enter']).toBe('cubic-bezier(0.2, 0, 0, 1)'); + } + }); }); diff --git a/packages/cli/src/linter/linter/rules/omitted.ts b/packages/cli/src/linter/linter/rules/omitted.ts index d802c35..91bdb1f 100644 --- a/packages/cli/src/linter/linter/rules/omitted.ts +++ b/packages/cli/src/linter/linter/rules/omitted.ts @@ -22,7 +22,7 @@ export function omittedRules(state: DesignSystemState): RuleFinding[] { const findings: RuleFinding[] = []; if (!state.omitted) return findings; - const validSections = new Set(['colors', 'typography', 'spacing', 'rounded', 'components']); + const validSections = new Set(['colors', 'typography', 'spacing', 'rounded', 'motion', 'components']); for (const item of state.omitted) { const sectionName = item.section.toLowerCase(); @@ -43,6 +43,7 @@ export function omittedRules(state: DesignSystemState): RuleFinding[] { if (sectionName === 'typography' && state.typography && state.typography.size > 0) isPresent = true; if (sectionName === 'spacing' && state.spacing && state.spacing.size > 0) isPresent = true; if (sectionName === 'rounded' && state.rounded && state.rounded.size > 0) isPresent = true; + if (sectionName === 'motion' && state.motion && state.motion.size > 0) isPresent = true; if (sectionName === 'components' && state.components && state.components.size > 0) isPresent = true; if (isPresent) { diff --git a/packages/cli/src/linter/linter/rules/section-order.test.ts b/packages/cli/src/linter/linter/rules/section-order.test.ts index 4e94ebe..c38d192 100644 --- a/packages/cli/src/linter/linter/rules/section-order.test.ts +++ b/packages/cli/src/linter/linter/rules/section-order.test.ts @@ -91,7 +91,7 @@ describe('sectionOrder', () => { it('should handle mixed aliases and canonical names', () => { const state = { - sections: ['Brand & Style', 'Colors', 'Typography', 'Layout & Spacing', 'Elevation & Depth', 'Shapes', 'Components'], + sections: ['Brand & Style', 'Colors', 'Typography', 'Layout & Spacing', 'Elevation & Depth', 'Shapes', 'Motion & Micro-interaction', 'Components'], } as unknown as DesignSystemState; const findings = sectionOrder(state); @@ -105,6 +105,8 @@ describe('resolveAlias', () => { expect(resolveAlias('Brand & Style')).toBe('Overview'); expect(resolveAlias('Layout & Spacing')).toBe('Layout'); expect(resolveAlias('Elevation')).toBe('Elevation & Depth'); + expect(resolveAlias('Motion & Micro-interactions')).toBe('Motion & Micro-interaction'); + expect(resolveAlias('Motion')).toBe('Motion & Micro-interaction'); }); it('should pass through canonical names unchanged', () => { diff --git a/packages/cli/src/linter/linter/rules/token-like-ignored.ts b/packages/cli/src/linter/linter/rules/token-like-ignored.ts index 3fbc4c3..0744f88 100644 --- a/packages/cli/src/linter/linter/rules/token-like-ignored.ts +++ b/packages/cli/src/linter/linter/rules/token-like-ignored.ts @@ -88,7 +88,7 @@ export function tokenLikeIgnored(state: DesignSystemState): RuleFinding[] { path: key, message: `"${key}" looks like a design-token map but is not a recognized schema key ` + - `(colors, typography, spacing, rounded, components). ` + + `(colors, typography, spacing, rounded, motion, components). ` + `It will be silently ignored by export commands. ` + `Rename it to a supported key or move its values under a recognized section.`, }); diff --git a/packages/cli/src/linter/linter/rules/token-summary.ts b/packages/cli/src/linter/linter/rules/token-summary.ts index 8c096e0..a6fd235 100644 --- a/packages/cli/src/linter/linter/rules/token-summary.ts +++ b/packages/cli/src/linter/linter/rules/token-summary.ts @@ -25,6 +25,7 @@ export function tokenSummary(state: DesignSystemState): RuleFinding[] { if (state.typography.size > 0) parts.push(`${state.typography.size} typography scale${state.typography.size !== 1 ? 's' : ''}`); if (state.rounded.size > 0) parts.push(`${state.rounded.size} rounding level${state.rounded.size !== 1 ? 's' : ''}`); if (state.spacing.size > 0) parts.push(`${state.spacing.size} spacing token${state.spacing.size !== 1 ? 's' : ''}`); + if (state.motion && state.motion.size > 0) parts.push(`${state.motion.size} motion token${state.motion.size !== 1 ? 's' : ''}`); if (state.components.size > 0) parts.push(`${state.components.size} component${state.components.size !== 1 ? 's' : ''}`); if (parts.length > 0) { diff --git a/packages/cli/src/linter/model/handler.test.ts b/packages/cli/src/linter/model/handler.test.ts index e402307..cdcb7e4 100644 --- a/packages/cli/src/linter/model/handler.test.ts +++ b/packages/cli/src/linter/model/handler.test.ts @@ -804,4 +804,47 @@ describe('ModelHandler', () => { expect(result.designSystem.colors.get('ok')?.hex).toBe('#ffffff'); }); }); + + describe('motion validation', () => { + it('resolves valid motion tokens into designSystem.motion and symbolTable', () => { + const result = handler.execute(makeParsed({ + motion: { + 'state-change': { + duration: '100ms', + delay: '0ms', + timingFunction: 'ease-out', + }, + 'surface-enter': { + duration: '250ms', + delay: '0ms', + timingFunction: 'cubic-bezier(0.2, 0, 0, 1)', + }, + }, + })); + expect(result.findings).toEqual([]); + expect(result.designSystem.motion?.size).toBe(2); + expect(result.designSystem.motion?.get('state-change')).toEqual({ + type: 'motion', + duration: '100ms', + delay: '0ms', + timingFunction: 'ease-out', + }); + expect(result.designSystem.symbolTable.has('motion.surface-enter')).toBe(true); + }); + + it('emits error for invalid duration or timingFunction and warning for unknown property', () => { + const result = handler.execute(makeParsed({ + motion: { + 'bad-motion': { + duration: '2s', + timingFunction: 'invalid-easing', + extraProp: 'foo', + }, + }, + })); + expect(result.findings.some(f => f.path === 'motion.bad-motion.duration' && f.severity === 'error')).toBe(true); + expect(result.findings.some(f => f.path === 'motion.bad-motion.timingFunction' && f.severity === 'error')).toBe(true); + expect(result.findings.some(f => f.path === 'motion.bad-motion.extraProp' && f.severity === 'warning')).toBe(true); + }); + }); }); \ No newline at end of file diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index 9ea1193..df53f29 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -20,12 +20,13 @@ import type { ResolvedColor, ResolvedDimension, ResolvedTypography, + ResolvedMotion, ResolvedValue, ComponentDef, Finding, } from './spec.js'; -import { isValidColor, isParseableDimension, isTokenReference, parseDimensionParts, VALID_TYPOGRAPHY_PROPS } from './spec.js'; +import { isValidColor, isParseableDimension, isTokenReference, parseDimensionParts, VALID_TYPOGRAPHY_PROPS, VALID_MOTION_PROPS } from './spec.js'; import { parseCssColor } from './color-parser.js'; import { @@ -35,6 +36,9 @@ import { const SCHEMA_KEY_SET: ReadonlySet = new Set(SCHEMA_KEYS); const TYPOGRAPHY_PROP_SET: ReadonlySet = new Set(VALID_TYPOGRAPHY_PROPS); +const MOTION_PROP_SET: ReadonlySet = new Set(VALID_MOTION_PROPS); +const DURATION_RE = /^\d+(\.\d+)?ms$/; +const EASING_RE = /^(linear|ease|ease-in|ease-out|ease-in-out|steps\(.+\)|cubic-bezier\(.+\))$/; /** * Builds a resolved DesignSystemState from parsed YAML tokens. @@ -51,6 +55,7 @@ export class ModelHandler implements ModelSpec { const typography = new Map(); const rounded = new Map(); const spacing = new Map(); + const motion = new Map(); // ── Phase 1: Resolve primitive tokens ────────────────────────── // Colors @@ -135,6 +140,19 @@ export class ModelHandler implements ModelSpec { }, '', 0, findings, 'spacing'); } + // Motion + if (input.motion && typeof input.motion === 'object') { + for (const [name, props] of Object.entries(input.motion)) { + if (props && typeof props === 'object' && !Array.isArray(props)) { + const resolved = parseMotion(props, `motion.${name}`, findings); + motion.set(name, resolved); + symbolTable.set(`motion.${name}`, resolved); + } else { + symbolTable.set(`motion.${name}`, props as any); + } + } + } + // ── Phase 2: Resolve chained token references ────────────────── // Iterate the symbol table directly (not re-walking raw input) so that // Phase 1 collision decisions are never overwritten. @@ -217,6 +235,7 @@ export class ModelHandler implements ModelSpec { typography, rounded, spacing, + motion, components, symbolTable, sections: input.sections, @@ -420,6 +439,53 @@ function parseTypography(props: Record, path: string, f return result; } +/** + * Parse a motion properties object into a ResolvedMotion. + */ +function parseMotion(props: Record, path: string, findings: Finding[]): ResolvedMotion { + const result: ResolvedMotion = { type: 'motion' }; + + for (const key of ['duration', 'delay'] as const) { + if (props[key] !== undefined) { + const raw = String(props[key]); + if (DURATION_RE.test(raw)) { + result[key] = raw; + } else { + findings.push({ + severity: 'error', + path: `${path}.${key}`, + message: `'${raw}' is not a valid ms duration (e.g., 100ms, 250ms).`, + }); + } + } + } + + if (props['timingFunction'] !== undefined) { + const raw = String(props['timingFunction']); + if (EASING_RE.test(raw)) { + result.timingFunction = raw; + } else { + findings.push({ + severity: 'error', + path: `${path}.timingFunction`, + message: `'${raw}' is not a valid timing function (expected linear, ease, ease-in, ease-out, ease-in-out, steps(...), or cubic-bezier(...)).`, + }); + } + } + + for (const key of Object.keys(props)) { + if (!MOTION_PROP_SET.has(key)) { + findings.push({ + severity: 'warning', + path: `${path}.${key}`, + message: `'${key}' is not a recognized motion property. Valid properties: ${VALID_MOTION_PROPS.join(', ')}.`, + }); + } + } + + return result; +} + /** * Resolve a token reference with chained resolution and cycle detection. * Returns null if the reference cannot be resolved (not found or circular). diff --git a/packages/cli/src/linter/model/spec.ts b/packages/cli/src/linter/model/spec.ts index c84b09b..55205c6 100644 --- a/packages/cli/src/linter/model/spec.ts +++ b/packages/cli/src/linter/model/spec.ts @@ -17,6 +17,7 @@ import type { ParsedDesignSystem, OmittedSection } from '../parser/spec.js'; import { STANDARD_UNITS as _STANDARD_UNITS, VALID_TYPOGRAPHY_PROPS as _VALID_TYPOGRAPHY_PROPS, + VALID_MOTION_PROPS as _VALID_MOTION_PROPS, VALID_COMPONENT_SUB_TOKENS as _VALID_COMPONENT_SUB_TOKENS, } from '../spec-config.js'; import { parseCssColor } from './color-parser.js'; @@ -62,10 +63,18 @@ export interface ResolvedTypography { fontVariation?: string | undefined; } -export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | string | number | boolean; +export interface ResolvedMotion { + type: 'motion'; + duration?: string | undefined; + delay?: string | undefined; + timingFunction?: string | undefined; +} + +export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | ResolvedMotion | string | number | boolean; // ── Re-exported from spec-config (single source of truth) ───────── export const VALID_TYPOGRAPHY_PROPS = _VALID_TYPOGRAPHY_PROPS; +export const VALID_MOTION_PROPS = _VALID_MOTION_PROPS; export const VALID_COMPONENT_SUB_TOKENS = _VALID_COMPONENT_SUB_TOKENS; // ── STATE ────────────────────────────────────────────────────────── @@ -77,6 +86,7 @@ export interface DesignSystemState { typography: Map; rounded: Map; spacing: Map; + motion?: Map | undefined; components: Map; /** Flat lookup: "colors.primary" → ResolvedColor */ symbolTable: Map; diff --git a/packages/cli/src/linter/parser/handler.ts b/packages/cli/src/linter/parser/handler.ts index 624fdee..1bb0f2d 100644 --- a/packages/cli/src/linter/parser/handler.ts +++ b/packages/cli/src/linter/parser/handler.ts @@ -219,6 +219,7 @@ export class ParserHandler implements ParserSpec { typography: raw['typography'] as Record> | undefined, rounded: raw['rounded'] as Record | undefined, spacing: raw['spacing'] as Record | undefined, + motion: raw['motion'] as Record> | undefined, components: raw['components'] as Record> | undefined, sourceMap, sections, diff --git a/packages/cli/src/linter/parser/spec.ts b/packages/cli/src/linter/parser/spec.ts index d274c7c..65e645d 100644 --- a/packages/cli/src/linter/parser/spec.ts +++ b/packages/cli/src/linter/parser/spec.ts @@ -52,6 +52,7 @@ export interface ParsedDesignSystem { typography?: Record> | undefined; rounded?: Record | undefined; spacing?: Record | undefined; + motion?: Record> | undefined; components?: Record> | undefined; sourceMap: Map; /** Markdown heading names found in the document (e.g., 'Colors', 'Typography') */ @@ -72,6 +73,7 @@ export const SCHEMA_KEYS = [ 'typography', 'rounded', 'spacing', + 'motion', 'components', ] as const; diff --git a/packages/cli/src/linter/spec-config.ts b/packages/cli/src/linter/spec-config.ts index 4b73af5..0b52272 100644 --- a/packages/cli/src/linter/spec-config.ts +++ b/packages/cli/src/linter/spec-config.ts @@ -59,12 +59,14 @@ const ConfigSchema = z.object({ aliases: z.array(z.string()).optional(), })).min(1), typography_properties: z.array(PropertyDefSchema).min(1), + motion_properties: z.array(PropertyDefSchema).default([]), component_sub_tokens: z.array(PropertyDefSchema).min(1), color_roles: z.array(z.string()).min(1), recommended_tokens: z.record(z.string(), z.array(z.string())), examples: z.object({ colors: z.record(z.string(), z.string()), typography: z.record(z.string(), z.record(z.string(), z.union([z.string(), z.number()]))), + motion: z.record(z.string(), z.record(z.string(), z.string())).optional(), components: z.record(z.string(), z.record(z.string(), z.string())), }), }); @@ -112,6 +114,15 @@ export interface TypographyPropertyDef { description?: string | undefined; } +export interface MotionPropertyDef { + /** Property name as it appears in YAML. */ + name: string; + /** Human-readable type for the spec document. */ + type: string; + /** Extended description for the spec (appears after the type). */ + description?: string | undefined; +} + export interface ComponentSubTokenDef { /** Sub-token property name. */ name: string; @@ -158,6 +169,8 @@ export const SECTIONS = config.sections; export const TYPOGRAPHY_PROPERTIES: readonly TypographyPropertyDef[] = config.typography_properties; +export const MOTION_PROPERTIES: readonly MotionPropertyDef[] = config.motion_properties; + export const COMPONENT_SUB_TOKENS: readonly ComponentSubTokenDef[] = config.component_sub_tokens; /** Core color roles that every design system should define. */ @@ -192,6 +205,9 @@ export function resolveAlias(heading: string): string { /** Valid typography property names (for linter validation). */ export const VALID_TYPOGRAPHY_PROPS = TYPOGRAPHY_PROPERTIES.map(p => p.name); +/** Valid motion property names (for linter validation). */ +export const VALID_MOTION_PROPS = MOTION_PROPERTIES.map(p => p.name); + /** Valid component sub-token names (for linter validation). */ export const VALID_COMPONENT_SUB_TOKENS = COMPONENT_SUB_TOKENS.map(p => p.name); @@ -206,6 +222,7 @@ export interface SpecConfig { SPEC_TYPES: typeof SPEC_TYPES; SECTIONS: typeof SECTIONS; TYPOGRAPHY_PROPERTIES: typeof TYPOGRAPHY_PROPERTIES; + MOTION_PROPERTIES: typeof MOTION_PROPERTIES; COMPONENT_SUB_TOKENS: typeof COMPONENT_SUB_TOKENS; CORE_COLOR_ROLES: typeof CORE_COLOR_ROLES; RECOMMENDED_TOKENS: typeof RECOMMENDED_TOKENS; @@ -222,6 +239,7 @@ export const SPEC_CONFIG: SpecConfig = { SPEC_TYPES, SECTIONS, TYPOGRAPHY_PROPERTIES, + MOTION_PROPERTIES, COMPONENT_SUB_TOKENS, CORE_COLOR_ROLES, RECOMMENDED_TOKENS, diff --git a/packages/cli/src/linter/spec-config.yaml b/packages/cli/src/linter/spec-config.yaml index 6b1e90f..146812a 100644 --- a/packages/cli/src/linter/spec-config.yaml +++ b/packages/cli/src/linter/spec-config.yaml @@ -45,6 +45,8 @@ types: - px - em - rem + Duration: + description: "A duration value is a string with a `ms` unit suffix (e.g., `100ms`, `250ms`)." sections: - canonical: Overview @@ -59,6 +61,10 @@ sections: aliases: - Elevation - canonical: Shapes + - canonical: Motion & Micro-interaction + aliases: + - Motion & Micro-interactions + - Motion - canonical: Components - canonical: "Do's and Don'ts" @@ -82,6 +88,17 @@ typography_properties: type: string description: "configures\n [`font-variation-settings`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variation-settings)." +motion_properties: + - name: duration + type: Duration + description: "The length of time a transition takes to complete (e.g., `100ms`, `250ms`)." + - name: delay + type: Duration + description: "The time to wait before a transition starts (e.g., `0ms`, `50ms`)." + - name: timingFunction + type: string + description: "A CSS easing function such as `linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out`, `steps(...)`, or `cubic-bezier(...)`." + component_sub_tokens: - name: backgroundColor type: Color @@ -132,6 +149,10 @@ recommended_tokens: - lg - xl - full + motion: + - state-change + - surface-enter + - surface-exit examples: colors: @@ -157,6 +178,19 @@ examples: fontWeight: 500 lineHeight: 1.0 letterSpacing: "0.1em" + motion: + state-change: + duration: 100ms + delay: 0ms + timingFunction: ease-out + surface-enter: + duration: 250ms + delay: 0ms + timingFunction: "cubic-bezier(0.2, 0, 0, 1)" + surface-exit: + duration: 150ms + delay: 0ms + timingFunction: "cubic-bezier(0.3, 0, 1, 1)" components: button-primary: backgroundColor: "{colors.primary-60}" diff --git a/packages/cli/src/linter/spec-gen/compiler.test.ts b/packages/cli/src/linter/spec-gen/compiler.test.ts index 16f79bd..1de3ff2 100644 --- a/packages/cli/src/linter/spec-gen/compiler.test.ts +++ b/packages/cli/src/linter/spec-gen/compiler.test.ts @@ -68,8 +68,10 @@ describe('compileMdx', () => { frontmatterExample: () => renderers.frontmatterExample(cfg), colorsExample: () => renderers.colorsExample(cfg), typographyExample: () => renderers.typographyExample(cfg), + motionExample: () => renderers.motionExample(cfg), componentsExample: () => renderers.componentsExample(cfg), typographyPropertyList: () => renderers.typographyPropertyList(cfg), + motionPropertyList: () => renderers.motionPropertyList(cfg), sectionOrderList: () => renderers.sectionOrderList(cfg), componentSubTokenList: () => renderers.componentSubTokenList(cfg), recommendedTokens: () => renderers.recommendedTokens(cfg), diff --git a/packages/cli/src/linter/spec-gen/generate.ts b/packages/cli/src/linter/spec-gen/generate.ts index d7d6e45..2147b8b 100644 --- a/packages/cli/src/linter/spec-gen/generate.ts +++ b/packages/cli/src/linter/spec-gen/generate.ts @@ -44,8 +44,10 @@ async function main() { frontmatterExample: () => renderers.frontmatterExample(cfg), colorsExample: () => renderers.colorsExample(cfg), typographyExample: () => renderers.typographyExample(cfg), + motionExample: () => renderers.motionExample(cfg), componentsExample: () => renderers.componentsExample(cfg), typographyPropertyList: () => renderers.typographyPropertyList(cfg), + motionPropertyList: () => renderers.motionPropertyList(cfg), sectionOrderList: () => renderers.sectionOrderList(cfg), componentSubTokenList: () => renderers.componentSubTokenList(cfg), recommendedTokens: () => renderers.recommendedTokens(cfg), diff --git a/packages/cli/src/linter/spec-gen/renderers.ts b/packages/cli/src/linter/spec-gen/renderers.ts index b8d60f9..c5a03c9 100644 --- a/packages/cli/src/linter/spec-gen/renderers.ts +++ b/packages/cli/src/linter/spec-gen/renderers.ts @@ -19,7 +19,7 @@ * Each function returns a ready-to-embed markdown string. */ -import type { SpecConfig, TypographyPropertyDef, SectionDef, ComponentSubTokenDef, TypeDef } from '../spec-config.js'; +import type { SpecConfig, TypographyPropertyDef, MotionPropertyDef, SectionDef, ComponentSubTokenDef, TypeDef } from '../spec-config.js'; // ── YAML code block helpers ───────────────────────────────────── @@ -86,6 +86,16 @@ export function componentsExample(config: SpecConfig): string { return yamlBlock(lines); } +/** Motion YAML example. */ +export function motionExample(config: SpecConfig): string { + const lines = ['motion:']; + for (const [name, props] of Object.entries(config.EXAMPLES.motion ?? {})) { + lines.push(` ${name}:`); + lines.push(...yamlObject(props as Record)); + } + return yamlBlock(lines); +} + /** Typography property list (for the schema section). */ export function typographyPropertyList(config: SpecConfig): string { return config.TYPOGRAPHY_PROPERTIES.map((p: TypographyPropertyDef) => @@ -95,6 +105,15 @@ export function typographyPropertyList(config: SpecConfig): string { ).join('\n'); } +/** Motion property list (for the schema section). */ +export function motionPropertyList(config: SpecConfig): string { + return config.MOTION_PROPERTIES.map((p: MotionPropertyDef) => + p.description + ? `- \`${p.name}\` (${p.type}) - ${p.description}` + : `- \`${p.name}\` (${p.type})` + ).join('\n'); +} + /** Primitive type definitions (Color, Dimension, etc.) for the schema section. */ export function typeDefinitions(config: SpecConfig, typeName?: string): string { const types = config.PRIMITIVE_TYPES || config.SPEC_TYPES; diff --git a/packages/cli/src/linter/spec-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx index c9e417d..06cc448 100644 --- a/packages/cli/src/linter/spec-gen/spec.mdx +++ b/packages/cli/src/linter/spec-gen/spec.mdx @@ -1,5 +1,5 @@ -import { SPEC_VERSION, SECTIONS, TYPOGRAPHY_PROPERTIES, COMPONENT_SUB_TOKENS, CORE_COLOR_ROLES, RECOMMENDED_TOKENS, EXAMPLES } from '../spec-config.js' -import { frontmatterExample, colorsExample, typographyExample, componentsExample, typographyPropertyList, typeDefinitions, sectionOrderList, componentSubTokenList, recommendedTokens } from './renderers.js' +import { SPEC_VERSION, SECTIONS, TYPOGRAPHY_PROPERTIES, MOTION_PROPERTIES, COMPONENT_SUB_TOKENS, CORE_COLOR_ROLES, RECOMMENDED_TOKENS, EXAMPLES } from '../spec-config.js' +import { frontmatterExample, colorsExample, typographyExample, motionExample, componentsExample, typographyPropertyList, motionPropertyList, typeDefinitions, sectionOrderList, componentSubTokenList, recommendedTokens } from './renderers.js' # DESIGN.md Format @@ -37,6 +37,8 @@ rounded: : spacing: : +motion: + : components: : : @@ -50,7 +52,13 @@ The `` placeholder represents a named level in a sizing or spacing {typeDefinitions('Dimension')} -**Omitted**: An array of sections that are intentionally omitted from the design system. This suppresses linter warnings for missing sections (e.g. colors, typography, spacing, rounded, components). Each entry can be: +{typeDefinitions('Duration')} + +**Motion**: A motion token is an object that maps a purpose-driven motion name to its timing properties: + +{motionPropertyList()} + +**Omitted**: An array of sections that are intentionally omitted from the design system. This suppresses linter warnings for missing sections (e.g. colors, typography, spacing, rounded, motion, components). Each entry can be: * A string representing the section name (e.g., `spacing`) * An object of the form `{ section: string, reason?: string }` mapping a section to a documented reason for its omission: ```yaml @@ -228,6 +236,33 @@ rounded: full: 9999px ``` +## Motion & Micro-interaction + +Also known as "Motion & Micro-interactions" or "Motion". + +This section describes how the interface moves. It pairs prose choreography rules with structured `motion` tokens so transitions feel consistent and respect user motion preferences: + +- **What moves**: State changes on interactive elements, surfaces entering and leaving, and content loading, as well as what deliberately does not move. +- **Duration rhythm**: Small, local changes are faster than large structural transitions. +- **Easing intent**: Elements entering the screen decelerate, elements leaving accelerate, and elements staying on screen use a symmetric curve. +- **Restraint**: Honor `prefers-reduced-motion` and avoid animating properties that force layout recalculation (prefer `transform` and `opacity`). + +Example: + +```markdown +## Motion & Micro-interaction + +Motion is quick and understated. Interactive feedback lands within a tenth of a second so the interface feels immediate, while surfaces that enter the screen take longer and decelerate. Nothing loops, and nothing moves purely for decoration. Honor `prefers-reduced-motion`. +``` + +### Design Tokens + +The `motion` section defines purpose-named motion tokens (such as `state-change`, `surface-enter`, and `surface-exit`) rather than raw speed labels. Each entry maps a semantic motion name to its timing properties (`duration`, `delay`, and `timingFunction`). + +It is a map\. + +{motionExample()} + ## Components This section provides style guidance for component atoms within the design system. The following are common component types. Design systems are encouraged to define additional components relevant to their domain. @@ -284,6 +319,7 @@ When a DESIGN.md consumer encounters content not defined by this spec: | Unknown section heading | Preserve; do not error | `## Iconography` | | Unknown color token name | Accept if value is valid | `surface-container-high: '#ede7dd'` | | Unknown typography token name | Accept as valid typography | `telemetry-data` | +| Unknown motion token name | Accept as valid motion | `page-transition` | | Unknown spacing value | Accept; store as string if not a valid dimension | `grid-columns: '5'` | | Unknown component property | Accept with warning | `borderColor` | | Duplicate section heading | Error; reject the file | Two `## Colors` headings | diff --git a/packages/cli/src/linter/tailwind/handler.test.ts b/packages/cli/src/linter/tailwind/handler.test.ts index b51f8c8..e8f251a 100644 --- a/packages/cli/src/linter/tailwind/handler.test.ts +++ b/packages/cli/src/linter/tailwind/handler.test.ts @@ -102,6 +102,34 @@ describe('TailwindEmitterHandler', () => { }); }); + describe('motion mapping', () => { + it('maps motion tokens to transitionDuration, transitionDelay, and transitionTimingFunction', () => { + const state = buildState({ + motion: { + 'state-change': { + duration: '100ms', + delay: '0ms', + timingFunction: 'ease-out', + }, + 'surface-enter': { + duration: '250ms', + delay: '0ms', + timingFunction: 'cubic-bezier(0.2, 0, 0, 1)', + }, + }, + }); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + const config = result.data; + + expect(config.theme.extend.transitionDuration?.['state-change']).toBe('100ms'); + expect(config.theme.extend.transitionDuration?.['surface-enter']).toBe('250ms'); + expect(config.theme.extend.transitionDelay?.['state-change']).toBe('0ms'); + expect(config.theme.extend.transitionTimingFunction?.['state-change']).toBe('ease-out'); + expect(config.theme.extend.transitionTimingFunction?.['surface-enter']).toBe('cubic-bezier(0.2, 0, 0, 1)'); + }); + }); + // ── Empty state produces empty config ───────────────────────────── describe('empty state', () => { it('produces a valid config with empty extend sections', () => { diff --git a/packages/cli/src/linter/tailwind/handler.ts b/packages/cli/src/linter/tailwind/handler.ts index c6b2f20..fbe3657 100644 --- a/packages/cli/src/linter/tailwind/handler.ts +++ b/packages/cli/src/linter/tailwind/handler.ts @@ -21,6 +21,7 @@ import type { DesignSystemState, ResolvedDimension } from '../model/spec.js'; */ export class TailwindEmitterHandler implements TailwindEmitterSpec { execute(state: DesignSystemState): TailwindEmitterResult { + const motionExtend = this.mapMotion(state); return { success: true, data: { @@ -31,12 +32,40 @@ export class TailwindEmitterHandler implements TailwindEmitterSpec { fontSize: this.mapFontSizes(state), borderRadius: this.mapDimensions(state.rounded), spacing: this.mapDimensions(state.spacing), + ...motionExtend, }, }, } }; } + private mapMotion(state: DesignSystemState): { + transitionDuration?: Record; + transitionDelay?: Record; + transitionTimingFunction?: Record; + } { + if (!state.motion || state.motion.size === 0) return {}; + const transitionDuration: Record = {}; + const transitionDelay: Record = {}; + const transitionTimingFunction: Record = {}; + + for (const [name, entry] of state.motion) { + if (entry.duration) transitionDuration[name] = entry.duration; + if (entry.delay) transitionDelay[name] = entry.delay; + if (entry.timingFunction) transitionTimingFunction[name] = entry.timingFunction; + } + + const result: { + transitionDuration?: Record; + transitionDelay?: Record; + transitionTimingFunction?: Record; + } = {}; + if (Object.keys(transitionDuration).length > 0) result.transitionDuration = transitionDuration; + if (Object.keys(transitionDelay).length > 0) result.transitionDelay = transitionDelay; + if (Object.keys(transitionTimingFunction).length > 0) result.transitionTimingFunction = transitionTimingFunction; + return result; + } + private mapColors(state: DesignSystemState): Record { const result: Record = {}; for (const [name, color] of state.colors) { diff --git a/packages/cli/src/linter/tailwind/spec.ts b/packages/cli/src/linter/tailwind/spec.ts index dfffb35..3267ac6 100644 --- a/packages/cli/src/linter/tailwind/spec.ts +++ b/packages/cli/src/linter/tailwind/spec.ts @@ -23,6 +23,9 @@ export const TailwindThemeExtendSchema = z.object({ fontSize: z.record(z.tuple([z.string(), z.record(z.string())])).optional(), borderRadius: z.record(z.string()).optional(), spacing: z.record(z.string()).optional(), + transitionDuration: z.record(z.string()).optional(), + transitionDelay: z.record(z.string()).optional(), + transitionTimingFunction: z.record(z.string()).optional(), }); export type TailwindThemeExtend = z.infer;