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
} 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;
+23 -10
View File
@@ -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({
<section className="flex flex-wrap gap-3">
<RuntimeBadge label="theme" value={theme} />
<RuntimeBadge label="skin" value={skin} />
<RuntimeBadge label="motion" value={motion} />
<RuntimeBadge label="motion pack" value={motionPack} />
<RuntimeBadge label="accessibility" value={motionAccessibility} />
</section>
<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`",
"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) => (
<div
key={item}
@@ -201,7 +206,8 @@ function StyleContractShowcase({
{[
"Button, card, input, dialog, switch, and skeleton recipe extraction",
"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) => (
<div
key={item}
@@ -232,7 +238,8 @@ const meta = {
title: "Foundation/Style Contract",
component: StyleContractShowcase,
args: {
motion: defaultMotionMode,
motionAccessibility: defaultMotionAccessibility,
motionPack: defaultMotionPack,
skin: defaultSkin,
theme: defaultTheme
},
@@ -240,13 +247,19 @@ const meta = {
docs: {
description: {
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) => (
<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}
theme={(context.globals.theme as ThemeName | undefined) ?? defaultTheme}
/>
+60 -27
View File
@@ -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 (
<section
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}
>
<div className="flex flex-wrap gap-2">
<RuntimePill>{motionPackDetails[motionPack].label}</RuntimePill>
<RuntimePill>{skinDetails[skin].label}</RuntimePill>
<RuntimePill>{motion}</RuntimePill>
<RuntimePill>{motionAccessibility}</RuntimePill>
</div>
<Card interactive tone="default">
@@ -166,29 +175,52 @@ function StyleMatrixShowcase() {
</h1>
<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.
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=&quot;reduced&quot;` scopes so the same building blocks can be
reviewed side by side.
</p>
</header>
<section className="grid gap-4">
{motionModes.map((motionMode) => (
<div key={motionMode.value} className="grid gap-3">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold">{motionMode.label}</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
`{motionMode.value === "reduced" ? 'data-motion="reduced"' : "default"}`
{" "}
on the wrapper scope.
</p>
{motionPackNames.map((motionPack) => (
<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>
<h3 className="text-xl font-semibold">
{motionAccessibilityMode.label}
</h3>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
{motionAccessibilityMode.value === "reduced"
? '`data-motion="reduced"`'
: "System preference"}
{" "}with{" "}
<code className="text-[var(--color-foreground)]">
{`data-motion-pack="${motionPack}"`}
</code>
.
</p>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-3">
{skinNames.map((skin) => (
<ComparisonCell
key={`${motionPack}-${motionAccessibilityMode.value}-${skin}`}
motionAccessibility={motionAccessibilityMode.value}
motionPack={motionPack}
skin={skin}
/>
))}
</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-3">
{skinNames.map((skin) => (
<ComparisonCell key={`${motionMode.value}-${skin}`} motion={motionMode.value} skin={skin} />
))}
</div>
))}
</div>
))}
</section>
@@ -199,7 +231,8 @@ function StyleMatrixShowcase() {
<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
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>
</div>
<div className="flex justify-start lg:justify-end">
+34 -8
View File
@@ -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 (
<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">
@@ -157,10 +167,18 @@ function TokensOverview({ motionMode, theme }: TokensOverviewProps) {
</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)]">
Motion Mode
Motion Pack
</p>
<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>
</div>
</div>
@@ -282,7 +300,8 @@ function TokensOverview({ motionMode, theme }: TokensOverviewProps) {
<h2 className="text-2xl font-semibold">Motion tokens</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
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>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="space-y-3">
@@ -370,12 +389,19 @@ type Story = StoryObj<typeof meta>;
export const Overview: Story = {
args: {
motionMode: "system",
motionAccessibility: defaultMotionAccessibility,
motionPack: defaultMotionPack,
theme: "light"
},
render: (_args, context) => (
<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}
/>
)
+15 -5
View File
@@ -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
<StyleProvider theme="minimal" skin="glass" motion="micro">
<StyleProvider
theme="minimal"
skin="glass"
motionPack="spring"
motionAccessibility="system"
>
<App />
</StyleProvider>
```
@@ -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
+61 -4
View File
@@ -18,10 +18,50 @@ export const themeDetails = {
}
} as const satisfies Record<ThemeName, { label: string; note: string }>;
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<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 = {
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);
}
+46 -1
View File
@@ -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;
@@ -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");
});
});