Add runtime skin contract and docs

This commit is contained in:
2026-03-20 11:22:03 +08:00
parent 5c9eb84c63
commit 246851a68e
9 changed files with 1053 additions and 1 deletions
+20
View File
@@ -1,6 +1,12 @@
import "../src/preview.css";
import type { Preview } from "@storybook/react";
import {
defaultSkin,
setSkin,
skinDetails,
skinNames
} from "@ai-ui/ui";
import {
defaultMotionMode,
defaultTheme,
@@ -34,10 +40,22 @@ const preview: Preview = {
title: modeName === "system" ? "Motion / System" : "Motion / Reduced"
}))
}
},
skin: {
description: "Preview component skin",
toolbar: {
icon: "mirror",
dynamicTitle: true,
items: skinNames.map((skinName) => ({
value: skinName,
title: skinDetails[skinName].label
}))
}
}
},
initialGlobals: {
motion: defaultMotionMode,
skin: defaultSkin,
theme: defaultTheme
},
parameters: {
@@ -63,8 +81,10 @@ const preview: Preview = {
if (typeof document !== "undefined") {
setTheme(context.globals.theme ?? defaultTheme);
setMotionMode(context.globals.motion ?? defaultMotionMode);
setSkin(context.globals.skin ?? defaultSkin);
document.body.dataset.theme = context.globals.theme ?? defaultTheme;
document.body.dataset.skin = context.globals.skin ?? defaultSkin;
}
return Story();
+4
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "@ai-ui/tokens/styles.css";
@import "@ai-ui/ui/skins.css";
@source "../../../packages/ui/src";
:root {
@@ -14,5 +15,8 @@ body,
body {
background: var(--color-background);
background-image: var(--ui-canvas-image);
background-size: var(--ui-canvas-size);
background-attachment: fixed;
color: var(--color-foreground);
}
+260
View File
@@ -0,0 +1,260 @@
import { useState } from "react";
import {
Button,
Input,
Skeleton,
Switch,
defaultSkin,
skinDetails,
skinNames,
type SkinName
} from "@ai-ui/ui";
import {
defaultMotionMode,
defaultTheme,
type MotionModeName,
type ThemeName
} from "@ai-ui/tokens";
import type { Meta, StoryObj } from "@storybook/react";
type StyleContractShowcaseProps = {
motion: MotionModeName;
skin: SkinName;
theme: ThemeName;
};
function RuntimeBadge({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[var(--radius-full)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
<span className="mr-2 text-[var(--color-foreground)]">{label}</span>
{value}
</div>
);
}
function SkinPanel({
description,
name
}: {
description: string;
name: SkinName;
}) {
const [enabled, setEnabled] = useState(name !== "minimal");
return (
<article
data-skin={name}
className="grid gap-4 border p-5"
style={{
background: "var(--ui-surface-bg)",
borderColor: "var(--ui-surface-border)",
borderRadius: "var(--ui-surface-radius)",
boxShadow: "var(--ui-surface-shadow)",
backdropFilter: "blur(var(--ui-surface-backdrop-blur))"
}}
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
data-skin=&quot;{name}&quot;
</p>
<div>
<h3 className="text-xl font-semibold text-[var(--color-foreground)]">
{skinDetails[name].label}
</h3>
<p className="mt-1 text-sm leading-6 text-[var(--color-muted-foreground)]">
{description}
</p>
</div>
</div>
<span
className="rounded-full border px-3 py-1 text-xs font-medium text-[var(--color-foreground)]"
style={{
background: "var(--ui-control-bg)",
borderColor: "var(--ui-control-border)",
borderRadius: "var(--ui-control-radius)",
boxShadow: "var(--ui-control-shadow)"
}}
>
phase 1
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div
className="relative overflow-hidden border p-4"
style={{
background: "var(--ui-control-bg)",
borderColor: "var(--ui-control-border)",
borderRadius: "var(--ui-control-radius)",
boxShadow: "var(--ui-control-shadow)"
}}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-4 top-0 h-12"
style={{
background:
"linear-gradient(135deg, color-mix(in oklch, var(--color-primary) 24%, transparent), transparent)",
mixBlendMode:
name === "glass" ? "screen" : name === "pixel" ? "multiply" : "normal",
opacity: "var(--ui-ornament-opacity)"
}}
/>
<p className="text-sm font-medium text-[var(--color-foreground)]">Surface hooks</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
This panel reads the new Phase 1 skin variables directly. It is the proof that
root or nested `data-skin` scopes are now a stable runtime contract.
</p>
</div>
<div
className="grid gap-3 border p-4"
style={{
background: "var(--ui-control-bg)",
borderColor: "var(--ui-control-border)",
borderRadius: "var(--ui-control-radius)",
boxShadow: "var(--ui-control-shadow)"
}}
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-[var(--color-foreground)]">
Preview controls
</span>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<Input
aria-label={`${name} skin search`}
defaultValue={skinDetails[name].note}
readOnly
/>
<Button variant={name === "glass" ? "secondary" : "primary"}>
Same API, future skin target
</Button>
<Skeleton shape="line" />
</div>
</div>
</article>
);
}
function StyleContractShowcase({
motion,
skin,
theme
}: StyleContractShowcaseProps) {
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-4xl space-y-4">
<p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
AI UI / Phase 1
</p>
<h1
className="font-semibold tracking-[var(--tracking-tight)]"
style={{
fontFamily: "var(--font-display)",
fontSize: "var(--text-4xl)",
lineHeight: "var(--leading-tight)"
}}
>
Runtime skin switching is now a first-class docs contract, even before
component recipes are extracted.
</h1>
<p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]">
Phase 1 introduces `data-skin`, root helpers, Storybook toolbar wiring, and a
dedicated skin CSS entrypoint. Phase 2 will move component recipes onto this
contract.
</p>
</header>
<section className="flex flex-wrap gap-3">
<RuntimeBadge label="theme" value={theme} />
<RuntimeBadge label="skin" value={skin} />
<RuntimeBadge label="motion" value={motion} />
</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)]">
<h2 className="text-2xl font-semibold">What Phase 1 includes</h2>
<div className="mt-5 grid gap-3">
{[
"A new runtime attribute: `data-skin`",
"Public helpers from `@ai-ui/ui` for skin names, defaults, and root updates",
"A dedicated `@ai-ui/ui/skins.css` entrypoint imported by the docs app",
"Storybook globals that apply theme, skin, and motion together"
].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">What still waits for Phase 2</h2>
<div className="mt-5 grid gap-3">
{[
"Button, card, input, dialog, switch, and skeleton recipe extraction",
"Skin-specific component semantic variables such as `--button-*` and `--panel-*`",
"A docs comparison page where existing components fully restyle under each skin"
].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">
{skinNames.map((name) => (
<SkinPanel
key={name}
description={skinDetails[name].note}
name={name}
/>
))}
</section>
</div>
</div>
);
}
const meta = {
title: "Foundation/Style Contract",
component: StyleContractShowcase,
args: {
motion: defaultMotionMode,
skin: defaultSkin,
theme: defaultTheme
},
parameters: {
docs: {
description: {
component:
"Phase 1 adds the runtime style contract. Use the Storybook toolbar to switch the active `theme`, `skin`, and `motion` values globally, or inspect the side-by-side nested `data-skin` panels below."
}
}
},
render: (_args, context) => (
<StyleContractShowcase
motion={(context.globals.motion as MotionModeName | undefined) ?? defaultMotionMode}
skin={(context.globals.skin as SkinName | undefined) ?? defaultSkin}
theme={(context.globals.theme as ThemeName | undefined) ?? defaultTheme}
/>
)
} satisfies Meta<typeof StyleContractShowcase>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Overview: Story = {};