From 3960e0a0e7c83b713dcc35d1d8ca69b9be66ea2e Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 19 Mar 2026 14:21:13 +0800 Subject: [PATCH] feat: add token system and ui contracts --- .gitignore | 2 +- apps/docs/.storybook/main.ts | 23 ++ apps/docs/.storybook/preview.ts | 75 ++++++ apps/docs/src/contracts.stories.tsx | 222 ++++++++++++++++ apps/docs/src/preview.css | 10 + apps/docs/src/tokens.stories.tsx | 382 ++++++++++++++++++++++++++++ eslint.config.mjs | 8 +- packages/tokens/src/base.css | 10 + packages/tokens/src/index.ts | 172 ++++++++++++- packages/tokens/src/motion.css | 137 +++++++++- packages/tokens/src/tokens.css | 123 +++++++-- packages/ui/src/index.ts | 35 ++- packages/ui/src/lib/contracts.ts | 185 ++++++++++++++ packages/ui/src/lib/motion.ts | 37 ++- 14 files changed, 1389 insertions(+), 32 deletions(-) create mode 100644 apps/docs/.storybook/main.ts create mode 100644 apps/docs/.storybook/preview.ts create mode 100644 apps/docs/src/contracts.stories.tsx create mode 100644 apps/docs/src/tokens.stories.tsx create mode 100644 packages/ui/src/lib/contracts.ts 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) => ( +
+

{item}

+
+ ))} +
+
+ +
+

CVA Conventions

+
+ {cvaConventions.map((item) => ( +
+

{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]) => ( +
+
+

{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 ( +
+
+ ); +} + +function ThemeCard({ themeName }: { themeName: ThemeName }) { + const theme = themeDetails[themeName]; + + return ( +
+
+
+

+ {theme.label} +

+

+ {theme.note} +

+
+ + {themeName} + +
+ +
+
+ +
+ +
+ +
+
+ ); +} + +function TokensOverview({ motionMode, theme }: TokensOverviewProps) { + return ( +
+
+
+
+

+ AI UI / Phase 1 +

+

+ The first stable token layer defines color, type, surface depth, and + motion rhythm. +

+

+ Theme switching now happens at the token layer, not inside component + implementations. Motion is also represented as named tokens and starter + recipes rather than ad hoc transition values. +

+
+ +
+
+

+ Active Theme +

+

+ {themeDetails[theme].label} +

+
+
+

+ Motion Mode +

+

+ {motionMode === "system" ? "System preference" : "Reduced motion"} +

+
+
+
+ +
+
+
+

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(" "); +}