From cb6ad34643f051703b88b16c0694aa456d35434b Mon Sep 17 00:00:00 2001 From: xkxx Date: Thu, 8 Oct 2026 00:52:24 -0700 Subject: [PATCH] feat(spec): add Motion & Micro-interaction section and motion tokens --- README.md | 10 ++- docs/spec.md | 61 +++++++++++++++- packages/cli/src/linter/index.test.ts | 70 +++++++++++++++++++ .../cli/src/linter/linter/rules/omitted.ts | 3 +- .../linter/linter/rules/section-order.test.ts | 4 +- .../linter/linter/rules/token-like-ignored.ts | 2 +- .../src/linter/linter/rules/token-summary.ts | 1 + packages/cli/src/linter/model/handler.test.ts | 43 ++++++++++++ packages/cli/src/linter/model/handler.ts | 68 +++++++++++++++++- packages/cli/src/linter/model/spec.ts | 12 +++- packages/cli/src/linter/parser/handler.ts | 1 + packages/cli/src/linter/parser/spec.ts | 2 + packages/cli/src/linter/spec-config.ts | 18 +++++ packages/cli/src/linter/spec-config.yaml | 34 +++++++++ .../cli/src/linter/spec-gen/compiler.test.ts | 2 + packages/cli/src/linter/spec-gen/generate.ts | 2 + packages/cli/src/linter/spec-gen/renderers.ts | 21 +++++- packages/cli/src/linter/spec-gen/spec.mdx | 42 ++++++++++- .../cli/src/linter/tailwind/handler.test.ts | 28 ++++++++ packages/cli/src/linter/tailwind/handler.ts | 29 ++++++++ packages/cli/src/linter/tailwind/spec.ts | 3 + 21 files changed, 442 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ed855c52..1ae437f6 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 5995e548..18c39c16 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 ba12e98b..6e41df68 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 d802c352..91bdb1f7 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 4e94ebef..c38d1923 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 3fbc4c3e..0744f886 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 8c096e0c..a6fd235f 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 e4023074..cdcb7e45 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 9ea1193b..df53f29c 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 c84b09b3..55205c6c 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 624fdee1..1bb0f2dd 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 d274c7c2..65e645df 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 4b73af52..0b52272f 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 6b1e90fd..146812ae 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 16f79bd4..1de3ff21 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 d7d6e459..2147b8be 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 b8d60f97..c5a03c96 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 c9e417d6..06cc4487 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 b51f8c82..e8f251ac 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 c6b2f209..fbe3657c 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 dfffb35f..3267ac6e 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;