feat: add token system and ui contracts
This commit is contained in:
+1
-1
@@ -4,5 +4,5 @@ storybook-static
|
|||||||
coverage
|
coverage
|
||||||
.turbo
|
.turbo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.storybook
|
/.storybook
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 (
|
||||||
|
<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">
|
||||||
|
<header className="max-w-3xl space-y-3">
|
||||||
|
<p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
|
AI UI / Phase 2
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
className="font-semibold tracking-[var(--tracking-tight)]"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-display)",
|
||||||
|
fontSize: "var(--text-4xl)",
|
||||||
|
lineHeight: "var(--leading-tight)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Component authoring now follows one repeatable contract.
|
||||||
|
</h1>
|
||||||
|
<p className="text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]">
|
||||||
|
Phase 2 does not ship real components yet. It defines the shared state,
|
||||||
|
slot, variant, and motion conventions that every future component will use.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<h2 className="text-2xl font-semibold">Authoring Checklist</h2>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{authoringChecklist.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-foreground)]">{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<h2 className="text-2xl font-semibold">CVA Conventions</h2>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{cvaConventions.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-foreground)]">{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<h2 className="text-2xl font-semibold">State Naming</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
Public styling state should flow through stable `data-*` attributes.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{commonStateNames.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.state}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<code className="text-sm font-medium">{`data-${item.state}`}</code>
|
||||||
|
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
|
convention
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||||
|
{item.guidance}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<h2 className="text-2xl font-semibold">Slot Naming</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
Slots create stable styling hooks for component internals and docs.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{commonSlotNames.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.slot}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<code className="text-sm font-medium">{`data-slot="${item.slot}"`}</code>
|
||||||
|
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
|
slot
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||||
|
{item.guidance}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<h2 className="text-2xl font-semibold">Variant Base Example</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
Variants should start from a strong base string, then branch only where
|
||||||
|
appearance semantics actually change.
|
||||||
|
</p>
|
||||||
|
<pre className="mt-5 overflow-x-auto rounded-[var(--radius-md)] bg-[var(--color-surface-contrast)] p-4 text-sm leading-6 text-[var(--color-background)]">
|
||||||
|
<code>{componentRecipeExample}</code>
|
||||||
|
</pre>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<h2 className="text-2xl font-semibold">State Helper Example</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
Shared helpers standardize slot names and `data-*` state attributes.
|
||||||
|
</p>
|
||||||
|
<pre className="mt-5 overflow-x-auto rounded-[var(--radius-md)] bg-[var(--color-surface-contrast)] p-4 text-sm leading-6 text-[var(--color-background)]">
|
||||||
|
<code>{stateRecipeExample}</code>
|
||||||
|
</pre>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Motion Recipe Helpers</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
These class names map directly to the recipe layer defined in
|
||||||
|
`motion.css`.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<code className="rounded-[var(--radius-full)] bg-[var(--color-surface)] px-3 py-2 text-sm">
|
||||||
|
{getMotionRecipeClassNames("transition", "ring", "pressable")}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Object.entries(motionRecipes).map(([name, className]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium">{name}</p>
|
||||||
|
<code className="text-xs text-[var(--color-muted-foreground)]">{className}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Foundation/Contracts",
|
||||||
|
component: ContractsOverview,
|
||||||
|
parameters: {
|
||||||
|
layout: "fullscreen"
|
||||||
|
}
|
||||||
|
} satisfies Meta<typeof ContractsOverview>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Overview: Story = {};
|
||||||
@@ -5,3 +5,13 @@
|
|||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#storybook-root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 <span>{value || cssVar}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TokenSwatch({
|
||||||
|
cssVar,
|
||||||
|
name,
|
||||||
|
role
|
||||||
|
}: {
|
||||||
|
cssVar: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-xs)]">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-20 rounded-[var(--radius-sm)] border border-[var(--color-border)]"
|
||||||
|
style={{ background: `var(${cssVar})` }}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<code className="text-sm font-medium text-[var(--color-foreground)]">{cssVar}</code>
|
||||||
|
<span className="text-xs text-[var(--color-muted-foreground)]">
|
||||||
|
<ResolvedTokenValue cssVar={cssVar} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-[var(--color-foreground)]">{name}</p>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">{role}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeCard({ themeName }: { themeName: ThemeName }) {
|
||||||
|
const theme = themeDetails[themeName];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
data-theme={themeName}
|
||||||
|
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-background)] p-5 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
|
{theme.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||||
|
{theme.note}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-[var(--color-secondary)] px-3 py-1 text-xs font-medium text-[var(--color-secondary-foreground)]">
|
||||||
|
{themeName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-16 rounded-[calc(var(--radius-sm)-4px)] border border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<p className="text-sm font-medium">Surface</p>
|
||||||
|
<p className="text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
Secondary containers and ambient panels.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[var(--radius-sm)] border border-[var(--color-border-strong)] bg-[var(--color-background)] p-4">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-16 rounded-[calc(var(--radius-sm)-4px)] border border-[var(--color-border-strong)] bg-[var(--color-card)]"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<p className="text-sm font-medium">Card</p>
|
||||||
|
<p className="text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
Elevated containers for composition.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] p-4 text-[var(--color-foreground)]">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-16 rounded-[calc(var(--radius-sm)-4px)] bg-[var(--color-primary)] ring-1 ring-white/20"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<p className="text-sm font-medium">Primary</p>
|
||||||
|
<p className="text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
Shared action color and emphasis layer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TokensOverview({ motionMode, 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">
|
||||||
|
<header className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="max-w-3xl space-y-3">
|
||||||
|
<p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
|
AI UI / Phase 1
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
className="max-w-4xl font-semibold tracking-[var(--tracking-tight)]"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-display)",
|
||||||
|
fontSize: "var(--text-4xl)",
|
||||||
|
lineHeight: "var(--leading-tight)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
The first stable token layer defines color, type, surface depth, and
|
||||||
|
motion rhythm.
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<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)]">
|
||||||
|
Active Theme
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">
|
||||||
|
{themeDetails[theme].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)]">
|
||||||
|
Motion Mode
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">
|
||||||
|
{motionMode === "system" ? "System preference" : "Reduced motion"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Theme scaffolds</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
These cards render their own nested theme roots, so tokens can be
|
||||||
|
validated side by side without touching component code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
{themeNames.map((themeName) => (
|
||||||
|
<ThemeCard key={themeName} themeName={themeName} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Color roles</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
Semantic color tokens replace hardcoded brand values. Components consume
|
||||||
|
roles such as primary, muted, destructive, or surface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{colorTokens.map((token) => (
|
||||||
|
<TokenSwatch
|
||||||
|
key={token.cssVar}
|
||||||
|
cssVar={token.cssVar}
|
||||||
|
name={token.name}
|
||||||
|
role={token.role}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Typography scale</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
The system now separates display voice, body readability, and metadata
|
||||||
|
density into named text roles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
{typographyTokens.map((token) => (
|
||||||
|
<div key={token.name} className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
|
<span>{token.name}</span>
|
||||||
|
<code>{token.fontVar}</code>
|
||||||
|
<code>{token.familyVar}</code>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: `var(${token.familyVar})`,
|
||||||
|
fontSize: `var(${token.fontVar})`,
|
||||||
|
lineHeight: `var(${token.lineHeightVar})`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token.sample}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<h2 className="text-2xl font-semibold">Radius scale</h2>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{radiusTokens.map((token) => (
|
||||||
|
<div key={token.cssVar} className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="h-12 w-24 border border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||||
|
style={{ borderRadius: `var(${token.cssVar})` }}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{token.name}</p>
|
||||||
|
<code className="text-xs text-[var(--color-muted-foreground)]">
|
||||||
|
{token.cssVar}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<h2 className="text-2xl font-semibold">Shadow scale</h2>
|
||||||
|
<div className="mt-5 grid gap-4">
|
||||||
|
{shadowTokens.map((token) => (
|
||||||
|
<div key={token.cssVar} className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="h-14 w-24 rounded-[var(--radius-sm)] bg-[var(--color-background)]"
|
||||||
|
style={{ boxShadow: `var(${token.cssVar})` }}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{token.name}</p>
|
||||||
|
<code className="text-xs text-[var(--color-muted-foreground)]">
|
||||||
|
{token.cssVar}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
|
Durations
|
||||||
|
</h3>
|
||||||
|
{motionTokens.durations.map((token) => (
|
||||||
|
<div key={token.cssVar} className="rounded-[var(--radius-sm)] bg-[var(--color-surface)] px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm font-medium">{token.name}</span>
|
||||||
|
<code className="text-xs text-[var(--color-muted-foreground)]">
|
||||||
|
<ResolvedTokenValue cssVar={token.cssVar} />
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
|
Distances
|
||||||
|
</h3>
|
||||||
|
{motionTokens.distances.map((token) => (
|
||||||
|
<div key={token.cssVar} className="rounded-[var(--radius-sm)] bg-[var(--color-surface)] px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm font-medium">{token.name}</span>
|
||||||
|
<code className="text-xs text-[var(--color-muted-foreground)]">
|
||||||
|
<ResolvedTokenValue cssVar={token.cssVar} />
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<h2 className="text-2xl font-semibold">Starter recipes</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
`motion.css` now includes a small recipe layer for transitions, press
|
||||||
|
feedback, enters, and overlays.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 grid gap-3">
|
||||||
|
{[
|
||||||
|
"motion-transition",
|
||||||
|
"motion-pressable",
|
||||||
|
"motion-enter-fade",
|
||||||
|
"motion-enter-rise",
|
||||||
|
"motion-overlay-enter",
|
||||||
|
"motion-overlay-exit"
|
||||||
|
].map((recipe) => (
|
||||||
|
<div
|
||||||
|
key={recipe}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<code className="text-sm font-medium text-[var(--color-foreground)]">
|
||||||
|
{recipe}
|
||||||
|
</code>
|
||||||
|
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
|
recipe
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Foundation/Tokens",
|
||||||
|
component: TokensOverview,
|
||||||
|
parameters: {
|
||||||
|
layout: "fullscreen"
|
||||||
|
}
|
||||||
|
} satisfies Meta<typeof TokensOverview>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Overview: Story = {
|
||||||
|
args: {
|
||||||
|
motionMode: "system",
|
||||||
|
theme: "light"
|
||||||
|
},
|
||||||
|
render: (_args, context) => (
|
||||||
|
<TokensOverview
|
||||||
|
motionMode={context.globals.motion as MotionModeName}
|
||||||
|
theme={context.globals.theme as ThemeName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
};
|
||||||
+6
-2
@@ -6,7 +6,12 @@ import tseslint from "typescript-eslint";
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ["dist/**", "node_modules/**", "storybook-static/**", "coverage/**"]
|
ignores: [
|
||||||
|
"**/dist/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/storybook-static/**",
|
||||||
|
"**/coverage/**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
@@ -35,4 +40,3 @@ export default tseslint.config(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
background: var(--color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -14,6 +15,8 @@ body {
|
|||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,3 +27,10 @@ select {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(in oklch, var(--color-primary) 24%, transparent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<ThemeName, { label: string; note: string }>;
|
||||||
|
|
||||||
|
export const motionModeNames = ["system", "reduced"] as const;
|
||||||
|
export type MotionModeName = (typeof motionModeNames)[number];
|
||||||
|
|
||||||
|
export const defaultMotionMode: MotionModeName = "system";
|
||||||
|
|
||||||
export const motionScale = {
|
export const motionScale = {
|
||||||
|
instant: "var(--dur-instant)",
|
||||||
fast: "var(--dur-fast)",
|
fast: "var(--dur-fast)",
|
||||||
base: "var(--dur-base)",
|
base: "var(--dur-base)",
|
||||||
slow: "var(--dur-slow)"
|
slow: "var(--dur-slow)",
|
||||||
|
deliberate: "var(--dur-deliberate)"
|
||||||
} as const;
|
} 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,62 @@
|
|||||||
:root {
|
:root {
|
||||||
|
--dur-instant: 1ms;
|
||||||
--dur-fast: 120ms;
|
--dur-fast: 120ms;
|
||||||
--dur-base: 200ms;
|
--dur-base: 200ms;
|
||||||
--dur-slow: 320ms;
|
--dur-slow: 320ms;
|
||||||
|
--dur-deliberate: 460ms;
|
||||||
|
|
||||||
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
|
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
|
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
|
||||||
--distance-xs: 4px;
|
--distance-xs: 4px;
|
||||||
--distance-sm: 8px;
|
--distance-sm: 8px;
|
||||||
--distance-md: 16px;
|
--distance-md: 16px;
|
||||||
|
--distance-lg: 24px;
|
||||||
|
|
||||||
--scale-press: 0.98;
|
--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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
:root {
|
:root:not([data-motion="full"]) {
|
||||||
|
--dur-instant: 1ms;
|
||||||
--dur-fast: 1ms;
|
--dur-fast: 1ms;
|
||||||
--dur-base: 1ms;
|
--dur-base: 1ms;
|
||||||
--dur-slow: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
:root {
|
||||||
*::before,
|
scroll-behavior: auto;
|
||||||
*::after {
|
}
|
||||||
|
|
||||||
|
:root:not([data-motion="full"]) *,
|
||||||
|
:root:not([data-motion="full"]) *::before,
|
||||||
|
:root:not([data-motion="full"]) *::after {
|
||||||
animation-duration: 1ms !important;
|
animation-duration: 1ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
scroll-behavior: auto !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);
|
||||||
|
}
|
||||||
|
|||||||
+104
-19
@@ -1,30 +1,72 @@
|
|||||||
:root,
|
:root {
|
||||||
[data-theme="light"] {
|
|
||||||
color-scheme: light;
|
|
||||||
--font-sans: "Avenir Next", "Segoe UI", sans-serif;
|
--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;
|
--font-mono: "SF Mono", "SFMono-Regular", "Consolas", monospace;
|
||||||
|
|
||||||
--color-background: oklch(0.985 0.004 85);
|
--text-xs: 0.75rem;
|
||||||
--color-foreground: oklch(0.24 0.03 60);
|
--text-sm: 0.875rem;
|
||||||
--color-surface: oklch(0.965 0.008 80);
|
--text-base: 1rem;
|
||||||
--color-surface-strong: oklch(0.93 0.012 78);
|
--text-lg: 1.125rem;
|
||||||
--color-border: oklch(0.87 0.01 75);
|
--text-xl: 1.25rem;
|
||||||
--color-ring: oklch(0.56 0.12 32);
|
--text-2xl: 1.5rem;
|
||||||
--color-primary: oklch(0.53 0.15 30);
|
--text-3xl: 2rem;
|
||||||
--color-primary-foreground: oklch(0.98 0.01 80);
|
--text-4xl: clamp(2.5rem, 4vw, 4rem);
|
||||||
--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);
|
|
||||||
|
|
||||||
--radius-sm: 10px;
|
--leading-tight: 1.1;
|
||||||
--radius-md: 16px;
|
--leading-snug: 1.25;
|
||||||
--radius-lg: 24px;
|
--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-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-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-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"] {
|
[data-theme="dark"] {
|
||||||
@@ -33,13 +75,56 @@
|
|||||||
--color-foreground: oklch(0.94 0.01 80);
|
--color-foreground: oklch(0.94 0.01 80);
|
||||||
--color-surface: oklch(0.26 0.018 60);
|
--color-surface: oklch(0.26 0.018 60);
|
||||||
--color-surface-strong: oklch(0.31 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: 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-ring: oklch(0.7 0.12 35);
|
||||||
--color-primary: oklch(0.72 0.13 40);
|
--color-primary: oklch(0.72 0.13 40);
|
||||||
--color-primary-foreground: oklch(0.22 0.014 60);
|
--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: oklch(0.28 0.015 60);
|
||||||
--color-muted-foreground: oklch(0.8 0.02 72);
|
--color-muted-foreground: oklch(0.8 0.02 72);
|
||||||
--color-accent: oklch(0.68 0.09 82);
|
--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: color-mix(in oklch, var(--color-surface) 90%, black 10%);
|
||||||
--color-card-foreground: var(--color-foreground);
|
--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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,35 @@
|
|||||||
export { cn } from "./lib/cn";
|
export { cn } from "./lib/cn";
|
||||||
export { cva, cx, type VariantProps } from "./lib/cva";
|
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";
|
||||||
|
|||||||
@@ -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<T> = {
|
||||||
|
defaultValue?: T;
|
||||||
|
onValueChange?: (value: T) => void;
|
||||||
|
value?: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataAttributes = Partial<Record<`data-${string}`, string | undefined>>;
|
||||||
|
|
||||||
|
export type SlotAttributes<Name extends string> = {
|
||||||
|
"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<string, boolean | number | string | null | undefined>
|
||||||
|
): 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 extends string>(name: Name): SlotAttributes<Name> {
|
||||||
|
return { "data-slot": name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withRootProps<T extends object>(
|
||||||
|
props: T,
|
||||||
|
options: {
|
||||||
|
slot?: string;
|
||||||
|
states?: Record<string, boolean | number | string | null | undefined>;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
...(options.slot ? createSlot(options.slot) : {}),
|
||||||
|
...(options.states ? createDataAttributes(options.states) : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ButtonLikeElementProps = ComponentPropsWithoutRef<"button"> &
|
||||||
|
PressableStateProps;
|
||||||
|
|
||||||
|
export type InputLikeElementProps = ComponentPropsWithoutRef<"input"> &
|
||||||
|
FieldStateProps;
|
||||||
@@ -1,11 +1,44 @@
|
|||||||
export const motionDurations = {
|
export const motionDurations = {
|
||||||
|
instant: "var(--dur-instant)",
|
||||||
fast: "var(--dur-fast)",
|
fast: "var(--dur-fast)",
|
||||||
base: "var(--dur-base)",
|
base: "var(--dur-base)",
|
||||||
slow: "var(--dur-slow)"
|
slow: "var(--dur-slow)",
|
||||||
|
deliberate: "var(--dur-deliberate)"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const motionEasings = {
|
export const motionEasings = {
|
||||||
standard: "var(--ease-standard)",
|
standard: "var(--ease-standard)",
|
||||||
emphasized: "var(--ease-emphasized)"
|
emphasized: "var(--ease-emphasized)",
|
||||||
|
exit: "var(--ease-exit)"
|
||||||
} as const;
|
} 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(" ");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user