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/components/TokenGrid.vue b/docs/.vitepress/theme/components/TokenGrid.vue
index eef49e4..6b71bcd 100644
--- a/docs/.vitepress/theme/components/TokenGrid.vue
+++ b/docs/.vitepress/theme/components/TokenGrid.vue
@@ -45,14 +45,14 @@ const { copied, copy } = useCopyToken()
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/.vitepress/theme/tokens.css b/docs/.vitepress/theme/tokens.css
index 6dabe77..b487e91 100644
--- a/docs/.vitepress/theme/tokens.css
+++ b/docs/.vitepress/theme/tokens.css
@@ -76,6 +76,51 @@
overflow: visible;
}
+/*
+ * Table cells, for the same reason as the controls above.
+ *
+ * VitePress styles markdown tables as a grid, unlayered:
+ *
+ * .vp-doc th, .vp-doc td { border: 1px solid …; padding: 8px 16px }
+ * .vp-doc th { background: var(--vp-c-bg-soft); color: var(--vp-c-text-2) }
+ *
+ * Every one of those beat the component's layered utilities at once, so a
+ * DataTable demo rendered with vertical rules between every column, the wrong
+ * padding, a grey header band and muted header text — four separate departures
+ * from the real component, none of them visible in its class list.
+ *
+ * `th` and `td` only. Reverting the row would take the hover and selected
+ * backgrounds with it, and those are layered utilities the component wants.
+ */
+.rk-demo :is(th, td) {
+ all: revert-layer;
+}
+
+/*
+ * Body rows, for the third time and the same reason.
+ *
+ * .vp-doc tr { background-color: …; border-top: 1px solid …;
+ * transition: background-color 0.5s }
+ * .vp-doc tr:nth-child(2n) { background-color: var(--vp-c-bg-soft) }
+ *
+ * Zebra striping is right for a markdown table and wrong for a component that
+ * paints its own rows — every other row went grey, and in the loading state the
+ * stripe sat on top of the skeletons and hid them. The row also inherited a
+ * border it already draws on its cells, and a **half-second** background
+ * transition, which is why hover in a demo lagged behind the pointer.
+ *
+ * `revert-layer` restores the layered value rather than removing it, so the
+ * row's own `bg-card`, `hover:` and selected utilities all come back.
+ *
+ * `tbody tr:nth-child(2n)` is 0-2-2 against VitePress's 0-2-1. A plain
+ * `.rk-demo tr` is 0-1-1 and loses to the stripe, which is how this survived
+ * the first pass at the cells.
+ */
+.rk-demo tbody tr,
+.rk-demo tbody tr:nth-child(2n) {
+ all: revert-layer;
+}
+
/*
* The markdown below the hero shares the hero's lines.
*
@@ -139,11 +184,11 @@
--vp-c-brand-soft: var(--color-primary-100);
--vp-c-bg: var(--color-background);
- --vp-c-bg-alt: var(--color-surface-subtle);
- --vp-c-bg-soft: var(--color-surface-subtle);
+ --vp-c-bg-alt: var(--color-muted);
+ --vp-c-bg-soft: var(--color-muted);
- --vp-c-text-1: var(--color-text);
- --vp-c-text-2: var(--color-text-muted);
+ --vp-c-text-1: var(--color-foreground);
+ --vp-c-text-2: var(--color-muted-foreground);
--vp-c-text-3: var(--color-text-subtle);
--vp-c-divider: var(--color-border);
diff --git a/docs/agents.md b/docs/agents.md
index 3ed2535..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.
@@ -95,7 +95,8 @@ handling.
**Props**
- `variant: 'primary' | 'danger' | 'secondary' | 'ghost'` — default `'primary'`. Visual weight and intent.
-- `size: 'sm' | 'md' | 'lg'` — default `'md'`. Control height and text size.
+- `size: 'sm' | 'md' | 'xs' | 'lg'` — default `'md'`. Control height and text size.
+- `icon: boolean` — default `false`. Renders the button square, for a label that is only an icon.
- `block: boolean` — default `false`. Stretches the button to fill its container.
- `loading: boolean` — default `false`. Swaps the leading slot for a spinner and blocks activation.
- `disabled: boolean` — default `false`. Disables the button.
@@ -276,6 +277,35 @@ handling.
- `#leading` — Content rendered before the input, inside the control's border.
- `#trailing` — Content rendered after the input — a unit, a clear button, a spinner.
+### Pagination
+
+`import { Pagination } from 'rowkit'`
+
+**Props**
+
+- `total: number` _(required)_. Total number of rows across all pages.
+- `pageSizeOptions: number[]` — default `() => [10, 25, 50, 100]`. Choices offered in the rows-per-page control.
+- `siblingCount: number` — default `1`. How many page numbers to show on each side of the current one.
+- `showEdges: boolean` — default `true`. Always show the first and last page, with ellipses between.
+- `hidePageSize: boolean` — default `false`. Hides the rows-per-page control.
+- `hideSummary: boolean` — default `false`. Hides the "1–10 of 247" summary.
+- `pageSizeLabel: string` — default `'Rows per page'`. Label for the rows-per-page control.
+- `label: string` — default `'Pagination'`. Accessible name for the navigation region.
+- `previousLabel: string` — default `'Previous page'`. Accessible name for the previous-page control.
+- `nextLabel: string` — default `'Next page'`. Accessible name for the next-page control.
+- `size: 'sm' | 'md'` — default `'md'`. Control height and text size.
+- `disabled: boolean` — default `false`. Disables every control.
+- `class: string`. Additional classes, merged so a consumer's utility wins.
+
+**v-model**
+
+- `v-model:page` — `number`. The current page, 1-based.
+- `v-model:pageSize` — `number`. Rows per page.
+
+**Slots**
+
+- `#summary` `(props: { from: number; to: number; total: number })` — Replaces the range summary.
+
### Select
`import { Select } from 'rowkit'`
@@ -323,35 +353,6 @@ handling.
- `as: string | Component` — default `'div'`. Element or component to render as.
- `asChild: boolean` — default `false`. Merge props onto the single child element instead of rendering a wrapper.
-### TablePagination
-
-`import { TablePagination } from 'rowkit'`
-
-**Props**
-
-- `total: number` _(required)_. Total number of rows across all pages.
-- `pageSizeOptions: number[]` — default `() => [10, 25, 50, 100]`. Choices offered in the rows-per-page control.
-- `siblingCount: number` — default `1`. How many page numbers to show on each side of the current one.
-- `showEdges: boolean` — default `true`. Always show the first and last page, with ellipses between.
-- `hidePageSize: boolean` — default `false`. Hides the rows-per-page control.
-- `hideSummary: boolean` — default `false`. Hides the "1–10 of 247" summary.
-- `pageSizeLabel: string` — default `'Rows per page'`. Label for the rows-per-page control.
-- `label: string` — default `'Pagination'`. Accessible name for the navigation region.
-- `previousLabel: string` — default `'Previous page'`. Accessible name for the previous-page control.
-- `nextLabel: string` — default `'Next page'`. Accessible name for the next-page control.
-- `size: 'sm' | 'md'` — default `'md'`. Control height and text size.
-- `disabled: boolean` — default `false`. Disables every control.
-- `class: string`. Additional classes, merged so a consumer's utility wins.
-
-**v-model**
-
-- `v-model:page` — `number`. The current page, 1-based.
-- `v-model:pageSize` — `number`. Rows per page.
-
-**Slots**
-
-- `#summary` `(props: { from: number; to: number; total: number })` — Replaces the range summary.
-
### Toaster
`import { Toaster } from 'rowkit'`
diff --git a/docs/components/button.md b/docs/components/button.md
index 90cfe5a..5ad3776 100644
--- a/docs/components/button.md
+++ b/docs/components/button.md
@@ -74,7 +74,8 @@ grows by the width of the spinner.
| Prop | Type | Default | Description |
| -------------- | ------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------- |
| `variant` | `'primary' \| 'danger' \| 'secondary' \| 'ghost'` | `'primary'` | Visual weight and intent. |
-| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Control height and text size. |
+| `size` | `'sm' \| 'md' \| 'xs' \| 'lg'` | `'md'` | Control height and text size. |
+| `icon` | `boolean` | `false` | Renders the button square, for a label that is only an icon. |
| `block` | `boolean` | `false` | Stretches the button to fill its container. |
| `loading` | `boolean` | `false` | Swaps the leading slot for a spinner and blocks activation. |
| `disabled` | `boolean` | `false` | Disables the button. |
@@ -121,5 +122,5 @@ pointer and the keyboard path.
- Rendered as something other than `
` — a link, say — `disabled`
becomes `aria-disabled`, because `` has no `disabled` attribute and
setting one does nothing.
-- Focus is a 2px ring offset by 2px, using `--color-focus-ring`. Never remove
+- Focus is a 2px ring offset by 2px, using `--color-ring`. Never remove
it; recolour it if you must.
diff --git a/docs/components/data-table.md b/docs/components/data-table.md
index c55f510..1c318e4 100644
--- a/docs/components/data-table.md
+++ b/docs/components/data-table.md
@@ -381,7 +381,7 @@ browser paints only what is visible. What does grow is initial render — about
640 ms for 10,000 rows against 66 ms for four, and linear in between.
So the threshold is a render-time one: **above ~500 rows, paginate** with
-`TablePagination`. That is the better interaction regardless, since nobody
+`Pagination`. That is the better interaction regardless, since nobody
scrolls ten thousand rows looking for something.
## Dark mode
diff --git a/docs/components/dialog.md b/docs/components/dialog.md
index e6153a7..3511ea1 100644
--- a/docs/components/dialog.md
+++ b/docs/components/dialog.md
@@ -32,7 +32,7 @@ function remove() {
Delete project
Read the terms
- Deleted — and focus is back on the button that opened it.
+ Deleted — and focus is back on the button that opened it.
-
@@ -205,7 +205,7 @@ render, because live regions announce changes rather than initial content.
There is no roving tabstop across the chips. Each remove control is a button in
document order, so Tab reaches every one of them — the same reasoning
-as `TablePagination`'s page numbers.
+as `Pagination`'s page numbers.
## Accessibility
diff --git a/docs/components/table-pagination.md b/docs/components/pagination.md
similarity index 95%
rename from docs/components/table-pagination.md
rename to docs/components/pagination.md
index 890f6ee..0ef2985 100644
--- a/docs/components/table-pagination.md
+++ b/docs/components/pagination.md
@@ -1,4 +1,4 @@
-# TablePagination
+# Pagination
**Stage:** 🟢 Stable
@@ -6,7 +6,7 @@ Page controls for a table: a range summary, a rows-per-page control, and page
numbers. Built on Reka UI's `Pagination` primitive.
```vue
-
+
```
-
-
+
page {{ page }} · {{ pageSize }} per page
@@ -63,7 +63,7 @@ you meant.
## Props
-
+
| Prop | Type | Default | Description |
| ----------------- | -------------- | ------------------------- | -------------------------------------------------------------- |
@@ -146,8 +146,8 @@ page.** Pagination above and below a long table is a normal layout, and two
entries and cannot tell them apart.
```vue
-
-
+
+
```
**The current page carries `aria-current="page"`**, and is filled rather than
diff --git a/docs/conventions.md b/docs/conventions.md
index d963e9e..610e991 100644
--- a/docs/conventions.md
+++ b/docs/conventions.md
@@ -141,7 +141,7 @@ system. `unknown` with a narrowing predicate is almost always the answer — see
These recur often enough to be conventions rather than per-component decisions.
**Decorative by default, announced on request.** Anything that repeats
-information already present is `aria-hidden`: `Badge`'s dot, `TablePagination`'s
+information already present is `aria-hidden`: `Badge`'s dot, `Pagination`'s
ellipsis, `DataTable`'s sort icon. `Skeleton` is `aria-hidden` unconditionally
and has no say in the matter — a loading table renders dozens of them.
diff --git a/docs/decisions/004-datatable-performance.md b/docs/decisions/004-datatable-performance.md
index c35f6ee..79ab3b5 100644
--- a/docs/decisions/004-datatable-performance.md
+++ b/docs/decisions/004-datatable-performance.md
@@ -59,7 +59,7 @@ and says so, so neither cost hides behind the other.
- Renders 10k rows correctly — no crash, scrolling unaffected
- Keeps per-row cost minimal: no per-cell component wrappers, no per-cell
computed, one ` ` per cell
-- Documents the threshold: **above ~500 rows, paginate** — `TablePagination`
+- Documents the threshold: **above ~500 rows, paginate** — `Pagination`
wiring is in `docs/components/data-table.md`
- Keeps `Data/DataTable/Ten Thousand Rows` as a living benchmark, with its axe
scan deliberately off (walking 60,000 nodes takes minutes and asserts nothing
diff --git a/docs/foundations/tokens.md b/docs/foundations/tokens.md
index ef17967..3a0633b 100644
--- a/docs/foundations/tokens.md
+++ b/docs/foundations/tokens.md
@@ -28,8 +28,8 @@ tokens.color.primary[600] // 'oklch(0.546 0.209 259)'
## Two layers, and why it matters
**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
+means nothing on its own. **Semantic** tokens name a role — `--color-card`,
+`--color-muted-foreground`, `--color-border` — and point at a primitive through
`var()`.
Components only ever reference the semantic layer. That is what makes dark mode
@@ -93,7 +93,7 @@ than one table.
@@ -111,7 +111,7 @@ headroom left to darken — so the raised surface colour does that work.
@@ -148,18 +148,18 @@ absent entirely for anyone who has asked for reduced motion.
## Using them
Through Tailwind, which is the normal path — every token is a theme value, so
-`bg-surface`, `text-text-muted`, `p-4`, `rounded-md` and `shadow-lg` all resolve
+`bg-card`, `text-muted-foreground`, `p-4`, `rounded-md` and `shadow-lg` all resolve
to the tokens above:
```vue
-…
+…
```
Or directly, 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);
}
```
diff --git a/docs/index.md b/docs/index.md
index 3a79521..7c01b8e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -84,7 +84,7 @@ Sort a column. Select some rows. This is the real component, not a screenshot.
{{ row.status }}
-
+
{{ selected.length }} selected ·
{{ sort ? `sorted by ${sort.key}, ${sort.direction}` : 'unsorted' }}
@@ -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/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/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/patterns/data-table-page.md b/docs/patterns/data-table-page.md
index b29e0b2..2a5348b 100644
--- a/docs/patterns/data-table-page.md
+++ b/docs/patterns/data-table-page.md
@@ -125,7 +125,7 @@ watch([search, role, status, sort, pageSize], () => {
-
+
Search for a name, narrow by role, sort a column, page through. Then filter down
@@ -137,7 +137,7 @@ that you have no users.
```vue
-```
-
-> **`undefined`, not `null`, for unsorted.** An absent Vue prop is already `undefined`, so `DataTableSort | null` gives three states for a two-state concept. Clearing the sort emits `update:sort` with `undefined`.
-
-Decisions embedded here, each worth stating in the PR:
-
-- **`TRow extends { id: string | number }`.** Requiring a stable id makes `:key` correct and selection unambiguous. The alternative — a `rowKey` prop — is more flexible and much worse: it makes the common case verbose to serve a rare one.
-
- **Ids must be stable across renders**: present in the data, or assigned once at fetch or ingest time — never derived in a computed. An id minted by a `.map()` inside a computed produces a fresh object for every row on every change, which defeats reference equality: `:key` churns and the `v-memo` in §4.6 never hits, because it compares `row` by reference. An earlier draft offered exactly that `.map()` as the escape hatch. It is the one thing not to do.
-
-- **`key: keyof TRow & string`** is the payoff line. `columns: [{ key: 'emial' }]` fails compilation. This single constraint is what "typed column defs" means, and it's your best demo material.
-- **Selection is `Array`, not `TRow[]`.** Ids survive refetches; object references don't. A refetch replacing row objects would silently orphan an object-based selection.
-- **Sorting is controlled and means "request", not "behavior".** The table emits what the user asked for; the consumer sorts (or forwards it to an API). The table never sorts data itself — this keeps server-side and client-side workflows identical from the table's point of view. Ship a `useClientSort(rows, sort, columns?)` composable for the client-side case so convenience isn't lost; logic in a composable rather than baked into the component is the pattern that keeps table components maintainable.
-
- > Built and shipped. An interim version put client sorting **inside** the component behind a `sortMode` prop; it was removed in favour of the composable, which is testable without mounting and cannot be reached by a server-paged table by accident.
-
-### 4.2 Slots — typed cell rendering
-
-```ts
-defineSlots<
- {
- /** Per-column cell override. Slot name = column key. */
- [K in keyof TRow & string as `cell:${K}`]?: (props: { value: TRow[K]; row: TRow }) => unknown
- } & {
- empty?: () => unknown
- loading?: () => unknown
- }
->()
-```
-
-Usage: `#cell:status="{ value, row }"` — with `value` typed as that column's actual type.
-
-> ⚠️ **Known tooling caveat:** template-literal slot names combined with generics sit at the edge of what `vue-tsc` handles — there are open language-tools issues around exactly this pattern. Verify early in session (a) that autocomplete and type errors actually work in the playground. If the DX is broken in practice, fall back to a single `#cell="{ column, value, row }"` slot with a `column` discriminator: slightly weaker types, reliable tooling. **Working DX beats impressive types.**
->
-> Whichever way it lands, the investigation is written up in `docs/decisions/003-cell-slot-typing.md` — what was tried, what `vue-tsc` did, and which way it went. Not in the PR description: a decision that lives only in a merged PR is a decision nobody will find in six months. That write-up is also your best LinkedIn post of the project.
-
-### 4.3 Composition with the earlier components
-
-- **Loading:** `loading=true` renders skeleton rows _matching the column layout_ (real column widths, one Skeleton per cell) — not a spinner replacing the table. Layout stability is the whole point of skeletons. The table gets `aria-busy="true"`.
-
- **Do not disable the sort buttons while loading.** They stay focusable and take `aria-disabled="true"`, with the handler a no-op. Disabling the control a keyboard user just activated destroys their focus mid-request and throws them to the top of the document — the same failure §5 rightly guards against for chips. Focus must survive a request cycle, and there is an interaction test asserting it does.
-
-- **Empty:** `rows.length === 0 && !loading` renders the `#empty` slot, defaulting to `EmptyState reason="no-data"`. Docs show overriding with `no-results` + a clear-filters action when filters are active.
-- **The states are exclusive and priority-ordered:** loading > empty > data. An explicit internal `state` computed with exactly one value prevents the "skeleton and empty state at once" class of bug. Write a test asserting each state is exclusive.
-
-### 4.4 Accessibility — tables have real semantics
-
-This is where `addon-a11y` won't catch everything; part of the checklist is manual:
-
-- Semantic `/// ` — never divs-as-grid. Screen readers navigate real tables; div grids require re-implementing everything for zero benefit here.
-- **Sortable headers:** the `` carries `aria-sort="ascending" | "descending" | "none"`, and contains a real `` wrapping the label — keyboard operability comes from the button, not a click handler on the `th`.
-- Sort cycle on activation: `asc → desc → undefined`. The third click clears — pin this in an interaction test; it's the spec detail everyone forgets.
-- **Select-all checkbox** in the header, built on **Reka's `Checkbox`**, which takes `'indeterminate'` as a value directly. An earlier draft said to set `indeterminate` as a DOM property rather than an attribute — true of a native ` `, but hard rule 2 requires the Reka primitive where one exists, and that note is struck. Label it "Select all rows".
-- Row checkboxes name the row they select — `"Select Ada Lovelace"`, not `"Select row 3"`. A column of positional labels is close to useless read out of context, so the labeller is a prop.
-- `row:click` must not fight selection: clicking the checkbox cell doesn't fire `row:click`. Test this — it's the annoying-in-production bug.
-- **`row:click` needs a keyboard path.** A row carrying a `row:click` listener gets `tabindex="0"` and activates on Enter and Space ; without a listener it stays out of the tab order entirely. The docs state the rule that goes with it: **a clickable row is an enhancement, never the only path.** Whatever the row click does must also exist as an explicit control — a link or a button in the row — because a pointer-only affordance is unreachable for anyone not using a pointer.
-
-### 4.5 Sticky header
-
-`position: sticky` on `thead th` inside a scroll container — with two known traps handled deliberately:
-
-- Backgrounds: sticky headers need an opaque background token, or rows show through on scroll
-- `overflow-x: auto` on a wrapper for horizontal scroll, with a scroll-shadow affordance (a gradient signalling "more columns this way") driven by a small scroll listener
-
-### 4.6 Performance — the 10,000-row question
-
-The plan requires 10k rows without jank, and demands the approach be _documented_. The honest engineering answer:
-
-**Don't virtualize in v1. Say so.**
-
-Reasons, which go verbatim into the docs:
-
-1. Virtualization conflicts with semantic `` markup (row heights, sticky interplay, screen-reader row counts) — every virtualized table makes real a11y trade-offs
-2. The realistic dataset for this component's audience is paginated at 25–100 rows; 10k unpaginated rows is an anti-pattern the library shouldn't optimize for at the cost of a11y
-3. It's additive later (a `virtual` prop) without breaking API
-
-What v1 _does_ instead:
-
-- Renders 10k rows _correctly_ — no crash
-- Keeps per-row cost minimal: no per-cell component wrappers, no per-cell computed, `v-memo` on rows keyed by `[row, isSelected]` (which requires stable row identity — see §4.1)
-- Documents: "above ~500 rows, paginate — here's the TablePagination wiring"
-- A Storybook story with 10k rows exists as the living benchmark
-
-**The benchmark measures rendering, not sorting, and says so.** The 10k story is fed **pre-sorted** data and its description states plainly what it does and does not cover. An earlier draft claimed "sort requests still instant (the table doesn't sort)" as a property of the 10k case — true server-side, false the moment `useClientSort` is in play, where sorting 10k rows happens on the main thread. Measuring the table with the sort already done would have quietly benchmarked the easy path.
-
-`useClientSort` carries its **own** timing note, recorded in its tests rather than in the story, so the two costs stay separate and neither hides behind the other.
-
-Measure once with Chrome DevTools on the 10k story and record the numbers (initial render ms, scroll fps) in `docs/decisions/004-datatable-performance.md`. "Deliberately not virtualized, here's why, here are the numbers" reads as _more_ senior than a virtualization checkbox — it's a documented judgment call.
-
-### Done when
-
-Every item from the standard component DoD, plus: the typed-slot decision written up in `docs/decisions/003-cell-slot-typing.md`; the exclusive-state test; the sort-cycle test; the checkbox-vs-row-click test; the row-click keyboard-path test; the focus-survives-loading test; the 10k story with measured numbers in `docs/decisions/004-datatable-performance.md`.
-
----
-
-## 5. FilterBar (~3h)
-
-Deliberately the _thinnest_ component in the phase — the temptation is to build a filter engine; the job is to display applied filters.
-
-### API
-
-```ts
-export interface FilterChip {
- /** Stable identifier, e.g. the field name. */
- id: string
- /** The field being filtered — "Role", "Status". */
- label: string
- /** The applied value — "Admin", "Active". Omit and the chip shows the label alone. */
- value?: string
- /** Defaults to true. False for a scope the user is not permitted to clear. */
- removable?: boolean
-}
-
-interface FilterBarProps {
- /** Currently applied filters. */
- filters?: FilterChip[]
- /** Matching rows. Announced politely when it changes. */
- resultCount?: number
- /** Shows a built-in search box, bound with `v-model:search`. @default true */
- searchable?: boolean
-}
-
-defineEmits<{
- /** A single chip's remove was activated. */
- remove: [id: string]
- /** Clear-all was activated. */
- clear: []
-}>()
-```
-
-Slots: `#controls` (where the consumer puts their actual filter controls — selects, date pickers), `#actions` (trailing actions), `#chip="{ filter }"` (custom chip rendering), `#summary` (replaces the result count).
-
-> **Two shipped deviations from the API above, both still open for a decision.**
->
-> The chip splits `label` and `value` and formats "Role: Admin" itself, where this spec has the consumer pre-format one `label` string. The split is what lets the remove control be named after the filter — "Remove Role: Admin filter" — without the consumer having to build that string too.
->
-> The bar also ships a **built-in search box** and a `resultCount` live region, where this spec puts search in `#controls` and has no count. Both earn their place — the count is the only feedback a screen reader user gets that filtering did anything — but they are outside the scope line this section draws, and `hideClearAll` was not built. If the thin-component argument wins, these come out.
-
-> Renamed from `#leading`. The slot names a **role**, not a position — "your filter controls" — and `#leading` reads as "before the chips", which is where it happens to sit rather than what it is for.
-
-### The scope line, drawn explicitly
-
-FilterBar does **not** know what a filter _is_ — no operators, no field types, no filter-building UI. That's an application concern with unbounded surface area, and it's precisely where table libraries go to die. rowkit's FilterBar is: chips in, remove/clear events out, a slot for your controls. The docs "when not to use" section says this in the first sentence.
-
-### Details
-
-- Chips are buttons with `aria-label="Remove filter, {label}"` — a **comma, not a colon**. `label` is already a formatted `"Status: Active"`, so a colon here announces "Remove filter: Status: Active"
-- Removal returns focus to a sensible place (next chip, else clear-all, else the bar) — focus loss on removal is the standard failure here
-- Empty `filters` renders the `#controls` slot only; the bar doesn't reserve ghost space
-- Canonical docs example: FilterBar + DataTable + EmptyState `no-results` wired together — this one example is the pattern page for the whole phase
-
----
-
-## Cross-cutting practices for the phase
-
-**Controlled state, everywhere.** Sort, selection, page, pageSize, filters — all owned by the consumer, all `v-model`. The components render state and request changes. This is the single most important architectural stance in the phase: it's what makes server-driven and client-driven usage identical, and it's the documented pattern of every serious table library.
-
-**Logic in composables, rendering in components.** `useClientSort` ships in this phase; selection helpers too if they grow. Composables are testable without mounting and reusable without the component.
-
-**Every stateful behavior gets an interaction test, not just a render test.** Sort cycling, selection with indeterminate, pagination boundaries, chip-removal focus. Render tests catch markup regressions; interaction tests catch the bugs users actually hit.
-
-**The playground page is a deliverable, not a demo.** The final session ends with the users-admin page: FilterBar + DataTable + TablePagination + EmptyState + Skeleton, against generated data with artificial latency so loading states are visible. This page is what you screen-record for the Upwork portfolio — build it like someone will watch it, because someone will.
-
-**Changeset per component,** as established. Five changesets exit this phase.
-
----
-
-## Session plan
-
-| Session | Scope | Exit |
-| ------- | -------------------------------------------------------------- | ---------------------------------------------------- |
-| 3.1 | Skeleton + EmptyState | Both Stable |
-| 3.2 | TablePagination | Stable |
-| 3.3 | DataTable: types on paper → review → rendering + sorting | Types locked, sorting works, slot-DX verdict reached |
-| 3.4 | DataTable: selection, sticky header, loading/empty composition | Feature-complete |
-| 3.5 | DataTable: 10k story + measurements, docs, polish → Stable | DataTable Stable |
-| 3.6 | FilterBar | FilterBar Stable |
-| 3.7 | Playground users-admin page; screen recording | Phase done |
-
-Session 3.3 starts with _you_ writing the types and the session reviewing them — not the reverse. The type design is the part that's yours.
-
----
-
-## Phase Definition of Done
-
-- [x] All five components at 🟢 Stable per the standard checklist
-- [x] `useClientSort` composable shipped and tested, with its own timing note
-- [x] Typed-slot approach decided, verified against the real component, written up in `docs/decisions/003-cell-slot-typing.md`
-- [x] 10k-row story exists, fed pre-sorted data, with measured numbers in `docs/decisions/004-datatable-performance.md`
-- [x] Virtualization decision documented in DataTable docs
-- [x] Playground users-admin page: filterable, sortable, paginated, loading and empty states, built only from rowkit components
-- [ ] 30-second screen capture recorded and saved — **yours to record; the page is ready**
-- [x] Five changesets
-- [x] Bundle budget still green — 9.6 kB brotli against a 14 kB ceiling, gated in CI
-
----
-
-## Failure modes to watch
-
-**Feature accretion on DataTable.** Column resizing, reordering, pinning, grouping, row expansion — all real features, all out of v1. Each lands in ROADMAP's "Considered, not planned" the moment it occurs to you.
-
-**The generic-types rabbit hole.** If slot typing fights `vue-tsc` for more than ~2 hours, take the fallback and write down why. The library's value is twelve components, not one heroic type signature.
-
-> Resolved, and the reason turned out to be sharper than "tooling". Per-column typed slots need statically-known keys; data-driven columns need a dynamic slot name. Those two requirements are in direct conflict regardless of tooling. See decision 003.
-
-**Building FilterBar's filter engine.** The moment a filter _operator_ appears in FilterBar's props, stop — the scope line is being crossed.
-
-**Skipping the measurements.** "Handles 10k rows" without numbers is a claim; with numbers it's evidence. Measuring takes twenty minutes and produces the most quotable line in the portfolio case study.
diff --git a/docs/phases/phase-4-overlays.md b/docs/phases/phase-4-overlays.md
deleted file mode 100644
index 80bdf76..0000000
--- a/docs/phases/phase-4-overlays.md
+++ /dev/null
@@ -1,293 +0,0 @@
-# Phase 4 — Overlays, in detail
-
-**Components:** `Dialog`, `Toast`, `Tooltip`
-**Estimated effort:** ~10h across 3 sessions
-**Prerequisite:** Phase 3 complete. Tokens include z-index layers and motion durations (Phase 1); Button exists for dialog footers and toast actions.
-
-Overlays are where hand-rolled component libraries quietly fail. Focus management, scroll locking, portal rendering, SSR hydration, touch behavior — each is a minefield with a decade of documented failure modes. This is the phase where "built on Reka UI" earns its keep: the primitives handle the treacherous parts, and rowkit's job is styling, API shape, and the opinions on top.
-
-The rule for the phase: **if you find yourself writing focus-trap logic, stop.** Either the Reka primitive covers it and you missed it, or the design is wrong.
-
----
-
-## Shared infrastructure — settle before the first component
-
-Three cross-cutting concerns touch all three components. Decide them once, first.
-
-### Teleport target and z-index
-
-All overlays render through a portal to `` — never inline, where `overflow: hidden` or `transform` on an ancestor silently breaks positioning.
-
-- Stacking uses the Phase 1 z-index tokens, emitted under Tailwind v4's `--z-index-*` namespace (not `--z-*`, which generates no utility and no error). The shipped order is `base < sticky < dropdown < overlay < modal < popover < toast < tooltip`, spaced by 100.
-
- > **Corrected.** An earlier draft gave the chain as `dropdown < sticky < overlay < toast`. Sticky sits **below** dropdown — a menu opened from a toolbar has to paint over a pinned table header — and the draft omitted `modal`, `popover` and `tooltip` entirely. The three orderings this phase actually leans on: `overlay < modal` (surface above its own backdrop), `modal < popover` (a `Select` inside a `Dialog` must escape upward), and `modal < toast`. `packages/tokens/src/z-index.test.ts` now asserts all of them, so a token tweak cannot break Phase 4 silently.
-
-- No component ever carries a hardcoded `z-index`. If a stacking bug appears, the fix is in the token scale, not a `9999` patch.
-- Multiple dialogs stacking (dialog opens dialog) is _supported by Reka_ but **explicitly discouraged in rowkit docs** — the pattern is almost always a design smell. Document the alternative (sequence, or a single dialog with steps) instead of polishing the anti-pattern.
-
-### SSR safety
-
-Overlays are the components most likely to break under Nuxt SSR, for two reasons: portals don't exist server-side, and anything reading `window` at setup explodes.
-
-- Reka handles deferred teleport mounting, but **verify each component in the Nuxt playground with JS disabled first paint** — hydration mismatch warnings in the console count as failures.
-- **`provideSSRWidth` is not needed, and was checked rather than assumed.** The advice circulates for Reka-based apps; on Reka 2.10 the only viewport read anywhere in the dependency tree is `matchMedia('(pointer:coarse)')` in `utils/registry.js`, already guarded by a `typeof matchMedia === 'function'` check. Adding it would also mean taking `@vueuse/core` as a direct dependency for nothing — it is currently only a transitive dep of Reka. If a future Reka version introduces responsive behaviour that needs it, `docs/installation.md` has the section ready.
-- The Reka API that _is_ SSR-relevant here is **`ConfigProvider`**: `useId` (hydration id stability), `scrollBody` (the scroll-lock behaviour Dialog depends on, and the hook for the layout-shift fix below), and `teleportTo` (a global portal target, which answers the teleport question above for shadow-DOM consumers). Audit these before writing Dialog.
-
-### Motion
-
-- All enter/leave transitions use Phase 1 motion tokens. Nothing animates with a literal `150ms`. The shipped scale is `--transition-duration-{instant,fast,normal,slow}` at 0/120/200/320ms with `--ease-{enter,exit,standard}` — direction lives in the easing, not in a separate in/out duration pair, so "150ms in, 100ms out" maps to `duration-normal ease-enter` and `duration-fast ease-exit`.
-- **The reduced-motion rule is not "no animation".** An _ambient_ loop is gated behind `motion-safe:`. A loop that is the only signal something is happening — a spinner — stays, because gating it removes information rather than motion. Enter/leave transitions on overlays are ambient: they collapse to instant show/hide.
-- `packages/ui/src/styles/motion.test.ts` is the gate. Any ungated `animate-*` in a component fails unless it is in that file's exemption list with a written reason, and the same test asserts `motion-safe:` really compiles to `prefers-reduced-motion: no-preference` — so the mechanism itself cannot rot. Overlays are covered the moment they are written; no per-component decorator to remember.
-
----
-
-## 1. Dialog (~4h)
-
-### API
-
-```ts
-interface DialogProps {
- /** Controlled visibility. v-model:open. */
- open: boolean
- /** Accessible title. Required — a dialog without a name is an a11y failure.
- * Rendered in the header unless the header slot overrides it. */
- title: string
- /** Supporting text under the title. Also wired to aria-describedby. */
- description?: string
- /** Visual width preset. @default 'md' */
- size?: 'sm' | 'md' | 'lg'
- /** Block closing via Escape / overlay click — for destructive-confirm flows.
- * The close button remains. @default false */
- preventClose?: boolean
-}
-
-defineEmits<{
- 'update:open': [open: boolean]
-}>()
-```
-
-Slots: `default` (body), `#header` (replaces title row, title prop still feeds `aria-labelledby`), `#footer` (actions — the docs example is Cancel + primary Button, primary on the right).
-
-### Design decisions worth stating in the PR
-
-- **`title` is a required prop, not just a slot.** A slot-only title makes the accessible name optional in practice, and "optional" means "missing." Requiring the prop guarantees `aria-labelledby` is always wired; the header slot customizes presentation without breaking the contract.
-- **Controlled-only.** `v-model:open`, no internal open state, no `ref` with an `.open()` method. Same doctrine as Phase 3: the consumer owns state. This also makes "close on successful submit" trivial — flip your own ref.
-- **`preventClose` blocks Escape and overlay click but never removes the close button.** A dialog that traps the user with zero exit is hostile; the prop hardens accidental dismissal, not intentional exit.
-- **No `DialogConfirm` convenience component in v1.** Tempting, deferrable, in ROADMAP's "Considered, not planned."
-
-### What Reka provides — verify, don't rebuild
-
-Focus trap while open; focus restore to the trigger on close; `role="dialog"` + `aria-modal`; Escape handling; scroll lock. Your work is checking each in the playground, not implementing them.
-
-One thing Reka's scroll lock needs checked explicitly: **layout shift**. Locking scroll by removing the scrollbar shifts the page ~15px on scrollbar-visible platforms (Windows, Linux, macOS with external mouse). Verify Reka compensates with `scrollbar-gutter` or padding; if it doesn't on your version, the fix is a few lines of CSS on the lock — and a test on a page with a visible scrollbar, because macOS overlay scrollbars will hide the bug from you.
-
-### Accessibility checklist (manual, beyond addon-a11y)
-
-- Focus lands on the dialog (or first focusable) on open; returns to the trigger on close — test with keyboard only
-- Tab and Shift+Tab cycle inside; nothing behind the overlay is reachable
-- Escape closes (unless `preventClose`); overlay click closes (unless `preventClose`)
-- Screen reader announces title and description on open (`aria-labelledby` + `aria-describedby` both wired)
-- Background content is inert — Reka should apply `aria-hidden`/`inert` to siblings; verify in the DOM
-
-### Done when
-
-Standard DoD, plus: keyboard-only walkthrough recorded in the PR description; scroll-lock layout-shift verified on a visible-scrollbar platform; SSR check clean in the Nuxt playground; `preventClose` interaction test.
-
----
-
-## 2. Toast (~4h)
-
-The most architecturally interesting of the three, because it isn't really a component — it's a **service with a component attached**. Consumers don't render a toast; they call one into being from anywhere, including non-component code.
-
-### Architecture: three pieces
-
-```
-useToast() → the API consumers call: toast.success('Saved'), toast.dismiss(id)
-toast state → module-level queue (tiny store, no Pinia dependency)
- → renders the queue; mounted once at app root
-```
-
-This split is the standard shape (Sonner popularized it) and the reasons are practical: calling `toast()` from a Pinia action or an API error handler must work without component context, and rendering must live in one portal so stacking is coherent.
-
-### API
-
-```ts
-interface ToastOptions {
- /** Visual + semantic tone. @default 'neutral' */
- variant?: 'neutral' | 'success' | 'warning' | 'danger'
- /** Auto-dismiss delay in ms. 0 disables — the toast stays until dismissed.
- * @default 5000 */
- duration?: number
- /** Optional action button. */
- action?: { label: string; onClick: () => void }
-}
-
-interface UseToastReturn {
- toast: (message: string, options?: ToastOptions) => string // returns id
- success: (message: string, options?: Omit) => string
- warning: (message: string, options?: Omit) => string
- danger: (message: string, options?: Omit) => string
- dismiss: (id: string) => void
- dismissAll: () => void
-}
-```
-
-` ` props: `position?: 'top-right' | 'top-center' | 'bottom-right' | 'bottom-center'` (default `bottom-right`), `max?: number` (default 3).
-
-### Queue rules — write them down, then test them
-
-Queues without explicit rules accumulate weird behavior. rowkit's rules:
-
-1. At most `max` toasts visible; overflow waits FIFO and enters as slots free
-2. New toasts enter at the position edge; existing ones shift, animated
-3. **Hover pauses the auto-dismiss timer of the hovered toast** — dismissal mid-read is the classic toast failure. Timer resumes on leave
-4. `duration: 0` never auto-dismisses (for danger toasts with actions — an undo that vanishes at its own pace is worse than none)
-5. Duplicate message within ~300ms is coalesced, not stacked — protects against double-fire handlers by default
-
-Each rule is an interaction test. Rule 3's test (hover, advance fake timers past duration, assert still present) is the one that catches real regressions.
-
-### Accessibility — the part most toast libraries get wrong
-
-- The Toaster container is a **polite live region**: `aria-live="polite"`, `role="status"` for neutral/success. Screen readers announce the message without interrupting.
-- **Danger toasts do not automatically become `role="alert"`.** Assertive announcements interrupt whatever the user is doing; reserve that for genuine emergencies. Default everything to polite; document the reasoning.
-- Toasts must not steal focus — ever. The action button is reachable by Tab in DOM order, but nothing moves focus on toast entry.
-- Auto-dismiss + action is a WCAG tension (2.2.1 Timing Adjustable): a user may not reach the action in 5s. Mitigations shipped: hover-pause, generous default, `duration: 0` documented as the recommendation whenever an action is attached.
-
-### SSR note
-
-The toast queue is module-level state → on the server it must be per-request-safe. Simplest correct answer: ` ` is client-only (`` in Nuxt docs example), and `toast()` calls before client mount are queued, not dropped. Test: call `toast()` in `onMounted` of a page component and confirm it renders post-hydration.
-
-### Done when
-
-Standard DoD, plus: the five queue rules each covered by an interaction test; live-region semantics verified with VoiceOver once (note it in the PR); position variants in stories; client-only SSR behavior confirmed in the playground.
-
----
-
-## 3. Tooltip (~2h)
-
-The smallest component and the easiest to over-build. rowkit's tooltip is for **labels, not content**.
-
-### API
-
-```ts
-interface TooltipProps {
- /** Tooltip text. Plain string only — no slot for rich content, by design. */
- content: string
- /** Preferred placement; flips automatically on collision. @default 'top' */
- placement?: 'top' | 'right' | 'bottom' | 'left'
- /** Delay before showing, ms. @default 300 */
- delay?: number
- /** Disable without unwrapping the trigger. @default false */
- disabled?: boolean
-}
-```
-
-Slot: `default` — the trigger element.
-
-### The design opinion: string-only content
-
-No default-slot-for-HTML, no interactive children, no headings inside. If it needs a link or a button, it's a _popover_ — a different component with different focus semantics, and one that's deliberately **not in v1** (ROADMAP, "Considered, not planned"). Rich hover-cards are where tooltip a11y goes to die: an interactive element inside a hover-triggered, focus-less container is unreachable by keyboard by construction.
-
-Enforcing `content: string` in the type system closes the entire failure class. The docs "when not to use" section leads with this and points to Dialog for anything interactive.
-
-### Behavior requirements
-
-- **Opens on hover _and_ on keyboard focus** of the trigger — a hover-only tooltip is invisible to keyboard users. Reka handles this; verify it.
-- `aria-describedby` links trigger → tooltip content (Reka wires it; check the DOM).
-- Dismissible with Escape while visible, without moving focus (WCAG 1.4.13).
-- Hoverable: moving the pointer from trigger onto the tooltip must not dismiss it (also 1.4.13).
-- Delay on first open (`300ms`), **no delay when moving between adjacent tooltipped elements** — Reka's provider grace period covers the "toolbar sweep" case; expose the provider setup in docs for icon-bar consumers.
-- Collision-aware placement: `placement` is a preference, flipping near viewport edges is automatic.
-
-### Touch: the honest answer
-
-Tooltips fundamentally don't work on touch — there is no hover. The spec'd behavior:
-
-- Long-press shows the tooltip (Reka default where supported)
-- **The docs say plainly: never put essential information in a tooltip.** If touch users must know it, it belongs in visible text, an accessible label, or a dialog. A tooltip is progressive enhancement.
-
-That sentence in the docs is the correct engineering answer, and it also reads as someone who has shipped mobile UI.
-
-### The trigger caveat
-
-Tooltips wrapping _disabled_ buttons don't fire — disabled elements emit no pointer/focus events. This is the single most-asked tooltip question in every library's issues. Pre-empt it in docs: the pattern is `` wrapper or `aria-disabled` styling instead of the `disabled` attribute, with a working example. One docs paragraph, dozens of future issues avoided.
-
-### Done when
-
-Standard DoD, plus: focus-open verified by keyboard test; Escape-dismiss test; 1.4.13 hoverable behavior verified manually; disabled-trigger pattern documented with example; touch behavior documented honestly.
-
----
-
-## Cross-cutting for the phase
-
-**Verification over implementation.** The recurring session shape is: wire the Reka primitive, style with tokens, then _audit_ the built-in behavior against the checklist. Finding a gap in Reka's handling is possible (version-dependent) — the response is a minimal patch plus a comment naming the Reka version, so it's removable later, not a parallel implementation.
-
-**Keyboard-only pass on everything.** One full session segment at the end: unplug the mouse (literally), operate every overlay in the playground. Ten minutes, catches what no automated scan does.
-
-**Z-index integration story.** One playground scene with everything at once: dialog open, toast firing over it, tooltip on a dialog button. This is the stacking test and it makes a good secondary portfolio clip.
-
-**Changeset per component.** Three exit the phase.
-
----
-
-## Session plan
-
-| Session | Scope | Exit |
-| ------- | ----------------------------------------------------------------------------------- | ------------- |
-| 4.1 | Shared: z-index/teleport audit, motion decorator, Nuxt SSR plugin docs. Then Dialog | Dialog Stable |
-| 4.2 | Toast: store + useToast + Toaster, queue rules, tests | Toast Stable |
-| 4.3 | Tooltip; keyboard-only pass on all three; stacking scene; SSR sweep in playground | Phase done |
-
----
-
-## Phase Definition of Done
-
-- [x] All three components 🟢 Stable per the standard checklist
-- [x] Zero hydration warnings in the Nuxt playground for all three — measured with Playwright over `/`, `/users` and `/overlays`; console clean on every one
-- [x] Nuxt setup docs updated — `docs/installation.md` covers the `` Toaster and records why the SSR-width plugin is _not_ needed; the disabled-trigger pattern is in `docs/components/tooltip.md`
-- [x] Toast queue rules each covered by a test
-- [ ] Keyboard-only walkthrough — **yours to do literally**; unplug the mouse and work `/overlays`
-- [x] Stacking scene in the playground (`/overlays`)
-- [x] Reduced-motion covered — see the note below
-- [x] Three changesets; bundle budget green at 11.6 kB against 14 kB
-
-### The stacking scene, measured
-
-Toast fired from inside an open dialog, on the built Nuxt output:
-
-| Layer | Computed `z-index` |
-| -------------- | ------------------ |
-| Dialog overlay | 300 |
-| Dialog surface | 400 |
-| Toast viewport | 600 |
-
-Those are the token values, unmodified. More usefully, `elementFromPoint` at the
-centre of the toast returns the toast — it genuinely paints over the dialog,
-which reading `z-index` alone would not prove, since a stacking context anywhere
-up the tree could have trapped it.
-
-### Reduced motion, differently than planned
-
-The spec asked for "one shared story decorator emulating the preference, each
-component with a story under it". Storybook has no way to emulate
-`prefers-reduced-motion`, and a decorator that merely injects
-`animation: none` tests a stylesheet we wrote rather than the media query.
-
-What ships instead is stronger and needs no per-component discipline:
-`packages/ui/src/styles/motion.test.ts` fails on any ungated `animate-*` in any
-component, and separately asserts that `motion-safe:` still compiles to
-`prefers-reduced-motion: no-preference` — so the mechanism itself cannot rot.
-Each overlay also has a unit test walking its rendered classes. New overlays are
-covered the moment they are written.
-
----
-
-## Failure modes to watch
-
-**Rebuilding what Reka provides.** Any focus-management, scroll-lock, or positioning code appearing in rowkit source is a red flag — stop and re-read the primitive's docs.
-
-**Popover creep.** The moment tooltip content wants a slot, or Dialog wants an anchored non-modal variant, that's Popover asking to be born. It goes in "Considered, not planned," not in this phase.
-
-**Toast feature accretion.** Promise-based toasts (`toast.promise(fetchThing())`), progress bars, custom render functions — all real Sonner features, all out of v1. The queue rules and four variants are the product.
-
-**Skipping the visible-scrollbar test.** macOS overlay scrollbars hide the scroll-lock layout shift completely. Test on Windows, or force scrollbars on (System Settings → Appearance → Show scroll bars: Always) before calling Dialog done.
diff --git a/docs/phases/phase-5-docs-site.md b/docs/phases/phase-5-docs-site.md
deleted file mode 100644
index 5bb96e8..0000000
--- a/docs/phases/phase-5-docs-site.md
+++ /dev/null
@@ -1,393 +0,0 @@
-# Phase 5 — Documentation Site, in detail
-
-**Deliverable:** VitePress site live on `rowkit.dev`, Storybook deployed and linked
-**Estimated effort:** ~8h across 2–3 sessions
-**Prerequisite:** Phase 4 complete. Critically: `docs/` already contains conventions, installation, tokens, and twelve component pages — written incrementally since Phase 1. This phase builds the _site_, not the _content_.
-
-That prerequisite is the whole reason this phase is 8 hours instead of 25. If any component page is missing, that's Phase 2/3/4 debt — go pay it first, because writing twelve docs pages in one push produces twelve mediocre pages.
-
-One framing thought before the task list: **for a library nobody has heard of, the docs site _is_ the product.** A client evaluating you on Upwork will spend 90 seconds on rowkit.dev and zero seconds reading source. The site is judged on three questions — what is this, why would I use it, how do I start — and everything below serves those three.
-
----
-
-## Architecture decisions
-
-### VitePress, default theme, customized — not a custom theme
-
-> **Version skew, recorded.** VitePress 1.6.4 bundles its own **Vite 5**, while
-> the library builds on Vite 8. Harmless at runtime — VitePress uses its copy —
-> but the two sets of types cannot share a TypeScript project: pulling
-> `docs/.vitepress/**/*.ts` into the root `tsconfig.json` fails on
-> `http-proxy` server types. The folder has its own `tsconfig.json`, which the
-> lint project service finds, and the root project leaves it out. Same shape as
-> the Storybook 9-versus-Vite-8 finding in Phase 0b: check the heaviest
-> dependency's peer range before writing a version into a spec.
-
-The temptation is a bespoke design (you're a frontend engineer building a design system; of course you want the docs to look designed). Resist it in v1:
-
-- The default theme ships search, sidebar, prev/next, mobile nav, dark mode, and a11y for free — rebuilding those is a week of work that produces a worse version
-- Brand customization goes through CSS variables in `.vitepress/theme/custom.css`, mapped to **rowkit's own tokens**. The site literally consuming `@rowkit/tokens` for its brand colors is a better story than a custom theme: the docs are dogfooding
-- A custom theme is a legitimate v2 project. In v1 it's the highest-effort, lowest-information part of the site
-
-### Live components in the docs: yes, scoped
-
-VitePress renders Vue in markdown, so rowkit components can run live on their own pages. Do it — a component library with static screenshots reads as abandoned — but scope it:
-
-- Register rowkit globally once in `.vitepress/theme/index.ts` (`enhanceApp` → install from the workspace)
-- Each component page gets **one live demo block** at the top: the component in its default state plus 2–3 variants, wrapped in a shared `` container (bordered, padded, dark-mode-aware — build it once, ~30 lines)
-- Interactive playgrounds with editable props are **out of scope** — that's what the linked Storybook is for. Duplicating Storybook inside VitePress is the classic docs-site scope explosion
-
-**SSR trap, and a second one that was not predicted.** The predicted trap is
-real — the Toast demo will need ``. The unpredicted one cost more:
-Vite inlines an `@import` but does **not** run Tailwind, so the site loaded the
-token custom properties, generated no utilities at all, and rendered every demo
-unstyled with nothing in the console. `@tailwindcss/vite` in the VitePress
-`vite.plugins` is the fix, exactly as in `.storybook/main.ts`. A second silent
-one: registering components by testing for `render` skips every
-`
-
+
-
+
0 of 0
@@ -91,11 +91,11 @@ const isDisabled = computed(() => props.disabled || props.total === 0)
-
+
@@ -112,7 +112,7 @@ const isDisabled = computed(() => props.disabled || props.total === 0)
>
props.disabled || props.total === 0)
v-if="item.type === 'page'"
:value="item.value"
:aria-current="item.value === page ? 'page' : undefined"
- :class="tablePaginationItemVariants({ size: props.size, active: item.value === page })"
+ :class="paginationItemVariants({ size: props.size, active: item.value === page })"
>
{{ item.value }}
@@ -143,7 +143,7 @@ const isDisabled = computed(() => props.disabled || props.total === 0)
…
@@ -152,7 +152,7 @@ const isDisabled = computed(() => props.disabled || props.total === 0)
` cannot
* export a type, and a consumer annotating their own wrapper needs one.
*/
-export interface TablePaginationProps {
+export interface PaginationProps {
/** Total number of rows across all pages. */
total: number
/** Choices offered in the rows-per-page control. */
@@ -36,7 +36,7 @@ export interface TablePaginationProps {
/** Accessible name for the next-page control. */
nextLabel?: string
/** Control height and text size. */
- size?: NonNullable
+ size?: NonNullable
/** Disables every control. */
disabled?: boolean
/** Additional classes, merged so a consumer's utility wins. */
diff --git a/packages/ui/src/components/Select/Select.stories.ts b/packages/ui/src/components/Select/Select.stories.ts
index dd8a0fe..8c44643 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: `
-
Value: {{ value ?? 'none' }}
+
Value: {{ value ?? 'none' }}
`,
}),
@@ -241,3 +241,39 @@ export const KeyboardOnly: Story = {
await expect(control).toHaveAttribute('aria-expanded', 'false')
},
}
+
+/**
+ * Tabbing to the trigger paints a visible focus ring.
+ *
+ * `outline-none` removes the browser's own indicator, so the replacement has to
+ * actually render — and it is a `box-shadow`, which nothing in the class list
+ * can confirm. A trigger that lost its ring looks identical at rest and is
+ * unusable by keyboard.
+ */
+export const FocusRingIsVisible: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ const trigger = canvas.getByRole('combobox')
+
+ // The ring paints on the anchor, not on the input that holds focus.
+ const anchor = trigger.parentElement
+ if (!anchor) throw new Error('no anchor around the combobox')
+
+ // `shadow-xs` is already on the anchor at rest, so "has a box-shadow" is
+ // true whether or not the ring works. The only assertion that separates the
+ // two is that focusing *changes* it.
+ const resting = getComputedStyle(anchor).boxShadow
+
+ // Keyboard, not `.focus()` — `:focus-visible` is what the recipe hangs on,
+ // and browsers deliberately withhold it from a pointer click.
+ await userEvent.tab()
+ await expect(trigger).toHaveFocus()
+
+ const focused = getComputedStyle(anchor).boxShadow
+ await expect(focused).not.toBe(resting)
+ await expect(focused).not.toBe('none')
+
+ // The input keeps its own outline suppressed, or two indicators stack.
+ await expect(getComputedStyle(trigger).outlineStyle).toBe('none')
+ },
+}
diff --git a/packages/ui/src/components/Select/Select.variants.ts b/packages/ui/src/components/Select/Select.variants.ts
index c2f50c9..b468e8b 100644
--- a/packages/ui/src/components/Select/Select.variants.ts
+++ b/packages/ui/src/components/Select/Select.variants.ts
@@ -2,23 +2,35 @@ import { cva, type VariantProps } from 'class-variance-authority'
export const selectTriggerVariants = cva(
[
- 'flex w-full items-center justify-between gap-2 border bg-surface text-left text-text',
+ 'flex w-full items-center justify-between gap-2 border bg-transparent text-left text-foreground shadow-xs',
'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',
- 'disabled:cursor-not-allowed disabled:bg-surface-disabled disabled:text-text-disabled',
- 'disabled:hover:bg-surface-disabled',
+ /*
+ * `has-[:focus-visible]`, not `focus-visible`.
+ *
+ * These classes sit on the anchor, which is a wrapper. The element that
+ * actually takes focus is the input inside it — Reka needs a real
+ * `ComboboxInput` to be the focusable combobox, and the anchor is never
+ * focused itself. A plain `focus-visible:` here therefore matched nothing,
+ * ever: the Select had no visible focus indicator at all, while its class
+ * list read exactly like every other control's.
+ *
+ * The input carries `outline-none`, so this is the only indicator; it has
+ * to be on the box the user sees, which is the anchor.
+ */
+ 'outline-none has-[:focus-visible]:border-ring has-[:focus-visible]:ring-3',
+ 'has-[: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-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 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 },
@@ -26,20 +38,20 @@ export const selectTriggerVariants = cva(
)
export const selectContentVariants = cva([
- 'z-dropdown overflow-hidden rounded-md border border-border bg-surface shadow-lg',
+ 'z-dropdown overflow-hidden rounded-md border border-border bg-card shadow-md',
// Matches the trigger so the panel never renders narrower than the control
// that opened it — a list of truncated labels is not a choice.
'w-(--reka-combobox-trigger-width) min-w-40',
])
export const selectItemVariants = cva([
- 'flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm text-text outline-none',
+ 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-foreground outline-none',
// Reka drives highlight through data-highlighted, which follows the keyboard
// as well as the pointer. Styling :hover instead would leave keyboard users
// with no visible cursor.
- 'data-[highlighted]:bg-surface-hover',
+ 'data-[highlighted]:bg-accent',
'data-[state=checked]:bg-surface-selected data-[state=checked]:font-medium',
- 'data-[disabled]:pointer-events-none data-[disabled]:text-text-disabled',
+ 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
])
export type SelectVariants = VariantProps
diff --git a/packages/ui/src/components/Select/Select.vue b/packages/ui/src/components/Select/Select.vue
index f3c7515..e04e907 100644
--- a/packages/ui/src/components/Select/Select.vue
+++ b/packages/ui/src/components/Select/Select.vue
@@ -200,13 +200,13 @@ 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'
)
"
/>
{
-
+
{{ props.loadingText }}
-
+
{{ props.emptyText }}
diff --git a/packages/ui/src/components/Skeleton/Skeleton.stories.ts b/packages/ui/src/components/Skeleton/Skeleton.stories.ts
index bd83ca0..a9c505d 100644
--- a/packages/ui/src/components/Skeleton/Skeleton.stories.ts
+++ b/packages/ui/src/components/Skeleton/Skeleton.stories.ts
@@ -49,15 +49,15 @@ export const Variants: Story = {
template: `
- text
+ text
- circle
+ circle
- rect
+ rect
@@ -87,7 +87,7 @@ export const CardPlaceholder: Story = {
render: () => ({
components: { Skeleton },
template: `
-
+
@@ -110,8 +110,8 @@ export const TablePlaceholder: Story = {
- User
- Role
+ User
+ Role
diff --git a/packages/ui/src/components/Skeleton/Skeleton.test.ts b/packages/ui/src/components/Skeleton/Skeleton.test.ts
index fe674aa..14c36e1 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')
})
+ // The reference design'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..7b7d83b 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: {
+ // The reference design's Skeleton is a single `rounded-md` shape. rowkit keeps the
+ // geometry presets, but the corner is the reference design'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/components/TablePagination/TablePagination.variants.ts b/packages/ui/src/components/TablePagination/TablePagination.variants.ts
deleted file mode 100644
index 7a0002c..0000000
--- a/packages/ui/src/components/TablePagination/TablePagination.variants.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { cva, type VariantProps } from 'class-variance-authority'
-
-export const tablePaginationVariants = cva(
- 'flex flex-wrap items-center justify-between gap-x-4 gap-y-2',
- {
- variants: {
- size: {
- sm: 'text-xs',
- md: 'text-sm',
- },
- },
- defaultVariants: { size: 'md' },
- }
-)
-
-/**
- * Shared by the page numbers and the prev/next controls so they sit on one
- * baseline and share a hit area.
- *
- * The disabled treatment is a `disabled:` variant rather than a branch, for the
- * specificity reason spelled out in `Button.variants.ts`.
- */
-export const tablePaginationItemVariants = cva(
- [
- 'inline-flex shrink-0 items-center justify-center rounded-sm 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-transparent disabled:text-text-disabled',
- ],
- {
- variants: {
- size: {
- sm: 'h-7 min-w-7 px-1.5 text-xs',
- md: 'h-8 min-w-8 px-2 text-sm',
- },
- /**
- * The current page. Filled rather than merely bolder — in a row of
- * numbers, weight alone is not a strong enough signal to find your place.
- */
- active: {
- true: 'border-primary-solid bg-primary-solid text-primary-on-solid',
- false: 'border-transparent bg-transparent text-text hover:bg-surface-hover',
- },
- },
- defaultVariants: { size: 'md', active: false },
- }
-)
-
-export const tablePaginationEllipsisVariants = cva(
- 'inline-flex shrink-0 select-none items-center justify-center text-text-subtle',
- {
- variants: {
- size: {
- sm: 'h-7 min-w-7',
- md: 'h-8 min-w-8',
- },
- },
- defaultVariants: { size: 'md' },
- }
-)
-
-export const tablePaginationSummaryVariants = cva('text-text-muted tabular-nums', {
- variants: {
- size: {
- sm: 'text-xs',
- md: 'text-sm',
- },
- },
- defaultVariants: { size: 'md' },
-})
-
-export type TablePaginationVariants = VariantProps
diff --git a/packages/ui/src/components/TablePagination/index.ts b/packages/ui/src/components/TablePagination/index.ts
deleted file mode 100644
index 8109fd2..0000000
--- a/packages/ui/src/components/TablePagination/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export { default as TablePagination } from './TablePagination.vue'
-export {
- tablePaginationEllipsisVariants,
- tablePaginationItemVariants,
- tablePaginationSummaryVariants,
- tablePaginationVariants,
- type TablePaginationVariants,
-} from './TablePagination.variants'
-export type { TablePaginationProps } from './types'
diff --git a/packages/ui/src/components/Toaster/Toaster.stories.ts b/packages/ui/src/components/Toaster/Toaster.stories.ts
index 5e8c129..079581d 100644
--- a/packages/ui/src/components/Toaster/Toaster.stories.ts
+++ b/packages/ui/src/components/Toaster/Toaster.stories.ts
@@ -144,7 +144,7 @@ export const QueueOverflow: Story = {
>
Fire five at once
-
+
Three show; dismiss one and the next appears.
diff --git a/packages/ui/src/components/Toaster/Toaster.variants.ts b/packages/ui/src/components/Toaster/Toaster.variants.ts
index 3599e12..3ff199c 100644
--- a/packages/ui/src/components/Toaster/Toaster.variants.ts
+++ b/packages/ui/src/components/Toaster/Toaster.variants.ts
@@ -24,7 +24,7 @@ export const toasterViewportVariants = cva(
export const toastVariants = cva(
[
- 'pointer-events-auto flex items-start gap-3 rounded-md border p-4 shadow-lg',
+ 'pointer-events-auto flex items-start gap-3 rounded-lg border p-3 text-sm shadow-lg',
'motion-safe:data-[state=open]:animate-toast-in',
'motion-safe:data-[state=closed]:animate-toast-out',
// Reka drives the swipe with a transform custom property.
@@ -34,7 +34,7 @@ export const toastVariants = cva(
{
variants: {
variant: {
- neutral: 'border-border bg-surface text-text',
+ neutral: 'border-border bg-card text-foreground',
success: 'border-success-border bg-success-subtle text-success-on-subtle',
warning: 'border-warning-border bg-warning-subtle text-warning-on-subtle',
danger: 'border-danger-border bg-danger-subtle text-danger-on-subtle',
@@ -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-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-ring',
])
export type ToasterVariants = VariantProps
diff --git a/packages/ui/src/components/Tooltip/Tooltip.stories.ts b/packages/ui/src/components/Tooltip/Tooltip.stories.ts
index cf070f7..fef247a 100644
--- a/packages/ui/src/components/Tooltip/Tooltip.stories.ts
+++ b/packages/ui/src/components/Tooltip/Tooltip.stories.ts
@@ -147,14 +147,14 @@ export const DisabledTriggerPattern: Story = {
Truly disabled
- ✗ no events, no tooltip
+ ✗ no events, no tooltip
Export
- ✓ aria-disabled, tooltip works
+ ✓ aria-disabled, tooltip works
`,
diff --git a/packages/ui/src/components/Tooltip/Tooltip.variants.ts b/packages/ui/src/components/Tooltip/Tooltip.variants.ts
index f0c985e..2505134 100644
--- a/packages/ui/src/components/Tooltip/Tooltip.variants.ts
+++ b/packages/ui/src/components/Tooltip/Tooltip.variants.ts
@@ -7,10 +7,13 @@ 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.
+ *
+ * Near-black in light mode, near-white in dark — quiet chrome, not the brand
+ * primary. A coloured tooltip reads as a floating button.
*/
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',
+ 'z-tooltip max-w-xs rounded-md px-3 py-1.5',
+ 'bg-foreground text-xs text-balance text-background 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',
diff --git a/packages/ui/src/docs-content.test.ts b/packages/ui/src/docs-content.test.ts
new file mode 100644
index 0000000..865be70
--- /dev/null
+++ b/packages/ui/src/docs-content.test.ts
@@ -0,0 +1,72 @@
+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 when present: dated planning records are not product
+ * docs, and version numbers written there stay historically true. `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([])
+ })
+})
diff --git a/packages/ui/src/docs-styles.test.ts b/packages/ui/src/docs-styles.test.ts
index 8152f3d..6f355b1 100644
--- a/packages/ui/src/docs-styles.test.ts
+++ b/packages/ui/src/docs-styles.test.ts
@@ -25,6 +25,37 @@ describe('docs stylesheet', () => {
).toMatch(/\.rk-demo :is\(button, input[^)]*\)\s*\{\s*all: revert-layer/)
})
+ it("stops VitePress drawing a grid over a demo's table", async () => {
+ /*
+ * `.vp-doc th, .vp-doc td { border: 1px solid …; padding: 8px 16px }` is
+ * unlayered, so it beat every layered utility the component set: vertical
+ * rules between the columns, the wrong padding, a grey header band and
+ * muted header text — four departures at once, none of them visible in the
+ * class list, and only on the docs site.
+ */
+ const css = await readFile(join(repoRoot, 'docs/.vitepress/theme/tokens.css'), 'utf8')
+ expect(css, 'without this every DataTable demo renders as a bordered grid').toMatch(
+ /\.rk-demo :is\(th, td\)\s*\{\s*all: revert-layer/
+ )
+ })
+
+ it("stops VitePress striping a demo's rows", async () => {
+ /*
+ * `.vp-doc tr:nth-child(2n)` paints every other row grey, which is right
+ * for a markdown table and wrong for a component that paints its own rows —
+ * in the loading state the stripe sat on top of the skeletons and hid them.
+ * The same rule adds a border the cells already draw and a half-second
+ * background transition, which made hover lag the pointer.
+ *
+ * The selector has to out-specify `:nth-child(2n)`; a plain `.rk-demo tr`
+ * loses to it, which is how the stripe survived the fix for the cells.
+ */
+ const css = await readFile(join(repoRoot, 'docs/.vitepress/theme/tokens.css'), 'utf8')
+ expect(css, 'without this every other row in a demo is grey').toMatch(
+ /\.rk-demo tbody tr:nth-child\(2n\)/
+ )
+ })
+
it('does not reach for `important` mode', async () => {
/*
* The blunt fix for the same problem, and it backfires: every utility
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 0ed5d72..694c2f4 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -10,7 +10,7 @@ export * from './components/Field'
export * from './components/Input'
export * from './components/Select'
export * from './components/Skeleton'
-export * from './components/TablePagination'
+export * from './components/Pagination'
export * from './components/Toaster'
export * from './components/Tooltip'
diff --git a/packages/ui/src/styles/theme.test.ts b/packages/ui/src/styles/theme.test.ts
index cef7345..648e2f2 100644
--- a/packages/ui/src/styles/theme.test.ts
+++ b/packages/ui/src/styles/theme.test.ts
@@ -54,12 +54,16 @@ async function build(...candidates: string[]): Promise {
const utilities: readonly (readonly [string, string])[] = [
['bg-primary-600', '--color-primary-600'],
['bg-neutral-50', '--color-neutral-50'],
- ['bg-surface', '--color-surface'],
- ['bg-surface-hover', '--color-surface-hover'],
- ['text-text-muted', '--color-text-muted'],
+ ['bg-card', '--color-card'],
+ ['bg-accent', '--color-accent'],
+ ['text-muted-foreground', '--color-muted-foreground'],
['text-danger-on-solid', '--color-danger-on-solid'],
- ['border-border-control', '--color-border-control'],
- ['ring-focus-ring', '--color-focus-ring'],
+ ['border-input', '--color-input'],
+ ['ring-ring', '--color-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'],
@@ -83,7 +88,79 @@ describe('rowkit tokens compile to Tailwind utilities', () => {
})
})
+describe('the focus ring compiles', () => {
+ /*
+ * the reference design 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-ring/50')
+ expect(css).toContain('var(--color-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-ring')).toContain('var(--color-ring)')
+ })
+})
+
+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('draws the sticky header rule as an inset shadow that keeps its token', async () => {
+ // A border cannot do this job: under `border-collapse` it belongs to the
+ // table grid, so a sticky header scrolls away from its own rule. The value
+ // has to survive Tailwind's shadow-colour handling with the var() intact,
+ // or the line renders in the wrong colour under `.dark`.
+ const css = await build('shadow-sticky-header')
+ expect(css, 'shadow-sticky-header generated no rule').toContain('.shadow-sticky-header {')
+ expect(css).toContain('inset')
+ expect(css).toContain('var(--color-border)')
+ })
+
it.each(['shadow-xs', 'shadow-md', 'shadow-scroll-x'])('%s is generated', async (utility) => {
expect(await build(utility)).toContain(`.${utility} {`)
})
@@ -91,7 +168,7 @@ describe('shadows', () => {
it('carries the geometry from the token', async () => {
// Shadows are the one scale Tailwind inlines rather than referencing, so
// the assertion is on the value instead of on a var().
- expect(await build('shadow-scroll-x')).toContain('8px 0 8px -8px')
+ expect(await build('shadow-scroll-x')).toContain('12px 0 16px -8px')
})
it('keeps the shadow colour a variable, so .dark repoints it', async () => {
@@ -110,7 +187,7 @@ describe('shadows', () => {
describe('dark mode', () => {
it('is driven by the .dark class, not the OS setting', async () => {
- const css = await build('dark:bg-surface')
+ const css = await build('dark:bg-card')
expect(css).toContain('.dark')
// Tailwind's stock `dark` variant is prefers-color-scheme. The token
// stylesheet redefines it so an app can offer an explicit theme switch.
@@ -118,9 +195,9 @@ describe('dark mode', () => {
})
it('repoints semantic colours without redefining primitives', async () => {
- const css = await build('bg-surface')
+ const css = await build('bg-card')
const darkBlock = css.slice(css.indexOf('.dark'))
- expect(darkBlock).toContain('--color-surface:')
+ expect(darkBlock).toContain('--color-card:')
expect(darkBlock).not.toContain('--color-neutral-900:')
})
})
diff --git a/packages/ui/src/styles/variants.test.ts b/packages/ui/src/styles/variants.test.ts
index 269b6a4..8a4200d 100644
--- a/packages/ui/src/styles/variants.test.ts
+++ b/packages/ui/src/styles/variants.test.ts
@@ -67,11 +67,11 @@ import {
} from '../components/Toaster/Toaster.variants'
import { tooltipContentVariants } from '../components/Tooltip/Tooltip.variants'
import {
- tablePaginationEllipsisVariants,
- tablePaginationItemVariants,
- tablePaginationSummaryVariants,
- tablePaginationVariants,
-} from '../components/TablePagination/TablePagination.variants'
+ paginationEllipsisVariants,
+ paginationItemVariants,
+ paginationSummaryVariants,
+ paginationVariants,
+} from '../components/Pagination/Pagination.variants'
/**
* Every class a component can emit has to produce CSS.
@@ -98,7 +98,7 @@ async function loadStylesheet(id: string, base: string) {
/** Escapes a class name into the selector Tailwind emits for it. */
function toSelector(className: string): string {
- return `.${className.replace(/[:.[\]()/%!#,'"+*~>^$=]/g, (char) => `\\${char}`)}`
+ return `.${className.replace(/[:.[\]()/%!#,'"+*~>^$=&]/g, (char) => `\\${char}`)}`
}
/** Every combination of a cva config's variant options. */
@@ -145,10 +145,10 @@ const components: readonly (readonly [string, CvaFn])[] = [
['EmptyState title', emptyStateTitleVariants],
['EmptyState description', emptyStateDescriptionVariants],
['EmptyState actions', emptyStateActionsVariants],
- ['TablePagination', tablePaginationVariants],
- ['TablePagination item', tablePaginationItemVariants],
- ['TablePagination ellipsis', tablePaginationEllipsisVariants],
- ['TablePagination summary', tablePaginationSummaryVariants],
+ ['Pagination', paginationVariants],
+ ['Pagination item', paginationItemVariants],
+ ['Pagination ellipsis', paginationEllipsisVariants],
+ ['Pagination summary', paginationSummaryVariants],
['FilterBar', filterBarVariants],
['FilterBar controls', filterBarControlsVariants],
['FilterBar chips', filterBarChipsVariants],
@@ -207,3 +207,31 @@ describe('component classes compile to real utilities', () => {
expect(total).toBeGreaterThan(60)
})
})
+
+describe('the focus ring has something to draw', () => {
+ /*
+ * The reference design'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-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-ring/50'
+ const RECOLOURS_BORDER = 'focus-visible:border-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)
+ })
+})
diff --git a/packages/ui/src/utils/cn.test.ts b/packages/ui/src/utils/cn.test.ts
index 07bd13a..46df39c 100644
--- a/packages/ui/src/utils/cn.test.ts
+++ b/packages/ui/src/utils/cn.test.ts
@@ -17,7 +17,7 @@ describe('cn', () => {
it('keeps utilities that only look similar', () => {
// Font size and text colour share the `text-` prefix but not a group.
- expect(cn('text-sm', 'text-text-muted')).toBe('text-sm text-text-muted')
+ expect(cn('text-sm', 'text-muted-foreground')).toBe('text-sm text-muted-foreground')
})
})
@@ -82,6 +82,6 @@ describe('semantic colour utilities collide within a property', () => {
})
it('does not collide across properties', () => {
- expect(cn('bg-surface', 'text-text')).toBe('bg-surface text-text')
+ expect(cn('bg-card', 'text-foreground')).toBe('bg-card text-foreground')
})
})
diff --git a/packages/ui/src/utils/cn.ts b/packages/ui/src/utils/cn.ts
index 0f09671..82c0859 100644
--- a/packages/ui/src/utils/cn.ts
+++ b/packages/ui/src/utils/cn.ts
@@ -16,7 +16,7 @@ import { extendTailwindMerge } from 'tailwind-merge'
* able to override the component's.
*
* Colour and spacing utilities need no help: `tailwind-merge` groups
- * `bg-*`/`text-*`/`border-*` by shape, so `bg-surface` and `bg-primary-600`
+ * `bg-*`/`text-*`/`border-*` by shape, so `bg-card` and `bg-primary-600`
* already collide correctly.
*
* The scale names are read from the token package rather than listed here, so a
diff --git a/playground/app/app.vue b/playground/app/app.vue
index 8ffbd45..da4d91a 100644
--- a/playground/app/app.vue
+++ b/playground/app/app.vue
@@ -12,31 +12,36 @@ function toggleTheme() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
}
+
+const linkClass =
+ 'rounded-sm px-3 py-1 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground'
+const activeClass = 'bg-card text-foreground shadow-xs'
-
-
+
diff --git a/playground/app/pages/index.vue b/playground/app/pages/index.vue
index b31d834..bf76e80 100644
--- a/playground/app/pages/index.vue
+++ b/playground/app/pages/index.vue
@@ -89,13 +89,13 @@ function reset() {
-
- Email
+
+ Email
{{ form.email }}
- Name
+ Name
{{ form.name || '—' }}
- Role
+ Role
{{ roleLabel }}
- Team
+ Team
{{ teams.find((team) => team.value === form.team)?.label ?? '—' }}
diff --git a/playground/app/pages/overlays.vue b/playground/app/pages/overlays.vue
index 5426987..ea92f3a 100644
--- a/playground/app/pages/overlays.vue
+++ b/playground/app/pages/overlays.vue
@@ -58,14 +58,16 @@ function toastOverDialog() {
- The stacking test
-
+
+ The stacking test
+
+
Open the dialog, then fire a toast from inside it. The toast must sit
above the dialog — a confirmation you cannot read is worse than none. The
select inside the dialog must open above it too.
@@ -80,40 +82,34 @@ function toastOverDialog() {
- Tooltips
-
+
Tooltips
+
Tab through these — every one opens on focus, not hover alone. The last is
aria-disabled rather than disabled, which is why its tooltip works
at all.
- Archive
+ Archive
- Duplicate
+ Duplicate
- Export
+ Export
- Transfer
+ Transfer
- Toast tones
+ Toast tones
-
- Success
-
-
- Warning
-
-
- Delete, with undo
-
+ Success
+ Warning
+ Delete, with undo
Three visible at a time
@@ -136,9 +132,7 @@ function toastOverDialog() {
-
- Fire a toast from in here
-
+
Fire a toast from in here
Cancel
diff --git a/playground/app/pages/users.vue b/playground/app/pages/users.vue
index 8e01607..5525a80 100644
--- a/playground/app/pages/users.vue
+++ b/playground/app/pages/users.vue
@@ -6,7 +6,8 @@ import {
EmptyState,
FilterBar,
Select,
- TablePagination,
+ Pagination,
+ Tooltip,
compareSortable,
type DataTableColumn,
type DataTableSort,
@@ -190,7 +191,7 @@ function clearFilters() {
/**
* Resetting the page is the application's job, not the component's.
*
- * `TablePagination` deliberately never moves the page itself — so narrowing the
+ * `Pagination` deliberately never moves the page itself — so narrowing the
* results, re-sorting, or changing the page size all reset it here. Without
* this the user lands on page 9 of a two-page result and sees nothing.
*/
@@ -212,11 +213,11 @@ const selectedCount = computed(() => selected.value.length)
-