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
- 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
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 reasoning effort
- 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:
- `DESIGN.md`
- `roadmap.md`
- `packages/ui/src/lib/contracts.ts`
- `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.
## Default workflow
1. Confirm the component or change fits the current system layers.
2. Reuse the existing contract helpers, slot names, state naming, and variant conventions.
3. Add or update Storybook stories so behavior is reviewable.
4. Add or update tests before treating the component as done.
5. Run the relevant validation commands locally.
1. Create or update an execution plan in `docs/exec-plans/` when the change is non-trivial.
2. Confirm the component or change fits the current system layers.
3. Reuse the existing contract helpers, slot names, state naming, and variant conventions.
4. Add or update Storybook stories so behavior is reviewable.
5. Add or update tests before treating the component as done.
6. Run the relevant validation commands locally.
## Authoring rules
@@ -107,6 +111,17 @@ pnpm typecheck
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:
```bash
@@ -115,6 +130,13 @@ pnpm build:docs
pnpm test:e2e:smoke
```
Broader gates:
```bash
pnpm harness:validate:pr
pnpm harness:validate:release
```
## Practical repo guidance
- 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 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 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
@@ -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.
- 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
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
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
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
installer to copy component source into another project:
@@ -166,6 +181,34 @@ uses:
- Playwright smoke coverage for core Storybook flows
- 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
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 type { Preview } from "@storybook/react";
import {
defaultSkin,
setSkin,
skinDetails,
skinNames
} from "@ai-ui/ui";
import { defaultSkin, setSkin } from "@ai-ui/ui";
import {
defaultMotionMode,
defaultTheme,
@@ -21,7 +16,7 @@ import {
const preview: Preview = {
globalTypes: {
theme: {
description: "Preview theme",
description: "Preview dynamic seed preset",
toolbar: {
icon: "paintbrush",
dynamicTitle: true,
@@ -32,7 +27,7 @@ const preview: Preview = {
}
},
motionMode: {
description: "Preview motion mode",
description: "Preview motion baseline",
toolbar: {
icon: "contrast",
dynamicTitle: true,
@@ -41,22 +36,10 @@ const preview: Preview = {
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: {
motionMode: defaultMotionMode,
skin: defaultSkin,
theme: defaultTheme
},
parameters: {
@@ -82,11 +65,11 @@ const preview: Preview = {
if (typeof document !== "undefined") {
setTheme(context.globals.theme ?? defaultTheme);
setMotionMode(context.globals.motionMode ?? defaultMotionMode);
setSkin(context.globals.skin ?? defaultSkin);
setSkin(defaultSkin);
document.body.dataset.theme = context.globals.theme ?? defaultTheme;
document.body.dataset.motion = context.globals.motionMode ?? defaultMotionMode;
document.body.dataset.skin = context.globals.skin ?? defaultSkin;
document.body.dataset.skin = defaultSkin;
}
return Story();
+36 -5
View File
@@ -146,11 +146,42 @@ export const Motion: Story = {
}
},
render: () => (
<div className="grid w-[720px] 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 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)]">
<div className="pointer-events-none absolute inset-0">
<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" />
<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" />
</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>
)
};
+40 -15
View File
@@ -52,21 +52,46 @@ export const Playground: Story = {
export const Grid: Story = {
render: () => (
<div className="grid w-[760px] gap-4 md:grid-cols-2">
<Card>
<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 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 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)]">
<div className="pointer-events-none absolute inset-0">
<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" />
<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" />
</div>
<div className="relative grid gap-4 self-start">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Showcase slabs
</p>
<h3 className="max-w-sm text-3xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Cards should feel like lit objects on a display plinth, not admin rectangles.
</h3>
</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>
)
};
@@ -1,6 +1,5 @@
import {
Badge,
Button,
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
@@ -9,7 +8,6 @@ import {
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
@@ -414,6 +414,7 @@ function DataTablePlayground() {
</SelectContent>
</Select>
<Button
className="border-[var(--color-border-strong)] bg-[var(--color-background)] text-[var(--color-foreground)] hover:bg-[var(--color-surface)]"
size="sm"
variant="secondary"
onClick={resetView}
@@ -8,7 +8,6 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
+6 -1
View File
@@ -173,7 +173,12 @@ function LaunchSettingsForm() {
>
Reset
</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 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="max-w-3xl space-y-3">
<p className="text-sm uppercase tracking-[0.28em] text-[var(--color-muted-foreground)]">
AI UI / Phase 0
AI UI / Foundation
</p>
<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>
<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 next phase can focus on component contracts instead of repo setup.
The workspace foundation now supports dynamic seed color, a shared UI package,
and a Storybook review surface. The next work can stay focused on component
quality instead of repo setup.
</p>
</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)]">
<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)]">
{themeNames.map((themeName) => (
<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 {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Skeleton,
Switch,
defaultSkin,
skinDetails,
skinNames,
type SkinName
defaultSkin
} from "@ai-ui/ui";
import {
createDynamicColorVariables,
defaultMotionMode,
defaultTheme,
motionModeDetails,
themeDetails,
themeNames,
type MotionModeName,
type ThemeName
} from "@ai-ui/tokens";
import type { Meta, StoryObj } from "@storybook/react";
type StyleContractShowcaseProps = {
type MaterialRuntimeShowcaseProps = {
motionMode: MotionModeName;
skin: SkinName;
theme: ThemeName;
};
function RuntimeBadge({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[var(--radius-full)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
<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>
{value}
</div>
);
}
function SkinPanel({
description,
name
function FloatingNote({
className,
lines,
title
}: {
description: string;
name: SkinName;
className?: string;
lines: string[];
title: string;
}) {
const [enabled, setEnabled] = useState(name !== "minimal");
return (
<article
data-skin={name}
className="grid gap-4 border p-5"
style={{
background: "var(--ui-surface-bg)",
borderColor: "var(--ui-surface-border)",
borderRadius: "var(--ui-surface-radius)",
boxShadow: "var(--ui-surface-shadow)",
backdropFilter: "blur(var(--ui-surface-backdrop-blur))"
}}
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 ?? ""}`}
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
data-skin=&quot;{name}&quot;
</p>
<div>
<h3 className="text-xl font-semibold text-[var(--color-foreground)]">
{skinDetails[name].label}
</h3>
<p className="mt-1 text-sm leading-6 text-[var(--color-muted-foreground)]">
{description}
</p>
</div>
</div>
<span
className="rounded-full border px-3 py-1 text-xs font-medium text-[var(--color-foreground)]"
style={{
background: "var(--ui-control-bg)",
borderColor: "var(--ui-control-border)",
borderRadius: "var(--ui-control-radius)",
boxShadow: "var(--ui-control-shadow)"
}}
>
phase 1
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div
className="relative overflow-hidden border p-4"
style={{
background: "var(--ui-control-bg)",
borderColor: "var(--ui-control-border)",
borderRadius: "var(--ui-control-radius)",
boxShadow: "var(--ui-control-shadow)"
}}
>
<p className="text-[0.72rem] font-medium uppercase tracking-[0.16em] text-[var(--color-muted-foreground)]">
{title}
</p>
<div className="grid gap-2">
{lines.map((line, index) => (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-4 top-0 h-12"
style={{
background:
"linear-gradient(135deg, color-mix(in oklch, var(--color-primary) 24%, transparent), transparent)",
mixBlendMode:
name === "glass" ? "screen" : name === "pixel" ? "multiply" : "normal",
opacity: "var(--ui-ornament-opacity)"
}}
key={line}
className="h-2.5 rounded-full bg-[var(--color-foreground)]/10"
style={{ width: `${100 - index * 16}%` }}
/>
<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>
</article>
);
}
function StyleContractShowcase({
motionMode,
skin,
theme
}: StyleContractShowcaseProps) {
function ShowcasePhone({
accentClassName,
className,
eyebrow,
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 (
<div className="min-h-screen bg-[var(--color-background)] px-6 py-10 text-[var(--color-foreground)] sm:px-10">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<header className="max-w-4xl space-y-4">
<p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
AI UI / Phase 1
AI UI / Material Runtime
</p>
<h1
className="font-semibold tracking-[var(--tracking-tight)]"
@@ -163,35 +200,107 @@ function StyleContractShowcase({
lineHeight: "var(--leading-tight)"
}}
>
Runtime skin switching is now a first-class docs contract, even before
component recipes are extracted.
Cadence UI now treats Material as the system language, with dynamic seed color
and one consistent motion baseline.
</h1>
<p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]">
Phase 1 introduces `data-skin`, root helpers, Storybook toolbar wiring, and a
dedicated skin CSS entrypoint. Phase 2 will move component recipes onto this
contract.
The old multi-skin showcase has been collapsed into a single rounded, tonal
component system. Personalization now comes from seed color rather than
competing style packs.
</p>
</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">
<RuntimeBadge label="theme" value={theme} />
<RuntimeBadge label="skin" value={skin} />
<RuntimeBadge label="motion" value={motionMode} />
<RuntimeBadge label="theme" value={themeDetails[theme].label} />
<RuntimeBadge label="motion" value={motionModeDetails[motionMode].label} />
<RuntimeBadge label="skin" value={defaultSkin} />
</section>
<section className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">What Phase 1 includes</h2>
<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-outline-variant)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">Runtime contract</h2>
<div className="mt-5 grid gap-3">
{[
"A new runtime attribute: `data-skin`",
"Public helpers from `@ai-ui/ui` for skin names, defaults, and root updates",
"A dedicated `@ai-ui/ui/skins.css` entrypoint imported by the docs app",
"Storybook globals that apply theme, skin, and interactive/static motion mode together"
"`setTheme(preset)` applies a named seed preset for docs and common app defaults.",
"`setDynamicColor(seed)` generates a full tonal palette from one color.",
"`setSkin(\"material\")` remains as the stable UI runtime marker, but no longer branches into multiple aesthetics.",
"`setMotionMode(mode)` keeps one default motion language plus a static accessibility override."
].map((item) => (
<div
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>
</div>
@@ -199,18 +308,18 @@ function StyleContractShowcase({
</div>
</article>
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">What still waits for Phase 2</h2>
<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">Material priorities</h2>
<div className="mt-5 grid gap-3">
{[
"Button, card, input, dialog, switch, and skeleton recipe extraction",
"Skin-specific component semantic variables such as `--button-*` and `--panel-*`",
"A docs comparison page where existing components fully restyle under each skin",
"Consumer-facing polish after the runtime contract and docs surface are stable"
"Dynamic color replaces fixed stylistic theme packs.",
"Tonal surfaces replace decorative gradients, blur, and ornamental skins.",
"Large radii and softer outlines create warmth without losing system discipline.",
"Motion stays predictable: expressive enough to communicate, restrained enough to stay calm."
].map((item) => (
<div
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>
</div>
@@ -219,13 +328,9 @@ function StyleContractShowcase({
</article>
</section>
<section className="grid gap-4">
{skinNames.map((name) => (
<SkinPanel
key={name}
description={skinDetails[name].note}
name={name}
/>
<section className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)_minmax(0,1fr)]">
{themeNames.map((name) => (
<SeedPanel key={name} name={name} />
))}
</section>
</div>
@@ -234,34 +339,34 @@ function StyleContractShowcase({
}
const meta = {
title: "Foundation/Style Contract",
component: StyleContractShowcase,
args: {
motionMode: defaultMotionMode,
skin: defaultSkin,
theme: defaultTheme
},
title: "Foundation/Material Runtime",
component: MaterialRuntimeShowcase,
parameters: {
docs: {
description: {
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."
}
}
},
render: (_args, context) => (
<StyleContractShowcase
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>;
},
layout: "fullscreen"
}
} satisfies Meta<typeof MaterialRuntimeShowcase>;
export default 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 {
motionModeDetails,
motionModeNames,
type MotionModeName
} from "@ai-ui/tokens";
import type { CSSProperties } from "react";
import {
Button,
Card,
@@ -10,162 +7,152 @@ import {
CardDescription,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Skeleton,
Switch,
skinDetails,
skinNames,
type SkinName
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateHeader,
EmptyStateTitle,
Input
} from "@ai-ui/ui";
import {
createDynamicColorVariables,
motionModeDetails,
motionModeNames,
themeDetails,
themeNames
} from "@ai-ui/tokens";
import type { Meta, StoryObj } from "@storybook/react";
function ClosePreviewIcon() {
function MiniPhone({
className,
title
}: {
className?: string;
title: string;
}) {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<path
d="m4.5 4.5 7 7m0-7-7 7"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.75"
/>
</svg>
<article
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 ?? ""}`}
>
<div className="flex items-center justify-between text-[0.58rem] font-medium text-[var(--color-muted-foreground)]">
<span>9:30</span>
<span className="h-1.5 w-5 rounded-full bg-[var(--color-foreground)]/45" />
</div>
<div className="mt-3 grid gap-3">
<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 }) {
return (
<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)]">
{children}
</span>
);
}
function SurfaceCluster({
motionMode,
themeName
}: {
motionMode: (typeof motionModeNames)[number];
themeName: (typeof themeNames)[number];
}) {
const theme = themeDetails[themeName];
function PanelPreview() {
return (
<div
className="grid gap-3 border p-4"
style={{
background: "var(--ui-panel-bg)",
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))"
}}
<article
data-motion={motionMode}
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 shadow-[var(--shadow-sm)] ${motionMode === "interactive" ? "motion-float" : ""}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--color-foreground)]">
Dialog panel contract
</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 className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{motionModeDetails[motionMode].label}
</p>
<h3 className="mt-1 text-xl font-semibold text-[var(--color-foreground)]">
{theme.label}
</h3>
</div>
<Button aria-hidden="true" size="icon" tabIndex={-1} variant="ghost">
<ClosePreviewIcon />
</Button>
</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>
<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 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>
<CardTitle>Release routing</CardTitle>
<CardTitle>Unified Material surface</CardTitle>
<CardDescription>
The same component tree should now pick up distinct skin treatments.
The palette changes, but density, radius, and tonal layering remain stable.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<Input
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>
<CardContent className="grid gap-4">
<Input aria-label={`${themeName} input`} defaultValue="Release cadence" />
<div className="flex flex-wrap gap-3">
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="subtle">Subtle</Button>
<Button variant="secondary">Tonal</Button>
<Button variant="subtle">Surface</Button>
</div>
</CardContent>
</Card>
<PanelPreview />
</section>
<EmptyState tone="subtle">
<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() {
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() {
function MaterialToneMatrix() {
return (
<div className="min-h-screen bg-[var(--color-background)] px-6 py-10 text-[var(--color-foreground)] sm:px-10">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-8">
<header className="max-w-4xl space-y-4">
<p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
AI UI / Phase 3
AI UI / Material Tone Matrix
</p>
<h1
className="font-semibold tracking-[var(--tracking-tight)]"
@@ -175,69 +162,75 @@ function StyleMatrixShowcase() {
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>
<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.
The grid uses nested `data-skin` and `data-motion` scopes so the same
building blocks can be
reviewed side by side.
This page is now the regression surface for tonal hierarchy. If a preset feels
off, the fix belongs in the token generator, not in a separate skin branch.
</p>
</header>
<section className="grid gap-4">
{motionModeNames.map((motionMode) => (
<div key={motionMode} className="grid gap-4">
<div>
<h2 className="text-2xl font-semibold">{motionModeDetails[motionMode].label}</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
{motionModeDetails[motionMode].note}
</p>
</div>
<div className="grid gap-4 xl:grid-cols-3">
{skinNames.map((skin) => (
<ComparisonCell
key={`${motionMode}-${skin}`}
motionMode={motionMode}
skin={skin}
/>
))}
</div>
<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">
<div className="pointer-events-none absolute inset-0">
<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 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" />
</div>
<div className="relative flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Tonal regression rig
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-[var(--tracking-tight)]">
Interactive mode should feel alive. Static mode should still feel expensive.
</h2>
</div>
))}
<div className="flex flex-wrap gap-3">
<Button>Interactive baseline</Button>
<Button variant="secondary">Static fallback</Button>
</div>
</div>
</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">
<div>
<h2 className="text-2xl font-semibold">Live overlay validation</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
Dialog still portals to the document root, so compare its real overlay and
panel treatment with the Storybook toolbar. The matrix above covers scoped
inline regression across interactive and static motion modes. The control below
covers the live overlay behavior.
</p>
</div>
<div className="flex justify-start lg:justify-end">
<MatrixDialogSandbox />
</div>
</section>
{motionModeNames.map((motionMode) => (
<section key={motionMode} className="grid gap-4">
<div>
<h2 className="text-2xl font-semibold">
{motionModeDetails[motionMode].label}
</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
{motionModeDetails[motionMode].note}
</p>
</div>
<div className="grid gap-4 xl:grid-cols-3">
{themeNames.map((themeName) => (
<SurfaceCluster
key={`${motionMode}-${themeName}`}
motionMode={motionMode}
themeName={themeName}
/>
))}
</div>
</section>
))}
</div>
</div>
);
}
const meta = {
title: "Foundation/Style Matrix",
component: StyleMatrixShowcase,
title: "Foundation/Material Tone Matrix",
component: MaterialToneMatrix,
parameters: {
docs: {
description: {
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;
+11 -9
View File
@@ -1,5 +1,6 @@
import {
colorTokens,
createDynamicColorVariables,
defaultTheme,
defaultMotionMode,
motionTokens,
@@ -13,6 +14,7 @@ import {
type ThemeName
} from "@ai-ui/tokens";
import type { Meta, StoryObj } from "@storybook/react";
import type { CSSProperties } from "react";
type TokensOverviewProps = {
motionMode: MotionModeName;
@@ -63,8 +65,8 @@ function ThemeCard({ themeName }: { themeName: ThemeName }) {
return (
<article
data-theme={themeName}
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-background)] p-5 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]"
style={createDynamicColorVariables(theme.seed) as CSSProperties}
>
<div className="flex items-start justify-between gap-4">
<div>
@@ -143,19 +145,19 @@ function TokensOverview({
}}
>
The first stable token layer defines color, type, surface depth, and
motion rhythm.
motion rhythm around a Material You style system.
</h1>
<p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]">
Theme switching now happens at the token layer, not inside component
implementations. Motion is also represented as named tokens and starter
recipes rather than ad hoc transition values.
Seed color now drives the palette. Components inherit tonal surfaces and
emphasis roles from the token layer instead of shipping disconnected visual
skins.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] px-4 py-3 shadow-[var(--shadow-xs)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Active Theme
Active Seed Preset
</p>
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">
{themeDetails[theme].label}
@@ -175,10 +177,10 @@ function TokensOverview({
<section className="space-y-4">
<div className="flex items-center justify-between gap-4">
<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)]">
These cards render their own nested theme roots, so tokens can be
validated side by side without touching component code.
These cards render their own seed-derived palettes, so the tonal system
can be reviewed side by side without changing component code.
</p>
</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
Proposed
Superseded by the Material You convergence work started on 2026-03-23.
## Last Updated
@@ -10,6 +10,10 @@ Proposed
## 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
styles without forking component behavior or losing the existing source-owned model.
+15
View File
@@ -12,6 +12,19 @@
"changeset": "changeset",
"changeset:status": "changeset status --verbose",
"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 .",
"registry:build": "node ./scripts/build-registry.mjs",
"registry:check": "node ./scripts/build-registry.mjs --check",
@@ -21,6 +34,7 @@
"release:version": "pnpm changeset version && pnpm install --lockfile-only && pnpm registry:build",
"test": "pnpm --filter @ai-ui/ui 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:package:consumer": "node ./tests/package-consumer/smoke.mjs",
"test:registry:consumer": "node ./tests/registry/consumer-smoke.mjs",
@@ -31,6 +45,7 @@
"@changesets/cli": "^2.30.0",
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.55.0",
"axe-core": "^4.11.1",
"@storybook/addon-a11y": "^8.6.14",
"@storybook/addon-essentials": "^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 const defaultTheme: ThemeName = "morandi";
export const defaultTheme: ThemeName = "violet";
export const themeDetails = {
morandi: {
label: "Morandi",
note: "Muted dusty neutrals with a calm, understated luxury mood"
violet: {
label: "Violet Seed",
note: "A Material baseline seeded from a soft violet, close to the reference M3 demos.",
seed: "#6750A4"
},
earth: {
label: "Earth",
note: "Organic browns, terracotta warmth, olive depth, and sandstone calm"
jade: {
label: "Jade Seed",
note: "Cool green-blue tonal families for calmer product surfaces and lower visual heat.",
seed: "#0B8F83"
},
brand: {
label: "Brand",
note: "Verdant accent scaffold"
sunset: {
label: "Sunset Seed",
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 type MotionModeName = (typeof motionModeNames)[number];
@@ -25,12 +28,12 @@ export const defaultMotionMode: MotionModeName = "interactive";
export const motionModeDetails = {
interactive: {
label: "Interactive",
note: "Micro-interactions with hover lift, press feedback, focus transitions, and animated state changes"
label: "Standard",
note: "The default Material motion language for state, depth, and spatial feedback."
},
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 }>;
@@ -44,66 +47,84 @@ export const motionScale = {
export const colorTokens = [
{ 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: "surface", cssVar: "--color-surface", role: "Secondary surface backgrounds" },
{
name: "surface-strong",
cssVar: "--color-surface-strong",
role: "Elevated surface emphasis"
name: "on-surface-variant",
cssVar: "--color-on-surface-variant",
role: "Supporting text, dividers, and lower-emphasis iconography"
},
{ name: "card", cssVar: "--color-card", role: "Cards and floating panels" },
{ name: "border", cssVar: "--color-border", role: "Default dividers and input borders" },
{ name: "primary", cssVar: "--color-primary", role: "Filled actions and active emphasis" },
{
name: "border-strong",
cssVar: "--color-border-strong",
role: "Higher emphasis dividers"
name: "primary-container",
cssVar: "--color-primary-container",
role: "Tonal action backgrounds and highlighted containers"
},
{ name: "primary", cssVar: "--color-primary", role: "Primary actions and highlights" },
{
name: "secondary",
cssVar: "--color-secondary",
role: "Secondary fills and supporting actions"
name: "secondary-container",
cssVar: "--color-secondary-container",
role: "Filled tonal surfaces for secondary emphasis"
},
{ name: "muted", cssVar: "--color-muted", role: "Subtle supporting surfaces" },
{
name: "muted-foreground",
cssVar: "--color-muted-foreground",
role: "Secondary text and captions"
name: "tertiary-container",
cssVar: "--color-tertiary-container",
role: "Expressive accent container for supportive highlights"
},
{ name: "accent", cssVar: "--color-accent", role: "Moments of emphasis or delight" },
{ name: "success", cssVar: "--color-success", role: "Success feedback" },
{ name: "warning", cssVar: "--color-warning", role: "Warning feedback" },
{ name: "destructive", cssVar: "--color-destructive", role: "Destructive actions" }
{ name: "outline", cssVar: "--color-outline", role: "Primary stroke and input outline color" },
{
name: "outline-variant",
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;
export const typographyTokens = [
{
name: "caption",
fontVar: "--text-xs",
lineHeightVar: "--leading-normal",
name: "label",
fontVar: "--text-sm",
lineHeightVar: "--leading-snug",
familyVar: "--font-sans",
sample: "Small labels, metadata, and supporting notes."
sample: "Labels stay crisp, compact, and readable across controls."
},
{
name: "body",
fontVar: "--text-base",
lineHeightVar: "--leading-normal",
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",
lineHeightVar: "--leading-loose",
familyVar: "--font-sans",
sample: "Lead text introduces a surface without becoming display copy."
lineHeightVar: "--leading-snug",
familyVar: "--font-display",
sample: "Titles feel warm and rounded, without drifting into editorial flourish."
},
{
name: "display",
fontVar: "--text-4xl",
lineHeightVar: "--leading-tight",
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;
@@ -149,6 +170,61 @@ export const motionTokens = {
]
} 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) {
if (root) {
return root;
@@ -161,6 +237,163 @@ function getTargetElement(root?: HTMLElement) {
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) {
const target = getTargetElement(root);
@@ -168,7 +401,23 @@ export function setTheme(theme: ThemeName, root?: HTMLElement) {
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) {
+108 -6
View File
@@ -2,14 +2,14 @@
:root[data-motion="interactive"],
[data-motion="interactive"] {
--dur-instant: 1ms;
--dur-fast: 140ms;
--dur-base: 200ms;
--dur-fast: 120ms;
--dur-base: 180ms;
--dur-slow: 280ms;
--dur-deliberate: 300ms;
--dur-deliberate: 360ms;
--ease-standard: cubic-bezier(0.25, 1, 0.5, 1);
--ease-emphasized: cubic-bezier(0.22, 1, 0.36, 1);
--ease-exit: cubic-bezier(0.3, 1, 0.5, 1);
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
--distance-xs: 4px;
--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 {
transition-duration: var(--dur-base);
transition-property: color, background-color, border-color, box-shadow, opacity,
@@ -166,6 +241,33 @@
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 {
transition-duration: var(--dur-fast);
transition-property: box-shadow, outline-color, border-color;
+76 -106
View File
@@ -1,130 +1,100 @@
:root {
--font-sans: "Avenir Next", "Segoe UI", sans-serif;
--font-display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia,
serif;
color-scheme: light;
--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;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 2rem;
--text-4xl: clamp(2.5rem, 4vw, 4rem);
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
--text-3xl: 2.25rem;
--text-4xl: clamp(2.75rem, 4vw, 4.75rem);
--leading-tight: 1.1;
--leading-snug: 1.25;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-loose: 1.7;
--leading-loose: 1.65;
--tracking-tight: -0.03em;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-caps: 0.18em;
--tracking-caps: 0.12em;
--border-width-thin: 1px;
--border-width-strong: 1.5px;
--border-width-strong: 1px;
--radius-xs: 8px;
--radius-sm: 12px;
--radius-md: 18px;
--radius-sm: 16px;
--radius-md: 20px;
--radius-lg: 28px;
--radius-xl: 40px;
--radius-xl: 36px;
--radius-full: 999px;
--shadow-xs: 0 1px 2px oklch(0.28 0.02 55 / 0.06);
--shadow-sm: 0 8px 24px oklch(0.28 0.02 55 / 0.08);
--shadow-md: 0 18px 48px oklch(0.28 0.03 55 / 0.12);
--shadow-lg: 0 32px 72px oklch(0.2 0.02 55 / 0.16);
}
--shadow-xs: 0 1px 2px rgb(25 18 42 / 0.08), 0 4px 10px rgb(94 74 145 / 0.05);
--shadow-sm: 0 8px 22px rgb(83 63 128 / 0.12), 0 2px 8px rgb(25 18 42 / 0.06);
--shadow-md: 0 18px 42px rgb(83 63 128 / 0.16), 0 6px 18px rgb(25 18 42 / 0.1);
--shadow-lg: 0 30px 70px rgb(83 63 128 / 0.18), 0 14px 32px rgb(25 18 42 / 0.12);
:root,
[data-theme="morandi"] {
color-scheme: light;
--color-background: color-mix(in oklch, #d4b5a0 10%, white 90%);
--color-foreground: #544c46;
--color-surface: color-mix(in oklch, #a6b3a7 12%, white 88%);
--color-surface-strong: color-mix(in oklch, #d4b5a0 20%, white 80%);
--color-surface-contrast: #6a615a;
--color-border: color-mix(in oklch, #9b8e82 34%, white 66%);
--color-border-strong: #9b8e82;
--color-input: var(--color-border);
--color-ring: #8e9aaf;
--color-primary: #6f7785;
--color-primary-foreground: #f7f3ef;
--color-secondary: #a6b3a7;
--color-secondary-foreground: #495247;
--color-muted: color-mix(in oklch, #d4b5a0 18%, white 82%);
--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);
}
--color-background: hsl(22 18% 96%);
--color-foreground: hsl(259 6% 15%);
--color-surface: hsl(22 12% 94%);
--color-surface-strong: hsl(278 24% 87%);
--color-surface-contrast: hsl(259 10% 28%);
--color-surface-dim: hsl(22 10% 87%);
--color-surface-bright: hsl(22 18% 98%);
--color-surface-container-low: hsl(18 20% 97%);
--color-surface-container: hsl(278 20% 91%);
--color-surface-container-high: hsl(274 24% 88%);
--color-surface-container-highest: hsl(112 22% 84%);
--color-outline: hsl(259 10% 56%);
--color-outline-variant: hsl(259 12% 82%);
--color-border: var(--color-outline-variant);
--color-border-strong: var(--color-outline);
--color-input: var(--color-outline);
--color-ring: hsl(259 40% 42%);
[data-theme="earth"] {
color-scheme: light;
--color-background: color-mix(in oklch, #d4c5a9 34%, white 66%);
--color-foreground: #4f3c27;
--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);
}
--color-primary: hsl(264 38% 45%);
--color-primary-foreground: hsl(259 24% 98%);
--color-primary-container: hsl(272 38% 86%);
--color-on-primary-container: hsl(264 30% 20%);
[data-theme="brand"] {
color-scheme: light;
--color-background: oklch(0.972 0.016 172);
--color-foreground: oklch(0.24 0.03 182);
--color-surface: oklch(0.946 0.018 172);
--color-surface-strong: oklch(0.91 0.024 172);
--color-surface-contrast: oklch(0.29 0.034 182);
--color-border: oklch(0.83 0.026 172);
--color-border-strong: oklch(0.67 0.045 176);
--color-input: var(--color-border);
--color-ring: oklch(0.53 0.12 190);
--color-primary: oklch(0.48 0.12 188);
--color-primary-foreground: oklch(0.97 0.008 172);
--color-secondary: oklch(0.82 0.066 156);
--color-secondary-foreground: oklch(0.2 0.02 178);
--color-muted: oklch(0.91 0.018 172);
--color-muted-foreground: oklch(0.42 0.03 180);
--color-accent: oklch(0.75 0.105 130);
--color-accent-foreground: oklch(0.2 0.02 160);
--color-success: oklch(0.6 0.12 155);
--color-success-foreground: oklch(0.98 0.006 170);
--color-warning: oklch(0.76 0.13 86);
--color-warning-foreground: oklch(0.22 0.02 74);
--color-destructive: oklch(0.53 0.16 30);
--color-destructive-foreground: oklch(0.98 0.01 80);
--color-card: color-mix(in oklch, var(--color-surface) 88%, white 12%);
--color-secondary: hsl(286 24% 90%);
--color-secondary-foreground: hsl(284 24% 22%);
--color-secondary-container: hsl(286 24% 90%);
--color-on-secondary-container: hsl(284 24% 22%);
--color-tertiary: hsl(128 18% 40%);
--color-tertiary-foreground: hsl(128 16% 98%);
--color-tertiary-container: hsl(112 24% 84%);
--color-on-tertiary-container: hsl(128 18% 22%);
--color-muted: var(--color-surface-container);
--color-muted-foreground: hsl(259 10% 34%);
--color-accent: var(--color-tertiary-container);
--color-accent-foreground: var(--color-on-tertiary-container);
--color-success: hsl(152 35% 42%);
--color-success-foreground: hsl(152 18% 98%);
--color-warning: hsl(76 62% 48%);
--color-warning-foreground: hsl(76 20% 14%);
--color-destructive: hsl(12 72% 44%);
--color-destructive-foreground: hsl(12 20% 98%);
--color-error: var(--color-destructive);
--color-on-error: var(--color-destructive-foreground);
--color-error-container: hsl(12 58% 88%);
--color-on-error-container: hsl(12 72% 20%);
--color-card: var(--color-surface-container-low);
--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 userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
@@ -9,7 +11,9 @@ import {
AccordionTrigger
} from "./accordion";
function ExampleAccordion(props: any = {}) {
type ExampleAccordionProps = ComponentProps<typeof Accordion>;
function ExampleAccordion(props: ExampleAccordionProps = {}) {
return (
<Accordion {...props}>
<AccordionItem value="editorial">
@@ -85,7 +89,9 @@ describe("Accordion", () => {
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(content).toHaveAttribute("data-state", "open");
+2 -1
View File
@@ -19,7 +19,8 @@ export const cardVariants = cva(
},
interactive: {
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: {
+1 -1
View File
@@ -42,7 +42,7 @@ describe("Combobox", () => {
it("renders a selected value, filters options, and updates uncontrolled state", async () => {
const user = userEvent.setup();
const loadingView = render(
render(
<Combobox
aria-label="Review lane"
defaultValue="design"
+1 -1
View File
@@ -157,7 +157,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
return haystack.includes(query);
});
}, [items, resolvedSearchValue]);
}, [filter, items, resolvedSearchValue]);
const listboxId = filteredItems.length > 0 ? `${controlId}-listbox` : undefined;
const groupedItems = useMemo(() => {
+90 -97
View File
@@ -32,11 +32,9 @@ import {
datePickerFooterVariants,
datePickerGridVariants,
datePickerHeaderVariants,
datePickerMonthLabelVariants,
datePickerNavigationVariants,
datePickerRootVariants,
datePickerSelectorsVariants,
datePickerTriggerVariants,
datePickerWeekdayVariants
} from "./date-picker.variants";
import { cn } from "../lib/cn";
@@ -56,10 +54,6 @@ function normalizeDate(value?: Date) {
: undefined;
}
function getDateKey(value?: Date) {
return value ? `${value.getFullYear()}-${value.getMonth()}-${value.getDate()}` : "";
}
function sameDay(left?: Date, right?: Date) {
if (!left || !right) {
return false;
@@ -232,111 +226,109 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
},
ref
) {
const reactId = useId();
const today = useMemo(() => normalizeDate(new Date()), []);
const normalizedControlledValue = useMemo(
() => normalizeDate(value),
[value ? getDateKey(value) : ""]
);
const normalizedDefaultValue = useMemo(
() => normalizeDate(defaultValue),
[defaultValue ? getDateKey(defaultValue) : ""]
);
const normalizedDefaultMonth = useMemo(
() => normalizeDate(defaultMonth),
[defaultMonth ? getDateKey(defaultMonth) : ""]
);
const [selectedDate, setSelectedDate] = useControllableState<DatePickerValue>({
controlledValue: normalizedControlledValue,
defaultValue: normalizedDefaultValue,
onChange: onValueChange
const reactId = useId();
const today = useMemo(() => normalizeDate(new Date()), []);
const normalizedControlledValue = normalizeDate(value);
const normalizedDefaultValue = normalizeDate(defaultValue);
const normalizedDefaultMonth = normalizeDate(defaultMonth);
const [selectedDate, setSelectedDate] = useControllableState<DatePickerValue>({
controlledValue: normalizedControlledValue,
defaultValue: normalizedDefaultValue,
onChange: onValueChange
});
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const resolvedOpen = open ?? uncontrolledOpen;
const [visibleMonth, setVisibleMonth] = useState(
startOfMonth(
normalizedDefaultMonth ?? normalizedControlledValue ?? normalizedDefaultValue ?? today ?? new Date()
)
);
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(() => {
if (selectedDate) {
setVisibleMonth(startOfMonth(selectedDate));
}
}, [selectedDate]);
return () => cancelAnimationFrame(frame);
}, [selectedDate]);
const monthLabel = formatMonthLabel(visibleMonth, locale);
const weekdays = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
const base = new Date(2025, 0, 5 + getWeekStartIndex(weekStartsOn));
const monthLabel = formatMonthLabel(visibleMonth, locale);
const weekdays = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
const base = new Date(2025, 0, 5 + getWeekStartIndex(weekStartsOn));
return Array.from({ length: 7 }, (_, index) => {
const day = new Date(base);
day.setDate(base.getDate() + index);
return formatter.format(day);
});
}, [locale, weekStartsOn]);
const days = useMemo(
() => buildMonthGrid(visibleMonth, weekStartsOn),
[visibleMonth, weekStartsOn]
);
const yearOptions = useMemo(
() => getYearOptions(visibleMonth, selectedDate),
[selectedDate, visibleMonth]
);
const selectedIndex = days.findIndex((day) => sameDay(day, selectedDate));
return Array.from({ length: 7 }, (_, index) => {
const day = new Date(base);
day.setDate(base.getDate() + index);
return formatter.format(day);
});
}, [locale, weekStartsOn]);
const days = useMemo(
() => buildMonthGrid(visibleMonth, weekStartsOn),
[visibleMonth, weekStartsOn]
);
const yearOptions = useMemo(
() => getYearOptions(visibleMonth, selectedDate),
[selectedDate, visibleMonth]
);
const selectedIndex = days.findIndex((day) => sameDay(day, selectedDate));
useEffect(() => {
dayRefs.current = [];
}, [visibleMonth]);
useEffect(() => {
dayRefs.current = [];
}, [visibleMonth]);
useEffect(() => {
if (!resolvedOpen) {
return;
}
useEffect(() => {
if (!resolvedOpen) {
return;
}
const focusIndex =
selectedIndex >= 0
? selectedIndex
: days.findIndex(
(day) =>
day.getMonth() === visibleMonth.getMonth() &&
sameDay(day, today)
);
const focusIndex =
selectedIndex >= 0
? selectedIndex
: days.findIndex(
(day) => day.getMonth() === visibleMonth.getMonth() && sameDay(day, today)
);
const frame = requestAnimationFrame(() => {
dayRefs.current[focusIndex >= 0 ? focusIndex : 0]?.focus();
});
const frame = requestAnimationFrame(() => {
dayRefs.current[focusIndex >= 0 ? focusIndex : 0]?.focus();
});
return () => cancelAnimationFrame(frame);
}, [days, resolvedOpen, selectedIndex, today, visibleMonth]);
return () => cancelAnimationFrame(frame);
}, [days, resolvedOpen, selectedIndex, today, visibleMonth]);
const setOpenState = (nextOpen: boolean) => {
if (open === undefined) {
setUncontrolledOpen(nextOpen);
}
const setOpenState = (nextOpen: boolean) => {
if (open === undefined) {
setUncontrolledOpen(nextOpen);
}
onOpenChange?.(nextOpen);
};
onOpenChange?.(nextOpen);
};
const goToMonth = (offset: number) => {
const next = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + offset, 1);
setVisibleMonth(next);
onMonthChange?.(next);
};
const goToMonth = (offset: number) => {
const next = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + offset, 1);
setVisibleMonth(next);
onMonthChange?.(next);
};
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpenState(true);
}
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpenState(true);
}
if (event.key === "Escape") {
setOpenState(false);
}
};
if (event.key === "Escape") {
setOpenState(false);
}
};
const handleDayKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const handleDayKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const movementMap: Record<string, number> = {
ArrowDown: 7,
ArrowLeft: -1,
@@ -408,6 +400,7 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
<div {...createSlot("field")} className={datePickerFieldVariants()}>
<Input
{...props}
aria-controls={popupId}
aria-expanded={resolvedOpen}
aria-haspopup="dialog"
className={cn("cursor-pointer pr-20", className)}
@@ -430,7 +423,7 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
</div>
</PopoverAnchor>
<PopoverContent className={datePickerContentVariants()} padding="sm" size="xl">
<PopoverContent className={datePickerContentVariants()} id={popupId} padding="sm" size="xl">
<div className="grid gap-3">
<div {...createSlot("header")} className={datePickerHeaderVariants()}>
<div className={datePickerNavigationVariants()}>
@@ -16,5 +16,5 @@ export const switchVariants = 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)]",
"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",
exitFade: "motion-exit-fade",
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"
} 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", () => {
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", () => {
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 const defaultSkin: SkinName = "minimal";
export const defaultSkin: SkinName = "material";
export const skinDetails = {
minimal: {
label: "Minimal",
note: "Restrained surfaces and low-ornament defaults"
},
glass: {
label: "Glass",
note: "Translucent layers, brighter edges, and blurred panels"
},
pixel: {
label: "Pixel",
note: "Hard edges, crisp borders, and stepped shadows"
material: {
label: "Material",
note: "One tonal, rounded, dynamic-color-first component language"
}
} as const satisfies Record<SkinName, { label: string; note: string }>;
+205 -376
View File
@@ -1,422 +1,251 @@
:root,
[data-skin="minimal"] {
--ui-canvas-image: radial-gradient(
circle at top,
color-mix(in oklch, var(--color-primary) 8%, transparent),
transparent 58%
);
[data-skin="material"] {
--ui-canvas-image:
radial-gradient(
circle at 18% 12%,
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-surface-bg: color-mix(in oklch, var(--color-card) 88%, white 12%);
--ui-surface-border: color-mix(in oklch, var(--color-border) 92%, white 8%);
--ui-surface-shadow: var(--shadow-sm);
--ui-surface-radius: var(--radius-lg);
--ui-surface-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface) 76%, var(--color-surface-bright) 24%),
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-control-bg: color-mix(in oklch, var(--color-background) 92%, white 8%);
--ui-control-border: var(--color-border);
--ui-control-shadow: var(--shadow-xs);
--ui-control-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) 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-ornament-opacity: 0.1;
--ui-ornament-opacity: 0;
--ui-ornament-mix: normal;
--ui-button-radius: var(--radius-sm);
--ui-button-radius: var(--radius-full);
--ui-button-border-width: 1px;
--ui-button-transition-duration: var(--dur-fast);
--ui-button-sheen-opacity: 0.14;
--ui-button-sheen-mix: screen;
--ui-button-sheen-gradient: linear-gradient(
120deg,
transparent 0%,
rgba(255, 255, 255, 0.24) 45%,
transparent 100%
--ui-button-sheen-opacity: 0;
--ui-button-sheen-mix: normal;
--ui-button-sheen-gradient: linear-gradient(180deg, transparent, transparent);
--ui-button-primary-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-primary-container) 82%, white 18%),
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: color-mix(in oklch, var(--color-primary) 90%, black 10%);
--ui-button-primary-fg: var(--color-primary-foreground);
--ui-button-primary-hover-bg: linear-gradient(
180deg,
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-shadow: var(--shadow-xs);
--ui-button-secondary-bg: var(--color-secondary);
--ui-button-secondary-hover-bg: color-mix(in oklch, var(--color-secondary) 88%, black 12%);
--ui-button-secondary-fg: var(--color-secondary-foreground);
--ui-button-secondary-border: var(--color-border-strong);
--ui-button-secondary-shadow: none;
--ui-button-primary-shadow:
inset 0 1px 0 color-mix(in oklch, white 60%, transparent),
0 14px 26px color-mix(in oklch, var(--color-primary) 16%, transparent);
--ui-button-secondary-bg: linear-gradient(
180deg,
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-hover-bg: var(--color-surface);
--ui-button-ghost-fg: var(--color-foreground);
--ui-button-ghost-hover-bg: color-mix(
in oklch,
var(--color-surface-container-high) 72%,
transparent
);
--ui-button-ghost-fg: var(--color-primary);
--ui-button-ghost-border: transparent;
--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-border);
--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-subtle-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-high) 64%, var(--color-surface-bright) 36%),
color-mix(in oklch, var(--color-surface-container) 74%, var(--color-surface-bright) 26%)
);
--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-shadow: var(--shadow-xs);
--ui-button-hover-scale: 1.02;
--ui-button-press-scale: 0.98;
--ui-button-hover-translate: -1px;
--ui-button-hover-shadow: var(--shadow-sm);
--ui-button-active-shadow: var(--shadow-xs);
--ui-button-destructive-shadow:
inset 0 1px 0 color-mix(in oklch, white 42%, transparent),
0 12px 24px color-mix(in oklch, var(--color-error) 12%, transparent);
--ui-button-hover-scale: 1.024;
--ui-button-press-scale: 0.985;
--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-border-width: 2px;
--ui-card-radius: var(--radius-lg);
--ui-card-border-width: 1px;
--ui-card-bg: var(--color-card);
--ui-card-shadow: var(--shadow-sm);
--ui-card-default-bg: var(--color-card);
--ui-card-default-border: var(--color-border);
--ui-card-default-shadow: var(--shadow-sm);
--ui-card-subtle-bg: var(--color-surface);
--ui-card-subtle-border: color-mix(in oklch, var(--color-border) 86%, transparent);
--ui-card-subtle-shadow: var(--shadow-xs);
--ui-card-accent-bg: color-mix(in oklch, var(--color-primary) 8%, var(--color-card));
--ui-card-accent-border: color-mix(in oklch, var(--color-primary) 26%, var(--color-border));
--ui-card-accent-shadow: var(--shadow-sm);
--ui-card-hover-translate: -2px;
--ui-card-hover-shadow: var(--shadow-md);
--ui-card-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-low) 78%, var(--color-surface-bright) 22%),
color-mix(in oklch, var(--color-surface-container) 18%, var(--color-surface-container-low) 82%)
);
--ui-card-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-default-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-low) 78%, var(--color-surface-bright) 22%),
color-mix(in oklch, var(--color-surface-container) 18%, var(--color-surface-container-low) 82%)
);
--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-bg: var(--color-card);
--ui-input-border: var(--color-input);
--ui-input-fg: var(--color-foreground);
--ui-input-shadow: var(--shadow-xs);
--ui-input-focus-border: color-mix(in oklch, var(--color-primary) 32%, var(--color-input));
--ui-input-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-highest) 56%, var(--color-surface-bright) 44%),
color-mix(in oklch, var(--color-surface-container) 14%, var(--color-surface-bright) 86%)
);
--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:
0 0 0 1px color-mix(in oklch, var(--color-primary) 18%, transparent),
var(--shadow-sm);
--ui-input-focus-lift: -1px;
--ui-input-disabled-bg: var(--color-surface);
--ui-input-readonly-bg: var(--color-surface);
0 0 0 3px color-mix(in oklch, var(--color-primary) 18%, transparent),
0 14px 28px color-mix(in oklch, var(--color-primary) 12%, transparent);
--ui-input-focus-lift: 0px;
--ui-input-disabled-bg: var(--color-surface-container);
--ui-input-readonly-bg: var(--color-surface-container-low);
--ui-input-backdrop-blur: 0px;
--ui-panel-radius: var(--radius-lg);
--ui-panel-border-width: 1px;
--ui-panel-bg: var(--color-card);
--ui-panel-border: var(--color-border);
--ui-panel-shadow: var(--shadow-md);
--ui-panel-bg: linear-gradient(
180deg,
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-overlay-bg: var(--color-overlay);
--ui-panel-overlay-blur: 2px;
--ui-panel-overlay-bg: color-mix(in oklch, var(--color-overlay) 88%, transparent);
--ui-panel-overlay-blur: 10px;
--ui-switch-track-radius: var(--radius-full);
--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-shadow: var(--shadow-xs);
--ui-switch-track-checked-bg: var(--color-primary);
--ui-switch-track-shadow:
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-thumb-radius: var(--radius-full);
--ui-switch-thumb-bg: white;
--ui-switch-thumb-shadow: var(--shadow-xs);
--ui-switch-thumb-checked-shadow: var(--shadow-sm);
--ui-switch-thumb-bg: var(--color-surface-bright);
--ui-switch-thumb-checked-bg: var(--color-primary);
--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-skeleton-radius: var(--radius-sm);
--ui-skeleton-block-radius: var(--radius-md);
--ui-skeleton-pill-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-muted-bg: var(--color-muted);
--ui-skeleton-bg: linear-gradient(
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(
110deg,
transparent 0%,
rgba(255, 255, 255, 0.48) 42%,
color-mix(in oklch, var(--color-primary-container) 40%, white 60%) 42%,
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':
specifier: ^18.3.7
version: 18.3.7(@types/react@18.3.28)
axe-core:
specifier: ^4.11.1
version: 4.11.1
eslint:
specifier: ^9.39.4
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 ({
page
@@ -6,21 +11,20 @@ test("storybook button, select, and static-motion form stories stay interactive"
await page.goto("/");
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" });
await expect(button).toBeVisible();
await button.focus();
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();
await expect(selectTrigger).toBeVisible();
await selectTrigger.click();
await expect(page.getByRole("option", { name: "Legal review" })).toBeVisible();
await page.goto(
"/iframe.html?id=components-form--launch-settings&viewMode=story&globals=motion:reduced"
);
await page.goto("/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("combobox", { name: "Review lane" }).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 }) => {
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" });
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 }) => {
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 expect(page.getByRole("dialog", { name: "Launch this release?" })).toBeVisible();
await page.getByRole("button", { name: "Close dialog" }).click();
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 expect(page.getByText("Release health")).toBeVisible();
await page.getByRole("button", { name: "Dismiss" }).click();
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 expect(page.getByRole("dialog", { name: "Launch settings" })).toBeVisible();
await page.getByRole("button", { name: "Close sheet" }).click();
+5 -4
View File
@@ -172,11 +172,12 @@ import {
DialogTrigger,
Input
} from "@ai-ui/ui";
import { setTheme } from "@ai-ui/tokens";
import { setDynamicColor, setTheme } from "@ai-ui/tokens";
import "./styles.css";
setTheme("morandi");
setTheme("violet");
setDynamicColor("#6750A4");
function App() {
return (
@@ -285,12 +286,12 @@ async function main() {
});
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
});
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
});