From 5cc87e50f5932c6bdf429d0877ab334f210f66c5 Mon Sep 17 00:00:00 2001 From: NikolaiKushner Date: Mon, 3 Aug 2026 19:26:54 +0400 Subject: [PATCH 01/19] feat(dock) Update documentation to dynamically display the npm version using a new `` component, ensuring accurate version representation across all documentation. Adjust build scripts to include package builds before documentation generation for consistency. --- .../theme/components/NpmVersion.vue | 22 ++++++ docs/.vitepress/theme/index.ts | 2 + docs/index.md | 2 +- docs/introduction.md | 2 +- docs/roadmap.md | 4 +- package.json | 2 +- packages/ui/src/docs-content.test.ts | 71 +++++++++++++++++++ 7 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 docs/.vitepress/theme/components/NpmVersion.vue create mode 100644 packages/ui/src/docs-content.test.ts diff --git a/docs/.vitepress/theme/components/NpmVersion.vue b/docs/.vitepress/theme/components/NpmVersion.vue new file mode 100644 index 0000000..febe688 --- /dev/null +++ b/docs/.vitepress/theme/components/NpmVersion.vue @@ -0,0 +1,22 @@ + + + diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index c3082de..8b93f08 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -4,6 +4,7 @@ import DefaultTheme from 'vitepress/theme' import * as rowkit from 'rowkit' import ColorScale from './components/ColorScale.vue' import DemoBox from './components/DemoBox.vue' +import NpmVersion from './components/NpmVersion.vue' import TokenGrid from './components/TokenGrid.vue' import './tokens.css' @@ -30,6 +31,7 @@ export default { app.component('DemoBox', DemoBox) app.component('ColorScale', ColorScale) app.component('TokenGrid', TokenGrid) + app.component('NpmVersion', NpmVersion) /* * Vercel Analytics, guarded because `enhanceApp` runs during the static diff --git a/docs/index.md b/docs/index.md index 3a79521..b8d8394 100644 --- a/docs/index.md +++ b/docs/index.md @@ -109,7 +109,7 @@ pnpm add rowkit Both lines are required, and that second one is the step people miss — see [installation](/installation) for why, and for the Nuxt path. -**v0.1.0 is on npm.** Every component above is built, tested and published — you +** is on npm.** Every component above is built, tested and published — you are looking at them running. The API is stabilising toward v1.0, so breaking changes are still possible until then. diff --git a/docs/introduction.md b/docs/introduction.md index d5182e4..f533756 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -99,7 +99,7 @@ it is designed to sit beside a general-purpose kit rather than replace it. **v0.x.** The API is stabilising toward v1.0, every component has reached the project's definition of done, and breaking changes are still possible until v1. -`v0.1.0` is on npm, published from CI with provenance attestation. The source +Version is on npm, published from CI with provenance attestation. The source and the full roadmap are on [GitHub](https://github.com/NikolaiKushner/rowkit). ## Where to go next diff --git a/docs/roadmap.md b/docs/roadmap.md index 7e9690e..dfc4c49 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -51,8 +51,8 @@ real-world use: an API is not proven by its author. ## Where the project is -The library is built, tested, documented, and **published**: `rowkit@0.1.0` and -`@rowkit/tokens@0.1.0` are on npm, released from CI with provenance. The source +The library is built, tested, documented, and **published**: `rowkit` and +`@rowkit/tokens` are on npm at , released from CI with provenance. The source is on [GitHub](https://github.com/NikolaiKushner/rowkit) and this site runs the real components. diff --git a/package.json b/package.json index 6cc4c47..675e055 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "docs:props": "node packages/ui/scripts/generate-props.mjs", "docs:agents": "node packages/ui/scripts/generate-agents.mjs", "docs:dev": "vitepress dev docs", - "docs:build": "vitepress build docs", + "docs:build": "pnpm build && vitepress build docs", "docs:preview": "vitepress preview docs" }, "lint-staged": { diff --git a/packages/ui/src/docs-content.test.ts b/packages/ui/src/docs-content.test.ts new file mode 100644 index 0000000..4c40d67 --- /dev/null +++ b/packages/ui/src/docs-content.test.ts @@ -0,0 +1,71 @@ +import { readFile, readdir } from 'node:fs/promises' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import { repoRoot } from '../scripts/component-api.mjs' + +/** + * Published documentation may not hardcode rowkit's own version number. + * + * The site said `v0.1.0` for a day after `0.1.1` went out. Nothing failed, + * because a version typed into prose has nothing to disagree with — the same + * silent-drift shape as the props tables and `AGENTS.md`, which is why those + * are generated and gated rather than written by hand. + * + * `` renders it from the package instead. This test is what keeps + * a future edit from quietly typing the literal back in. + * + * `phases/` is excluded: those are dated planning records, and "Phase 6 shipped + * 0.1.0" stays true forever. `srcExclude` keeps them off the site entirely. + */ + +const docsDir = join(repoRoot, 'docs') + +/** Every markdown page VitePress actually publishes. */ +async function publishedPages(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const files = await Promise.all( + entries.map(async (entry) => { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + // `srcExclude: ['phases/**']`, plus VitePress's own build output. + if (entry.name === 'phases' || entry.name === '.vitepress' || entry.name === 'public') { + return [] + } + return publishedPages(full) + } + return entry.name.endsWith('.md') ? [full] : [] + }) + ) + return files.flat() +} + +describe('published docs', () => { + it('never hardcodes a rowkit version', async () => { + const pages = await publishedPages(docsDir) + expect(pages.length, 'no pages found — the walk is looking in the wrong place').toBeGreaterThan( + 5 + ) + + /* + * Two shapes, both narrow on purpose. A bare `\d+\.\d+\.\d+` would flag the + * WCAG criteria the accessibility sections cite by number (1.4.13, 2.2.1) + * and the pinned `^6.0.3` in the TypeScript decision record. + */ + const patterns = [ + { re: /v\d+\.\d+\.\d+/g, what: 'a `vX.Y.Z` literal' }, + { re: /@?rowkit(?:\/tokens)?@\d+\.\d+\.\d+/g, what: 'a pinned `rowkit@X.Y.Z`' }, + ] + + const offences: string[] = [] + for (const page of pages) { + const text = await readFile(page, 'utf8') + for (const { re, what } of patterns) { + for (const match of text.matchAll(re)) { + offences.push(`${relative(repoRoot, page)}: ${what} — "${match[0]}"`) + } + } + } + + expect(offences, 'use ``, which reads the version from the package').toEqual([]) + }) +}) From dff5d291d2b127e7b35c0c86c92e857480058971 Mon Sep 17 00:00:00 2001 From: NikolaiKushner Date: Mon, 3 Aug 2026 20:19:02 +0400 Subject: [PATCH 02/19] feat(tokens): adopt shadcn/ui radius scale for consistent corner radii across components This update introduces a new radius scale based on a single `--radius` variable, allowing for easier customization of corner radii. The new scale includes defined multiples for various sizes, enhancing design consistency. The `radiusBase` export is added to facilitate this change, while existing token names remain unchanged to ensure backward compatibility. Documentation has been updated to reflect these changes. --- .changeset/wild-pugs-shave.md | 12 ++ docs/phases/restyle-shadcn.md | 279 +++++++++++++++++++++++++++ packages/tokens/src/css.ts | 25 ++- packages/tokens/src/index.ts | 5 +- packages/tokens/src/radius.ts | 95 +++++++-- packages/ui/src/styles/theme.test.ts | 33 ++++ 6 files changed, 428 insertions(+), 21 deletions(-) create mode 100644 .changeset/wild-pugs-shave.md create mode 100644 docs/phases/restyle-shadcn.md diff --git a/.changeset/wild-pugs-shave.md b/.changeset/wild-pugs-shave.md new file mode 100644 index 0000000..ac4a33c --- /dev/null +++ b/.changeset/wild-pugs-shave.md @@ -0,0 +1,12 @@ +--- +'@rowkit/tokens': minor +'rowkit': minor +--- + +Adopt the shadcn/ui radius scale, derived from a single `--radius`. + +Corners are now multiples of one variable rather than a flat list: `xs` 4px, `sm` 6px, `md` 8px, `lg` 10px, `xl` 14px, up from 2/4/6/8/12px. Setting `--radius` in your own CSS retunes every corner in the library at once. + +`radiusBase` is a new export holding that length. Token names are unchanged, so no component or utility class needs editing — only the values they resolve to. + +Design language based on shadcn/ui by shadcn, adapted for Vue. diff --git a/docs/phases/restyle-shadcn.md b/docs/phases/restyle-shadcn.md new file mode 100644 index 0000000..54d9c0a --- /dev/null +++ b/docs/phases/restyle-shadcn.md @@ -0,0 +1,279 @@ +# rowkit Restyle — Adopting the shadcn/ui Design Language + +**Goal:** rowkit components become visually indistinguishable from shadcn/ui defaults — colors, radii, shadows, sizing, typography, focus treatment, dark mode. +**Version impact:** visual breaking change → ships as **0.2.0**. Not "a single minor changeset": Part 3 splits the work across six PRs and hard rule 6 requires one per public API change, so expect six, all `minor`, collapsing into one `0.2.0` release when the last merges. The summary sentence below belongs in the R1 changeset; the rest describe their own slice. +**Source of truth:** ui.shadcn.com/docs/theming (token values verified against the live docs) + shadcn-vue component source for per-component classes. +**License note:** shadcn/ui is MIT. Copying token values and class recipes is permitted. Add one line to the rowkit README and docs: _"Design language based on shadcn/ui by shadcn, adapted for Vue."_ Attribution isn't legally required by MIT for design values, but it's honest and reads well. + +--- + +## Part 0 — The architectural decision (read before touching code) + +shadcn's theme model and rowkit's current token model are **structurally different**, and the agent must not blend them naively: + +- **rowkit today:** 11-step primitive ramps (`neutral-50…950`, etc.) + a semantic layer referencing them. +- **shadcn:** a **flat, purely semantic** model. No exposed primitive ramps. Every token is a surface/foreground _pair_: `primary` + `primary-foreground`, `card` + `card-foreground`. The base token is the surface; `-foreground` is what sits on it. + +**Decision: adopt shadcn's semantic model as rowkit's public token API.** + +- The primitive ramps may remain as internal implementation detail in `@rowkit/tokens` (they're useful for future theme presets), but **components reference only semantic tokens**, and the semantic set becomes shadcn's set. +- This changes `@rowkit/tokens`' public surface → it's the reason this is 0.2.0, not 0.1.x. +- Existing rowkit semantic names map as follows: + +| rowkit 0.1 token | becomes (shadcn convention) | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--color-background` | `--background` (the page) | +| `--color-surface` | `--card` — rowkit's `surface` is the raised surface, not the page | +| `--color-surface-subtle` | `--muted` (table headers, toolbars) | +| `--color-surface-hover` | `--accent` — shadcn has no hover token; `accent` is what its rows and items hover to | +| `--color-skeleton` | `--accent` (shadcn's Skeleton is `bg-accent`) | +| `--color-border` | `--border` | +| `--color-border-control` | `--input` — **this** is rowkit's form-control boundary, not `border-strong` | +| `--color-border-strong` | no shadcn equivalent; audit each usage and collapse into `--border` | +| `--color-focus-ring` | `--ring` | +| `--color-text` | `--foreground` | +| `--color-text-muted` | `--muted-foreground` | +| `--color-text-subtle` | `--muted-foreground` (shadcn has one muted level; collapse) | +| `--color-primary-*` | `--primary` (+ `--primary-foreground`) | +| `--color-danger-*` | `--destructive` (shadcn's naming; keep `danger` as a documented alias in the Badge/Toast variant API — **do not rename component props**, only tokens) | +| success / warning tones | **not in shadcn's default set** — add per shadcn's own "Adding New Tokens" recipe (below), styled to match | + +**Component prop APIs do not change.** `variant="danger"` stays `danger`. This is a restyle, not an API break. + +### 0.1 The constraint that decides how the values are entered + +`color.test.ts` asserts that **every semantic token matches `^var\(--color-[a-z0-9-]+\)$`** and points at a primitive that exists — hard rule 1, enforced. A second test forbids a semantic token referencing another semantic token, and `contrast.test.ts` resolves tokens through the same `var()` form, so a literal there throws rather than fails. + +shadcn's model is flat literals in the semantic layer. Pasting §1.1 into `semanticColorLight` therefore breaks the token suite on the first run, before any component is touched. + +**So the values enter as primitives.** Add shadcn's greys as a primitive scale (they do not coincide with rowkit's `neutral-*` ramp — shadcn's are zero-chroma, rowkit's are hue 264), then point the new semantic set at them by reference. The public semantic API becomes shadcn's; the mechanism stays rowkit's. Nothing about the architecture needs relaxing, but the naive paste does not work and the agent must not "fix" the tests to make it. + +`--border: oklch(1 0 0 / 10%)` in dark mode is white at alpha, not a ramp step — it needs a primitive of its own (`white-alpha-10`, `white-alpha-15`), since the regex admits no `/` in a token name. + +--- + +## Part 1 — The exact token values (verified against shadcn docs) + +### 1.1 Light theme (`:root`) + +```css +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); +} +``` + +### 1.2 Dark theme (`.dark`) + +```css +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); +} +``` + +**Details that make dark mode _feel_ like shadcn — do not "fix" these:** + +- Dark borders are **white at 10% alpha**, not a solid grey. Inputs at 15%. This is why shadcn dark borders look soft on any surface. +- Dark `primary` **inverts**: near-white surface, near-black text. A rowkit primary button in dark mode is light, not colored. +- Everything is **zero chroma** (pure neutral) except `destructive`. The shadcn look _is_ this restraint. + +### 1.3 Success / warning (rowkit additions, shadcn recipe) + +shadcn's default set has no success/warning; rowkit's Badge and Toast need them. + +> **The values previously listed here were unusable and have been replaced.** Measured with rowkit's own contrast maths: `--success-foreground` on `--success` came to **2.19:1** in light mode against a 4.5 requirement — a label you cannot read. The warning fill sat at **1.65:1** against the page (light) and **2.14:1** (dark), failing WCAG 1.4.11 for a control the user has to locate. "Eyeball it in Storybook" is not how this repo has ever set a colour; every pairing is asserted. + +Solved against the same gates instead — lightest step that clears both a 4.5:1 label and a 3:1 fill against the page, at rowkit's existing hues and chroma so the families stay perceptually matched: + +```css +:root { + --success: oklch(0.65 0.142 152); + --success-foreground: oklch(0.205 0 0); /* dark label — white gives 3.04:1 */ + --warning: oklch(0.67 0.128 75); + --warning-foreground: oklch(0.205 0 0); +} +``` + +Light mode: success fill 3.04:1 vs page with a 5.89:1 label; warning 3.05:1 with 5.87:1. Both carry a **dark** label, not white — the same conclusion rowkit reached in 0.1 for amber, and it holds for green too at any lightness that keeps the fill visible on a white page. + +Dark mode values are still to be solved when R1 runs; do it the same way rather than mirroring, and add every new pair to `contrast.test.ts` in the same commit. + +### 1.4 Radius scale — a formula, not a list + +**Status: implemented.** Shipped ahead of the rest of R1, since token names do not change and no component needed editing. + +`--radius: 0.625rem` (10px) is the single source; everything derives: + +```css +--radius-xs: calc(var(--radius) * 0.4); /* rowkit's — lands on 4px */ +--radius-sm: calc(var(--radius) * 0.6); +--radius-md: calc(var(--radius) * 0.8); +--radius-lg: var(--radius); +--radius-xl: calc(var(--radius) * 1.4); +``` + +Multiplication, not `calc(var(--radius) - 4px)` subtraction — confirmed against both ui.shadcn.com and shadcn-vue. The two forms happen to agree at the default 10px and diverge only once `--radius` is overridden, which is exactly when it matters. + +`xs` is rowkit's addition: components use `rounded-xs` nine times, shadcn's list has no `xs`, and left undefined it falls back to Tailwind's own 2px. At 0.4 it lands on 4px — the radius shadcn hardcodes on its Checkbox. `none` and `full` stay as they were. + +> **`--radius` must be declared outside `@theme`.** Tailwind emits only the theme variables its generated utilities reference, and a bare `--radius` generates no utility. Left inside `@theme` it can be dropped from the output while every `rounded-*` rule still looks correct — and `calc()` over an undefined variable is not a CSS error, so `border-radius` computes to nothing and every corner in the library goes square with no warning anywhere. It is declared in its own `:root` block, and `theme.test.ts` asserts on the compiled stylesheet that it survived. + +One variable retunes every corner in the library — keep that property; it's better engineering than a flat list. + +### 1.5 The Tailwind bridge (`@theme inline`) + +The `:root`/`.dark` variables are raw values; expose them to Tailwind utilities via `@theme inline` in the tokens CSS, mapping `--color-background: var(--background)` etc. for every pair, plus the radius scale. This replaces rowkit's current `@theme` block contents. The `@source` distribution architecture from Phase 1 is unaffected — only the values and names inside change. + +--- + +## Part 2 — Component-level restyle specs + +**Method for the agent:** for every component below, the authoritative class recipes live in **shadcn-vue's source** (`ui.shadcn.com` renders React; shadcn-vue is the Vue port of the same design and is the better crib for Vue templates). Step one of each component's session: pull up the corresponding shadcn-vue component source and extract its classes into the `.variants.ts` file. The reference values below are the current well-known recipes — **verify each against live source at implementation time; where they differ, source wins.** + +### Shared treatments (apply first, everywhere) + +- **Typography:** component text is `text-sm` (14px). No component uses base-16 text. +- **Focus ring:** `focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]` — a 3px ring at 50% opacity **plus** the border shifting to ring color. This exact recipe on every focusable element; it's the single most recognizable shadcn detail. +- **Invalid state:** `aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive`. +- **Shadows are near-absent:** `shadow-xs` on buttons/inputs/cards, `shadow-md` on popover-scale overlays, `shadow-lg` on Dialog. Nothing heavier. If a rowkit component currently has a stronger shadow, remove it. +- **Transitions:** `transition-[color,box-shadow]` on interactive elements — shadcn does not transition all properties. +- **Disabled:** `disabled:pointer-events-none disabled:opacity-50`. + +### Button + +- Sizes: `sm` = `h-8 px-3`, `md` (default) = `h-9 px-4 py-2`, `lg` = `h-10 px-6`; all `text-sm font-medium`, gap-2, `rounded-md`, `shadow-xs` (not on ghost). +- Variants → shadcn mapping: `primary` → `bg-primary text-primary-foreground hover:bg-primary/90`; `secondary` → `bg-secondary text-secondary-foreground hover:bg-secondary/80`; `ghost` → `hover:bg-accent hover:text-accent-foreground`; `danger` → destructive recipe (`bg-destructive text-white hover:bg-destructive/90`, dark: `dark:bg-destructive/60`). +- Icon slots: SVGs auto-sized `size-4`, `shrink-0`. +- Keep rowkit's `loading` / `aria-busy` behavior — style the spinner `size-4 animate-spin`, keep layout reservation. + +### Field / Input + +- Input: `h-9 px-3 py-1 text-sm rounded-md border bg-transparent shadow-xs`, border color from `--input`, dark: `dark:bg-input/30`. Placeholder `placeholder:text-muted-foreground`. Focus + invalid per shared treatments. +- Field label: `text-sm font-medium`; hint and error `text-sm`, hint in `text-muted-foreground`, error in `text-destructive`. Keep all existing wiring (ids, describedby, role=alert) untouched — this phase changes classes only. + +### Select + +- Trigger styled as Input (h-9 recipe + chevron `size-4 opacity-50`). +- Popup: `bg-popover text-popover-foreground rounded-md border shadow-md`, items `text-sm rounded-sm px-2 py-1.5`, highlighted item `bg-accent text-accent-foreground`, check indicator `size-4`. + +### Badge + +- `rounded-md border px-2 py-0.5 text-xs font-medium w-fit gap-1`. +- `neutral` → secondary recipe; `success`/`warning` → the new token pairs; `danger` → destructive. Note shadcn badges are **borderless when filled** (`border-transparent`) — replicate. + +### DataTable / Table + +- shadcn's Table: rows `border-b`, `hover:bg-muted/50`, selected rows `data-[state=selected]:bg-muted`; cells `p-2 align-middle text-sm`; header cells `h-10 px-2 text-left font-medium text-muted-foreground`. +- Sticky header background: `bg-background` (opaque — the existing scroll-shadow affordance stays, restyled subtle). +- Selection checkboxes: shadcn Checkbox recipe (`size-4 rounded-[4px] border shadow-xs`, checked `bg-primary text-primary-foreground border-primary`). +- Sort buttons inside `th`: ghost-button treatment, `text-muted-foreground`, arrow icon `size-4`. +- Loading skeleton rows and EmptyState composition unchanged structurally — restyle only. + +### TablePagination + +- Buttons: ghost/outline button `size-9` (icon buttons) recipe; range text `text-sm text-muted-foreground`; page-size Select inherits Select restyle. + +### EmptyState + +- Container: no border by default (shadcn "Empty" pattern is open space): icon slot `text-muted-foreground`, title `text-lg font-medium`, description `text-sm text-muted-foreground`, action gets Button as-is. + +### Skeleton + +- shadcn's Skeleton is exactly: `bg-accent rounded-md animate-pulse`. Replace rowkit's shimmer/opacity animation with `animate-pulse` — **keep** the `prefers-reduced-motion` story and the `static` prop (pulse honors reduced motion via the existing media-query guard). + +### Dialog + +- Overlay: `bg-black/50`. Panel: `bg-background rounded-lg border p-6 shadow-lg sm:max-w-lg gap-4`, title `text-lg font-semibold`, description `text-sm text-muted-foreground`. Close button top-right: ghost, `size-4` icon, `opacity-70 hover:opacity-100`. +- Enter/leave: fade + slight zoom (`zoom-in-95` feel) — map to rowkit motion tokens; durations stay token-driven. + +### Toast + +- Card: `bg-popover text-popover-foreground rounded-lg border p-4 shadow-lg text-sm`, variant left-accent or icon per current design but colored via the semantic pairs (`destructive`, `success`, `warning`). Action button = small outline Button. Queue behavior untouched. + +### Tooltip + +- `bg-primary text-primary-foreground rounded-md px-3 py-1.5 text-xs`, subtle fade/zoom in. (Yes — shadcn tooltips are primary-colored, i.e. near-black in light mode, near-white in dark. Keep it; it's part of the look.) + +### FilterBar + +- No shadcn equivalent — style by composition: chips = Badge `secondary` recipe with a ghost close button (`size-3.5` icon), clear-all = ghost Button `sm`. Container spacing `gap-2`. + +--- + +## Part 3 — Execution plan for the agent + +Work on branch `feat/shadcn-restyle`. One PR per group, standard DoD applies minus docs-prose rewrites (visual specs in stories are the artifact). + +| Session | Scope | Gate | +| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | `@rowkit/tokens`: new semantic set, foreground pairs, success/warning additions, `@theme inline` bridge, dark mode. Migrate primitives to internal. (Radius formula: **done**, see §1.4.) | Tokens docs page renders the new set; dark toggle correct; **zero hardcoded values anywhere** (existing hard rule); every new pair added to `contrast.test.ts` | +| R2 | Shared treatments + Button, Badge, Skeleton | Storybook side-by-side vs ui.shadcn.com matches | +| R3 | Field/Input, Select | Same + a11y suite still green (focus recipe changed — re-verify visible focus in both modes) | +| R4 | Table family: DataTable, TablePagination, EmptyState, FilterBar | Users-admin playground page reads as a shadcn dashboard | +| R5 | Overlays: Dialog, Toast, Tooltip | Stacking scene re-verified; reduced-motion stories pass | +| R6 | Sweep: docs site theme vars remapped to new tokens; new screenshots; changeset; bundle budget check | Chromatic diff reviewed — every change intentional | + +**Visual regression:** Chromatic is **not set up in this repo** — there is no account, token, or workflow, so an R6 gate reading "Chromatic diff reviewed" would pass by never running. Either wire it up as its own task before R1 (it is a real setup job, not a checkbox), or run the comparison with what exists: Storybook builds on every PR, and the a11y suite already drives Playwright, so a screenshot pass over the story list is a day's work against a third-party service and an ongoing cost. Decide before R1, and if the answer is "no Chromatic", strike the gate rather than leaving it aspirational. + +**Verification protocol (the "double-check" requested):** + +1. Per component: open ui.shadcn.com's component demo and rowkit's story side by side, light and dark, at 100% zoom. Compare: height, padding, radius, border color, text size/weight, focus ring, hover state, shadow. +2. DevTools-measure any doubt — computed `height` on a default button must be **36px** (h-9), radius **calc(0.625rem * 0.8)** = 8px for `rounded-md`. +3. The a11y gates re-run in full. **Correction: shadcn's own pairs do not all pass AA** — this was asserted here without being measured, and it is wrong. Run against rowkit's contrast maths, shadcn's default set fails in four places before any rowkit addition: + + | Pair | shadcn | required | + | ----------------------------- | ------ | -------- | + | focus ring vs page (light) | 2.59:1 | 3:1 | + | input border vs page (light) | 1.26:1 | 3:1 | + | muted text on `muted` (light) | 4.34:1 | 4.5:1 | + | white on `destructive` (dark) | 2.89:1 | 4.5:1 | + + shadcn's own dark-mode recipe `dark:bg-destructive/60` lifts the last to 4.36:1 — still short. Dark mode is otherwise clean; every failure above is light mode. + + **Resolution chosen: match shadcn, fix only what fails.** Minimal solved deviations: `--ring` 0.708 → **0.669**, `--muted-foreground` 0.556 → **0.547**, `--input` 0.922 → **0.669**. `--border` stays at shadcn's 0.922 — rowkit's model already distinguishes a decorative hairline from a control boundary, and WCAG 1.4.11 governs only the latter, so tables and cards remain pixel-exact while input borders darken. + +4. Bundle budget: class churn shouldn't move it meaningfully; confirm. + +**Out of scope, explicitly:** sidebar tokens, chart tokens (rowkit has no such components); any component API change; any behavior change. If a session finds itself editing anything but classes, variants files, and token files — stop, that's scope drift. + +--- + +## Part 4 — What to tell the world (post-restyle) + +- Changeset text: "Visual refresh: rowkit now ships the shadcn/ui neutral design language out of the box. No API changes. Custom themes via the same token overrides as before." +- Docs: the theming page gains "rowkit follows the shadcn token convention — if you've themed shadcn/ui, you already know how to theme rowkit." That sentence is the strategic payoff of this whole effort: **instant familiarity for the largest design-token-literate audience in frontend.** +- One more LinkedIn post lives here: "why my Vue library adopted shadcn's design language (and what its token system taught me)." diff --git a/packages/tokens/src/css.ts b/packages/tokens/src/css.ts index 8f72b2c..80c291a 100644 --- a/packages/tokens/src/css.ts +++ b/packages/tokens/src/css.ts @@ -1,6 +1,6 @@ import { colorPrimitives, semanticColorDark, semanticColorLight } from './color' import { duration, easing } from './motion' -import { radius } from './radius' +import { radiusBase, radiusCss } from './radius' import { shadow } from './shadow' import { spacing, spacingBase } from './spacing' import { fontFamily, fontSize, fontWeight, letterSpacing, lineHeight } from './typography' @@ -46,8 +46,8 @@ export function buildThemeCss(): string { ...entries(letterSpacing, (k) => `--tracking-${k}`), ...entries(lineHeight, (k) => `--leading-${k}`), '', - section('radii'), - ...entries(radius, (k) => `--radius-${k}`), + section('radii — multiples of --radius, declared in :root below'), + ...entries(radiusCss, (k) => `--radius-${k}`), '', section('shadows'), ...entries(shadow, (k) => `--shadow-${k}`), @@ -64,6 +64,25 @@ export function buildThemeCss(): string { ...entries(zIndex, (k) => `--z-index-${k}`), '}', '', + /* + * Outside `@theme` on purpose. + * + * The radius scale is `calc(var(--radius) * f)`, so `--radius` has to + * resolve wherever a `rounded-*` utility lands. Tailwind only emits the + * theme variables its generated utilities reference, and nothing generates + * a utility from a bare `--radius` — left inside `@theme` it can be dropped, + * and every `calc()` above then references an undefined variable. That is + * not an error in CSS: `border-radius` simply computes to nothing and every + * corner in the library goes square, with no warning anywhere. + * + * Declaring it here also makes it the documented override point: a consumer + * sets `--radius` once and the whole scale follows. + */ + '/* The one length the radius scale multiplies. Override to retune every corner. */', + ':root {', + ` --radius: ${radiusBase};`, + '}', + '', '/* Dark mode repoints semantic tokens only. Primitives are theme-agnostic. */', '.dark {', ...entries(semanticColorDark, (k) => `--color-${k}`), diff --git a/packages/tokens/src/index.ts b/packages/tokens/src/index.ts index 1cfef24..8f0c27a 100644 --- a/packages/tokens/src/index.ts +++ b/packages/tokens/src/index.ts @@ -31,7 +31,7 @@ import { warning, } from './color' import { duration, easing } from './motion' -import { radius } from './radius' +import { radius, radiusBase } from './radius' import { shadow } from './shadow' import { spacing, spacingBase } from './spacing' import { fontFamily, fontSize, fontWeight, letterSpacing, lineHeight } from './typography' @@ -53,7 +53,7 @@ export type { ColorRef, ColorStep, SemanticColorName } from './color' export { duration, easing } from './motion' export type { DurationName, EasingName } from './motion' -export { radius } from './radius' +export { radius, radiusBase } from './radius' export type { RadiusName } from './radius' export { shadow } from './shadow' @@ -101,6 +101,7 @@ export const tokens = { lineHeight, }, radius, + radiusBase, shadow, zIndex, motion: { diff --git a/packages/tokens/src/radius.ts b/packages/tokens/src/radius.ts index e8accd0..1b76814 100644 --- a/packages/tokens/src/radius.ts +++ b/packages/tokens/src/radius.ts @@ -1,25 +1,88 @@ /** - * Corner radii. + * Corner radii, derived from one variable. * - * Restrained by design: heavily rounded corners waste horizontal space at the - * edges of a dense grid and make adjacent cells read as separate objects. + * Every step is a multiple of `--radius`, following the shadcn/ui scale. One + * declaration retunes every corner in the library: + * + * ```css + * :root { --radius: 0.5rem; } + * ``` + * + * The factors are the source of truth, not the resulting lengths. Two things + * are generated from them and cannot drift: {@link radius}, which resolves to + * real `rem` values so a TypeScript consumer gets a number it can use, and + * {@link radiusCss}, which keeps the `calc()` so a consumer's override of + * `--radius` still cascades through the whole scale. + * + * Design language based on shadcn/ui by shadcn, adapted for Vue. */ -export const radius = { + +/** The single length the scale multiplies. shadcn/ui's default. */ +export const radiusBase = '0.625rem' + +/** + * Multiples of `--radius`. + * + * `sm`/`md`/`lg`/`xl` are shadcn's published factors. `xs` is rowkit's, and + * lands on 4px — the radius shadcn hardcodes on its Checkbox, which is the + * control this step exists for. + */ +export const radiusFactor = { /** Square. Table cells, and anything that tiles edge to edge. */ - none: '0rem', - /** 2px — checkboxes, tags inside a cell. */ - xs: '0.125rem', - /** 4px — inputs, buttons, badges. The rowkit default. */ - sm: '0.25rem', - /** 6px — cards, popovers. */ - md: '0.375rem', - /** 8px — dialogs. */ - lg: '0.5rem', - /** 12px — large empty-state panels. */ - xl: '0.75rem', + none: 0, + /** 4px — checkboxes, tags inside a cell. */ + xs: 0.4, + /** 6px — badges, small controls. */ + sm: 0.6, + /** 8px — buttons, inputs, cards. The rowkit default. */ + md: 0.8, + /** 10px — dialogs, popovers. */ + lg: 1, + /** 14px — large empty-state panels. */ + xl: 1.4, +} as const + +/** A radius that is not a multiple of the base. */ +const PILL = '9999px' + +/** + * Resolved lengths, for TypeScript consumers and for the contrast of reading + * an actual size in the docs table. + */ +export const radius = { + ...(Object.fromEntries( + Object.entries(radiusFactor).map(([name, factor]) => [name, resolve(factor)]) + ) as { [K in keyof typeof radiusFactor]: string }), /** Pill. Status chips and avatars. */ - full: '9999px', + full: PILL, +} as const + +/** + * The same scale as CSS expressions, for the emitted `@theme` block. + * + * `lg` is bare `var(--radius)` rather than `calc(var(--radius) * 1)` because + * the multiplication is noise at a factor of one. + */ +export const radiusCss = { + ...(Object.fromEntries( + Object.entries(radiusFactor).map(([name, factor]) => [name, expression(factor)]) + ) as { [K in keyof typeof radiusFactor]: string }), + full: PILL, } as const /** Names of every radius token. */ export type RadiusName = keyof typeof radius + +function resolve(factor: number): string { + if (factor === 0) return '0rem' + const base = Number.parseFloat(radiusBase) + // Six places, then trailing zeros stripped: 0.625 * 1.4 is 0.8749999… in + // binary floating point, and `0.875rem` is the value that belongs in the docs. + return `${Number((base * factor).toFixed(6))}rem` +} + +function expression(factor: number): string { + if (factor === 0) return '0rem' + if (factor === 1) return 'var(--radius)' + return `calc(var(--radius) * ${factor})` +} diff --git a/packages/ui/src/styles/theme.test.ts b/packages/ui/src/styles/theme.test.ts index cef7345..c22cd03 100644 --- a/packages/ui/src/styles/theme.test.ts +++ b/packages/ui/src/styles/theme.test.ts @@ -83,6 +83,39 @@ describe('rowkit tokens compile to Tailwind utilities', () => { }) }) +describe('the radius scale resolves', () => { + /* + * Every radius is `calc(var(--radius) * f)`. Tailwind emits only the theme + * variables its generated utilities reference, and no utility is generated + * from a bare `--radius` — so if it lived inside `@theme` it could be dropped + * from the output while every `rounded-*` rule still looked perfectly correct. + * + * A `calc()` over an undefined variable is not a CSS error. `border-radius` + * computes to nothing and every corner in the library goes square, silently. + * That is why `--radius` is declared in its own `:root` block, and why this + * asserts on the compiled stylesheet rather than on the token object. + */ + it('declares --radius, so the calc() has something to multiply', async () => { + const css = await build('rounded-md') + expect(css, '--radius vanished — every rounded-* utility now computes to 0').toMatch( + /--radius:\s*0\.625rem/ + ) + }) + + it.each([ + ['rounded-xs', 0.4], + ['rounded-sm', 0.6], + ['rounded-md', 0.8], + ['rounded-xl', 1.4], + ])('%s multiplies --radius by %d', async (utility, factor) => { + expect(await build(utility)).toContain(`calc(var(--radius) * ${factor})`) + }) + + it('leaves rounded-lg as the base, unmultiplied', async () => { + expect(await build('rounded-lg')).toMatch(/--radius-lg:\s*var\(--radius\)/) + }) +}) + describe('shadows', () => { it.each(['shadow-xs', 'shadow-md', 'shadow-scroll-x'])('%s is generated', async (utility) => { expect(await build(utility)).toContain(`.${utility} {`) From 7858dc7b342df35ef103e0838d6763af5eceb2e2 Mon Sep 17 00:00:00 2001 From: NikolaiKushner Date: Tue, 4 Aug 2026 11:45:07 +0400 Subject: [PATCH 03/19] feat(tokens): update color tokens to align with shadcn/ui palette This commit repoints the neutral core to the shadcn/ui palette, replacing rowkit's blue-tinted greys with zero-chroma greys. It introduces new `gray` and `whiteAlpha` scales, ensuring compliance with WCAG standards for contrast. The selected table row is now neutral, removing brand tinting. Additionally, adjustments to semantic colors and tests for translucent tokens have been made to enhance visual consistency and accessibility. --- .changeset/olive-cups-repeat.md | 16 ++ packages/tokens/src/color.ts | 232 ++++++++++++++++++++------- packages/tokens/src/contrast.test.ts | 33 ++++ packages/tokens/src/index.ts | 6 + packages/tokens/test/oklch.ts | 51 +++++- 5 files changed, 275 insertions(+), 63 deletions(-) create mode 100644 .changeset/olive-cups-repeat.md diff --git a/.changeset/olive-cups-repeat.md b/.changeset/olive-cups-repeat.md new file mode 100644 index 0000000..6dc1c1b --- /dev/null +++ b/.changeset/olive-cups-repeat.md @@ -0,0 +1,16 @@ +--- +'@rowkit/tokens': minor +'rowkit': minor +--- + +Repoint the neutral core to the shadcn/ui palette. + +Page, surfaces, text, borders, focus ring and skeleton now use shadcn's zero-chroma greys instead of rowkit's blue-tinted `neutral` ramp. Dark-mode borders are white at 10% and 15% alpha, as shadcn's are, which is what keeps them soft on both the page and a card. The selected table row is no longer tinted with the brand. + +New `gray` and `whiteAlpha` primitive scales, exported alongside the existing ramps. `gray` is keyed by OKLCH lightness — `gray-922` is `oklch(0.922 0 0)` — so a step can be checked against shadcn's published value by reading its name. + +Three values deviate from shadcn deliberately, each the smallest change that clears WCAG: `--ring` and `--input` (2.59:1 and 1.26:1 against a white page, both failing 1.4.11) and `--muted-foreground` (4.34:1 on the recessed surface a table header sits on). The decorative `--border` keeps shadcn's value exactly, so row and card hairlines are unchanged. + +Token names are unchanged; no component or utility class needs editing. Status colours — primary, success, warning, danger — are untouched and follow in a later release. + +Design language based on shadcn/ui by shadcn, adapted for Vue. diff --git a/packages/tokens/src/color.ts b/packages/tokens/src/color.ts index 05d9c42..d7dec75 100644 --- a/packages/tokens/src/color.ts +++ b/packages/tokens/src/color.ts @@ -105,6 +105,85 @@ export const danger = { 950: 'oklch(0.282 0.086 25)', } as const +/** + * Zero-chroma greys, the shadcn/ui neutral. + * + * Named by lightness rather than by a 50…950 position: the step *is* its OKLCH + * lightness × 1000, so `gray-922` is `oklch(0.922 0 0)` and the table above can + * be checked against ui.shadcn.com by reading it. A ramp position would have + * been a lie here — shadcn's greys are not evenly spaced and do not fill a + * ramp, and two of the steps below exist only because a shadcn value failed + * contrast and had to be darkened. + * + * Pure neutral is the point. rowkit's own `neutral` ramp carries a trace of + * blue (hue 264); the shadcn look is the absence of that. + * + * Design language based on shadcn/ui by shadcn, adapted for Vue. + */ +export const gray = { + /** shadcn `--primary-foreground`, `--foreground` (dark). */ + 985: 'oklch(0.985 0 0)', + /** shadcn `--secondary`, `--muted`, `--accent`. */ + 970: 'oklch(0.97 0 0)', + /** shadcn `--border`. Decorative hairline — deliberately below 3:1. */ + 922: 'oklch(0.922 0 0)', + /** Emphasised hairline. rowkit's; shadcn has no "strong border". */ + 870: 'oklch(0.87 0 0)', + /** shadcn `--muted-foreground` (dark), where it clears AA at 7.63:1. */ + 708: 'oklch(0.708 0 0)', + /** + * rowkit's correction to shadcn `--ring` and `--input` in light mode. + * + * shadcn puts them at 0.708 and 0.922, which measure 2.59:1 and 1.26:1 + * against a white page — a focus ring and a control boundary that both fail + * WCAG 1.4.11. + * + * Not the mathematical minimum. Solving in floating point gave 0.669 and a + * tidy 3.00:1; the browser paints `#959595` and axe measured **2.995:1**, + * because a colour is quantised to eight bits per channel before anyone sees + * it. Anything solved exactly onto a threshold lands on whichever side the + * rounding chooses. 0.635 is 3.45:1 against the page and 3.17:1 against + * `surface-subtle`, which clears the bar on both sides of the rounding. + */ + 635: 'oklch(0.635 0 0)', + /** shadcn `--ring` (dark), 4.18:1 against the dark page. */ + 556: 'oklch(0.556 0 0)', + /** + * rowkit's correction to shadcn `--muted-foreground` in light mode. + * + * shadcn's 0.556 is 4.73:1 on white but only 4.34:1 on `--muted`, the + * recessed surface a table header sits on — and a table header is the single + * most common use this token has. + * + * 0.547 was the first attempt and shipped 4.51:1 in floating point; axe, + * reading the painted `#717171`, called it 4.47:1 and failed twenty-four + * stories. See {@link gray[635]} — same lesson, same cause. 0.535 measures + * 4.75:1 on `--muted` and 5.17:1 on the page. + */ + 535: 'oklch(0.535 0 0)', + /** Pressed row in dark mode. */ + 371: 'oklch(0.371 0 0)', + /** shadcn `--secondary`, `--muted`, `--accent` (dark). */ + 269: 'oklch(0.269 0 0)', + /** shadcn `--primary` (light), `--card` and `--popover` (dark). */ + 205: 'oklch(0.205 0 0)', + /** shadcn `--foreground` (light), `--background` (dark). */ + 145: 'oklch(0.145 0 0)', +} as const + +/** + * White at a fraction of opacity, for dark-mode borders. + * + * shadcn's dark borders are white at 10% and inputs at 15%, not a solid grey. + * That is why they read as soft against every surface instead of drawing a hard + * line on the darkest ones — a solid grey tuned for `--background` is too + * bright on `--card`. Keep the alpha; it is doing work no ramp step can. + */ +export const whiteAlpha = { + 10: 'oklch(1 0 0 / 10%)', + 15: 'oklch(1 0 0 / 15%)', +} as const + /** * Every primitive colour, keyed by the CSS custom property it becomes. * @@ -114,6 +193,8 @@ export const danger = { export const colorPrimitives = { white: 'oklch(1 0 0)', black: 'oklch(0 0 0)', + ...prefixKeys('gray', gray), + ...prefixKeys('white-alpha', whiteAlpha), ...prefix('neutral', neutral), ...prefix('primary', primary), ...prefix('success', success), @@ -130,6 +211,21 @@ function prefix( return out } +/** + * The same, for a scale that is not an eleven-step ramp. + * + * `gray` and `whiteAlpha` are keyed by lightness and by opacity, so they cannot + * go through {@link prefix}, which walks {@link colorSteps}. + */ +function prefixKeys>( + name: N, + scale: S +): { [K in keyof S & (string | number) as `${N}-${K}`]: string } { + const out: Record = {} + for (const [key, value] of Object.entries(scale)) out[`${name}-${key}`] = value + return out as { [K in keyof S & (string | number) as `${N}-${K}`]: string } +} + /** A reference to a primitive colour, as a CSS `var()` expression. */ export type ColorRef = `var(--color-${string})` @@ -143,20 +239,31 @@ const ref = (token: keyof typeof colorPrimitives): ColorRef => `var(--color-${to * hunting down hex codes. `semantic.test.ts` enforces this. */ export const semanticColorLight = { - /** Page background, behind all surfaces. */ - background: ref('neutral-50'), - /** Cards, panels, table bodies — the plane content sits on. */ + /** Page background, behind all surfaces. shadcn `--background`. */ + background: ref('white'), + /** Cards, panels, table bodies — the plane content sits on. shadcn `--card`. */ surface: ref('white'), - /** Table headers, toolbars: a surface that recedes slightly. */ - 'surface-subtle': ref('neutral-100'), - /** Row hover. */ - 'surface-hover': ref('neutral-100'), - /** Row press / active. */ - 'surface-active': ref('neutral-200'), - /** Selected table row. */ - 'surface-selected': ref('primary-50'), + /** + * Table headers, toolbars: a surface that recedes slightly. shadcn `--muted`. + * + * Identical to `surface-hover`: shadcn gives `--muted` and `--accent` the same + * value, and the distinction survives here because the two are separate + * override points, not because they differ out of the box. + */ + 'surface-subtle': ref('gray-970'), + /** Row hover. shadcn `--accent`. */ + 'surface-hover': ref('gray-970'), + /** Row press / active. One step past hover; shadcn has no press token. */ + 'surface-active': ref('gray-922'), + /** + * Selected table row. shadcn's `data-[state=selected]` is `bg-muted`. + * + * No longer tinted with the brand: shadcn's neutral is zero-chroma + * throughout, and a blue selected row was the loudest thing on the page. + */ + 'surface-selected': ref('gray-970'), /** Disabled control background. */ - 'surface-disabled': ref('neutral-100'), + 'surface-disabled': ref('gray-970'), /** * Loading placeholder fill. * @@ -168,24 +275,23 @@ export const semanticColorLight = { * standing in for content that has not arrived, so there is nothing for a * reader to perceive and WCAG 1.4.11 does not apply. */ - skeleton: ref('neutral-200'), + skeleton: ref('gray-970'), - /** Primary body and heading text. */ - text: ref('neutral-900'), + /** Primary body and heading text. shadcn `--foreground`. */ + text: ref('gray-145'), /** - * Secondary text, column labels, help text. + * Secondary text, column labels, help text. shadcn `--muted-foreground`. * - * `neutral-600`, not `500`. A table header is muted text on `surface-subtle`, - * and at `500` that pairing reached only 4.41:1 — passing on white, failing - * WCAG 1.4.3 on the recessed surface this token is most often used against. - * `600` clears it at 6.90:1 and is still 2.3× lighter than `text`, so the - * hierarchy survives. + * `gray-535`, not shadcn's 0.556. The same trap the old `neutral-500` fell + * into: a table header is muted text on `surface-subtle`, and shadcn's value + * reaches 4.73:1 on white but only 4.34:1 on the recessed surface this token + * is most often used against. Nine thousandths of lightness buy the pass. */ - 'text-muted': ref('neutral-600'), + 'text-muted': ref('gray-535'), /** Placeholders and de-emphasised metadata. */ - 'text-subtle': ref('neutral-400'), + 'text-subtle': ref('gray-635'), /** Text on a disabled control. */ - 'text-disabled': ref('neutral-400'), + 'text-disabled': ref('gray-708'), /** * Decorative hairline: row separators, card outlines. @@ -193,11 +299,11 @@ export const semanticColorLight = { * Deliberately below 3:1 against the surface. Do not use it for the boundary * of an interactive control — see {@link semanticColorLight['border-control']}. */ - border: ref('neutral-200'), + border: ref('gray-922'), /** Emphasised decorative border: dividers that need to read as structure. */ - 'border-strong': ref('neutral-300'), + 'border-strong': ref('gray-870'), /** Barely-there separation inside a dense group. */ - 'border-subtle': ref('neutral-100'), + 'border-subtle': ref('gray-970'), /** * Boundary of an interactive control — text inputs, checkboxes, outlined * buttons. @@ -205,14 +311,26 @@ export const semanticColorLight = { * WCAG 1.4.11 requires 3:1 against the adjacent surface for the visual * boundary of a UI component. `border` manages only 1.24:1 and * `border-strong` 1.49:1, so neither is legal here; this token is the - * lightest neutral that clears the bar (4.83:1 on `surface`). + * lightest neutral that clears the bar. + * + * shadcn's `--input` is 0.922 — the same value as its `--border` — which + * measures 1.26:1 and is not a legal control boundary. `gray-635` clears 3:1 + * against the page, a card and a toolbar alike, and it applies only to + * controls: `border` keeps shadcn's value exactly, so the hairlines between + * table rows and around cards are pixel-identical to shadcn. + */ + 'border-control': ref('gray-635'), + /** + * Focus ring. Never remove the ring — recolour it. shadcn `--ring`. + * + * Neutral now, not brand blue: shadcn's ring is grey, and the whole look is + * the absence of chroma. shadcn's own 0.708 is 2.59:1 against the page and + * fails 1.4.11, so this is the darkened step. */ - 'border-control': ref('neutral-500'), - /** Focus ring. Never remove the ring — recolour it. */ - 'focus-ring': ref('primary-600'), + 'focus-ring': ref('gray-635'), /** Base colour shadows are mixed from. */ - shadow: ref('neutral-950'), + shadow: ref('black'), // `neutral` completes the status family so a component's variant matrix has // no special case: a neutral Badge reads the same token names as a danger @@ -271,29 +389,33 @@ export const semanticColorLight = { * both label and background. */ export const semanticColorDark = { - background: ref('neutral-950'), - surface: ref('neutral-900'), - 'surface-subtle': ref('neutral-800'), - 'surface-hover': ref('neutral-800'), - 'surface-active': ref('neutral-700'), - 'surface-selected': ref('primary-950'), - 'surface-disabled': ref('neutral-800'), - // Lifts off `surface` (neutral-900) rather than receding. On a dark page a - // placeholder darker than its card reads as a hole in the layout. - skeleton: ref('neutral-800'), - - text: ref('neutral-50'), - 'text-muted': ref('neutral-400'), - 'text-subtle': ref('neutral-500'), - 'text-disabled': ref('neutral-600'), - - border: ref('neutral-800'), - 'border-strong': ref('neutral-700'), - 'border-subtle': ref('neutral-900'), - // neutral-500 is again the lightest step clearing 3:1, here against - // `surface` (neutral-900) at 3.67:1. - 'border-control': ref('neutral-500'), - 'focus-ring': ref('primary-400'), + background: ref('gray-145'), + surface: ref('gray-205'), + 'surface-subtle': ref('gray-269'), + 'surface-hover': ref('gray-269'), + 'surface-active': ref('gray-371'), + 'surface-selected': ref('gray-269'), + 'surface-disabled': ref('gray-269'), + // Lifts off `surface` rather than receding. On a dark page a placeholder + // darker than its card reads as a hole in the layout. + skeleton: ref('gray-269'), + + text: ref('gray-985'), + // shadcn's own value, kept: 7.63:1 on the page and 5.83:1 on `--muted`, so + // dark mode needs none of the correction light mode did. + 'text-muted': ref('gray-708'), + 'text-subtle': ref('gray-556'), + 'text-disabled': ref('gray-556'), + + // White at alpha, not a solid grey — see `whiteAlpha`. A grey tuned against + // `background` draws too hard a line once the same border sits on `surface`. + border: ref('white-alpha-10'), + 'border-strong': ref('white-alpha-15'), + 'border-subtle': ref('white-alpha-10'), + // shadcn's `--input`, unchanged: composited over the page it measures + // 3.82:1, and 3.54:1 over a card, so both clear 1.4.11 without help. + 'border-control': ref('white-alpha-15'), + 'focus-ring': ref('gray-556'), shadow: ref('black'), diff --git a/packages/tokens/src/contrast.test.ts b/packages/tokens/src/contrast.test.ts index 5964137..b616fff 100644 --- a/packages/tokens/src/contrast.test.ts +++ b/packages/tokens/src/contrast.test.ts @@ -56,6 +56,12 @@ const pairings: readonly Pairing[] = [ ['focus ring against a surface', 'focus-ring', 'surface', AA_NON_TEXT], ['control border against a surface', 'border-control', 'surface', AA_NON_TEXT], ['control border against the page', 'border-control', 'background', AA_NON_TEXT], + // Controls live in toolbars and table headers too, which are `surface-subtle` + // rather than `surface` — the darkest plane either token normally sits on, + // and the one neither was checked against until the palette changed under + // them. + ['focus ring against a recessed surface', 'focus-ring', 'surface-subtle', AA_NON_TEXT], + ['control border against a recessed surface', 'border-control', 'surface-subtle', AA_NON_TEXT], ] describe.each([ @@ -68,6 +74,33 @@ describe.each([ }) }) +describe('translucent tokens are measured as they render', () => { + /* + * Dark mode's borders are white at 10% and 15% alpha. Measuring the source + * colour instead of the composite would score them as pure white — around + * 15:1 against the page — and every threshold above would pass for a border + * nobody can see. The pass/fail assertions cannot catch that on their own, + * because the wrong answer is comfortably over the bar too. + * + * So the composite is pinned by value. If `semanticContrast` ever stops + * compositing, these fail; a ratio near 15 is the signature of that bug. + */ + it('composites the 15% control border over the surface behind it', () => { + expect( + semanticContrast(semanticColorDark['border-control'], semanticColorDark.surface) + ).toBeCloseTo(3.54, 1) + expect( + semanticContrast(semanticColorDark['border-control'], semanticColorDark.background) + ).toBeCloseTo(3.82, 1) + }) + + it('composites the 10% hairline, which is decorative and stays under 3:1', () => { + const ratio = semanticContrast(semanticColorDark.border, semanticColorDark.surface) + expect(ratio).toBeLessThan(AA_NON_TEXT) + expect(ratio).toBeGreaterThan(1.5) + }) +}) + describe('solid fills are distinguishable from the page behind them', () => { // A button whose label is legible but whose body blends into the page is // still broken. This is what ruled out mirroring light mode's 600 fill in diff --git a/packages/tokens/src/index.ts b/packages/tokens/src/index.ts index 8f0c27a..51d8e37 100644 --- a/packages/tokens/src/index.ts +++ b/packages/tokens/src/index.ts @@ -23,12 +23,14 @@ import { version as pkgVersion } from '../package.json' with { type: 'json' } import { colorPrimitives, danger, + gray, neutral, primary, semanticColorDark, semanticColorLight, success, warning, + whiteAlpha, } from './color' import { duration, easing } from './motion' import { radius, radiusBase } from './radius' @@ -41,12 +43,14 @@ export { colorPrimitives, colorSteps, danger, + gray, neutral, primary, semanticColorDark, semanticColorLight, success, warning, + whiteAlpha, } from './color' export type { ColorRef, ColorStep, SemanticColorName } from './color' @@ -78,6 +82,8 @@ export { buildThemeCss } from './css' */ export const tokens = { color: { + gray, + whiteAlpha, neutral, primary, success, diff --git a/packages/tokens/test/oklch.ts b/packages/tokens/test/oklch.ts index 7be4009..9119c0f 100644 --- a/packages/tokens/test/oklch.ts +++ b/packages/tokens/test/oklch.ts @@ -12,13 +12,33 @@ import { colorPrimitives } from '../src/color' /** Linear-light sRGB, unclamped so out-of-gamut colours stay detectable. */ export type LinearRgb = readonly [number, number, number] -const OKLCH = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)$/ +const OKLCH = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+)%\s*)?\)$/ -/** Parses an `oklch(L C H)` string. Throws on anything else, by design. */ -export function parseOklch(value: string): { l: number; c: number; h: number } { +/** + * Parses an `oklch(L C H)` string, or `oklch(L C H / P%)`. + * + * Alpha exists for the dark-mode borders, which are white at 10% and 15% rather + * than a solid grey. `alpha` is 1 when the value carries no slash, so callers + * that predate it keep working. + */ +export function parseOklch(value: string): { l: number; c: number; h: number; alpha: number } { const match = OKLCH.exec(value) if (!match) throw new Error(`not a plain oklch() value: ${value}`) - return { l: Number(match[1]), c: Number(match[2]), h: Number(match[3]) } + return { + l: Number(match[1]), + c: Number(match[2]), + h: Number(match[3]), + alpha: match[4] === undefined ? 1 : Number(match[4]) / 100, + } +} + +/** Composites a translucent colour over an opaque one, in linear light. */ +export function over(source: LinearRgb, backdrop: LinearRgb, alpha: number): LinearRgb { + return [ + source[0] * alpha + backdrop[0] * (1 - alpha), + source[1] * alpha + backdrop[1] * (1 - alpha), + source[2] * alpha + backdrop[2] * (1 - alpha), + ] } /** Converts OKLCH to linear-light sRGB. */ @@ -61,16 +81,31 @@ export function contrastRatio(a: LinearRgb, b: LinearRgb): number { * of the primitive it points at. */ export function resolveColorRef(cssVar: string): LinearRgb { + const { rgb } = resolveColorRefWithAlpha(cssVar) + return rgb +} + +/** The same, keeping the alpha channel so a translucent token can be composited. */ +export function resolveColorRefWithAlpha(cssVar: string): { rgb: LinearRgb; alpha: number } { const match = /^var\(--color-([a-z0-9-]+)\)$/.exec(cssVar) if (!match) throw new Error(`not a primitive colour reference: ${cssVar}`) const name = match[1] as keyof typeof colorPrimitives const literal = colorPrimitives[name] if (literal === undefined) throw new Error(`unknown primitive: --color-${String(name)}`) - const { l, c, h } = parseOklch(literal) - return oklchToLinearRgb(l, c, h) + const { l, c, h, alpha } = parseOklch(literal) + return { rgb: oklchToLinearRgb(l, c, h), alpha } } -/** Contrast between two semantic tokens, each given as a `var()` reference. */ +/** + * Contrast between two semantic tokens, each given as a `var()` reference. + * + * A translucent foreground is composited over the background first. Without + * that, dark mode's `border-control` — white at 15% — would be measured as pure + * white and score 15:1 against the page, which is not a colour anyone sees. + * The background is assumed opaque, which every surface token is. + */ export function semanticContrast(foreground: string, background: string): number { - return contrastRatio(resolveColorRef(foreground), resolveColorRef(background)) + const bg = resolveColorRef(background) + const { rgb, alpha } = resolveColorRefWithAlpha(foreground) + return contrastRatio(alpha === 1 ? rgb : over(rgb, bg, alpha), bg) } From 8a09d9d534de913305e5085418b30a3ca718ac4b Mon Sep 17 00:00:00 2001 From: NikolaiKushner Date: Tue, 4 Aug 2026 11:56:18 +0400 Subject: [PATCH 04/19] feat(tokens): align color tokens with shadcn/ui design language This update repoints the achromatic and destructive status colors to the shadcn/ui palette, introducing a new `red` primitive scale for danger states. The tooltip color is adjusted to `primary-solid` for better visibility, and the `neutral` color is redefined to match shadcn's `--secondary`. The changes enhance visual consistency and accessibility across components while maintaining existing values for success and warning colors. --- .changeset/tidy-moons-invent.md | 16 ++++ packages/tokens/src/color.ts | 93 ++++++++++++------- packages/tokens/src/contrast.test.ts | 12 ++- packages/tokens/src/index.ts | 3 + .../components/Tooltip/Tooltip.variants.ts | 7 +- 5 files changed, 98 insertions(+), 33 deletions(-) create mode 100644 .changeset/tidy-moons-invent.md diff --git a/.changeset/tidy-moons-invent.md b/.changeset/tidy-moons-invent.md new file mode 100644 index 0000000..e04b75f --- /dev/null +++ b/.changeset/tidy-moons-invent.md @@ -0,0 +1,16 @@ +--- +'@rowkit/tokens': minor +'rowkit': minor +--- + +Repoint the achromatic and destructive status colours to shadcn/ui. + +`primary` is now shadcn's `--primary`: near-black in light mode, and it **inverts** under `.dark` to near-white with near-black text. There is no brand hue in this design language — the default action is the darkest thing on the page, not the bluest. `neutral` becomes shadcn's `--secondary`, the quiet near-white chip. + +Tooltip moves from `neutral-solid` to `primary-solid`, matching shadcn, whose tooltip is `bg-primary`. Left as it was it would have rendered pale grey on a pale page. + +`danger` uses a new `red` primitive scale. Two deviations from shadcn, both forced: its published `oklch(0.577 0.245 27.325)` does not fit in sRGB, so the chroma is clamped to 0.235 — a difference nobody can see, in exchange for a colour that renders the same on sRGB and P3 instead of being gamut-mapped per browser. And shadcn's lighter dark-mode red carries its white label at 2.86:1, the worst failure in its default set, so one red now serves both themes at 4.90:1. + +`success` and `warning` are unchanged: shadcn has no equivalent to match, rowkit's existing values are already chroma-matched to their families and pass every gate, and replacing working colours for no fidelity gain is churn. + +Design language based on shadcn/ui by shadcn, adapted for Vue. diff --git a/packages/tokens/src/color.ts b/packages/tokens/src/color.ts index d7dec75..c90a8f2 100644 --- a/packages/tokens/src/color.ts +++ b/packages/tokens/src/color.ts @@ -171,6 +171,28 @@ export const gray = { 145: 'oklch(0.145 0 0)', } as const +/** + * shadcn's destructive red, clamped into sRGB. Keyed by lightness, like `gray`. + * + * shadcn publishes `oklch(0.577 0.245 27.325)`, and that chroma **does not fit + * in sRGB** — 0.235 is the maximum at this lightness and hue. The difference is + * invisible; what it buys is a colour that renders identically on an sRGB + * monitor and a P3 laptop, instead of one each browser gamut-maps by its own + * rules. rowkit clamps every chromatic primitive for this reason, and + * `color.test.ts` enforces it. + * + * One red serves both themes. shadcn's dark `--destructive` is a lighter + * `oklch(0.704 …)`, which carries its white label at **2.86:1** — the single + * worst failure in shadcn's default set. Reusing the light value gives 4.90:1 + * on the label in both themes and still clears 4.04:1 against the dark page. + */ +export const red = { + /** Destructive fill. shadcn's lightness, chroma clamped. */ + 577: 'oklch(0.577 0.235 27.325)', + /** Destructive hover — darkens in both themes, so the white label improves. */ + 520: 'oklch(0.52 0.212 27.325)', +} as const + /** * White at a fraction of opacity, for dark-mode borders. * @@ -194,6 +216,7 @@ export const colorPrimitives = { white: 'oklch(1 0 0)', black: 'oklch(0 0 0)', ...prefixKeys('gray', gray), + ...prefixKeys('red', red), ...prefixKeys('white-alpha', whiteAlpha), ...prefix('neutral', neutral), ...prefix('primary', primary), @@ -335,19 +358,23 @@ export const semanticColorLight = { // `neutral` completes the status family so a component's variant matrix has // no special case: a neutral Badge reads the same token names as a danger // one. It is the default state — "no status" — not an absence of styling. - 'neutral-solid': ref('neutral-700'), - 'neutral-solid-hover': ref('neutral-800'), - 'neutral-on-solid': ref('white'), - 'neutral-subtle': ref('neutral-100'), - 'neutral-on-subtle': ref('neutral-700'), - 'neutral-border': ref('neutral-200'), - - 'primary-solid': ref('primary-600'), - 'primary-solid-hover': ref('primary-700'), - 'primary-on-solid': ref('white'), - 'primary-subtle': ref('primary-50'), - 'primary-on-subtle': ref('primary-700'), - 'primary-border': ref('primary-200'), + // shadcn `--secondary`: the quiet chip, a near-white fill with dark text. + 'neutral-solid': ref('gray-970'), + 'neutral-solid-hover': ref('gray-922'), + 'neutral-on-solid': ref('gray-205'), + 'neutral-subtle': ref('gray-970'), + 'neutral-on-subtle': ref('gray-205'), + 'neutral-border': ref('gray-922'), + + // shadcn `--primary`: near-black in light mode, and it inverts under `.dark`. + // There is no brand hue in this design language — the default action is the + // darkest thing on the page, not the bluest. + 'primary-solid': ref('gray-205'), + 'primary-solid-hover': ref('gray-269'), + 'primary-on-solid': ref('gray-985'), + 'primary-subtle': ref('gray-970'), + 'primary-on-subtle': ref('gray-205'), + 'primary-border': ref('gray-922'), 'success-solid': ref('success-600'), 'success-solid-hover': ref('success-700'), @@ -371,8 +398,8 @@ export const semanticColorLight = { 'warning-on-subtle': ref('warning-700'), 'warning-border': ref('warning-200'), - 'danger-solid': ref('danger-600'), - 'danger-solid-hover': ref('danger-700'), + 'danger-solid': ref('red-577'), + 'danger-solid-hover': ref('red-520'), 'danger-on-solid': ref('white'), 'danger-subtle': ref('danger-50'), 'danger-on-subtle': ref('danger-700'), @@ -419,19 +446,22 @@ export const semanticColorDark = { shadow: ref('black'), - 'neutral-solid': ref('neutral-400'), - 'neutral-solid-hover': ref('neutral-300'), - 'neutral-on-solid': ref('neutral-950'), - 'neutral-subtle': ref('neutral-800'), - 'neutral-on-subtle': ref('neutral-200'), - 'neutral-border': ref('neutral-700'), - - 'primary-solid': ref('primary-400'), - 'primary-solid-hover': ref('primary-300'), - 'primary-on-solid': ref('neutral-950'), - 'primary-subtle': ref('primary-950'), - 'primary-on-subtle': ref('primary-300'), - 'primary-border': ref('primary-800'), + 'neutral-solid': ref('gray-269'), + 'neutral-solid-hover': ref('gray-371'), + 'neutral-on-solid': ref('gray-985'), + 'neutral-subtle': ref('gray-269'), + 'neutral-on-subtle': ref('gray-985'), + 'neutral-border': ref('gray-371'), + + // The inversion. A primary button in dark mode is near-white with near-black + // text, not a brighter version of a colour. Keeping this is most of what + // makes a dark shadcn interface recognisable. + 'primary-solid': ref('gray-922'), + 'primary-solid-hover': ref('gray-985'), + 'primary-on-solid': ref('gray-205'), + 'primary-subtle': ref('gray-269'), + 'primary-on-subtle': ref('gray-985'), + 'primary-border': ref('gray-371'), 'success-solid': ref('success-400'), 'success-solid-hover': ref('success-300'), @@ -447,9 +477,10 @@ export const semanticColorDark = { 'warning-on-subtle': ref('warning-300'), 'warning-border': ref('warning-800'), - 'danger-solid': ref('danger-400'), - 'danger-solid-hover': ref('danger-300'), - 'danger-on-solid': ref('neutral-950'), + // The same red as light mode, with a white label. See `red`. + 'danger-solid': ref('red-577'), + 'danger-solid-hover': ref('red-520'), + 'danger-on-solid': ref('white'), 'danger-subtle': ref('danger-950'), 'danger-on-subtle': ref('danger-300'), 'danger-border': ref('danger-800'), diff --git a/packages/tokens/src/contrast.test.ts b/packages/tokens/src/contrast.test.ts index b616fff..28aa0b3 100644 --- a/packages/tokens/src/contrast.test.ts +++ b/packages/tokens/src/contrast.test.ts @@ -105,7 +105,17 @@ describe('solid fills are distinguishable from the page behind them', () => { // A button whose label is legible but whose body blends into the page is // still broken. This is what ruled out mirroring light mode's 600 fill in // dark mode, where it only reached 3.6:1 against the background. - const families = ['neutral', 'primary', 'success', 'warning', 'danger'] as const + // + // `neutral` is deliberately absent. It now carries shadcn's `--secondary` — + // a near-white fill on a white page, 1.09:1 — and shadcn is right that this + // needs no fill contrast, because nothing interactive uses it: Tooltip moved + // to `primary-solid`, leaving Badge, which is static text. WCAG 1.4.11 governs + // the boundary of a *user interface component*; a badge is not one, and the + // contrast that carries its meaning is its label, asserted above. + // + // If a future component uses `neutral-solid` as an interactive fill, this + // exclusion stops being true — put it back and retune the token. + const families = ['primary', 'success', 'warning', 'danger'] as const it.each([ ['light', semanticColorLight], diff --git a/packages/tokens/src/index.ts b/packages/tokens/src/index.ts index 51d8e37..170b13e 100644 --- a/packages/tokens/src/index.ts +++ b/packages/tokens/src/index.ts @@ -26,6 +26,7 @@ import { gray, neutral, primary, + red, semanticColorDark, semanticColorLight, success, @@ -46,6 +47,7 @@ export { gray, neutral, primary, + red, semanticColorDark, semanticColorLight, success, @@ -86,6 +88,7 @@ export const tokens = { whiteAlpha, neutral, primary, + red, success, warning, danger, diff --git a/packages/ui/src/components/Tooltip/Tooltip.variants.ts b/packages/ui/src/components/Tooltip/Tooltip.variants.ts index f0c985e..b35a3fe 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.variants.ts +++ b/packages/ui/src/components/Tooltip/Tooltip.variants.ts @@ -7,10 +7,15 @@ import { cva, type VariantProps } from 'class-variance-authority' * * `max-w-xs` is a hard limit rather than a suggestion. A tooltip that wraps to * four lines is documentation, and documentation belongs in the page. + * + * The fill is `primary-solid`, not `neutral-solid`. shadcn's tooltip is + * `bg-primary` — near-black in light mode and near-white in dark — while its + * `secondary`, which `neutral-solid` now carries, is the quiet near-white chip. + * Left on `neutral-solid` the tooltip would render as pale grey on a pale page. */ export const tooltipContentVariants = cva([ 'z-tooltip max-w-xs rounded-sm px-2 py-1', - 'bg-neutral-solid text-xs text-neutral-on-solid shadow-md', + 'bg-primary-solid text-xs text-primary-on-solid shadow-md', 'motion-safe:data-[state=delayed-open]:animate-tooltip-in', 'motion-safe:data-[state=instant-open]:animate-tooltip-in', 'motion-safe:data-[state=closed]:animate-tooltip-out', From 90d8981646244582e3cd2282d477fd2ccfb3e7ac Mon Sep 17 00:00:00 2001 From: NikolaiKushner Date: Tue, 4 Aug 2026 12:17:44 +0400 Subject: [PATCH 05/19] feat(dialog): enhance overlay with backdrop blur and focus trap functionality This update introduces a blurred scrim for the dialog overlay, improving visual context by allowing the background to remain legible. The blur is conditionally applied based on support for `backdrop-filter`, ensuring a consistent experience across different environments. Additionally, keyboard navigation is enhanced with a focus trap that prevents users from tabbing to the content behind the dialog, ensuring accessibility and usability. A new `blur` token is added to the design system for this purpose. --- .changeset/blue-rings-shave.md | 14 +++++ docs/phases/restyle-shadcn.md | 30 ++++++++++- packages/tokens/src/blur.ts | 21 ++++++++ packages/tokens/src/css.ts | 4 ++ packages/tokens/src/index.ts | 5 ++ .../src/components/Dialog/Dialog.stories.ts | 53 +++++++++++++++++++ .../src/components/Dialog/Dialog.variants.ts | 14 ++++- packages/ui/src/styles/theme.test.ts | 5 ++ 8 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 .changeset/blue-rings-shave.md create mode 100644 packages/tokens/src/blur.ts diff --git a/.changeset/blue-rings-shave.md b/.changeset/blue-rings-shave.md new file mode 100644 index 0000000..f9ee8d4 --- /dev/null +++ b/.changeset/blue-rings-shave.md @@ -0,0 +1,14 @@ +--- +'@rowkit/tokens': minor +'rowkit': minor +--- + +Blur the dialog scrim, and assert the focus trap. + +The overlay gains a backdrop blur behind `supports-[backdrop-filter]:`, so the page reads as context rather than a flat grey field. The guard matters: `backdrop-filter` is missing or disabled in more places than its support table suggests, and unguarded it degrades to a plain scrim on some machines and not others. Everyone gets the scrim; the blur is the enhancement. + +New `blur` token scale in `@rowkit/tokens` — one entry, `--blur-overlay`, named for its job. The scrim also moves from a raw `neutral-950` primitive to the `shadow` semantic token. + +shadcn ships two overlays and they differ: its default is `bg-black/50` with no blur, while its named styles use `bg-black/80` with `backdrop-blur-xs`. rowkit takes the blur at the default's 50%, because blur plus 80% black is nearly opaque and defeats the reason to blur at all. + +Three keyboard behaviours are now tested rather than assumed: Tab cycles inside the dialog and cannot reach the page behind it, Shift+Tab holds in the same way, and one full lap of Tab returns focus where it started. A focus trap that stops trapping changes nothing about the rendered output — the dialog still looks modal while a keyboard user tabs out and operates the page underneath. diff --git a/docs/phases/restyle-shadcn.md b/docs/phases/restyle-shadcn.md index 54d9c0a..8c78d59 100644 --- a/docs/phases/restyle-shadcn.md +++ b/docs/phases/restyle-shadcn.md @@ -217,9 +217,37 @@ The `:root`/`.dark` variables are raw values; expose them to Tailwind utilities ### Dialog -- Overlay: `bg-black/50`. Panel: `bg-background rounded-lg border p-6 shadow-lg sm:max-w-lg gap-4`, title `text-lg font-semibold`, description `text-sm text-muted-foreground`. Close button top-right: ghost, `size-4` icon, `opacity-70 hover:opacity-100`. +- Panel: `bg-background rounded-lg border p-6 shadow-lg sm:max-w-lg gap-4`, title `text-lg font-semibold`, description `text-sm text-muted-foreground`. Close button top-right: ghost, `size-4` icon, `opacity-70 hover:opacity-100`. - Enter/leave: fade + slight zoom (`zoom-in-95` feel) — map to rowkit motion tokens; durations stay token-driven. +**Overlay — the scrim is blurred.** Two recipes exist upstream and they are not the same: + +| shadcn source | overlay | +| --------------------------------------------------- | ------------------------------------------------------- | +| `registry/new-york-v4/ui/dialog.tsx` (the default) | `bg-black/50`, **no blur** | +| `registry/styles/style-*.css` (maia, lyra, vega, …) | `bg-black/80 supports-backdrop-filter:backdrop-blur-xs` | + +rowkit takes the blurred one, at the default's 50% scrim rather than 80%: blur plus 80% black is nearly opaque, and the point of blurring is that the page behind stays legible as context. + +- The blur **must** be behind `supports-[backdrop-filter]:`. `backdrop-filter` is unsupported or disabled in enough places (older WebKit, some Linux/GPU configurations, forced-colors mode) that an unguarded blur silently degrades to a plain scrim on some machines and not others. The guard makes that a declared fallback instead of an accident. +- The blur radius is a token. There is no blur scale in `@rowkit/tokens` yet — add one; hard rule 1 has no exception for filters. +- `bg-black/50` is not literal black in rowkit: the scrim already references the shadow primitive. Keep it a token reference. + +### Dialog — keyboard, in full + +Reka UI supplies this behaviour; the work is asserting it, because a focus trap that quietly stops trapping is invisible until someone tabs into the page behind an open modal and starts operating it. + +Every one of these gets an interaction test: + +- **Tab cycles inside the dialog and never leaves it.** From the last focusable element, Tab returns to the first. +- **Shift+Tab cycles backwards**, and from the first element wraps to the last. +- **Focus enters the dialog on open** — asserted today by `Accessibility`. +- **Focus returns to the trigger on close** — asserted today by `EscapeRestoresFocus`. +- **Escape closes**, unless `preventClose`, in which case the close button still works and the dialog is not a trap — asserted today by `PreventCloseIsNotATrap`. +- **Background content is inert**: elements behind the scrim are not reachable by Tab. + +The first, second and last of these are missing and are the reason this section exists. + ### Toast - Card: `bg-popover text-popover-foreground rounded-lg border p-4 shadow-lg text-sm`, variant left-accent or icon per current design but colored via the semantic pairs (`destructive`, `success`, `warning`). Action button = small outline Button. Queue behavior untouched. diff --git a/packages/tokens/src/blur.ts b/packages/tokens/src/blur.ts new file mode 100644 index 0000000..001fea8 --- /dev/null +++ b/packages/tokens/src/blur.ts @@ -0,0 +1,21 @@ +/** + * Backdrop blur radii. + * + * One entry, and named for its job rather than a t-shirt size. Blur is not a + * scale rowkit designs with — it appears in exactly one place, behind a modal, + * and a second value would be a decision nobody has had to make yet. + * + * Deliberately small. The scrim separates the planes; the blur only stops the + * page behind from competing for the eye. Anything heavier reads as an effect + * and makes the content behind unrecognisable, which defeats the reason a + * modal shows its context at all. + * + * Design language based on shadcn/ui by shadcn, adapted for Vue. + */ +export const blur = { + /** The dialog scrim. shadcn's `backdrop-blur-xs`. */ + overlay: '4px', +} as const + +/** Names of every blur token. */ +export type BlurName = keyof typeof blur diff --git a/packages/tokens/src/css.ts b/packages/tokens/src/css.ts index 80c291a..67867bd 100644 --- a/packages/tokens/src/css.ts +++ b/packages/tokens/src/css.ts @@ -1,3 +1,4 @@ +import { blur } from './blur' import { colorPrimitives, semanticColorDark, semanticColorLight } from './color' import { duration, easing } from './motion' import { radiusBase, radiusCss } from './radius' @@ -49,6 +50,9 @@ export function buildThemeCss(): string { section('radii — multiples of --radius, declared in :root below'), ...entries(radiusCss, (k) => `--radius-${k}`), '', + section('blur'), + ...entries(blur, (k) => `--blur-${k}`), + '', section('shadows'), ...entries(shadow, (k) => `--shadow-${k}`), '', diff --git a/packages/tokens/src/index.ts b/packages/tokens/src/index.ts index 170b13e..000b1fc 100644 --- a/packages/tokens/src/index.ts +++ b/packages/tokens/src/index.ts @@ -20,6 +20,7 @@ */ import { version as pkgVersion } from '../package.json' with { type: 'json' } +import { blur } from './blur' import { colorPrimitives, danger, @@ -56,6 +57,9 @@ export { } from './color' export type { ColorRef, ColorStep, SemanticColorName } from './color' +export { blur } from './blur' +export type { BlurName } from './blur' + export { duration, easing } from './motion' export type { DurationName, EasingName } from './motion' @@ -111,6 +115,7 @@ export const tokens = { }, radius, radiusBase, + blur, shadow, zIndex, motion: { diff --git a/packages/ui/src/components/Dialog/Dialog.stories.ts b/packages/ui/src/components/Dialog/Dialog.stories.ts index 9678e18..be33888 100644 --- a/packages/ui/src/components/Dialog/Dialog.stories.ts +++ b/packages/ui/src/components/Dialog/Dialog.stories.ts @@ -222,6 +222,59 @@ export const EscapeRestoresFocus: Story = { }, } +/** + * Tab cycles inside the dialog and cannot reach the page behind it. + * + * A focus trap that stops trapping is invisible: the dialog still looks modal, + * and a keyboard user simply tabs out into content the scrim says is + * unavailable, then operates it. Nothing about the rendered output changes when + * this breaks, which is why it is asserted rather than assumed. + */ +export const TabIsTrapped: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const trigger = canvas.getByRole('button', { name: 'Open dialog' }) + await userEvent.click(trigger) + + const body = within(document.body) + const dialog = await body.findByRole('dialog') + + // Tab far enough to have escaped several times over if it could. + for (let i = 0; i < 12; i++) { + await userEvent.tab() + await expect(dialog.contains(document.activeElement)).toBe(true) + } + + // Backwards too — a trap that only holds in one direction is still broken. + for (let i = 0; i < 12; i++) { + await userEvent.tab({ shift: true }) + await expect(dialog.contains(document.activeElement)).toBe(true) + } + + // The trigger sits behind the scrim, so it must never take focus while open. + await expect(trigger).not.toHaveFocus() + }, +} + +/** Tab visits every control in the dialog, then wraps to the first. */ +export const TabCyclesThroughControls: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('button', { name: 'Open dialog' })) + + const body = within(document.body) + const dialog = await body.findByRole('dialog') + + const focusable = [...dialog.querySelectorAll('button, [href], input, select')] + await expect(focusable.length).toBeGreaterThan(1) + + // Walking one full lap must return focus to where the lap started. + const start = document.activeElement + for (let i = 0; i < focusable.length; i++) await userEvent.tab() + await expect(document.activeElement).toBe(start) + }, +} + /** With `preventClose`, Escape does nothing and the close button still works. */ export const PreventCloseIsNotATrap: Story = { args: { preventClose: true }, diff --git a/packages/ui/src/components/Dialog/Dialog.variants.ts b/packages/ui/src/components/Dialog/Dialog.variants.ts index 70e28bc..fa2ba01 100644 --- a/packages/ui/src/components/Dialog/Dialog.variants.ts +++ b/packages/ui/src/components/Dialog/Dialog.variants.ts @@ -3,9 +3,21 @@ import { cva, type VariantProps } from 'class-variance-authority' /** * The scrim. `z-overlay` sits below `z-modal` so the surface paints over its own * backdrop — asserted in the token package's stacking test. + * + * The blur is guarded by `supports-[backdrop-filter]`. `backdrop-filter` is + * missing or switched off in more places than its support table suggests — + * older WebKit, some Linux GPU configurations, forced-colors mode — and an + * unguarded blur degrades to a plain scrim on those machines silently. The + * guard turns that into a declared fallback: everyone gets the 50% scrim, and + * the blur is the enhancement on top. + * + * 50%, not the 80% shadcn pairs with its blur. Blur plus 80% black is very + * nearly opaque, and the reason to blur rather than simply darken is that the + * page behind should still read as context. */ export const dialogOverlayVariants = cva([ - 'fixed inset-0 z-overlay bg-neutral-950/50', + 'fixed inset-0 z-overlay bg-shadow/50', + 'supports-[backdrop-filter]:backdrop-blur-overlay', 'motion-safe:data-[state=open]:animate-overlay-in', 'motion-safe:data-[state=closed]:animate-overlay-out', ]) diff --git a/packages/ui/src/styles/theme.test.ts b/packages/ui/src/styles/theme.test.ts index c22cd03..e8fadad 100644 --- a/packages/ui/src/styles/theme.test.ts +++ b/packages/ui/src/styles/theme.test.ts @@ -60,6 +60,10 @@ const utilities: readonly (readonly [string, string])[] = [ ['text-danger-on-solid', '--color-danger-on-solid'], ['border-border-control', '--color-border-control'], ['ring-focus-ring', '--color-focus-ring'], + // The dialog scrim. Without a utility behind it the overlay renders fully + // transparent — the dialog still opens, and nothing looks wrong until you + // notice the page behind is not dimmed. + ['bg-shadow', '--color-shadow'], ['p-4', '--spacing-4'], ['gap-2', '--spacing-2'], ['text-sm', '--text-sm'], @@ -68,6 +72,7 @@ const utilities: readonly (readonly [string, string])[] = [ ['tracking-wide', '--tracking-wide'], ['leading-snug', '--leading-snug'], ['rounded-md', '--radius-md'], + ['backdrop-blur-overlay', '--blur-overlay'], ['z-modal', '--z-index-modal'], ['duration-fast', '--transition-duration-fast'], ['ease-standard', '--ease-standard'], From 9910cb4762cb1dc3909b987efc2d6cee404234df Mon Sep 17 00:00:00 2001 From: NikolaiKushner Date: Tue, 4 Aug 2026 12:37:05 +0400 Subject: [PATCH 06/19] feat(ui): adopt shadcn/ui design for Button, Badge, and Skeleton components This update implements shadcn/ui's focus ring and restyles the Button, Badge, and Skeleton components for consistency. The Button now features a `rounded-md` shape across all sizes, with updated padding and disabled states. The Badge adopts a `rounded-md` style with adjusted text sizes, while the Skeleton is unified under a single `rounded-md` shape. Additionally, the focus ring is refined to enhance accessibility, replacing previous outline styles with a more effective visual indicator. --- .changeset/great-poems-tickle.md | 15 ++++++++ .../ui/src/components/Badge/Badge.variants.ts | 7 ++-- .../src/components/Button/Button.variants.ts | 34 ++++++++++++++----- .../src/components/Skeleton/Skeleton.test.ts | 8 +++-- .../components/Skeleton/Skeleton.variants.ts | 6 ++-- packages/ui/src/styles/theme.test.ts | 28 +++++++++++++++ 6 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 .changeset/great-poems-tickle.md diff --git a/.changeset/great-poems-tickle.md b/.changeset/great-poems-tickle.md new file mode 100644 index 0000000..d1561ef --- /dev/null +++ b/.changeset/great-poems-tickle.md @@ -0,0 +1,15 @@ +--- +'rowkit': minor +--- + +Adopt shadcn/ui's focus ring, and restyle Button, Badge and Skeleton. + +The focus ring is the language's most recognisable detail: the border turns the ring colour **and** a 3px ring at 50% opacity appears outside it. Both halves are load-bearing — the ring is translucent and cannot carry 3:1 on its own, so the solid border is what satisfies WCAG 1.4.11 and the ring is what makes it read as focus rather than hover. This replaces the previous `outline-2 outline-offset-2` rather than joining it; two indicators on one element is noise. + +Button takes shadcn's geometry: `rounded-md` at every size, `lg` at `px-6`, and `text-sm` throughout — shadcn does not enlarge type on a larger button. Disabled is now `opacity-50` instead of swapping to disabled colour tokens. + +Badge is `rounded-md` with `text-xs` at both sizes. Skeleton is a single `rounded-md` shape at every geometry preset. + +Two places where the written spec and shadcn's source disagreed, resolved in favour of the source: the transition is `transition-all`, not `transition-[color,box-shadow]`, and `lg` is `px-6`, not `px-5`. + +The ring width comes from Tailwind's scale as `ring-3` rather than shadcn's arbitrary `ring-[3px]`, and the compile test asserts all three focus utilities resolve — a utility that generates nothing is this project's recurring failure mode. diff --git a/packages/ui/src/components/Badge/Badge.variants.ts b/packages/ui/src/components/Badge/Badge.variants.ts index a925598..124612a 100644 --- a/packages/ui/src/components/Badge/Badge.variants.ts +++ b/packages/ui/src/components/Badge/Badge.variants.ts @@ -26,9 +26,12 @@ export const badgeVariants = cva( solid: '', outline: 'bg-transparent', }, + // shadcn's badge is `rounded-md px-2 py-0.5 text-xs`. `sm` keeps a tighter + // inline size for badges that live inside a table cell, where `md`'s + // padding pushes the row height up. size: { - sm: 'rounded-xs px-1.5 py-0.5 text-xs', - md: 'rounded-sm px-2 py-0.5 text-sm', + sm: 'rounded-md px-1.5 py-0.5 text-xs', + md: 'rounded-md px-2 py-0.5 text-xs', }, }, compoundVariants: [ diff --git a/packages/ui/src/components/Button/Button.variants.ts b/packages/ui/src/components/Button/Button.variants.ts index 2deb7aa..b1ed179 100644 --- a/packages/ui/src/components/Button/Button.variants.ts +++ b/packages/ui/src/components/Button/Button.variants.ts @@ -2,17 +2,29 @@ import { cva, type VariantProps } from 'class-variance-authority' /** * Disabled styling is expressed with the `disabled:` variant rather than a - * separate branch, because `.disabled\:bg-surface-disabled:disabled` carries a + * separate branch, because `.disabled\:opacity-50:disabled` carries a * pseudo-class and therefore outranks the plain `bg-primary-solid` from the * variant — no ordering discipline required at the call site. + * + * ## The focus ring + * + * shadcn's recipe, and the single most recognisable detail in the language: + * the border turns the ring colour *and* a 3px ring at 50% opacity appears + * outside it. Both halves are load-bearing. The ring alone is translucent and + * would not carry 3:1 against the page; the solid border is what satisfies WCAG + * 1.4.11, and the ring is the glow that makes it read as focus rather than as a + * hover state. + * + * That is why this replaced `outline-2 outline-offset-2` rather than joining + * it: two indicators competing on the same element is noise, and the outline + * was the one carrying no brand information. */ export const buttonVariants = cva( [ - 'inline-flex shrink-0 items-center justify-center gap-2 border font-medium', - 'cursor-pointer transition-colors duration-fast ease-standard', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', - 'disabled:pointer-events-none disabled:border-transparent', - 'disabled:bg-surface-disabled disabled:text-text-disabled', + 'inline-flex shrink-0 items-center justify-center gap-2 border font-medium whitespace-nowrap', + 'cursor-pointer transition-all duration-fast ease-standard', + 'outline-none focus-visible:border-focus-ring focus-visible:ring-3 focus-visible:ring-focus-ring/50', + 'disabled:pointer-events-none disabled:opacity-50', // A button mid-request should not look clickable, but it must stay // focusable so a screen reader user is not thrown out of the form. 'aria-busy:pointer-events-none', @@ -28,10 +40,14 @@ export const buttonVariants = cva( danger: 'border-danger-solid bg-danger-solid text-danger-on-solid hover:border-danger-solid-hover hover:bg-danger-solid-hover', }, + // shadcn's heights and padding, and `rounded-md` at every size — its + // buttons do not change shape as they grow, only scale. `lg` keeps + // `text-sm`: shadcn has no larger type on a larger button, and bumping to + // `text-base` was rowkit's own invention. size: { - sm: 'h-8 rounded-sm px-3 text-sm', - md: 'h-9 rounded-md px-4 text-sm', - lg: 'h-10 rounded-md px-5 text-base', + sm: 'h-8 rounded-md px-3 text-sm', + md: 'h-9 rounded-md px-4 py-2 text-sm', + lg: 'h-10 rounded-md px-6 text-sm', }, /** Stretches the button to fill its container. */ block: { diff --git a/packages/ui/src/components/Skeleton/Skeleton.test.ts b/packages/ui/src/components/Skeleton/Skeleton.test.ts index fe674aa..afc333b 100644 --- a/packages/ui/src/components/Skeleton/Skeleton.test.ts +++ b/packages/ui/src/components/Skeleton/Skeleton.test.ts @@ -6,13 +6,15 @@ describe('Skeleton', () => { it('renders a text bar by default', () => { const el = mount(Skeleton) expect(el.classes()).toContain('bg-skeleton') - expect(el.classes()).toContain('rounded-xs') + expect(el.classes()).toContain('rounded-md') }) + // shadcn's Skeleton is one `rounded-md` shape. The presets keep their + // geometry, but no longer their own corner radii. it.each([ - ['text', 'rounded-xs'], + ['text', 'rounded-md'], ['circle', 'rounded-full'], - ['rect', 'rounded-sm'], + ['rect', 'rounded-md'], ] as const)('%s uses the %s radius token', (variant, expected) => { expect(mount(Skeleton, { props: { variant } }).classes()).toContain(expected) }) diff --git a/packages/ui/src/components/Skeleton/Skeleton.variants.ts b/packages/ui/src/components/Skeleton/Skeleton.variants.ts index 5d18d93..506318e 100644 --- a/packages/ui/src/components/Skeleton/Skeleton.variants.ts +++ b/packages/ui/src/components/Skeleton/Skeleton.variants.ts @@ -13,12 +13,14 @@ export const skeletonVariants = cva('block shrink-0 bg-skeleton', { variants: { /** Geometry preset. */ variant: { + // shadcn's Skeleton is a single `rounded-md` shape. rowkit keeps the + // geometry presets, but the corner is shadcn's at every one of them. /** A line of text. Height tracks the `sm`/`base` line box. */ - text: 'h-4 w-full rounded-xs', + text: 'h-4 w-full rounded-md', /** Avatars and icon buttons. */ circle: 'size-10 rounded-full', /** Thumbnails, cards, table cells. */ - rect: 'h-4 w-full rounded-sm', + rect: 'h-4 w-full rounded-md', }, /** * `motion-safe:` rather than a bare `animate-pulse`, so the pulse is absent diff --git a/packages/ui/src/styles/theme.test.ts b/packages/ui/src/styles/theme.test.ts index e8fadad..965adf9 100644 --- a/packages/ui/src/styles/theme.test.ts +++ b/packages/ui/src/styles/theme.test.ts @@ -88,6 +88,34 @@ describe('rowkit tokens compile to Tailwind utilities', () => { }) }) +describe('the focus ring compiles', () => { + /* + * shadcn writes the width as `ring-[3px]`, an arbitrary value. Tailwind v4 + * takes a bare number on `ring-*`, so `ring-3` is the same 3px through the + * scale instead of around it — but only if v4 really does generate it, and a + * utility that generates nothing is this project's recurring failure. + */ + it('generates a 3px ring from the scale, not an arbitrary value', async () => { + const css = await build('ring-3') + expect(css, 'ring-3 produced no rule — the arbitrary `ring-[3px]` would be needed').toContain( + '.ring-3 {' + ) + expect(css).toContain('3px') + }) + + it('tints the ring from the focus-ring token', async () => { + const css = await build('ring-focus-ring/50') + expect(css).toContain('var(--color-focus-ring)') + }) + + it('recolours the border to match, which is the half that carries 1.4.11', async () => { + // The ring is 50% opaque and cannot be relied on for contrast; the solid + // border is the indicator. If this utility stops resolving, focus still + // *looks* present in a screenshot and no longer meets the criterion. + expect(await build('border-focus-ring')).toContain('var(--color-focus-ring)') + }) +}) + describe('the radius scale resolves', () => { /* * Every radius is `calc(var(--radius) * f)`. Tailwind emits only the theme From 74dff95a3fbda6e422b9e514aa6dd3596278fc67 Mon Sep 17 00:00:00 2001 From: NikolaiKushner Date: Tue, 4 Aug 2026 12:50:46 +0400 Subject: [PATCH 07/19] feat(table): restyle DataTable components to align with shadcn/ui design This update implements a comprehensive restyling of the DataTable components, adopting shadcn/ui's design principles. Key changes include a transparent header with a hairline border, updated row hover effects for better pointer indication, and refined padding for a denser layout. The focus ring is now consistently applied across various components, enhancing accessibility. Additionally, selection checkboxes and other interactive elements have been adjusted to match shadcn's specifications, ensuring a cohesive user experience. --- .changeset/olive-donuts-repeat.md | 13 +++++ docs/phases/restyle-shadcn.md | 13 ++++- .../components/DataTable/DataTable.test.ts | 6 +-- .../DataTable/DataTable.variants.ts | 48 +++++++++++++------ .../src/components/Dialog/Dialog.variants.ts | 2 +- .../ui/src/components/Input/Input.variants.ts | 2 +- .../src/components/Select/Select.variants.ts | 2 +- .../TablePagination.variants.ts | 4 +- .../components/Toaster/Toaster.variants.ts | 4 +- packages/ui/src/styles/variants.test.ts | 28 +++++++++++ 10 files changed, 96 insertions(+), 26 deletions(-) create mode 100644 .changeset/olive-donuts-repeat.md diff --git a/.changeset/olive-donuts-repeat.md b/.changeset/olive-donuts-repeat.md new file mode 100644 index 0000000..180f469 --- /dev/null +++ b/.changeset/olive-donuts-repeat.md @@ -0,0 +1,13 @@ +--- +'rowkit': minor +--- + +Restyle the table family to shadcn/ui, and carry the focus ring across every component. + +The header loses its grey band: shadcn's is transparent with a hairline under it, and the column labels are full-strength foreground rather than muted. rowkit's header stays opaque — it can be sticky, and a transparent sticky header lets rows scroll through it — but takes the table's own surface instead of the recessed one. Row hover drops to half strength so it reads as a pointer follow rather than as selection, which is the full tint. Density tightens to shadcn's `px-2` and `p-2`, and the row separator moves from the faint hairline to the standard one, which the old grey header had been masking. + +Selection checkboxes take shadcn's recipe including `shadow-xs`. Their `rounded-xs` is exactly shadcn's `rounded-[4px]` — that is what the `xs` step at 0.4 × `--radius` exists for. + +The focus ring introduced for Button now covers Dialog, Input, Select, Toast, TablePagination and every focusable part of DataTable. + +**Borderless elements get a solid ring, not shadcn's translucent one.** The recipe is two halves — the border turns the ring colour, and a 3px ring at 50% sits outside it — and the border is the half that carries WCAG 1.4.11, because a 50% ring cannot reach 3:1 alone. On an element with no border, `focus-visible:border-focus-ring` colours a zero-width border and paints nothing, leaving a faint halo that still photographs like a focus ring. shadcn has this on its own ghost buttons; rowkit gives those five elements a fully opaque ring instead, and a new test fails any variant that asks to recolour a border it does not have. diff --git a/docs/phases/restyle-shadcn.md b/docs/phases/restyle-shadcn.md index 8c78d59..6a416bf 100644 --- a/docs/phases/restyle-shadcn.md +++ b/docs/phases/restyle-shadcn.md @@ -197,7 +197,18 @@ The `:root`/`.dark` variables are raw values; expose them to Tailwind utilities ### DataTable / Table -- shadcn's Table: rows `border-b`, `hover:bg-muted/50`, selected rows `data-[state=selected]:bg-muted`; cells `p-2 align-middle text-sm`; header cells `h-10 px-2 text-left font-medium text-muted-foreground`. +- shadcn's Table, from `registry/new-york-v4/ui/table.tsx` verbatim: + + ``` + row: border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted + head: h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground + cell: p-2 align-middle whitespace-nowrap + table: w-full caption-bottom text-sm header: [&_tr]:border-b + ``` + +- **Correction: header cells are `text-foreground`, not `text-muted-foreground`** as this document previously said, and they carry **no background fill** — shadcn's header is transparent with a hairline under it, not a recessed grey band. Both were wrong here and both are visible in any screenshot of the component. +- rowkit's header must still be opaque, because it can be sticky; it takes `bg-surface` (the table's own plane) rather than transparency, which is the smallest change that keeps sticky working. +- Padding tightens: `px-2` on heads and `p-2` on cells, against rowkit's `px-3`. shadcn's table is denser than rowkit's was. - Sticky header background: `bg-background` (opaque — the existing scroll-shadow affordance stays, restyled subtle). - Selection checkboxes: shadcn Checkbox recipe (`size-4 rounded-[4px] border shadow-xs`, checked `bg-primary text-primary-foreground border-primary`). - Sort buttons inside `th`: ghost-button treatment, `text-muted-foreground`, arrow icon `size-4`. diff --git a/packages/ui/src/components/DataTable/DataTable.test.ts b/packages/ui/src/components/DataTable/DataTable.test.ts index f822a57..606fb99 100644 --- a/packages/ui/src/components/DataTable/DataTable.test.ts +++ b/packages/ui/src/components/DataTable/DataTable.test.ts @@ -295,9 +295,9 @@ describe('DataTable', () => { }) it('does not highlight rows on hover unless they do something', () => { - expect(setup().find('tbody tr').classes()).not.toContain('hover:bg-surface-hover') + expect(setup().find('tbody tr').classes()).not.toContain('hover:bg-surface-hover/50') expect(setup({ hoverable: true }).find('tbody tr').classes()).toContain( - 'hover:bg-surface-hover' + 'hover:bg-surface-hover/50' ) }) @@ -503,7 +503,7 @@ describe('DataTable', () => { }) it('shows a hover affordance once rows respond to a click', () => { - expect(clickable().find('tbody tr').classes()).toContain('hover:bg-surface-hover') + expect(clickable().find('tbody tr').classes()).toContain('hover:bg-surface-hover/50') }) }) diff --git a/packages/ui/src/components/DataTable/DataTable.variants.ts b/packages/ui/src/components/DataTable/DataTable.variants.ts index f9372dd..a304a51 100644 --- a/packages/ui/src/components/DataTable/DataTable.variants.ts +++ b/packages/ui/src/components/DataTable/DataTable.variants.ts @@ -7,7 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority' export const dataTableWrapperVariants = cva([ 'relative w-full overflow-auto rounded-md border border-border bg-surface', // Focusable when it actually scrolls, so the ring has to be visible. - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', + 'outline-none focus-visible:border-focus-ring focus-visible:ring-3 focus-visible:ring-focus-ring/50', ]) export const dataTableVariants = cva('w-full border-collapse text-left', { @@ -39,19 +39,26 @@ export const dataTableCaptionVariants = cva('px-3 py-2 text-left text-text-muted * only have to be ordered against each other, which takes a plain offset rather * than another step on the token scale. */ -export const dataTableHeaderRowVariants = cva('relative z-sticky') +export const dataTableHeaderRowVariants = cva('relative z-sticky border-b border-border') /** * Body cells need no z-index of their own: a `sticky` cell is positioned, and a * positioned element already paints above its static siblings. + * + * `bg-surface`, not `bg-surface-subtle`, and `text-text` rather than muted. + * + * shadcn's header is transparent with a hairline beneath it — the column labels + * are full-strength foreground, not a recessed grey band with quiet text. The + * fill stays opaque here only because the header can be sticky, and a + * transparent sticky header lets the rows scroll through it. */ export const dataTableHeaderCellVariants = cva( - 'bg-surface-subtle font-medium whitespace-nowrap text-text-muted', + 'bg-surface align-middle font-medium whitespace-nowrap text-text', { variants: { size: { sm: 'h-8 px-2', - md: 'h-10 px-3', + md: 'h-10 px-2', }, align: { start: 'text-start', @@ -91,7 +98,7 @@ export const dataTableSortButtonVariants = cva( 'group inline-flex w-full cursor-pointer items-center gap-1', 'rounded-xs font-medium text-inherit', 'transition-colors duration-fast ease-standard hover:text-text', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', + 'outline-none focus-visible:ring-3 focus-visible:ring-focus-ring', ], { variants: { @@ -122,11 +129,14 @@ export const dataTableSortIconVariants = cva( } ) -export const dataTableCellVariants = cva('border-t border-border-subtle text-text', { +// `border-border`, not `border-border-subtle`: shadcn's row separator is its +// standard hairline, and the fainter one disappeared entirely once the header +// stopped being a grey band to anchor the grid. +export const dataTableCellVariants = cva('border-t border-border align-middle text-text', { variants: { size: { sm: 'h-8 px-2', - md: 'h-10 px-3', + md: 'h-10 p-2', }, align: { start: 'text-start', @@ -161,7 +171,9 @@ export const dataTableRowVariants = cva( * not. */ interactive: { - true: 'hover:bg-surface-hover', + // `/50` is shadcn's: the hover tint is half-strength so it reads as a + // pointer follow rather than as selection, which is the full tint. + true: 'hover:bg-surface-hover/50', false: '', }, /** Selected wins over hover — losing the highlight on hover hides the state. */ @@ -174,25 +186,31 @@ export const dataTableRowVariants = cva( } ) -export const dataTableSelectCellVariants = cva('w-px border-t border-border-subtle', { +// Matches the body cell: same hairline, same padding. shadcn drops the right +// padding on a checkbox cell so the control sits tight against its column. +export const dataTableSelectCellVariants = cva('w-px border-t border-border pr-0 align-middle', { variants: { size: { sm: 'h-8 px-2', - md: 'h-10 px-3', + md: 'h-10 p-2', }, }, defaultVariants: { size: 'md' }, }) +/* + * `rounded-xs` is shadcn's `rounded-[4px]` — the radius scale now lands exactly + * there, which is the whole reason `xs` exists at 0.4 × `--radius`. + */ export const dataTableCheckboxVariants = cva( [ - 'flex shrink-0 cursor-pointer items-center justify-center rounded-xs border', + 'flex shrink-0 cursor-pointer items-center justify-center rounded-xs border shadow-xs', 'border-border-control bg-surface text-primary-on-solid', - 'transition-colors duration-fast ease-standard', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', + 'transition-all duration-fast ease-standard', + 'outline-none focus-visible:border-focus-ring focus-visible:ring-3 focus-visible:ring-focus-ring/50', 'data-[state=checked]:border-primary-solid data-[state=checked]:bg-primary-solid', 'data-[state=indeterminate]:border-primary-solid data-[state=indeterminate]:bg-primary-solid', - 'disabled:cursor-not-allowed disabled:border-border disabled:bg-surface-disabled', + 'disabled:cursor-not-allowed disabled:opacity-50', ], { variants: { @@ -208,7 +226,7 @@ export const dataTableCheckboxVariants = cva( export const dataTableRadioVariants = cva( [ 'cursor-pointer accent-primary-solid', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', + 'outline-none focus-visible:ring-3 focus-visible:ring-focus-ring', ], { variants: { diff --git a/packages/ui/src/components/Dialog/Dialog.variants.ts b/packages/ui/src/components/Dialog/Dialog.variants.ts index fa2ba01..9caee11 100644 --- a/packages/ui/src/components/Dialog/Dialog.variants.ts +++ b/packages/ui/src/components/Dialog/Dialog.variants.ts @@ -67,7 +67,7 @@ export const dialogCloseVariants = cva([ 'items-center justify-center rounded-sm text-text-muted', 'transition-colors duration-fast ease-standard', 'hover:bg-surface-hover hover:text-text', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', + 'outline-none focus-visible:ring-3 focus-visible:ring-focus-ring', ]) export type DialogVariants = VariantProps diff --git a/packages/ui/src/components/Input/Input.variants.ts b/packages/ui/src/components/Input/Input.variants.ts index 112b5ba..a3b3ca9 100644 --- a/packages/ui/src/components/Input/Input.variants.ts +++ b/packages/ui/src/components/Input/Input.variants.ts @@ -11,7 +11,7 @@ export const inputVariants = cva( 'w-full border bg-surface text-text', 'transition-colors duration-fast ease-standard', 'placeholder:text-text-subtle', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', + 'outline-none focus-visible:border-focus-ring focus-visible:ring-3 focus-visible:ring-focus-ring/50', 'disabled:cursor-not-allowed disabled:bg-surface-disabled disabled:text-text-disabled', ], { diff --git a/packages/ui/src/components/Select/Select.variants.ts b/packages/ui/src/components/Select/Select.variants.ts index c2f50c9..985211f 100644 --- a/packages/ui/src/components/Select/Select.variants.ts +++ b/packages/ui/src/components/Select/Select.variants.ts @@ -5,7 +5,7 @@ export const selectTriggerVariants = cva( 'flex w-full items-center justify-between gap-2 border bg-surface text-left text-text', 'cursor-pointer transition-colors duration-fast ease-standard', 'hover:bg-surface-hover', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', + 'outline-none focus-visible:border-focus-ring focus-visible:ring-3 focus-visible:ring-focus-ring/50', 'disabled:cursor-not-allowed disabled:bg-surface-disabled disabled:text-text-disabled', 'disabled:hover:bg-surface-disabled', ], diff --git a/packages/ui/src/components/TablePagination/TablePagination.variants.ts b/packages/ui/src/components/TablePagination/TablePagination.variants.ts index 7a0002c..db8c32b 100644 --- a/packages/ui/src/components/TablePagination/TablePagination.variants.ts +++ b/packages/ui/src/components/TablePagination/TablePagination.variants.ts @@ -22,9 +22,9 @@ export const tablePaginationVariants = cva( */ export const tablePaginationItemVariants = cva( [ - 'inline-flex shrink-0 items-center justify-center rounded-sm border font-medium', + 'inline-flex shrink-0 items-center justify-center rounded-md border font-medium', 'cursor-pointer transition-colors duration-fast ease-standard', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', + 'outline-none focus-visible:border-focus-ring focus-visible:ring-3 focus-visible:ring-focus-ring/50', 'disabled:pointer-events-none disabled:border-transparent', 'disabled:bg-transparent disabled:text-text-disabled', ], diff --git a/packages/ui/src/components/Toaster/Toaster.variants.ts b/packages/ui/src/components/Toaster/Toaster.variants.ts index 3599e12..f85963a 100644 --- a/packages/ui/src/components/Toaster/Toaster.variants.ts +++ b/packages/ui/src/components/Toaster/Toaster.variants.ts @@ -49,13 +49,13 @@ export const toastMessageVariants = cva('min-w-0 flex-1 text-sm') export const toastActionVariants = cva([ 'shrink-0 cursor-pointer rounded-sm text-sm font-medium underline underline-offset-2', 'transition-opacity duration-fast ease-standard hover:opacity-80', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', + 'outline-none focus-visible:ring-3 focus-visible:ring-focus-ring', ]) export const toastCloseVariants = cva([ 'inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-xs', 'opacity-60 transition-opacity duration-fast ease-standard hover:opacity-100', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', + 'outline-none focus-visible:ring-3 focus-visible:ring-focus-ring', ]) export type ToasterVariants = VariantProps diff --git a/packages/ui/src/styles/variants.test.ts b/packages/ui/src/styles/variants.test.ts index 269b6a4..87be5d1 100644 --- a/packages/ui/src/styles/variants.test.ts +++ b/packages/ui/src/styles/variants.test.ts @@ -207,3 +207,31 @@ describe('component classes compile to real utilities', () => { expect(total).toBeGreaterThan(60) }) }) + +describe('the focus ring has something to draw', () => { + /* + * shadcn's recipe is two halves: the border turns the ring colour, and a 3px + * ring at 50% opacity appears outside it. The ring is translucent and cannot + * carry 3:1 on its own — the solid border is what satisfies WCAG 1.4.11. + * + * On an element with no border, `focus-visible:border-focus-ring` sets a + * colour on a zero-width border and paints nothing. Focus then shows as a + * faint translucent halo and the criterion is missed, while a screenshot + * still shows "a focus ring". Borderless elements take a solid ring instead. + */ + const TRANSLUCENT = 'focus-visible:ring-focus-ring/50' + const RECOLOURS_BORDER = 'focus-visible:border-focus-ring' + + it.each(components)('%s', (_name, variant) => { + const classes = classesOf(variant) + if (!classes.includes(TRANSLUCENT)) return + + expect(classes, 'a 50% ring is only legal alongside the border half of the recipe').toContain( + RECOLOURS_BORDER + ) + expect( + classes.some((c) => c === 'border' || /^border-[xytrbles]$/.test(c)), + 'recolours a border it does not have — use a solid ring instead' + ).toBe(true) + }) +}) From 4b4b436d4cbb03020ae4b8f00a6dfd57a4aef26b Mon Sep 17 00:00:00 2001 From: NikolaiKushner Date: Tue, 4 Aug 2026 13:54:19 +0400 Subject: [PATCH 08/19] feat(tokens): rename semantic tokens to align with shadcn/ui conventions This update renames core semantic tokens to match shadcn/ui's naming scheme, enhancing consistency across the design system. Key changes include renaming `--color-surface` to `--color-card`, `--color-text` to `--color-foreground`, and adjusting related utility classes accordingly. Additionally, the design language is further refined with updated color tokens for success and warning states, ensuring a cohesive visual experience. Documentation has been updated to reflect these changes, and the shadcn attribution is now included in all relevant READMEs. --- .changeset/lazy-pears-brake.md | 11 + .changeset/soft-moons-argue.md | 17 + .changeset/violet-donkeys-hammer.md | 26 + README.md | 2 + .../theme/components/ColorScale.vue | 6 +- docs/.vitepress/theme/components/DemoBox.vue | 2 +- .../.vitepress/theme/components/TokenGrid.vue | 4 +- docs/.vitepress/theme/tokens.css | 8 +- docs/components/button.md | 2 +- docs/components/dialog.md | 2 +- docs/components/filter-bar.md | 2 +- docs/components/table-pagination.md | 2 +- docs/foundations/tokens.md | 14 +- docs/index.md | 2 +- docs/installation.md | 2 +- docs/patterns/forms.md | 2 +- docs/patterns/loading-states.md | 2 +- docs/phases/phase-1-tokens.md | 20 +- docs/phases/restyle-shadcn.md | 53 +- packages/tokens/README.md | 8 +- packages/tokens/scripts/emit-reference.mjs | 22 +- packages/tokens/src/color.ts | 125 +++- packages/tokens/src/contrast.test.ts | 43 +- packages/tokens/src/index.ts | 6 + packages/ui/README.md | 2 + .../ui/src/components/Badge/Badge.stories.ts | 12 +- .../ui/src/components/Button/Button.test.ts | 2 +- .../src/components/Button/Button.variants.ts | 7 +- .../components/DataTable/DataTable.stories.ts | 4 +- .../components/DataTable/DataTable.test.ts | 10 +- .../DataTable/DataTable.variants.ts | 67 +- .../src/components/Dialog/Dialog.stories.ts | 4 +- .../src/components/Dialog/Dialog.variants.ts | 14 +- .../EmptyState/EmptyState.stories.ts | 30 +- .../components/EmptyState/EmptyState.test.ts | 4 +- .../EmptyState/EmptyState.variants.ts | 8 +- .../ui/src/components/Field/Field.variants.ts | 12 +- .../FilterBar/FilterBar.variants.ts | 20 +- .../ui/src/components/Input/Input.stories.ts | 2 +- .../ui/src/components/Input/Input.variants.ts | 27 +- packages/ui/src/components/Input/Input.vue | 4 +- .../src/components/Select/Select.stories.ts | 2 +- .../src/components/Select/Select.variants.ts | 26 +- packages/ui/src/components/Select/Select.vue | 6 +- .../components/Skeleton/Skeleton.stories.ts | 12 +- .../TablePagination.variants.ts | 10 +- .../src/components/Toaster/Toaster.stories.ts | 2 +- .../components/Toaster/Toaster.variants.ts | 8 +- .../src/components/Tooltip/Tooltip.stories.ts | 4 +- .../components/Tooltip/Tooltip.variants.ts | 7 +- packages/ui/src/styles/theme.test.ts | 22 +- packages/ui/src/styles/variants.test.ts | 6 +- packages/ui/src/utils/cn.test.ts | 4 +- packages/ui/src/utils/cn.ts | 2 +- playground/app/app.vue | 16 +- playground/app/pages/index.vue | 14 +- playground/app/pages/overlays.vue | 14 +- playground/app/pages/users.vue | 4 +- pnpm-lock.yaml | 678 ++---------------- 59 files changed, 527 insertions(+), 922 deletions(-) create mode 100644 .changeset/lazy-pears-brake.md create mode 100644 .changeset/soft-moons-argue.md create mode 100644 .changeset/violet-donkeys-hammer.md diff --git a/.changeset/lazy-pears-brake.md b/.changeset/lazy-pears-brake.md new file mode 100644 index 0000000..ecddee5 --- /dev/null +++ b/.changeset/lazy-pears-brake.md @@ -0,0 +1,11 @@ +--- +'rowkit': minor +--- + +Finish the form and filter surfaces. + +**Select** takes the Input treatment on its trigger — `bg-transparent`, `shadow-xs`, `disabled:opacity-50` — and drops the hover fill, which shadcn's trigger does not have. The popup moves from `shadow-lg` to `shadow-md`, and its items gain `rounded-sm` so the highlight is a rounded band rather than a full-bleed stripe. Invalid tints the focus ring as well as the border. + +**Field** hint and error text grow to `text-sm` at the default size, matching shadcn's, and the error takes the destructive fill colour rather than the badge-text one. A disabled label fades instead of switching to a disabled colour token. + +**FilterBar** chips are now shadcn `secondary` badges: neutral fill, `rounded-md`, `text-xs`, `font-medium`, with the remove control down to `size-3.5`/`size-4`. They were the last surface still using raw surface tokens for something that is conceptually a badge. diff --git a/.changeset/soft-moons-argue.md b/.changeset/soft-moons-argue.md new file mode 100644 index 0000000..c666171 --- /dev/null +++ b/.changeset/soft-moons-argue.md @@ -0,0 +1,17 @@ +--- +'@rowkit/tokens': minor +'rowkit': minor +--- + +Finish the visual pass: restrained success and warning, and shadcn's geometry everywhere. + +`success` and `warning` move to new `green` and `amber` primitive scales sitting at the same lightness band as `red`, so a success, a warning and a destructive control now carry the same perceptual weight and differ only in hue. Both take a white label. That is a real change for warning, which was bright amber with dark text — bright amber is the loudest thing on a shadcn page, and one saturated chip undoes a language built on restraint. shadcn has no success or warning to copy, so the rule here is internal consistency rather than fidelity. + +Geometry, from shadcn's source: + +- **Tooltip** — `rounded-md px-3 py-1.5 text-xs`, and the drop shadow is gone. A near-black bubble does not need one, and it was reading as a second edge on a light page. +- **Input** — `bg-transparent` so a field inside a card does not paint a second white rectangle, plus `shadow-xs`, `py-1`, muted placeholder, `disabled:opacity-50`, and `rounded-md` at every size. Invalid now tints the focus ring as well as the border, so the state survives being focused. +- **Dialog** — `shadow-lg` rather than `shadow-xl`, title `leading-none`, close button at `opacity-70` rising to full on hover. +- **Toast** — `rounded-lg` with `text-sm`. +- **EmptyState** — the icon moves from subtle to muted, matching shadcn's `text-muted-foreground`. +- **TablePagination** — controls grow to `h-9`, matching Button's default height. diff --git a/.changeset/violet-donkeys-hammer.md b/.changeset/violet-donkeys-hammer.md new file mode 100644 index 0000000..1a2f565 --- /dev/null +++ b/.changeset/violet-donkeys-hammer.md @@ -0,0 +1,26 @@ +--- +'@rowkit/tokens': minor +'rowkit': minor +--- + +Rename the core semantic tokens to shadcn/ui's names. + +**Breaking for anyone who overrides tokens or writes rowkit utility classes directly.** Components are unaffected — no prop, slot or event changes. + +| before | after | +| ------------------------ | -------------------------- | +| `--color-surface` | `--color-card` | +| `--color-surface-subtle` | `--color-muted` | +| `--color-surface-hover` | `--color-accent` | +| `--color-text` | `--color-foreground` | +| `--color-text-muted` | `--color-muted-foreground` | +| `--color-border-control` | `--color-input` | +| `--color-focus-ring` | `--color-ring` | + +Utility classes follow: `bg-surface` → `bg-card`, `text-text-muted` → `text-muted-foreground`, `border-border-control` → `border-input`, `ring-focus-ring` → `ring-ring`. + +These seven map one-to-one onto shadcn's, which is the point: if you have themed shadcn/ui, you already know how to theme rowkit. + +**Renamed, and no further.** `surface-active`, `surface-selected`, `surface-disabled`, `skeleton`, `text-subtle`, `text-disabled`, `border-strong` and `border-subtle` keep their names because shadcn has no equivalent, and collapsing them all into `muted-foreground` would delete real states — a pressed row and a selected row would become one token. The status families (`primary-solid`, `danger-subtle`, `warning-on-solid`, …) keep theirs because shadcn's flat `--primary` carries no solid/subtle/outline axis, and Badge and Button expose exactly that axis as a prop; renaming them would mean redesigning those APIs. + +Also in this release: the shadcn attribution line is now in all three READMEs, and the bundle budget is re-verified at 12.29 kB against a 14 kB limit — the restyle moved it by class churn only. diff --git a/README.md b/README.md index 78b9d67..845eef3 100644 --- a/README.md +++ b/README.md @@ -117,3 +117,5 @@ pnpm docs:dev # documentation site ## License MIT © Nikolai Kushner + +Design language based on [shadcn/ui](https://ui.shadcn.com) by shadcn, adapted for Vue. shadcn/ui is MIT licensed; rowkit adopts its token values and class recipes, not its code. diff --git a/docs/.vitepress/theme/components/ColorScale.vue b/docs/.vitepress/theme/components/ColorScale.vue index dfb8083..bdbaa27 100644 --- a/docs/.vitepress/theme/components/ColorScale.vue +++ b/docs/.vitepress/theme/components/ColorScale.vue @@ -20,13 +20,13 @@ const { copied, copy } = useCopyToken() -

+

{{ selected.length }} selected · {{ sort ? `sorted by ${sort.key}, ${sort.direction}` : 'unsorted' }}

diff --git a/docs/installation.md b/docs/installation.md index 0fcafdb..4ea1f12 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -35,7 +35,7 @@ document.documentElement.classList.toggle('dark', isDark) ``` Only semantic tokens change under `.dark`; the colour primitives stay fixed. A -component never knows which theme is active — it reads `--color-surface` and the +component never knows which theme is active — it reads `--color-card` and the answer differs. ## Nuxt diff --git a/docs/patterns/forms.md b/docs/patterns/forms.md index 3a14a7c..de19b10 100644 --- a/docs/patterns/forms.md +++ b/docs/patterns/forms.md @@ -61,7 +61,7 @@ function submit() {
- Invitation sent. + Invitation sent.
diff --git a/docs/patterns/loading-states.md b/docs/patterns/loading-states.md index 65a18bb..18dab7b 100644 --- a/docs/patterns/loading-states.md +++ b/docs/patterns/loading-states.md @@ -62,7 +62,7 @@ onUnmounted(() => { -

{{ pending ? (showSkeleton ? 'loading — placeholder shown' : 'loading — under the delay, nothing shown') : 'idle' }}

+

{{ pending ? (showSkeleton ? 'loading — placeholder shown' : 'loading — under the delay, nothing shown') : 'idle' }}

Press **Fast response** and watch nothing happen: the request finishes before the diff --git a/docs/phases/phase-1-tokens.md b/docs/phases/phase-1-tokens.md index 65aa2dd..bd14fd9 100644 --- a/docs/phases/phase-1-tokens.md +++ b/docs/phases/phase-1-tokens.md @@ -66,19 +66,19 @@ One source generates both; they cannot drift. ### The scales -| Scale | Shape | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Color, primitive** | 11 steps × 5 ramps: `neutral`, `primary`, `success`, `warning`, `danger`. Eleven because data-dense dark UIs need distinct values for surface/hover/border that 9-step ramps collapse | -| **Color, semantic** | Surfaces `--color-surface` / `-subtle` / `-hover` / `-active` / `-selected` / `-disabled` (no `-raised`; the shipped family is state-based), text `--color-text` / `-muted` / `-subtle` / `-disabled`, borders `--color-border` / `-strong` / `-subtle` / `-control`, plus `--color-focus-ring`, `--color-shadow` and `--color-skeleton`. Each tone ships six: `-solid`, `-solid-hover`, `-on-solid`, `-subtle`, `-on-subtle`, `-border` — the `-on-*` pairs are what make contrast assertable as a build gate. **Semantic references primitive; nothing references a raw hex** — this is hard rule 1's enforcement point | -| **Spacing** | 4px base progression | -| **Typography** | sizes with _paired_ line-heights — never free-floating | -| **Radii / shadows** | small closed sets. Shadows mix from `--color-shadow`, a semantic token, so dark mode repoints one variable. Dark mode then leans on **surface lightness** for elevation rather than on tuned shadows — on a near-black page there is very little headroom left to darken | -| **Z-index** | named layers, emitted under Tailwind v4's `--z-index-*` namespace (not `--z-*`, which generates no utilities and no error). Ordered `base < sticky < dropdown < overlay < modal < popover < toast < tooltip`, spaced by 100. **Sticky sits below dropdown**, not above: a menu opened from a toolbar has to paint over a sticky table header. Popover above modal (a `Select` inside a `Dialog`) and toast above modal are both deliberate — Phase 4 depends on this ordering | -| **Motion** | durations + easings; overlays consume these in Phase 4. Short: ~150ms in, ~100ms out neighborhood | +| Scale | Shape | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Color, primitive** | 11 steps × 5 ramps: `neutral`, `primary`, `success`, `warning`, `danger`. Eleven because data-dense dark UIs need distinct values for surface/hover/border that 9-step ramps collapse | +| **Color, semantic** | Surfaces `--color-card` / `-subtle` / `-hover` / `-active` / `-selected` / `-disabled` (no `-raised`; the shipped family is state-based), text `--color-foreground` / `-muted` / `-subtle` / `-disabled`, borders `--color-border` / `-strong` / `-subtle` / `-control`, plus `--color-ring`, `--color-shadow` and `--color-skeleton`. Each tone ships six: `-solid`, `-solid-hover`, `-on-solid`, `-subtle`, `-on-subtle`, `-border` — the `-on-*` pairs are what make contrast assertable as a build gate. **Semantic references primitive; nothing references a raw hex** — this is hard rule 1's enforcement point | +| **Spacing** | 4px base progression | +| **Typography** | sizes with _paired_ line-heights — never free-floating | +| **Radii / shadows** | small closed sets. Shadows mix from `--color-shadow`, a semantic token, so dark mode repoints one variable. Dark mode then leans on **surface lightness** for elevation rather than on tuned shadows — on a near-black page there is very little headroom left to darken | +| **Z-index** | named layers, emitted under Tailwind v4's `--z-index-*` namespace (not `--z-*`, which generates no utilities and no error). Ordered `base < sticky < dropdown < overlay < modal < popover < toast < tooltip`, spaced by 100. **Sticky sits below dropdown**, not above: a menu opened from a toolbar has to paint over a sticky table header. Popover above modal (a `Select` inside a `Dialog`) and toast above modal are both deliberate — Phase 4 depends on this ordering | +| **Motion** | durations + easings; overlays consume these in Phase 4. Short: ~150ms in, ~100ms out neighborhood | ### Dark mode -Overrides on `.dark` touch **semantic tokens only** — primitives never change. A component therefore never knows which theme is active; it reads `--color-surface` and the answer differs. This one constraint is what makes theme presets (future roadmap) nearly free later. +Overrides on `.dark` touch **semantic tokens only** — primitives never change. A component therefore never knows which theme is active; it reads `--color-card` and the answer differs. This one constraint is what makes theme presets (future roadmap) nearly free later. ### Division of labor diff --git a/docs/phases/restyle-shadcn.md b/docs/phases/restyle-shadcn.md index 6a416bf..b0e387c 100644 --- a/docs/phases/restyle-shadcn.md +++ b/docs/phases/restyle-shadcn.md @@ -20,23 +20,24 @@ shadcn's theme model and rowkit's current token model are **structurally differe - This changes `@rowkit/tokens`' public surface → it's the reason this is 0.2.0, not 0.1.x. - Existing rowkit semantic names map as follows: -| rowkit 0.1 token | becomes (shadcn convention) | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--color-background` | `--background` (the page) | -| `--color-surface` | `--card` — rowkit's `surface` is the raised surface, not the page | -| `--color-surface-subtle` | `--muted` (table headers, toolbars) | -| `--color-surface-hover` | `--accent` — shadcn has no hover token; `accent` is what its rows and items hover to | -| `--color-skeleton` | `--accent` (shadcn's Skeleton is `bg-accent`) | -| `--color-border` | `--border` | -| `--color-border-control` | `--input` — **this** is rowkit's form-control boundary, not `border-strong` | -| `--color-border-strong` | no shadcn equivalent; audit each usage and collapse into `--border` | -| `--color-focus-ring` | `--ring` | -| `--color-text` | `--foreground` | -| `--color-text-muted` | `--muted-foreground` | -| `--color-text-subtle` | `--muted-foreground` (shadcn has one muted level; collapse) | -| `--color-primary-*` | `--primary` (+ `--primary-foreground`) | -| `--color-danger-*` | `--destructive` (shadcn's naming; keep `danger` as a documented alias in the Badge/Toast variant API — **do not rename component props**, only tokens) | -| success / warning tones | **not in shadcn's default set** — add per shadcn's own "Adding New Tokens" recipe (below), styled to match | +| rowkit 0.1 token | rowkit 0.2 token | note | +| ------------------------ | -------------------------- | -------------------------------------------------------- | +| `--color-background` | `--color-background` | unchanged — already shadcn's name | +| `--color-surface` | `--color-card` | rowkit's `surface` is the raised plane, not the page | +| `--color-surface-subtle` | `--color-muted` | table headers, toolbars | +| `--color-surface-hover` | `--color-accent` | shadcn has no hover token; `accent` is what its rows use | +| `--color-text` | `--color-foreground` | | +| `--color-text-muted` | `--color-muted-foreground` | | +| `--color-border-control` | `--color-input` | the form-control boundary — **not** `border-strong` | +| `--color-focus-ring` | `--color-ring` | | +| `--color-border` | `--color-border` | unchanged | + +**Renamed, and no further.** The tokens above map one-to-one onto shadcn's, so anyone who has themed shadcn already knows how to theme rowkit — which was the whole strategic point of Part 0. + +The rest keep rowkit's names on purpose: + +- `surface-active`, `surface-selected`, `surface-disabled`, `skeleton`, `text-subtle`, `text-disabled`, `border-strong`, `border-subtle` have **no shadcn equivalent**. Collapsing them into `--muted-foreground` as this document once proposed would delete real states — a pressed row and a selected row would become the same token, and a disabled label the same as a placeholder. +- The status families (`primary-solid`, `danger-subtle`, `warning-on-solid`, …) stay. shadcn's flat `--primary` / `--destructive` carries no `solid`/`subtle`/`outline` axis, and Badge and Button expose exactly that axis as a prop. Renaming them would mean redesigning those APIs, which Part 0 forbids two lines further down. **Component prop APIs do not change.** `variant="danger"` stays `danger`. This is a restyle, not an API break. @@ -207,7 +208,7 @@ The `:root`/`.dark` variables are raw values; expose them to Tailwind utilities ``` - **Correction: header cells are `text-foreground`, not `text-muted-foreground`** as this document previously said, and they carry **no background fill** — shadcn's header is transparent with a hairline under it, not a recessed grey band. Both were wrong here and both are visible in any screenshot of the component. -- rowkit's header must still be opaque, because it can be sticky; it takes `bg-surface` (the table's own plane) rather than transparency, which is the smallest change that keeps sticky working. +- rowkit's header must still be opaque, because it can be sticky; it takes `bg-card` (the table's own plane) rather than transparency, which is the smallest change that keeps sticky working. - Padding tightens: `px-2` on heads and `p-2` on cells, against rowkit's `px-3`. shadcn's table is denser than rowkit's was. - Sticky header background: `bg-background` (opaque — the existing scroll-shadow affordance stays, restyled subtle). - Selection checkboxes: shadcn Checkbox recipe (`size-4 rounded-[4px] border shadow-xs`, checked `bg-primary text-primary-foreground border-primary`). @@ -284,9 +285,21 @@ Work on branch `feat/shadcn-restyle`. One PR per group, standard DoD applies min | R3 | Field/Input, Select | Same + a11y suite still green (focus recipe changed — re-verify visible focus in both modes) | | R4 | Table family: DataTable, TablePagination, EmptyState, FilterBar | Users-admin playground page reads as a shadcn dashboard | | R5 | Overlays: Dialog, Toast, Tooltip | Stacking scene re-verified; reduced-motion stories pass | -| R6 | Sweep: docs site theme vars remapped to new tokens; new screenshots; changeset; bundle budget check | Chromatic diff reviewed — every change intentional | +| R6 | Sweep: docs site theme vars remapped to new tokens; new screenshots; changeset; bundle budget check | Storybook reviewed by eye in both themes; no visual-regression service — see below | + +**Visual regression: decided against.** Chromatic was wired up and then removed. It is a paid third-party service with an ongoing cost, and its first act on this repository would have been to demand a diff review across every story at once — the restyle rewrote nearly all of them, so the first build carries no signal and the value only begins afterwards. + +The gate is struck rather than left aspirational, because a checklist item nobody can run is worse than an absent one: it reads as covered. + +**What this costs, stated plainly.** Nothing checks that a visual change was intended. The suite still catches a great deal — `theme.test.ts` proves every utility resolves to a token, `variants.test.ts` proves every class compiles, `contrast.test.ts` proves every colour pairing meets AA, and the axe scan proves each story renders without a violation in both themes. None of that notices a button that is forty pixels too wide. + +**If it comes back**, three things have to be true or it is worse than nothing: + +- `fetch-depth: 0` on checkout. Chromatic finds its baseline by walking git history; under the default shallow clone there is no ancestor, so every commit becomes a new baseline and **every visual change passes silently**. +- `exitZeroOnChanges: true`. A visual diff is a review decision, not a test failure. +- **TurboSnap off.** It picks which stories to re-shoot from the Vite module graph, and a design-token change is precisely the case where "this story's files did not change" is both true and completely wrong. -**Visual regression:** Chromatic is **not set up in this repo** — there is no account, token, or workflow, so an R6 gate reading "Chromatic diff reviewed" would pass by never running. Either wire it up as its own task before R1 (it is a real setup job, not a checkbox), or run the comparison with what exists: Storybook builds on every PR, and the a11y suite already drives Playwright, so a screenshot pass over the story list is a day's work against a third-party service and an ongoing cost. Decide before R1, and if the answer is "no Chromatic", strike the gate rather than leaving it aspirational. +The cheap substitute, if one is wanted later: the a11y suite already drives Playwright, so a screenshot pass over the story list is a local afternoon rather than a subscription. **Verification protocol (the "double-check" requested):** diff --git a/packages/tokens/README.md b/packages/tokens/README.md index 79e2318..d54d0ff 100644 --- a/packages/tokens/README.md +++ b/packages/tokens/README.md @@ -17,7 +17,7 @@ npm i @rowkit/tokens ## Two layers -**Primitives** are the raw ramps: `--color-primary-600` is one specific blue and means nothing on its own. **Semantic** tokens name a role — `--color-surface`, `--color-text-muted`, `--color-border` — and point at a primitive through `var()`. +**Primitives** are the raw ramps: `--color-primary-600` is one specific blue and means nothing on its own. **Semantic** tokens name a role — `--color-card`, `--color-muted-foreground`, `--color-border` — and point at a primitive through `var()`. Only the semantic layer changes under `.dark`, which is what makes dark mode a matter of repointing references rather than hunting hex codes. @@ -30,13 +30,13 @@ As a Tailwind v4 theme: @import '@rowkit/tokens/css'; ``` -Every token becomes a theme value, so `bg-surface`, `text-text-muted`, `p-4`, `rounded-md` and `shadow-lg` resolve to the scales above. +Every token becomes a theme value, so `bg-card`, `text-muted-foreground`, `p-4`, `rounded-md` and `shadow-lg` resolve to the scales above. As CSS custom properties, for anything Tailwind does not cover: ```css .my-thing { - background: var(--color-surface-subtle); + background: var(--color-muted); border-radius: var(--radius-md); } ``` @@ -66,3 +66,5 @@ const series = [tokens.color.primary[500], tokens.color.success[500]] ## License MIT © Nikolai Kushner + +Design language based on [shadcn/ui](https://ui.shadcn.com) by shadcn, adapted for Vue. shadcn/ui is MIT licensed; rowkit adopts its token values and class recipes, not its code. diff --git a/packages/tokens/scripts/emit-reference.mjs b/packages/tokens/scripts/emit-reference.mjs index b603e42..cfa057b 100644 --- a/packages/tokens/scripts/emit-reference.mjs +++ b/packages/tokens/scripts/emit-reference.mjs @@ -109,33 +109,33 @@ ${darkVars} font-family: var(--font-sans); font-size: var(--text-sm, 0.875rem); background: var(--color-background); - color: var(--color-text); + color: var(--color-foreground); transition: background var(--transition-duration-normal) var(--ease-standard); } header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--spacing-8); } h1 { font-size: 1.5rem; font-weight: var(--font-weight-bold); margin: 0; } h2 { font-size: 1.125rem; font-weight: var(--font-weight-semibold); margin: var(--spacing-12) 0 var(--spacing-4); padding-bottom: var(--spacing-2); border-bottom: 1px solid var(--color-border); } - h3 { font-size: 0.875rem; font-weight: var(--font-weight-medium); color: var(--color-text-muted); margin: var(--spacing-6) 0 var(--spacing-3); text-transform: uppercase; letter-spacing: var(--tracking-wide); } + h3 { font-size: 0.875rem; font-weight: var(--font-weight-medium); color: var(--color-muted-foreground); margin: var(--spacing-6) 0 var(--spacing-3); text-transform: uppercase; letter-spacing: var(--tracking-wide); } code { font-family: var(--font-mono); font-size: 0.75rem; } button { font: inherit; font-weight: var(--font-weight-medium); cursor: pointer; padding: var(--spacing-2) var(--spacing-4); border-radius: var(--radius-sm); - border: 1px solid var(--color-border-control); - background: var(--color-surface); color: var(--color-text); + border: 1px solid var(--color-input); + background: var(--color-card); color: var(--color-foreground); } - button:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 2px; } + button:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; } .scale { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--spacing-2); } .swatch { display: flex; flex-direction: column; gap: var(--spacing-1); } .chip { height: 56px; border-radius: var(--radius-sm); border: 1px solid var(--color-border); } .step { font-weight: var(--font-weight-medium); } - .val, .ref { color: var(--color-text-muted); } + .val, .ref { color: var(--color-muted-foreground); } table { width: 100%; border-collapse: collapse; } - th { text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--color-text-muted); font-weight: var(--font-weight-medium); padding: var(--spacing-2); border-bottom: 1px solid var(--color-border); } + th { text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--color-muted-foreground); font-weight: var(--font-weight-medium); padding: var(--spacing-2); border-bottom: 1px solid var(--color-border); } td { padding: var(--spacing-2); border-bottom: 1px solid var(--color-border-subtle); vertical-align: middle; } - tbody tr:hover { background: var(--color-surface-hover); } + tbody tr:hover { background: var(--color-accent); } .dot { display: inline-block; width: 28px; height: 20px; border-radius: var(--radius-xs); border: 1px solid var(--color-border); } - .demo-box { background: var(--color-surface); border: 1px solid var(--color-border); } + .demo-box { background: var(--color-card); border: 1px solid var(--color-border); } .status-row { display: flex; flex-wrap: wrap; gap: var(--spacing-3); } .solid { padding: var(--spacing-2) var(--spacing-4); border-radius: var(--radius-sm); font-weight: var(--font-weight-medium); } .subtle { padding: var(--spacing-1) var(--spacing-3); border-radius: var(--radius-full); font-size: 0.75rem; font-weight: var(--font-weight-medium); border: 1px solid; } @@ -145,7 +145,7 @@ ${darkVars}

rowkit design tokens

-

+

Generated from @rowkit/tokens. Every value below comes from the same source the components consume.

@@ -172,7 +172,7 @@ ${darkVars}

Semantic colours

-

+

Semantic tokens never hold a literal colour — each points at a primitive, which is what lets the theme flip without any value being redefined.

diff --git a/packages/tokens/src/color.ts b/packages/tokens/src/color.ts index c90a8f2..a55fc94 100644 --- a/packages/tokens/src/color.ts +++ b/packages/tokens/src/color.ts @@ -193,6 +193,51 @@ export const red = { 520: 'oklch(0.52 0.212 27.325)', } as const +/** + * Success and warning, at shadcn's weight. Keyed by lightness, like `gray`. + * + * shadcn has no equivalent to copy, so the rule is consistency rather than + * fidelity: the solid step sits at the same lightness band as `red-577` and + * carries a white label, so a success, a warning and a destructive button are + * the same perceptual weight and only differ in hue. + * + * That is a real change for warning, which used to be bright amber with dark + * text. Bright amber is the loudest thing on a shadcn page — the language is + * built on restraint, and one saturated chip undoes it. Chroma is clamped to + * the sRGB boundary at every step, as everywhere else. + */ +export const green = { + /** Badge fill, light. */ + 950: 'oklch(0.95 0.05 152)', + /** Badge border, light. */ + 880: 'oklch(0.88 0.05 152)', + /** Badge text, dark. */ + 850: 'oklch(0.85 0.12 152)', + /** Solid fill, both themes. White label at 4.56:1. */ + 550: 'oklch(0.55 0.144 152)', + /** Solid hover — darkens, so the white label improves. */ + 520: 'oklch(0.52 0.136 152)', + /** Badge text, light. */ + 400: 'oklch(0.4 0.105 152)', + /** Badge border, dark. */ + 350: 'oklch(0.35 0.092 152)', + /** Badge fill, dark. */ + 260: 'oklch(0.26 0.068 152)', +} as const + +/** Warning, mirroring {@link green} step for step. */ +export const amber = { + 950: 'oklch(0.95 0.04 75)', + 880: 'oklch(0.88 0.05 75)', + 850: 'oklch(0.85 0.12 75)', + /** Solid fill, both themes. White label at 4.96:1. */ + 550: 'oklch(0.55 0.116 75)', + 520: 'oklch(0.52 0.109 75)', + 400: 'oklch(0.4 0.084 75)', + 350: 'oklch(0.35 0.074 75)', + 260: 'oklch(0.26 0.055 75)', +} as const + /** * White at a fraction of opacity, for dark-mode borders. * @@ -217,6 +262,8 @@ export const colorPrimitives = { black: 'oklch(0 0 0)', ...prefixKeys('gray', gray), ...prefixKeys('red', red), + ...prefixKeys('green', green), + ...prefixKeys('amber', amber), ...prefixKeys('white-alpha', whiteAlpha), ...prefix('neutral', neutral), ...prefix('primary', primary), @@ -265,7 +312,7 @@ export const semanticColorLight = { /** Page background, behind all surfaces. shadcn `--background`. */ background: ref('white'), /** Cards, panels, table bodies — the plane content sits on. shadcn `--card`. */ - surface: ref('white'), + card: ref('white'), /** * Table headers, toolbars: a surface that recedes slightly. shadcn `--muted`. * @@ -273,9 +320,9 @@ export const semanticColorLight = { * value, and the distinction survives here because the two are separate * override points, not because they differ out of the box. */ - 'surface-subtle': ref('gray-970'), + muted: ref('gray-970'), /** Row hover. shadcn `--accent`. */ - 'surface-hover': ref('gray-970'), + accent: ref('gray-970'), /** Row press / active. One step past hover; shadcn has no press token. */ 'surface-active': ref('gray-922'), /** @@ -301,7 +348,7 @@ export const semanticColorLight = { skeleton: ref('gray-970'), /** Primary body and heading text. shadcn `--foreground`. */ - text: ref('gray-145'), + foreground: ref('gray-145'), /** * Secondary text, column labels, help text. shadcn `--muted-foreground`. * @@ -310,7 +357,7 @@ export const semanticColorLight = { * reaches 4.73:1 on white but only 4.34:1 on the recessed surface this token * is most often used against. Nine thousandths of lightness buy the pass. */ - 'text-muted': ref('gray-535'), + 'muted-foreground': ref('gray-535'), /** Placeholders and de-emphasised metadata. */ 'text-subtle': ref('gray-635'), /** Text on a disabled control. */ @@ -320,7 +367,7 @@ export const semanticColorLight = { * Decorative hairline: row separators, card outlines. * * Deliberately below 3:1 against the surface. Do not use it for the boundary - * of an interactive control — see {@link semanticColorLight['border-control']}. + * of an interactive control — see {@link semanticColorLight['input']}. */ border: ref('gray-922'), /** Emphasised decorative border: dividers that need to read as structure. */ @@ -342,7 +389,7 @@ export const semanticColorLight = { * controls: `border` keeps shadcn's value exactly, so the hairlines between * table rows and around cards are pixel-identical to shadcn. */ - 'border-control': ref('gray-635'), + input: ref('gray-635'), /** * Focus ring. Never remove the ring — recolour it. shadcn `--ring`. * @@ -350,7 +397,7 @@ export const semanticColorLight = { * the absence of chroma. shadcn's own 0.708 is 2.59:1 against the page and * fails 1.4.11, so this is the darkened step. */ - 'focus-ring': ref('gray-635'), + ring: ref('gray-635'), /** Base colour shadows are mixed from. */ shadow: ref('black'), @@ -376,12 +423,12 @@ export const semanticColorLight = { 'primary-on-subtle': ref('gray-205'), 'primary-border': ref('gray-922'), - 'success-solid': ref('success-600'), - 'success-solid-hover': ref('success-700'), + 'success-solid': ref('green-550'), + 'success-solid-hover': ref('green-520'), 'success-on-solid': ref('white'), - 'success-subtle': ref('success-50'), - 'success-on-subtle': ref('success-700'), - 'success-border': ref('success-200'), + 'success-subtle': ref('green-950'), + 'success-on-subtle': ref('green-400'), + 'success-border': ref('green-880'), // Amber is squeezed from both sides in light mode. It cannot carry white text // at any usable weight (white on warning-600 is 3.78:1), and a bright amber @@ -391,12 +438,12 @@ export const semanticColorLight = { // // Hover therefore brightens rather than darkens — warning-700 would drop dark // text to 3.27:1. - 'warning-solid': ref('warning-600'), - 'warning-solid-hover': ref('warning-500'), - 'warning-on-solid': ref('neutral-900'), - 'warning-subtle': ref('warning-50'), - 'warning-on-subtle': ref('warning-700'), - 'warning-border': ref('warning-200'), + 'warning-solid': ref('amber-550'), + 'warning-solid-hover': ref('amber-520'), + 'warning-on-solid': ref('white'), + 'warning-subtle': ref('amber-950'), + 'warning-on-subtle': ref('amber-400'), + 'warning-border': ref('amber-880'), 'danger-solid': ref('red-577'), 'danger-solid-hover': ref('red-520'), @@ -417,9 +464,9 @@ export const semanticColorLight = { */ export const semanticColorDark = { background: ref('gray-145'), - surface: ref('gray-205'), - 'surface-subtle': ref('gray-269'), - 'surface-hover': ref('gray-269'), + card: ref('gray-205'), + muted: ref('gray-269'), + accent: ref('gray-269'), 'surface-active': ref('gray-371'), 'surface-selected': ref('gray-269'), 'surface-disabled': ref('gray-269'), @@ -427,10 +474,10 @@ export const semanticColorDark = { // darker than its card reads as a hole in the layout. skeleton: ref('gray-269'), - text: ref('gray-985'), + foreground: ref('gray-985'), // shadcn's own value, kept: 7.63:1 on the page and 5.83:1 on `--muted`, so // dark mode needs none of the correction light mode did. - 'text-muted': ref('gray-708'), + 'muted-foreground': ref('gray-708'), 'text-subtle': ref('gray-556'), 'text-disabled': ref('gray-556'), @@ -441,8 +488,8 @@ export const semanticColorDark = { 'border-subtle': ref('white-alpha-10'), // shadcn's `--input`, unchanged: composited over the page it measures // 3.82:1, and 3.54:1 over a card, so both clear 1.4.11 without help. - 'border-control': ref('white-alpha-15'), - 'focus-ring': ref('gray-556'), + input: ref('white-alpha-15'), + ring: ref('gray-556'), shadow: ref('black'), @@ -463,19 +510,19 @@ export const semanticColorDark = { 'primary-on-subtle': ref('gray-985'), 'primary-border': ref('gray-371'), - 'success-solid': ref('success-400'), - 'success-solid-hover': ref('success-300'), - 'success-on-solid': ref('neutral-950'), - 'success-subtle': ref('success-950'), - 'success-on-subtle': ref('success-300'), - 'success-border': ref('success-800'), - - 'warning-solid': ref('warning-400'), - 'warning-solid-hover': ref('warning-300'), - 'warning-on-solid': ref('neutral-950'), - 'warning-subtle': ref('warning-950'), - 'warning-on-subtle': ref('warning-300'), - 'warning-border': ref('warning-800'), + 'success-solid': ref('green-550'), + 'success-solid-hover': ref('green-520'), + 'success-on-solid': ref('white'), + 'success-subtle': ref('green-260'), + 'success-on-subtle': ref('green-850'), + 'success-border': ref('green-350'), + + 'warning-solid': ref('amber-550'), + 'warning-solid-hover': ref('amber-520'), + 'warning-on-solid': ref('white'), + 'warning-subtle': ref('amber-260'), + 'warning-on-subtle': ref('amber-850'), + 'warning-border': ref('amber-350'), // The same red as light mode, with a white label. See `red`. 'danger-solid': ref('red-577'), diff --git a/packages/tokens/src/contrast.test.ts b/packages/tokens/src/contrast.test.ts index 28aa0b3..74e019c 100644 --- a/packages/tokens/src/contrast.test.ts +++ b/packages/tokens/src/contrast.test.ts @@ -19,17 +19,17 @@ const AA_TEXT = 4.5 const AA_NON_TEXT = 3 const pairings: readonly Pairing[] = [ - ['body text on the page', 'text', 'background', AA_TEXT], - ['body text on a surface', 'text', 'surface', AA_TEXT], - ['body text on a recessed surface', 'text', 'surface-subtle', AA_TEXT], - ['body text on a hovered row', 'text', 'surface-hover', AA_TEXT], - ['body text on an active row', 'text', 'surface-active', AA_TEXT], - ['body text on a selected row', 'text', 'surface-selected', AA_TEXT], - ['muted text on the page', 'text-muted', 'background', AA_TEXT], - ['muted text on a surface', 'text-muted', 'surface', AA_TEXT], + ['body text on the page', 'foreground', 'background', AA_TEXT], + ['body text on a surface', 'foreground', 'card', AA_TEXT], + ['body text on a recessed surface', 'foreground', 'muted', AA_TEXT], + ['body text on a hovered row', 'foreground', 'accent', AA_TEXT], + ['body text on an active row', 'foreground', 'surface-active', AA_TEXT], + ['body text on a selected row', 'foreground', 'surface-selected', AA_TEXT], + ['muted text on the page', 'muted-foreground', 'background', AA_TEXT], + ['muted text on a surface', 'muted-foreground', 'card', AA_TEXT], // A table header is muted text on a recessed surface, which is the one // muted pairing this list originally missed. - ['muted text on a recessed surface', 'text-muted', 'surface-subtle', AA_TEXT], + ['muted text on a recessed surface', 'muted-foreground', 'muted', AA_TEXT], ['label on a neutral button', 'neutral-on-solid', 'neutral-solid', AA_TEXT], ['label on a primary button', 'primary-on-solid', 'primary-solid', AA_TEXT], @@ -52,16 +52,16 @@ const pairings: readonly Pairing[] = [ ['text in a warning badge', 'warning-on-subtle', 'warning-subtle', AA_TEXT], ['text in a danger badge', 'danger-on-subtle', 'danger-subtle', AA_TEXT], - ['focus ring against the page', 'focus-ring', 'background', AA_NON_TEXT], - ['focus ring against a surface', 'focus-ring', 'surface', AA_NON_TEXT], - ['control border against a surface', 'border-control', 'surface', AA_NON_TEXT], - ['control border against the page', 'border-control', 'background', AA_NON_TEXT], + ['focus ring against the page', 'ring', 'background', AA_NON_TEXT], + ['focus ring against a surface', 'ring', 'card', AA_NON_TEXT], + ['control border against a surface', 'input', 'card', AA_NON_TEXT], + ['control border against the page', 'input', 'background', AA_NON_TEXT], // Controls live in toolbars and table headers too, which are `surface-subtle` // rather than `surface` — the darkest plane either token normally sits on, // and the one neither was checked against until the palette changed under // them. - ['focus ring against a recessed surface', 'focus-ring', 'surface-subtle', AA_NON_TEXT], - ['control border against a recessed surface', 'border-control', 'surface-subtle', AA_NON_TEXT], + ['focus ring against a recessed surface', 'ring', 'muted', AA_NON_TEXT], + ['control border against a recessed surface', 'input', 'muted', AA_NON_TEXT], ] describe.each([ @@ -86,16 +86,15 @@ describe('translucent tokens are measured as they render', () => { * compositing, these fail; a ratio near 15 is the signature of that bug. */ it('composites the 15% control border over the surface behind it', () => { - expect( - semanticContrast(semanticColorDark['border-control'], semanticColorDark.surface) - ).toBeCloseTo(3.54, 1) - expect( - semanticContrast(semanticColorDark['border-control'], semanticColorDark.background) - ).toBeCloseTo(3.82, 1) + expect(semanticContrast(semanticColorDark.input, semanticColorDark.card)).toBeCloseTo(3.54, 1) + expect(semanticContrast(semanticColorDark.input, semanticColorDark.background)).toBeCloseTo( + 3.82, + 1 + ) }) it('composites the 10% hairline, which is decorative and stays under 3:1', () => { - const ratio = semanticContrast(semanticColorDark.border, semanticColorDark.surface) + const ratio = semanticContrast(semanticColorDark.border, semanticColorDark.card) expect(ratio).toBeLessThan(AA_NON_TEXT) expect(ratio).toBeGreaterThan(1.5) }) diff --git a/packages/tokens/src/index.ts b/packages/tokens/src/index.ts index 000b1fc..3afa8d6 100644 --- a/packages/tokens/src/index.ts +++ b/packages/tokens/src/index.ts @@ -22,9 +22,11 @@ import { version as pkgVersion } from '../package.json' with { type: 'json' } import { blur } from './blur' import { + amber, colorPrimitives, danger, gray, + green, neutral, primary, red, @@ -42,10 +44,12 @@ import { fontFamily, fontSize, fontWeight, letterSpacing, lineHeight } from './t import { zIndex } from './z-index' export { + amber, colorPrimitives, colorSteps, danger, gray, + green, neutral, primary, red, @@ -89,6 +93,8 @@ export { buildThemeCss } from './css' export const tokens = { color: { gray, + green, + amber, whiteAlpha, neutral, primary, diff --git a/packages/ui/README.md b/packages/ui/README.md index b62812d..dfe9b90 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -95,3 +95,5 @@ That is the whole library. If you need forty components covering every case, [Nu ## License MIT © Nikolai Kushner + +Design language based on [shadcn/ui](https://ui.shadcn.com) by shadcn, adapted for Vue. shadcn/ui is MIT licensed; rowkit adopts its token values and class recipes, not its code. diff --git a/packages/ui/src/components/Badge/Badge.stories.ts b/packages/ui/src/components/Badge/Badge.stories.ts index da7d96c..4652378 100644 --- a/packages/ui/src/components/Badge/Badge.stories.ts +++ b/packages/ui/src/components/Badge/Badge.stories.ts @@ -52,7 +52,7 @@ export const Matrix: Story = { template: `
- {{ appearance }} + {{ appearance }}
{{ variant }} @@ -112,21 +112,21 @@ export const InATable: Story = { - - + + - + - + - + diff --git a/packages/ui/src/components/Button/Button.test.ts b/packages/ui/src/components/Button/Button.test.ts index b4ca221..2580408 100644 --- a/packages/ui/src/components/Button/Button.test.ts +++ b/packages/ui/src/components/Button/Button.test.ts @@ -14,7 +14,7 @@ describe('Button', () => { it.each([ ['primary', 'bg-primary-solid'], - ['secondary', 'border-border-control'], + ['secondary', 'border-input'], ['ghost', 'bg-transparent'], ['danger', 'bg-danger-solid'], ] as const)('%s uses the %s token', (variant, expected) => { diff --git a/packages/ui/src/components/Button/Button.variants.ts b/packages/ui/src/components/Button/Button.variants.ts index b1ed179..8de59b5 100644 --- a/packages/ui/src/components/Button/Button.variants.ts +++ b/packages/ui/src/components/Button/Button.variants.ts @@ -23,7 +23,7 @@ export const buttonVariants = cva( [ 'inline-flex shrink-0 items-center justify-center gap-2 border font-medium whitespace-nowrap', 'cursor-pointer transition-all duration-fast ease-standard', - 'outline-none focus-visible:border-focus-ring focus-visible:ring-3 focus-visible:ring-focus-ring/50', + 'outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50', 'disabled:pointer-events-none disabled:opacity-50', // A button mid-request should not look clickable, but it must stay // focusable so a screen reader user is not thrown out of the form. @@ -34,9 +34,8 @@ export const buttonVariants = cva( variant: { primary: 'border-primary-solid bg-primary-solid text-primary-on-solid hover:border-primary-solid-hover hover:bg-primary-solid-hover', - secondary: - 'border-border-control bg-surface text-text hover:bg-surface-hover active:bg-surface-active', - ghost: 'border-transparent bg-transparent text-text hover:bg-surface-hover', + secondary: 'border-input bg-card text-foreground hover:bg-accent active:bg-surface-active', + ghost: 'border-transparent bg-transparent text-foreground hover:bg-accent', danger: 'border-danger-solid bg-danger-solid text-danger-on-solid hover:border-danger-solid-hover hover:bg-danger-solid-hover', }, diff --git a/packages/ui/src/components/DataTable/DataTable.stories.ts b/packages/ui/src/components/DataTable/DataTable.stories.ts index 77ae312..637ad7c 100644 --- a/packages/ui/src/components/DataTable/DataTable.stories.ts +++ b/packages/ui/src/components/DataTable/DataTable.stories.ts @@ -312,7 +312,7 @@ export const ManualSorting: Story = { setup: () => ({ users, columns: sortableColumns, sort: ref(undefined) }), template: `
-

+

Emitted sort: {{ sort ? sort.id + ' ' + sort.direction : 'none' }} — the rows below never move.

@@ -426,7 +426,7 @@ function selectable(mode: 'single' | 'multiple') { setup: () => ({ users, columns, mode, selected: ref<(string | number)[]>([]) }), template: `
-

Selected: {{ selected.length }}

+

Selected: {{ selected.length }}

{ // states are not painted over by the pinned column. const el = setup({ columns: [{ key: 'name', header: 'Name', sticky: true }] }) expect(el.find('tbody td').classes()).toContain('bg-inherit') - expect(el.find('tbody tr').classes()).toContain('bg-surface') + expect(el.find('tbody tr').classes()).toContain('bg-card') }) it('lets a selected row show through its pinned cell', () => { @@ -295,10 +295,8 @@ describe('DataTable', () => { }) it('does not highlight rows on hover unless they do something', () => { - expect(setup().find('tbody tr').classes()).not.toContain('hover:bg-surface-hover/50') - expect(setup({ hoverable: true }).find('tbody tr').classes()).toContain( - 'hover:bg-surface-hover/50' - ) + expect(setup().find('tbody tr').classes()).not.toContain('hover:bg-accent/50') + expect(setup({ hoverable: true }).find('tbody tr').classes()).toContain('hover:bg-accent/50') }) describe('column identity', () => { @@ -503,7 +501,7 @@ describe('DataTable', () => { }) it('shows a hover affordance once rows respond to a click', () => { - expect(clickable().find('tbody tr').classes()).toContain('hover:bg-surface-hover/50') + expect(clickable().find('tbody tr').classes()).toContain('hover:bg-accent/50') }) }) diff --git a/packages/ui/src/components/DataTable/DataTable.variants.ts b/packages/ui/src/components/DataTable/DataTable.variants.ts index a304a51..56e562b 100644 --- a/packages/ui/src/components/DataTable/DataTable.variants.ts +++ b/packages/ui/src/components/DataTable/DataTable.variants.ts @@ -5,9 +5,9 @@ import { cva, type VariantProps } from 'class-variance-authority' * pushing the page sideways, which is what makes a sticky column meaningful. */ export const dataTableWrapperVariants = cva([ - 'relative w-full overflow-auto rounded-md border border-border bg-surface', + 'relative w-full overflow-auto rounded-md border border-border bg-card', // Focusable when it actually scrolls, so the ring has to be visible. - 'outline-none focus-visible:border-focus-ring focus-visible:ring-3 focus-visible:ring-focus-ring/50', + 'outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50', ]) export const dataTableVariants = cva('w-full border-collapse text-left', { @@ -20,7 +20,7 @@ export const dataTableVariants = cva('w-full border-collapse text-left', { defaultVariants: { size: 'md' }, }) -export const dataTableCaptionVariants = cva('px-3 py-2 text-left text-text-muted', { +export const dataTableCaptionVariants = cva('px-3 py-2 text-left text-muted-foreground', { variants: { size: { sm: 'text-xs', @@ -45,7 +45,7 @@ export const dataTableHeaderRowVariants = cva('relative z-sticky border-b border * Body cells need no z-index of their own: a `sticky` cell is positioned, and a * positioned element already paints above its static siblings. * - * `bg-surface`, not `bg-surface-subtle`, and `text-text` rather than muted. + * `bg-card`, not `bg-muted`, and `text-foreground` rather than muted. * * shadcn's header is transparent with a hairline beneath it — the column labels * are full-strength foreground, not a recessed grey band with quiet text. The @@ -53,7 +53,7 @@ export const dataTableHeaderRowVariants = cva('relative z-sticky border-b border * transparent sticky header lets the rows scroll through it. */ export const dataTableHeaderCellVariants = cva( - 'bg-surface align-middle font-medium whitespace-nowrap text-text', + 'bg-card align-middle font-medium whitespace-nowrap text-foreground', { variants: { size: { @@ -97,8 +97,8 @@ export const dataTableSortButtonVariants = cva( [ 'group inline-flex w-full cursor-pointer items-center gap-1', 'rounded-xs font-medium text-inherit', - 'transition-colors duration-fast ease-standard hover:text-text', - 'outline-none focus-visible:ring-3 focus-visible:ring-focus-ring', + 'transition-colors duration-fast ease-standard hover:text-foreground', + 'outline-none focus-visible:ring-3 focus-visible:ring-ring', ], { variants: { @@ -132,7 +132,7 @@ export const dataTableSortIconVariants = cva( // `border-border`, not `border-border-subtle`: shadcn's row separator is its // standard hairline, and the fainter one disappeared entirely once the header // stopped being a grey band to anchor the grid. -export const dataTableCellVariants = cva('border-t border-border align-middle text-text', { +export const dataTableCellVariants = cva('border-t border-border align-middle text-foreground', { variants: { size: { sm: 'h-8 px-2', @@ -157,34 +157,31 @@ export const dataTableCellVariants = cva('border-t border-border align-middle te * The row owns the background, not the cell. * * A pinned cell has to be opaque or the rows underneath show through it while - * scrolling, but hardcoding `bg-surface` there would paint over the selected + * scrolling, but hardcoding `bg-card` there would paint over the selected * and hover states. `bg-inherit` on the cell and a real colour on the row keeps * one source of truth. */ -export const dataTableRowVariants = cva( - 'bg-surface transition-colors duration-fast ease-standard', - { - variants: { - /** - * Row hover is off unless the row does something. A highlight that follows - * the pointer across static data suggests the row is clickable when it is - * not. - */ - interactive: { - // `/50` is shadcn's: the hover tint is half-strength so it reads as a - // pointer follow rather than as selection, which is the full tint. - true: 'hover:bg-surface-hover/50', - false: '', - }, - /** Selected wins over hover — losing the highlight on hover hides the state. */ - selected: { - true: 'bg-surface-selected hover:bg-surface-selected', - false: '', - }, +export const dataTableRowVariants = cva('bg-card transition-colors duration-fast ease-standard', { + variants: { + /** + * Row hover is off unless the row does something. A highlight that follows + * the pointer across static data suggests the row is clickable when it is + * not. + */ + interactive: { + // `/50` is shadcn's: the hover tint is half-strength so it reads as a + // pointer follow rather than as selection, which is the full tint. + true: 'hover:bg-accent/50', + false: '', }, - defaultVariants: { interactive: false, selected: false }, - } -) + /** Selected wins over hover — losing the highlight on hover hides the state. */ + selected: { + true: 'bg-surface-selected hover:bg-surface-selected', + false: '', + }, + }, + defaultVariants: { interactive: false, selected: false }, +}) // Matches the body cell: same hairline, same padding. shadcn drops the right // padding on a checkbox cell so the control sits tight against its column. @@ -205,9 +202,9 @@ export const dataTableSelectCellVariants = cva('w-px border-t border-border pr-0 export const dataTableCheckboxVariants = cva( [ 'flex shrink-0 cursor-pointer items-center justify-center rounded-xs border shadow-xs', - 'border-border-control bg-surface text-primary-on-solid', + 'border-input bg-card text-primary-on-solid', 'transition-all duration-fast ease-standard', - 'outline-none focus-visible:border-focus-ring focus-visible:ring-3 focus-visible:ring-focus-ring/50', + 'outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50', 'data-[state=checked]:border-primary-solid data-[state=checked]:bg-primary-solid', 'data-[state=indeterminate]:border-primary-solid data-[state=indeterminate]:bg-primary-solid', 'disabled:cursor-not-allowed disabled:opacity-50', @@ -226,7 +223,7 @@ export const dataTableCheckboxVariants = cva( export const dataTableRadioVariants = cva( [ 'cursor-pointer accent-primary-solid', - 'outline-none focus-visible:ring-3 focus-visible:ring-focus-ring', + 'outline-none focus-visible:ring-3 focus-visible:ring-ring', ], { variants: { diff --git a/packages/ui/src/components/Dialog/Dialog.stories.ts b/packages/ui/src/components/Dialog/Dialog.stories.ts index be33888..04d5e5e 100644 --- a/packages/ui/src/components/Dialog/Dialog.stories.ts +++ b/packages/ui/src/components/Dialog/Dialog.stories.ts @@ -174,7 +174,7 @@ export const CustomHeader: Story = { Billing -

Upgrade plan

+

Upgrade plan

The accessible name is still "Upgrade plan", from the prop. @@ -306,7 +306,7 @@ export const ScrollLock: Story = { template: `
-

+

Page line {{ line }} — the page must not shift sideways when the dialog opens.

diff --git a/packages/ui/src/components/Dialog/Dialog.variants.ts b/packages/ui/src/components/Dialog/Dialog.variants.ts index 9caee11..0ee250c 100644 --- a/packages/ui/src/components/Dialog/Dialog.variants.ts +++ b/packages/ui/src/components/Dialog/Dialog.variants.ts @@ -26,7 +26,7 @@ export const dialogContentVariants = cva( [ 'fixed left-1/2 top-1/2 z-modal -translate-x-1/2 -translate-y-1/2', 'flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] flex-col', - 'rounded-lg border border-border bg-surface shadow-xl', + 'rounded-lg border border-border bg-card shadow-lg', 'focus-visible:outline-none', 'motion-safe:data-[state=open]:animate-dialog-in', 'motion-safe:data-[state=closed]:animate-dialog-out', @@ -47,16 +47,16 @@ export const dialogContentVariants = cva( /** Header, body and footer are separate rows so only the body scrolls. */ export const dialogHeaderVariants = cva('flex shrink-0 flex-col gap-1 p-6 pb-4') -export const dialogTitleVariants = cva('text-lg font-semibold text-text') +export const dialogTitleVariants = cva('text-lg leading-none font-semibold text-foreground') -export const dialogDescriptionVariants = cva('text-sm text-text-muted') +export const dialogDescriptionVariants = cva('text-sm text-muted-foreground') /** * The body is the only scrolling region. A dialog that scrolls as a whole hides * its own footer actions off-screen, which is where "where did the Save button * go" comes from. */ -export const dialogBodyVariants = cva('min-h-0 flex-1 overflow-y-auto px-6 text-sm text-text') +export const dialogBodyVariants = cva('min-h-0 flex-1 overflow-y-auto px-6 text-sm text-foreground') export const dialogFooterVariants = cva( 'flex shrink-0 flex-wrap items-center justify-end gap-3 p-6 pt-4' @@ -64,10 +64,10 @@ export const dialogFooterVariants = cva( export const dialogCloseVariants = cva([ 'absolute right-4 top-4 inline-flex size-8 shrink-0 cursor-pointer', - 'items-center justify-center rounded-sm text-text-muted', + 'items-center justify-center rounded-xs text-muted-foreground opacity-70 hover:opacity-100', 'transition-colors duration-fast ease-standard', - 'hover:bg-surface-hover hover:text-text', - 'outline-none focus-visible:ring-3 focus-visible:ring-focus-ring', + 'hover:bg-accent hover:text-foreground', + 'outline-none focus-visible:ring-3 focus-visible:ring-ring', ]) export type DialogVariants = VariantProps diff --git a/packages/ui/src/components/EmptyState/EmptyState.stories.ts b/packages/ui/src/components/EmptyState/EmptyState.stories.ts index c3c4efe..c72cce3 100644 --- a/packages/ui/src/components/EmptyState/EmptyState.stories.ts +++ b/packages/ui/src/components/EmptyState/EmptyState.stories.ts @@ -51,7 +51,7 @@ const meta: Meta = { components: { EmptyState }, setup: () => ({ args }), template: ` -
+
`, @@ -73,7 +73,7 @@ export const Reasons: Story = { components: { EmptyState, Button }, template: `
-
+
-
+
-
+
@@ -108,7 +108,7 @@ export const DefaultCopy: Story = { components: { EmptyState }, setup: () => ({ args }), template: ` -
+
`, @@ -126,7 +126,7 @@ export const TitleOnly: Story = { render: () => ({ components: { EmptyState }, template: ` -
+
`, @@ -138,7 +138,7 @@ export const WithIcon: Story = { components: { EmptyState }, setup: () => ({ args, boxIcon }), template: ` -
+
@@ -156,7 +156,7 @@ export const FirstRun: Story = { components: { EmptyState, Button }, setup: () => ({ boxIcon }), template: ` -
+
({ components: { EmptyState, Button }, template: ` -
+
- - - + + + @@ -238,7 +238,7 @@ export const Sizes: Story = { setup: () => ({ sizes }), template: `
-
+
@@ -256,12 +256,12 @@ export const AgainstLoading: Story = { components: { EmptyState, Skeleton }, template: `
-
+
-
+
diff --git a/packages/ui/src/components/EmptyState/EmptyState.test.ts b/packages/ui/src/components/EmptyState/EmptyState.test.ts index 7a3ada7..70942e0 100644 --- a/packages/ui/src/components/EmptyState/EmptyState.test.ts +++ b/packages/ui/src/components/EmptyState/EmptyState.test.ts @@ -65,13 +65,13 @@ describe('EmptyState', () => { // that says what to do. const el = mount(EmptyState, { props: { title, reason: 'error' } }) expect(el.find('p').classes()).toContain('text-danger-on-subtle') - expect(el.find('h2').classes()).toContain('text-text') + expect(el.find('h2').classes()).toContain('text-foreground') }) it('keeps the other two reasons muted', () => { for (const reason of ['no-data', 'no-results'] as const) { const el = mount(EmptyState, { props: { title, reason, description: 'x' } }) - expect(el.find('p').classes(), reason).toContain('text-text-muted') + expect(el.find('p').classes(), reason).toContain('text-muted-foreground') } }) }) diff --git a/packages/ui/src/components/EmptyState/EmptyState.variants.ts b/packages/ui/src/components/EmptyState/EmptyState.variants.ts index 4ad4b5c..c93d619 100644 --- a/packages/ui/src/components/EmptyState/EmptyState.variants.ts +++ b/packages/ui/src/components/EmptyState/EmptyState.variants.ts @@ -21,7 +21,7 @@ export const emptyStateVariants = cva('flex flex-col items-center justify-center * misses the sentence telling them what to do. */ export const emptyStateIconVariants = cva( - 'flex shrink-0 items-center justify-center text-text-subtle', + 'flex shrink-0 items-center justify-center text-muted-foreground', { variants: { size: { @@ -34,7 +34,7 @@ export const emptyStateIconVariants = cva( } ) -export const emptyStateTitleVariants = cva('font-medium text-text', { +export const emptyStateTitleVariants = cva('font-medium text-foreground', { variants: { size: { sm: 'text-sm', @@ -67,8 +67,8 @@ export const emptyStateDescriptionVariants = cva('text-balance', { lg: 'max-w-md text-sm', }, reason: { - 'no-data': 'text-text-muted', - 'no-results': 'text-text-muted', + 'no-data': 'text-muted-foreground', + 'no-results': 'text-muted-foreground', error: 'text-danger-on-subtle', }, }, diff --git a/packages/ui/src/components/Field/Field.variants.ts b/packages/ui/src/components/Field/Field.variants.ts index 9b2ec61..84352db 100644 --- a/packages/ui/src/components/Field/Field.variants.ts +++ b/packages/ui/src/components/Field/Field.variants.ts @@ -11,7 +11,7 @@ export const fieldVariants = cva('flex flex-col', { defaultVariants: { size: 'md' }, }) -export const fieldLabelVariants = cva('font-medium text-text', { +export const fieldLabelVariants = cva('font-medium text-foreground', { variants: { size: { sm: 'text-xs', @@ -19,29 +19,29 @@ export const fieldLabelVariants = cva('font-medium text-text', { lg: 'text-sm', }, disabled: { - true: 'text-text-disabled', + true: 'opacity-50', false: '', }, }, defaultVariants: { size: 'md', disabled: false }, }) -export const fieldHintVariants = cva('text-text-muted', { +export const fieldHintVariants = cva('text-muted-foreground', { variants: { size: { sm: 'text-xs', - md: 'text-xs', + md: 'text-sm', lg: 'text-sm', }, }, defaultVariants: { size: 'md' }, }) -export const fieldErrorVariants = cva('font-medium text-danger-on-subtle', { +export const fieldErrorVariants = cva('font-medium text-danger-solid', { variants: { size: { sm: 'text-xs', - md: 'text-xs', + md: 'text-sm', lg: 'text-sm', }, }, diff --git a/packages/ui/src/components/FilterBar/FilterBar.variants.ts b/packages/ui/src/components/FilterBar/FilterBar.variants.ts index 86bb9d3..9237197 100644 --- a/packages/ui/src/components/FilterBar/FilterBar.variants.ts +++ b/packages/ui/src/components/FilterBar/FilterBar.variants.ts @@ -40,12 +40,12 @@ export const filterBarChipsVariants = cva('flex flex-wrap items-center', { * the data for attention and makes the filters look like alerts. */ export const filterBarChipVariants = cva( - 'inline-flex max-w-full items-center border border-border bg-surface-subtle text-text', + 'inline-flex max-w-full items-center border border-neutral-border bg-neutral-subtle font-medium text-neutral-on-subtle', { variants: { size: { - sm: 'gap-1 rounded-xs py-0.5 pl-1.5 text-xs', - md: 'gap-1 rounded-sm py-0.5 pl-2 text-sm', + sm: 'gap-1 rounded-md py-0.5 pl-1.5 text-xs', + md: 'gap-1 rounded-md py-0.5 pl-2 text-xs', }, /** A chip the user cannot clear keeps the trailing padding the button would occupy. */ removable: { @@ -60,23 +60,23 @@ export const filterBarChipVariants = cva( export const filterBarChipRemoveVariants = cva( [ 'inline-flex shrink-0 cursor-pointer items-center justify-center rounded-xs', - 'text-text-muted transition-colors duration-fast ease-standard', - 'hover:bg-surface-active hover:text-text', - 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-focus-ring', - 'disabled:pointer-events-none disabled:text-text-disabled', + 'text-muted-foreground transition-colors duration-fast ease-standard', + 'hover:bg-surface-active hover:text-foreground', + 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring', + 'disabled:pointer-events-none disabled:opacity-50', ], { variants: { size: { - sm: 'mr-0.5 size-4', - md: 'mr-1 size-5', + sm: 'mr-0.5 size-3.5', + md: 'mr-1 size-4', }, }, defaultVariants: { size: 'md' }, } ) -export const filterBarSummaryVariants = cva('text-text-muted tabular-nums', { +export const filterBarSummaryVariants = cva('text-muted-foreground tabular-nums', { variants: { size: { sm: 'text-xs', diff --git a/packages/ui/src/components/Input/Input.stories.ts b/packages/ui/src/components/Input/Input.stories.ts index b252c5f..abd871a 100644 --- a/packages/ui/src/components/Input/Input.stories.ts +++ b/packages/ui/src/components/Input/Input.stories.ts @@ -130,7 +130,7 @@ export const TypingUpdatesTheModel: Story = { -

Model: {{ value }}

+

Model: {{ value }}

`, }), diff --git a/packages/ui/src/components/Input/Input.variants.ts b/packages/ui/src/components/Input/Input.variants.ts index a3b3ca9..837b693 100644 --- a/packages/ui/src/components/Input/Input.variants.ts +++ b/packages/ui/src/components/Input/Input.variants.ts @@ -8,24 +8,29 @@ import { cva, type VariantProps } from 'class-variance-authority' */ export const inputVariants = cva( [ - 'w-full border bg-surface text-text', - 'transition-colors duration-fast ease-standard', - 'placeholder:text-text-subtle', - 'outline-none focus-visible:border-focus-ring focus-visible:ring-3 focus-visible:ring-focus-ring/50', - 'disabled:cursor-not-allowed disabled:bg-surface-disabled disabled:text-text-disabled', + // `bg-transparent` is shadcn's: the field takes the colour of whatever it + // sits on, so a form inside a card does not show a second white rectangle. + 'w-full border bg-transparent text-foreground shadow-xs', + 'transition-all duration-fast ease-standard', + 'placeholder:text-muted-foreground', + 'outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50', + 'disabled:cursor-not-allowed disabled:opacity-50', ], { variants: { size: { - sm: 'h-8 rounded-sm px-2 text-sm', - md: 'h-9 rounded-md px-3 text-sm', - lg: 'h-10 rounded-md px-3 text-base', + sm: 'h-8 rounded-md px-2 text-sm', + md: 'h-9 rounded-md px-3 py-1 text-sm', + lg: 'h-10 rounded-md px-3 text-sm', }, invalid: { - // Colour is not the only signal: the error text below the control + // shadcn's invalid treatment: the border goes destructive and the focus + // ring is tinted to match, so the state survives being focused. + // + // Colour is not the only signal — the error text below the control // carries the message, and aria-invalid carries it to assistive tech. - true: 'border-danger-solid focus-visible:outline-danger-solid', - false: 'border-border-control', + true: 'border-danger-solid ring-3 ring-danger-solid/20 focus-visible:border-danger-solid focus-visible:ring-danger-solid/20', + false: 'border-input', }, }, defaultVariants: { size: 'md', invalid: false }, diff --git a/packages/ui/src/components/Input/Input.vue b/packages/ui/src/components/Input/Input.vue index 0112d63..16f8fdd 100644 --- a/packages/ui/src/components/Input/Input.vue +++ b/packages/ui/src/components/Input/Input.vue @@ -46,7 +46,7 @@ const describedBy = computed(() => field?.describedBy.value)
@@ -72,7 +72,7 @@ const describedBy = computed(() => field?.describedBy.value) " /> - +
diff --git a/packages/ui/src/components/Select/Select.stories.ts b/packages/ui/src/components/Select/Select.stories.ts index dd8a0fe..5143f5c 100644 --- a/packages/ui/src/components/Select/Select.stories.ts +++ b/packages/ui/src/components/Select/Select.stories.ts @@ -209,7 +209,7 @@ export const SelectingAnOption: Story = { template: `
props.disabled || props.total === 0) -
diff --git a/packages/ui/src/components/Select/Select.variants.ts b/packages/ui/src/components/Select/Select.variants.ts index 34cfa74..b468e8b 100644 --- a/packages/ui/src/components/Select/Select.variants.ts +++ b/packages/ui/src/components/Select/Select.variants.ts @@ -24,9 +24,9 @@ export const selectTriggerVariants = cva( { variants: { size: { - sm: 'h-7 rounded-lg px-2.5 text-sm', - md: 'h-8 rounded-lg px-2.5 text-sm', - lg: 'h-9 rounded-lg px-2.5 text-sm', + sm: 'h-7 rounded-md px-2.5 text-sm', + md: 'h-8 rounded-md px-2.5 text-sm', + lg: 'h-9 rounded-md px-2.5 text-sm', }, invalid: { true: 'border-danger-solid ring-3 ring-danger-solid/20 focus-visible:border-danger-solid focus-visible:ring-danger-solid/20', diff --git a/packages/ui/src/components/Select/Select.vue b/packages/ui/src/components/Select/Select.vue index 749a075..e04e907 100644 --- a/packages/ui/src/components/Select/Select.vue +++ b/packages/ui/src/components/Select/Select.vue @@ -200,7 +200,7 @@ watch(inputValue, (value) => { :class=" cn( 'min-w-0 flex-1 truncate bg-transparent text-inherit outline-none', - 'placeholder:text-text-subtle disabled:cursor-not-allowed', + 'placeholder:text-muted-foreground disabled:cursor-not-allowed', !props.searchable && 'cursor-pointer' ) " From 69e7477d04d83cb75d52fd061963f0724ce1db2e Mon Sep 17 00:00:00 2001 From: NikolaiKushner Date: Thu, 6 Aug 2026 20:05:02 +0400 Subject: [PATCH 17/19] refactor(ui, tokens): enhance badge and button styles for improved consistency This commit refines the styles of Badge and Button components, ensuring a cohesive design language across the UI. Key updates include the introduction of a soft tinted chip appearance for badges, aligning with the overall visual theme. Button styles are adjusted to unify height, radius, and padding with other form controls, enhancing usability and visual clarity. Additionally, the hover effects for DataTable rows are updated to maintain distinct interactions, while the pagination component's button sizes are standardized for consistency. Documentation and tests are also updated to reflect these changes. --- .changeset/cool-hairlines-compact.md | 15 +++--- docs/agents.md | 2 +- packages/tokens/src/color.ts | 22 +++++---- packages/tokens/src/contrast.test.ts | 1 + packages/tokens/src/shadow.ts | 2 +- packages/ui/AGENTS.md | 2 +- .../ui/src/components/Badge/Badge.test.ts | 10 ++++ .../ui/src/components/Badge/Badge.variants.ts | 2 + packages/ui/src/components/Badge/types.ts | 4 +- .../ui/src/components/Button/Button.test.ts | 2 +- .../src/components/Button/Button.variants.ts | 15 ++++-- .../components/DataTable/DataTable.stories.ts | 8 ++-- .../components/DataTable/DataTable.test.ts | 6 +-- .../DataTable/DataTable.variants.ts | 47 +++++++++++-------- packages/ui/src/components/DataTable/index.ts | 1 + .../src/components/Dialog/Dialog.variants.ts | 8 +++- .../ui/src/components/Input/Input.variants.ts | 7 +-- .../Pagination/Pagination.variants.ts | 2 +- .../src/components/Pagination/Pagination.vue | 2 +- .../components/Tooltip/Tooltip.variants.ts | 11 ++--- packages/ui/src/styles/theme.test.ts | 2 +- playground/app/app.vue | 27 ++++++----- playground/app/pages/overlays.vue | 24 ++++------ playground/app/pages/users.vue | 26 ++++++++-- 24 files changed, 148 insertions(+), 100 deletions(-) diff --git a/.changeset/cool-hairlines-compact.md b/.changeset/cool-hairlines-compact.md index b696a80..86dfb7a 100644 --- a/.changeset/cool-hairlines-compact.md +++ b/.changeset/cool-hairlines-compact.md @@ -1,12 +1,15 @@ --- '@rowkit/tokens': minor -'rowkit': patch +'rowkit': minor --- -Soften the chrome and give primary a real colour, while keeping controls the same shape everywhere. +Quiet the working surface: cool chrome, indigo primary, and denser table pages that stay scannable. -Decorative borders pick up a cool cast (hue 264) and lift off pure white: light mode `border` moves from the reference `0.922` to `oklch(0.940 0.004 264)`, with matching softer `border-strong` and recessed `muted`/`accent` steps. The page background becomes a cool off-white so white cards still lift without heavy outlines; dark mode hairlines drop from 10% to 8% white. +Decorative borders pick up a cool cast and lift off pure white; the page background is a cool off-white so white cards still lift. Control borders (`input`) follow with a cooler, slightly softer step that still clears WCAG 1.4.11. Primary leaves the near-black inversion for brand indigo, and the focus ring follows it. Selected rows take a quiet primary wash; tooltips invert foreground/background instead of painting as a floating primary button. Sticky column scroll shadows are a touch stronger so a pinned Name column still reads when the grid moves. -Primary leaves the near-black/near-white inversion for brand indigo (`primary-600` / `primary-400`), and the focus ring follows it. Tooltips, checkboxes and primary buttons share that fill. - -Button, Input, Select and pagination items all use the same height band and `rounded-md`, and Pagination's rows-per-page Select inherits the pagination `size` instead of staying stuck on `sm`. Stale `text-text*` classes in Select, Pagination and Storybook's preview are aligned to the renamed tokens so playground, Storybook and VitePress render the same controls. +Button, Input and Select share one control geometry from `sm` up — same height, +radius, padding and `text-sm`. Secondary is a muted fill (`bg-muted`), not the +hollow `border-input` shell fields use, so a button next to an input stays one +system without looking like another field. Dialog close matches icon-button +geometry and the shared focus recipe. Chromatic Badge `subtle` stays a soft +tinted chip (fill + hairline), same recipe as neutral — not bare coloured text. diff --git a/docs/agents.md b/docs/agents.md index 08571ed..72e2f38 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -77,7 +77,7 @@ handling. **Props** - `variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger'` — default `'neutral'`. Status family. `neutral` is the "no particular status" default rather than an absence of styling. -- `appearance: 'subtle' | 'solid' | 'outline'` — default `'subtle'`. How much visual weight the badge carries. Prefer `subtle` in a table — a column of `solid` badges reads as a wall of colour and stops communicating anything. +- `appearance: 'subtle' | 'solid' | 'outline'` — default `'subtle'`. How much visual weight the badge carries. Prefer `subtle` in a table — soft tinted chip with a matching hairline, quieter than `solid` / `outline`. `solid` is for when a single badge has to carry the page. - `size: 'sm' | 'md'` — default `'md'`. Badge size. `sm` is intended for dense table rows. - `dot: boolean` — default `false`. Shows a filled dot before the label, inheriting the text colour. - `class: string`. Additional classes, merged with the variant classes so a consumer's utility wins over the component's own. diff --git a/packages/tokens/src/color.ts b/packages/tokens/src/color.ts index ea9cee4..e55d38b 100644 --- a/packages/tokens/src/color.ts +++ b/packages/tokens/src/color.ts @@ -160,6 +160,12 @@ export const gray = { * `surface-subtle`, which clears the bar on both sides of the rounding. */ 635: 'oklch(0.635 0 0)', + /** + * Cool control boundary. Softer and cooler than the a11y floor at 0.635, still + * clears 3:1 on the page, a card and a recessed toolbar — so inputs speak the + * same language as the cool hairlines without failing WCAG 1.4.11. + */ + 642: 'oklch(0.642 0.012 264)', /** the reference `--ring` (dark), 4.18:1 against the dark page. */ 556: 'oklch(0.556 0 0)', /** @@ -348,10 +354,9 @@ export const semanticColorLight = { /** Row press / active. One step past hover; the reference design has no press token. */ 'surface-active': ref('gray-940'), /** - * Selected table row. Quiet cool tint — enough to mark state, not enough to - * compete with the data. + * Selected table row. Quiet primary wash — distinct from hover, not a shout. */ - 'surface-selected': ref('gray-972'), + 'surface-selected': ref('primary-50'), /** Disabled control background. */ 'surface-disabled': ref('gray-972'), /** @@ -399,12 +404,11 @@ export const semanticColorLight = { * Boundary of an interactive control — text inputs, checkboxes, outlined * buttons. * - * WCAG 1.4.11 requires 3:1 against the adjacent surface for the visual - * boundary of a UI component. Decorative `border` stays well under that; - * this token is the lightest neutral that clears the bar on the page, a - * card and a toolbar alike. + * Cooler and a touch lighter than the old pure `gray-635`, still ≥3:1 on the + * page, a card and a toolbar. Matches the cool hairline language without + * dropping below WCAG 1.4.11. */ - input: ref('gray-635'), + input: ref('gray-642'), /** * Focus ring. Never remove the ring — recolour it. * @@ -479,7 +483,7 @@ export const semanticColorDark = { muted: ref('gray-269'), accent: ref('gray-269'), 'surface-active': ref('gray-371'), - 'surface-selected': ref('gray-269'), + 'surface-selected': ref('primary-950'), 'surface-disabled': ref('gray-269'), // Lifts off `surface` rather than receding. On a dark page a placeholder // darker than its card reads as a hole in the layout. diff --git a/packages/tokens/src/contrast.test.ts b/packages/tokens/src/contrast.test.ts index 4beffa4..eb8d753 100644 --- a/packages/tokens/src/contrast.test.ts +++ b/packages/tokens/src/contrast.test.ts @@ -30,6 +30,7 @@ const pairings: readonly Pairing[] = [ // A table header is muted text on a recessed surface, which is the one // muted pairing this list originally missed. ['muted text on a recessed surface', 'muted-foreground', 'muted', AA_TEXT], + ['muted text on a selected row', 'muted-foreground', 'surface-selected', AA_TEXT], ['label on a neutral button', 'neutral-on-solid', 'neutral-solid', AA_TEXT], ['label on a primary button', 'primary-on-solid', 'primary-solid', AA_TEXT], diff --git a/packages/tokens/src/shadow.ts b/packages/tokens/src/shadow.ts index 576ee2a..fb1d2bb 100644 --- a/packages/tokens/src/shadow.ts +++ b/packages/tokens/src/shadow.ts @@ -23,7 +23,7 @@ export const shadow = { /** Modal dialogs. */ xl: '0 20px 25px -5px color-mix(in oklab, var(--color-shadow) 12%, transparent), 0 8px 10px -6px color-mix(in oklab, var(--color-shadow) 12%, transparent)', /** Horizontal-scroll affordance on a sticky table column. */ - 'scroll-x': '8px 0 8px -8px color-mix(in oklab, var(--color-shadow) 15%, transparent)', + 'scroll-x': '12px 0 16px -8px color-mix(in oklab, var(--color-shadow) 22%, transparent)', /** * The rule under a sticky table header, drawn as a shadow rather than a * border. diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 50e66ea..3203f4c 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -70,7 +70,7 @@ handling. **Props** - `variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger'` — default `'neutral'`. Status family. `neutral` is the "no particular status" default rather than an absence of styling. -- `appearance: 'subtle' | 'solid' | 'outline'` — default `'subtle'`. How much visual weight the badge carries. Prefer `subtle` in a table — a column of `solid` badges reads as a wall of colour and stops communicating anything. +- `appearance: 'subtle' | 'solid' | 'outline'` — default `'subtle'`. How much visual weight the badge carries. Prefer `subtle` in a table — soft tinted chip with a matching hairline, quieter than `solid` / `outline`. `solid` is for when a single badge has to carry the page. - `size: 'sm' | 'md'` — default `'md'`. Badge size. `sm` is intended for dense table rows. - `dot: boolean` — default `false`. Shows a filled dot before the label, inheriting the text colour. - `class: string`. Additional classes, merged with the variant classes so a consumer's utility wins over the component's own. diff --git a/packages/ui/src/components/Badge/Badge.test.ts b/packages/ui/src/components/Badge/Badge.test.ts index 20ab308..089c3b1 100644 --- a/packages/ui/src/components/Badge/Badge.test.ts +++ b/packages/ui/src/components/Badge/Badge.test.ts @@ -33,6 +33,16 @@ describe('Badge', () => { expect(html).toContain('border-danger-border') }) + it('chromatic subtle is a soft chip, not bare text', () => { + const html = mount(Badge, { + props: { variant: 'success', appearance: 'subtle' }, + slots: { default: 'x' }, + }).html() + expect(html).toContain('bg-success-subtle') + expect(html).toContain('border-success-border') + expect(html).not.toContain('bg-transparent') + }) + it('hides the dot from assistive technology', () => { const dot = mount(Badge, { props: { dot: true }, slots: { default: 'x' } }).find( '[aria-hidden]' diff --git a/packages/ui/src/components/Badge/Badge.variants.ts b/packages/ui/src/components/Badge/Badge.variants.ts index 783634b..aa5a227 100644 --- a/packages/ui/src/components/Badge/Badge.variants.ts +++ b/packages/ui/src/components/Badge/Badge.variants.ts @@ -51,6 +51,8 @@ export const badgeVariants = cva( class: 'border-neutral-border text-neutral-on-subtle', }, + // Soft chip: tinted fill + matching hairline. Same recipe as neutral — + // colour lives in the wash, not in a solid pill or bare coloured text. { variant: 'primary', appearance: 'subtle', diff --git a/packages/ui/src/components/Badge/types.ts b/packages/ui/src/components/Badge/types.ts index a3e39ad..af4744b 100644 --- a/packages/ui/src/components/Badge/types.ts +++ b/packages/ui/src/components/Badge/types.ts @@ -14,8 +14,8 @@ export interface BadgeProps { variant?: NonNullable /** * How much visual weight the badge carries. Prefer `subtle` in a table — - * a column of `solid` badges reads as a wall of colour and stops - * communicating anything. + * soft tinted chip with a matching hairline, quieter than `solid` / `outline`. + * `solid` is for when a single badge has to carry the page. */ appearance?: NonNullable /** Badge size. `sm` is intended for dense table rows. */ diff --git a/packages/ui/src/components/Button/Button.test.ts b/packages/ui/src/components/Button/Button.test.ts index 2580408..91c1c14 100644 --- a/packages/ui/src/components/Button/Button.test.ts +++ b/packages/ui/src/components/Button/Button.test.ts @@ -14,7 +14,7 @@ describe('Button', () => { it.each([ ['primary', 'bg-primary-solid'], - ['secondary', 'border-input'], + ['secondary', 'bg-muted'], ['ghost', 'bg-transparent'], ['danger', 'bg-danger-solid'], ] as const)('%s uses the %s token', (variant, expected) => { diff --git a/packages/ui/src/components/Button/Button.variants.ts b/packages/ui/src/components/Button/Button.variants.ts index 3d411e0..52562bf 100644 --- a/packages/ui/src/components/Button/Button.variants.ts +++ b/packages/ui/src/components/Button/Button.variants.ts @@ -38,17 +38,22 @@ export const buttonVariants = cva( variant: { primary: 'border-primary-solid bg-primary-solid text-primary-on-solid hover:border-primary-solid-hover hover:bg-primary-solid-hover', - secondary: 'border-input bg-card text-foreground hover:bg-accent active:bg-surface-active', + // Filled muted — not the hollow `border-input` shell Input/Select use. + // Same height/radius/type as fields; different surface so a secondary + // next to a text field never reads as another field. + secondary: + 'border-transparent bg-muted text-foreground shadow-xs hover:bg-surface-active active:bg-surface-active', ghost: 'border-transparent bg-transparent text-foreground hover:bg-accent', danger: 'border-danger-solid bg-danger-solid text-danger-on-solid hover:border-danger-solid-hover hover:bg-danger-solid-hover', }, - // Shared control geometry with Input and Select: same height band, same - // corner. `rounded-md` (not `rounded-lg`) so a filled primary does not - // read as a pill next to an outlined field. + // Shared control geometry with Input and Select — same height, radius, + // horizontal padding and type size at every step. Only `xs` is button-only + // (tighter type); from `sm` up the three controls are interchangeable in a + // toolbar. size: { xs: 'h-6 gap-1 rounded-md px-2 text-xs', - sm: 'h-7 gap-1 rounded-md px-2.5 text-xs', + sm: 'h-7 gap-1 rounded-md px-2.5 text-sm', md: 'h-8 gap-1.5 rounded-md px-2.5 text-sm', lg: 'h-9 gap-1.5 rounded-md px-2.5 text-sm', }, diff --git a/packages/ui/src/components/DataTable/DataTable.stories.ts b/packages/ui/src/components/DataTable/DataTable.stories.ts index f6bf43a..76c5280 100644 --- a/packages/ui/src/components/DataTable/DataTable.stories.ts +++ b/packages/ui/src/components/DataTable/DataTable.stories.ts @@ -167,15 +167,17 @@ export const CustomCells: Story = { columns: [...columns, { id: 'actions', header: 'Actions', headerSrOnly: true, align: 'end' }], tone: (status: User['status']) => status === 'active' ? 'success' : status === 'invited' ? 'warning' : 'danger', + rowAction: + 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100', }), template: `
- +
diff --git a/packages/ui/src/components/DataTable/DataTable.test.ts b/packages/ui/src/components/DataTable/DataTable.test.ts index 790280d..ed10dd3 100644 --- a/packages/ui/src/components/DataTable/DataTable.test.ts +++ b/packages/ui/src/components/DataTable/DataTable.test.ts @@ -295,8 +295,8 @@ describe('DataTable', () => { }) it('does not highlight rows on hover unless they do something', () => { - expect(setup().find('tbody tr').classes()).not.toContain('hover:bg-accent/50') - expect(setup({ hoverable: true }).find('tbody tr').classes()).toContain('hover:bg-accent/50') + expect(setup().find('tbody tr').classes()).not.toContain('hover:bg-accent') + expect(setup({ hoverable: true }).find('tbody tr').classes()).toContain('hover:bg-accent') }) describe('column identity', () => { @@ -501,7 +501,7 @@ describe('DataTable', () => { }) it('shows a hover affordance once rows respond to a click', () => { - expect(clickable().find('tbody tr').classes()).toContain('hover:bg-accent/50') + expect(clickable().find('tbody tr').classes()).toContain('hover:bg-accent') }) }) diff --git a/packages/ui/src/components/DataTable/DataTable.variants.ts b/packages/ui/src/components/DataTable/DataTable.variants.ts index 6159f86..0978364 100644 --- a/packages/ui/src/components/DataTable/DataTable.variants.ts +++ b/packages/ui/src/components/DataTable/DataTable.variants.ts @@ -173,27 +173,34 @@ export const dataTableCellVariants = cva('border-t border-border align-middle te * and hover states. `bg-inherit` on the cell and a real colour on the row keeps * one source of truth. */ -export const dataTableRowVariants = cva('bg-card transition-colors duration-fast ease-standard', { - variants: { - /** - * Row hover is off unless the row does something. A highlight that follows - * the pointer across static data suggests the row is clickable when it is - * not. - */ - interactive: { - // `/50` is the reference design's: the hover tint is half-strength so it reads as a - // pointer follow rather than as selection, which is the full tint. - true: 'hover:bg-accent/50', - false: '', - }, - /** Selected wins over hover — losing the highlight on hover hides the state. */ - selected: { - true: 'bg-surface-selected hover:bg-surface-selected', - false: '', +export const dataTableRowVariants = cva( + 'group bg-card transition-colors duration-fast ease-standard', + { + variants: { + /** + * Row hover is off unless the row does something. A highlight that follows + * the pointer across static data suggests the row is clickable when it is + * not. + */ + interactive: { + // Full accent — `/50` disappeared on the cool muted wash. Selection uses + // a primary tint, so hover and selected stay distinct. + true: 'hover:bg-accent', + false: '', + }, + /** Selected wins over hover — losing the highlight on hover hides the state. */ + selected: { + true: 'bg-surface-selected hover:bg-surface-selected', + false: '', + }, }, - }, - defaultVariants: { interactive: false, selected: false }, -}) + defaultVariants: { interactive: false, selected: false }, + } +) + +/** Quiet row action — visible on row hover / focus-within, always for keyboard. */ +export const dataTableRowActionClass = + 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100' // Matches the body cell: same hairline, same padding. The reference design drops the right // padding on a checkbox cell so the control sits tight against its column. diff --git a/packages/ui/src/components/DataTable/index.ts b/packages/ui/src/components/DataTable/index.ts index be3febc..7c262c3 100644 --- a/packages/ui/src/components/DataTable/index.ts +++ b/packages/ui/src/components/DataTable/index.ts @@ -21,6 +21,7 @@ export { dataTableHeaderRowVariants, dataTableCheckboxVariants, dataTableRadioVariants, + dataTableRowActionClass, dataTableRowVariants, dataTableSelectCellVariants, dataTableSortButtonVariants, diff --git a/packages/ui/src/components/Dialog/Dialog.variants.ts b/packages/ui/src/components/Dialog/Dialog.variants.ts index f84ffe6..dca739e 100644 --- a/packages/ui/src/components/Dialog/Dialog.variants.ts +++ b/packages/ui/src/components/Dialog/Dialog.variants.ts @@ -82,11 +82,15 @@ export const dialogFooterVariants = cva( ) export const dialogCloseVariants = cva([ + // Same geometry as a Button `icon` at `md`: 32px square, `rounded-md`, and the + // shared focus recipe (border + translucent ring). A borderless opaque ring + // in the brand colour read as a blue square around the X. 'absolute right-3 top-3 inline-flex size-8 shrink-0 cursor-pointer', - 'items-center justify-center rounded-xs text-muted-foreground opacity-70 hover:opacity-100', + 'items-center justify-center rounded-md border border-transparent', + 'text-muted-foreground opacity-70 hover:opacity-100', 'transition-colors duration-fast ease-standard', 'hover:bg-accent hover:text-foreground', - 'outline-none focus-visible:ring-3 focus-visible:ring-ring', + 'outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50', ]) export type DialogVariants = VariantProps diff --git a/packages/ui/src/components/Input/Input.variants.ts b/packages/ui/src/components/Input/Input.variants.ts index 87ac2c8..7c2a59d 100644 --- a/packages/ui/src/components/Input/Input.variants.ts +++ b/packages/ui/src/components/Input/Input.variants.ts @@ -19,9 +19,10 @@ export const inputVariants = cva( { variants: { size: { - sm: 'h-7 rounded-md px-2.5 py-1 text-sm', - md: 'h-8 rounded-md px-2.5 py-1 text-sm', - lg: 'h-9 rounded-md px-2.5 py-1 text-sm', + // No vertical padding — height is locked by `h-*`, same as Button/Select. + sm: 'h-7 rounded-md px-2.5 text-sm', + md: 'h-8 rounded-md px-2.5 text-sm', + lg: 'h-9 rounded-md px-2.5 text-sm', }, invalid: { // The reference design's invalid treatment: the border goes destructive and the focus diff --git a/packages/ui/src/components/Pagination/Pagination.variants.ts b/packages/ui/src/components/Pagination/Pagination.variants.ts index 303103e..bfa865a 100644 --- a/packages/ui/src/components/Pagination/Pagination.variants.ts +++ b/packages/ui/src/components/Pagination/Pagination.variants.ts @@ -35,7 +35,7 @@ export const paginationItemVariants = cva( // `h-9` chased Button's height, but a page number is not a button // you press once — a row of them reads as a strip, and 36px squares // make that strip heavier than the table it pages through. - sm: 'h-7 min-w-7 px-2 text-xs', + sm: 'h-7 min-w-7 px-2 text-sm', md: 'h-8 min-w-8 px-2 text-sm', }, /** diff --git a/packages/ui/src/components/Pagination/Pagination.vue b/packages/ui/src/components/Pagination/Pagination.vue index 5b4afd1..efbfc6c 100644 --- a/packages/ui/src/components/Pagination/Pagination.vue +++ b/packages/ui/src/components/Pagination/Pagination.vue @@ -93,7 +93,7 @@ const isDisabled = computed(() => props.disabled || props.total === 0) :label="props.pageSizeLabel" :size="props.size" :disabled="isDisabled" - class="flex-row items-center gap-2" + class="flex-row items-center gap-2 [&>label]:whitespace-nowrap" >
UserStatusUserStatus
ada@example.comada@example.com Active
grace@example.comgrace@example.com Invited
alan@example.comalan@example.com Suspended
UserRoleStatusUserRoleStatus