diff --git a/.agents/skills/add-component/SKILL.md b/.agents/skills/add-component/SKILL.md index 5eebde7c..0b4b5168 100644 --- a/.agents/skills/add-component/SKILL.md +++ b/.agents/skills/add-component/SKILL.md @@ -57,8 +57,9 @@ states, and story coverage. Variants, Sizes, states, …) so the two Storybooks read identically. Mirror the button stories: `packages/react/src/components/ui/button/button.stories.tsx` and `packages/angular/src/lib/ui/button/src/lib/hlm-button.stories.ts`. - - **Tokens:** both packages share the same oklch token names. If you add a new token, - add it to **both** `react/src/index.css` and `angular/src/styles.css`. + - **Tokens:** semantic values live in `@surfnet/curve-tokens` DTCG JSON (rebuild tokens, + don't hand-edit `:root`). React maps them in `packages/react/src/index.css` (`@theme + inline`); Angular in `packages/angular/src/styles.css`. Keep names aligned across both. 6. **Verify both:** @@ -69,6 +70,7 @@ states, and story coverage. # parity check — both should list the same story ids: node -e "console.log(Object.keys(require('./packages/react/storybook-static/index.json').entries))" node -e "console.log(Object.keys(require('./packages/angular/storybook-static/index.json').entries))" + pnpm test:visual # React snapshots + React/Angular screenshot comparison ``` ## Contract step @@ -149,22 +151,29 @@ Then: ``` The per-framework playbooks (`react.md`, `angular.md`) each have a matching step that ties -the component to the contract **for every axis it has**: a `cva` map carries -`satisfies Record`; an inline-union prop (no `cva`) is typed as -`size?: CardSizeName`. Either way a name mismatch fails `pnpm lint` at compile time. A -description-only contract has nothing to enforce — wiring it into the story's docs is -enough. +the component to the contract **for every axis it has**: + +- **React:** a CSS Module class map (or `data-*` attribute + CSS) carries + `satisfies Record` on the TS side; inline-union props are typed as + `size?: CardSizeName`. +- **Angular:** Spartan `cva` / `hlm` maps carry the same `satisfies Record<…>` pattern. + +Either way a name mismatch fails `pnpm lint` at compile time. A description-only contract +has nothing to enforce — wiring it into the story's docs is enough. ## Definition of done - Component vendored via the framework CLI(s) — never hand-write primitives. - A `Contract` `as const` entry exists in `@surfnet/curve-contracts` for **every** component (description-only when it has no axis) and is exported from its `index.ts`. -- For each axis the component exposes, both frameworks are tied to the contract: `cva` maps - carry `satisfies Record<...>`, inline-union props are typed as the contract's `*Name` - (see per-framework playbooks). Description-only contracts have nothing to enforce. +- For each axis the component exposes, both frameworks are tied to the contract (React: + CSS Module class maps + `satisfies Record<...>`; Angular: `cva`/`hlm` + `satisfies`). + Inline-union props are typed as the contract's `*Name` (see per-framework playbooks). + Description-only contracts have nothing to enforce. - Exported from each package's entry (`src/index.ts` / `src/public-api.ts`). - A Storybook story per package covering the component's full surface; when added to both, the story sets match. - `pnpm build`, `pnpm lint`, `pnpm format`, and the relevant `build-storybook` runs pass. +- Visual tests pass (`pnpm test:visual`). React snapshot baselines are refreshed with + `pnpm test:visual:update`. - Any unavoidable React/Angular difference is documented, not hidden. diff --git a/.agents/skills/add-component/angular.md b/.agents/skills/add-component/angular.md index 83fb035d..18627bde 100644 --- a/.agents/skills/add-component/angular.md +++ b/.agents/skills/add-component/angular.md @@ -103,6 +103,7 @@ pnpm --filter @surfnet/curve-contracts lint # contract types still compile pnpm --filter @surfnet/curve-angular build # ng-packagr (FESM + d.ts); satisfies check runs here pnpm --filter @surfnet/curve-angular build-storybook pnpm format +pnpm test:visual # after both Storybooks are built ``` ## Definition of done @@ -116,6 +117,9 @@ pnpm format - Story covers full variant/size/state surface, sourcing its description and axis lists from the contract object. - `pnpm build`, `pnpm format`, and `build-storybook` all pass. +- Visual tests pass (`pnpm test:visual`). Parity screenshots compare this story to its React + counterpart by story id — keep the story names in sync. Tag stories `skip-visual` if they + cannot be snapshotted stably. ## Notes diff --git a/.agents/skills/add-component/react.md b/.agents/skills/add-component/react.md index c46327a0..9a09d3fe 100644 --- a/.agents/skills/add-component/react.md +++ b/.agents/skills/add-component/react.md @@ -4,9 +4,15 @@ Part of the **add-component** skill — see [`SKILL.md`](SKILL.md) for scope/par Components are **vendored** with the shadcn CLI — copied into the package so we own and edit them. The package is pre-configured (`packages/react/components.json`) for **Base -UI** primitives (`"style": "base-nova"`) and **Phosphor** icons (`"iconLibrary": "phosphor"`). +UI** primitives (`"style": "base-vega"`) and **Phosphor** icons (`"iconLibrary": "phosphor"`). Never hand-write a primitive or switch `style` to a Radix value. +**Styling:** library components use **co-located CSS Modules** (`*.module.css`) and Curve +tokens (`var(--primary)`, `var(--border)`, …). The shadcn CLI may still emit Tailwind +utility strings — **migrate them to a module** before considering the component done. See +[`packages/react/docs/css-modules-pilot.md`](../../packages/react/docs/css-modules-pilot.md) +for the pattern, migration table, and gaps (e.g. overlay motion not ported). + > **Available tooling:** the **`shadcn` skill** (`.agents/skills/shadcn/`) is the deep > reference for component APIs, registries, and presets, and the repo exposes a **shadcn > MCP server** (`.mcp.json` / `.vscode/mcp.json`, scoped to this package) for @@ -34,47 +40,76 @@ Never hand-write a primitive or switch `style` to a Radix value. `components.json`. (A `--dry-run` preview _displays_ a flat path — ignore that; the real write nests correctly.) -3. **Tie the component to the contract — for every axis it has.** Import the `*Name` unions - from `@surfnet/curve-contracts` and wire them in. There are two wiring styles depending on how - the vendored component models the axis: +3. **Add `card.module.css` and wire styles.** Prefer semantic tokens, not Tailwind + utilities or shadcn/css `--color-*` scales. + + - Put layout, states, and variant rules in the module; use `data-variant`, `data-size`, + `data-orientation`, etc. on the element when selectors need them. + - Import the module in `card.tsx` and merge with `cn(styles.root, className)` from + `@/lib/utils` (lets consumers pass extra classes, including app-level Tailwind). + - TypeScript: `packages/react/src/vite-env.d.ts` already declares `*.module.css`. With + `noUncheckedIndexedAccess`, assert the import when you need dotted access: + + ```ts + type CardModuleClasses = { root: string; variantOutline: string }; + const styles = cardStyles as CardModuleClasses; + ``` + + - **Do not** add new `class-variance-authority` / Tailwind `cva` maps in React — use + module classes + optional exported `cardVariants()` helpers (see `button.tsx`). - **a. `cva` map → `satisfies Record<…>`** (e.g. `button`, `field`): +4. **Tie the component to the contract — for every axis it has.** Import the `*Name` unions + from `@surfnet/curve-contracts` and wire them in: + + **a. Variant / size class map → `satisfies Record<…>`** (e.g. `button`, `badge`, + `toggle`): ```ts import type { CardVariantName, CardSizeName } from '@surfnet/curve-contracts'; - const cardVariants = cva('...', { - variants: { - variant: { - default: '...', - outline: '...', - } satisfies Record, - size: { - default: '...', - sm: '...', - lg: '...', - } satisfies Record, - }, - }); + const variantClass: Record = { + default: styles.variantDefault, + outline: styles.variantOutline, + } satisfies Record; + + export function cardVariants({ + variant = 'default', + size = 'default', + className, + }: { + variant?: CardVariantName; + size?: CardSizeName; + className?: string; + } = {}) { + return cn(styles.card, variantClass[variant], sizeClass[size], className); + } ``` - **b. inline-union prop (no `cva`) → type the prop** (e.g. `avatar` size, - `select` trigger size, `dropdown-menu` item variant). Replace the hand-written union with - the contract type: + Export `cardVariants` when another component composes your styles (Calendar → + `buttonVariants`, Toggle group → `toggleVariants`). + + **b. Inline-union prop → type the prop** (e.g. `select` trigger size, `dropdown-menu` + item variant): ```ts import type { AvatarSizeName } from '@surfnet/curve-contracts'; function Avatar({ size = 'default', ...props }: AvatarPrimitive.Root.Props & { - size?: AvatarSizeName; // was: 'default' | 'sm' | 'lg' - }) { /* … */ } + size?: AvatarSizeName; + }) { + return ( + + ); + } ``` - A **description-only** contract has no axis, so there's nothing to wire here — skip to the - barrel. Confirm `pnpm lint` passes — a name mismatch between the component and the - contract type is a compile error. + A **description-only** contract has no axis to enforce — skip wiring, but still add a + module and pull `docs.description` from the contract in Storybook. -4. **Add a barrel** `src/components/ui/card/index.ts`: + Confirm `pnpm lint` passes — a name mismatch between the component and the contract + type is a compile error. + +5. **Add a barrel** `src/components/ui/card/index.ts`: ```ts export * from './card'; @@ -82,34 +117,34 @@ Never hand-write a primitive or switch `style` to a Radix value. This keeps `@/components/ui/card` imports resolving for other shadcn components. -5. **Re-export from the package entry** `src/index.ts`: +6. **Re-export from the package entry** `src/index.ts`: ```ts export * from '@/components/ui/card'; ``` -6. **Add a story** `src/components/ui/card/card.stories.tsx`. Mirror +7. **Add a story** `src/components/ui/card/card.stories.tsx`. Mirror `src/components/ui/button/button.stories.tsx`: a `Playground` with `argTypes`/`args`, plus stories covering every variant/size/state the component offers. Source the docs description **and** any axis lists from the contract object (`cardContract.description`, `cardContract.variants`, `cardContract.variantDocs`, etc.) instead of duplicating - literals — a description-only component still pulls its `docs.description.component` from - the contract. Use `@storybook/react-vite` types (`Meta`, `StoryObj`). + literals. + + **Story layout:** Tailwind utilities in `*.stories.tsx` are fine for demo chrome + (`flex gap-3`, `w-80`) — they are not shipped as the component API. Keep component + `className` overrides in stories minimal so visuals reflect the module. **Every control you declare must be live.** A control in `argTypes`/`args` only does something if a story actually consumes those args — an args-driven Playground (`export const Default: Story = {}`, or `render: (args) => `). If _every_ story - hardcodes its props in `render`, the control is dead: it shows in the panel but moving it - changes nothing. So keep one args-driven Playground per declared control, reserve - hardcoded `render` for static showcase stories (Variants, Sizes, …), and don't declare a - control no story consumes. (Storybook here only surfaces controls you declare explicitly — - it does not infer them from props — so an unused `argTypes` entry is always dead weight.) + hardcodes its props in `render`, the control is dead. See existing button stories. ## Target layout ``` src/components/ui/card/ -├── card.tsx # vendored component (yours to edit) +├── card.tsx # vendored component (Base UI + module classes) +├── card.module.css # component styles (Curve tokens) ├── card.stories.tsx # Storybook story └── index.ts # export * from './card' ``` @@ -119,33 +154,42 @@ src/components/ui/card/ ```bash pnpm --filter @surfnet/curve-contracts lint # contract types still compile pnpm --filter @surfnet/curve-react lint # tsc --noEmit (satisfies check runs here) -pnpm --filter @surfnet/curve-react build # vite lib build + d.ts +pnpm --filter @surfnet/curve-react build # vite lib build + d.ts; CSS → dist/styles.css pnpm --filter @surfnet/curve-react build-storybook pnpm format +pnpm test:visual # after both Storybooks are built ``` ## Definition of done - Component vendored via the shadcn CLI — never hand-written. -- A `Contract` entry exists in `@surfnet/curve-contracts` (description-only if the component - has no axis). For every axis, the component is tied to the contract — `cva` maps carry - `satisfies Record<...>`, inline-union props are typed as the contract's `*Name`; `pnpm - lint` fails if either side adds or removes a name. +- Co-located `*.module.css`; no Tailwind utility strings left in the component `.tsx`. +- A `Contract` entry exists in `@surfnet/curve-contracts` (description-only if the + component has no axis). For every axis, the component is tied to the contract via + `satisfies Record<...>` and/or `*Name` prop types; `pnpm lint` fails on name drift. - Barrel `index.ts` in place; component exported from `src/index.ts`. - Story covers full variant/size/state surface, sourcing its description and axis lists from the contract object. - `pnpm build`, `pnpm lint`, `pnpm format`, and `build-storybook` all pass. +- Visual tests pass (`pnpm test:visual`). Refresh baselines with `pnpm test:visual:update` + when appearance changed. Tag stories `skip-visual` if they cannot be snapshotted stably. ## Notes -- Icons: import from `@phosphor-icons/react` (components are suffixed `Icon`, e.g. - `PlusIcon`). The package is an **optional peer dependency** (kept as a devDependency so - stories build); never move it back to `dependencies`. Render icons directly (``) or inside a button — the button auto-sizes the SVG per size, and - `data-icon="inline-start"` / `data-icon="inline-end"` tightens the padding next to text. - See `button.stories.tsx` (`IconSizes`, `WithIcon`) for the established pattern. -- If the component pulls in sibling shadcn components, they're vendored flat by default; - apply the same per-directory + barrel treatment to each if you want them nested. -- The `@surfnet/curve-contracts` import is a `devDependency` only — it must not appear in the - published `dist`. The `satisfies` annotation is erased by TypeScript at compile time, so - no import survives into the built output. +- **Published CSS:** consumers import `@surfnet/curve-react/styles.css` for tokens, base + styles, **semantic color utilities** (`text-*` / `bg-*` / `border-*` on theme names), + and compiled CSS Modules. Full Tailwind (layout, spacing, opacity modifiers) is **not** + shipped — apps add their own Tailwind or plain CSS. Storybook uses `.storybook/story-chrome.css`. +- **Icons:** import from `@phosphor-icons/react` (suffix `Icon`, e.g. `PlusIcon`). Optional + peer for consumers; devDependency for Storybook. Size/color icons in the module or via + parent `svg` rules — see `button.stories.tsx` (`IconSizes`, `WithIcon`). +- **Native alternatives:** for simple form controls, consider a `Native*` sibling + + description-only contract (see `css-modules-pilot.md` table). Do not replace Base UI + overlays without an explicit product decision. +- If the component pulls in sibling shadcn components, apply the same per-directory + + barrel + module treatment to each. +- The `@surfnet/curve-contracts` import is a `devDependency` only — it must not appear in + published `dist`. Types and `satisfies` erase at compile time. +- **No Tailwind in `@surfnet/curve-react`** — not in the lib build or Storybook. New story + layout classes go in `.storybook/story-chrome.css` if needed. See + [`css-modules-pilot.md`](../../packages/react/docs/css-modules-pilot.md). diff --git a/.changeset/alert-badge-semantic-variants.md b/.changeset/alert-badge-semantic-variants.md new file mode 100644 index 00000000..d5333c68 --- /dev/null +++ b/.changeset/alert-badge-semantic-variants.md @@ -0,0 +1,5 @@ +--- +'@surfnet/curve-react': patch +--- + +Map Alert and Badge CSS Module variants to the contract names (`info`, `success`, `warning`, `danger`) instead of the leftover `destructive` keys. diff --git a/.changeset/css-modules-button-pilot.md b/.changeset/css-modules-button-pilot.md new file mode 100644 index 00000000..2bedecf6 --- /dev/null +++ b/.changeset/css-modules-button-pilot.md @@ -0,0 +1,5 @@ +--- +'@surfnet/curve-react': minor +--- + +Expand CSS Modules migration to overlays and menus: Dialog, Sheet, Select, Sidebar, Calendar, Accordion, Alert dialog, Carousel, Data table, Dropdown/Context menu, Command, Input group, Combobox, Navigation menu, plus earlier components. Base UI primitives unchanged; motion/enter animations largely not ported. diff --git a/.changeset/react-drop-tailwind-bundle.md b/.changeset/react-drop-tailwind-bundle.md new file mode 100644 index 00000000..d59212fa --- /dev/null +++ b/.changeset/react-drop-tailwind-bundle.md @@ -0,0 +1,5 @@ +--- +'@surfnet/curve-react': minor +--- + +Stop shipping Tailwind in `styles.css`. Published CSS now includes tokens, minimal base styles, semantic color utility classes (`text-primary`, `bg-muted`, etc.), and CSS Modules. Storybook uses plain `.storybook/story-chrome.css` for demo layout. Removed `class-variance-authority` and Tailwind devDependencies from the React package. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 54b34126..ca89caed 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,6 +18,7 @@ - [ ] `pnpm build`, `pnpm lint`, and `pnpm format` pass - [ ] Storybook story added/updated to cover the change (variants, sizes, states) +- [ ] Visual snapshots updated (`pnpm test:visual:update` after `pnpm build-storybook`) if appearance changed - [ ] Changeset added (`pnpm changeset`) — required for any change to `@surfnet/curve-react` or `@surfnet/curve-angular`; N/A for docs/CI-only changes - [ ] React and Angular kept in parity, if applicable (contract updated in `@surfnet/curve-contracts`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8916b24..3c1fd247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,47 @@ jobs: - run: pnpm lint - run: pnpm build + + visual: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ github.sha }} + restore-keys: | + turbo-${{ runner.os }}- + + - uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + + - run: pnpm install --frozen-lockfile + + - run: pnpm build-storybook + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Visual regression (React + Angular story snapshots) + run: pnpm test:visual + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 7b3d4fd9..a54d3cb0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,12 @@ storybook-static # angular .angular +# playwright +test-results +playwright-report +blob-report +playwright/.cache + # logs *.log npm-debug.log* diff --git a/.prettierignore b/.prettierignore index 33f8f222..c74a7887 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,9 @@ storybook-static .turbo .next pnpm-lock.yaml +test-results +playwright-report +blob-report # vendored upstream skills — keep them byte-for-byte as fetched .agents/skills diff --git a/AGENTS.md b/AGENTS.md index 22063288..da35ff06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,10 +53,13 @@ pnpm format # prettier --write across the rep pnpm storybook # both Storybooks (React :6006, Angular :6007) pnpm storybook:react # React Storybook (port 6006) pnpm storybook:angular # Angular Storybook (port 6007) +pnpm build-storybook && pnpm test:visual # story screenshots vs baselines (React + Angular) ``` Always run `pnpm lint` and `pnpm format` before considering a change done, and rebuild -the package you touched. +the package you touched. Refresh snapshot baselines with `pnpm test:visual:update` +(after `pnpm build-storybook`). Compare React to Angular when you want with +`pnpm test:visual:parity`. ## MCP servers @@ -120,6 +123,16 @@ OpenCode): `npx shadcn@latest mcp init --client ` for shadcn, and add the `storybook` script's `-p 6006` in `packages/react/package.json`), **Angular → 6007** (the `storybook` target's `"port": 6007` in `packages/angular/angular.json`). +### Visual tests (Playwright) + +- Screenshots come from the **built** Storybooks (`pnpm build-storybook`), not the + Vite/webpack dev servers. Serve them on 6008/6009 so they don't collide with + `pnpm storybook` on 6006/6007. +- React and Angular each have a `*.spec.ts` that `toHaveScreenshot`s every story (light + dark). + Baselines live in separate folders: `tests/visual/__screenshots__/react/` and + `.../angular/` (same story ids, different PNGs — never share one flat directory). +- Refresh baselines with `pnpm test:visual:update`. Optional parity: `pnpm test:visual:parity`. + ### Shared packages (`@surfnet/curve-tokens` + `@surfnet/curve-contracts`) - **Token source of truth is DTCG JSON only.** All color and other semantic token values @@ -185,7 +198,7 @@ Gotchas: 1. Component vendored via the framework's CLI (don't hand-write primitives). 2. Exported from the package entry (`src/index.ts` / `src/public-api.ts`). 3. A Storybook story covering the component's full surface (variants, sizes, states). -4. `pnpm build`, `pnpm lint`, and `pnpm format` all pass. +4. `pnpm build`, `pnpm lint`, `pnpm format`, and `pnpm test:visual` all pass. 5. A changeset added (`pnpm changeset`) if a publishable package changed. ## Skills diff --git a/README.md b/README.md index ce1241fc..184640a1 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ pnpm format # format everything with Prettier pnpm storybook # both (React :6006, Angular :6007) pnpm storybook:react # http://localhost:6006 pnpm storybook:angular # http://localhost:6007 + +# Visual regression (Playwright screenshots of the built Storybooks) +pnpm build-storybook +pnpm test:visual ``` Each component ships a Storybook story covering its full surface (variants, sizes, @@ -76,6 +80,34 @@ to GitHub Pages on every push to `main`: - **React** — https://surfnet.github.io/DesignSystem/react/ - **Angular** — https://surfnet.github.io/DesignSystem/angular/ +## Visual regression + +Playwright screenshots each **Components** and **Foundations** story in the built +Storybooks (React and Angular separately). CI compares those PNGs to baselines in +`tests/visual/__screenshots__/react/` and `tests/visual/__screenshots__/angular/`. + +```bash +pnpm test:visual:install # once per machine / after @playwright/test upgrades +pnpm build-storybook +pnpm test:visual # compare snapshots to committed baselines +pnpm test:visual:parity # optional: React vs Angular pixel diff (run when you want) +pnpm test:visual:ui # Playwright UI mode +``` + +Baselines live in `tests/visual/__screenshots__/` and are committed. Refresh after +`pnpm build-storybook`: + +```bash +pnpm test:visual:update +``` + +CI runs on `ubuntu-latest` (Linux Chromium). If snapshots from your machine do not +match CI, update baselines on Linux or from the failing CI run’s diff, then commit. + +React↔Angular alignment is a separate step (`pnpm test:visual:parity`), not part of +the default `test:visual`. Tag a story `skip-visual` to exclude it (Spinner does). +Use `visual-fullpage` when the UI portals outside `#storybook-root`. + ## Documentation The written Curve docs — overview, designer and developer guides, accessibility, diff --git a/apps/react-app/src/app/globals.css b/apps/react-app/src/app/globals.css index ff37c0f7..f26f74e2 100644 --- a/apps/react-app/src/app/globals.css +++ b/apps/react-app/src/app/globals.css @@ -1,12 +1,12 @@ /* * App-level Tailwind v4 entry. * - * The compiled @surfnet/curve-react/styles.css (imported first in layout.tsx) already - * ships Tailwind's preflight, the design-token CSS variables (:root / .dark / - * .theme-*), and the component utilities. So here we pull in Tailwind WITHOUT - * preflight (granular imports, no `tailwindcss/preflight.css`) to avoid a second - * base reset, and re-declare the token -> color mapping so app-authored utilities - * like `bg-primary` / `text-foreground` resolve to the same theme variables. + * The compiled @surfnet/curve-react/styles.css (imported first in layout.tsx) ships + * design tokens, minimal base styles, semantic color utilities (`text-primary`, + * `bg-muted`, …), and hashed CSS Module rules for components — not full Tailwind. + * Here we pull in Tailwind WITHOUT preflight (granular imports) for app layout + * utilities (`flex`, `gap-*`, …) and re-declare the token → `--color-*` mapping so + * those utilities resolve to the same theme variables. */ @layer theme, base, components, utilities; diff --git a/package.json b/package.json index fd61c374..22b17188 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,11 @@ "storybook:react": "pnpm --filter @surfnet/curve-react storybook", "storybook:angular": "pnpm --filter @surfnet/curve-angular storybook", "build-storybook": "turbo run build-storybook", + "test:visual": "playwright test", + "test:visual:install": "playwright install chromium", + "test:visual:parity": "playwright test tests/visual/parity.spec.ts", + "test:visual:update": "playwright test tests/visual/react.spec.ts --update-snapshots=all && playwright test tests/visual/angular.spec.ts --update-snapshots=all", + "test:visual:ui": "playwright test --ui", "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx,js,mjs,cjs,json,md,css,html}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,mjs,cjs,json,md,css,html}\"", @@ -26,11 +31,15 @@ "devDependencies": { "@changesets/cli": "3.0.2", "@figma/rest-api-spec": "0.40.0", + "@playwright/test": "1.62.1", "@types/node": "^22.19.21", + "@types/pngjs": "6.0.5", "dotenv": "17.4.2", "husky": "9.1.7", "jiti": "2.7.0", "lint-staged": "17.0.7", + "pixelmatch": "7.2.0", + "pngjs": "7.0.0", "prettier": "3.8.4", "turbo": "2.9.18" }, diff --git a/packages/angular/.storybook/storybook-docs.css b/packages/angular/.storybook/storybook-docs.css index f0c77ab2..ed987548 100644 --- a/packages/angular/.storybook/storybook-docs.css +++ b/packages/angular/.storybook/storybook-docs.css @@ -1,28 +1,28 @@ /* Restore list markers inside docs prose; Tailwind preflight strips them globally. */ -.sbdocs-content ul, -.sbdocs-content ol { +.sbdocs-content > ul, +.sbdocs-content > ol { list-style: revert; font-size: inherit; padding-inline-start: 1.5rem; } -.sbdocs-content ul { +.sbdocs-content > ul { list-style-type: disc; } -.sbdocs-content ol { +.sbdocs-content > ol { list-style-type: decimal; } -.sbdocs-content ul ul { +.sbdocs-content > ul ul { list-style-type: circle; } -.sbdocs-content ul ul ul { +.sbdocs-content > ul ul ul { list-style-type: square; } -.sbdocs-content ol li, -.sbdocs-content ul li { +.sbdocs-content > ol li, +.sbdocs-content > ul li { font-size: inherit; } -.sbdocs-content.sbdocs-content dl { +.sbdocs-content.sbdocs-content > dl { margin-block: 1em; } .sbdocs-content.sbdocs-content dt { @@ -46,18 +46,18 @@ vertical-align: top; text-align: left; } -.sbdocs-content.sbdocs-content h2 { +.sbdocs-content.sbdocs-content > h2 { margin-block-start: 2rem; } -.sbdocs-content.sbdocs-content h3 { +.sbdocs-content.sbdocs-content > h3 { margin-block-start: 1.5rem; } -.sbdocs-content.sbdocs-content h4 { +.sbdocs-content.sbdocs-content > h4 { margin-block-start: 1rem; } -.sbdocs-content.sbdocs-content h5 { +.sbdocs-content.sbdocs-content > h5 { margin-block-start: 0.5rem; } -.sbdocs-a.sbdocs-a { +.sbdocs-content.sbdocs-content > p > .sbdocs-a.sbdocs-a { color: rgb(0 85 182); } diff --git a/packages/angular/src/lib/ui/spinner/src/lib/hlm-spinner.stories.ts b/packages/angular/src/lib/ui/spinner/src/lib/hlm-spinner.stories.ts index 496acbbd..de212542 100644 --- a/packages/angular/src/lib/ui/spinner/src/lib/hlm-spinner.stories.ts +++ b/packages/angular/src/lib/ui/spinner/src/lib/hlm-spinner.stories.ts @@ -9,6 +9,7 @@ import { HlmSpinner, HlmSpinnerImports } from '..'; const meta: Meta = { title: 'Components/Spinner', component: HlmSpinner, + tags: ['skip-visual'], decorators: [ moduleMetadata({ imports: [HlmSpinnerImports, HlmButton, HlmCardImports, HlmEmptyImports], diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 50364c94..b12d8b20 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -33,7 +33,17 @@ export { } from './item.js'; export { kbdContract } from './kbd.js'; export { labelContract } from './label.js'; +export { nativeCheckboxContract } from './native-checkbox.js'; +export { nativeDetailsContract } from './native-details.js'; +export { nativeDialogContract } from './native-dialog.js'; +export { nativeFieldsetContract } from './native-fieldset.js'; +export { nativeInputContract, type NativeInputSizeName } from './native-input.js'; +export { nativePopoverContract } from './native-popover.js'; +export { nativeProgressContract } from './native-progress.js'; +export { nativeRadioContract } from './native-radio.js'; +export { nativeRangeContract } from './native-range.js'; export { nativeSelectContract, type NativeSelectSizeName } from './native-select.js'; +export { nativeTextareaContract } from './native-textarea.js'; export { navigationMenuContract } from './navigation-menu.js'; export { paginationContract } from './pagination.js'; export { popoverContract } from './popover.js'; diff --git a/packages/contracts/src/native-checkbox.ts b/packages/contracts/src/native-checkbox.ts new file mode 100644 index 00000000..13316172 --- /dev/null +++ b/packages/contracts/src/native-checkbox.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const nativeCheckboxContract = defineContract({ + docs: { + description: + 'A styled native . Prefer the Base UI Checkbox for indicator icons and form-field integration.', + }, +}); diff --git a/packages/contracts/src/native-details.ts b/packages/contracts/src/native-details.ts new file mode 100644 index 00000000..049c1bda --- /dev/null +++ b/packages/contracts/src/native-details.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const nativeDetailsContract = defineContract({ + docs: { + description: + 'Expandable sections built with native
and . Prefer the Base UI Accordion when you need single-expand groups or full keyboard roving focus.', + }, +}); diff --git a/packages/contracts/src/native-dialog.ts b/packages/contracts/src/native-dialog.ts new file mode 100644 index 00000000..1f163582 --- /dev/null +++ b/packages/contracts/src/native-dialog.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const nativeDialogContract = defineContract({ + docs: { + description: + 'A styled native element with showModal/close support. Prefer the Base UI Dialog when you need focus trapping, nested overlays, or composable header/footer primitives.', + }, +}); diff --git a/packages/contracts/src/native-fieldset.ts b/packages/contracts/src/native-fieldset.ts new file mode 100644 index 00000000..611de161 --- /dev/null +++ b/packages/contracts/src/native-fieldset.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const nativeFieldsetContract = defineContract({ + docs: { + description: + 'Groups related form controls with a native
and . Prefer Field when you need label/description/error layout without semantic grouping.', + }, +}); diff --git a/packages/contracts/src/native-input.ts b/packages/contracts/src/native-input.ts new file mode 100644 index 00000000..5b2e45fb --- /dev/null +++ b/packages/contracts/src/native-input.ts @@ -0,0 +1,20 @@ +import { defineContract } from './define-contract.js'; + +export const nativeInputContract = defineContract({ + props: { + sizes: ['default', 'sm'], + }, + defaults: { + sizes: 'default', + }, + docs: { + description: + 'A styled native for text-like types (text, email, password, number, search, url, tel, date, time, etc.). Prefer the Base UI Input when you need consistent behavior with other form primitives.', + sizes: { + default: 'Standard height for most UI contexts.', + sm: 'Small — compact forms and toolbars.', + }, + }, +}); + +export type NativeInputSizeName = (typeof nativeInputContract.props.sizes)[number]; diff --git a/packages/contracts/src/native-popover.ts b/packages/contracts/src/native-popover.ts new file mode 100644 index 00000000..fb67ccc8 --- /dev/null +++ b/packages/contracts/src/native-popover.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const nativePopoverContract = defineContract({ + docs: { + description: + 'Floating content using the native Popover API (`popover` + `popovertarget`). Prefer Base UI Popover when you need positioning props, portals, or animation parity with other overlays.', + }, +}); diff --git a/packages/contracts/src/native-progress.ts b/packages/contracts/src/native-progress.ts new file mode 100644 index 00000000..1bc53788 --- /dev/null +++ b/packages/contracts/src/native-progress.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const nativeProgressContract = defineContract({ + docs: { + description: + 'A styled native element. Prefer this over the Base UI Progress when you only need a simple determinate bar without custom labels.', + }, +}); diff --git a/packages/contracts/src/native-radio.ts b/packages/contracts/src/native-radio.ts new file mode 100644 index 00000000..db386232 --- /dev/null +++ b/packages/contracts/src/native-radio.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const nativeRadioContract = defineContract({ + docs: { + description: + 'A styled native . Use inside NativeFieldset or with a shared name; prefer Radio Group for roving focus and keyboard UX.', + }, +}); diff --git a/packages/contracts/src/native-range.ts b/packages/contracts/src/native-range.ts new file mode 100644 index 00000000..a210e7db --- /dev/null +++ b/packages/contracts/src/native-range.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const nativeRangeContract = defineContract({ + docs: { + description: + 'A styled native . Prefer Slider for custom thumbs, multiple values, or vertical orientation.', + }, +}); diff --git a/packages/contracts/src/native-textarea.ts b/packages/contracts/src/native-textarea.ts new file mode 100644 index 00000000..e2457133 --- /dev/null +++ b/packages/contracts/src/native-textarea.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const nativeTextareaContract = defineContract({ + docs: { + description: + 'A styled native