diff --git a/apps/docs/.storybook/preview.ts b/apps/docs/.storybook/preview.ts index 5a78e0c..f2a3819 100644 --- a/apps/docs/.storybook/preview.ts +++ b/apps/docs/.storybook/preview.ts @@ -8,10 +8,15 @@ import { skinNames } from "@ai-ui/ui"; import { - defaultMotionMode, + defaultMotionAccessibility, + defaultMotionPack, defaultTheme, - motionModeNames, - setMotionMode, + motionAccessibilityDetails, + motionAccessibilityNames, + motionPackDetails, + motionPackNames, + setMotionAccessibility, + setMotionPack, setTheme, themeDetails, themeNames @@ -30,14 +35,25 @@ const preview: Preview = { })) } }, - motion: { - description: "Preview motion mode", + motionPack: { + description: "Preview motion pack", toolbar: { icon: "transfer", dynamicTitle: true, - items: motionModeNames.map((modeName) => ({ + items: motionPackNames.map((packName) => ({ + value: packName, + title: motionPackDetails[packName].label + })) + } + }, + motionAccessibility: { + description: "Preview motion accessibility override", + toolbar: { + icon: "accessibility", + dynamicTitle: true, + items: motionAccessibilityNames.map((modeName) => ({ value: modeName, - title: modeName === "system" ? "Motion / System" : "Motion / Reduced" + title: motionAccessibilityDetails[modeName].label })) } }, @@ -54,7 +70,8 @@ const preview: Preview = { } }, initialGlobals: { - motion: defaultMotionMode, + motionAccessibility: defaultMotionAccessibility, + motionPack: defaultMotionPack, skin: defaultSkin, theme: defaultTheme }, @@ -80,7 +97,10 @@ const preview: Preview = { (Story, context) => { if (typeof document !== "undefined") { setTheme(context.globals.theme ?? defaultTheme); - setMotionMode(context.globals.motion ?? defaultMotionMode); + setMotionPack(context.globals.motionPack ?? defaultMotionPack); + setMotionAccessibility( + context.globals.motionAccessibility ?? defaultMotionAccessibility + ); setSkin(context.globals.skin ?? defaultSkin); document.body.dataset.theme = context.globals.theme ?? defaultTheme; diff --git a/apps/docs/src/style-contract.stories.tsx b/apps/docs/src/style-contract.stories.tsx index befb3b8..3c8b99f 100644 --- a/apps/docs/src/style-contract.stories.tsx +++ b/apps/docs/src/style-contract.stories.tsx @@ -11,15 +11,18 @@ import { type SkinName } from "@ai-ui/ui"; import { - defaultMotionMode, + defaultMotionAccessibility, + defaultMotionPack, defaultTheme, - type MotionModeName, + type MotionAccessibilityName, + type MotionPackName, type ThemeName } from "@ai-ui/tokens"; import type { Meta, StoryObj } from "@storybook/react"; type StyleContractShowcaseProps = { - motion: MotionModeName; + motionAccessibility: MotionAccessibilityName; + motionPack: MotionPackName; skin: SkinName; theme: ThemeName; }; @@ -140,7 +143,8 @@ function SkinPanel({ } function StyleContractShowcase({ - motion, + motionAccessibility, + motionPack, skin, theme }: StyleContractShowcaseProps) { @@ -172,7 +176,8 @@ function StyleContractShowcase({
- + +
@@ -183,7 +188,7 @@ function StyleContractShowcase({ "A new runtime attribute: `data-skin`", "Public helpers from `@ai-ui/ui` for skin names, defaults, and root updates", "A dedicated `@ai-ui/ui/skins.css` entrypoint imported by the docs app", - "Storybook globals that apply theme, skin, and motion together" + "Storybook globals that apply theme, skin, motion pack, and accessibility override together" ].map((item) => (
(
( diff --git a/apps/docs/src/style-matrix.stories.tsx b/apps/docs/src/style-matrix.stories.tsx index 92ab596..66a9dd8 100644 --- a/apps/docs/src/style-matrix.stories.tsx +++ b/apps/docs/src/style-matrix.stories.tsx @@ -1,3 +1,8 @@ +import { + motionPackDetails, + motionPackNames, + type MotionPackName +} from "@ai-ui/tokens"; import { Button, Card, @@ -21,18 +26,18 @@ import { } from "@ai-ui/ui"; import type { Meta, StoryObj } from "@storybook/react"; -const motionModes = [ +const motionAccessibilityModes = [ { - label: "System motion", + label: "System accessibility", value: "system" }, { - label: "Reduced motion", + label: "Reduced accessibility", value: "reduced" } ] as const; -type MotionMode = (typeof motionModes)[number]["value"]; +type MotionAccessibilityMode = (typeof motionAccessibilityModes)[number]["value"]; function RuntimePill({ children }: { children: React.ReactNode }) { return ( @@ -78,21 +83,25 @@ function PanelPreview() { } function ComparisonCell({ - motion, + motionAccessibility, + motionPack, skin }: { - motion: MotionMode; + motionAccessibility: MotionAccessibilityMode; + motionPack: MotionPackName; skin: SkinName; }) { return (
+ {motionPackDetails[motionPack].label} {skinDetails[skin].label} - {motion} + {motionAccessibility}
@@ -166,29 +175,52 @@ function StyleMatrixShowcase() {

This page is the screenshot-friendly regression target for the pilot skin work. - The grid uses nested `data-skin` and `data-motion` scopes so the same building - blocks can be reviewed side by side. + The grid uses nested `data-skin`, `data-motion-pack`, and + `data-motion="reduced"` scopes so the same building blocks can be + reviewed side by side.

- {motionModes.map((motionMode) => ( -
-
-
-

{motionMode.label}

-

- `{motionMode.value === "reduced" ? 'data-motion="reduced"' : "default"}` - {" "} - on the wrapper scope. -

+ {motionPackNames.map((motionPack) => ( +
+
+

{motionPackDetails[motionPack].label}

+

+ {motionPackDetails[motionPack].note} +

+
+ {motionAccessibilityModes.map((motionAccessibilityMode) => ( +
+
+
+

+ {motionAccessibilityMode.label} +

+

+ {motionAccessibilityMode.value === "reduced" + ? '`data-motion="reduced"`' + : "System preference"} + {" "}with{" "} + + {`data-motion-pack="${motionPack}"`} + + . +

+
+
+
+ {skinNames.map((skin) => ( + + ))} +
-
-
- {skinNames.map((skin) => ( - - ))} -
+ ))}
))}
@@ -199,7 +231,8 @@ function StyleMatrixShowcase() {

Dialog still portals to the document root, so compare its real overlay and panel treatment with the Storybook toolbar. The matrix above covers scoped - inline regression. The control below covers the live overlay behavior. + inline regression across packs and reduced-motion overlay. The control below + covers the live overlay behavior.

diff --git a/apps/docs/src/tokens.stories.tsx b/apps/docs/src/tokens.stories.tsx index f091526..db91981 100644 --- a/apps/docs/src/tokens.stories.tsx +++ b/apps/docs/src/tokens.stories.tsx @@ -1,18 +1,24 @@ import { colorTokens, + defaultMotionAccessibility, + defaultMotionPack, motionTokens, + motionAccessibilityDetails, + motionPackDetails, radiusTokens, shadowTokens, themeDetails, themeNames, typographyTokens, - type MotionModeName, + type MotionAccessibilityName, + type MotionPackName, type ThemeName } from "@ai-ui/tokens"; import type { Meta, StoryObj } from "@storybook/react"; type TokensOverviewProps = { - motionMode: MotionModeName; + motionAccessibility: MotionAccessibilityName; + motionPack: MotionPackName; theme: ThemeName; }; @@ -119,7 +125,11 @@ function ThemeCard({ themeName }: { themeName: ThemeName }) { ); } -function TokensOverview({ motionMode, theme }: TokensOverviewProps) { +function TokensOverview({ + motionAccessibility, + motionPack, + theme +}: TokensOverviewProps) { return (
@@ -157,10 +167,18 @@ function TokensOverview({ motionMode, theme }: TokensOverviewProps) {

- Motion Mode + Motion Pack

- {motionMode === "system" ? "System preference" : "Reduced motion"} + {motionPackDetails[motionPack].label} +

+
+
+

+ Accessibility Override +

+

+ {motionAccessibilityDetails[motionAccessibility].label}

@@ -282,7 +300,8 @@ function TokensOverview({ motionMode, theme }: TokensOverviewProps) {

Motion tokens

Timing and motion scale now live in variables that components can consume - directly. The toolbar can force reduced motion for preview validation. + directly. The toolbar now separates the active motion pack from the + accessibility override.

@@ -370,12 +389,19 @@ type Story = StoryObj; export const Overview: Story = { args: { - motionMode: "system", + motionAccessibility: defaultMotionAccessibility, + motionPack: defaultMotionPack, theme: "light" }, render: (_args, context) => ( ) diff --git a/docs/rfcs/multi-style-architecture.md b/docs/rfcs/multi-style-architecture.md index 806516c..b237038 100644 --- a/docs/rfcs/multi-style-architecture.md +++ b/docs/rfcs/multi-style-architecture.md @@ -422,19 +422,27 @@ Minimum contract: ```ts type ThemeName = "light" | "dark" | "brand" | "minimal"; type SkinName = "minimal" | "glass" | "pixel"; -type MotionName = "system" | "reduced" | "micro" | "spring"; +type MotionPackName = "calm" | "snappy" | "spring"; +type MotionAccessibilityName = "system" | "full" | "reduced"; ``` Likely helpers: - `setTheme(theme, root?)` - `setSkin(skin, root?)` -- `setMotionMode(mode, root?)` +- `setMotionPack(pack, root?)` +- `setMotionAccessibility(mode, root?)` +- `setMotionMode(mode, root?)` as a backward-compatible alias for accessibility mode Provider shape if needed: ```tsx - + ``` @@ -587,8 +595,8 @@ proven inside this repo. recommendation is `@ai-ui/ui`. - Should the runtime API expose a `StyleProvider`, or should root attributes remain the only public contract at first? -- Should motion packs beyond `system` and `reduced` ship in the first skin milestone, or - should additional motion packs wait until after the pilot components land? +- Should the current `calm / snappy / spring` set remain the long-term pack list, or + should product-specific packs be introduced later? - Should `minimal` become the new default appearance, or should the current warm editorial look remain the default and be renamed explicitly? @@ -607,6 +615,8 @@ As of 2026-03-20, the project is at this point: and `Skeleton` - screenshot-friendly validation surface added in `Foundation/Style Matrix` - scoped `data-motion="reduced"` now works for nested docs wrappers +- motion now uses real packs through `data-motion-pack` +- reduced motion remains available as a separate accessibility override layer - shared skin-aware treatment now extends across the broader component library surface, including controls, menus, overlays, feedback, and data-heavy patterns - package consumers can now import a single combined stylesheet from diff --git a/packages/tokens/src/index.ts b/packages/tokens/src/index.ts index f18b16b..f5281f4 100644 --- a/packages/tokens/src/index.ts +++ b/packages/tokens/src/index.ts @@ -18,10 +18,50 @@ export const themeDetails = { } } as const satisfies Record; -export const motionModeNames = ["system", "reduced"] as const; -export type MotionModeName = (typeof motionModeNames)[number]; +export const motionPackNames = ["calm", "snappy", "spring"] as const; +export type MotionPackName = (typeof motionPackNames)[number]; -export const defaultMotionMode: MotionModeName = "system"; +export const defaultMotionPack: MotionPackName = "calm"; + +export const motionPackDetails = { + calm: { + label: "Calm", + note: "Editorial default with restrained lift and steady transitions" + }, + snappy: { + label: "Snappy", + note: "Shorter durations, tighter distances, and more direct response" + }, + spring: { + label: "Spring", + note: "More elastic easing, wider movement, and livelier feedback" + } +} as const satisfies Record; + +export const motionAccessibilityNames = ["system", "full", "reduced"] as const; +export type MotionAccessibilityName = (typeof motionAccessibilityNames)[number]; + +export const defaultMotionAccessibility: MotionAccessibilityName = "system"; + +export const motionAccessibilityDetails = { + system: { + label: "System", + note: "Follow the operating system reduced-motion preference" + }, + full: { + label: "Full", + note: "Always show the selected motion pack, even if the OS prefers reduced motion" + }, + reduced: { + label: "Reduced", + note: "Always collapse durations, distances, and animated feedback" + } +} as const satisfies Record; + +export const motionModeNames = motionAccessibilityNames; +export type MotionModeName = MotionAccessibilityName; + +export const defaultMotionMode: MotionModeName = defaultMotionAccessibility; export const motionScale = { instant: "var(--dur-instant)", @@ -150,6 +190,16 @@ function getTargetElement(root?: HTMLElement) { return document.documentElement; } +export function setMotionPack(pack: MotionPackName, root?: HTMLElement) { + const target = getTargetElement(root); + + if (!target) { + return; + } + + target.dataset.motionPack = pack; +} + export function setTheme(theme: ThemeName, root?: HTMLElement) { const target = getTargetElement(root); @@ -160,7 +210,10 @@ export function setTheme(theme: ThemeName, root?: HTMLElement) { target.dataset.theme = theme; } -export function setMotionMode(mode: MotionModeName, root?: HTMLElement) { +export function setMotionAccessibility( + mode: MotionAccessibilityName, + root?: HTMLElement +) { const target = getTargetElement(root); if (!target) { @@ -174,3 +227,7 @@ export function setMotionMode(mode: MotionModeName, root?: HTMLElement) { target.dataset.motion = mode; } + +export function setMotionMode(mode: MotionModeName, root?: HTMLElement) { + setMotionAccessibility(mode, root); +} diff --git a/packages/tokens/src/motion.css b/packages/tokens/src/motion.css index 59af710..b822867 100644 --- a/packages/tokens/src/motion.css +++ b/packages/tokens/src/motion.css @@ -1,4 +1,5 @@ -:root { +:root, +[data-motion-pack="calm"] { --dur-instant: 1ms; --dur-fast: 120ms; --dur-base: 200ms; @@ -19,6 +20,50 @@ --scale-pop: 1.02; } +:root[data-motion-pack="snappy"], +[data-motion-pack="snappy"] { + --dur-instant: 1ms; + --dur-fast: 90ms; + --dur-base: 150ms; + --dur-slow: 220ms; + --dur-deliberate: 300ms; + + --ease-standard: cubic-bezier(0.18, 1, 0.32, 1); + --ease-emphasized: cubic-bezier(0.2, 1.08, 0.28, 1); + --ease-exit: cubic-bezier(0.4, 0, 1, 1); + + --distance-xs: 2px; + --distance-sm: 4px; + --distance-md: 10px; + --distance-lg: 16px; + + --scale-press: 0.985; + --scale-hover: 1.006; + --scale-pop: 1.012; +} + +:root[data-motion-pack="spring"], +[data-motion-pack="spring"] { + --dur-instant: 1ms; + --dur-fast: 140ms; + --dur-base: 220ms; + --dur-slow: 360ms; + --dur-deliberate: 520ms; + + --ease-standard: cubic-bezier(0.2, 1.08, 0.28, 1); + --ease-emphasized: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-exit: cubic-bezier(0.42, 0, 1, 1); + + --distance-xs: 6px; + --distance-sm: 12px; + --distance-md: 20px; + --distance-lg: 32px; + + --scale-press: 0.96; + --scale-hover: 1.018; + --scale-pop: 1.035; +} + :root[data-motion="reduced"], [data-motion="reduced"] { --dur-instant: 1ms; diff --git a/packages/ui/src/lib/motion-contract.test.ts b/packages/ui/src/lib/motion-contract.test.ts new file mode 100644 index 0000000..4159a69 --- /dev/null +++ b/packages/ui/src/lib/motion-contract.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + defaultMotionAccessibility, + defaultMotionMode, + defaultMotionPack, + motionAccessibilityDetails, + motionAccessibilityNames, + motionModeNames, + motionPackDetails, + motionPackNames, + setMotionAccessibility, + setMotionMode, + setMotionPack +} from "@ai-ui/tokens"; + +describe("motion contract", () => { + it("exposes default values that exist in the public name sets", () => { + expect(motionPackNames).toContain(defaultMotionPack); + expect(motionAccessibilityNames).toContain(defaultMotionAccessibility); + expect(motionModeNames).toContain(defaultMotionMode); + expect(motionPackDetails[defaultMotionPack].label).toBeTruthy(); + expect(motionAccessibilityDetails[defaultMotionAccessibility].label).toBeTruthy(); + }); + + it("sets the active motion pack on the document root", () => { + setMotionPack("spring"); + + expect(document.documentElement.dataset.motionPack).toBe("spring"); + }); + + it("sets reduced motion accessibility on the document root", () => { + setMotionAccessibility("reduced"); + + expect(document.documentElement.dataset.motion).toBe("reduced"); + }); + + it("removes the accessibility override when system mode is restored", () => { + setMotionAccessibility("reduced"); + setMotionMode("system"); + + expect(document.documentElement.dataset.motion).toBeUndefined(); + }); + + it("supports explicit full motion override on custom roots", () => { + const target = document.createElement("div"); + + setMotionAccessibility("full", target); + + expect(target.dataset.motion).toBe("full"); + }); +});