Add runtime skin contract and docs
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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="{name}"
|
||||
</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 = {};
|
||||
@@ -0,0 +1,610 @@
|
||||
# RFC: Multi-Style Architecture
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-03-20
|
||||
|
||||
## Why this document exists
|
||||
|
||||
This document records the current plan for making Cadence UI support multiple visual
|
||||
styles without forking component behavior or losing the existing source-owned model.
|
||||
|
||||
It is written as a handoff document. A different agent should be able to read this
|
||||
file, inspect the listed repo files, and continue the work without reconstructing the
|
||||
intent from chat history.
|
||||
|
||||
## Objective
|
||||
|
||||
Support runtime switching across multiple visual styles such as:
|
||||
|
||||
- `minimal`
|
||||
- `glass`
|
||||
- `pixel`
|
||||
|
||||
while preserving:
|
||||
|
||||
- the current public React component APIs
|
||||
- accessibility behavior
|
||||
- reduced-motion support
|
||||
- source ownership inside this repository
|
||||
|
||||
## Summary Decision
|
||||
|
||||
Cadence UI should not treat "style" as a single token pack.
|
||||
|
||||
The working model should be:
|
||||
|
||||
1. `theme`: color, typography, radius, shadow baseline
|
||||
2. `skin`: component appearance recipe
|
||||
3. `motion`: interaction timing and effect vocabulary
|
||||
4. `layout`: page composition patterns, handled outside the base component package
|
||||
|
||||
For the first milestone, this repo should implement `theme + skin + motion`. Layout
|
||||
patterns such as `bento`, `sidebar`, or `magazine` should remain a docs or blocks
|
||||
concern, not a base `@ai-ui/ui` concern.
|
||||
|
||||
## External Reference Synthesis
|
||||
|
||||
The site `https://ui-gallery.codebanana.app/` is useful because it separates style into
|
||||
multiple dimensions:
|
||||
|
||||
- `UI 风格`
|
||||
- `色调`
|
||||
- `字体`
|
||||
- `布局`
|
||||
- `动效`
|
||||
|
||||
That separation matches the conclusion above: styles like `glassmorphism` or
|
||||
`pixel art` are not just color swaps. They change component surfaces, borders,
|
||||
decoration, motion, and sometimes page composition.
|
||||
|
||||
## Current Repo State
|
||||
|
||||
### What already exists
|
||||
|
||||
- `packages/tokens` already defines semantic CSS variables for color, typography,
|
||||
radius, shadow, and motion.
|
||||
- `packages/tokens` already supports multiple themes through `data-theme`.
|
||||
- `packages/tokens` already supports reduced-motion behavior through `data-motion`
|
||||
and `prefers-reduced-motion`.
|
||||
- `packages/ui` already consumes semantic token variables in many component recipes.
|
||||
- `packages/ui` now exposes a public skin contract through `skinNames`, `defaultSkin`,
|
||||
`skinDetails`, and `setSkin`.
|
||||
- `packages/ui` now exports a dedicated `@ai-ui/ui/skins.css` entrypoint.
|
||||
- `apps/docs` already acts as the review surface and imports token CSS globally.
|
||||
- `apps/docs` Storybook globals now apply `theme`, `skin`, and `motion` together.
|
||||
- `apps/docs` now includes a `Foundation/Style Contract` page that documents the Phase 1
|
||||
runtime contract.
|
||||
|
||||
### What does not exist yet
|
||||
|
||||
- No component skin layer exists.
|
||||
- No style provider exists that treats `theme`, `skin`, and `motion` as separate axes.
|
||||
- No existing component family fully restyles across multiple skins yet.
|
||||
|
||||
### Why the repo is not "multi-style" yet
|
||||
|
||||
The current system is multi-theme, not multi-style.
|
||||
|
||||
Many visual decisions are still written directly inside `packages/ui`, for example:
|
||||
|
||||
- button sheen gradient and animation
|
||||
- skeleton shimmer gradient
|
||||
- white switch thumb
|
||||
- fixed rounded forms like `rounded-full`
|
||||
- fixed spacing and size choices that encode a particular visual language
|
||||
|
||||
That means changing `tokens` alone can shift tone, but it cannot reliably produce
|
||||
strongly different styles like `glass` or `pixel`.
|
||||
|
||||
## Read These Files First
|
||||
|
||||
Another agent resuming this work should inspect these files first:
|
||||
|
||||
- `roadmap.md`
|
||||
- `CONTRIBUTING.md`
|
||||
- `packages/tokens/src/index.ts`
|
||||
- `packages/tokens/src/tokens.css`
|
||||
- `packages/tokens/src/motion.css`
|
||||
- `packages/ui/src/lib/skin.ts`
|
||||
- `packages/ui/src/skins.css`
|
||||
- `apps/docs/src/preview.css`
|
||||
- `apps/docs/.storybook/preview.ts`
|
||||
- `apps/docs/src/style-contract.stories.tsx`
|
||||
- `packages/ui/src/lib/motion.ts`
|
||||
- `packages/ui/src/components/button.tsx`
|
||||
- `packages/ui/src/components/button.variants.ts`
|
||||
- `packages/ui/src/components/skeleton.tsx`
|
||||
- `packages/ui/src/components/switch.variants.ts`
|
||||
|
||||
These files capture the current token contract, motion contract, docs wiring, and the
|
||||
best concrete examples of hardcoded visual decisions that block style switching.
|
||||
|
||||
## Working Definitions
|
||||
|
||||
### Theme
|
||||
|
||||
Theme sets the foundational visual baseline:
|
||||
|
||||
- color roles
|
||||
- font families
|
||||
- font scales
|
||||
- radius scale
|
||||
- shadow scale
|
||||
|
||||
Examples:
|
||||
|
||||
- `editorial`
|
||||
- `minimal`
|
||||
- `dark-luxury`
|
||||
|
||||
### Skin
|
||||
|
||||
Skin defines how components look while keeping structure and behavior stable.
|
||||
|
||||
Skin can change:
|
||||
|
||||
- surface treatment
|
||||
- border style
|
||||
- blur and transparency
|
||||
- decoration layers
|
||||
- contrast level
|
||||
- component-specific gradients
|
||||
- special effects such as sheen or pixelated shadows
|
||||
|
||||
Examples:
|
||||
|
||||
- `minimal`
|
||||
- `glass`
|
||||
- `pixel`
|
||||
|
||||
### Motion Pack
|
||||
|
||||
Motion pack defines interaction feel:
|
||||
|
||||
- durations
|
||||
- easing
|
||||
- hover feedback
|
||||
- entrance and exit behavior
|
||||
- loading behavior
|
||||
|
||||
Examples:
|
||||
|
||||
- `calm`
|
||||
- `micro`
|
||||
- `spring`
|
||||
|
||||
### Layout Pattern
|
||||
|
||||
Layout pattern is page composition, not component skin.
|
||||
|
||||
Examples:
|
||||
|
||||
- `bento-grid`
|
||||
- `sidebar-app`
|
||||
- `magazine`
|
||||
- `single-column-longform`
|
||||
|
||||
These should live in docs, blocks, or app-level patterns. They should not be mixed into
|
||||
the core style-switching milestone.
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
### Layer 1: Semantic Foundation Tokens
|
||||
|
||||
Keep `packages/tokens` as the owner of global semantic variables.
|
||||
|
||||
Do not split multiple token packages yet. Keep a single package with multiple themes
|
||||
until the contract is stable.
|
||||
|
||||
The existing token groups remain the baseline:
|
||||
|
||||
- `--color-*`
|
||||
- `--font-*`
|
||||
- `--text-*`
|
||||
- `--radius-*`
|
||||
- `--shadow-*`
|
||||
- `--dur-*`
|
||||
- `--ease-*`
|
||||
- `--distance-*`
|
||||
- `--scale-*`
|
||||
|
||||
### Layer 2: Skin Contract
|
||||
|
||||
Add a new root attribute:
|
||||
|
||||
```html
|
||||
<html data-theme="minimal" data-skin="glass" data-motion="micro">
|
||||
```
|
||||
|
||||
`data-skin` should be the runtime contract for component appearance.
|
||||
|
||||
Recommended initial values:
|
||||
|
||||
- `minimal`
|
||||
- `glass`
|
||||
- `pixel`
|
||||
|
||||
### Layer 3: Component Semantic Style Variables
|
||||
|
||||
The current repo uses global semantic tokens directly in many components. That is good
|
||||
for theme switching, but it is not enough for skin switching.
|
||||
|
||||
The next step should be introducing component-semantic variables or skin hooks for the
|
||||
highest-value components first.
|
||||
|
||||
Examples:
|
||||
|
||||
- button:
|
||||
- `--button-bg`
|
||||
- `--button-border`
|
||||
- `--button-fg`
|
||||
- `--button-shadow`
|
||||
- `--button-sheen-opacity`
|
||||
- card:
|
||||
- `--card-bg`
|
||||
- `--card-border`
|
||||
- `--card-shadow`
|
||||
- input:
|
||||
- `--input-bg`
|
||||
- `--input-border`
|
||||
- `--input-shadow`
|
||||
- dialog or popover:
|
||||
- `--panel-bg`
|
||||
- `--panel-border`
|
||||
- `--panel-shadow`
|
||||
- `--panel-backdrop-blur`
|
||||
- switch:
|
||||
- `--switch-track-bg`
|
||||
- `--switch-thumb-bg`
|
||||
|
||||
The rule is:
|
||||
|
||||
- global semantic tokens define product-wide roles
|
||||
- component semantic variables define component appearance
|
||||
- skins map global tokens to component variables
|
||||
|
||||
### Layer 4: Skin CSS Ownership
|
||||
|
||||
The recommended ownership split is:
|
||||
|
||||
- `packages/tokens` owns global theme and motion variables
|
||||
- `packages/ui` owns component skin CSS
|
||||
|
||||
Reason:
|
||||
|
||||
- `theme` is a design-system baseline concern
|
||||
- `skin` is a component recipe concern
|
||||
|
||||
That means the likely future CSS imports are:
|
||||
|
||||
```css
|
||||
@import "@ai-ui/tokens/styles.css";
|
||||
@import "@ai-ui/ui/skins.css";
|
||||
```
|
||||
|
||||
Consumer ergonomics can later be improved by providing a convenience entry such as:
|
||||
|
||||
```css
|
||||
@import "@ai-ui/ui/styles.css";
|
||||
```
|
||||
|
||||
where `@ai-ui/ui/styles.css` re-exports both token and skin styles.
|
||||
|
||||
## Implementation Rules
|
||||
|
||||
### Rule 1: Preserve behavior and API
|
||||
|
||||
Do not fork component logic per skin.
|
||||
|
||||
Skins should change appearance, not:
|
||||
|
||||
- props
|
||||
- ARIA behavior
|
||||
- controlled or uncontrolled patterns
|
||||
- slot naming
|
||||
- `data-*` state contract
|
||||
|
||||
### Rule 2: Separate structure from appearance
|
||||
|
||||
Leave these in component code:
|
||||
|
||||
- layout structure
|
||||
- sizing variants
|
||||
- slot and state attributes
|
||||
- accessibility and interaction logic
|
||||
|
||||
Move these toward skin hooks:
|
||||
|
||||
- surface fill
|
||||
- border treatment
|
||||
- shadow treatment
|
||||
- blur
|
||||
- decoration layers
|
||||
- visual effect toggles
|
||||
|
||||
### Rule 3: Do not put layout patterns into the skin milestone
|
||||
|
||||
Page layouts are a separate concern and should not block the component skin work.
|
||||
|
||||
### Rule 4: Default gracefully
|
||||
|
||||
If a consumer does not specify `data-skin`, the system should default to the current
|
||||
closest appearance, likely `minimal`.
|
||||
|
||||
### Rule 5: Reduced motion remains authoritative
|
||||
|
||||
No skin may bypass reduced-motion expectations. `data-motion` and
|
||||
`prefers-reduced-motion` still govern the final motion behavior.
|
||||
|
||||
## Recommended First Slice
|
||||
|
||||
Do not start by updating every component.
|
||||
|
||||
Start with a focused pilot set that proves the architecture:
|
||||
|
||||
- `Button`
|
||||
- `Card`
|
||||
- `Input`
|
||||
- `Dialog`
|
||||
- `Switch`
|
||||
- `Skeleton`
|
||||
|
||||
These are enough to validate:
|
||||
|
||||
- basic surfaces
|
||||
- overlays
|
||||
- focus treatments
|
||||
- loading affordances
|
||||
- special effects
|
||||
|
||||
## Suggested Skin Definitions
|
||||
|
||||
### Minimal
|
||||
|
||||
Intent:
|
||||
|
||||
- remove ornamental effects
|
||||
- keep surfaces solid
|
||||
- keep shadows subtle
|
||||
- prefer clarity over decoration
|
||||
|
||||
Expected traits:
|
||||
|
||||
- no sheen
|
||||
- no blur
|
||||
- low-contrast borders
|
||||
- restrained shadows
|
||||
|
||||
### Glass
|
||||
|
||||
Intent:
|
||||
|
||||
- translucent layers
|
||||
- blurred panels
|
||||
- floating surfaces
|
||||
- higher use of overlays and edge highlights
|
||||
|
||||
Expected traits:
|
||||
|
||||
- semi-transparent surfaces
|
||||
- stronger border highlights
|
||||
- backdrop blur on floating panels
|
||||
- controlled glow or sheen on selected components
|
||||
|
||||
### Pixel
|
||||
|
||||
Intent:
|
||||
|
||||
- square geometry
|
||||
- hard edges
|
||||
- crisp borders
|
||||
- step-like shadow language
|
||||
|
||||
Expected traits:
|
||||
|
||||
- zero or near-zero radius
|
||||
- no blur
|
||||
- hard shadows
|
||||
- no soft sheen
|
||||
- sharper, less fluid motion
|
||||
|
||||
## Suggested Runtime API
|
||||
|
||||
The end state should support runtime switching without remounting components.
|
||||
|
||||
Minimum contract:
|
||||
|
||||
```ts
|
||||
type ThemeName = "light" | "dark" | "brand" | "minimal";
|
||||
type SkinName = "minimal" | "glass" | "pixel";
|
||||
type MotionName = "system" | "reduced" | "micro" | "spring";
|
||||
```
|
||||
|
||||
Likely helpers:
|
||||
|
||||
- `setTheme(theme, root?)`
|
||||
- `setSkin(skin, root?)`
|
||||
- `setMotionMode(mode, root?)`
|
||||
|
||||
Provider shape if needed:
|
||||
|
||||
```tsx
|
||||
<StyleProvider theme="minimal" skin="glass" motion="micro">
|
||||
<App />
|
||||
</StyleProvider>
|
||||
```
|
||||
|
||||
This provider is optional. Direct root attributes are acceptable if they keep the system
|
||||
simple.
|
||||
|
||||
## Delivery Plan
|
||||
|
||||
### Phase 0: Analysis and Contract Draft
|
||||
|
||||
Goal:
|
||||
|
||||
- document the problem clearly
|
||||
- separate `theme`, `skin`, `motion`, and `layout`
|
||||
- identify which components prove the architecture
|
||||
|
||||
Deliverables:
|
||||
|
||||
- this RFC
|
||||
- explicit initial skin names
|
||||
- initial pilot component list
|
||||
|
||||
Status:
|
||||
|
||||
- completed
|
||||
|
||||
### Phase 1: Runtime Skin Contract
|
||||
|
||||
Goal:
|
||||
|
||||
- introduce `data-skin`
|
||||
- define `skinNames` and related helpers
|
||||
- decide final CSS file ownership and export path
|
||||
|
||||
Deliverables:
|
||||
|
||||
- root skin attribute contract
|
||||
- helper API
|
||||
- docs wiring for skin switching
|
||||
|
||||
Status:
|
||||
|
||||
- completed
|
||||
|
||||
### Phase 2: Pilot Skin Extraction
|
||||
|
||||
Goal:
|
||||
|
||||
- extract hardcoded visual decisions from the pilot components into skin-aware hooks
|
||||
|
||||
Deliverables:
|
||||
|
||||
- `Button`, `Card`, `Input`, `Dialog`, `Switch`, and `Skeleton` render distinctly under
|
||||
`minimal`, `glass`, and `pixel`
|
||||
- no public component API breakage
|
||||
|
||||
Status:
|
||||
|
||||
- not started
|
||||
|
||||
### Phase 3: Docs Validation Surface
|
||||
|
||||
Goal:
|
||||
|
||||
- create a docs page or stories that compare the same components across all supported
|
||||
skins and motion modes
|
||||
|
||||
Deliverables:
|
||||
|
||||
- a stable review surface in `apps/docs`
|
||||
- screenshot-friendly comparisons
|
||||
- clear regression target for future work
|
||||
|
||||
Status:
|
||||
|
||||
- not started
|
||||
|
||||
### Phase 4: Expand Coverage
|
||||
|
||||
Goal:
|
||||
|
||||
- apply the same pattern to the rest of the component library
|
||||
|
||||
Priority order:
|
||||
|
||||
- form controls
|
||||
- overlays
|
||||
- navigation and menus
|
||||
- feedback components
|
||||
- advanced patterns such as `DataTable`
|
||||
|
||||
Status:
|
||||
|
||||
- not started
|
||||
|
||||
### Phase 5: Packaging and Consumer Ergonomics
|
||||
|
||||
Goal:
|
||||
|
||||
- make skin CSS consumable outside this repo without forcing consumers to understand the
|
||||
internal file graph
|
||||
|
||||
Deliverables:
|
||||
|
||||
- final CSS import path
|
||||
- final package exports
|
||||
- install guidance in release and registry docs
|
||||
|
||||
Status:
|
||||
|
||||
- not started
|
||||
|
||||
## Acceptance Criteria For The First Real Milestone
|
||||
|
||||
The first milestone should be considered complete only when all of these are true:
|
||||
|
||||
- the same `Button`, `Card`, `Input`, `Dialog`, `Switch`, and `Skeleton` instances can
|
||||
switch across `minimal`, `glass`, and `pixel`
|
||||
- switching uses root attributes, not per-component prop forks
|
||||
- reduced motion still works correctly
|
||||
- Storybook or docs demonstrate the comparison clearly
|
||||
- no public component APIs were broken to achieve the visual switching
|
||||
|
||||
## Risks
|
||||
|
||||
### Risk 1: Tailwind classes currently mix structure and appearance
|
||||
|
||||
This will make extraction repetitive. The work must stay disciplined and not turn into
|
||||
ad hoc component rewrites.
|
||||
|
||||
### Risk 2: Some skins may require more than variables
|
||||
|
||||
`glass` and `pixel` may need extra selectors, pseudo-elements, or special wrapper
|
||||
classes. That is acceptable, but those decisions must remain in the skin layer.
|
||||
|
||||
### Risk 3: Layout can derail the effort
|
||||
|
||||
The external reference site includes layout categories. If layout gets pulled into the
|
||||
first milestone, the implementation scope will expand too quickly.
|
||||
|
||||
### Risk 4: Packaging decisions can arrive too early
|
||||
|
||||
Do not split multiple npm packages for separate skins until the internal contract is
|
||||
proven inside this repo.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should `skin` helpers live in `@ai-ui/tokens` or `@ai-ui/ui`? The current
|
||||
recommendation is `@ai-ui/ui`.
|
||||
- Should the runtime API expose a `StyleProvider`, or should root attributes remain the
|
||||
only public contract at first?
|
||||
- Should motion packs beyond `system` and `reduced` ship in the first skin milestone, or
|
||||
should additional motion packs wait until after the pilot components land?
|
||||
- Should `minimal` become the new default appearance, or should the current warm
|
||||
editorial look remain the default and be renamed explicitly?
|
||||
|
||||
## Current Completion Snapshot
|
||||
|
||||
As of 2026-03-20, the project is at this point:
|
||||
|
||||
- problem analysis completed
|
||||
- architecture direction chosen
|
||||
- scope boundaries chosen
|
||||
- phase 1 runtime skin contract completed
|
||||
- `@ai-ui/ui/skins.css` export added
|
||||
- Storybook globals now switch `skin`
|
||||
- docs switching surface added in `Foundation/Style Contract`
|
||||
- no component extraction started
|
||||
- no broad skin-aware component recipes implemented yet
|
||||
|
||||
The next implementation task should be Phase 2: pilot skin extraction for
|
||||
`Button`, `Card`, `Input`, `Dialog`, `Switch`, and `Skeleton`.
|
||||
@@ -3,8 +3,12 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./skins.css": "./src/skins.css"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -401,3 +401,10 @@ export {
|
||||
motionScales,
|
||||
type MotionRecipeName
|
||||
} from "./lib/motion";
|
||||
export {
|
||||
defaultSkin,
|
||||
setSkin,
|
||||
skinDetails,
|
||||
skinNames,
|
||||
type SkinName
|
||||
} from "./lib/skin";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { defaultSkin, setSkin, skinDetails, skinNames } from "./skin";
|
||||
|
||||
describe("skin contract", () => {
|
||||
it("exposes a default skin that exists in the public name set", () => {
|
||||
expect(skinNames).toContain(defaultSkin);
|
||||
expect(skinDetails[defaultSkin].label).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets the document root skin when no target element is provided", () => {
|
||||
setSkin("glass");
|
||||
|
||||
expect(document.documentElement.dataset.skin).toBe("glass");
|
||||
});
|
||||
|
||||
it("sets the provided target element instead of the document root", () => {
|
||||
const target = document.createElement("div");
|
||||
|
||||
setSkin("pixel", target);
|
||||
|
||||
expect(target.dataset.skin).toBe("pixel");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
export const skinNames = ["minimal", "glass", "pixel"] as const;
|
||||
export type SkinName = (typeof skinNames)[number];
|
||||
|
||||
export const defaultSkin: SkinName = "minimal";
|
||||
|
||||
export const skinDetails = {
|
||||
minimal: {
|
||||
label: "Minimal",
|
||||
note: "Restrained surfaces and low-ornament defaults"
|
||||
},
|
||||
glass: {
|
||||
label: "Glass",
|
||||
note: "Translucent layers, brighter edges, and blurred panels"
|
||||
},
|
||||
pixel: {
|
||||
label: "Pixel",
|
||||
note: "Hard edges, crisp borders, and stepped shadows"
|
||||
}
|
||||
} as const satisfies Record<SkinName, { label: string; note: string }>;
|
||||
|
||||
function getTargetElement(root?: HTMLElement) {
|
||||
if (root) {
|
||||
return root;
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return document.documentElement;
|
||||
}
|
||||
|
||||
export function setSkin(skin: SkinName, root?: HTMLElement) {
|
||||
const target = getTargetElement(root);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.dataset.skin = skin;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
:root,
|
||||
[data-skin="minimal"] {
|
||||
--ui-canvas-image: radial-gradient(
|
||||
circle at top,
|
||||
color-mix(in oklch, var(--color-primary) 8%, transparent),
|
||||
transparent 58%
|
||||
);
|
||||
--ui-canvas-size: auto;
|
||||
--ui-surface-bg: color-mix(in oklch, var(--color-card) 88%, white 12%);
|
||||
--ui-surface-border: color-mix(in oklch, var(--color-border) 92%, white 8%);
|
||||
--ui-surface-shadow: var(--shadow-sm);
|
||||
--ui-surface-radius: var(--radius-lg);
|
||||
--ui-surface-backdrop-blur: 0px;
|
||||
--ui-control-bg: color-mix(in oklch, var(--color-background) 92%, white 8%);
|
||||
--ui-control-border: var(--color-border);
|
||||
--ui-control-shadow: var(--shadow-xs);
|
||||
--ui-control-radius: var(--radius-md);
|
||||
--ui-ornament-opacity: 0.1;
|
||||
--ui-ornament-mix: normal;
|
||||
}
|
||||
|
||||
[data-skin="glass"] {
|
||||
--ui-canvas-image:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
color-mix(in oklch, var(--color-primary) 22%, transparent),
|
||||
transparent 42%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at top right,
|
||||
color-mix(in oklch, var(--color-accent) 18%, transparent),
|
||||
transparent 48%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklch, var(--color-background) 64%, white 36%),
|
||||
var(--color-background)
|
||||
);
|
||||
--ui-canvas-size: auto;
|
||||
--ui-surface-bg: color-mix(in oklch, var(--color-card) 58%, transparent);
|
||||
--ui-surface-border: color-mix(in oklch, white 46%, var(--color-border));
|
||||
--ui-surface-shadow: 0 24px 64px oklch(0.18 0.03 255 / 0.18);
|
||||
--ui-surface-radius: var(--radius-xl);
|
||||
--ui-surface-backdrop-blur: 20px;
|
||||
--ui-control-bg: color-mix(in oklch, var(--color-card) 52%, transparent);
|
||||
--ui-control-border: color-mix(in oklch, white 36%, var(--color-border-strong));
|
||||
--ui-control-shadow: 0 14px 38px oklch(0.2 0.03 255 / 0.14);
|
||||
--ui-control-radius: var(--radius-lg);
|
||||
--ui-ornament-opacity: 0.36;
|
||||
--ui-ornament-mix: screen;
|
||||
}
|
||||
|
||||
[data-skin="pixel"] {
|
||||
--ui-canvas-image:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in oklch, var(--color-foreground) 7%, transparent) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklch, var(--color-foreground) 7%, transparent) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklch, var(--color-background) 92%, black 8%),
|
||||
var(--color-background)
|
||||
);
|
||||
--ui-canvas-size: 12px 12px, 12px 12px, auto;
|
||||
--ui-surface-bg: color-mix(in oklch, var(--color-card) 96%, white 4%);
|
||||
--ui-surface-border: var(--color-foreground);
|
||||
--ui-surface-shadow: 6px 6px 0 color-mix(in oklch, var(--color-foreground) 38%, transparent);
|
||||
--ui-surface-radius: 0px;
|
||||
--ui-surface-backdrop-blur: 0px;
|
||||
--ui-control-bg: var(--color-background);
|
||||
--ui-control-border: var(--color-foreground);
|
||||
--ui-control-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 30%, transparent);
|
||||
--ui-control-radius: 0px;
|
||||
--ui-ornament-opacity: 0.2;
|
||||
--ui-ornament-mix: multiply;
|
||||
}
|
||||
Reference in New Issue
Block a user