diff --git a/.gitignore b/.gitignore
index b983656..7ccce7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,5 +4,5 @@ storybook-static
coverage
.turbo
.pnpm-store
-.storybook
+/.storybook
.DS_Store
diff --git a/apps/docs/.storybook/main.ts b/apps/docs/.storybook/main.ts
new file mode 100644
index 0000000..4ac8b01
--- /dev/null
+++ b/apps/docs/.storybook/main.ts
@@ -0,0 +1,23 @@
+import tailwindcss from "@tailwindcss/vite";
+import type { StorybookConfig } from "@storybook/react-vite";
+import { mergeConfig } from "vite";
+
+const config: StorybookConfig = {
+ stories: ["../src/**/*.stories.@(ts|tsx)"],
+ addons: [
+ "@storybook/addon-a11y",
+ "@storybook/addon-essentials",
+ "@storybook/addon-interactions"
+ ],
+ framework: {
+ name: "@storybook/react-vite",
+ options: {}
+ },
+ async viteFinal(config) {
+ return mergeConfig(config, {
+ plugins: [tailwindcss()]
+ });
+ }
+};
+
+export default config;
diff --git a/apps/docs/.storybook/preview.ts b/apps/docs/.storybook/preview.ts
new file mode 100644
index 0000000..e1a57e2
--- /dev/null
+++ b/apps/docs/.storybook/preview.ts
@@ -0,0 +1,75 @@
+import "../src/preview.css";
+
+import type { Preview } from "@storybook/react";
+import {
+ defaultMotionMode,
+ defaultTheme,
+ motionModeNames,
+ setMotionMode,
+ setTheme,
+ themeDetails,
+ themeNames
+} from "@ai-ui/tokens";
+
+const preview: Preview = {
+ globalTypes: {
+ theme: {
+ description: "Preview theme",
+ toolbar: {
+ icon: "paintbrush",
+ dynamicTitle: true,
+ items: themeNames.map((themeName) => ({
+ value: themeName,
+ title: themeDetails[themeName].label
+ }))
+ }
+ },
+ motion: {
+ description: "Preview motion mode",
+ toolbar: {
+ icon: "transfer",
+ dynamicTitle: true,
+ items: motionModeNames.map((modeName) => ({
+ value: modeName,
+ title: modeName === "system" ? "Motion / System" : "Motion / Reduced"
+ }))
+ }
+ }
+ },
+ initialGlobals: {
+ motion: defaultMotionMode,
+ theme: defaultTheme
+ },
+ parameters: {
+ a11y: {
+ test: "todo"
+ },
+ backgrounds: {
+ default: "canvas",
+ values: [
+ {
+ name: "canvas",
+ value: "var(--color-background)"
+ }
+ ]
+ },
+ controls: {
+ expanded: true
+ },
+ layout: "fullscreen"
+ },
+ decorators: [
+ (Story, context) => {
+ if (typeof document !== "undefined") {
+ setTheme(context.globals.theme ?? defaultTheme);
+ setMotionMode(context.globals.motion ?? defaultMotionMode);
+
+ document.body.dataset.theme = context.globals.theme ?? defaultTheme;
+ }
+
+ return Story();
+ }
+ ]
+};
+
+export default preview;
diff --git a/apps/docs/src/contracts.stories.tsx b/apps/docs/src/contracts.stories.tsx
new file mode 100644
index 0000000..9a2a205
--- /dev/null
+++ b/apps/docs/src/contracts.stories.tsx
@@ -0,0 +1,222 @@
+import {
+ authoringChecklist,
+ commonSlotNames,
+ commonStateNames,
+ cvaConventions,
+ getMotionRecipeClassNames,
+ motionRecipes
+} from "@ai-ui/ui";
+import type { Meta, StoryObj } from "@storybook/react";
+
+const componentRecipeExample = `const buttonVariants = cva(
+ [
+ "inline-flex items-center justify-center gap-2",
+ "rounded-[var(--radius-sm)] font-medium",
+ "motion-pressable motion-ring"
+ ],
+ {
+ variants: {
+ variant: {
+ primary: "bg-[var(--color-primary)] text-[var(--color-primary-foreground)]",
+ secondary: "bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)]"
+ },
+ size: {
+ sm: "h-9 px-3 text-sm",
+ md: "h-10 px-4 text-sm",
+ lg: "h-12 px-5 text-base"
+ }
+ },
+ defaultVariants: {
+ variant: "primary",
+ size: "md"
+ }
+ }
+);`;
+
+const stateRecipeExample = `const rootProps = withRootProps(
+ { className: cn(buttonVariants({ variant, size }), className) },
+ {
+ slot: "root",
+ states: {
+ disabled,
+ loading,
+ state: open ? "open" : "closed"
+ }
+ }
+);`;
+
+function ContractsOverview() {
+ return (
+
+
+
+
+ AI UI / Phase 2
+
+
+ Component authoring now follows one repeatable contract.
+
+
+ Phase 2 does not ship real components yet. It defines the shared state,
+ slot, variant, and motion conventions that every future component will use.
+
+
+
+
+
+ Authoring Checklist
+
+ {authoringChecklist.map((item) => (
+
+ ))}
+
+
+
+
+ CVA Conventions
+
+ {cvaConventions.map((item) => (
+
+ ))}
+
+
+
+
+
+
+ State Naming
+
+ Public styling state should flow through stable `data-*` attributes.
+
+
+ {commonStateNames.map((item) => (
+
+
+ {`data-${item.state}`}
+
+ convention
+
+
+
+ {item.guidance}
+
+
+ ))}
+
+
+
+
+ Slot Naming
+
+ Slots create stable styling hooks for component internals and docs.
+
+
+ {commonSlotNames.map((item) => (
+
+
+ {`data-slot="${item.slot}"`}
+
+ slot
+
+
+
+ {item.guidance}
+
+
+ ))}
+
+
+
+
+
+
+ Variant Base Example
+
+ Variants should start from a strong base string, then branch only where
+ appearance semantics actually change.
+
+
+ {componentRecipeExample}
+
+
+
+
+ State Helper Example
+
+ Shared helpers standardize slot names and `data-*` state attributes.
+
+
+ {stateRecipeExample}
+
+
+
+
+
+
+
+
Motion Recipe Helpers
+
+ These class names map directly to the recipe layer defined in
+ `motion.css`.
+
+
+
+ {getMotionRecipeClassNames("transition", "ring", "pressable")}
+
+
+
+
+ {Object.entries(motionRecipes).map(([name, className]) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+const meta = {
+ title: "Foundation/Contracts",
+ component: ContractsOverview,
+ parameters: {
+ layout: "fullscreen"
+ }
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Overview: Story = {};
diff --git a/apps/docs/src/preview.css b/apps/docs/src/preview.css
index 4755138..51ebf5f 100644
--- a/apps/docs/src/preview.css
+++ b/apps/docs/src/preview.css
@@ -5,3 +5,13 @@
background: var(--color-background);
}
+html,
+body,
+#storybook-root {
+ min-height: 100%;
+}
+
+body {
+ background: var(--color-background);
+ color: var(--color-foreground);
+}
diff --git a/apps/docs/src/tokens.stories.tsx b/apps/docs/src/tokens.stories.tsx
new file mode 100644
index 0000000..f091526
--- /dev/null
+++ b/apps/docs/src/tokens.stories.tsx
@@ -0,0 +1,382 @@
+import {
+ colorTokens,
+ motionTokens,
+ radiusTokens,
+ shadowTokens,
+ themeDetails,
+ themeNames,
+ typographyTokens,
+ type MotionModeName,
+ type ThemeName
+} from "@ai-ui/tokens";
+import type { Meta, StoryObj } from "@storybook/react";
+
+type TokensOverviewProps = {
+ motionMode: MotionModeName;
+ theme: ThemeName;
+};
+
+function ResolvedTokenValue({ cssVar }: { cssVar: string }) {
+ const value =
+ typeof document === "undefined"
+ ? ""
+ : getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
+
+ return {value || cssVar};
+}
+
+function TokenSwatch({
+ cssVar,
+ name,
+ role
+}: {
+ cssVar: string;
+ name: string;
+ role: string;
+}) {
+ return (
+
+
+
+
+ {cssVar}
+
+
+
+
+
{name}
+
{role}
+
+
+ );
+}
+
+function ThemeCard({ themeName }: { themeName: ThemeName }) {
+ const theme = themeDetails[themeName];
+
+ return (
+
+
+
+
+ {theme.label}
+
+
+ {theme.note}
+
+
+
+ {themeName}
+
+
+
+
+
+
+
+
Surface
+
+ Secondary containers and ambient panels.
+
+
+
+
+
+
+
Card
+
+ Elevated containers for composition.
+
+
+
+
+
+
+
Primary
+
+ Shared action color and emphasis layer.
+
+
+
+
+
+ );
+}
+
+function TokensOverview({ motionMode, theme }: TokensOverviewProps) {
+ return (
+
+
+
+
+
+
+
+
Theme scaffolds
+
+ These cards render their own nested theme roots, so tokens can be
+ validated side by side without touching component code.
+
+
+
+
+ {themeNames.map((themeName) => (
+
+ ))}
+
+
+
+
+
+
Color roles
+
+ Semantic color tokens replace hardcoded brand values. Components consume
+ roles such as primary, muted, destructive, or surface.
+
+
+
+ {colorTokens.map((token) => (
+
+ ))}
+
+
+
+
+
+
+
Typography scale
+
+ The system now separates display voice, body readability, and metadata
+ density into named text roles.
+
+
+
+ {typographyTokens.map((token) => (
+
+
+ {token.name}
+ {token.fontVar}
+ {token.familyVar}
+
+
+ {token.sample}
+
+
+ ))}
+
+
+
+
+
+ Radius scale
+
+ {radiusTokens.map((token) => (
+
+
+
+
{token.name}
+
+ {token.cssVar}
+
+
+
+ ))}
+
+
+
+
+ Shadow scale
+
+ {shadowTokens.map((token) => (
+
+
+
+
{token.name}
+
+ {token.cssVar}
+
+
+
+ ))}
+
+
+
+
+
+
+
+ Motion tokens
+
+ Timing and motion scale now live in variables that components can consume
+ directly. The toolbar can force reduced motion for preview validation.
+
+
+
+
+ Durations
+
+ {motionTokens.durations.map((token) => (
+
+
+ {token.name}
+
+
+
+
+
+ ))}
+
+
+
+
+ Distances
+
+ {motionTokens.distances.map((token) => (
+
+
+ {token.name}
+
+
+
+
+
+ ))}
+
+
+
+
+
+ Starter recipes
+
+ `motion.css` now includes a small recipe layer for transitions, press
+ feedback, enters, and overlays.
+
+
+ {[
+ "motion-transition",
+ "motion-pressable",
+ "motion-enter-fade",
+ "motion-enter-rise",
+ "motion-overlay-enter",
+ "motion-overlay-exit"
+ ].map((recipe) => (
+
+
+
+ {recipe}
+
+
+ recipe
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+const meta = {
+ title: "Foundation/Tokens",
+ component: TokensOverview,
+ parameters: {
+ layout: "fullscreen"
+ }
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Overview: Story = {
+ args: {
+ motionMode: "system",
+ theme: "light"
+ },
+ render: (_args, context) => (
+
+ )
+};
diff --git a/eslint.config.mjs b/eslint.config.mjs
index efcc1a9..ec4c926 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -6,7 +6,12 @@ import tseslint from "typescript-eslint";
export default tseslint.config(
{
- ignores: ["dist/**", "node_modules/**", "storybook-static/**", "coverage/**"]
+ ignores: [
+ "**/dist/**",
+ "**/node_modules/**",
+ "**/storybook-static/**",
+ "**/coverage/**"
+ ]
},
js.configs.recommended,
...tseslint.configs.recommended,
@@ -35,4 +40,3 @@ export default tseslint.config(
}
}
);
-
diff --git a/packages/tokens/src/base.css b/packages/tokens/src/base.css
index 39140f4..8e9546d 100644
--- a/packages/tokens/src/base.css
+++ b/packages/tokens/src/base.css
@@ -6,6 +6,7 @@
html {
color-scheme: light;
+ background: var(--color-background);
}
body {
@@ -14,6 +15,8 @@ body {
background: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
+ font-size: var(--text-base);
+ line-height: var(--leading-normal);
text-rendering: optimizeLegibility;
}
@@ -24,3 +27,10 @@ select {
font: inherit;
}
+a {
+ color: inherit;
+}
+
+::selection {
+ background: color-mix(in oklch, var(--color-primary) 24%, transparent);
+}
diff --git a/packages/tokens/src/index.ts b/packages/tokens/src/index.ts
index 77f7a54..f18b16b 100644
--- a/packages/tokens/src/index.ts
+++ b/packages/tokens/src/index.ts
@@ -1,8 +1,176 @@
-export const themeNames = ["light", "dark"] as const;
+export const themeNames = ["light", "dark", "brand"] as const;
+export type ThemeName = (typeof themeNames)[number];
+
+export const defaultTheme: ThemeName = "light";
+
+export const themeDetails = {
+ light: {
+ label: "Light",
+ note: "Warm editorial default"
+ },
+ dark: {
+ label: "Dark",
+ note: "Warm charcoal default"
+ },
+ brand: {
+ label: "Brand",
+ note: "Verdant accent scaffold"
+ }
+} as const satisfies Record;
+
+export const motionModeNames = ["system", "reduced"] as const;
+export type MotionModeName = (typeof motionModeNames)[number];
+
+export const defaultMotionMode: MotionModeName = "system";
export const motionScale = {
+ instant: "var(--dur-instant)",
fast: "var(--dur-fast)",
base: "var(--dur-base)",
- slow: "var(--dur-slow)"
+ slow: "var(--dur-slow)",
+ deliberate: "var(--dur-deliberate)"
} as const;
+export const colorTokens = [
+ { name: "background", cssVar: "--color-background", role: "Application canvas" },
+ { name: "foreground", cssVar: "--color-foreground", role: "Primary text and icons" },
+ { name: "surface", cssVar: "--color-surface", role: "Secondary surface backgrounds" },
+ {
+ name: "surface-strong",
+ cssVar: "--color-surface-strong",
+ role: "Elevated surface emphasis"
+ },
+ { name: "card", cssVar: "--color-card", role: "Cards and floating panels" },
+ { name: "border", cssVar: "--color-border", role: "Default dividers and input borders" },
+ {
+ name: "border-strong",
+ cssVar: "--color-border-strong",
+ role: "Higher emphasis dividers"
+ },
+ { name: "primary", cssVar: "--color-primary", role: "Primary actions and highlights" },
+ {
+ name: "secondary",
+ cssVar: "--color-secondary",
+ role: "Secondary fills and supporting actions"
+ },
+ { name: "muted", cssVar: "--color-muted", role: "Subtle supporting surfaces" },
+ {
+ name: "muted-foreground",
+ cssVar: "--color-muted-foreground",
+ role: "Secondary text and captions"
+ },
+ { name: "accent", cssVar: "--color-accent", role: "Moments of emphasis or delight" },
+ { name: "success", cssVar: "--color-success", role: "Success feedback" },
+ { name: "warning", cssVar: "--color-warning", role: "Warning feedback" },
+ { name: "destructive", cssVar: "--color-destructive", role: "Destructive actions" }
+] as const;
+
+export const typographyTokens = [
+ {
+ name: "caption",
+ fontVar: "--text-xs",
+ lineHeightVar: "--leading-normal",
+ familyVar: "--font-sans",
+ sample: "Small labels, metadata, and supporting notes."
+ },
+ {
+ name: "body",
+ fontVar: "--text-base",
+ lineHeightVar: "--leading-normal",
+ familyVar: "--font-sans",
+ sample: "Body copy stays warm, readable, and stable across themes."
+ },
+ {
+ name: "lead",
+ fontVar: "--text-xl",
+ lineHeightVar: "--leading-loose",
+ familyVar: "--font-sans",
+ sample: "Lead text introduces a surface without becoming display copy."
+ },
+ {
+ name: "display",
+ fontVar: "--text-4xl",
+ lineHeightVar: "--leading-tight",
+ familyVar: "--font-display",
+ sample: "Display text carries the editorial voice of the system."
+ }
+] as const;
+
+export const radiusTokens = [
+ { name: "xs", cssVar: "--radius-xs" },
+ { name: "sm", cssVar: "--radius-sm" },
+ { name: "md", cssVar: "--radius-md" },
+ { name: "lg", cssVar: "--radius-lg" },
+ { name: "xl", cssVar: "--radius-xl" },
+ { name: "full", cssVar: "--radius-full" }
+] as const;
+
+export const shadowTokens = [
+ { name: "xs", cssVar: "--shadow-xs" },
+ { name: "sm", cssVar: "--shadow-sm" },
+ { name: "md", cssVar: "--shadow-md" },
+ { name: "lg", cssVar: "--shadow-lg" }
+] as const;
+
+export const motionTokens = {
+ durations: [
+ { name: "instant", cssVar: "--dur-instant" },
+ { name: "fast", cssVar: "--dur-fast" },
+ { name: "base", cssVar: "--dur-base" },
+ { name: "slow", cssVar: "--dur-slow" },
+ { name: "deliberate", cssVar: "--dur-deliberate" }
+ ],
+ easings: [
+ { name: "standard", cssVar: "--ease-standard" },
+ { name: "emphasized", cssVar: "--ease-emphasized" },
+ { name: "exit", cssVar: "--ease-exit" }
+ ],
+ distances: [
+ { name: "xs", cssVar: "--distance-xs" },
+ { name: "sm", cssVar: "--distance-sm" },
+ { name: "md", cssVar: "--distance-md" },
+ { name: "lg", cssVar: "--distance-lg" }
+ ],
+ scales: [
+ { name: "press", cssVar: "--scale-press" },
+ { name: "hover", cssVar: "--scale-hover" },
+ { name: "pop", cssVar: "--scale-pop" }
+ ]
+} as const;
+
+function getTargetElement(root?: HTMLElement) {
+ if (root) {
+ return root;
+ }
+
+ if (typeof document === "undefined") {
+ return undefined;
+ }
+
+ return document.documentElement;
+}
+
+export function setTheme(theme: ThemeName, root?: HTMLElement) {
+ const target = getTargetElement(root);
+
+ if (!target) {
+ return;
+ }
+
+ target.dataset.theme = theme;
+}
+
+export function setMotionMode(mode: MotionModeName, root?: HTMLElement) {
+ const target = getTargetElement(root);
+
+ if (!target) {
+ return;
+ }
+
+ if (mode === "system") {
+ delete target.dataset.motion;
+ return;
+ }
+
+ target.dataset.motion = mode;
+}
diff --git a/packages/tokens/src/motion.css b/packages/tokens/src/motion.css
index f3e8d3d..a2e3d4e 100644
--- a/packages/tokens/src/motion.css
+++ b/packages/tokens/src/motion.css
@@ -1,28 +1,62 @@
:root {
+ --dur-instant: 1ms;
--dur-fast: 120ms;
--dur-base: 200ms;
--dur-slow: 320ms;
+ --dur-deliberate: 460ms;
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
+ --ease-exit: cubic-bezier(0.4, 0, 1, 1);
--distance-xs: 4px;
--distance-sm: 8px;
--distance-md: 16px;
+ --distance-lg: 24px;
--scale-press: 0.98;
+ --scale-hover: 1.01;
+ --scale-pop: 1.02;
+}
+
+:root[data-motion="reduced"] {
+ --dur-instant: 1ms;
+ --dur-fast: 1ms;
+ --dur-base: 1ms;
+ --dur-slow: 1ms;
+ --dur-deliberate: 1ms;
+ --distance-xs: 0px;
+ --distance-sm: 0px;
+ --distance-md: 0px;
+ --distance-lg: 0px;
+ --scale-press: 1;
+ --scale-hover: 1;
+ --scale-pop: 1;
}
@media (prefers-reduced-motion: reduce) {
- :root {
+ :root:not([data-motion="full"]) {
+ --dur-instant: 1ms;
--dur-fast: 1ms;
--dur-base: 1ms;
--dur-slow: 1ms;
+ --dur-deliberate: 1ms;
+ --distance-xs: 0px;
+ --distance-sm: 0px;
+ --distance-md: 0px;
+ --distance-lg: 0px;
+ --scale-press: 1;
+ --scale-hover: 1;
+ --scale-pop: 1;
}
- *,
- *::before,
- *::after {
+ :root {
+ scroll-behavior: auto;
+ }
+
+ :root:not([data-motion="full"]) *,
+ :root:not([data-motion="full"]) *::before,
+ :root:not([data-motion="full"]) *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
@@ -30,3 +64,98 @@
}
}
+@keyframes aiui-fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes aiui-fade-out {
+ from {
+ opacity: 1;
+ }
+
+ to {
+ opacity: 0;
+ }
+}
+
+@keyframes aiui-slide-up-sm {
+ from {
+ opacity: 0;
+ transform: translateY(var(--distance-sm));
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes aiui-slide-down-sm {
+ from {
+ opacity: 0;
+ transform: translateY(calc(var(--distance-sm) * -1));
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.motion-transition {
+ transition-duration: var(--dur-base);
+ transition-property: color, background-color, border-color, box-shadow, opacity,
+ transform;
+ transition-timing-function: var(--ease-standard);
+}
+
+.motion-pressable {
+ transition-duration: var(--dur-fast);
+ transition-property: color, background-color, border-color, box-shadow, transform;
+ transition-timing-function: var(--ease-standard);
+}
+
+.motion-pressable:hover {
+ transform: translateY(calc(var(--distance-xs) * -0.25)) scale(var(--scale-hover));
+}
+
+.motion-pressable:active {
+ transform: scale(var(--scale-press));
+}
+
+.motion-enter-fade {
+ animation: aiui-fade-in var(--dur-base) var(--ease-standard) both;
+}
+
+.motion-enter-rise {
+ animation: aiui-slide-up-sm var(--dur-slow) var(--ease-emphasized) both;
+}
+
+.motion-overlay-enter {
+ animation: aiui-fade-in var(--dur-fast) var(--ease-standard) both;
+}
+
+.motion-overlay-exit {
+ animation: aiui-fade-out var(--dur-fast) var(--ease-exit) both;
+}
+
+.motion-exit-fade {
+ animation: aiui-fade-out var(--dur-fast) var(--ease-exit) both;
+}
+
+.motion-exit-drop {
+ animation: aiui-slide-down-sm calc(var(--dur-fast) * 0.9) var(--ease-exit) reverse
+ both;
+}
+
+.motion-ring {
+ transition-duration: var(--dur-fast);
+ transition-property: box-shadow, outline-color, border-color;
+ transition-timing-function: var(--ease-standard);
+}
diff --git a/packages/tokens/src/tokens.css b/packages/tokens/src/tokens.css
index 2638724..91bbb07 100644
--- a/packages/tokens/src/tokens.css
+++ b/packages/tokens/src/tokens.css
@@ -1,30 +1,72 @@
-:root,
-[data-theme="light"] {
- color-scheme: light;
+:root {
--font-sans: "Avenir Next", "Segoe UI", sans-serif;
+ --font-display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia,
+ serif;
--font-mono: "SF Mono", "SFMono-Regular", "Consolas", monospace;
- --color-background: oklch(0.985 0.004 85);
- --color-foreground: oklch(0.24 0.03 60);
- --color-surface: oklch(0.965 0.008 80);
- --color-surface-strong: oklch(0.93 0.012 78);
- --color-border: oklch(0.87 0.01 75);
- --color-ring: oklch(0.56 0.12 32);
- --color-primary: oklch(0.53 0.15 30);
- --color-primary-foreground: oklch(0.98 0.01 80);
- --color-muted: oklch(0.94 0.008 78);
- --color-muted-foreground: oklch(0.42 0.028 60);
- --color-accent: oklch(0.76 0.1 82);
- --color-card: color-mix(in oklch, var(--color-surface) 86%, white 14%);
- --color-card-foreground: var(--color-foreground);
+ --text-xs: 0.75rem;
+ --text-sm: 0.875rem;
+ --text-base: 1rem;
+ --text-lg: 1.125rem;
+ --text-xl: 1.25rem;
+ --text-2xl: 1.5rem;
+ --text-3xl: 2rem;
+ --text-4xl: clamp(2.5rem, 4vw, 4rem);
- --radius-sm: 10px;
- --radius-md: 16px;
- --radius-lg: 24px;
+ --leading-tight: 1.1;
+ --leading-snug: 1.25;
+ --leading-normal: 1.5;
+ --leading-loose: 1.7;
+
+ --tracking-tight: -0.03em;
+ --tracking-normal: 0;
+ --tracking-caps: 0.18em;
+
+ --border-width-thin: 1px;
+ --border-width-strong: 1.5px;
+
+ --radius-xs: 8px;
+ --radius-sm: 12px;
+ --radius-md: 18px;
+ --radius-lg: 28px;
+ --radius-xl: 40px;
+ --radius-full: 999px;
--shadow-xs: 0 1px 2px oklch(0.28 0.02 55 / 0.06);
--shadow-sm: 0 8px 24px oklch(0.28 0.02 55 / 0.08);
--shadow-md: 0 18px 48px oklch(0.28 0.03 55 / 0.12);
+ --shadow-lg: 0 32px 72px oklch(0.2 0.02 55 / 0.16);
+}
+
+:root,
+[data-theme="light"] {
+ color-scheme: light;
+ --color-background: oklch(0.985 0.004 85);
+ --color-foreground: oklch(0.24 0.03 60);
+ --color-surface: oklch(0.965 0.008 80);
+ --color-surface-strong: oklch(0.93 0.012 78);
+ --color-surface-contrast: oklch(0.28 0.028 58);
+ --color-border: oklch(0.87 0.01 75);
+ --color-border-strong: oklch(0.72 0.018 68);
+ --color-input: var(--color-border);
+ --color-ring: oklch(0.56 0.12 32);
+ --color-primary: oklch(0.53 0.15 30);
+ --color-primary-foreground: oklch(0.98 0.01 80);
+ --color-secondary: oklch(0.9 0.02 74);
+ --color-secondary-foreground: oklch(0.26 0.024 60);
+ --color-muted: oklch(0.94 0.008 78);
+ --color-muted-foreground: oklch(0.42 0.028 60);
+ --color-accent: oklch(0.76 0.1 82);
+ --color-accent-foreground: oklch(0.24 0.03 60);
+ --color-success: oklch(0.58 0.12 152);
+ --color-success-foreground: oklch(0.97 0.01 155);
+ --color-warning: oklch(0.74 0.12 80);
+ --color-warning-foreground: oklch(0.22 0.02 64);
+ --color-destructive: oklch(0.51 0.18 28);
+ --color-destructive-foreground: oklch(0.98 0.01 80);
+ --color-card: color-mix(in oklch, var(--color-surface) 86%, white 14%);
+ --color-card-foreground: var(--color-foreground);
+ --color-overlay: oklch(0.12 0.01 40 / 0.48);
}
[data-theme="dark"] {
@@ -33,13 +75,56 @@
--color-foreground: oklch(0.94 0.01 80);
--color-surface: oklch(0.26 0.018 60);
--color-surface-strong: oklch(0.31 0.018 60);
+ --color-surface-contrast: oklch(0.93 0.012 78);
--color-border: oklch(0.4 0.015 60);
+ --color-border-strong: oklch(0.56 0.028 64);
+ --color-input: var(--color-border);
--color-ring: oklch(0.7 0.12 35);
--color-primary: oklch(0.72 0.13 40);
--color-primary-foreground: oklch(0.22 0.014 60);
+ --color-secondary: oklch(0.39 0.035 66);
+ --color-secondary-foreground: oklch(0.95 0.01 80);
--color-muted: oklch(0.28 0.015 60);
--color-muted-foreground: oklch(0.8 0.02 72);
--color-accent: oklch(0.68 0.09 82);
+ --color-accent-foreground: oklch(0.17 0.012 60);
+ --color-success: oklch(0.7 0.12 152);
+ --color-success-foreground: oklch(0.17 0.012 152);
+ --color-warning: oklch(0.78 0.12 82);
+ --color-warning-foreground: oklch(0.16 0.012 64);
+ --color-destructive: oklch(0.68 0.16 28);
+ --color-destructive-foreground: oklch(0.16 0.012 60);
--color-card: color-mix(in oklch, var(--color-surface) 90%, black 10%);
--color-card-foreground: var(--color-foreground);
+ --color-overlay: oklch(0.05 0.01 50 / 0.72);
+}
+
+[data-theme="brand"] {
+ color-scheme: light;
+ --color-background: oklch(0.972 0.016 172);
+ --color-foreground: oklch(0.24 0.03 182);
+ --color-surface: oklch(0.946 0.018 172);
+ --color-surface-strong: oklch(0.91 0.024 172);
+ --color-surface-contrast: oklch(0.29 0.034 182);
+ --color-border: oklch(0.83 0.026 172);
+ --color-border-strong: oklch(0.67 0.045 176);
+ --color-input: var(--color-border);
+ --color-ring: oklch(0.53 0.12 190);
+ --color-primary: oklch(0.48 0.12 188);
+ --color-primary-foreground: oklch(0.97 0.008 172);
+ --color-secondary: oklch(0.82 0.066 156);
+ --color-secondary-foreground: oklch(0.2 0.02 178);
+ --color-muted: oklch(0.91 0.018 172);
+ --color-muted-foreground: oklch(0.42 0.03 180);
+ --color-accent: oklch(0.75 0.105 130);
+ --color-accent-foreground: oklch(0.2 0.02 160);
+ --color-success: oklch(0.6 0.12 155);
+ --color-success-foreground: oklch(0.98 0.006 170);
+ --color-warning: oklch(0.76 0.13 86);
+ --color-warning-foreground: oklch(0.22 0.02 74);
+ --color-destructive: oklch(0.53 0.16 30);
+ --color-destructive-foreground: oklch(0.98 0.01 80);
+ --color-card: color-mix(in oklch, var(--color-surface) 88%, white 12%);
+ --color-card-foreground: var(--color-foreground);
+ --color-overlay: oklch(0.13 0.015 185 / 0.5);
}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 435c8cf..140c9ef 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,4 +1,35 @@
export { cn } from "./lib/cn";
export { cva, cx, type VariantProps } from "./lib/cva";
-export { motionDurations, motionEasings } from "./lib/motion";
-
+export {
+ authoringChecklist,
+ commonSlotNames,
+ commonStateNames,
+ createDataAttributes,
+ createSlot,
+ cvaConventions,
+ dataAttr,
+ withRootProps,
+ type AsChildProp,
+ type ButtonLikeElementProps,
+ type CommonComponentProps,
+ type ControllableStateProps,
+ type DataAttributes,
+ type DisableableProps,
+ type FieldStateProps,
+ type InputLikeElementProps,
+ type InvalidatableProps,
+ type LoadingProps,
+ type PressableStateProps,
+ type ReadonlyProps,
+ type RequiredProps,
+ type SlotAttributes
+} from "./lib/contracts";
+export {
+ getMotionRecipeClassNames,
+ motionDistances,
+ motionDurations,
+ motionEasings,
+ motionRecipes,
+ motionScales,
+ type MotionRecipeName
+} from "./lib/motion";
diff --git a/packages/ui/src/lib/contracts.ts b/packages/ui/src/lib/contracts.ts
new file mode 100644
index 0000000..4e57c41
--- /dev/null
+++ b/packages/ui/src/lib/contracts.ts
@@ -0,0 +1,185 @@
+import type { ComponentPropsWithoutRef } from "react";
+
+export type CommonComponentProps = {
+ className?: string;
+};
+
+export type AsChildProp = {
+ asChild?: boolean;
+};
+
+export type DisableableProps = {
+ disabled?: boolean;
+};
+
+export type InvalidatableProps = {
+ invalid?: boolean;
+};
+
+export type LoadingProps = {
+ loading?: boolean;
+};
+
+export type ReadonlyProps = {
+ readOnly?: boolean;
+};
+
+export type RequiredProps = {
+ required?: boolean;
+};
+
+export type PressableStateProps = CommonComponentProps &
+ DisableableProps &
+ LoadingProps &
+ AsChildProp;
+
+export type FieldStateProps = CommonComponentProps &
+ DisableableProps &
+ InvalidatableProps &
+ ReadonlyProps &
+ RequiredProps;
+
+export type ControllableStateProps = {
+ defaultValue?: T;
+ onValueChange?: (value: T) => void;
+ value?: T;
+};
+
+export type DataAttributes = Partial>;
+
+export type SlotAttributes = {
+ "data-slot": Name;
+};
+
+export const commonSlotNames = [
+ {
+ slot: "root",
+ guidance: "The outermost element rendered by the component."
+ },
+ {
+ slot: "label",
+ guidance: "Primary visible label or title within the component."
+ },
+ {
+ slot: "description",
+ guidance: "Secondary supporting copy connected to the component."
+ },
+ {
+ slot: "control",
+ guidance: "Focusable or interactive control surface."
+ },
+ {
+ slot: "input",
+ guidance: "Typed value entry element such as input or textarea."
+ },
+ {
+ slot: "trigger",
+ guidance: "Element that opens, closes, or toggles related content."
+ },
+ {
+ slot: "content",
+ guidance: "Popover, drawer, menu, dialog, or expandable content region."
+ },
+ {
+ slot: "icon",
+ guidance: "Decorative or stateful icon container."
+ }
+] as const;
+
+export const commonStateNames = [
+ {
+ state: "state",
+ guidance: "Use for finite machine-like values such as open, closed, active, or inactive."
+ },
+ {
+ state: "disabled",
+ guidance: "Set when interaction is blocked."
+ },
+ {
+ state: "invalid",
+ guidance: "Set when validation fails or the field is in an error state."
+ },
+ {
+ state: "loading",
+ guidance: "Set when the component is waiting on async work."
+ },
+ {
+ state: "readonly",
+ guidance: "Set when the value can be viewed but not edited."
+ },
+ {
+ state: "required",
+ guidance: "Set when the field requires a value."
+ },
+ {
+ state: "orientation",
+ guidance: "Use for horizontal or vertical layout state when styling depends on it."
+ }
+] as const;
+
+export const authoringChecklist = [
+ "Expose `className` on every styled public component.",
+ "Forward `ref` on every focusable or measurable public component.",
+ "Use `asChild` only for components whose root element is meant to be polymorphic.",
+ "Represent boolean UI states with empty-string `data-*` attributes.",
+ "Represent finite machine states with `data-state=\"...\"` and keep the values stable.",
+ "Name internal stylable parts with `data-slot` so variants and docs can target them consistently.",
+ "Prefer controlled and uncontrolled APIs together when the component manages user state.",
+ "Consume tokens and motion recipes instead of raw visual values."
+] as const;
+
+export const cvaConventions = [
+ "Put shared layout and focus primitives in the CVA base string, not in individual variants.",
+ "Reserve `variant` for semantic appearance changes and `size` only when spacing or density genuinely changes.",
+ "Keep default variants explicit so stories and tests do not depend on implicit visual fallbacks.",
+ "Prefer a small stable variant surface over one-off booleans that fragment the API."
+] as const;
+
+export function dataAttr(active?: boolean) {
+ return active ? "" : undefined;
+}
+
+export function createDataAttributes(
+ states: Record
+): DataAttributes {
+ const attributes: DataAttributes = {};
+
+ for (const [name, value] of Object.entries(states)) {
+ const key = `data-${name}` as const;
+
+ if (typeof value === "boolean") {
+ attributes[key] = dataAttr(value);
+ continue;
+ }
+
+ if (value !== null && value !== undefined) {
+ attributes[key] = String(value);
+ }
+ }
+
+ return attributes;
+}
+
+export function createSlot(name: Name): SlotAttributes {
+ return { "data-slot": name };
+}
+
+export function withRootProps(
+ props: T,
+ options: {
+ slot?: string;
+ states?: Record;
+ } = {}
+) {
+ return {
+ ...props,
+ ...(options.slot ? createSlot(options.slot) : {}),
+ ...(options.states ? createDataAttributes(options.states) : {})
+ };
+}
+
+export type ButtonLikeElementProps = ComponentPropsWithoutRef<"button"> &
+ PressableStateProps;
+
+export type InputLikeElementProps = ComponentPropsWithoutRef<"input"> &
+ FieldStateProps;
diff --git a/packages/ui/src/lib/motion.ts b/packages/ui/src/lib/motion.ts
index 380ee93..e9d14c1 100644
--- a/packages/ui/src/lib/motion.ts
+++ b/packages/ui/src/lib/motion.ts
@@ -1,11 +1,44 @@
export const motionDurations = {
+ instant: "var(--dur-instant)",
fast: "var(--dur-fast)",
base: "var(--dur-base)",
- slow: "var(--dur-slow)"
+ slow: "var(--dur-slow)",
+ deliberate: "var(--dur-deliberate)"
} as const;
export const motionEasings = {
standard: "var(--ease-standard)",
- emphasized: "var(--ease-emphasized)"
+ emphasized: "var(--ease-emphasized)",
+ exit: "var(--ease-exit)"
} as const;
+export const motionDistances = {
+ xs: "var(--distance-xs)",
+ sm: "var(--distance-sm)",
+ md: "var(--distance-md)",
+ lg: "var(--distance-lg)"
+} as const;
+
+export const motionScales = {
+ press: "var(--scale-press)",
+ hover: "var(--scale-hover)",
+ pop: "var(--scale-pop)"
+} as const;
+
+export const motionRecipes = {
+ transition: "motion-transition",
+ pressable: "motion-pressable",
+ enterFade: "motion-enter-fade",
+ enterRise: "motion-enter-rise",
+ overlayEnter: "motion-overlay-enter",
+ overlayExit: "motion-overlay-exit",
+ exitFade: "motion-exit-fade",
+ exitDrop: "motion-exit-drop",
+ ring: "motion-ring"
+} as const;
+
+export type MotionRecipeName = keyof typeof motionRecipes;
+
+export function getMotionRecipeClassNames(...recipes: MotionRecipeName[]) {
+ return recipes.map((recipe) => motionRecipes[recipe]).join(" ");
+}