Add harness workflow and Material showcase design system
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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="{name}"
|
||||
</p>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-[var(--color-foreground)]">
|
||||
{skinDetails[name].label}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full border px-3 py-1 text-xs font-medium text-[var(--color-foreground)]"
|
||||
style={{
|
||||
background: "var(--ui-control-bg)",
|
||||
borderColor: "var(--ui-control-border)",
|
||||
borderRadius: "var(--ui-control-radius)",
|
||||
boxShadow: "var(--ui-control-shadow)"
|
||||
}}
|
||||
>
|
||||
phase 1
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
className="relative overflow-hidden border p-4"
|
||||
style={{
|
||||
background: "var(--ui-control-bg)",
|
||||
borderColor: "var(--ui-control-border)",
|
||||
borderRadius: "var(--ui-control-radius)",
|
||||
boxShadow: "var(--ui-control-shadow)"
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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))]"
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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%
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user