Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ rounded:
<scale-level>: <Dimension>
spacing:
<scale-level>: <Dimension | number>
motion:
<token-name>: <Motion>
components:
<component-name>:
<token-name>: <string | token reference>
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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 |

Expand Down
61 changes: 58 additions & 3 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ rounded:
<scale-level>: <Dimension>
spacing:
<scale-level>: <Dimension | number>
motion:
<token-name>: <Motion>
components:
<component-name>:
<token-name>: <string|token reference>
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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\<string, Motion>.

```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.
Expand Down Expand Up @@ -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:
Expand All @@ -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 |
70 changes: 70 additions & 0 deletions packages/cli/src/linter/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
}
});
});
3 changes: 2 additions & 1 deletion packages/cli/src/linter/linter/rules/omitted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/linter/linter/rules/section-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/linter/linter/rules/token-like-ignored.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
});
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/linter/linter/rules/token-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/linter/model/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading
Loading