Split motion packs from accessibility overrides

This commit is contained in:
2026-03-20 12:03:46 +08:00
parent 010638503f
commit e5434bada9
8 changed files with 320 additions and 64 deletions
+29 -9
View File
@@ -8,10 +8,15 @@ import {
skinNames skinNames
} from "@ai-ui/ui"; } from "@ai-ui/ui";
import { import {
defaultMotionMode, defaultMotionAccessibility,
defaultMotionPack,
defaultTheme, defaultTheme,
motionModeNames, motionAccessibilityDetails,
setMotionMode, motionAccessibilityNames,
motionPackDetails,
motionPackNames,
setMotionAccessibility,
setMotionPack,
setTheme, setTheme,
themeDetails, themeDetails,
themeNames themeNames
@@ -30,14 +35,25 @@ const preview: Preview = {
})) }))
} }
}, },
motion: { motionPack: {
description: "Preview motion mode", description: "Preview motion pack",
toolbar: { toolbar: {
icon: "transfer", icon: "transfer",
dynamicTitle: true, 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, value: modeName,
title: modeName === "system" ? "Motion / System" : "Motion / Reduced" title: motionAccessibilityDetails[modeName].label
})) }))
} }
}, },
@@ -54,7 +70,8 @@ const preview: Preview = {
} }
}, },
initialGlobals: { initialGlobals: {
motion: defaultMotionMode, motionAccessibility: defaultMotionAccessibility,
motionPack: defaultMotionPack,
skin: defaultSkin, skin: defaultSkin,
theme: defaultTheme theme: defaultTheme
}, },
@@ -80,7 +97,10 @@ const preview: Preview = {
(Story, context) => { (Story, context) => {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
setTheme(context.globals.theme ?? defaultTheme); 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); setSkin(context.globals.skin ?? defaultSkin);
document.body.dataset.theme = context.globals.theme ?? defaultTheme; document.body.dataset.theme = context.globals.theme ?? defaultTheme;
+23 -10
View File
@@ -11,15 +11,18 @@ import {
type SkinName type SkinName
} from "@ai-ui/ui"; } from "@ai-ui/ui";
import { import {
defaultMotionMode, defaultMotionAccessibility,
defaultMotionPack,
defaultTheme, defaultTheme,
type MotionModeName, type MotionAccessibilityName,
type MotionPackName,
type ThemeName type ThemeName
} from "@ai-ui/tokens"; } from "@ai-ui/tokens";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
type StyleContractShowcaseProps = { type StyleContractShowcaseProps = {
motion: MotionModeName; motionAccessibility: MotionAccessibilityName;
motionPack: MotionPackName;
skin: SkinName; skin: SkinName;
theme: ThemeName; theme: ThemeName;
}; };
@@ -140,7 +143,8 @@ function SkinPanel({
} }
function StyleContractShowcase({ function StyleContractShowcase({
motion, motionAccessibility,
motionPack,
skin, skin,
theme theme
}: StyleContractShowcaseProps) { }: StyleContractShowcaseProps) {
@@ -172,7 +176,8 @@ function StyleContractShowcase({
<section className="flex flex-wrap gap-3"> <section className="flex flex-wrap gap-3">
<RuntimeBadge label="theme" value={theme} /> <RuntimeBadge label="theme" value={theme} />
<RuntimeBadge label="skin" value={skin} /> <RuntimeBadge label="skin" value={skin} />
<RuntimeBadge label="motion" value={motion} /> <RuntimeBadge label="motion pack" value={motionPack} />
<RuntimeBadge label="accessibility" value={motionAccessibility} />
</section> </section>
<section className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]"> <section className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
@@ -183,7 +188,7 @@ function StyleContractShowcase({
"A new runtime attribute: `data-skin`", "A new runtime attribute: `data-skin`",
"Public helpers from `@ai-ui/ui` for skin names, defaults, and root updates", "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", "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) => ( ].map((item) => (
<div <div
key={item} key={item}
@@ -201,7 +206,8 @@ function StyleContractShowcase({
{[ {[
"Button, card, input, dialog, switch, and skeleton recipe extraction", "Button, card, input, dialog, switch, and skeleton recipe extraction",
"Skin-specific component semantic variables such as `--button-*` and `--panel-*`", "Skin-specific component semantic variables such as `--button-*` and `--panel-*`",
"A docs comparison page where existing components fully restyle under each skin" "A docs comparison page where existing components fully restyle under each skin",
"Consumer-facing polish after the runtime contract and docs surface are stable"
].map((item) => ( ].map((item) => (
<div <div
key={item} key={item}
@@ -232,7 +238,8 @@ const meta = {
title: "Foundation/Style Contract", title: "Foundation/Style Contract",
component: StyleContractShowcase, component: StyleContractShowcase,
args: { args: {
motion: defaultMotionMode, motionAccessibility: defaultMotionAccessibility,
motionPack: defaultMotionPack,
skin: defaultSkin, skin: defaultSkin,
theme: defaultTheme theme: defaultTheme
}, },
@@ -240,13 +247,19 @@ const meta = {
docs: { docs: {
description: { description: {
component: component:
"Phase 1 adds the runtime style contract. Use the Storybook toolbar to switch the active `theme`, `skin`, and `motion` values globally, or inspect the side-by-side nested `data-skin` panels below." "Phase 1 adds the runtime style contract. Use the Storybook toolbar to switch the active `theme`, `skin`, `motion pack`, and accessibility override globally, or inspect the side-by-side nested `data-skin` panels below."
} }
} }
}, },
render: (_args, context) => ( render: (_args, context) => (
<StyleContractShowcase <StyleContractShowcase
motion={(context.globals.motion as MotionModeName | undefined) ?? defaultMotionMode} motionAccessibility={
(context.globals.motionAccessibility as MotionAccessibilityName | undefined) ??
defaultMotionAccessibility
}
motionPack={
(context.globals.motionPack as MotionPackName | undefined) ?? defaultMotionPack
}
skin={(context.globals.skin as SkinName | undefined) ?? defaultSkin} skin={(context.globals.skin as SkinName | undefined) ?? defaultSkin}
theme={(context.globals.theme as ThemeName | undefined) ?? defaultTheme} theme={(context.globals.theme as ThemeName | undefined) ?? defaultTheme}
/> />
+51 -18
View File
@@ -1,3 +1,8 @@
import {
motionPackDetails,
motionPackNames,
type MotionPackName
} from "@ai-ui/tokens";
import { import {
Button, Button,
Card, Card,
@@ -21,18 +26,18 @@ import {
} from "@ai-ui/ui"; } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
const motionModes = [ const motionAccessibilityModes = [
{ {
label: "System motion", label: "System accessibility",
value: "system" value: "system"
}, },
{ {
label: "Reduced motion", label: "Reduced accessibility",
value: "reduced" value: "reduced"
} }
] as const; ] as const;
type MotionMode = (typeof motionModes)[number]["value"]; type MotionAccessibilityMode = (typeof motionAccessibilityModes)[number]["value"];
function RuntimePill({ children }: { children: React.ReactNode }) { function RuntimePill({ children }: { children: React.ReactNode }) {
return ( return (
@@ -78,21 +83,25 @@ function PanelPreview() {
} }
function ComparisonCell({ function ComparisonCell({
motion, motionAccessibility,
motionPack,
skin skin
}: { }: {
motion: MotionMode; motionAccessibility: MotionAccessibilityMode;
motionPack: MotionPackName;
skin: SkinName; skin: SkinName;
}) { }) {
return ( return (
<section <section
className="grid gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]" className="grid gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]"
data-motion={motion === "reduced" ? "reduced" : undefined} data-motion={motionAccessibility === "reduced" ? "reduced" : undefined}
data-motion-pack={motionPack}
data-skin={skin} data-skin={skin}
> >
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<RuntimePill>{motionPackDetails[motionPack].label}</RuntimePill>
<RuntimePill>{skinDetails[skin].label}</RuntimePill> <RuntimePill>{skinDetails[skin].label}</RuntimePill>
<RuntimePill>{motion}</RuntimePill> <RuntimePill>{motionAccessibility}</RuntimePill>
</div> </div>
<Card interactive tone="default"> <Card interactive tone="default">
@@ -166,31 +175,54 @@ function StyleMatrixShowcase() {
</h1> </h1>
<p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]"> <p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]">
This page is the screenshot-friendly regression target for the pilot skin work. 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 The grid uses nested `data-skin`, `data-motion-pack`, and
blocks can be reviewed side by side. `data-motion=&quot;reduced&quot;` scopes so the same building blocks can be
reviewed side by side.
</p> </p>
</header> </header>
<section className="grid gap-4"> <section className="grid gap-4">
{motionModes.map((motionMode) => ( {motionPackNames.map((motionPack) => (
<div key={motionMode.value} className="grid gap-3"> <div key={motionPack} className="grid gap-4">
<div>
<h2 className="text-2xl font-semibold">{motionPackDetails[motionPack].label}</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
{motionPackDetails[motionPack].note}
</p>
</div>
{motionAccessibilityModes.map((motionAccessibilityMode) => (
<div key={`${motionPack}-${motionAccessibilityMode.value}`} className="grid gap-3">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<h2 className="text-2xl font-semibold">{motionMode.label}</h2> <h3 className="text-xl font-semibold">
{motionAccessibilityMode.label}
</h3>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]"> <p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
`{motionMode.value === "reduced" ? 'data-motion="reduced"' : "default"}` {motionAccessibilityMode.value === "reduced"
{" "} ? '`data-motion="reduced"`'
on the wrapper scope. : "System preference"}
{" "}with{" "}
<code className="text-[var(--color-foreground)]">
{`data-motion-pack="${motionPack}"`}
</code>
.
</p> </p>
</div> </div>
</div> </div>
<div className="grid gap-4 xl:grid-cols-3"> <div className="grid gap-4 xl:grid-cols-3">
{skinNames.map((skin) => ( {skinNames.map((skin) => (
<ComparisonCell key={`${motionMode.value}-${skin}`} motion={motionMode.value} skin={skin} /> <ComparisonCell
key={`${motionPack}-${motionAccessibilityMode.value}-${skin}`}
motionAccessibility={motionAccessibilityMode.value}
motionPack={motionPack}
skin={skin}
/>
))} ))}
</div> </div>
</div> </div>
))} ))}
</div>
))}
</section> </section>
<section className="grid gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"> <section className="grid gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
@@ -199,7 +231,8 @@ function StyleMatrixShowcase() {
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]"> <p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
Dialog still portals to the document root, so compare its real overlay and Dialog still portals to the document root, so compare its real overlay and
panel treatment with the Storybook toolbar. The matrix above covers scoped 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.
</p> </p>
</div> </div>
<div className="flex justify-start lg:justify-end"> <div className="flex justify-start lg:justify-end">
+34 -8
View File
@@ -1,18 +1,24 @@
import { import {
colorTokens, colorTokens,
defaultMotionAccessibility,
defaultMotionPack,
motionTokens, motionTokens,
motionAccessibilityDetails,
motionPackDetails,
radiusTokens, radiusTokens,
shadowTokens, shadowTokens,
themeDetails, themeDetails,
themeNames, themeNames,
typographyTokens, typographyTokens,
type MotionModeName, type MotionAccessibilityName,
type MotionPackName,
type ThemeName type ThemeName
} from "@ai-ui/tokens"; } from "@ai-ui/tokens";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
type TokensOverviewProps = { type TokensOverviewProps = {
motionMode: MotionModeName; motionAccessibility: MotionAccessibilityName;
motionPack: MotionPackName;
theme: ThemeName; theme: ThemeName;
}; };
@@ -119,7 +125,11 @@ function ThemeCard({ themeName }: { themeName: ThemeName }) {
); );
} }
function TokensOverview({ motionMode, theme }: TokensOverviewProps) { function TokensOverview({
motionAccessibility,
motionPack,
theme
}: TokensOverviewProps) {
return ( return (
<div className="min-h-screen bg-[var(--color-background)] px-6 py-10 text-[var(--color-foreground)] sm:px-10"> <div className="min-h-screen bg-[var(--color-background)] px-6 py-10 text-[var(--color-foreground)] sm:px-10">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8"> <div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
@@ -157,10 +167,18 @@ function TokensOverview({ motionMode, theme }: TokensOverviewProps) {
</div> </div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] px-4 py-3 shadow-[var(--shadow-xs)]"> <div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] px-4 py-3 shadow-[var(--shadow-xs)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"> <p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Motion Mode Motion Pack
</p> </p>
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]"> <p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">
{motionMode === "system" ? "System preference" : "Reduced motion"} {motionPackDetails[motionPack].label}
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] px-4 py-3 shadow-[var(--shadow-xs)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Accessibility Override
</p>
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">
{motionAccessibilityDetails[motionAccessibility].label}
</p> </p>
</div> </div>
</div> </div>
@@ -282,7 +300,8 @@ function TokensOverview({ motionMode, theme }: TokensOverviewProps) {
<h2 className="text-2xl font-semibold">Motion tokens</h2> <h2 className="text-2xl font-semibold">Motion tokens</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]"> <p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
Timing and motion scale now live in variables that components can consume 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.
</p> </p>
<div className="mt-6 grid gap-4 md:grid-cols-2"> <div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="space-y-3"> <div className="space-y-3">
@@ -370,12 +389,19 @@ type Story = StoryObj<typeof meta>;
export const Overview: Story = { export const Overview: Story = {
args: { args: {
motionMode: "system", motionAccessibility: defaultMotionAccessibility,
motionPack: defaultMotionPack,
theme: "light" theme: "light"
}, },
render: (_args, context) => ( render: (_args, context) => (
<TokensOverview <TokensOverview
motionMode={context.globals.motion as MotionModeName} motionAccessibility={
(context.globals.motionAccessibility as MotionAccessibilityName | undefined) ??
defaultMotionAccessibility
}
motionPack={
(context.globals.motionPack as MotionPackName | undefined) ?? defaultMotionPack
}
theme={context.globals.theme as ThemeName} theme={context.globals.theme as ThemeName}
/> />
) )
+15 -5
View File
@@ -422,19 +422,27 @@ Minimum contract:
```ts ```ts
type ThemeName = "light" | "dark" | "brand" | "minimal"; type ThemeName = "light" | "dark" | "brand" | "minimal";
type SkinName = "minimal" | "glass" | "pixel"; type SkinName = "minimal" | "glass" | "pixel";
type MotionName = "system" | "reduced" | "micro" | "spring"; type MotionPackName = "calm" | "snappy" | "spring";
type MotionAccessibilityName = "system" | "full" | "reduced";
``` ```
Likely helpers: Likely helpers:
- `setTheme(theme, root?)` - `setTheme(theme, root?)`
- `setSkin(skin, 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: Provider shape if needed:
```tsx ```tsx
<StyleProvider theme="minimal" skin="glass" motion="micro"> <StyleProvider
theme="minimal"
skin="glass"
motionPack="spring"
motionAccessibility="system"
>
<App /> <App />
</StyleProvider> </StyleProvider>
``` ```
@@ -587,8 +595,8 @@ proven inside this repo.
recommendation is `@ai-ui/ui`. recommendation is `@ai-ui/ui`.
- Should the runtime API expose a `StyleProvider`, or should root attributes remain the - Should the runtime API expose a `StyleProvider`, or should root attributes remain the
only public contract at first? only public contract at first?
- Should motion packs beyond `system` and `reduced` ship in the first skin milestone, or - Should the current `calm / snappy / spring` set remain the long-term pack list, or
should additional motion packs wait until after the pilot components land? should product-specific packs be introduced later?
- Should `minimal` become the new default appearance, or should the current warm - Should `minimal` become the new default appearance, or should the current warm
editorial look remain the default and be renamed explicitly? 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` and `Skeleton`
- screenshot-friendly validation surface added in `Foundation/Style Matrix` - screenshot-friendly validation surface added in `Foundation/Style Matrix`
- scoped `data-motion="reduced"` now works for nested docs wrappers - 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, - shared skin-aware treatment now extends across the broader component library surface,
including controls, menus, overlays, feedback, and data-heavy patterns including controls, menus, overlays, feedback, and data-heavy patterns
- package consumers can now import a single combined stylesheet from - package consumers can now import a single combined stylesheet from
+61 -4
View File
@@ -18,10 +18,50 @@ export const themeDetails = {
} }
} as const satisfies Record<ThemeName, { label: string; note: string }>; } as const satisfies Record<ThemeName, { label: string; note: string }>;
export const motionModeNames = ["system", "reduced"] as const; export const motionPackNames = ["calm", "snappy", "spring"] as const;
export type MotionModeName = (typeof motionModeNames)[number]; 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<MotionPackName, { label: string; note: string }>;
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<MotionAccessibilityName, { label: string; note: string }>;
export const motionModeNames = motionAccessibilityNames;
export type MotionModeName = MotionAccessibilityName;
export const defaultMotionMode: MotionModeName = defaultMotionAccessibility;
export const motionScale = { export const motionScale = {
instant: "var(--dur-instant)", instant: "var(--dur-instant)",
@@ -150,6 +190,16 @@ function getTargetElement(root?: HTMLElement) {
return document.documentElement; 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) { export function setTheme(theme: ThemeName, root?: HTMLElement) {
const target = getTargetElement(root); const target = getTargetElement(root);
@@ -160,7 +210,10 @@ export function setTheme(theme: ThemeName, root?: HTMLElement) {
target.dataset.theme = theme; target.dataset.theme = theme;
} }
export function setMotionMode(mode: MotionModeName, root?: HTMLElement) { export function setMotionAccessibility(
mode: MotionAccessibilityName,
root?: HTMLElement
) {
const target = getTargetElement(root); const target = getTargetElement(root);
if (!target) { if (!target) {
@@ -174,3 +227,7 @@ export function setMotionMode(mode: MotionModeName, root?: HTMLElement) {
target.dataset.motion = mode; target.dataset.motion = mode;
} }
export function setMotionMode(mode: MotionModeName, root?: HTMLElement) {
setMotionAccessibility(mode, root);
}
+46 -1
View File
@@ -1,4 +1,5 @@
:root { :root,
[data-motion-pack="calm"] {
--dur-instant: 1ms; --dur-instant: 1ms;
--dur-fast: 120ms; --dur-fast: 120ms;
--dur-base: 200ms; --dur-base: 200ms;
@@ -19,6 +20,50 @@
--scale-pop: 1.02; --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"], :root[data-motion="reduced"],
[data-motion="reduced"] { [data-motion="reduced"] {
--dur-instant: 1ms; --dur-instant: 1ms;
@@ -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");
});
});