Add harness workflow and Material showcase design system

This commit is contained in:
2026-03-23 17:30:30 +08:00
parent c570431dba
commit 5d02bf9df4
46 changed files with 3343 additions and 1068 deletions
+64
View File
@@ -0,0 +1,64 @@
name: Harness Validate
on:
pull_request:
types:
- opened
- reopened
- synchronize
- ready_for_review
push:
branches:
- main
permissions:
contents: read
jobs:
harness-validate:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.25.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Determine harness diff range
id: range
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
echo "from=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
else
echo "from=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
fi
echo "to=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Show selected harness suites
run: pnpm harness:select -- --from "${{ steps.range.outputs.from }}" --to "${{ steps.range.outputs.to }}"
- name: Run changed harness suites on pull requests
if: github.event_name == 'pull_request'
run: pnpm harness:validate:changed -- --from "${{ steps.range.outputs.from }}" --to "${{ steps.range.outputs.to }}"
- name: Run PR harness suite on main
if: github.event_name == 'push'
run: pnpm harness:validate:pr
+34
View File
@@ -24,10 +24,44 @@ Sub-agents should use this context policy:
- Keep shared integration files owned by the main thread whenever possible - Keep shared integration files owned by the main thread whenever possible
- If a sub-agent needs a shared dependency or a shared export change, have it report that back rather than editing unrelated files - If a sub-agent needs a shared dependency or a shared export change, have it report that back rather than editing unrelated files
## Harness Workflow
Agents should treat the following files as the default system of record before making non-trivial
changes:
- `DESIGN.md`
- `README.md`
- `CONTRIBUTING.md`
- `roadmap.md`
- `packages/ui/src/lib/contracts.ts`
- `apps/docs/src/component-authoring.stories.tsx`
- `docs/harness-engineering.md`
- `docs/orchestration.md`
When work spans multiple surfaces or changes public behavior, start by creating or updating an
execution plan in `docs/exec-plans/`.
Prefer the narrowest useful harness suite while working:
- `pnpm harness:select`
- `pnpm harness:validate:changed`
- `pnpm harness:validate:static`
- `pnpm harness:validate:component`
- `pnpm harness:validate:docs`
- `pnpm harness:validate:docs-smoke`
- `pnpm harness:validate:consumers`
- `pnpm harness:validate:pr`
- `pnpm harness:validate:release`
Every suite writes a JSON report under `.artifacts/harness/`.
Worktree-backed orchestration runs use `pnpm harness:orch -- <orch command>` and store state under
`.artifacts/orch/`.
## Priority ## Priority
If there is any conflict between older default delegation habits and this file: If there is any conflict between older default delegation habits and this file:
- follow `DESIGN.md` for the active visual language and motion direction
- follow this file for sub-agent model selection - follow this file for sub-agent model selection
- follow this file for sub-agent reasoning effort - follow this file for sub-agent reasoning effort
- follow this file for context fallback behavior - follow this file for context fallback behavior
+27 -5
View File
@@ -8,19 +8,23 @@ discipline instead of introducing parallel patterns.
Read the current contract and docs baseline first: Read the current contract and docs baseline first:
- `DESIGN.md`
- `roadmap.md` - `roadmap.md`
- `packages/ui/src/lib/contracts.ts` - `packages/ui/src/lib/contracts.ts`
- `apps/docs/src/component-authoring.stories.tsx` - `apps/docs/src/component-authoring.stories.tsx`
- `docs/harness-engineering.md`
- `docs/orchestration.md` when the work may be split across agents
Then inspect the closest existing component before adding a new one. Then inspect the closest existing component before adding a new one.
## Default workflow ## Default workflow
1. Confirm the component or change fits the current system layers. 1. Create or update an execution plan in `docs/exec-plans/` when the change is non-trivial.
2. Reuse the existing contract helpers, slot names, state naming, and variant conventions. 2. Confirm the component or change fits the current system layers.
3. Add or update Storybook stories so behavior is reviewable. 3. Reuse the existing contract helpers, slot names, state naming, and variant conventions.
4. Add or update tests before treating the component as done. 4. Add or update Storybook stories so behavior is reviewable.
5. Run the relevant validation commands locally. 5. Add or update tests before treating the component as done.
6. Run the relevant validation commands locally.
## Authoring rules ## Authoring rules
@@ -107,6 +111,17 @@ pnpm typecheck
pnpm test pnpm test
``` ```
Harness shortcuts:
```bash
pnpm harness:select
pnpm harness:validate:static
pnpm harness:validate:changed
pnpm harness:validate:component
pnpm harness:validate:docs
pnpm harness:validate:consumers
```
Docs and smoke checks: Docs and smoke checks:
```bash ```bash
@@ -115,6 +130,13 @@ pnpm build:docs
pnpm test:e2e:smoke pnpm test:e2e:smoke
``` ```
Broader gates:
```bash
pnpm harness:validate:pr
pnpm harness:validate:release
```
## Practical repo guidance ## Practical repo guidance
- Keep shared integration points small. If you only need a new component, avoid unrelated changes. - Keep shared integration points small. If you only need a new component, avoid unrelated changes.
+290
View File
@@ -0,0 +1,290 @@
# Design System: Cadence UI Material Runtime
**Repository:** cadence-ui
**Status:** Active direction as of 2026-03-23
## 1. Visual Theme & Atmosphere
Cadence UI should feel like a **softly lit Material You showcase**, not a generic dashboard kit
and not a rotating gallery of unrelated skins. The visual language is **pastel-tonal, ambient,
rounded, and slightly theatrical**, borrowing the warmth and optimism of Google's Material You
presentations while staying usable as a real product UI system.
The overall mood is **luminous and calm**. Surfaces should feel like layered slabs of tinted light:
cream canvas, lilac containers, sage accents, and soft violet action tones. The system should feel
**premium without becoming severe**, **expressive without becoming noisy**, and **mobile-informed
without becoming toy-like**.
The experience should evoke the feeling of a polished launch demo:
- soft environmental glow instead of hard contrast
- large rounded geometry instead of sharp technical edges
- tonal surfaces instead of flat white cards
- gentle spatial motion instead of bouncy micro-interaction clutter
- one coherent design language instead of multiple competing visual personalities
**Key characteristics:**
- Warm cream backgrounds with subtle tinted atmospheric light
- Rounded "slab" components with generous radii
- Tonal color relationships derived from a single seed color
- Pastel purple and sage-green emphasis by default
- Mobile-product elegance scaled to desktop docs and app surfaces
- Motion that feels staged, floaty, and spatial rather than mechanical
## 2. Color Palette & Roles
Cadence UI is **dynamic-color first**. New screens should be described in terms of **roles**, not
hardcoded one-off fills. The seed color may change, but the role structure should stay stable.
### Default Seed Presets
- **Violet Seed** (`#6750A4`) - the default Material baseline. Use when no stronger palette
choice exists.
- **Jade Seed** (`#0B8F83`) - cooler, calmer, more aquatic. Good for operational or wellness-like
product surfaces.
- **Sunset Seed** (`#B75A46`) - warmer and more human. Good for editorial, consumer, or
lifestyle-heavy screens.
### Default Tonal Foundation
These are the current default fallback values when the active palette is not overridden:
- **Warm Cream Canvas** (`hsl(22 18% 96%)`) - primary page background
- **Soft Surface Cream** (`hsl(22 12% 94%)`) - general surface background
- **Lilac Mist Surface** (`hsl(274 18% 92%)`) - default tonal container
- **Lilac Lifted Surface** (`hsl(274 22% 89%)`) - elevated tonal container
- **Sage Mist Surface** (`hsl(117 18% 86%)`) - strongest supporting container / accent slab
- **Ink Violet Text** (`hsl(259 6% 15%)`) - primary text and icon color
- **Muted Violet Text** (`hsl(259 10% 38%)`) - secondary text
- **Primary Violet** (`hsl(264 38% 45%)`) - active emphasis and focus color
- **Primary Violet Container** (`hsl(270 34% 87%)`) - filled action and highlighted slab
- **Tertiary Sage** (`hsl(128 18% 40%)`) - supporting accent color
- **Tertiary Sage Container** (`hsl(112 20% 86%)`) - soft secondary accent background
- **Outline Violet Gray** (`hsl(259 10% 56%)`) - structural outline color
- **Outline Variant** (`hsl(259 10% 84%)`) - low-emphasis borders and separators
### Role Rules
- **Backgrounds** should read as tinted light, never as flat white.
- **Containers** should come from surface-container roles, not arbitrary translucent cards.
- **Primary actions** should usually use `primary-container` rather than a fully saturated fill.
- **Secondary emphasis** should prefer sage/tertiary-container instead of introducing a new accent.
- **Borders** should be whisper-soft or transparent. Hard dividers are the exception, not the default.
- **Errors** should use the error container family rather than loud red fills.
### Dynamic Color Principle
When generating new screens, treat color like this:
1. Pick a seed color.
2. Generate a full tonal family from that seed.
3. Map the resulting tones to stable semantic roles.
4. Keep the component geometry and motion language unchanged.
Do **not** solve personalization by introducing a new skin. Personalization comes from **seed-driven
tonal shifts**, not from changing the entire design language.
## 3. Typography Rules
**Primary Font Family:** Google Sans Text / Google Sans / Roboto Flex / Roboto
**Display Font Family:** Google Sans Display / Google Sans / Roboto Flex / Roboto
The typography should feel **friendly, modern, and softly engineered**. It should not feel
editorial-serif, brutalist, developer-minimal, or luxury-fashion. The character is **rounded,
high-legibility, and quietly optimistic**.
### Hierarchy & Weights
- **Display Headlines:** Semibold weight, `clamp(2.75rem, 4vw, 4.75rem)`, tight tracking
(`-0.02em`), line-height `1.1`
- **Primary Section Headlines:** Semibold weight, `2.25rem` or `1.75rem`, line-height `1.1-1.3`
- **Card / Panel Titles:** Semibold weight, `1.375rem`, line-height `1.3`
- **Body Text:** Regular weight, `1rem`, line-height `1.5`
- **Supporting Text:** Regular weight, `0.875rem`, line-height `1.5`
- **Eyebrow / Small Label Text:** `0.75rem` or `0.875rem`, uppercase only when used sparingly,
tracking around `0.12em`
- **Buttons:** Medium to semibold presence through weight and shape, never all-caps shouting
### Typography Principles
- Headline scale should feel **heroic but rounded**, not sharp or corporate.
- Body copy should feel **quiet and readable**, letting color and space carry the emotional load.
- Eyebrow text is allowed, but only as a small framing device.
- Avoid serif display typography, condensed newspaper styling, and overly technical monospace accents.
- Large headlines should often be **left-aligned** and allowed to breathe.
## 4. Component Stylings
### Buttons
- **Shape:** fully pill or near-pill; default radius should feel almost capsule-like
- **Primary Button:** use `primary-container` with darker on-primary-container text
- **Secondary Button:** use the sage / tertiary container family
- **Ghost Button:** no visible container at rest; reveal a tonal hover plate on interaction
- **Subtle Button:** pale lifted slab, close to the surrounding tonal field
- **Hover Behavior:** gentle lift (`translateY(-1px)`), slight scale, soft shadow bloom
- **Press Behavior:** subtle compression, never dramatic squash
- **Tone:** polished and tactile, not loud, glossy, metallic, or game-like
### Cards & Surfaces
- **Corner Radius:** large and soft; standard cards should feel more like 28px slabs than desktop cards
- **Borders:** usually transparent
- **Depth:** use soft diffused shadow and tonal contrast before introducing visible strokes
- **Accent Card:** use primary-container tinting, never a neon or glass effect
- **Interactive Cards:** lift gently and scale microscopically; feel buoyant, not jumpy
### Inputs
- **Shape:** rounded but not full-pill in most cases
- **Background:** lifted tonal field rather than pure white
- **Border:** often transparent by default
- **Focus State:** soft violet glow ring plus tonal shadow bloom
- **Placeholder:** muted violet-gray
- **Overall Feel:** touch-friendly, calm, and integrated into the tonal field
### Panels, Dialogs, Popovers
- **Background:** elevated tonal container with slightly brighter internal light
- **Border:** transparent by default
- **Shadow:** deeper than cards, but still diffused and soft
- **Overlay:** blurred and dimmed, never harsh black
- **Motion:** smooth rise/fade with restrained speed; should feel spatial and deliberate
### Switches & Toggles
- **Track:** soft tonal channel
- **Checked Track:** primary-container, not fully saturated primary
- **Thumb:** bright neutral at rest, richer violet when active
- **Behavior:** smooth slide with calm easing; no spring snap
### Skeletons & Loaders
- **Skeleton Color:** muted tonal slab
- **Shimmer:** soft highlight pass that feels like light moving over frosted material
- **Spinners:** clean and minimal; motion should feel controlled and premium
## 5. Motion Language
Cadence UI should use **one motion language** plus a **static accessibility fallback**. Motion is
not a style pack. It is part of how the system communicates state and hierarchy.
### Motion Personality
- Calm, floaty, and spatial
- Slightly cinematic in hero/showcase moments
- Never bouncy, elastic, or playful-for-its-own-sake
- Built on ease-out curves that feel like soft deceleration
### Timing Guidance
- **Instant feedback:** ~120ms
- **Core state change:** ~180ms
- **Larger surface movement:** ~280ms
- **Deliberate ambient choreography:** ~360ms and above
### Approved Motion Patterns
- Soft rise / fade for surface entry
- Hover lift with small shadow bloom
- Press compression with minimal scale-down
- Slow floating or drifting ambient motion for showcase-only compositions
- Breathing accent glows or tonal pulses for decorative context
### Motion Rules
- Animate **transform**, **opacity**, and **shadow**; avoid layout-heavy animation
- Ambient motion belongs in showcase or hero surfaces, not on every control
- Exit motion should be faster than enter motion
- Respect reduced/static motion at all times
- Never use bounce, elastic easing, jitter, or constant busy loops across core UI
## 6. Layout Principles
### Structural Direction
Cadence UI layouts should feel like a **mobile-first visual language expanded to desktop**. Even on
large canvases, interfaces should maintain the rounded, stacked, tonal quality of contemporary
mobile surfaces.
### Spacing & Rhythm
- Use generous spacing to create a sense of premium calm
- Tighten within components, relax between sections
- Default spacing should feel deliberate, not grid-default or generic Tailwind spacing everywhere
- Prefer larger top-level section gaps than dense dashboard compression
### Alignment
- Headlines and body content should generally be left-aligned
- Centering is reserved for hero/showcase moments and empty states
- Use layered blocks and offset compositions when you need drama, not arbitrary asymmetry
### Showcase / Marketing-like Surfaces
For high-visibility demo pages:
- Use environmental light pools and blurred radial tints
- Layer device-like slabs or floating cards with overlap
- Let one hero composition dominate rather than filling the canvas with equal-weight content
- Create a clear front plane, middle plane, and background glow plane
### Product / Application Surfaces
For practical product screens:
- Use tonal grouping instead of borders to define regions
- Keep hierarchy obvious in under two seconds
- Maintain touch-friendly control sizes and generous internal padding
- Avoid the generic "card grid + icon + heading + paragraph" AI layout pattern
## 7. Design System Notes for Stitch / AI Generation
When using Stitch or any other AI design generator, this file should be treated as the
**source of truth for design direction**.
### Language to Use
Use phrases like:
- "softly lit Material You showcase"
- "pastel-tonal slabs with large rounded geometry"
- "warm cream canvas with lilac and sage tonal containers"
- "dynamic color derived from a single seed"
- "ambient spatial motion with restrained hover lift"
Avoid phrases like:
- "multiple skins"
- "glassmorphism"
- "neon"
- "cyber"
- "dark dashboard"
- "minimal monochrome admin panel"
- "brutalist"
### Component Prompt Examples
- "Create a dashboard hero using large rounded tonal slabs, warm cream background, lilac and sage containers, and softly staged Material motion."
- "Design a primary action button using a primary-container fill, dark violet text, pill geometry, and a gentle hover lift."
- "Build a settings card with a large 28px slab radius, transparent border, soft diffused shadow, and a tonal input field embedded into the same color family."
- "Generate a dialog that feels like an elevated Material container, with blurred overlay, soft entry rise, and large rounded corners."
### Iteration Rules
When refining screens:
1. Change one layer at a time: color, spacing, geometry, or motion.
2. Preserve the single Material language.
3. Prefer changing tonal roles before changing component structure.
4. Prefer changing seed color before inventing a new theme family.
5. If a screen starts to look like a different design system, pull it back.
### Non-Negotiables
- One visual language
- Dynamic color from a seed
- Tonal surfaces over flat white cards
- Large radii
- Calm spatial motion
- Reduced/static fallback
This repository should feel like **one memorable system**, not a catalog of styling experiments.
+44 -1
View File
@@ -18,6 +18,7 @@ default styling with its own tokens, motion recipes, and component contract.
- The public UI surface now includes the core form and overlay set plus advanced patterns such as `DataTable`, `Command`, `Combobox`, `Sheet`, and `EmptyState`. - The public UI surface now includes the core form and overlay set plus advanced patterns such as `DataTable`, `Command`, `Combobox`, `Sheet`, and `EmptyState`.
- The default distribution path is package-first: `@ai-ui/ui` and `@ai-ui/tokens` are versioned and validated for package consumption. - The default distribution path is package-first: `@ai-ui/ui` and `@ai-ui/tokens` are versioned and validated for package consumption.
- The internal source-copy registry flow remains available as an optional mode for teams that want local ownership of copied component source. - The internal source-copy registry flow remains available as an optional mode for teams that want local ownership of copied component source.
- The active visual direction is documented in [DESIGN.md](/Users/xd/project/cadence-ui/DESIGN.md): a single Material You inspired language with dynamic color, tonal surfaces, large radii, and one shared motion system.
## System principles ## System principles
@@ -27,6 +28,20 @@ default styling with its own tokens, motion recipes, and component contract.
- Accessibility by default: keyboard, focus, ARIA, and reduced motion are baseline expectations. - Accessibility by default: keyboard, focus, ARIA, and reduced motion are baseline expectations.
- Motion with purpose: animation should communicate state and hierarchy, not decorate at random. - Motion with purpose: animation should communicate state and hierarchy, not decorate at random.
## System Of Record
When changing public visuals, docs, or interaction behavior, treat these files as the baseline
context before making non-trivial changes:
- [DESIGN.md](/Users/xd/project/cadence-ui/DESIGN.md)
- [README.md](/Users/xd/project/cadence-ui/README.md)
- [CONTRIBUTING.md](/Users/xd/project/cadence-ui/CONTRIBUTING.md)
- [roadmap.md](/Users/xd/project/cadence-ui/roadmap.md)
- [packages/ui/src/lib/contracts.ts](/Users/xd/project/cadence-ui/packages/ui/src/lib/contracts.ts)
- [apps/docs/src/component-authoring.stories.tsx](/Users/xd/project/cadence-ui/apps/docs/src/component-authoring.stories.tsx)
- [docs/harness-engineering.md](/Users/xd/project/cadence-ui/docs/harness-engineering.md)
- [docs/orchestration.md](/Users/xd/project/cadence-ui/docs/orchestration.md)
## Getting started ## Getting started
Requirements: Requirements:
@@ -103,7 +118,7 @@ import { Button } from "@ai-ui/ui";
This keeps the app on one UI package import path while still pulling in token and skin This keeps the app on one UI package import path while still pulling in token and skin
styles together. Consumers that want lower-level control can still import styles together. Consumers that want lower-level control can still import
`@ai-ui/tokens/styles.css` and `@ai-ui/ui/skins.css` separately. If you need token helpers `@ai-ui/tokens/styles.css` and `@ai-ui/ui/skins.css` separately. If you need token helpers
such as `setTheme`, add `@ai-ui/tokens` directly as well. such as `setTheme` or `setDynamicColor`, add `@ai-ui/tokens` directly as well.
If you need source ownership instead of package upgrades, use the optional registry If you need source ownership instead of package upgrades, use the optional registry
installer to copy component source into another project: installer to copy component source into another project:
@@ -166,6 +181,34 @@ uses:
- Playwright smoke coverage for core Storybook flows - Playwright smoke coverage for core Storybook flows
- Storybook a11y checks as part of the docs review surface - Storybook a11y checks as part of the docs review surface
## Harness engineering
Cadence UI now includes a first-pass harness workflow for agent-friendly engineering. The goal is
to make the repository easier to understand, plan against, and validate mechanically.
- System guidance lives in [docs/harness-engineering.md](/Users/xd/project/cadence-ui/docs/harness-engineering.md).
- Non-trivial work should start with an execution plan under
[docs/exec-plans](/Users/xd/project/cadence-ui/docs/exec-plans/README.md).
- Shared validation suites are exposed through the root `pnpm harness:*` scripts.
- Worktree-oriented orchestration defaults live in
[docs/orchestration.md](/Users/xd/project/cadence-ui/docs/orchestration.md).
Useful commands:
```bash
pnpm harness:select
pnpm harness:suites
pnpm harness:validate:static
pnpm harness:validate:changed
pnpm harness:validate:component
pnpm harness:validate:docs
pnpm harness:validate:docs-smoke
pnpm harness:validate:consumers
pnpm harness:validate:pr
pnpm harness:validate:release
pnpm harness:orch -- status --run <run-id>
```
## Contributing ## Contributing
Read [CONTRIBUTING.md](/Users/xd/project/cadence-ui/CONTRIBUTING.md) before adding or Read [CONTRIBUTING.md](/Users/xd/project/cadence-ui/CONTRIBUTING.md) before adding or
+5 -22
View File
@@ -1,12 +1,7 @@
import "../src/preview.css"; import "../src/preview.css";
import type { Preview } from "@storybook/react"; import type { Preview } from "@storybook/react";
import { import { defaultSkin, setSkin } from "@ai-ui/ui";
defaultSkin,
setSkin,
skinDetails,
skinNames
} from "@ai-ui/ui";
import { import {
defaultMotionMode, defaultMotionMode,
defaultTheme, defaultTheme,
@@ -21,7 +16,7 @@ import {
const preview: Preview = { const preview: Preview = {
globalTypes: { globalTypes: {
theme: { theme: {
description: "Preview theme", description: "Preview dynamic seed preset",
toolbar: { toolbar: {
icon: "paintbrush", icon: "paintbrush",
dynamicTitle: true, dynamicTitle: true,
@@ -32,7 +27,7 @@ const preview: Preview = {
} }
}, },
motionMode: { motionMode: {
description: "Preview motion mode", description: "Preview motion baseline",
toolbar: { toolbar: {
icon: "contrast", icon: "contrast",
dynamicTitle: true, dynamicTitle: true,
@@ -41,22 +36,10 @@ const preview: Preview = {
title: motionModeDetails[modeName].label title: motionModeDetails[modeName].label
})) }))
} }
},
skin: {
description: "Preview component skin",
toolbar: {
icon: "mirror",
dynamicTitle: true,
items: skinNames.map((skinName) => ({
value: skinName,
title: skinDetails[skinName].label
}))
}
} }
}, },
initialGlobals: { initialGlobals: {
motionMode: defaultMotionMode, motionMode: defaultMotionMode,
skin: defaultSkin,
theme: defaultTheme theme: defaultTheme
}, },
parameters: { parameters: {
@@ -82,11 +65,11 @@ const preview: Preview = {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
setTheme(context.globals.theme ?? defaultTheme); setTheme(context.globals.theme ?? defaultTheme);
setMotionMode(context.globals.motionMode ?? defaultMotionMode); setMotionMode(context.globals.motionMode ?? defaultMotionMode);
setSkin(context.globals.skin ?? defaultSkin); setSkin(defaultSkin);
document.body.dataset.theme = context.globals.theme ?? defaultTheme; document.body.dataset.theme = context.globals.theme ?? defaultTheme;
document.body.dataset.motion = context.globals.motionMode ?? defaultMotionMode; document.body.dataset.motion = context.globals.motionMode ?? defaultMotionMode;
document.body.dataset.skin = context.globals.skin ?? defaultSkin; document.body.dataset.skin = defaultSkin;
} }
return Story(); return Story();
+36 -5
View File
@@ -146,11 +146,42 @@ export const Motion: Story = {
} }
}, },
render: () => ( render: () => (
<div className="grid w-[720px] gap-3 sm:grid-cols-2"> <div className="relative grid w-[840px] gap-5 overflow-hidden rounded-[2.2rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_84%,white_16%),color-mix(in_oklch,var(--color-background)_90%,white_10%))] p-6 shadow-[0_24px_72px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] sm:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<Button>Premium primary</Button> <div className="pointer-events-none absolute inset-0">
<Button variant="subtle">Subtle surface</Button> <div className="motion-drift absolute left-[-2rem] top-[-2rem] h-28 w-28 rounded-full bg-[color-mix(in_oklch,var(--color-primary-container)_62%,transparent)] blur-3xl" />
<Button variant="secondary">Secondary action</Button> <div className="motion-breathe absolute right-0 top-10 h-24 w-24 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary-container)_58%,transparent)] blur-3xl" />
<Button loading>Saving changes</Button> </div>
<div className="relative grid gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Material motion deck
</p>
<h3 className="max-w-md text-3xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Buttons should feel like touchable capsules floating over tinted light.
</h3>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Button>Premium primary</Button>
<Button variant="subtle">Subtle surface</Button>
<Button variant="secondary">Secondary action</Button>
<Button loading>Saving changes</Button>
</div>
</div>
<div className="relative flex items-center justify-center">
<div className="motion-float absolute left-5 top-8 rounded-full border border-white/45 bg-[color-mix(in_oklch,var(--color-surface-container-low)_78%,white_22%)] px-4 py-2 text-xs font-medium tracking-[0.14em] text-[var(--color-muted-foreground)] shadow-[0_12px_30px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]">
SOFT LIFT
</div>
<div className="motion-float-delayed absolute bottom-6 right-6 rounded-full bg-[var(--color-primary-container)] px-4 py-2 text-sm font-medium text-[var(--color-on-primary-container)] shadow-[0_14px_28px_color-mix(in_oklch,var(--color-primary)_12%,transparent)]">
PRESSED
</div>
<div className="grid w-full max-w-[16rem] gap-3 rounded-[2rem] border border-white/40 bg-[color-mix(in_oklch,var(--color-surface-container-low)_82%,white_18%)] p-4 shadow-[0_24px_60px_color-mix(in_oklch,var(--color-primary)_12%,transparent)]">
<div className="h-28 rounded-[1.5rem] bg-[linear-gradient(165deg,color-mix(in_oklch,var(--color-primary-container)_88%,white_12%),color-mix(in_oklch,var(--color-tertiary-container)_82%,white_18%))]" />
<Button>Shop set</Button>
<Button variant="ghost">Maybe later</Button>
</div>
</div>
</div> </div>
) )
}; };
+40 -15
View File
@@ -52,21 +52,46 @@ export const Playground: Story = {
export const Grid: Story = { export const Grid: Story = {
render: () => ( render: () => (
<div className="grid w-[760px] gap-4 md:grid-cols-2"> <div className="relative grid w-[940px] gap-6 overflow-hidden rounded-[2.3rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_82%,white_18%),color-mix(in_oklch,var(--color-background)_88%,white_12%))] p-6 shadow-[0_24px_72px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] md:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<Card> <div className="pointer-events-none absolute inset-0">
<CardHeader> <div className="motion-drift absolute left-[-1.5rem] top-6 h-24 w-24 rounded-full bg-[color-mix(in_oklch,var(--color-primary-container)_58%,transparent)] blur-3xl" />
<CardTitle>Default tone</CardTitle> <div className="motion-breathe absolute right-10 top-0 h-20 w-20 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary-container)_52%,transparent)] blur-3xl" />
<CardDescription>Standard elevated panel for data and form sections.</CardDescription> </div>
</CardHeader>
<CardContent>Reliable baseline for most admin surfaces.</CardContent> <div className="relative grid gap-4 self-start">
</Card> <div className="space-y-2">
<Card interactive tone="accent"> <p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
<CardHeader> Showcase slabs
<CardTitle>Interactive accent</CardTitle> </p>
<CardDescription>Hover-capable treatment for navigable cards.</CardDescription> <h3 className="max-w-sm text-3xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
</CardHeader> Cards should feel like lit objects on a display plinth, not admin rectangles.
<CardContent>Use sparingly for overview screens with clear primary actions.</CardContent> </h3>
</Card> </div>
<div className="grid gap-3 rounded-[1.6rem] bg-[color-mix(in_oklch,var(--color-surface-container)_82%,white_18%)] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.4)]">
<div className="h-40 rounded-[1.4rem] bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_78%,white_22%),color-mix(in_oklch,var(--color-tertiary-container)_74%,white_26%))]" />
<div className="grid gap-2">
<span className="h-3 w-24 rounded-full bg-[var(--color-foreground)]/12" />
<span className="h-3 w-40 rounded-full bg-[var(--color-foreground)]/9" />
</div>
</div>
</div>
<div className="relative grid gap-4">
<Card className="motion-float">
<CardHeader>
<CardTitle>Default tone</CardTitle>
<CardDescription>Standard elevated panel for data and form sections.</CardDescription>
</CardHeader>
<CardContent>Reliable baseline for most admin surfaces.</CardContent>
</Card>
<Card className="motion-float-delayed justify-self-end md:w-[88%]" interactive tone="accent">
<CardHeader>
<CardTitle>Interactive accent</CardTitle>
<CardDescription>Hover-capable treatment for navigable cards.</CardDescription>
</CardHeader>
<CardContent>Use sparingly for overview screens with clear primary actions.</CardContent>
</Card>
</div>
</div> </div>
) )
}; };
@@ -1,6 +1,5 @@
import { import {
Badge, Badge,
Button,
ContextMenu, ContextMenu,
ContextMenuCheckboxItem, ContextMenuCheckboxItem,
ContextMenuContent, ContextMenuContent,
@@ -9,7 +8,6 @@ import {
ContextMenuRadioGroup, ContextMenuRadioGroup,
ContextMenuRadioItem, ContextMenuRadioItem,
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub, ContextMenuSub,
ContextMenuSubContent, ContextMenuSubContent,
ContextMenuSubTrigger, ContextMenuSubTrigger,
@@ -414,6 +414,7 @@ function DataTablePlayground() {
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
className="border-[var(--color-border-strong)] bg-[var(--color-background)] text-[var(--color-foreground)] hover:bg-[var(--color-surface)]"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={resetView} onClick={resetView}
@@ -8,7 +8,6 @@ import {
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
+6 -1
View File
@@ -173,7 +173,12 @@ function LaunchSettingsForm() {
> >
Reset Reset
</Button> </Button>
<Button type="submit">Save settings</Button> <Button
className="bg-[var(--color-foreground)] text-[var(--color-background)] hover:bg-[color-mix(in_oklch,var(--color-foreground)_88%,white_12%)]"
type="submit"
>
Save settings
</Button>
</div> </div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4"> <div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
+6 -5
View File
@@ -8,14 +8,15 @@ function FoundationShowcase() {
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6"> <div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="max-w-3xl space-y-3"> <div className="max-w-3xl space-y-3">
<p className="text-sm uppercase tracking-[0.28em] text-[var(--color-muted-foreground)]"> <p className="text-sm uppercase tracking-[0.28em] text-[var(--color-muted-foreground)]">
AI UI / Phase 0 AI UI / Foundation
</p> </p>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl"> <h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
Monorepo scaffolding for a source-owned component system. Source-owned infrastructure for a Material-first component system.
</h1> </h1>
<p className="max-w-2xl text-base leading-7 text-[var(--color-muted-foreground)] sm:text-lg"> <p className="max-w-2xl text-base leading-7 text-[var(--color-muted-foreground)] sm:text-lg">
The repo now has workspace packages for tokens, UI utilities, and docs. The workspace foundation now supports dynamic seed color, a shared UI package,
The next phase can focus on component contracts instead of repo setup. and a Storybook review surface. The next work can stay focused on component
quality instead of repo setup.
</p> </p>
</div> </div>
@@ -46,7 +47,7 @@ function FoundationShowcase() {
<aside className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-xs)]"> <aside className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-xs)]">
<div className="space-y-3"> <div className="space-y-3">
<h2 className="text-xl font-semibold">Theme baseline</h2> <h2 className="text-xl font-semibold">Seed presets</h2>
<ul className="space-y-2 text-sm text-[var(--color-muted-foreground)]"> <ul className="space-y-2 text-sm text-[var(--color-muted-foreground)]">
{themeNames.map((themeName) => ( {themeNames.map((themeName) => (
<li key={themeName} className="flex items-center justify-between gap-4"> <li key={themeName} className="flex items-center justify-between gap-4">
+267 -162
View File
@@ -1,159 +1,196 @@
import type { CSSProperties } from "react";
import { useState } from "react"; import { useState } from "react";
import { import {
Button, Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input, Input,
Skeleton, Skeleton,
Switch, Switch,
defaultSkin, defaultSkin
skinDetails,
skinNames,
type SkinName
} from "@ai-ui/ui"; } from "@ai-ui/ui";
import { import {
createDynamicColorVariables,
defaultMotionMode, defaultMotionMode,
defaultTheme, defaultTheme,
motionModeDetails,
themeDetails,
themeNames,
type MotionModeName, type MotionModeName,
type ThemeName type ThemeName
} from "@ai-ui/tokens"; } from "@ai-ui/tokens";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
type StyleContractShowcaseProps = { type MaterialRuntimeShowcaseProps = {
motionMode: MotionModeName; motionMode: MotionModeName;
skin: SkinName;
theme: ThemeName; theme: ThemeName;
}; };
function RuntimeBadge({ label, value }: { label: string; value: string }) { function RuntimeBadge({ label, value }: { label: string; value: string }) {
return ( 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)]"> <div className="rounded-[var(--radius-full)] border border-[var(--color-outline-variant)] bg-[var(--color-surface-container)] 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> <span className="mr-2 text-[var(--color-foreground)]">{label}</span>
{value} {value}
</div> </div>
); );
} }
function SkinPanel({ function FloatingNote({
description, className,
name lines,
title
}: { }: {
description: string; className?: string;
name: SkinName; lines: string[];
title: string;
}) { }) {
const [enabled, setEnabled] = useState(name !== "minimal");
return ( return (
<article <article
data-skin={name} className={`grid gap-3 rounded-[1.4rem] border border-white/45 bg-[color-mix(in_oklch,var(--color-surface-container-low)_78%,white_22%)] p-4 shadow-[0_18px_40px_color-mix(in_oklch,var(--color-primary)_12%,transparent)] backdrop-blur-sm ${className ?? ""}`}
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"> <p className="text-[0.72rem] font-medium uppercase tracking-[0.16em] text-[var(--color-muted-foreground)]">
<div className="space-y-2"> {title}
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"> </p>
data-skin=&quot;{name}&quot; <div className="grid gap-2">
</p> {lines.map((line, index) => (
<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 <div
aria-hidden="true" key={line}
className="pointer-events-none absolute inset-x-4 top-0 h-12" className="h-2.5 rounded-full bg-[var(--color-foreground)]/10"
style={{ style={{ width: `${100 - index * 16}%` }}
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
aria-label={`${skinDetails[name].label} preview controls`}
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> </div>
</article> </article>
); );
} }
function StyleContractShowcase({ function ShowcasePhone({
motionMode, accentClassName,
skin, className,
theme eyebrow,
}: StyleContractShowcaseProps) { title
}: {
accentClassName: string;
className?: string;
eyebrow: string;
title: string;
}) {
return (
<article
className={`relative grid aspect-[0.53] w-[13.5rem] overflow-hidden rounded-[2.4rem] border border-white/50 bg-[color-mix(in_oklch,var(--color-surface-container-low)_82%,white_18%)] p-3 shadow-[0_28px_80px_color-mix(in_oklch,var(--color-primary)_14%,transparent)] ${className ?? ""}`}
>
<div className="flex items-center justify-between px-1 text-[0.6rem] font-medium text-[var(--color-muted-foreground)]">
<span>9:30</span>
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-foreground)]/70" />
<span className="h-1.5 w-4 rounded-full bg-[var(--color-foreground)]/50" />
</div>
</div>
<div className="mt-3 grid gap-3">
<div
className={`rounded-[1.8rem] p-5 text-[var(--color-foreground)] shadow-[inset_0_1px_0_rgba(255,255,255,0.45)] ${accentClassName}`}
>
<p className="text-[0.7rem] uppercase tracking-[0.16em] text-[var(--color-foreground)]/62">
{eyebrow}
</p>
<h3 className="mt-3 text-[2rem] font-semibold leading-[0.95] tracking-[-0.04em]">
{title}
</h3>
</div>
<div className="grid gap-2 rounded-[1.4rem] bg-[color-mix(in_oklch,var(--color-surface-container)_88%,white_12%)] p-3">
<div className="flex items-center gap-2">
<span className="size-8 rounded-[1rem] bg-[var(--color-tertiary-container)] motion-breathe" />
<div className="grid gap-1">
<span className="h-2.5 w-24 rounded-full bg-[var(--color-foreground)]/16" />
<span className="h-2.5 w-16 rounded-full bg-[var(--color-foreground)]/10" />
</div>
</div>
<div className="flex gap-2">
<span className="h-9 flex-1 rounded-[1.1rem] bg-[var(--color-primary-container)]/78" />
<span className="h-9 w-14 rounded-[1.1rem] bg-[var(--color-surface-container-highest)]" />
</div>
</div>
</div>
<div className="mt-auto flex items-center justify-between rounded-[1.2rem] bg-[color-mix(in_oklch,var(--color-surface-container)_72%,white_28%)] px-4 py-3 text-[0.72rem] text-[var(--color-muted-foreground)]">
<span>Home</span>
<span className="rounded-full bg-[var(--color-primary-container)] px-2 py-1 text-[var(--color-on-primary-container)]">
Flow
</span>
</div>
</article>
);
}
function SeedPanel({ name }: { name: ThemeName }) {
const [enabled, setEnabled] = useState(name !== "sunset");
const theme = themeDetails[name];
return (
<article
style={createDynamicColorVariables(theme.seed) as CSSProperties}
className="grid gap-4 rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)] bg-[var(--color-surface-container-low)] p-5 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]"
>
<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)]">
{name}
</p>
<div>
<h3 className="text-xl font-semibold">{theme.label}</h3>
<p className="mt-1 text-sm leading-6 text-[var(--color-muted-foreground)]">
{theme.note}
</p>
</div>
</div>
<span className="rounded-[var(--radius-full)] bg-[var(--color-secondary-container)] px-3 py-1 text-xs font-medium text-[var(--color-on-secondary-container)]">
{theme.seed}
</span>
</div>
<Card tone="accent">
<CardHeader>
<CardTitle>One style, many palettes</CardTitle>
<CardDescription>
The palette changes, but the component language stays recognizably Material.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-2">
<Input aria-label={`${name} preset input`} defaultValue="team@cadence.dev" />
<div className="flex items-center justify-between rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-4 py-3">
<span className="text-sm font-medium text-[var(--color-foreground)]">
Tonal preference
</span>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button>Filled action</Button>
<Button variant="secondary">Tonal action</Button>
<Button variant="subtle">Surface action</Button>
<Button variant="ghost">Text action</Button>
</div>
<Skeleton shape="line" />
</CardContent>
</Card>
</article>
);
}
function MaterialRuntimeShowcase({ motionMode, theme }: MaterialRuntimeShowcaseProps) {
return ( return (
<div className="min-h-screen bg-[var(--color-background)] px-6 py-10 text-[var(--color-foreground)] sm:px-10"> <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"> <div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<header className="max-w-4xl space-y-4"> <header className="max-w-4xl space-y-4">
<p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"> <p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
AI UI / Phase 1 AI UI / Material Runtime
</p> </p>
<h1 <h1
className="font-semibold tracking-[var(--tracking-tight)]" className="font-semibold tracking-[var(--tracking-tight)]"
@@ -163,35 +200,107 @@ function StyleContractShowcase({
lineHeight: "var(--leading-tight)" lineHeight: "var(--leading-tight)"
}} }}
> >
Runtime skin switching is now a first-class docs contract, even before Cadence UI now treats Material as the system language, with dynamic seed color
component recipes are extracted. and one consistent motion baseline.
</h1> </h1>
<p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]"> <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 The old multi-skin showcase has been collapsed into a single rounded, tonal
dedicated skin CSS entrypoint. Phase 2 will move component recipes onto this component system. Personalization now comes from seed color rather than
contract. competing style packs.
</p> </p>
</header> </header>
<section className="relative overflow-hidden rounded-[2rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_82%,white_18%),color-mix(in_oklch,var(--color-background)_86%,white_14%))] px-6 py-8 shadow-[0_28px_80px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] sm:px-8 lg:px-10">
<div className="pointer-events-none absolute inset-0">
<div className="motion-drift absolute left-[-4rem] top-[-3rem] h-40 w-40 rounded-full bg-[color-mix(in_oklch,var(--color-primary-container)_66%,transparent)] blur-3xl" />
<div className="motion-breathe absolute right-[-3rem] top-10 h-36 w-36 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary-container)_72%,transparent)] blur-3xl" />
<div className="motion-drift absolute bottom-[-4rem] left-1/3 h-44 w-44 rounded-full bg-[color-mix(in_oklch,var(--color-secondary-container)_72%,transparent)] blur-3xl" />
<div className="absolute inset-x-10 bottom-0 h-24 rounded-[2rem_2rem_0_0] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container)_40%,transparent),color-mix(in_oklch,var(--color-surface-container-high)_88%,white_12%))] blur-xl" />
</div>
<div className="relative grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_minmax(16rem,0.85fr)] lg:items-center">
<div className="space-y-5">
<div className="inline-flex items-center gap-2 rounded-full bg-[color-mix(in_oklch,var(--color-surface-container)_82%,white_18%)] px-4 py-2 text-sm font-medium text-[var(--color-muted-foreground)] shadow-[var(--shadow-xs)]">
<span className="motion-breathe size-2.5 rounded-full bg-[var(--color-primary)]" />
Material showcase mode
</div>
<div className="max-w-2xl space-y-4">
<h2 className="text-[clamp(2.2rem,5vw,4.6rem)] font-semibold leading-[0.94] tracking-[-0.05em]">
Softer slabs, tinted light, and motion that feels staged instead of flat.
</h2>
<p className="max-w-xl text-base leading-7 text-[var(--color-muted-foreground)] sm:text-lg">
This is the target mood for the system: pastel-tonal, editorial enough to
feel premium, but still obviously usable as a product surface.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button>Generate tonal palette</Button>
<Button variant="secondary">Preview motion</Button>
<Button variant="ghost">Inspect tokens</Button>
</div>
</div>
<div className="relative mx-auto flex h-[32rem] w-full max-w-[32rem] items-center justify-center">
<div className="absolute inset-x-3 bottom-2 h-12 rounded-[999px] bg-[color-mix(in_oklch,var(--color-primary)_16%,transparent)] blur-2xl" />
<div className="absolute inset-x-6 bottom-0 h-10 rounded-[1.6rem_1.6rem_0.9rem_0.9rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container)_70%,white_30%),color-mix(in_oklch,var(--color-surface-container-high)_82%,white_18%))] shadow-[inset_0_1px_0_rgba(255,255,255,0.6)]" />
<div className="absolute left-3 top-12 h-[20rem] w-[10.5rem] rounded-[2.6rem] border border-white/40 bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container)_72%,white_28%),color-mix(in_oklch,var(--color-surface-bright)_88%,white_12%))] shadow-[0_24px_64px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]" />
<div className="absolute right-4 top-8 h-[23rem] w-[11rem] rounded-[2.8rem] border border-white/40 bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-high)_76%,white_24%),color-mix(in_oklch,var(--color-surface-bright)_90%,white_10%))] shadow-[0_26px_70px_color-mix(in_oklch,var(--color-tertiary)_10%,transparent)]" />
<ShowcasePhone
accentClassName="bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_88%,white_12%),color-mix(in_oklch,var(--color-secondary-container)_82%,white_18%))]"
className="motion-float absolute left-0 top-10 -rotate-[10deg]"
eyebrow="Expressive type"
title="Move with tonal depth"
/>
<ShowcasePhone
accentClassName="bg-[linear-gradient(165deg,color-mix(in_oklch,var(--color-tertiary-container)_84%,white_16%),color-mix(in_oklch,var(--color-primary-container)_54%,white_46%))]"
className="motion-float-delayed relative z-10"
eyebrow="Dynamic color"
title="Palette from one seed"
/>
<ShowcasePhone
accentClassName="bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-secondary-container)_86%,white_14%),color-mix(in_oklch,var(--color-surface-container-highest)_64%,white_36%))]"
className="motion-float absolute right-0 top-14 rotate-[11deg]"
eyebrow="Calm feedback"
title="Motion with restraint"
/>
<FloatingNote
className="motion-float-delayed absolute left-2 top-3 z-20 w-36"
lines={["", "", ""]}
title="Launch deck"
/>
<FloatingNote
className="motion-float absolute bottom-16 right-4 z-20 w-40"
lines={["", "", "", ""]}
title="Material pulse"
/>
<div className="motion-breathe absolute left-[42%] top-8 z-20 rounded-full border border-white/50 bg-[color-mix(in_oklch,var(--color-primary-container)_72%,white_28%)] px-3 py-2 text-xs font-medium text-[var(--color-on-primary-container)] shadow-[0_12px_26px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]">
Dynamic color
</div>
</div>
</div>
</section>
<section className="flex flex-wrap gap-3"> <section className="flex flex-wrap gap-3">
<RuntimeBadge label="theme" value={theme} /> <RuntimeBadge label="theme" value={themeDetails[theme].label} />
<RuntimeBadge label="skin" value={skin} /> <RuntimeBadge label="motion" value={motionModeDetails[motionMode].label} />
<RuntimeBadge label="motion" value={motionMode} /> <RuntimeBadge label="skin" value={defaultSkin} />
</section> </section>
<section className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]"> <section className="grid gap-4 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]"> <article className="rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">What Phase 1 includes</h2> <h2 className="text-2xl font-semibold">Runtime contract</h2>
<div className="mt-5 grid gap-3"> <div className="mt-5 grid gap-3">
{[ {[
"A new runtime attribute: `data-skin`", "`setTheme(preset)` applies a named seed preset for docs and common app defaults.",
"Public helpers from `@ai-ui/ui` for skin names, defaults, and root updates", "`setDynamicColor(seed)` generates a full tonal palette from one color.",
"A dedicated `@ai-ui/ui/skins.css` entrypoint imported by the docs app", "`setSkin(\"material\")` remains as the stable UI runtime marker, but no longer branches into multiple aesthetics.",
"Storybook globals that apply theme, skin, and interactive/static motion mode together" "`setMotionMode(mode)` keeps one default motion language plus a static accessibility override."
].map((item) => ( ].map((item) => (
<div <div
key={item} key={item}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3" className="rounded-[var(--radius-sm)] border border-[var(--color-outline-variant)] bg-[var(--color-surface-container)] px-4 py-3"
> >
<p className="text-sm leading-6 text-[var(--color-foreground)]">{item}</p> <p className="text-sm leading-6 text-[var(--color-foreground)]">{item}</p>
</div> </div>
@@ -199,18 +308,18 @@ function StyleContractShowcase({
</div> </div>
</article> </article>
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]"> <article className="rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">What still waits for Phase 2</h2> <h2 className="text-2xl font-semibold">Material priorities</h2>
<div className="mt-5 grid gap-3"> <div className="mt-5 grid gap-3">
{[ {[
"Button, card, input, dialog, switch, and skeleton recipe extraction", "Dynamic color replaces fixed stylistic theme packs.",
"Skin-specific component semantic variables such as `--button-*` and `--panel-*`", "Tonal surfaces replace decorative gradients, blur, and ornamental skins.",
"A docs comparison page where existing components fully restyle under each skin", "Large radii and softer outlines create warmth without losing system discipline.",
"Consumer-facing polish after the runtime contract and docs surface are stable" "Motion stays predictable: expressive enough to communicate, restrained enough to stay calm."
].map((item) => ( ].map((item) => (
<div <div
key={item} key={item}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3" className="rounded-[var(--radius-sm)] border border-[var(--color-outline-variant)] bg-[var(--color-surface-container)] px-4 py-3"
> >
<p className="text-sm leading-6 text-[var(--color-foreground)]">{item}</p> <p className="text-sm leading-6 text-[var(--color-foreground)]">{item}</p>
</div> </div>
@@ -219,13 +328,9 @@ function StyleContractShowcase({
</article> </article>
</section> </section>
<section className="grid gap-4"> <section className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)_minmax(0,1fr)]">
{skinNames.map((name) => ( {themeNames.map((name) => (
<SkinPanel <SeedPanel key={name} name={name} />
key={name}
description={skinDetails[name].note}
name={name}
/>
))} ))}
</section> </section>
</div> </div>
@@ -234,34 +339,34 @@ function StyleContractShowcase({
} }
const meta = { const meta = {
title: "Foundation/Style Contract", title: "Foundation/Material Runtime",
component: StyleContractShowcase, component: MaterialRuntimeShowcase,
args: {
motionMode: defaultMotionMode,
skin: defaultSkin,
theme: defaultTheme
},
parameters: { parameters: {
docs: { docs: {
description: { description: {
component: component:
"Phase 1 adds the runtime style contract. Use the Storybook toolbar to switch the active `theme`, `skin`, and `motion` mode globally, or inspect the side-by-side nested `data-skin` panels below." "Use this page to review the new Material-centric runtime contract: one visual language, one motion baseline, and dynamic palette generation from a seed color."
} }
} },
}, layout: "fullscreen"
render: (_args, context) => ( }
<StyleContractShowcase } satisfies Meta<typeof MaterialRuntimeShowcase>;
motionMode={
(context.globals.motionMode 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; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Overview: Story = {}; export const Overview: Story = {
args: {
motionMode: defaultMotionMode,
theme: defaultTheme
},
render: (_args, context) => (
<MaterialRuntimeShowcase
motionMode={
(context.globals.motionMode as MotionModeName | undefined) ?? defaultMotionMode
}
theme={(context.globals.theme as ThemeName | undefined) ?? defaultTheme}
/>
)
};
+163 -170
View File
@@ -1,8 +1,5 @@
import { import type { CSSProperties } from "react";
motionModeDetails,
motionModeNames,
type MotionModeName
} from "@ai-ui/tokens";
import { import {
Button, Button,
Card, Card,
@@ -10,162 +7,152 @@ import {
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
Dialog, EmptyState,
DialogContent, EmptyStateActions,
DialogDescription, EmptyStateDescription,
DialogFooter, EmptyStateHeader,
DialogHeader, EmptyStateTitle,
DialogTitle, Input
DialogTrigger,
Input,
Skeleton,
Switch,
skinDetails,
skinNames,
type SkinName
} from "@ai-ui/ui"; } from "@ai-ui/ui";
import {
createDynamicColorVariables,
motionModeDetails,
motionModeNames,
themeDetails,
themeNames
} from "@ai-ui/tokens";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
function ClosePreviewIcon() { function MiniPhone({
className,
title
}: {
className?: string;
title: string;
}) {
return ( return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16"> <article
<path className={`grid aspect-[0.56] w-[10.5rem] overflow-hidden rounded-[2rem] border border-white/40 bg-[color-mix(in_oklch,var(--color-surface-container-low)_82%,white_18%)] p-3 shadow-[0_22px_58px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] ${className ?? ""}`}
d="m4.5 4.5 7 7m0-7-7 7" >
stroke="currentColor" <div className="flex items-center justify-between text-[0.58rem] font-medium text-[var(--color-muted-foreground)]">
strokeLinecap="round" <span>9:30</span>
strokeLinejoin="round" <span className="h-1.5 w-5 rounded-full bg-[var(--color-foreground)]/45" />
strokeWidth="1.75" </div>
/> <div className="mt-3 grid gap-3">
</svg> <div className="rounded-[1.4rem] bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_74%,white_26%),color-mix(in_oklch,var(--color-tertiary-container)_82%,white_18%))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.42)]">
<p className="text-[0.66rem] uppercase tracking-[0.14em] text-[var(--color-foreground)]/60">
M3 panel
</p>
<h3 className="mt-3 text-[1.3rem] font-semibold leading-[0.96] tracking-[-0.04em] text-[var(--color-foreground)]">
{title}
</h3>
</div>
<div className="grid gap-2 rounded-[1.2rem] bg-[color-mix(in_oklch,var(--color-surface-container)_86%,white_14%)] p-3">
<span className="h-8 rounded-[1rem] bg-[var(--color-surface-container-highest)]" />
<span className="h-8 rounded-[1rem] bg-[var(--color-secondary-container)]" />
</div>
</div>
<div className="mt-auto flex items-center justify-between rounded-[1rem] bg-[color-mix(in_oklch,var(--color-surface-container)_70%,white_30%)] px-3 py-2 text-[0.64rem] text-[var(--color-muted-foreground)]">
<span>Home</span>
<span>Feed</span>
<span>Save</span>
</div>
</article>
); );
} }
function RuntimePill({ children }: { children: React.ReactNode }) { function SurfaceCluster({
return ( motionMode,
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"> themeName
{children} }: {
</span> motionMode: (typeof motionModeNames)[number];
); themeName: (typeof themeNames)[number];
} }) {
const theme = themeDetails[themeName];
function PanelPreview() {
return ( return (
<div <article
className="grid gap-3 border p-4" data-motion={motionMode}
style={{ style={createDynamicColorVariables(theme.seed) as CSSProperties}
background: "var(--ui-panel-bg)", className={`grid gap-4 rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)] bg-[var(--color-surface-container-low)] p-5 shadow-[var(--shadow-sm)] ${motionMode === "interactive" ? "motion-float" : ""}`}
borderColor: "var(--ui-panel-border)",
borderRadius: "var(--ui-panel-radius)",
borderWidth: "var(--ui-panel-border-width)",
boxShadow: "var(--ui-panel-shadow)",
backdropFilter: "blur(var(--ui-panel-backdrop-blur))"
}}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<p className="text-sm font-semibold text-[var(--color-foreground)]"> <p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Dialog panel contract {motionModeDetails[motionMode].label}
</p>
<p className="mt-1 text-sm leading-6 text-[var(--color-muted-foreground)]">
Panel vars preview the dialog surface without opening an overlay in every
matrix cell.
</p> </p>
<h3 className="mt-1 text-xl font-semibold text-[var(--color-foreground)]">
{theme.label}
</h3>
</div> </div>
<Button aria-hidden="true" size="icon" tabIndex={-1} variant="ghost"> <span className="rounded-[var(--radius-full)] bg-[var(--color-secondary-container)] px-3 py-1 text-xs font-medium text-[var(--color-on-secondary-container)]">
<ClosePreviewIcon /> {theme.seed}
</Button> </span>
</div>
<div className="grid gap-2">
<Skeleton shape="line" />
<Skeleton shape="block" />
</div>
</div>
);
}
function ComparisonCell({
motionMode,
skin
}: {
motionMode: MotionModeName;
skin: SkinName;
}) {
return (
<section
className="grid gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]"
data-motion={motionMode}
data-skin={skin}
>
<div className="flex flex-wrap gap-2">
<RuntimePill>{motionModeDetails[motionMode].label}</RuntimePill>
<RuntimePill>{skinDetails[skin].label}</RuntimePill>
</div> </div>
<Card interactive tone="default"> <div className="relative flex items-center justify-center gap-3 rounded-[1.6rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container)_82%,white_18%))] px-3 py-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.4)]">
<div className="absolute left-6 top-4 h-12 w-12 rounded-full bg-[color-mix(in_oklch,var(--color-primary-container)_72%,transparent)] blur-2xl" />
<div className="absolute right-8 bottom-5 h-12 w-12 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary-container)_66%,transparent)] blur-2xl" />
<MiniPhone className={motionMode === "interactive" ? "motion-float -rotate-[8deg]" : "-rotate-[8deg]"} title="Soft motion" />
<MiniPhone className={motionMode === "interactive" ? "motion-float-delayed rotate-[7deg]" : "rotate-[7deg]"} title="Tonal lift" />
</div>
<div className="grid gap-3 sm:grid-cols-3">
{[
["Surface", "var(--color-surface)"],
["Container", "var(--color-surface-container)"],
["Highest", "var(--color-surface-container-highest)"]
].map(([label, value]) => (
<div
key={label}
className={`rounded-[var(--radius-md)] border border-[var(--color-outline-variant)] p-4 ${motionMode === "interactive" ? "motion-breathe" : ""}`}
style={{ background: value }}
>
<p className="text-sm font-medium text-[var(--color-foreground)]">{label}</p>
</div>
))}
</div>
<Card tone="default">
<CardHeader> <CardHeader>
<CardTitle>Release routing</CardTitle> <CardTitle>Unified Material surface</CardTitle>
<CardDescription> <CardDescription>
The same component tree should now pick up distinct skin treatments. The palette changes, but density, radius, and tonal layering remain stable.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <CardContent className="grid gap-4">
<Input <Input aria-label={`${themeName} input`} defaultValue="Release cadence" />
aria-label={`${motionMode} ${skin} release note status`}
defaultValue="Launch notes approved"
readOnly
/>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-[var(--color-muted-foreground)]">
Quiet notifications
</span>
<Switch
aria-label={`${motionMode} ${skin} quiet notifications`}
checked
/>
</div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button>Primary</Button> <Button>Primary</Button>
<Button variant="secondary">Secondary</Button> <Button variant="secondary">Tonal</Button>
<Button variant="subtle">Subtle</Button> <Button variant="subtle">Surface</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<PanelPreview /> <EmptyState tone="subtle">
</section> <EmptyStateHeader>
<EmptyStateTitle>No alternate skin to choose</EmptyStateTitle>
<EmptyStateDescription>
This matrix now validates one Material language across palettes and motion
modes instead of branching into separate aesthetics.
</EmptyStateDescription>
</EmptyStateHeader>
<EmptyStateActions>
<Button variant="ghost">Review tonal roles</Button>
</EmptyStateActions>
</EmptyState>
</article>
); );
} }
function MatrixDialogSandbox() { function MaterialToneMatrix() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary">Open live dialog preview</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog validation sandbox</DialogTitle>
<DialogDescription>
Use the Storybook toolbar to validate the real overlay under the active theme,
skin, and motion settings.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost">Back</Button>
<Button>Approve</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function StyleMatrixShowcase() {
return ( return (
<div className="min-h-screen bg-[var(--color-background)] px-6 py-10 text-[var(--color-foreground)] sm:px-10"> <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-7xl flex-col gap-8"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-8">
<header className="max-w-4xl space-y-4"> <header className="max-w-4xl space-y-4">
<p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"> <p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
AI UI / Phase 3 AI UI / Material Tone Matrix
</p> </p>
<h1 <h1
className="font-semibold tracking-[var(--tracking-tight)]" className="font-semibold tracking-[var(--tracking-tight)]"
@@ -175,69 +162,75 @@ function StyleMatrixShowcase() {
lineHeight: "var(--leading-tight)" lineHeight: "var(--leading-tight)"
}} }}
> >
Style matrix compares the same product surface across skin and motion scopes. Review the same Material component language across seed presets and the
standard versus static motion baselines.
</h1> </h1>
<p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]"> <p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]">
This page is the screenshot-friendly regression target for the pilot skin work. This page is now the regression surface for tonal hierarchy. If a preset feels
The grid uses nested `data-skin` and `data-motion` scopes so the same off, the fix belongs in the token generator, not in a separate skin branch.
building blocks can be
reviewed side by side.
</p> </p>
</header> </header>
<section className="grid gap-4"> <section className="relative overflow-hidden rounded-[2rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_84%,white_16%),color-mix(in_oklch,var(--color-background)_88%,white_12%))] px-6 py-7 shadow-[0_24px_72px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] sm:px-8">
{motionModeNames.map((motionMode) => ( <div className="pointer-events-none absolute inset-0">
<div key={motionMode} className="grid gap-4"> <div className="motion-drift absolute left-10 top-0 h-28 w-28 rounded-full bg-[color-mix(in_oklch,var(--color-primary-container)_60%,transparent)] blur-3xl" />
<div> <div className="motion-drift absolute right-12 top-8 h-24 w-24 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary-container)_56%,transparent)] blur-3xl" />
<h2 className="text-2xl font-semibold">{motionModeDetails[motionMode].label}</h2> </div>
<p className="mt-1 max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]"> <div className="relative flex flex-wrap items-center justify-between gap-4">
{motionModeDetails[motionMode].note} <div>
</p> <p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
</div> Tonal regression rig
<div className="grid gap-4 xl:grid-cols-3"> </p>
{skinNames.map((skin) => ( <h2 className="mt-2 text-3xl font-semibold tracking-[var(--tracking-tight)]">
<ComparisonCell Interactive mode should feel alive. Static mode should still feel expensive.
key={`${motionMode}-${skin}`} </h2>
motionMode={motionMode}
skin={skin}
/>
))}
</div>
</div> </div>
))} <div className="flex flex-wrap gap-3">
<Button>Interactive baseline</Button>
<Button variant="secondary">Static fallback</Button>
</div>
</div>
</section> </section>
<section className="grid gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"> {motionModeNames.map((motionMode) => (
<div> <section key={motionMode} className="grid gap-4">
<h2 className="text-2xl font-semibold">Live overlay validation</h2> <div>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]"> <h2 className="text-2xl font-semibold">
Dialog still portals to the document root, so compare its real overlay and {motionModeDetails[motionMode].label}
panel treatment with the Storybook toolbar. The matrix above covers scoped </h2>
inline regression across interactive and static motion modes. The control below <p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
covers the live overlay behavior. {motionModeDetails[motionMode].note}
</p> </p>
</div> </div>
<div className="flex justify-start lg:justify-end"> <div className="grid gap-4 xl:grid-cols-3">
<MatrixDialogSandbox /> {themeNames.map((themeName) => (
</div> <SurfaceCluster
</section> key={`${motionMode}-${themeName}`}
motionMode={motionMode}
themeName={themeName}
/>
))}
</div>
</section>
))}
</div> </div>
</div> </div>
); );
} }
const meta = { const meta = {
title: "Foundation/Style Matrix", title: "Foundation/Material Tone Matrix",
component: StyleMatrixShowcase, component: MaterialToneMatrix,
parameters: { parameters: {
docs: { docs: {
description: { description: {
component: component:
"Phase 3 adds the regression-oriented comparison surface. Use this page for screenshots and visual review, then use the live dialog sandbox below to validate portal-driven overlays under the active toolbar settings." "A regression surface for checking that seed presets and reduced/static motion still read as one coherent Material system."
} }
} },
layout: "fullscreen"
} }
} satisfies Meta<typeof StyleMatrixShowcase>; } satisfies Meta<typeof MaterialToneMatrix>;
export default meta; export default meta;
+11 -9
View File
@@ -1,5 +1,6 @@
import { import {
colorTokens, colorTokens,
createDynamicColorVariables,
defaultTheme, defaultTheme,
defaultMotionMode, defaultMotionMode,
motionTokens, motionTokens,
@@ -13,6 +14,7 @@ import {
type ThemeName type ThemeName
} from "@ai-ui/tokens"; } from "@ai-ui/tokens";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import type { CSSProperties } from "react";
type TokensOverviewProps = { type TokensOverviewProps = {
motionMode: MotionModeName; motionMode: MotionModeName;
@@ -63,8 +65,8 @@ function ThemeCard({ themeName }: { themeName: ThemeName }) {
return ( return (
<article <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)]" className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-background)] p-5 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]"
style={createDynamicColorVariables(theme.seed) as CSSProperties}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
@@ -143,19 +145,19 @@ function TokensOverview({
}} }}
> >
The first stable token layer defines color, type, surface depth, and The first stable token layer defines color, type, surface depth, and
motion rhythm. motion rhythm around a Material You style system.
</h1> </h1>
<p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]"> <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 Seed color now drives the palette. Components inherit tonal surfaces and
implementations. Motion is also represented as named tokens and starter emphasis roles from the token layer instead of shipping disconnected visual
recipes rather than ad hoc transition values. skins.
</p> </p>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <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)]"> <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)]"> <p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Active Theme Active Seed Preset
</p> </p>
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]"> <p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">
{themeDetails[theme].label} {themeDetails[theme].label}
@@ -175,10 +177,10 @@ function TokensOverview({
<section className="space-y-4"> <section className="space-y-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<h2 className="text-2xl font-semibold">Theme scaffolds</h2> <h2 className="text-2xl font-semibold">Seed presets</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]"> <p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
These cards render their own nested theme roots, so tokens can be These cards render their own seed-derived palettes, so the tonal system
validated side by side without touching component code. can be reviewed side by side without changing component code.
</p> </p>
</div> </div>
</div> </div>
@@ -0,0 +1,68 @@
# Harness Foundation
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-23`
## Goal
Introduce the first harness-engineering layer for Cadence UI so the repository becomes easier for
agents to understand, plan against, and validate mechanically.
## Scope
- In scope:
- document the harness workflow
- add execution-plan conventions
- expose shared validation suites from one entrypoint
- add a pull request workflow that runs the PR validation suite
- Out of scope:
- full worktree orchestration
- change-aware suite selection
- fixing pre-existing lint and Storybook smoke issues discovered during baseline review
## Constraints
- Keep the existing package, registry, and Storybook workflows intact.
- Avoid introducing a hard dependency on a specific coding-agent CLI.
- Preserve the current release scripts while adding a richer harness path alongside them.
## Affected Surfaces
- `AGENTS.md`
- `CONTRIBUTING.md`
- `README.md`
- `docs/harness-engineering.md`
- `docs/exec-plans/*`
- `package.json`
- `.github/workflows/harness-validate.yml`
- `scripts/harness/validate.mjs`
## Plan
1. Document what harness engineering means for this repository.
2. Add versioned execution-plan guidance and a real example plan.
3. Expose reusable validation suites behind a single script.
4. Run the new PR suite from GitHub Actions.
## Validation
- `pnpm harness:suites`
- `pnpm harness:validate:pr -- --dry-run`
Baseline findings captured during rollout:
- `pnpm test` passed
- `pnpm typecheck` passed
- `pnpm lint` failed on pre-existing story/test lint issues plus React Compiler hook lint
- `pnpm test:e2e:smoke` reached a real Storybook drift failure in the button playground flow once
browser launch permissions were provided
## Status Log
- `2026-03-23 12:18` inspected repo structure, QA stack, CI workflows, and agent guidance
- `2026-03-23 12:24` confirmed `test` and `typecheck` pass; found pre-existing `lint` failures
- `2026-03-23 12:26` reran Playwright smoke outside the sandbox and confirmed one real Storybook
smoke failure instead of a harness-only environment failure
- `2026-03-23 12:31` added harness docs, execution-plan conventions, validation script, and PR
workflow
@@ -0,0 +1,71 @@
# Harness Rollout Completion
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-23`
## Goal
Finish the next harness-engineering layer for Cadence UI by stabilizing baseline validation,
adding diff-aware suite selection, and exposing worktree-oriented orchestration defaults.
## Scope
- In scope:
- fix the current lint failures
- stabilize the flaky Storybook smoke route
- add suite selection from git diff and working tree changes
- add orchestration wrapper docs and scripts
- Out of scope:
- automated plan-to-task decomposition
- CI artifact uploads for harness JSON reports
- stronger container or VM isolation around worker execution
## Constraints
- Keep the existing validation commands working as direct entrypoints.
- Do not make the repository depend on a single hosted orchestration service.
- Preserve simple local development workflows alongside the richer harness path.
## Affected Surfaces
- `apps/docs/src/components/*`
- `packages/ui/src/components/*`
- `tests/e2e/storybook-smoke.spec.ts`
- `scripts/harness/*`
- `.github/workflows/harness-validate.yml`
- `docs/harness-engineering.md`
- `docs/orchestration.md`
## Plan
1. Repair the failing lint issues in stories, tests, and hooks-heavy components.
2. Stabilize Storybook smoke navigation so it waits for iframe story readiness.
3. Add a shared diff-to-suite selector and a changed-suite validator.
4. Expose a repository-local orchestration wrapper with strict worktree defaults.
5. Update docs and workflow wiring around the new control plane.
## Validation
- `pnpm lint`
- `pnpm harness:suites`
- `pnpm harness:select -- --json`
- `pnpm harness:validate:changed -- --dry-run`
- `pnpm test`
- `pnpm typecheck`
- `pnpm build:docs`
- `pnpm test:e2e:smoke`
## Orchestration Task Sketch
- `T1`: stabilize validation surfaces
- `T2`: add selector and reporting control plane
- `T3`: wire worktree dispatch defaults and docs
- `T4`: validate changed-suite and full smoke behavior
## Status Log
- `2026-03-23 12:36` fixed existing lint errors in stories, tests, and component hooks
- `2026-03-23 12:43` stabilized Storybook smoke route readiness via story-title waits
- `2026-03-23 12:50` added shared harness core, diff-aware suite selector, and changed-suite execution
- `2026-03-23 12:55` added orchestration wrapper and repository docs for worktree-backed dispatch
@@ -0,0 +1,77 @@
# Material You Convergence
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-23`
## Goal
Converge Cadence UI from a multi-skin showcase system into a single Material You inspired
design language with dynamic seed-color theming, tonal surfaces, large-radius component
defaults, and one consistent motion vocabulary plus a reduced/static accessibility override.
## Scope
- In scope:
- replace the current `minimal / glass / pixel` skin contract with a single `material` skin
- shift token defaults toward Material You roles and typography
- introduce runtime dynamic color generation from a seed color
- retune component surface variables around tonal containers instead of decorative skins
- update Storybook preview and foundation stories away from multi-skin demos
- update package and contract tests that exercise the public runtime styling API
- promote `DESIGN.md` into the repository system of record
- push the visual language further toward a showcase-grade Material presentation
- Out of scope:
- full wallpaper extraction from host operating systems
- dark theme parity for every token role in this first convergence slice
- a full component-by-component redesign of every docs story
## Constraints
- Keep the existing package import structure working for `@ai-ui/ui` and `@ai-ui/tokens`.
- Preserve the reduced/static motion accessibility mode.
- Prefer aliasing and runtime helpers over a sweeping component API rewrite.
- Record the direction change explicitly instead of silently continuing the multi-skin RFC.
## Affected Surfaces
- `packages/tokens/src/*`
- `packages/ui/src/lib/*`
- `packages/ui/src/skins.css`
- `packages/ui/src/styles.css`
- `apps/docs/.storybook/preview.ts`
- `apps/docs/src/*.stories.tsx`
- `DESIGN.md`
- `AGENTS.md`
- `README.md`
- `tests/package-consumer/*`
- `docs/exec-plans/*`
## Plan
1. Replace the style runtime contract with Material-centric theme and motion semantics.
2. Add a seed-color palette generator and map generated roles onto existing component tokens.
3. Collapse skin CSS to a single Material skin and retune shared component variables.
4. Update Storybook docs to demonstrate dynamic color, tonal surfaces, and the single motion language.
5. Promote `DESIGN.md` into the repository's official design system of record.
6. Push the showcase styling and motion layer until the docs feel closer to a Material launch demo.
7. Run focused validation on package contracts, docs build, and consumer smoke.
## Validation
- `pnpm test`
- `pnpm typecheck`
- `pnpm build:docs`
- `pnpm test:package:consumer`
## Orchestration Task Sketch
- `T1`: token/runtime contract shift
- `T2`: shared component surface restyle
- `T3`: docs and consumer validation updates
## Status Log
- `2026-03-23 14:18` started convergence plan after product direction changed from multi-skin showcase to Material You
- `2026-03-23 16:15` promoted `DESIGN.md` to the active design-system source of truth and started a second-pass visual polish toward a more animated Material showcase
- `2026-03-23 16:58` completed the second-pass polish across tokens, shared skin variables, Storybook showcase pages, and package-consumer validation
+44
View File
@@ -0,0 +1,44 @@
# Execution Plans
Execution plans make non-trivial work resumable and reviewable by both humans and agents.
## Naming
Use `YYYY-MM-DD-short-name.md`.
Examples:
- `2026-03-23-harness-foundation.md`
- `2026-03-24-date-picker-accessibility.md`
## When To Add One
Create or update a plan when the change:
- spans multiple directories or validation surfaces
- alters public contracts, release behavior, or build pipelines
- is large enough that another person or agent may continue it later
## Status Model
Prefer one of these status values near the top of the plan:
- `proposed`
- `in-progress`
- `blocked`
- `completed`
- `abandoned`
## Minimum Contents
Every plan should cover:
- goal
- scope
- constraints or non-goals
- affected surfaces
- implementation steps
- validation strategy
- status log
Start from [TEMPLATE.md](./TEMPLATE.md).
+46
View File
@@ -0,0 +1,46 @@
# <Plan Title>
- Status: `proposed`
- Owner: `<name or agent>`
- Date: `YYYY-MM-DD`
## Goal
Describe the outcome in one short paragraph.
## Scope
- In scope:
- Out of scope:
## Constraints
- List technical or product constraints.
## Affected Surfaces
- `packages/ui`
- `apps/docs`
- `tests`
- `registry`
- other paths as needed
## Plan
1. Step one.
2. Step two.
3. Step three.
## Validation
- `pnpm harness:validate:component`
- `pnpm harness:validate:docs`
- Add or remove commands for the actual task
## Orchestration Task Sketch
- Optional task IDs and dependencies when the work should be dispatched through `pnpm harness:orch`
## Status Log
- `YYYY-MM-DD HH:MM` initial note
+138
View File
@@ -0,0 +1,138 @@
# Harness Engineering
Cadence UI already has good validation primitives. Harness engineering makes those primitives
agent-usable by turning the repo into a system that can explain itself, accept explicit execution
plans, and expose repeatable machine-runnable feedback loops.
## What it means in this repo
For Cadence UI, harness engineering means:
- the repository has clear system-of-record files for architecture, contracts, and release rules
- non-trivial work starts with an execution plan checked into git
- validation is exposed as stable suites that humans, agents, and CI can run the same way
- known gaps are recorded explicitly instead of being rediscovered by every new task
This follows the direction described in OpenAI's harness engineering guidance: repositories should
be more legible to agents, use explicit execution plans, and prefer faster feedback loops over
purely ad hoc prompting.
## System Of Record
Agents and contributors should treat these files as the repository's baseline knowledge:
- `DESIGN.md`: active visual language, dynamic color direction, and motion rules
- `README.md`: repo purpose, workspace layout, distribution modes, and QA surface
- `CONTRIBUTING.md`: component contract, review expectations, and definition of done
- `roadmap.md`: current system direction and planned component work
- `packages/ui/src/lib/contracts.ts`: public authoring contract for components
- `apps/docs/src/component-authoring.stories.tsx`: review surface for authoring rules
- `docs/registry.md`: source-copy registry contract
- `docs/releasing.md`: package release contract
- `docs/rfcs/*`: design decisions that should not be silently bypassed
- `AGENTS.md`: agent operating mode for this repository
## Execution Plans
Non-trivial changes should start with an execution plan under `docs/exec-plans/`.
Use an execution plan when the work:
- touches multiple repo surfaces such as `packages/ui`, `apps/docs`, `tests`, or `registry`
- changes public component contracts or release behavior
- introduces new dependencies, workflows, or automation
- is large enough that another engineer or agent may need to resume it later
Plans should state:
- the problem or goal
- constraints and non-goals
- affected surfaces and likely files
- validation suites to run
- a status log with concrete checkpoints
## Validation Suites
Harness validation is exposed through `scripts/harness/validate.mjs` and root `pnpm` scripts.
Primary suites:
- `pnpm harness:validate:static`
- lint and workspace typecheck for general repo changes
- `pnpm harness:validate:component`
- lint, typecheck, and unit coverage for normal component work
- `pnpm harness:validate:docs`
- Storybook build for docs-surface changes
- `pnpm harness:validate:docs-smoke`
- Playwright smoke coverage for high-value Storybook flows
- `pnpm harness:validate:consumers`
- registry metadata plus consumer smoke validation
- `pnpm harness:validate:pr`
- baseline pull request gate for packages, docs build, and consumer surfaces
- `pnpm harness:validate:release`
- full release gate, including browser-driven smoke coverage
- `pnpm harness:validate:changed`
- selects suites from git diff or working tree changes before validating
Each run writes a JSON report to `.artifacts/harness/<suite>.json`.
## Working Loop
Recommended change loop:
1. Read the relevant system-of-record files.
2. Create or update an execution plan when the change is non-trivial.
3. Modify the smallest surface that can prove the change.
4. Run the narrowest useful harness suite first.
5. Escalate to broader suites before merge.
6. Record any skipped checks or known failures in the execution plan or PR.
## Diff-aware Selection
Harness selection is exposed through `pnpm harness:select` and `pnpm harness:validate:changed`.
- `pnpm harness:select`
- reads the current working tree diff by default
- `pnpm harness:select -- --from <ref> --to <ref>`
- selects suites from an explicit git range
- `pnpm harness:validate:changed`
- runs the selected suites with a JSON report
Selection intentionally maps repo surfaces to validation surfaces:
- package source changes select `component`, `docs`, `docs-smoke`, and `consumers`
- docs/story changes select `static`, `docs`, and `docs-smoke`
- registry/consumer changes select `static` and `consumers`
- doc-only or metadata-only changes may select no suites
## Worktree Orchestration
Cadence UI also exposes an orchestration wrapper:
- `pnpm harness:orch -- <orch command>`
The wrapper keeps orchestration state under `.artifacts/orch/` and applies worktree defaults for
dispatch. Details live in [docs/orchestration.md](/Users/xd/project/cadence-ui/docs/orchestration.md).
## Current Rollout Scope
This repository is adding harness engineering in phases.
Phase 1 establishes:
- a documented harness workflow
- execution-plan conventions
- shared validation suites
- a pull request workflow that runs the PR suite
Phase 2 adds:
- change-aware suite selection from git diff
- a stabilized Storybook smoke harness
- worktree-oriented orchestration defaults
Future phases can layer on:
- richer browser/app harnesses for interactive review
- deeper plan-to-task automation for orchestration runs
- stronger safety rails for agent-owned automation
+97
View File
@@ -0,0 +1,97 @@
# Worktree Orchestration
Cadence UI uses worktree-oriented orchestration as an optional layer on top of the harness
validation suites. The goal is to let a leader agent or operator dispatch isolated implementation
attempts without making the repository depend on one specific orchestration service.
## Wrapper Command
Use the repository wrapper instead of calling `orch` directly:
```bash
pnpm harness:orch -- <orch command> [flags]
```
The wrapper applies these defaults:
- orchestration database: `.artifacts/orch/coord.db`
- worktree root: `.artifacts/orch/worktrees`
- repo path for dispatch: the current Cadence UI repository root
- strict worktree mode for dispatch
- `--base-ref` defaults to the current branch if not provided
## Suggested Workflow
1. Write or update an execution plan in `docs/exec-plans/`.
2. Create an orchestration run.
3. Add tasks and dependencies that map to the execution plan.
4. Dispatch ready tasks into isolated worktrees.
5. Reconcile worker state and run the relevant harness suites before merge.
## Example
Create a run:
```bash
pnpm harness:orch -- run init \
--run cadence_ui_harness_001 \
--goal "Complete the next Cadence UI release slice" \
--summary "Break the work into isolated component, docs, and validation tasks"
```
Add tasks:
```bash
pnpm harness:orch -- task add \
--run cadence_ui_harness_001 \
--task T1 \
--title "Implement component change" \
--summary "Update the component source and unit tests"
pnpm harness:orch -- task add \
--run cadence_ui_harness_001 \
--task T2 \
--title "Update docs and smoke coverage" \
--summary "Refresh stories and keep Storybook smoke stable"
pnpm harness:orch -- dep add \
--run cadence_ui_harness_001 \
--task T2 \
--depends-on T1
```
Dispatch a task into a strict worktree:
```bash
pnpm harness:orch -- dispatch \
--run cadence_ui_harness_001 \
--task T1 \
--to default-worker \
--body-file docs/exec-plans/task-t1.md
```
Inspect status:
```bash
pnpm harness:orch -- status --run cadence_ui_harness_001
pnpm harness:orch -- blocked --run cadence_ui_harness_001
pnpm harness:orch -- reconcile --run cadence_ui_harness_001
pnpm harness:orch -- cleanup --run cadence_ui_harness_001 --all-completed
```
## Mapping Plans To Tasks
Execution plans are still the source of intent. Orchestration tasks should be a thin translation of
the plan:
- one task per independently dispatchable slice
- dependencies only where integration would otherwise conflict
- task bodies should point back to the execution plan and the relevant harness suites
- workers should validate the narrowest useful suites first, then report what remains
## Safety Notes
- dispatch from a committed or otherwise reviewable base when possible
- keep shared integration files on the leader side when multiple workers are active
- prefer one task per isolated write scope
- use `status`, `blocked`, `answer`, and `reconcile` instead of ad hoc coordination
+5 -1
View File
@@ -2,7 +2,7 @@
## Status ## Status
Proposed Superseded by the Material You convergence work started on 2026-03-23.
## Last Updated ## Last Updated
@@ -10,6 +10,10 @@ Proposed
## Why this document exists ## Why this document exists
This RFC is now a historical record of the repo's multi-skin exploration. The active
direction has changed: Cadence UI is converging on one Material-centric design language
with dynamic seed color instead of continuing to expand multiple visual skins.
This document records the current plan for making Cadence UI support multiple visual 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. styles without forking component behavior or losing the existing source-owned model.
+15
View File
@@ -12,6 +12,19 @@
"changeset": "changeset", "changeset": "changeset",
"changeset:status": "changeset status --verbose", "changeset:status": "changeset status --verbose",
"dev:docs": "pnpm --dir apps/docs run storybook", "dev:docs": "pnpm --dir apps/docs run storybook",
"harness:orch": "node ./scripts/harness/orchestrate.mjs",
"harness:select": "node ./scripts/harness/select-suites.mjs",
"harness:suites": "node ./scripts/harness/validate.mjs --list",
"harness:validate": "node ./scripts/harness/validate.mjs",
"harness:validate:changed": "node ./scripts/harness/validate.mjs --suite changed",
"harness:validate:a11y": "node ./scripts/harness/validate.mjs --suite a11y",
"harness:validate:component": "node ./scripts/harness/validate.mjs --suite component",
"harness:validate:docs": "node ./scripts/harness/validate.mjs --suite docs",
"harness:validate:docs-smoke": "node ./scripts/harness/validate.mjs --suite docs-smoke",
"harness:validate:consumers": "node ./scripts/harness/validate.mjs --suite consumers",
"harness:validate:pr": "node ./scripts/harness/validate.mjs --suite pr",
"harness:validate:release": "node ./scripts/harness/validate.mjs --suite release",
"harness:validate:static": "node ./scripts/harness/validate.mjs --suite static",
"lint": "eslint .", "lint": "eslint .",
"registry:build": "node ./scripts/build-registry.mjs", "registry:build": "node ./scripts/build-registry.mjs",
"registry:check": "node ./scripts/build-registry.mjs --check", "registry:check": "node ./scripts/build-registry.mjs --check",
@@ -21,6 +34,7 @@
"release:version": "pnpm changeset version && pnpm install --lockfile-only && pnpm registry:build", "release:version": "pnpm changeset version && pnpm install --lockfile-only && pnpm registry:build",
"test": "pnpm --filter @ai-ui/ui test", "test": "pnpm --filter @ai-ui/ui test",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:a11y": "node ./scripts/harness/run-storybook-a11y.mjs",
"test:e2e:smoke": "playwright test tests/e2e/storybook-smoke.spec.ts", "test:e2e:smoke": "playwright test tests/e2e/storybook-smoke.spec.ts",
"test:package:consumer": "node ./tests/package-consumer/smoke.mjs", "test:package:consumer": "node ./tests/package-consumer/smoke.mjs",
"test:registry:consumer": "node ./tests/registry/consumer-smoke.mjs", "test:registry:consumer": "node ./tests/registry/consumer-smoke.mjs",
@@ -31,6 +45,7 @@
"@changesets/cli": "^2.30.0", "@changesets/cli": "^2.30.0",
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.55.0", "@playwright/test": "^1.55.0",
"axe-core": "^4.11.1",
"@storybook/addon-a11y": "^8.6.14", "@storybook/addon-a11y": "^8.6.14",
"@storybook/addon-essentials": "^8.6.14", "@storybook/addon-essentials": "^8.6.14",
"@storybook/addon-interactions": "^8.6.14", "@storybook/addon-interactions": "^8.6.14",
+296 -47
View File
@@ -1,22 +1,25 @@
export const themeNames = ["morandi", "earth", "brand"] as const; export const themeNames = ["violet", "jade", "sunset"] as const;
export type ThemeName = (typeof themeNames)[number]; export type ThemeName = (typeof themeNames)[number];
export const defaultTheme: ThemeName = "morandi"; export const defaultTheme: ThemeName = "violet";
export const themeDetails = { export const themeDetails = {
morandi: { violet: {
label: "Morandi", label: "Violet Seed",
note: "Muted dusty neutrals with a calm, understated luxury mood" note: "A Material baseline seeded from a soft violet, close to the reference M3 demos.",
seed: "#6750A4"
}, },
earth: { jade: {
label: "Earth", label: "Jade Seed",
note: "Organic browns, terracotta warmth, olive depth, and sandstone calm" note: "Cool green-blue tonal families for calmer product surfaces and lower visual heat.",
seed: "#0B8F83"
}, },
brand: { sunset: {
label: "Brand", label: "Sunset Seed",
note: "Verdant accent scaffold" note: "Warm coral tonal families for more human, expressive Material palettes.",
seed: "#B75A46"
} }
} as const satisfies Record<ThemeName, { label: string; note: string }>; } as const satisfies Record<ThemeName, { label: string; note: string; seed: string }>;
export const motionModeNames = ["interactive", "static"] as const; export const motionModeNames = ["interactive", "static"] as const;
export type MotionModeName = (typeof motionModeNames)[number]; export type MotionModeName = (typeof motionModeNames)[number];
@@ -25,12 +28,12 @@ export const defaultMotionMode: MotionModeName = "interactive";
export const motionModeDetails = { export const motionModeDetails = {
interactive: { interactive: {
label: "Interactive", label: "Standard",
note: "Micro-interactions with hover lift, press feedback, focus transitions, and animated state changes" note: "The default Material motion language for state, depth, and spatial feedback."
}, },
static: { static: {
label: "Static", label: "Static",
note: "Keep visual states readable while removing motion-heavy feedback and animation" note: "Preserve clarity while removing motion-heavy transitions and hover choreography."
} }
} as const satisfies Record<MotionModeName, { label: string; note: string }>; } as const satisfies Record<MotionModeName, { label: string; note: string }>;
@@ -44,66 +47,84 @@ export const motionScale = {
export const colorTokens = [ export const colorTokens = [
{ name: "background", cssVar: "--color-background", role: "Application canvas" }, { name: "background", cssVar: "--color-background", role: "Application canvas" },
{ name: "surface", cssVar: "--color-surface", role: "Base surface container" },
{
name: "surface-container",
cssVar: "--color-surface-container",
role: "Default tonal container for cards and supporting panels"
},
{
name: "surface-container-high",
cssVar: "--color-surface-container-high",
role: "Raised tonal container for overlays and prominent groups"
},
{
name: "surface-container-highest",
cssVar: "--color-surface-container-highest",
role: "Highest emphasis surface used for fields and selected chips"
},
{ name: "foreground", cssVar: "--color-foreground", role: "Primary text and icons" }, { name: "foreground", cssVar: "--color-foreground", role: "Primary text and icons" },
{ name: "surface", cssVar: "--color-surface", role: "Secondary surface backgrounds" },
{ {
name: "surface-strong", name: "on-surface-variant",
cssVar: "--color-surface-strong", cssVar: "--color-on-surface-variant",
role: "Elevated surface emphasis" role: "Supporting text, dividers, and lower-emphasis iconography"
}, },
{ name: "card", cssVar: "--color-card", role: "Cards and floating panels" }, { name: "primary", cssVar: "--color-primary", role: "Filled actions and active emphasis" },
{ name: "border", cssVar: "--color-border", role: "Default dividers and input borders" },
{ {
name: "border-strong", name: "primary-container",
cssVar: "--color-border-strong", cssVar: "--color-primary-container",
role: "Higher emphasis dividers" role: "Tonal action backgrounds and highlighted containers"
}, },
{ name: "primary", cssVar: "--color-primary", role: "Primary actions and highlights" },
{ {
name: "secondary", name: "secondary-container",
cssVar: "--color-secondary", cssVar: "--color-secondary-container",
role: "Secondary fills and supporting actions" role: "Filled tonal surfaces for secondary emphasis"
}, },
{ name: "muted", cssVar: "--color-muted", role: "Subtle supporting surfaces" },
{ {
name: "muted-foreground", name: "tertiary-container",
cssVar: "--color-muted-foreground", cssVar: "--color-tertiary-container",
role: "Secondary text and captions" role: "Expressive accent container for supportive highlights"
}, },
{ name: "accent", cssVar: "--color-accent", role: "Moments of emphasis or delight" }, { name: "outline", cssVar: "--color-outline", role: "Primary stroke and input outline color" },
{ name: "success", cssVar: "--color-success", role: "Success feedback" }, {
{ name: "warning", cssVar: "--color-warning", role: "Warning feedback" }, name: "outline-variant",
{ name: "destructive", cssVar: "--color-destructive", role: "Destructive actions" } cssVar: "--color-outline-variant",
role: "Lower-emphasis border and separator color"
},
{ name: "surface-tint", cssVar: "--color-surface-tint", role: "Tint color for tonal elevation" },
{ name: "error", cssVar: "--color-error", role: "Error and destructive feedback" },
{ name: "success", cssVar: "--color-success", role: "Positive validation and confirmation" },
{ name: "warning", cssVar: "--color-warning", role: "Cautionary feedback" }
] as const; ] as const;
export const typographyTokens = [ export const typographyTokens = [
{ {
name: "caption", name: "label",
fontVar: "--text-xs", fontVar: "--text-sm",
lineHeightVar: "--leading-normal", lineHeightVar: "--leading-snug",
familyVar: "--font-sans", familyVar: "--font-sans",
sample: "Small labels, metadata, and supporting notes." sample: "Labels stay crisp, compact, and readable across controls."
}, },
{ {
name: "body", name: "body",
fontVar: "--text-base", fontVar: "--text-base",
lineHeightVar: "--leading-normal", lineHeightVar: "--leading-normal",
familyVar: "--font-sans", familyVar: "--font-sans",
sample: "Body copy stays warm, readable, and stable across themes." sample: "Body copy stays clear and quiet so color and hierarchy do the expressive work."
}, },
{ {
name: "lead", name: "title",
fontVar: "--text-xl", fontVar: "--text-xl",
lineHeightVar: "--leading-loose", lineHeightVar: "--leading-snug",
familyVar: "--font-sans", familyVar: "--font-display",
sample: "Lead text introduces a surface without becoming display copy." sample: "Titles feel warm and rounded, without drifting into editorial flourish."
}, },
{ {
name: "display", name: "display",
fontVar: "--text-4xl", fontVar: "--text-4xl",
lineHeightVar: "--leading-tight", lineHeightVar: "--leading-tight",
familyVar: "--font-display", familyVar: "--font-display",
sample: "Display text carries the editorial voice of the system." sample: "Display copy should feel optimistic, human, and distinctly Material."
} }
] as const; ] as const;
@@ -149,6 +170,61 @@ export const motionTokens = {
] ]
} as const; } as const;
type DynamicColorVariableName =
| "--color-background"
| "--color-foreground"
| "--color-surface"
| "--color-surface-strong"
| "--color-surface-contrast"
| "--color-surface-dim"
| "--color-surface-bright"
| "--color-surface-container-low"
| "--color-surface-container"
| "--color-surface-container-high"
| "--color-surface-container-highest"
| "--color-border"
| "--color-border-strong"
| "--color-input"
| "--color-ring"
| "--color-primary"
| "--color-primary-foreground"
| "--color-primary-container"
| "--color-on-primary-container"
| "--color-secondary"
| "--color-secondary-foreground"
| "--color-secondary-container"
| "--color-on-secondary-container"
| "--color-tertiary"
| "--color-tertiary-foreground"
| "--color-tertiary-container"
| "--color-on-tertiary-container"
| "--color-muted"
| "--color-muted-foreground"
| "--color-accent"
| "--color-accent-foreground"
| "--color-success"
| "--color-success-foreground"
| "--color-warning"
| "--color-warning-foreground"
| "--color-destructive"
| "--color-destructive-foreground"
| "--color-card"
| "--color-card-foreground"
| "--color-overlay"
| "--color-outline"
| "--color-outline-variant"
| "--color-on-surface"
| "--color-on-surface-variant"
| "--color-surface-tint"
| "--color-error"
| "--color-on-error"
| "--color-error-container"
| "--color-on-error-container"
| "--color-inverse-surface"
| "--color-inverse-on-surface";
export type DynamicColorVariables = Record<DynamicColorVariableName, string>;
function getTargetElement(root?: HTMLElement) { function getTargetElement(root?: HTMLElement) {
if (root) { if (root) {
return root; return root;
@@ -161,6 +237,163 @@ function getTargetElement(root?: HTMLElement) {
return document.documentElement; return document.documentElement;
} }
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function normalizeHexColor(seed: string) {
const normalized = seed.trim().replace(/^#/, "");
if (/^[0-9a-f]{3}$/i.test(normalized)) {
return `#${normalized
.split("")
.map((char) => `${char}${char}`)
.join("")
.toLowerCase()}`;
}
if (/^[0-9a-f]{6}$/i.test(normalized)) {
return `#${normalized.toLowerCase()}`;
}
throw new Error(`Expected a hex seed color such as #6750A4. Received "${seed}".`);
}
function hexToRgb(seed: string) {
const normalized = normalizeHexColor(seed).slice(1);
return {
b: Number.parseInt(normalized.slice(4, 6), 16),
g: Number.parseInt(normalized.slice(2, 4), 16),
r: Number.parseInt(normalized.slice(0, 2), 16)
};
}
function rgbToHsl({ b, g, r }: { b: number; g: number; r: number }) {
const red = r / 255;
const green = g / 255;
const blue = b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const lightness = (max + min) / 2;
const delta = max - min;
if (delta === 0) {
return { h: 0, l: lightness * 100, s: 0 };
}
const saturation =
lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
let hue = 0;
switch (max) {
case red:
hue = (green - blue) / delta + (green < blue ? 6 : 0);
break;
case green:
hue = (blue - red) / delta + 2;
break;
default:
hue = (red - green) / delta + 4;
break;
}
return {
h: hue * 60,
l: lightness * 100,
s: saturation * 100
};
}
function shiftHue(hue: number, delta: number) {
return (hue + delta + 360) % 360;
}
function toColor(hue: number, saturation: number, lightness: number) {
return `hsl(${Math.round(hue)} ${Math.round(clamp(saturation, 0, 100))}% ${Math.round(
clamp(lightness, 0, 100)
)}%)`;
}
export function createDynamicColorVariables(seed: string): DynamicColorVariables {
const { h, s } = rgbToHsl(hexToRgb(seed));
const accentSaturation = clamp(Math.max(42, s * 0.92), 42, 82);
const neutralHue = shiftHue(h, 8);
const secondaryHue = shiftHue(h, 12);
const tertiaryHue = shiftHue(h, -118);
const neutralSaturation = clamp(s * 0.1, 5, 13);
const neutralVariantSaturation = clamp(s * 0.22, 9, 20);
const containerSaturation = clamp(s * 0.24, 12, 22);
return {
"--color-background": toColor(neutralHue, neutralSaturation, 97),
"--color-foreground": toColor(neutralHue, neutralSaturation + 2, 15),
"--color-surface": toColor(neutralHue, neutralSaturation, 95),
"--color-surface-strong": toColor(secondaryHue, clamp(neutralVariantSaturation * 1.15, 10, 24), 89),
"--color-surface-contrast": toColor(neutralHue, neutralVariantSaturation + 2, 28),
"--color-surface-dim": toColor(neutralHue, neutralSaturation, 87),
"--color-surface-bright": toColor(neutralHue, neutralSaturation, 99),
"--color-surface-container-low": toColor(neutralHue, neutralSaturation, 96),
"--color-surface-container": toColor(secondaryHue, clamp(neutralVariantSaturation * 0.8, 8, 18), 92),
"--color-surface-container-high": toColor(h, clamp(neutralVariantSaturation * 0.95, 9, 20), 88),
"--color-surface-container-highest": toColor(tertiaryHue, clamp(s * 0.14, 9, 18), 84),
"--color-border": toColor(secondaryHue, clamp(neutralVariantSaturation * 0.9, 8, 18), 80),
"--color-border-strong": toColor(secondaryHue, clamp(neutralVariantSaturation * 1.2, 10, 24), 54),
"--color-input": toColor(secondaryHue, clamp(neutralVariantSaturation * 1.2, 10, 24), 54),
"--color-ring": toColor(h, accentSaturation, 40),
"--color-primary": toColor(h, accentSaturation, 44),
"--color-primary-foreground": toColor(h, 26, 98),
"--color-primary-container": toColor(h, clamp(s * 0.56, 24, 58), 86),
"--color-on-primary-container": toColor(h, accentSaturation, 18),
"--color-secondary": toColor(secondaryHue, containerSaturation, 90),
"--color-secondary-foreground": toColor(secondaryHue, clamp(s * 0.28, 14, 22), 22),
"--color-secondary-container": toColor(secondaryHue, containerSaturation, 89),
"--color-on-secondary-container": toColor(secondaryHue, clamp(s * 0.28, 14, 22), 22),
"--color-tertiary": toColor(tertiaryHue, clamp(s * 0.26, 14, 24), 40),
"--color-tertiary-foreground": toColor(tertiaryHue, 20, 98),
"--color-tertiary-container": toColor(tertiaryHue, clamp(s * 0.18, 12, 18), 86),
"--color-on-tertiary-container": toColor(tertiaryHue, clamp(s * 0.22, 14, 22), 18),
"--color-muted": toColor(neutralHue, neutralSaturation, 93),
"--color-muted-foreground": toColor(neutralHue, neutralVariantSaturation, 34),
"--color-accent": toColor(tertiaryHue, clamp(s * 0.18, 12, 18), 86),
"--color-accent-foreground": toColor(tertiaryHue, clamp(s * 0.22, 14, 22), 18),
"--color-success": toColor(152, 35, 42),
"--color-success-foreground": toColor(152, 18, 98),
"--color-warning": toColor(76, 62, 48),
"--color-warning-foreground": toColor(76, 20, 14),
"--color-destructive": toColor(12, 72, 44),
"--color-destructive-foreground": toColor(12, 20, 98),
"--color-card": toColor(secondaryHue, clamp(neutralSaturation * 0.9, 5, 14), 96),
"--color-card-foreground": toColor(neutralHue, neutralSaturation + 2, 15),
"--color-overlay": "color-mix(in oklch, black 24%, transparent)",
"--color-outline": toColor(secondaryHue, clamp(neutralVariantSaturation * 1.1, 10, 22), 54),
"--color-outline-variant": toColor(secondaryHue, clamp(neutralVariantSaturation * 0.95, 9, 20), 82),
"--color-on-surface": toColor(neutralHue, neutralSaturation + 2, 15),
"--color-on-surface-variant": toColor(neutralHue, neutralVariantSaturation, 34),
"--color-surface-tint": toColor(h, accentSaturation, 40),
"--color-error": toColor(12, 72, 44),
"--color-on-error": toColor(12, 20, 98),
"--color-error-container": toColor(12, 58, 88),
"--color-on-error-container": toColor(12, 72, 20),
"--color-inverse-surface": toColor(neutralHue, neutralSaturation + 2, 20),
"--color-inverse-on-surface": toColor(neutralHue, neutralSaturation, 96)
};
}
function applyDynamicColorVariables(
variables: DynamicColorVariables,
target: HTMLElement,
metadata: { seed: string; theme: string }
) {
for (const [name, value] of Object.entries(variables)) {
target.style.setProperty(name, value);
}
target.dataset.theme = metadata.theme;
target.dataset.seedColor = normalizeHexColor(metadata.seed);
}
export function setTheme(theme: ThemeName, root?: HTMLElement) { export function setTheme(theme: ThemeName, root?: HTMLElement) {
const target = getTargetElement(root); const target = getTargetElement(root);
@@ -168,7 +401,23 @@ export function setTheme(theme: ThemeName, root?: HTMLElement) {
return; return;
} }
target.dataset.theme = theme; applyDynamicColorVariables(createDynamicColorVariables(themeDetails[theme].seed), target, {
seed: themeDetails[theme].seed,
theme
});
}
export function setDynamicColor(seed: string, root?: HTMLElement) {
const target = getTargetElement(root);
if (!target) {
return;
}
applyDynamicColorVariables(createDynamicColorVariables(seed), target, {
seed,
theme: "dynamic"
});
} }
export function setMotionMode(mode: MotionModeName, root?: HTMLElement) { export function setMotionMode(mode: MotionModeName, root?: HTMLElement) {
+108 -6
View File
@@ -2,14 +2,14 @@
:root[data-motion="interactive"], :root[data-motion="interactive"],
[data-motion="interactive"] { [data-motion="interactive"] {
--dur-instant: 1ms; --dur-instant: 1ms;
--dur-fast: 140ms; --dur-fast: 120ms;
--dur-base: 200ms; --dur-base: 180ms;
--dur-slow: 280ms; --dur-slow: 280ms;
--dur-deliberate: 300ms; --dur-deliberate: 360ms;
--ease-standard: cubic-bezier(0.25, 1, 0.5, 1); --ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(0.22, 1, 0.36, 1); --ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-exit: cubic-bezier(0.3, 1, 0.5, 1); --ease-exit: cubic-bezier(0.4, 0, 1, 1);
--distance-xs: 4px; --distance-xs: 4px;
--distance-sm: 8px; --distance-sm: 8px;
@@ -105,6 +105,81 @@
} }
} }
@keyframes aiui-float-soft {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg);
}
50% {
transform: translate3d(calc(var(--distance-xs) * 0.5), calc(var(--distance-sm) * -1), 0)
rotate(-0.8deg);
}
}
@keyframes aiui-breathe {
0%,
100% {
opacity: 0.8;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(var(--scale-pop));
}
}
@keyframes aiui-drift {
0%,
100% {
transform: translate3d(0, 0, 0) scale(1);
}
33% {
transform: translate3d(2.5%, -3%, 0) scale(1.025);
}
66% {
transform: translate3d(-2%, 1.8%, 0) scale(0.992);
}
}
@keyframes aiui-float-hero {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg) scale(1);
}
25% {
transform: translate3d(1.2%, calc(var(--distance-sm) * -0.7), 0) rotate(-1deg)
scale(1.01);
}
50% {
transform: translate3d(-0.8%, calc(var(--distance-md) * -0.85), 0) rotate(0.8deg)
scale(1.02);
}
75% {
transform: translate3d(-1.2%, calc(var(--distance-sm) * -0.35), 0) rotate(-0.4deg)
scale(1.005);
}
}
@keyframes aiui-glimmer {
0% {
opacity: 0;
transform: translateX(-120%);
}
20%,
100% {
opacity: 1;
transform: translateX(120%);
}
}
.motion-transition { .motion-transition {
transition-duration: var(--dur-base); transition-duration: var(--dur-base);
transition-property: color, background-color, border-color, box-shadow, opacity, transition-property: color, background-color, border-color, box-shadow, opacity,
@@ -166,6 +241,33 @@
both; both;
} }
.motion-float {
animation: aiui-float-soft calc(var(--dur-deliberate) * 8) var(--ease-emphasized) infinite;
}
.motion-float-delayed {
animation: aiui-float-soft calc(var(--dur-deliberate) * 9) var(--ease-emphasized) infinite;
animation-delay: 180ms;
}
.motion-float-hero {
animation: aiui-float-hero calc(var(--dur-deliberate) * 11) var(--ease-emphasized)
infinite;
}
.motion-breathe {
animation: aiui-breathe calc(var(--dur-deliberate) * 6) var(--ease-standard) infinite;
}
.motion-drift {
animation: aiui-drift calc(var(--dur-deliberate) * 14) var(--ease-standard) infinite;
transform-origin: center;
}
.motion-glimmer {
animation: aiui-glimmer calc(var(--dur-deliberate) * 3.5) var(--ease-standard) infinite;
}
.motion-ring { .motion-ring {
transition-duration: var(--dur-fast); transition-duration: var(--dur-fast);
transition-property: box-shadow, outline-color, border-color; transition-property: box-shadow, outline-color, border-color;
+76 -106
View File
@@ -1,130 +1,100 @@
:root { :root {
--font-sans: "Avenir Next", "Segoe UI", sans-serif; color-scheme: light;
--font-display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia,
serif; --font-sans:
"Google Sans Text", "Google Sans", "Roboto Flex", "Roboto", "Segoe UI", sans-serif;
--font-display:
"Google Sans Display", "Google Sans", "Roboto Flex", "Roboto", "Segoe UI", sans-serif;
--font-mono: "SF Mono", "SFMono-Regular", "Consolas", monospace; --font-mono: "SF Mono", "SFMono-Regular", "Consolas", monospace;
--text-xs: 0.75rem; --text-xs: 0.75rem;
--text-sm: 0.875rem; --text-sm: 0.875rem;
--text-base: 1rem; --text-base: 1rem;
--text-lg: 1.125rem; --text-lg: 1.125rem;
--text-xl: 1.25rem; --text-xl: 1.375rem;
--text-2xl: 1.5rem; --text-2xl: 1.75rem;
--text-3xl: 2rem; --text-3xl: 2.25rem;
--text-4xl: clamp(2.5rem, 4vw, 4rem); --text-4xl: clamp(2.75rem, 4vw, 4.75rem);
--leading-tight: 1.1; --leading-tight: 1.1;
--leading-snug: 1.25; --leading-snug: 1.3;
--leading-normal: 1.5; --leading-normal: 1.5;
--leading-loose: 1.7; --leading-loose: 1.65;
--tracking-tight: -0.03em; --tracking-tight: -0.02em;
--tracking-normal: 0; --tracking-normal: 0;
--tracking-caps: 0.18em; --tracking-caps: 0.12em;
--border-width-thin: 1px; --border-width-thin: 1px;
--border-width-strong: 1.5px; --border-width-strong: 1px;
--radius-xs: 8px; --radius-xs: 8px;
--radius-sm: 12px; --radius-sm: 16px;
--radius-md: 18px; --radius-md: 20px;
--radius-lg: 28px; --radius-lg: 28px;
--radius-xl: 40px; --radius-xl: 36px;
--radius-full: 999px; --radius-full: 999px;
--shadow-xs: 0 1px 2px oklch(0.28 0.02 55 / 0.06); --shadow-xs: 0 1px 2px rgb(25 18 42 / 0.08), 0 4px 10px rgb(94 74 145 / 0.05);
--shadow-sm: 0 8px 24px oklch(0.28 0.02 55 / 0.08); --shadow-sm: 0 8px 22px rgb(83 63 128 / 0.12), 0 2px 8px rgb(25 18 42 / 0.06);
--shadow-md: 0 18px 48px oklch(0.28 0.03 55 / 0.12); --shadow-md: 0 18px 42px rgb(83 63 128 / 0.16), 0 6px 18px rgb(25 18 42 / 0.1);
--shadow-lg: 0 32px 72px oklch(0.2 0.02 55 / 0.16); --shadow-lg: 0 30px 70px rgb(83 63 128 / 0.18), 0 14px 32px rgb(25 18 42 / 0.12);
}
:root, --color-background: hsl(22 18% 96%);
[data-theme="morandi"] { --color-foreground: hsl(259 6% 15%);
color-scheme: light; --color-surface: hsl(22 12% 94%);
--color-background: color-mix(in oklch, #d4b5a0 10%, white 90%); --color-surface-strong: hsl(278 24% 87%);
--color-foreground: #544c46; --color-surface-contrast: hsl(259 10% 28%);
--color-surface: color-mix(in oklch, #a6b3a7 12%, white 88%); --color-surface-dim: hsl(22 10% 87%);
--color-surface-strong: color-mix(in oklch, #d4b5a0 20%, white 80%); --color-surface-bright: hsl(22 18% 98%);
--color-surface-contrast: #6a615a; --color-surface-container-low: hsl(18 20% 97%);
--color-border: color-mix(in oklch, #9b8e82 34%, white 66%); --color-surface-container: hsl(278 20% 91%);
--color-border-strong: #9b8e82; --color-surface-container-high: hsl(274 24% 88%);
--color-input: var(--color-border); --color-surface-container-highest: hsl(112 22% 84%);
--color-ring: #8e9aaf; --color-outline: hsl(259 10% 56%);
--color-primary: #6f7785; --color-outline-variant: hsl(259 12% 82%);
--color-primary-foreground: #f7f3ef; --color-border: var(--color-outline-variant);
--color-secondary: #a6b3a7; --color-border-strong: var(--color-outline);
--color-secondary-foreground: #495247; --color-input: var(--color-outline);
--color-muted: color-mix(in oklch, #d4b5a0 18%, white 82%); --color-ring: hsl(259 40% 42%);
--color-muted-foreground: #776c64;
--color-accent: #c4a882;
--color-accent-foreground: #4f4334;
--color-success: #879686;
--color-success-foreground: #f6f1ec;
--color-warning: #ba9c73;
--color-warning-foreground: #4d4031;
--color-destructive: #a7837c;
--color-destructive-foreground: #f9f4ef;
--color-card: color-mix(in oklch, var(--color-surface) 82%, white 18%);
--color-card-foreground: var(--color-foreground);
--color-overlay: rgb(84 76 70 / 0.42);
}
[data-theme="earth"] { --color-primary: hsl(264 38% 45%);
color-scheme: light; --color-primary-foreground: hsl(259 24% 98%);
--color-background: color-mix(in oklch, #d4c5a9 34%, white 66%); --color-primary-container: hsl(272 38% 86%);
--color-foreground: #4f3c27; --color-on-primary-container: hsl(264 30% 20%);
--color-surface: color-mix(in oklch, #d4c5a9 52%, white 48%);
--color-surface-strong: color-mix(in oklch, #c4956a 24%, white 76%);
--color-surface-contrast: #6f5437;
--color-border: color-mix(in oklch, #8b6f47 40%, white 60%);
--color-border-strong: #8b6f47;
--color-input: var(--color-border);
--color-ring: #a0522d;
--color-primary: #8b6f47;
--color-primary-foreground: #f7f1e7;
--color-secondary: #6b7c3f;
--color-secondary-foreground: #f4efe6;
--color-muted: color-mix(in oklch, #c4956a 18%, white 82%);
--color-muted-foreground: #78644a;
--color-accent: #a0522d;
--color-accent-foreground: #f8efe6;
--color-success: #72864c;
--color-success-foreground: #f5f1e8;
--color-warning: #c4956a;
--color-warning-foreground: #4a3826;
--color-destructive: #93492b;
--color-destructive-foreground: #faefe7;
--color-card: color-mix(in oklch, var(--color-surface) 84%, white 16%);
--color-card-foreground: var(--color-foreground);
--color-overlay: rgb(79 60 39 / 0.4);
}
[data-theme="brand"] { --color-secondary: hsl(286 24% 90%);
color-scheme: light; --color-secondary-foreground: hsl(284 24% 22%);
--color-background: oklch(0.972 0.016 172); --color-secondary-container: hsl(286 24% 90%);
--color-foreground: oklch(0.24 0.03 182); --color-on-secondary-container: hsl(284 24% 22%);
--color-surface: oklch(0.946 0.018 172);
--color-surface-strong: oklch(0.91 0.024 172); --color-tertiary: hsl(128 18% 40%);
--color-surface-contrast: oklch(0.29 0.034 182); --color-tertiary-foreground: hsl(128 16% 98%);
--color-border: oklch(0.83 0.026 172); --color-tertiary-container: hsl(112 24% 84%);
--color-border-strong: oklch(0.67 0.045 176); --color-on-tertiary-container: hsl(128 18% 22%);
--color-input: var(--color-border);
--color-ring: oklch(0.53 0.12 190); --color-muted: var(--color-surface-container);
--color-primary: oklch(0.48 0.12 188); --color-muted-foreground: hsl(259 10% 34%);
--color-primary-foreground: oklch(0.97 0.008 172); --color-accent: var(--color-tertiary-container);
--color-secondary: oklch(0.82 0.066 156); --color-accent-foreground: var(--color-on-tertiary-container);
--color-secondary-foreground: oklch(0.2 0.02 178);
--color-muted: oklch(0.91 0.018 172); --color-success: hsl(152 35% 42%);
--color-muted-foreground: oklch(0.42 0.03 180); --color-success-foreground: hsl(152 18% 98%);
--color-accent: oklch(0.75 0.105 130); --color-warning: hsl(76 62% 48%);
--color-accent-foreground: oklch(0.2 0.02 160); --color-warning-foreground: hsl(76 20% 14%);
--color-success: oklch(0.6 0.12 155); --color-destructive: hsl(12 72% 44%);
--color-success-foreground: oklch(0.98 0.006 170); --color-destructive-foreground: hsl(12 20% 98%);
--color-warning: oklch(0.76 0.13 86); --color-error: var(--color-destructive);
--color-warning-foreground: oklch(0.22 0.02 74); --color-on-error: var(--color-destructive-foreground);
--color-destructive: oklch(0.53 0.16 30); --color-error-container: hsl(12 58% 88%);
--color-destructive-foreground: oklch(0.98 0.01 80); --color-on-error-container: hsl(12 72% 20%);
--color-card: color-mix(in oklch, var(--color-surface) 88%, white 12%);
--color-card: var(--color-surface-container-low);
--color-card-foreground: var(--color-foreground); --color-card-foreground: var(--color-foreground);
--color-overlay: oklch(0.13 0.015 185 / 0.5); --color-overlay: color-mix(in oklch, black 24%, transparent);
--color-on-surface: var(--color-foreground);
--color-on-surface-variant: var(--color-muted-foreground);
--color-surface-tint: var(--color-primary);
--color-inverse-surface: hsl(259 6% 20%);
--color-inverse-on-surface: hsl(259 4% 96%);
} }
@@ -1,3 +1,5 @@
import type { ComponentProps } from "react";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -9,7 +11,9 @@ import {
AccordionTrigger AccordionTrigger
} from "./accordion"; } from "./accordion";
function ExampleAccordion(props: any = {}) { type ExampleAccordionProps = ComponentProps<typeof Accordion>;
function ExampleAccordion(props: ExampleAccordionProps = {}) {
return ( return (
<Accordion {...props}> <Accordion {...props}>
<AccordionItem value="editorial"> <AccordionItem value="editorial">
@@ -85,7 +89,9 @@ describe("Accordion", () => {
await user.click(trigger); await user.click(trigger);
const content = screen.getByText("Copy is locked for launch review.").closest('[data-slot="content"]'); const content = screen
.getByText("Copy is locked for launch review.")
.closest('[data-slot="content"]');
expect(trigger).toHaveAttribute("aria-expanded", "true"); expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(content).toHaveAttribute("data-state", "open"); expect(content).toHaveAttribute("data-state", "open");
+2 -1
View File
@@ -19,7 +19,8 @@ export const cardVariants = cva(
}, },
interactive: { interactive: {
false: "", false: "",
true: "hover:translate-y-[var(--ui-card-hover-translate)] hover:shadow-[var(--ui-card-hover-shadow)]" true:
"hover:translate-y-[var(--ui-card-hover-translate)] hover:scale-[var(--ui-card-hover-scale,1)] hover:shadow-[var(--ui-card-hover-shadow)]"
} }
}, },
defaultVariants: { defaultVariants: {
+1 -1
View File
@@ -42,7 +42,7 @@ describe("Combobox", () => {
it("renders a selected value, filters options, and updates uncontrolled state", async () => { it("renders a selected value, filters options, and updates uncontrolled state", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const loadingView = render( render(
<Combobox <Combobox
aria-label="Review lane" aria-label="Review lane"
defaultValue="design" defaultValue="design"
+1 -1
View File
@@ -157,7 +157,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
return haystack.includes(query); return haystack.includes(query);
}); });
}, [items, resolvedSearchValue]); }, [filter, items, resolvedSearchValue]);
const listboxId = filteredItems.length > 0 ? `${controlId}-listbox` : undefined; const listboxId = filteredItems.length > 0 ? `${controlId}-listbox` : undefined;
const groupedItems = useMemo(() => { const groupedItems = useMemo(() => {
+90 -97
View File
@@ -32,11 +32,9 @@ import {
datePickerFooterVariants, datePickerFooterVariants,
datePickerGridVariants, datePickerGridVariants,
datePickerHeaderVariants, datePickerHeaderVariants,
datePickerMonthLabelVariants,
datePickerNavigationVariants, datePickerNavigationVariants,
datePickerRootVariants, datePickerRootVariants,
datePickerSelectorsVariants, datePickerSelectorsVariants,
datePickerTriggerVariants,
datePickerWeekdayVariants datePickerWeekdayVariants
} from "./date-picker.variants"; } from "./date-picker.variants";
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
@@ -56,10 +54,6 @@ function normalizeDate(value?: Date) {
: undefined; : undefined;
} }
function getDateKey(value?: Date) {
return value ? `${value.getFullYear()}-${value.getMonth()}-${value.getDate()}` : "";
}
function sameDay(left?: Date, right?: Date) { function sameDay(left?: Date, right?: Date) {
if (!left || !right) { if (!left || !right) {
return false; return false;
@@ -232,111 +226,109 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
}, },
ref ref
) { ) {
const reactId = useId(); const reactId = useId();
const today = useMemo(() => normalizeDate(new Date()), []); const today = useMemo(() => normalizeDate(new Date()), []);
const normalizedControlledValue = useMemo( const normalizedControlledValue = normalizeDate(value);
() => normalizeDate(value), const normalizedDefaultValue = normalizeDate(defaultValue);
[value ? getDateKey(value) : ""] const normalizedDefaultMonth = normalizeDate(defaultMonth);
); const [selectedDate, setSelectedDate] = useControllableState<DatePickerValue>({
const normalizedDefaultValue = useMemo( controlledValue: normalizedControlledValue,
() => normalizeDate(defaultValue), defaultValue: normalizedDefaultValue,
[defaultValue ? getDateKey(defaultValue) : ""] onChange: onValueChange
); });
const normalizedDefaultMonth = useMemo( const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
() => normalizeDate(defaultMonth), const resolvedOpen = open ?? uncontrolledOpen;
[defaultMonth ? getDateKey(defaultMonth) : ""] const [visibleMonth, setVisibleMonth] = useState(
); startOfMonth(
const [selectedDate, setSelectedDate] = useControllableState<DatePickerValue>({ normalizedDefaultMonth ?? normalizedControlledValue ?? normalizedDefaultValue ?? today ?? new Date()
controlledValue: normalizedControlledValue, )
defaultValue: normalizedDefaultValue, );
onChange: onValueChange const dayRefs = useRef<Array<HTMLButtonElement | null>>([]);
const controlId = id ?? `date-picker-${reactId.replace(/:/g, "")}`;
const popupId = `${controlId}-dialog`;
useEffect(() => {
if (!selectedDate) {
return;
}
const frame = requestAnimationFrame(() => {
setVisibleMonth(startOfMonth(selectedDate));
}); });
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const resolvedOpen = open ?? uncontrolledOpen;
const [visibleMonth, setVisibleMonth] = useState(
startOfMonth(normalizedDefaultMonth ?? normalizedDefaultValue ?? today ?? new Date())
);
const dayRefs = useRef<Array<HTMLButtonElement | null>>([]);
const controlId = id ?? `date-picker-${reactId.replace(/:/g, "")}`;
useEffect(() => { return () => cancelAnimationFrame(frame);
if (selectedDate) { }, [selectedDate]);
setVisibleMonth(startOfMonth(selectedDate));
}
}, [selectedDate]);
const monthLabel = formatMonthLabel(visibleMonth, locale); const monthLabel = formatMonthLabel(visibleMonth, locale);
const weekdays = useMemo(() => { const weekdays = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" }); const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
const base = new Date(2025, 0, 5 + getWeekStartIndex(weekStartsOn)); const base = new Date(2025, 0, 5 + getWeekStartIndex(weekStartsOn));
return Array.from({ length: 7 }, (_, index) => { return Array.from({ length: 7 }, (_, index) => {
const day = new Date(base); const day = new Date(base);
day.setDate(base.getDate() + index); day.setDate(base.getDate() + index);
return formatter.format(day); return formatter.format(day);
}); });
}, [locale, weekStartsOn]); }, [locale, weekStartsOn]);
const days = useMemo( const days = useMemo(
() => buildMonthGrid(visibleMonth, weekStartsOn), () => buildMonthGrid(visibleMonth, weekStartsOn),
[visibleMonth, weekStartsOn] [visibleMonth, weekStartsOn]
); );
const yearOptions = useMemo( const yearOptions = useMemo(
() => getYearOptions(visibleMonth, selectedDate), () => getYearOptions(visibleMonth, selectedDate),
[selectedDate, visibleMonth] [selectedDate, visibleMonth]
); );
const selectedIndex = days.findIndex((day) => sameDay(day, selectedDate)); const selectedIndex = days.findIndex((day) => sameDay(day, selectedDate));
useEffect(() => { useEffect(() => {
dayRefs.current = []; dayRefs.current = [];
}, [visibleMonth]); }, [visibleMonth]);
useEffect(() => { useEffect(() => {
if (!resolvedOpen) { if (!resolvedOpen) {
return; return;
} }
const focusIndex = const focusIndex =
selectedIndex >= 0 selectedIndex >= 0
? selectedIndex ? selectedIndex
: days.findIndex( : days.findIndex(
(day) => (day) => day.getMonth() === visibleMonth.getMonth() && sameDay(day, today)
day.getMonth() === visibleMonth.getMonth() && );
sameDay(day, today)
);
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
dayRefs.current[focusIndex >= 0 ? focusIndex : 0]?.focus(); dayRefs.current[focusIndex >= 0 ? focusIndex : 0]?.focus();
}); });
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [days, resolvedOpen, selectedIndex, today, visibleMonth]); }, [days, resolvedOpen, selectedIndex, today, visibleMonth]);
const setOpenState = (nextOpen: boolean) => { const setOpenState = (nextOpen: boolean) => {
if (open === undefined) { if (open === undefined) {
setUncontrolledOpen(nextOpen); setUncontrolledOpen(nextOpen);
} }
onOpenChange?.(nextOpen); onOpenChange?.(nextOpen);
}; };
const goToMonth = (offset: number) => { const goToMonth = (offset: number) => {
const next = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + offset, 1); const next = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + offset, 1);
setVisibleMonth(next); setVisibleMonth(next);
onMonthChange?.(next); onMonthChange?.(next);
}; };
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLInputElement>) => { const handleTriggerKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") { if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault(); event.preventDefault();
setOpenState(true); setOpenState(true);
} }
if (event.key === "Escape") { if (event.key === "Escape") {
setOpenState(false); setOpenState(false);
} }
}; };
const handleDayKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => { const handleDayKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const movementMap: Record<string, number> = { const movementMap: Record<string, number> = {
ArrowDown: 7, ArrowDown: 7,
ArrowLeft: -1, ArrowLeft: -1,
@@ -408,6 +400,7 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
<div {...createSlot("field")} className={datePickerFieldVariants()}> <div {...createSlot("field")} className={datePickerFieldVariants()}>
<Input <Input
{...props} {...props}
aria-controls={popupId}
aria-expanded={resolvedOpen} aria-expanded={resolvedOpen}
aria-haspopup="dialog" aria-haspopup="dialog"
className={cn("cursor-pointer pr-20", className)} className={cn("cursor-pointer pr-20", className)}
@@ -430,7 +423,7 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
</div> </div>
</PopoverAnchor> </PopoverAnchor>
<PopoverContent className={datePickerContentVariants()} padding="sm" size="xl"> <PopoverContent className={datePickerContentVariants()} id={popupId} padding="sm" size="xl">
<div className="grid gap-3"> <div className="grid gap-3">
<div {...createSlot("header")} className={datePickerHeaderVariants()}> <div {...createSlot("header")} className={datePickerHeaderVariants()}>
<div className={datePickerNavigationVariants()}> <div className={datePickerNavigationVariants()}>
@@ -16,5 +16,5 @@ export const switchVariants = cva(
export const switchThumbVariants = cva([ export const switchThumbVariants = cva([
"pointer-events-none block size-5 rounded-[var(--ui-switch-thumb-radius)] bg-[var(--ui-switch-thumb-bg)] shadow-[var(--ui-switch-thumb-shadow)]", "pointer-events-none block size-5 rounded-[var(--ui-switch-thumb-radius)] bg-[var(--ui-switch-thumb-bg)] shadow-[var(--ui-switch-thumb-shadow)]",
"translate-x-0.5 will-change-transform transition-[transform,box-shadow,background-color] duration-[var(--ui-switch-transition-duration,var(--dur-base))] ease-[var(--ease-emphasized)]", "translate-x-0.5 will-change-transform transition-[transform,box-shadow,background-color] duration-[var(--ui-switch-transition-duration,var(--dur-base))] ease-[var(--ease-emphasized)]",
"data-[state=checked]:translate-x-[1.55rem] data-[state=checked]:shadow-[var(--ui-switch-thumb-checked-shadow,var(--ui-switch-thumb-shadow))]" "data-[state=checked]:translate-x-[1.55rem] data-[state=checked]:bg-[var(--ui-switch-thumb-checked-bg,var(--ui-switch-thumb-bg))] data-[state=checked]:shadow-[var(--ui-switch-thumb-checked-shadow,var(--ui-switch-thumb-shadow))]"
]); ]);
+6
View File
@@ -34,6 +34,12 @@ export const motionRecipes = {
overlayExit: "motion-overlay-exit", overlayExit: "motion-overlay-exit",
exitFade: "motion-exit-fade", exitFade: "motion-exit-fade",
exitDrop: "motion-exit-drop", exitDrop: "motion-exit-drop",
float: "motion-float",
floatDelayed: "motion-float-delayed",
floatHero: "motion-float-hero",
breathe: "motion-breathe",
drift: "motion-drift",
glimmer: "motion-glimmer",
ring: "motion-ring" ring: "motion-ring"
} as const; } as const;
+4 -4
View File
@@ -9,16 +9,16 @@ describe("skin contract", () => {
}); });
it("sets the document root skin when no target element is provided", () => { it("sets the document root skin when no target element is provided", () => {
setSkin("glass"); setSkin("material");
expect(document.documentElement.dataset.skin).toBe("glass"); expect(document.documentElement.dataset.skin).toBe("material");
}); });
it("sets the provided target element instead of the document root", () => { it("sets the provided target element instead of the document root", () => {
const target = document.createElement("div"); const target = document.createElement("div");
setSkin("pixel", target); setSkin("material", target);
expect(target.dataset.skin).toBe("pixel"); expect(target.dataset.skin).toBe("material");
}); });
}); });
+5 -13
View File
@@ -1,20 +1,12 @@
export const skinNames = ["minimal", "glass", "pixel"] as const; export const skinNames = ["material"] as const;
export type SkinName = (typeof skinNames)[number]; export type SkinName = (typeof skinNames)[number];
export const defaultSkin: SkinName = "minimal"; export const defaultSkin: SkinName = "material";
export const skinDetails = { export const skinDetails = {
minimal: { material: {
label: "Minimal", label: "Material",
note: "Restrained surfaces and low-ornament defaults" note: "One tonal, rounded, dynamic-color-first component language"
},
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 }>; } as const satisfies Record<SkinName, { label: string; note: string }>;
+205 -376
View File
@@ -1,422 +1,251 @@
:root, :root,
[data-skin="minimal"] { [data-skin="material"] {
--ui-canvas-image: radial-gradient( --ui-canvas-image:
circle at top, radial-gradient(
color-mix(in oklch, var(--color-primary) 8%, transparent), circle at 18% 12%,
transparent 58% color-mix(in oklch, var(--color-primary-container) 62%, transparent),
); transparent 28%
),
radial-gradient(
circle at 82% 22%,
color-mix(in oklch, var(--color-tertiary-container) 52%, transparent),
transparent 26%
),
radial-gradient(
circle at 50% 0%,
color-mix(in oklch, white 78%, transparent),
transparent 58%
),
linear-gradient(
180deg,
color-mix(in oklch, var(--color-background) 90%, white 10%),
color-mix(in oklch, var(--color-surface) 88%, white 12%)
);
--ui-canvas-size: auto; --ui-canvas-size: auto;
--ui-surface-bg: color-mix(in oklch, var(--color-card) 88%, white 12%); --ui-surface-bg: linear-gradient(
--ui-surface-border: color-mix(in oklch, var(--color-border) 92%, white 8%); 180deg,
--ui-surface-shadow: var(--shadow-sm); color-mix(in oklch, var(--color-surface) 76%, var(--color-surface-bright) 24%),
--ui-surface-radius: var(--radius-lg); color-mix(in oklch, var(--color-surface-container-low) 86%, white 14%)
);
--ui-surface-border: transparent;
--ui-surface-shadow:
inset 0 1px 0 color-mix(in oklch, white 54%, transparent),
0 14px 34px color-mix(in oklch, var(--color-primary) 10%, transparent);
--ui-surface-radius: var(--radius-xl);
--ui-surface-backdrop-blur: 0px; --ui-surface-backdrop-blur: 0px;
--ui-control-bg: color-mix(in oklch, var(--color-background) 92%, white 8%); --ui-control-bg: linear-gradient(
--ui-control-border: var(--color-border); 180deg,
--ui-control-shadow: var(--shadow-xs); color-mix(in oklch, var(--color-surface-container) 82%, var(--color-surface-bright) 18%),
color-mix(in oklch, var(--color-surface-container-high) 78%, var(--color-surface-bright) 22%)
);
--ui-control-border: transparent;
--ui-control-shadow: inset 0 1px 0 color-mix(in oklch, white 40%, transparent);
--ui-control-radius: var(--radius-md); --ui-control-radius: var(--radius-md);
--ui-ornament-opacity: 0.1; --ui-ornament-opacity: 0;
--ui-ornament-mix: normal; --ui-ornament-mix: normal;
--ui-button-radius: var(--radius-sm); --ui-button-radius: var(--radius-full);
--ui-button-border-width: 1px; --ui-button-border-width: 1px;
--ui-button-transition-duration: var(--dur-fast); --ui-button-transition-duration: var(--dur-fast);
--ui-button-sheen-opacity: 0.14; --ui-button-sheen-opacity: 0;
--ui-button-sheen-mix: screen; --ui-button-sheen-mix: normal;
--ui-button-sheen-gradient: linear-gradient( --ui-button-sheen-gradient: linear-gradient(180deg, transparent, transparent);
120deg, --ui-button-primary-bg: linear-gradient(
transparent 0%, 180deg,
rgba(255, 255, 255, 0.24) 45%, color-mix(in oklch, var(--color-primary-container) 82%, white 18%),
transparent 100% color-mix(in oklch, var(--color-primary-container) 74%, var(--color-secondary-container) 26%)
); );
--ui-button-primary-bg: var(--color-primary); --ui-button-primary-hover-bg: linear-gradient(
--ui-button-primary-hover-bg: color-mix(in oklch, var(--color-primary) 90%, black 10%); 180deg,
--ui-button-primary-fg: var(--color-primary-foreground); color-mix(in oklch, var(--color-primary-container) 72%, white 28%),
color-mix(in oklch, var(--color-primary-container) 74%, var(--color-on-primary-container) 26%)
);
--ui-button-primary-fg: var(--color-on-primary-container);
--ui-button-primary-border: transparent; --ui-button-primary-border: transparent;
--ui-button-primary-shadow: var(--shadow-xs); --ui-button-primary-shadow:
--ui-button-secondary-bg: var(--color-secondary); inset 0 1px 0 color-mix(in oklch, white 60%, transparent),
--ui-button-secondary-hover-bg: color-mix(in oklch, var(--color-secondary) 88%, black 12%); 0 14px 26px color-mix(in oklch, var(--color-primary) 16%, transparent);
--ui-button-secondary-fg: var(--color-secondary-foreground); --ui-button-secondary-bg: linear-gradient(
--ui-button-secondary-border: var(--color-border-strong); 180deg,
--ui-button-secondary-shadow: none; color-mix(in oklch, var(--color-tertiary-container) 84%, white 16%),
color-mix(in oklch, var(--color-tertiary-container) 72%, var(--color-surface-container-highest) 28%)
);
--ui-button-secondary-hover-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-tertiary-container) 76%, white 24%),
color-mix(in oklch, var(--color-tertiary-container) 74%, var(--color-on-tertiary-container) 26%)
);
--ui-button-secondary-fg: var(--color-on-tertiary-container);
--ui-button-secondary-border: transparent;
--ui-button-secondary-shadow:
inset 0 1px 0 color-mix(in oklch, white 54%, transparent),
0 12px 24px color-mix(in oklch, var(--color-tertiary) 12%, transparent);
--ui-button-ghost-bg: transparent; --ui-button-ghost-bg: transparent;
--ui-button-ghost-hover-bg: var(--color-surface); --ui-button-ghost-hover-bg: color-mix(
--ui-button-ghost-fg: var(--color-foreground); in oklch,
var(--color-surface-container-high) 72%,
transparent
);
--ui-button-ghost-fg: var(--color-primary);
--ui-button-ghost-border: transparent; --ui-button-ghost-border: transparent;
--ui-button-ghost-shadow: none; --ui-button-ghost-shadow: none;
--ui-button-subtle-bg: var(--color-card); --ui-button-subtle-bg: linear-gradient(
--ui-button-subtle-hover-bg: color-mix(in oklch, var(--color-card) 88%, black 12%); 180deg,
--ui-button-subtle-fg: var(--color-foreground); color-mix(in oklch, var(--color-surface-container-high) 64%, var(--color-surface-bright) 36%),
--ui-button-subtle-border: var(--color-border); color-mix(in oklch, var(--color-surface-container) 74%, var(--color-surface-bright) 26%)
--ui-button-subtle-shadow: var(--shadow-xs);
--ui-button-destructive-bg: var(--color-destructive);
--ui-button-destructive-hover-bg: color-mix(
in oklch,
var(--color-destructive) 88%,
black 12%
); );
--ui-button-destructive-fg: var(--color-destructive-foreground); --ui-button-subtle-hover-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-high) 70%, var(--color-surface-bright) 30%),
color-mix(in oklch, var(--color-surface-container-high) 82%, white 18%)
);
--ui-button-subtle-fg: var(--color-foreground);
--ui-button-subtle-border: transparent;
--ui-button-subtle-shadow:
inset 0 1px 0 color-mix(in oklch, white 40%, transparent),
0 10px 20px color-mix(in oklch, var(--color-primary) 8%, transparent);
--ui-button-destructive-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-error-container) 86%, white 14%),
color-mix(in oklch, var(--color-error-container) 76%, var(--color-surface-bright) 24%)
);
--ui-button-destructive-hover-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-error-container) 76%, white 24%),
color-mix(in oklch, var(--color-error) 18%, var(--color-error-container) 82%)
);
--ui-button-destructive-fg: var(--color-on-error-container);
--ui-button-destructive-border: transparent; --ui-button-destructive-border: transparent;
--ui-button-destructive-shadow: var(--shadow-xs); --ui-button-destructive-shadow:
--ui-button-hover-scale: 1.02; inset 0 1px 0 color-mix(in oklch, white 42%, transparent),
--ui-button-press-scale: 0.98; 0 12px 24px color-mix(in oklch, var(--color-error) 12%, transparent);
--ui-button-hover-translate: -1px; --ui-button-hover-scale: 1.024;
--ui-button-hover-shadow: var(--shadow-sm); --ui-button-press-scale: 0.985;
--ui-button-active-shadow: var(--shadow-xs); --ui-button-hover-translate: -2px;
--ui-button-hover-shadow: 0 16px 30px color-mix(in oklch, var(--color-primary) 14%, transparent);
--ui-button-active-shadow: 0 8px 16px color-mix(in oklch, var(--color-primary) 12%, transparent);
--ui-spinner-radius: var(--radius-full); --ui-spinner-radius: var(--radius-full);
--ui-spinner-border-width: 2px; --ui-spinner-border-width: 2px;
--ui-card-radius: var(--radius-lg); --ui-card-radius: var(--radius-lg);
--ui-card-border-width: 1px; --ui-card-border-width: 1px;
--ui-card-bg: var(--color-card); --ui-card-bg: linear-gradient(
--ui-card-shadow: var(--shadow-sm); 180deg,
--ui-card-default-bg: var(--color-card); color-mix(in oklch, var(--color-surface-container-low) 78%, var(--color-surface-bright) 22%),
--ui-card-default-border: var(--color-border); color-mix(in oklch, var(--color-surface-container) 18%, var(--color-surface-container-low) 82%)
--ui-card-default-shadow: var(--shadow-sm); );
--ui-card-subtle-bg: var(--color-surface); --ui-card-shadow:
--ui-card-subtle-border: color-mix(in oklch, var(--color-border) 86%, transparent); inset 0 1px 0 color-mix(in oklch, white 48%, transparent),
--ui-card-subtle-shadow: var(--shadow-xs); 0 18px 36px color-mix(in oklch, var(--color-primary) 12%, transparent);
--ui-card-accent-bg: color-mix(in oklch, var(--color-primary) 8%, var(--color-card)); --ui-card-default-bg: linear-gradient(
--ui-card-accent-border: color-mix(in oklch, var(--color-primary) 26%, var(--color-border)); 180deg,
--ui-card-accent-shadow: var(--shadow-sm); color-mix(in oklch, var(--color-surface-container-low) 78%, var(--color-surface-bright) 22%),
--ui-card-hover-translate: -2px; color-mix(in oklch, var(--color-surface-container) 18%, var(--color-surface-container-low) 82%)
--ui-card-hover-shadow: var(--shadow-md); );
--ui-card-default-border: transparent;
--ui-card-default-shadow:
inset 0 1px 0 color-mix(in oklch, white 48%, transparent),
0 18px 36px color-mix(in oklch, var(--color-primary) 12%, transparent);
--ui-card-subtle-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container) 82%, var(--color-surface-bright) 18%),
color-mix(in oklch, var(--color-surface-container-high) 72%, var(--color-surface-bright) 28%)
);
--ui-card-subtle-border: transparent;
--ui-card-subtle-shadow:
inset 0 1px 0 color-mix(in oklch, white 40%, transparent),
0 12px 28px color-mix(in oklch, var(--color-primary) 10%, transparent);
--ui-card-accent-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-primary-container) 70%, var(--color-surface-bright) 30%),
color-mix(in oklch, var(--color-secondary-container) 18%, var(--color-primary-container) 82%)
);
--ui-card-accent-border: transparent;
--ui-card-accent-shadow:
inset 0 1px 0 color-mix(in oklch, white 44%, transparent),
0 18px 36px color-mix(in oklch, var(--color-primary) 14%, transparent);
--ui-card-hover-translate: -6px;
--ui-card-hover-scale: 1.016;
--ui-card-hover-shadow: 0 24px 44px color-mix(in oklch, var(--color-primary) 16%, transparent);
--ui-input-radius: var(--radius-md); --ui-input-radius: var(--radius-sm);
--ui-input-border-width: 1px; --ui-input-border-width: 1px;
--ui-input-bg: var(--color-card); --ui-input-bg: linear-gradient(
--ui-input-border: var(--color-input); 180deg,
--ui-input-fg: var(--color-foreground); color-mix(in oklch, var(--color-surface-container-highest) 56%, var(--color-surface-bright) 44%),
--ui-input-shadow: var(--shadow-xs); color-mix(in oklch, var(--color-surface-container) 14%, var(--color-surface-bright) 86%)
--ui-input-focus-border: color-mix(in oklch, var(--color-primary) 32%, var(--color-input)); );
--ui-input-border: transparent;
--ui-input-fg: var(--color-on-surface);
--ui-input-shadow:
inset 0 1px 0 color-mix(in oklch, white 42%, transparent),
0 4px 12px color-mix(in oklch, var(--color-primary) 6%, transparent);
--ui-input-focus-border: var(--color-primary);
--ui-input-focus-shadow: --ui-input-focus-shadow:
0 0 0 1px color-mix(in oklch, var(--color-primary) 18%, transparent), 0 0 0 3px color-mix(in oklch, var(--color-primary) 18%, transparent),
var(--shadow-sm); 0 14px 28px color-mix(in oklch, var(--color-primary) 12%, transparent);
--ui-input-focus-lift: -1px; --ui-input-focus-lift: 0px;
--ui-input-disabled-bg: var(--color-surface); --ui-input-disabled-bg: var(--color-surface-container);
--ui-input-readonly-bg: var(--color-surface); --ui-input-readonly-bg: var(--color-surface-container-low);
--ui-input-backdrop-blur: 0px; --ui-input-backdrop-blur: 0px;
--ui-panel-radius: var(--radius-lg); --ui-panel-radius: var(--radius-lg);
--ui-panel-border-width: 1px; --ui-panel-border-width: 1px;
--ui-panel-bg: var(--color-card); --ui-panel-bg: linear-gradient(
--ui-panel-border: var(--color-border); 180deg,
--ui-panel-shadow: var(--shadow-md); color-mix(in oklch, var(--color-surface-container-high) 76%, var(--color-surface-bright) 24%),
color-mix(in oklch, var(--color-surface-container) 18%, var(--color-surface-container-high) 82%)
);
--ui-panel-border: transparent;
--ui-panel-shadow:
inset 0 1px 0 color-mix(in oklch, white 46%, transparent),
0 28px 64px color-mix(in oklch, var(--color-primary) 16%, transparent);
--ui-panel-backdrop-blur: 0px; --ui-panel-backdrop-blur: 0px;
--ui-panel-overlay-bg: var(--color-overlay); --ui-panel-overlay-bg: color-mix(in oklch, var(--color-overlay) 88%, transparent);
--ui-panel-overlay-blur: 2px; --ui-panel-overlay-blur: 10px;
--ui-switch-track-radius: var(--radius-full); --ui-switch-track-radius: var(--radius-full);
--ui-switch-track-border-width: 1px; --ui-switch-track-border-width: 1px;
--ui-switch-track-bg: var(--color-border); --ui-switch-track-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-highest) 72%, var(--color-surface-bright) 28%),
color-mix(in oklch, var(--color-surface-container) 18%, var(--color-surface-container-highest) 82%)
);
--ui-switch-track-border: transparent; --ui-switch-track-border: transparent;
--ui-switch-track-shadow: var(--shadow-xs); --ui-switch-track-shadow:
--ui-switch-track-checked-bg: var(--color-primary); inset 0 1px 0 color-mix(in oklch, white 38%, transparent),
0 6px 14px color-mix(in oklch, var(--color-primary) 8%, transparent);
--ui-switch-track-checked-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-primary-container) 82%, white 18%),
color-mix(in oklch, var(--color-primary-container) 72%, var(--color-secondary-container) 28%)
);
--ui-switch-track-checked-border: transparent; --ui-switch-track-checked-border: transparent;
--ui-switch-thumb-radius: var(--radius-full); --ui-switch-thumb-radius: var(--radius-full);
--ui-switch-thumb-bg: white; --ui-switch-thumb-bg: var(--color-surface-bright);
--ui-switch-thumb-shadow: var(--shadow-xs); --ui-switch-thumb-checked-bg: var(--color-primary);
--ui-switch-thumb-checked-shadow: var(--shadow-sm); --ui-switch-thumb-shadow:
inset 0 1px 0 color-mix(in oklch, white 54%, transparent),
var(--shadow-xs);
--ui-switch-thumb-checked-shadow:
inset 0 1px 0 color-mix(in oklch, white 36%, transparent),
var(--shadow-xs);
--ui-switch-transition-duration: var(--dur-base); --ui-switch-transition-duration: var(--dur-base);
--ui-skeleton-radius: var(--radius-sm); --ui-skeleton-radius: var(--radius-sm);
--ui-skeleton-block-radius: var(--radius-md); --ui-skeleton-block-radius: var(--radius-md);
--ui-skeleton-pill-radius: var(--radius-full); --ui-skeleton-pill-radius: var(--radius-full);
--ui-skeleton-avatar-radius: var(--radius-full); --ui-skeleton-avatar-radius: var(--radius-full);
--ui-skeleton-bg: color-mix(in oklch, var(--color-surface) 74%, var(--color-border)); --ui-skeleton-bg: linear-gradient(
--ui-skeleton-muted-bg: var(--color-muted); 180deg,
color-mix(in oklch, var(--color-surface-container-highest) 82%, white 18%),
color-mix(in oklch, var(--color-outline-variant) 18%, var(--color-surface-container-highest) 82%)
);
--ui-skeleton-muted-bg: var(--color-surface-container);
--ui-skeleton-gradient: linear-gradient( --ui-skeleton-gradient: linear-gradient(
110deg, 110deg,
transparent 0%, transparent 0%,
rgba(255, 255, 255, 0.48) 42%, color-mix(in oklch, var(--color-primary-container) 40%, white 60%) 42%,
transparent 72% transparent 72%
); );
} }
[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;
--ui-button-radius: var(--radius-lg);
--ui-button-border-width: 1px;
--ui-button-transition-duration: var(--dur-base);
--ui-button-sheen-opacity: 0.42;
--ui-button-sheen-mix: screen;
--ui-button-sheen-gradient: linear-gradient(
120deg,
transparent 0%,
rgba(255, 255, 255, 0.34) 45%,
transparent 100%
);
--ui-button-primary-bg: color-mix(in oklch, var(--color-primary) 72%, white 28%);
--ui-button-primary-hover-bg: color-mix(in oklch, var(--color-primary) 78%, white 22%);
--ui-button-primary-fg: var(--color-foreground);
--ui-button-primary-border: color-mix(in oklch, white 28%, var(--color-primary));
--ui-button-primary-shadow: 0 16px 34px oklch(0.24 0.06 250 / 0.18);
--ui-button-secondary-bg: color-mix(in oklch, var(--color-secondary) 52%, transparent);
--ui-button-secondary-hover-bg: color-mix(in oklch, var(--color-secondary) 64%, transparent);
--ui-button-secondary-fg: var(--color-secondary-foreground);
--ui-button-secondary-border: color-mix(in oklch, white 34%, var(--color-border-strong));
--ui-button-secondary-shadow: 0 12px 28px oklch(0.24 0.04 250 / 0.12);
--ui-button-ghost-bg: transparent;
--ui-button-ghost-hover-bg: color-mix(in oklch, white 18%, transparent);
--ui-button-ghost-fg: var(--color-foreground);
--ui-button-ghost-border: color-mix(in oklch, white 20%, transparent);
--ui-button-ghost-shadow: none;
--ui-button-subtle-bg: color-mix(in oklch, var(--color-card) 56%, transparent);
--ui-button-subtle-hover-bg: color-mix(in oklch, var(--color-card) 66%, transparent);
--ui-button-subtle-fg: var(--color-foreground);
--ui-button-subtle-border: color-mix(in oklch, white 30%, var(--color-border));
--ui-button-subtle-shadow: 0 12px 30px oklch(0.24 0.04 250 / 0.12);
--ui-button-destructive-bg: color-mix(in oklch, var(--color-destructive) 74%, white 26%);
--ui-button-destructive-hover-bg: color-mix(
in oklch,
var(--color-destructive) 80%,
white 20%
);
--ui-button-destructive-fg: var(--color-destructive-foreground);
--ui-button-destructive-border: color-mix(in oklch, white 28%, var(--color-destructive));
--ui-button-destructive-shadow: 0 16px 34px oklch(0.32 0.07 18 / 0.18);
--ui-button-hover-scale: 1.02;
--ui-button-press-scale: 0.985;
--ui-button-hover-translate: -2px;
--ui-button-hover-shadow: 0 20px 42px oklch(0.2 0.04 250 / 0.18);
--ui-button-active-shadow: 0 10px 24px oklch(0.2 0.04 250 / 0.14);
--ui-spinner-radius: var(--radius-full);
--ui-spinner-border-width: 2px;
--ui-card-radius: var(--radius-xl);
--ui-card-border-width: 1px;
--ui-card-bg: color-mix(in oklch, var(--color-card) 58%, transparent);
--ui-card-shadow: 0 24px 64px oklch(0.18 0.03 255 / 0.18);
--ui-card-default-bg: color-mix(in oklch, var(--color-card) 58%, transparent);
--ui-card-default-border: color-mix(in oklch, white 42%, var(--color-border));
--ui-card-default-shadow: 0 24px 64px oklch(0.18 0.03 255 / 0.18);
--ui-card-subtle-bg: color-mix(in oklch, var(--color-surface) 52%, transparent);
--ui-card-subtle-border: color-mix(in oklch, white 32%, var(--color-border));
--ui-card-subtle-shadow: 0 18px 46px oklch(0.18 0.03 255 / 0.12);
--ui-card-accent-bg: color-mix(in oklch, var(--color-primary) 16%, transparent);
--ui-card-accent-border: color-mix(in oklch, white 26%, var(--color-primary));
--ui-card-accent-shadow: 0 24px 54px oklch(0.22 0.08 245 / 0.18);
--ui-card-hover-translate: -4px;
--ui-card-hover-shadow: 0 30px 72px oklch(0.18 0.03 255 / 0.22);
--ui-input-radius: var(--radius-lg);
--ui-input-border-width: 1px;
--ui-input-bg: color-mix(in oklch, var(--color-card) 50%, transparent);
--ui-input-border: color-mix(in oklch, white 34%, var(--color-border));
--ui-input-fg: var(--color-foreground);
--ui-input-shadow: 0 14px 34px oklch(0.2 0.03 255 / 0.12);
--ui-input-focus-border: color-mix(in oklch, white 44%, var(--color-primary));
--ui-input-focus-shadow:
0 0 0 1px color-mix(in oklch, white 22%, var(--color-primary)),
0 18px 40px oklch(0.2 0.03 255 / 0.18);
--ui-input-focus-lift: -1px;
--ui-input-disabled-bg: color-mix(in oklch, var(--color-surface) 72%, transparent);
--ui-input-readonly-bg: color-mix(in oklch, var(--color-surface) 68%, transparent);
--ui-input-backdrop-blur: 12px;
--ui-panel-radius: var(--radius-xl);
--ui-panel-border-width: 1px;
--ui-panel-bg: color-mix(in oklch, var(--color-card) 54%, transparent);
--ui-panel-border: color-mix(in oklch, white 40%, var(--color-border));
--ui-panel-shadow: 0 28px 72px oklch(0.16 0.03 255 / 0.24);
--ui-panel-backdrop-blur: 20px;
--ui-panel-overlay-bg: color-mix(in oklch, var(--color-overlay) 74%, transparent);
--ui-panel-overlay-blur: 8px;
--ui-switch-track-radius: var(--radius-full);
--ui-switch-track-border-width: 1px;
--ui-switch-track-bg: color-mix(in oklch, var(--color-card) 44%, transparent);
--ui-switch-track-border: color-mix(in oklch, white 32%, var(--color-border));
--ui-switch-track-shadow: 0 10px 24px oklch(0.18 0.03 255 / 0.14);
--ui-switch-track-checked-bg: color-mix(in oklch, var(--color-primary) 72%, white 28%);
--ui-switch-track-checked-border: color-mix(in oklch, white 30%, var(--color-primary));
--ui-switch-thumb-radius: var(--radius-full);
--ui-switch-thumb-bg: color-mix(in oklch, white 84%, var(--color-card));
--ui-switch-thumb-shadow: 0 8px 18px oklch(0.16 0.02 255 / 0.22);
--ui-switch-thumb-checked-shadow: 0 12px 24px oklch(0.18 0.03 255 / 0.28);
--ui-switch-transition-duration: var(--dur-base);
--ui-skeleton-radius: var(--radius-md);
--ui-skeleton-block-radius: var(--radius-lg);
--ui-skeleton-pill-radius: var(--radius-full);
--ui-skeleton-avatar-radius: var(--radius-full);
--ui-skeleton-bg: color-mix(in oklch, var(--color-surface) 42%, transparent);
--ui-skeleton-muted-bg: color-mix(in oklch, var(--color-muted) 54%, transparent);
--ui-skeleton-gradient: linear-gradient(
110deg,
transparent 0%,
rgba(255, 255, 255, 0.58) 44%,
transparent 74%
);
}
[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;
--ui-button-radius: 0px;
--ui-button-border-width: 2px;
--ui-button-transition-duration: var(--dur-instant);
--ui-button-sheen-opacity: 0;
--ui-button-sheen-mix: normal;
--ui-button-sheen-gradient: linear-gradient(90deg, transparent, transparent);
--ui-button-primary-bg: var(--color-primary);
--ui-button-primary-hover-bg: color-mix(in oklch, var(--color-primary) 88%, white 12%);
--ui-button-primary-fg: var(--color-primary-foreground);
--ui-button-primary-border: var(--color-foreground);
--ui-button-primary-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 34%, transparent);
--ui-button-secondary-bg: var(--color-secondary);
--ui-button-secondary-hover-bg: color-mix(in oklch, var(--color-secondary) 86%, black 14%);
--ui-button-secondary-fg: var(--color-secondary-foreground);
--ui-button-secondary-border: var(--color-foreground);
--ui-button-secondary-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 30%, transparent);
--ui-button-ghost-bg: transparent;
--ui-button-ghost-hover-bg: color-mix(in oklch, var(--color-surface) 86%, black 14%);
--ui-button-ghost-fg: var(--color-foreground);
--ui-button-ghost-border: var(--color-foreground);
--ui-button-ghost-shadow: none;
--ui-button-subtle-bg: var(--color-card);
--ui-button-subtle-hover-bg: color-mix(in oklch, var(--color-card) 88%, black 12%);
--ui-button-subtle-fg: var(--color-foreground);
--ui-button-subtle-border: var(--color-foreground);
--ui-button-subtle-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 30%, transparent);
--ui-button-destructive-bg: var(--color-destructive);
--ui-button-destructive-hover-bg: color-mix(
in oklch,
var(--color-destructive) 88%,
white 12%
);
--ui-button-destructive-fg: var(--color-destructive-foreground);
--ui-button-destructive-border: var(--color-foreground);
--ui-button-destructive-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 34%, transparent);
--ui-button-hover-scale: 1;
--ui-button-press-scale: 0.98;
--ui-button-hover-translate: -1px;
--ui-button-hover-shadow: 5px 5px 0 color-mix(in oklch, var(--color-foreground) 36%, transparent);
--ui-button-active-shadow: 1px 1px 0 color-mix(in oklch, var(--color-foreground) 28%, transparent);
--ui-spinner-radius: 0px;
--ui-spinner-border-width: 2px;
--ui-card-radius: 0px;
--ui-card-border-width: 2px;
--ui-card-bg: var(--color-card);
--ui-card-shadow: 6px 6px 0 color-mix(in oklch, var(--color-foreground) 38%, transparent);
--ui-card-default-bg: var(--color-card);
--ui-card-default-border: var(--color-foreground);
--ui-card-default-shadow: 6px 6px 0 color-mix(in oklch, var(--color-foreground) 38%, transparent);
--ui-card-subtle-bg: var(--color-surface);
--ui-card-subtle-border: var(--color-foreground);
--ui-card-subtle-shadow: 4px 4px 0 color-mix(in oklch, var(--color-foreground) 34%, transparent);
--ui-card-accent-bg: color-mix(in oklch, var(--color-primary) 12%, var(--color-card));
--ui-card-accent-border: var(--color-foreground);
--ui-card-accent-shadow: 6px 6px 0 color-mix(in oklch, var(--color-primary) 34%, transparent);
--ui-card-hover-translate: -2px;
--ui-card-hover-shadow: 8px 8px 0 color-mix(in oklch, var(--color-foreground) 42%, transparent);
--ui-input-radius: 0px;
--ui-input-border-width: 2px;
--ui-input-bg: var(--color-background);
--ui-input-border: var(--color-foreground);
--ui-input-fg: var(--color-foreground);
--ui-input-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 28%, transparent);
--ui-input-focus-border: var(--color-primary);
--ui-input-focus-shadow:
0 0 0 2px color-mix(in oklch, var(--color-primary) 42%, transparent),
4px 4px 0 color-mix(in oklch, var(--color-foreground) 32%, transparent);
--ui-input-focus-lift: 0px;
--ui-input-disabled-bg: var(--color-surface);
--ui-input-readonly-bg: var(--color-surface);
--ui-input-backdrop-blur: 0px;
--ui-panel-radius: 0px;
--ui-panel-border-width: 2px;
--ui-panel-bg: var(--color-card);
--ui-panel-border: var(--color-foreground);
--ui-panel-shadow: 8px 8px 0 color-mix(in oklch, var(--color-foreground) 40%, transparent);
--ui-panel-backdrop-blur: 0px;
--ui-panel-overlay-bg: color-mix(in oklch, var(--color-overlay) 92%, black 8%);
--ui-panel-overlay-blur: 0px;
--ui-switch-track-radius: 0px;
--ui-switch-track-border-width: 2px;
--ui-switch-track-bg: var(--color-border);
--ui-switch-track-border: var(--color-foreground);
--ui-switch-track-shadow: 2px 2px 0 color-mix(in oklch, var(--color-foreground) 24%, transparent);
--ui-switch-track-checked-bg: var(--color-primary);
--ui-switch-track-checked-border: var(--color-foreground);
--ui-switch-thumb-radius: 0px;
--ui-switch-thumb-bg: var(--color-background);
--ui-switch-thumb-shadow: 2px 2px 0 color-mix(in oklch, var(--color-foreground) 24%, transparent);
--ui-switch-thumb-checked-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 28%, transparent);
--ui-switch-transition-duration: var(--dur-fast);
--ui-skeleton-radius: 0px;
--ui-skeleton-block-radius: 0px;
--ui-skeleton-pill-radius: 0px;
--ui-skeleton-avatar-radius: 0px;
--ui-skeleton-bg: color-mix(in oklch, var(--color-foreground) 18%, var(--color-background));
--ui-skeleton-muted-bg: color-mix(in oklch, var(--color-muted) 72%, black 28%);
--ui-skeleton-gradient: linear-gradient(
90deg,
transparent 0%,
transparent 28%,
rgba(255, 255, 255, 0.2) 28%,
rgba(255, 255, 255, 0.2) 42%,
transparent 42%,
transparent 100%
);
}
+3
View File
@@ -53,6 +53,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^18.3.7 specifier: ^18.3.7
version: 18.3.7(@types/react@18.3.28) version: 18.3.7(@types/react@18.3.28)
axe-core:
specifier: ^4.11.1
version: 4.11.1
eslint: eslint:
specifier: ^9.39.4 specifier: ^9.39.4
version: 9.39.4(jiti@2.6.1) version: 9.39.4(jiti@2.6.1)
+379
View File
@@ -0,0 +1,379 @@
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const zeroShaPattern = /^0{40}$/;
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
export const repoRoot = path.resolve(scriptDir, "../..");
export const artifactsDir = path.join(repoRoot, ".artifacts", "harness");
export const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
export const suiteDefinitions = {
static: {
description: "Fast static validation: lint and workspace typecheck.",
commands: [
{ args: ["lint"], label: "Lint repository" },
{ args: ["typecheck"], label: "Typecheck workspace" }
]
},
component: {
description: "Fast component feedback: lint, typecheck, and unit coverage.",
commands: [
{ args: ["lint"], label: "Lint repository" },
{ args: ["typecheck"], label: "Typecheck workspace" },
{ args: ["test"], label: "Run component tests" }
]
},
docs: {
description: "Build the Storybook review surface.",
commands: [{ args: ["build:docs"], label: "Build Storybook" }]
},
a11y: {
description: "Run Storybook accessibility validation and report violations/incomplete results.",
commands: [{ args: ["test:e2e:a11y"], label: "Run Storybook accessibility checks" }]
},
"docs-smoke": {
description: "Exercise high-value Storybook flows with Playwright.",
commands: [{ args: ["test:e2e:smoke"], label: "Run Storybook smoke tests" }]
},
consumers: {
description: "Validate generated registry metadata and downstream consumers.",
commands: [
{ args: ["registry:check"], label: "Check registry metadata" },
{ args: ["test:registry:consumer"], label: "Run registry consumer smoke test" },
{ args: ["test:package:consumer"], label: "Run package consumer smoke test" }
]
},
pr: {
description: "Baseline pull request gate for source, docs, and consumer surfaces.",
commands: [
{ args: ["lint"], label: "Lint repository" },
{ args: ["typecheck"], label: "Typecheck workspace" },
{ args: ["test"], label: "Run component tests" },
{ args: ["build"], label: "Build packages" },
{ args: ["build:docs"], label: "Build Storybook" },
{ args: ["test:e2e:a11y"], label: "Run Storybook accessibility checks" },
{ args: ["registry:check"], label: "Check registry metadata" },
{ args: ["test:registry:consumer"], label: "Run registry consumer smoke test" },
{ args: ["test:package:consumer"], label: "Run package consumer smoke test" }
]
},
release: {
description: "Full release gate, including browser-driven Storybook coverage.",
commands: [
{ args: ["lint"], label: "Lint repository" },
{ args: ["typecheck"], label: "Typecheck workspace" },
{ args: ["test"], label: "Run component tests" },
{ args: ["build"], label: "Build packages" },
{ args: ["build:docs"], label: "Build Storybook" },
{ args: ["test:e2e:a11y"], label: "Run Storybook accessibility checks" },
{ args: ["registry:check"], label: "Check registry metadata" },
{ args: ["test:registry:consumer"], label: "Run registry consumer smoke test" },
{ args: ["test:package:consumer"], label: "Run package consumer smoke test" },
{ args: ["test:e2e:smoke"], label: "Run Storybook smoke tests" }
]
}
};
const suiteOrder = [
"static",
"component",
"docs",
"a11y",
"docs-smoke",
"consumers",
"pr",
"release"
];
const rootStaticFiles = new Set([
"eslint.config.mjs",
"package.json",
"playwright.config.ts",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"tsconfig.base.json",
"tsconfig.json",
"vitest.config.ts"
]);
function normalizeFilePath(filePath) {
return filePath.split(path.sep).join(path.posix.sep);
}
function isExactMatch(filePath, exactPaths) {
return exactPaths.has(filePath);
}
function isWithin(filePath, directory) {
return filePath === directory || filePath.startsWith(`${directory}/`);
}
function isPackageSourceChange(filePath) {
return isWithin(filePath, "packages/ui/src") || isWithin(filePath, "packages/tokens/src");
}
function isPackageContractChange(filePath) {
return (
isPackageSourceChange(filePath) ||
filePath === "packages/ui/package.json" ||
filePath === "packages/tokens/package.json"
);
}
function isDocsSurfaceChange(filePath) {
return isWithin(filePath, "apps/docs") || isWithin(filePath, ".storybook");
}
function isDocsInteractiveChange(filePath) {
return (
isWithin(filePath, "apps/docs/src") ||
isWithin(filePath, ".storybook") ||
filePath === "playwright.config.ts"
);
}
function isConsumerSurfaceChange(filePath) {
return (
isWithin(filePath, "registry") ||
isWithin(filePath, "tests/package-consumer") ||
isWithin(filePath, "tests/registry") ||
filePath === "scripts/build-registry.mjs" ||
filePath === "scripts/registry-install.mjs" ||
isPackageContractChange(filePath)
);
}
function isHarnessCodeChange(filePath) {
return (
isWithin(filePath, "scripts/harness") ||
filePath === "AGENTS.md" ||
filePath === "CONTRIBUTING.md" ||
filePath === "README.md" ||
filePath === "docs/harness-engineering.md" ||
filePath === "docs/orchestration.md" ||
isWithin(filePath, "docs/exec-plans")
);
}
function isStaticSurfaceChange(filePath) {
return (
isPackageContractChange(filePath) ||
isDocsSurfaceChange(filePath) ||
isWithin(filePath, "tests") ||
isWithin(filePath, "scripts") ||
isWithin(filePath, ".github/workflows") ||
isExactMatch(filePath, rootStaticFiles) ||
isHarnessCodeChange(filePath)
);
}
function isLikelyCodeChange(filePath) {
return /\.(?:cjs|css|cts|js|json|jsx|mdx|mjs|mts|scss|ts|tsx|yaml|yml)$/.test(filePath);
}
function runGitCommand(args) {
return execFileSync("git", args, {
cwd: repoRoot,
encoding: "utf8"
})
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => normalizeFilePath(line));
}
function getWorkingTreeChangedFiles() {
const files = new Set([
...runGitCommand(["diff", "--name-only", "--diff-filter=ACMR", "HEAD"]),
...runGitCommand(["diff", "--cached", "--name-only", "--diff-filter=ACMR", "HEAD"]),
...runGitCommand(["ls-files", "--others", "--exclude-standard"])
]);
return [...files].sort();
}
export function getChangedFiles({ changedFiles = [], from = null, to = null } = {}) {
if (changedFiles.length > 0) {
return [...new Set(changedFiles.map((filePath) => normalizeFilePath(filePath)))].sort();
}
if (from || to) {
const toRef = to ?? "HEAD";
if (from && zeroShaPattern.test(from)) {
return [
...new Set([
...runGitCommand(["ls-files"]),
...runGitCommand(["ls-files", "--others", "--exclude-standard"])
])
].sort();
}
if (!from) {
throw new Error("Expected --from when selecting changed files from git refs.");
}
return runGitCommand(["diff", "--name-only", "--diff-filter=ACMR", `${from}...${toRef}`]);
}
return getWorkingTreeChangedFiles();
}
function addReason(reasonMap, suiteName, filePath, reason) {
const currentReasons = reasonMap.get(suiteName) ?? [];
currentReasons.push({ file: filePath, reason });
reasonMap.set(suiteName, currentReasons);
}
export function selectSuitesForChangedFiles(changedFiles) {
const normalizedFiles = [...new Set(changedFiles.map((filePath) => normalizeFilePath(filePath)))].sort();
const selectedSuites = new Set();
const reasons = new Map();
const unmatchedFiles = [];
for (const filePath of normalizedFiles) {
let matched = false;
if (isPackageContractChange(filePath)) {
selectedSuites.add("component");
selectedSuites.add("docs");
selectedSuites.add("a11y");
selectedSuites.add("consumers");
addReason(reasons, "component", filePath, "Package contract or source changed.");
addReason(reasons, "docs", filePath, "Package changes affect the Storybook review surface.");
addReason(reasons, "a11y", filePath, "Package changes can alter Storybook accessibility results.");
addReason(reasons, "consumers", filePath, "Package changes affect downstream consumers.");
matched = true;
if (isPackageSourceChange(filePath)) {
selectedSuites.add("docs-smoke");
addReason(reasons, "docs-smoke", filePath, "Interactive package source changed.");
}
}
if (isDocsSurfaceChange(filePath)) {
selectedSuites.add("static");
selectedSuites.add("docs");
selectedSuites.add("a11y");
addReason(reasons, "static", filePath, "Docs or Storybook source changed.");
addReason(reasons, "docs", filePath, "Docs or Storybook source changed.");
addReason(reasons, "a11y", filePath, "Docs or Storybook source changed.");
matched = true;
if (isDocsInteractiveChange(filePath)) {
selectedSuites.add("docs-smoke");
addReason(reasons, "docs-smoke", filePath, "Storybook stories or smoke harness changed.");
}
}
if (isConsumerSurfaceChange(filePath)) {
selectedSuites.add("static");
selectedSuites.add("consumers");
addReason(reasons, "static", filePath, "Registry or consumer validation surface changed.");
addReason(reasons, "consumers", filePath, "Registry or consumer validation surface changed.");
matched = true;
}
if (isStaticSurfaceChange(filePath)) {
selectedSuites.add("static");
addReason(reasons, "static", filePath, "Static validation surface changed.");
matched = true;
}
if (!matched) {
unmatchedFiles.push(filePath);
}
}
if (selectedSuites.has("component")) {
selectedSuites.delete("static");
reasons.delete("static");
}
if (selectedSuites.size === 0 && unmatchedFiles.some((filePath) => isLikelyCodeChange(filePath))) {
selectedSuites.add("static");
for (const filePath of unmatchedFiles) {
if (isLikelyCodeChange(filePath)) {
addReason(reasons, "static", filePath, "Fallback static validation for unmatched code change.");
}
}
}
return {
changedFiles: normalizedFiles,
reasons: Object.fromEntries(
[...reasons.entries()].map(([suiteName, suiteReasons]) => [suiteName, suiteReasons])
),
suites: suiteOrder.filter((suiteName) => selectedSuites.has(suiteName)),
unmatchedFiles
};
}
export function getCommandsForSuites(suiteNames) {
const seenCommands = new Set();
const commands = [];
for (const suiteName of suiteNames) {
const suite = suiteDefinitions[suiteName];
if (!suite) {
throw new Error(`Unknown suite "${suiteName}".`);
}
for (const command of suite.commands) {
const key = command.args.join("\u0000");
if (seenCommands.has(key)) {
continue;
}
seenCommands.add(key);
commands.push({
...command,
suite: suiteName
});
}
}
return commands;
}
export function ensureKnownSuite(name) {
if (name === "changed") {
return;
}
if (!(name in suiteDefinitions)) {
const knownSuites = [...suiteOrder, "changed"].join(", ");
throw new Error(`Unknown suite "${name}". Expected one of: ${knownSuites}.`);
}
}
export function writeHarnessArtifact(name, payload) {
fs.mkdirSync(artifactsDir, { recursive: true });
const outputPath = path.join(artifactsDir, `${name}.json`);
fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
return outputPath;
}
export function formatSuiteListing() {
return [...suiteOrder, "changed"]
.map((suiteName) => {
if (suiteName === "changed") {
return {
name: suiteName,
description: "Select suites from git diff or working tree changes before validating."
};
}
return {
name: suiteName,
description: suiteDefinitions[suiteName].description
};
});
}
+94
View File
@@ -0,0 +1,94 @@
import fs from "node:fs";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import { repoRoot } from "./core.mjs";
const orchBin =
process.env.CADENCE_UI_ORCH_BIN ?? "/Users/xd/.codex/skills/orch/assets/orch";
const defaultDbPath = path.join(repoRoot, ".artifacts", "orch", "coord.db");
const defaultWorkspaceRoot = path.join(repoRoot, ".artifacts", "orch", "worktrees");
function printHelp() {
process.stdout.write(`Cadence UI orchestration wrapper
Usage:
pnpm harness:orch -- <orch command> [flags]
Examples:
pnpm harness:orch -- run init --run cadence_ui_demo --goal "Refine release UX" --summary "Break work into isolated tasks"
pnpm harness:orch -- task add --run cadence_ui_demo --task T1 --title "Stabilize smoke tests" --summary "Fix Storybook smoke drift"
pnpm harness:orch -- dispatch --run cadence_ui_demo --task T1 --to default-worker --body-file docs/exec-plans/task-t1.md
pnpm harness:orch -- status --run cadence_ui_demo
Defaults applied by this wrapper:
--db ${path.relative(repoRoot, defaultDbPath)}
dispatch --repo-path ${repoRoot}
dispatch --workspace-root ${path.relative(repoRoot, defaultWorkspaceRoot)}
dispatch --strict-worktree
dispatch --base-ref <current branch>
`);
}
function hasFlag(args, flag) {
return args.includes(flag);
}
function getCurrentBranch() {
try {
return execFileSync("git", ["branch", "--show-current"], {
cwd: repoRoot,
encoding: "utf8"
}).trim();
} catch {
return "main";
}
}
const rawArgs = process.argv.slice(2).filter((value) => value !== "--");
if (
rawArgs.length === 0 ||
rawArgs[0] === "help" ||
rawArgs[0] === "--help" ||
rawArgs[0] === "-h"
) {
printHelp();
process.exit(0);
}
fs.mkdirSync(path.dirname(defaultDbPath), { recursive: true });
fs.mkdirSync(defaultWorkspaceRoot, { recursive: true });
const command = rawArgs[0];
const orchArgs = [...rawArgs];
if (!hasFlag(orchArgs, "--db")) {
orchArgs.unshift(defaultDbPath);
orchArgs.unshift("--db");
}
if (command === "dispatch") {
if (!hasFlag(orchArgs, "--repo-path")) {
orchArgs.push("--repo-path", repoRoot);
}
if (!hasFlag(orchArgs, "--workspace-root")) {
orchArgs.push("--workspace-root", defaultWorkspaceRoot);
}
if (!hasFlag(orchArgs, "--strict-worktree")) {
orchArgs.push("--strict-worktree");
}
if (!hasFlag(orchArgs, "--base-ref")) {
orchArgs.push("--base-ref", getCurrentBranch());
}
}
const result = spawnSync(orchBin, orchArgs, {
cwd: repoRoot,
stdio: "inherit"
});
process.exit(result.status ?? 1);
+238
View File
@@ -0,0 +1,238 @@
import fs from "node:fs/promises";
import path from "node:path";
import { createRequire } from "node:module";
import { chromium } from "@playwright/test";
import { repoRoot } from "./core.mjs";
const require = createRequire(import.meta.url);
const { startStorybookServer, stopStorybookServer } = require(
path.resolve(repoRoot, "tests/e2e/support/storybook-server.cjs")
);
const baseURL = "http://127.0.0.1:6006";
const axeSourcePath = require.resolve("axe-core/axe.min.js");
const reportDir = path.join(repoRoot, ".artifacts", "a11y");
const reportPath = path.join(reportDir, "storybook-a11y.json");
const stories = [
{
id: "components-button--playground",
label: "Button playground"
},
{
id: "components-combobox--controlled",
label: "Combobox controlled"
},
{
id: "components-data-table--playground",
label: "Data table playground"
},
{
id: "components-datepicker--playground",
label: "Date picker playground",
prepare: async (page) => {
await page.getByRole("combobox", { name: "Launch date" }).click();
await page.getByRole("grid").waitFor({ state: "visible" });
}
},
{
id: "components-dialog--playground",
label: "Dialog playground",
prepare: async (page) => {
await page.getByRole("button", { name: "Open approval dialog" }).click();
await page.getByRole("dialog", { name: "Launch this release?" }).waitFor({ state: "visible" });
}
},
{
id: "components-dropdownmenu--states",
label: "Dropdown menu states",
prepare: async (page) => {
await page.getByRole("button", { name: "Review lane menu" }).click();
await page.getByRole("menu").waitFor({ state: "visible" });
}
},
{
id: "components-form--launch-settings",
label: "Form launch settings",
query: "globals=motion:reduced"
},
{
id: "components-popover--playground",
label: "Popover playground",
prepare: async (page) => {
await page.getByRole("button", { name: "Inspect summary" }).click();
await page.getByText("Release health").waitFor({ state: "visible" });
}
},
{
id: "components-sheet--playground",
label: "Sheet playground",
prepare: async (page) => {
await page.getByRole("button", { name: "Open right sheet" }).click();
await page.getByRole("dialog", { name: "Launch settings" }).waitFor({ state: "visible" });
}
}
];
function buildStoryUrl(story) {
const params = new URLSearchParams({
id: story.id,
viewMode: "story"
});
if (story.query) {
for (const [key, value] of new URLSearchParams(story.query).entries()) {
params.set(key, value);
}
}
return `${baseURL}/iframe.html?${params.toString()}`;
}
async function gotoStory(page, story) {
await page.goto(buildStoryUrl(story));
await page.waitForLoadState("domcontentloaded");
await page.waitForFunction(
(storyId) => {
const title = document.title.toLowerCase();
const root = document.getElementById("storybook-root");
return (
title.includes(String(storyId).toLowerCase()) ||
Boolean(root && root.childElementCount > 0)
);
},
story.id,
{ timeout: 30_000 }
);
}
function summarizeResults(results) {
return results.map((result) => ({
description: result.description,
help: result.help,
helpUrl: result.helpUrl,
id: result.id,
impact: result.impact,
nodeCount: result.nodes.length,
nodes: result.nodes.map((node) => ({
failureSummary: node.failureSummary,
html: node.html,
target: node.target
}))
}));
}
async function runAxe(page) {
return page.evaluate(async () => {
const root = document.getElementById("storybook-root") ?? document.body;
return window.axe.run(root, {
resultTypes: ["violations", "incomplete"]
});
});
}
async function main() {
const axeSource = await fs.readFile(axeSourcePath, "utf8");
await fs.mkdir(reportDir, { recursive: true });
await startStorybookServer();
const browser = await chromium.launch({
headless: true
});
const context = await browser.newContext();
await context.addInitScript({ content: axeSource });
const report = {
checkedAt: new Date().toISOString(),
errors: [],
incompleteCount: 0,
stories: [],
storyCount: stories.length,
violationCount: 0
};
try {
const page = await context.newPage();
for (const story of stories) {
process.stdout.write(`[a11y] checking ${story.id}\n`);
try {
await gotoStory(page, story);
if (story.prepare) {
await story.prepare(page);
}
const result = await runAxe(page);
const violations = summarizeResults(result.violations);
const incomplete = summarizeResults(result.incomplete);
report.violationCount += violations.length;
report.incompleteCount += incomplete.length;
report.stories.push({
id: story.id,
incomplete,
label: story.label,
url: buildStoryUrl(story),
violations
});
process.stdout.write(
`[a11y] ${story.id}: ${violations.length} violations, ${incomplete.length} incomplete\n`
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
report.errors.push({
id: story.id,
label: story.label,
message
});
report.stories.push({
error: message,
id: story.id,
incomplete: [],
label: story.label,
url: buildStoryUrl(story),
violations: []
});
process.stdout.write(`[a11y] ${story.id}: error: ${message}\n`);
}
}
} finally {
await context.close();
await browser.close();
await stopStorybookServer();
}
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
process.stdout.write(`[a11y] Report written to ${path.relative(repoRoot, reportPath)}\n`);
if (report.incompleteCount > 0) {
process.stdout.write(
`[a11y] ${report.incompleteCount} incomplete results detected. Review the JSON report before merge.\n`
);
}
if (report.errors.length > 0) {
process.stdout.write(
`[a11y] ${report.errors.length} story checks failed before axe completed. Failing validation.\n`
);
process.exit(1);
}
if (report.violationCount > 0) {
process.stdout.write(
`[a11y] ${report.violationCount} violations detected. Failing validation.\n`
);
process.exit(1);
}
}
await main();
+92
View File
@@ -0,0 +1,92 @@
import path from "node:path";
import {
getChangedFiles,
selectSuitesForChangedFiles,
writeHarnessArtifact,
repoRoot
} from "./core.mjs";
function parseArgs(argv) {
const options = {
changedFiles: [],
from: null,
json: false,
to: null
};
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index];
if (current === "--") {
continue;
}
if (current === "--json") {
options.json = true;
continue;
}
if (current === "--changed-file" || current === "--from" || current === "--to") {
const next = argv[index + 1];
if (!next) {
throw new Error(`Expected a value after ${current}.`);
}
if (current === "--changed-file") {
options.changedFiles.push(next);
} else if (current === "--from") {
options.from = next;
} else {
options.to = next;
}
index += 1;
continue;
}
throw new Error(`Unknown argument: ${current}`);
}
return options;
}
const options = parseArgs(process.argv.slice(2));
const changedFiles = getChangedFiles(options);
const selection = selectSuitesForChangedFiles(changedFiles);
const reportPath = writeHarnessArtifact("selection", {
...selection,
selectedAt: new Date().toISOString()
});
if (options.json) {
process.stdout.write(
`${JSON.stringify({ ...selection, reportPath: path.relative(repoRoot, reportPath) }, null, 2)}\n`
);
process.exit(0);
}
process.stdout.write(`Harness selection report written to ${path.relative(repoRoot, reportPath)}\n`);
if (selection.suites.length === 0) {
process.stdout.write("No harness suites selected for the current diff.\n");
} else {
process.stdout.write("Selected harness suites:\n");
for (const suiteName of selection.suites) {
process.stdout.write(`- ${suiteName}\n`);
for (const reason of selection.reasons[suiteName] ?? []) {
process.stdout.write(` - ${reason.file}: ${reason.reason}\n`);
}
}
}
if (selection.unmatchedFiles.length > 0) {
process.stdout.write("Unmatched files:\n");
for (const filePath of selection.unmatchedFiles) {
process.stdout.write(`- ${filePath}\n`);
}
}
+160
View File
@@ -0,0 +1,160 @@
import path from "node:path";
import { spawnSync } from "node:child_process";
import {
ensureKnownSuite,
formatSuiteListing,
getChangedFiles,
getCommandsForSuites,
pnpmBin,
repoRoot,
selectSuitesForChangedFiles,
suiteDefinitions,
writeHarnessArtifact
} from "./core.mjs";
function parseArgs(argv) {
const options = {
changedFiles: [],
dryRun: false,
from: null,
list: false,
to: null,
suite: "component"
};
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index];
if (current === "--dry-run") {
options.dryRun = true;
continue;
}
if (current === "--list") {
options.list = true;
continue;
}
if (
current === "--changed-file" ||
current === "--from" ||
current === "--suite" ||
current === "--to"
) {
const next = argv[index + 1];
if (!next) {
throw new Error(`Expected a value after ${current}.`);
}
if (current === "--changed-file") {
options.changedFiles.push(next);
} else if (current === "--from") {
options.from = next;
} else if (current === "--to") {
options.to = next;
} else {
options.suite = next;
}
index += 1;
continue;
}
if (current === "--") {
continue;
}
throw new Error(`Unknown argument: ${current}`);
}
return options;
}
function runCommand(command) {
const startedAt = new Date().toISOString();
const startTime = Date.now();
process.stdout.write(`\n[harness] ${command.label}\n`);
process.stdout.write(`[harness] pnpm ${command.args.join(" ")}\n\n`);
const result = spawnSync(pnpmBin, command.args, {
cwd: repoRoot,
shell: false,
stdio: "inherit"
});
return {
command: `pnpm ${command.args.join(" ")}`,
durationMs: Date.now() - startTime,
exitCode: result.status ?? 1,
label: command.label,
startedAt,
status: result.status === 0 ? "passed" : "failed"
};
}
const options = parseArgs(process.argv.slice(2));
if (options.list) {
process.stdout.write("Available harness suites:\n");
for (const suite of formatSuiteListing()) {
process.stdout.write(`- ${suite.name}: ${suite.description}\n`);
}
process.exit(0);
}
ensureKnownSuite(options.suite);
const selection =
options.suite === "changed"
? selectSuitesForChangedFiles(getChangedFiles(options))
: null;
const selectedSuites = selection?.suites ?? [options.suite];
const commands = getCommandsForSuites(selectedSuites);
const report = {
commands: [],
description:
options.suite === "changed"
? "Validate suites selected from the current git diff or working tree."
: suiteDefinitions[options.suite].description,
finishedAt: null,
startedAt: new Date().toISOString(),
status: options.dryRun ? "dry-run" : "passed",
suite: options.suite,
selectedSuites
};
if (selection) {
report.selection = selection;
}
for (const command of commands) {
if (options.dryRun) {
report.commands.push({
command: `pnpm ${command.args.join(" ")}`,
label: command.label,
status: "planned",
suite: command.suite
});
continue;
}
const result = runCommand(command);
report.commands.push(result);
if (result.status === "failed") {
report.status = "failed";
break;
}
}
report.finishedAt = new Date().toISOString();
const reportPath = writeHarnessArtifact(options.suite, report);
process.stdout.write(`\n[harness] Report written to ${path.relative(repoRoot, reportPath)}\n`);
if (report.status === "failed") {
process.exit(1);
}
+14 -10
View File
@@ -1,4 +1,9 @@
import { expect, test } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
async function gotoStory(page: Page, storyId: string) {
await page.goto(`/iframe.html?id=${storyId}&viewMode=story`);
await expect(page).toHaveTitle(new RegExp(storyId, "i"), { timeout: 15_000 });
}
test("storybook button, select, and static-motion form stories stay interactive", async ({ test("storybook button, select, and static-motion form stories stay interactive", async ({
page page
@@ -6,21 +11,20 @@ test("storybook button, select, and static-motion form stories stay interactive"
await page.goto("/"); await page.goto("/");
await expect(page).toHaveTitle(/storybook/i); await expect(page).toHaveTitle(/storybook/i);
await page.goto("/iframe.html?id=components-button--playground&viewMode=story"); await gotoStory(page, "components-button--playground");
const button = page.getByRole("button", { name: "Save changes" }); const button = page.getByRole("button", { name: "Save changes" });
await expect(button).toBeVisible(); await expect(button).toBeVisible();
await button.focus(); await button.focus();
await expect(button).toBeFocused(); await expect(button).toBeFocused();
await page.goto("/iframe.html?id=components-select--playground&viewMode=story"); await gotoStory(page, "components-select--playground");
const selectTrigger = page.locator('[data-slot="trigger"]').first(); const selectTrigger = page.locator('[data-slot="trigger"]').first();
await expect(selectTrigger).toBeVisible(); await expect(selectTrigger).toBeVisible();
await selectTrigger.click(); await selectTrigger.click();
await expect(page.getByRole("option", { name: "Legal review" })).toBeVisible(); await expect(page.getByRole("option", { name: "Legal review" })).toBeVisible();
await page.goto( await page.goto("/iframe.html?id=components-form--launch-settings&viewMode=story&globals=motion:reduced");
"/iframe.html?id=components-form--launch-settings&viewMode=story&globals=motion:reduced" await expect(page).toHaveTitle(/components-form--launch-settings/i, { timeout: 15_000 });
);
await page.getByRole("textbox", { name: "Email address" }).fill("team@cadence.dev"); await page.getByRole("textbox", { name: "Email address" }).fill("team@cadence.dev");
await page.getByRole("combobox", { name: "Review lane" }).click(); await page.getByRole("combobox", { name: "Review lane" }).click();
await page.getByRole("option", { name: "Legal" }).click(); await page.getByRole("option", { name: "Legal" }).click();
@@ -32,7 +36,7 @@ test("storybook button, select, and static-motion form stories stay interactive"
}); });
test("storybook data table story stays interactive", async ({ page }) => { test("storybook data table story stays interactive", async ({ page }) => {
await page.goto("/iframe.html?id=components-data-table--playground&viewMode=story"); await gotoStory(page, "components-data-table--playground");
const table = page.getByRole("table", { name: "Routing lanes" }); const table = page.getByRole("table", { name: "Routing lanes" });
await expect(table).toBeVisible(); await expect(table).toBeVisible();
@@ -52,19 +56,19 @@ test("storybook data table story stays interactive", async ({ page }) => {
}); });
test("storybook overlay stories stay interactive", async ({ page }) => { test("storybook overlay stories stay interactive", async ({ page }) => {
await page.goto("/iframe.html?id=components-dialog--playground&viewMode=story"); await gotoStory(page, "components-dialog--playground");
await page.getByRole("button", { name: "Open approval dialog" }).click(); await page.getByRole("button", { name: "Open approval dialog" }).click();
await expect(page.getByRole("dialog", { name: "Launch this release?" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Launch this release?" })).toBeVisible();
await page.getByRole("button", { name: "Close dialog" }).click(); await page.getByRole("button", { name: "Close dialog" }).click();
await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByRole("dialog")).toHaveCount(0);
await page.goto("/iframe.html?id=components-popover--playground&viewMode=story"); await gotoStory(page, "components-popover--playground");
await page.getByRole("button", { name: "Inspect summary" }).click(); await page.getByRole("button", { name: "Inspect summary" }).click();
await expect(page.getByText("Release health")).toBeVisible(); await expect(page.getByText("Release health")).toBeVisible();
await page.getByRole("button", { name: "Dismiss" }).click(); await page.getByRole("button", { name: "Dismiss" }).click();
await expect(page.getByText("Release health")).toHaveCount(0); await expect(page.getByText("Release health")).toHaveCount(0);
await page.goto("/iframe.html?id=components-sheet--playground&viewMode=story"); await gotoStory(page, "components-sheet--playground");
await page.getByRole("button", { name: "Open right sheet" }).click(); await page.getByRole("button", { name: "Open right sheet" }).click();
await expect(page.getByRole("dialog", { name: "Launch settings" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Launch settings" })).toBeVisible();
await page.getByRole("button", { name: "Close sheet" }).click(); await page.getByRole("button", { name: "Close sheet" }).click();
+5 -4
View File
@@ -172,11 +172,12 @@ import {
DialogTrigger, DialogTrigger,
Input Input
} from "@ai-ui/ui"; } from "@ai-ui/ui";
import { setTheme } from "@ai-ui/tokens"; import { setDynamicColor, setTheme } from "@ai-ui/tokens";
import "./styles.css"; import "./styles.css";
setTheme("morandi"); setTheme("violet");
setDynamicColor("#6750A4");
function App() { function App() {
return ( return (
@@ -285,12 +286,12 @@ async function main() {
}); });
console.log("Verifying CommonJS package entrypoints"); console.log("Verifying CommonJS package entrypoints");
await run(process.execPath, ["-e", 'const ui=require("@ai-ui/ui"); const tokens=require("@ai-ui/tokens"); if(!ui.Button||!tokens.setTheme){throw new Error("Missing CommonJS export.");}'], { await run(process.execPath, ["-e", 'const ui=require("@ai-ui/ui"); const tokens=require("@ai-ui/tokens"); if(!ui.Button||!tokens.setTheme||!tokens.setDynamicColor){throw new Error("Missing CommonJS export.");}'], {
cwd: projectDir cwd: projectDir
}); });
console.log("Verifying ESM package entrypoints"); console.log("Verifying ESM package entrypoints");
await run(process.execPath, ["--input-type=module", "-e", 'const ui=await import("@ai-ui/ui"); const tokens=await import("@ai-ui/tokens"); if(!ui.Button||!tokens.setTheme){throw new Error("Missing ESM export.");}'], { await run(process.execPath, ["--input-type=module", "-e", 'const ui=await import("@ai-ui/ui"); const tokens=await import("@ai-ui/tokens"); if(!ui.Button||!tokens.setTheme||!tokens.setDynamicColor){throw new Error("Missing ESM export.");}'], {
cwd: projectDir cwd: projectDir
}); });