diff --git a/.changeset/button-shadcn-api.md b/.changeset/button-shadcn-api.md
new file mode 100644
index 0000000..a4fa74a
--- /dev/null
+++ b/.changeset/button-shadcn-api.md
@@ -0,0 +1,10 @@
+---
+'rowkit': minor
+'@rowkit/tokens': patch
+---
+
+**Breaking (Button).** Variants are now `default` | `outline` | `secondary` | `ghost` | `destructive` | `link` — soft-ink solid is the default (omit `variant` or pass `default`). `primary` and `danger` are removed; soft `destructive` replaces solid danger. Size scale is `default` | `xs` | `sm` | `lg` | `icon` | `icon-xs` | `icon-sm` | `icon-lg`; the `icon` boolean prop is gone. Former bordered `secondary` is now `outline`; `secondary` is a muted fill (`surface-active`). Soft ink solid lightened to `oklch(0.26…)`. Link focus stays typographic (ring only). Dialog Cancel convention is `ghost` so soft Delete wins hierarchy.
+
+**ButtonGroup.** New `ButtonGroup` joins related buttons with shared edges (`orientation` horizontal | vertical). Nested groups use a clear gap.
+
+**Tokens.** Default primary is warm espresso graphite (`oklch(0.31 0.038 48)` / `#402a1f`), not near-black. Soft destructive wash in dark mirrors light (coloured label on a quiet red tint). Link focus is underline-only.
diff --git a/.changeset/consistency-pass.md b/.changeset/consistency-pass.md
new file mode 100644
index 0000000..7594ada
--- /dev/null
+++ b/.changeset/consistency-pass.md
@@ -0,0 +1,5 @@
+---
+'rowkit': patch
+---
+
+**Consistency.** Select invalid focus uses `has-[:focus-visible]` (focus is on the inner input). FilterBar chip-remove focus uses the solid ring recipe. FilterBar Clear maps Button size to the bar (`default` at `md`, `sm` at `sm`). Field `size` inherits to nested Input/Select when they omit their own. DataTable gains `emptyReason` for the built-in empty state.
diff --git a/.changeset/docs-home.md b/.changeset/docs-home.md
new file mode 100644
index 0000000..9d77a55
--- /dev/null
+++ b/.changeset/docs-home.md
@@ -0,0 +1,5 @@
+---
+'rowkit': patch
+---
+
+**Docs.** New homepage composition: espresso mark/logo, branded hero, and a live Users FilterBar + DataTable + Pagination preview. README screenshots refreshed (`home.png`, `datatable-page.png`); outdated “twelve components” copy removed.
diff --git a/.changeset/docs-layer-order-and-roadmap.md b/.changeset/docs-layer-order-and-roadmap.md
new file mode 100644
index 0000000..90cf078
--- /dev/null
+++ b/.changeset/docs-layer-order-and-roadmap.md
@@ -0,0 +1,9 @@
+---
+'rowkit': patch
+---
+
+**Docs cascade fix.** Every heading on every docs content page was rendering at body size and weight. Wrapping VitePress's CSS in `@layer vp-theme` left the layer order to first-appearance, and the wrapped CSS lands at the top of the bundle — so `vp-theme` sorted _below_ Tailwind's `base`, and preflight's `h1`–`h6` reset (`font-size: inherit`) beat every VitePress heading rule, since a layer beats specificity. The order statement now rides on the wrapped CSS itself (`@layer theme, base, vp-theme, components, utilities`), which puts `vp-theme` above `base` so headings survive and below `utilities` so live demos still win. `docs-styles.test.ts` now asserts both bounds instead of only the lower one.
+
+**Docs homepage.** The bulk-actions bar moved below the table. Above it, every checkbox tick inserted or removed a band and shoved the table under the cursor — the row you were aiming at moved because you selected the one before it. The demo roster grew to 80 people so the money shot pages through real data — ten rows a page across seven pages, instead of a single page of six — and narrowing a filter now returns to page 1.
+
+**`NEXT.md` is now `ROADMAP.md`**, rewritten as a plan of record: current state, what 1.0 actually requires, and what stays out of scope. The docs page moves from `/next` to `/roadmap`.
diff --git a/.changeset/espresso-deeper.md b/.changeset/espresso-deeper.md
new file mode 100644
index 0000000..8376a43
--- /dev/null
+++ b/.changeset/espresso-deeper.md
@@ -0,0 +1,5 @@
+---
+'@rowkit/tokens': patch
+---
+
+**Tokens.** Default espresso primary nudged darker: `oklch(0.31 0.038 48)` / `#402a1f` (was `0.33` / `#462f24`).
diff --git a/.changeset/safari-docs-cascade.md b/.changeset/safari-docs-cascade.md
new file mode 100644
index 0000000..372a6cf
--- /dev/null
+++ b/.changeset/safari-docs-cascade.md
@@ -0,0 +1,5 @@
+---
+'rowkit': patch
+---
+
+**Safari / docs demos.** VitePress theme CSS is wrapped in `@layer vp-theme` so Tailwind utilities beat its form/table reset without `all: revert-layer` (broken in Safari). DemoBox isolates markdown-table chrome on `.rk-demo`. DataTable keeps `h-*` on cells (`min-height` is ignored for `table-cell`). Input/Select use `leading-normal` for Safari text centering.
diff --git a/.changeset/select-check-quiet-borders.md b/.changeset/select-check-quiet-borders.md
new file mode 100644
index 0000000..53fc3f2
--- /dev/null
+++ b/.changeset/select-check-quiet-borders.md
@@ -0,0 +1,6 @@
+---
+'rowkit': patch
+'@rowkit/tokens': patch
+---
+
+Select shows a trailing checkmark on the selected option (shadcn-style) instead of a left indicator with a selected fill. Secondary buttons use a quiet `border-input` outline on a card surface. Dialog footers keep a hairline divider with Cancel as secondary. Resting control borders (`input`) are quieter. Focus is soft silver (`ring` → `gray-708` light / soft white dark) — border + translucent outer ring, not an ink halo.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 1f8d4e3..9acd0a0 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -14,9 +14,9 @@ body:
attributes:
label: Scope check
options:
- - label: I have read [Considered, not planned](https://github.com/NikolaiKushner/rowkit/blob/main/ROADMAP.md#considered-not-planned) and this is not on that list.
+ - label: I have read [what's next / out of scope](https://github.com/NikolaiKushner/rowkit/blob/main/NEXT.md) and this is not on that list.
required: true
- - label: I understand rowkit is twelve components by design, and that a new component is unlikely to be accepted.
+ - label: I have described the problem first — a new component is welcome when it earns a place on a data-dense surface.
required: true
- type: textarea
diff --git a/.gitignore b/.gitignore
index 250338d..d8be247 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,7 @@ storybook-static/
npm-debug.log*
pnpm-debug.log*
plan.md
+.visual-check/
# VitePress build output and cache
docs/.vitepress/dist
diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db
new file mode 100644
index 0000000..7f9770b
Binary files /dev/null and b/.pnpm-store/v11/index.db differ
diff --git a/.storybook/main.ts b/.storybook/main.ts
index c2d543d..7381a4a 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -9,6 +9,18 @@ const config: StorybookConfig = {
name: '@storybook/vue3-vite',
options: {},
},
+ // Brand assets + fonts for the manager chrome (preview loads fonts via CSS).
+ staticDirs: [
+ '../docs/public',
+ {
+ from: '../node_modules/@fontsource-variable/geist',
+ to: '/fontsource/geist',
+ },
+ {
+ from: '../node_modules/@fontsource-variable/geist-mono',
+ to: '/fontsource/geist-mono',
+ },
+ ],
core: { disableTelemetry: true },
/**
* Two plugins the preview cannot build without.
diff --git a/.storybook/manager-head.html b/.storybook/manager-head.html
new file mode 100644
index 0000000..3c15871
--- /dev/null
+++ b/.storybook/manager-head.html
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/.storybook/manager.ts b/.storybook/manager.ts
new file mode 100644
index 0000000..f76147a
--- /dev/null
+++ b/.storybook/manager.ts
@@ -0,0 +1,9 @@
+import { addons } from 'storybook/manager-api'
+import theme from './theme'
+
+addons.setConfig({
+ theme,
+ sidebar: {
+ showRoots: true,
+ },
+})
diff --git a/.storybook/preview.css b/.storybook/preview.css
index 6dc5a57..7f6e165 100644
--- a/.storybook/preview.css
+++ b/.storybook/preview.css
@@ -5,7 +5,14 @@
* @source is needed because the stories and components are read from source
* here rather than from the built bundle that dist/styles.css points at.
*/
+@import '@fontsource-variable/geist/wght.css';
+@import '@fontsource-variable/geist-mono/wght.css';
@import 'tailwindcss';
@import '@rowkit/tokens/css';
@source '../packages/ui/src';
+
+html,
+body {
+ font-family: var(--font-sans);
+}
diff --git a/.storybook/preview.ts b/.storybook/preview.ts
index 48764c1..f3ea1a0 100644
--- a/.storybook/preview.ts
+++ b/.storybook/preview.ts
@@ -1,4 +1,5 @@
import type { Preview } from '@storybook/vue3-vite'
+import theme from './theme'
import './preview.css'
/**
@@ -9,6 +10,12 @@ import './preview.css'
const preview: Preview = {
parameters: {
controls: { matchers: { color: /(background|color)$/i, date: /Date$/i } },
+ docs: { theme },
+ options: {
+ storySort: {
+ order: ['Patterns', 'Foundations', 'Data', 'Overlay', '*'],
+ },
+ },
a11y: {
// Fail the story rather than reporting quietly in a panel. Definition of
// done says zero violations, which only means something if it is a gate.
@@ -34,9 +41,14 @@ const preview: Preview = {
(story, context) => {
const theme = context.globals.theme === 'dark' ? 'dark' : 'light'
document.documentElement.classList.toggle('dark', theme === 'dark')
+ // Paint the iframe body too — otherwise dark screenshots show a white
+ // page around a short story root and look broken.
+ document.body.style.background = 'var(--color-background)'
+ document.body.style.margin = '0'
+ document.body.style.minHeight = '100vh'
return {
components: { story },
- template: `
`,
+ template: `
`,
}
},
],
diff --git a/.storybook/theme.ts b/.storybook/theme.ts
new file mode 100644
index 0000000..8aa5f81
--- /dev/null
+++ b/.storybook/theme.ts
@@ -0,0 +1,48 @@
+import { create } from 'storybook/theming'
+
+/**
+ * Storybook manager chrome, tuned to rowkit's tokens.
+ *
+ * Warm espresso primary, quiet neutrals, Geist — the same restraint as the
+ * components. Not a second brand for the workshop.
+ */
+export default create({
+ base: 'light',
+
+ // Warm espresso — primary-800. Clearly brown-graphite, not near-black.
+ colorPrimary: '#402a1f',
+ colorSecondary: '#402a1f',
+
+ // Surfaces — gray-988 page, white card, gray-940 hairline.
+ appBg: '#F7F8FA',
+ appContentBg: '#FFFFFF',
+ appPreviewBg: '#F7F8FA',
+ appBorderColor: '#E4E7EC',
+ appBorderRadius: 6,
+
+ // Type
+ fontBase: '"Geist Variable", Geist, ui-sans-serif, system-ui, sans-serif',
+ fontCode: '"Geist Mono Variable", "Geist Mono", ui-monospace, monospace',
+ textColor: '#1A1D21',
+ textMutedColor: '#6B7280',
+ textInverseColor: '#F7F8FA',
+
+ // Toolbar
+ barBg: '#FFFFFF',
+ barTextColor: '#6B7280',
+ barSelectedColor: '#402a1f',
+ barHoverColor: '#402a1f',
+
+ // Controls
+ inputBg: '#FFFFFF',
+ inputBorder: '#9AA3AD',
+ inputTextColor: '#1A1D21',
+ inputBorderRadius: 6,
+
+ // Brand
+ brandTitle: 'rowkit',
+ brandUrl: 'https://rowkit.dev',
+ // Wordmark lockup — mark + “rowkit”. Served from docs/public via staticDirs.
+ brandImage: '/logo.svg',
+ brandTarget: '_self',
+})
diff --git a/AGENTS.md b/AGENTS.md
index f73a6c2..0cbd5c7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,8 +16,9 @@ Instructions for coding agents working **on** rowkit.
with a public surface. Prop naming, state ownership, event and slot shapes and
the recurring accessibility patterns are decided there, once, for every
component.
-- **[`ROADMAP.md`](./ROADMAP.md)** — before proposing a component. The scope is
- twelve, and that is a decision rather than a stage.
+- **[`ROADMAP.md`](./ROADMAP.md)** — the plan of record: current state, what 1.0
+ requires, what is out of scope. No fixed component count; add surface when it
+ earns its place.
## Commands
@@ -29,6 +30,10 @@ pnpm typecheck # vue-tsc, strict
pnpm format # prettier
pnpm size # bundle budget, brotli
+pnpm storybook # then, in another terminal:
+pnpm visual:check # screenshot default stories, light + dark → .visual-check/
+pnpm visual:check Button # scoped to one component
+
pnpm docs:props # regenerate the props tables after touching a prop or its JSDoc
pnpm docs:agents # regenerate packages/ui/AGENTS.md, likewise
```
@@ -37,6 +42,10 @@ pnpm docs:agents # regenerate packages/ui/AGENTS.md, likewise
the type checker all resolve `rowkit` through `packages/ui/dist`, and an unbuilt
workspace produces a wall of confusing type errors rather than one clear one.
+After any change that touches variants, tokens, layout, or dark mode: run
+`pnpm visual:check`, **Read the PNGs**, and fix what looks wrong before claiming
+done. Styling fails silently — screenshots are how agents catch it.
+
## Three things that are true here and not everywhere
**Backward compatibility matters.** rowkit is a published package with semver and
diff --git a/CLAUDE.md b/CLAUDE.md
index b2eb55d..82efb17 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,7 +6,11 @@ Repository: `github.com/NikolaiKushner/rowkit`
Package: `rowkit` on npm
Docs: `rowkit.dev`
-Read `ROADMAP.md` before proposing new components. The scope is fixed at twelve components for v1.0.
+Read `ROADMAP.md` before proposing work. It is the plan of record: where the
+library stands, what 1.0 requires, and what is deliberately out of scope. New
+surface earns its place — there is no fixed component count.
+
+**Visual direction:** restraint, structure without severity, no excess. Neutral chrome; warm-espresso primary (`oklch(0.31 0.038 48)`), not a chromatic shout. Consumers rebrand via tokens — defaults must not fight them. Geist stays.
Read `docs/conventions.md` before designing a component API. Prop naming, state ownership, event and slot shapes, and the recurring accessibility patterns are decided there, once, for every component.
@@ -14,14 +18,9 @@ Read `docs/conventions.md` before designing a component API. Prop naming, state
## Stack
-- **Vue 3.5+** — Composition API, `
+
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/components/HomePage.vue b/docs/.vitepress/theme/components/HomePage.vue
new file mode 100644
index 0000000..b966003
--- /dev/null
+++ b/docs/.vitepress/theme/components/HomePage.vue
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
+ rowkit
+
+
+
Components for data-dense interfaces
+
+ Vue 3 tables, filters, and the states around them — built on Reka UI, typed
+ against your row.
+
+ Columns typed against your row.
+
+ key is keyof TRow — a renamed field is a compile error, not a
+ column of blanks.
+
+
+
+ Built on Reka UI.
+
+ Focus traps, scroll lock, and keyboard models from the primitives. axe is a build gate,
+ not a panel.
+
+
+
+ Token-first theming.
+
+ Colour, space, radius, and layer — every value is a token. This site is styled from the
+ same package.
+
+
+
+
+
diff --git a/docs/.vitepress/theme/components/home-users.ts b/docs/.vitepress/theme/components/home-users.ts
new file mode 100644
index 0000000..7414c48
--- /dev/null
+++ b/docs/.vitepress/theme/components/home-users.ts
@@ -0,0 +1,154 @@
+/**
+ * Demo roster behind the homepage money shot.
+ *
+ * Big enough to page through — the hero is claiming rowkit is for data-dense
+ * screens, and a six-row table with a single page of results quietly says the
+ * opposite. Several pages at 25 rows is the smallest set that reads as a real
+ * workspace rather than a fixture.
+ *
+ * Generated from a name list rather than written out, because eighty literal
+ * objects is eighty chances for the demo data to drift out of shape, and none
+ * of the fields carry meaning beyond looking plausible. Derivations are pure
+ * index arithmetic so the table is identical on every render and in every
+ * screenshot — a hero that reshuffles itself makes visual diffs useless.
+ */
+
+export interface HomeUser {
+ id: number
+ name: string
+ email: string
+ role: string
+ status: 'active' | 'invited' | 'suspended'
+ seats: number
+ lastActive: string
+}
+
+const NAMES = [
+ 'Ada Lovelace',
+ 'Grace Hopper',
+ 'Alan Turing',
+ 'Barbara Liskov',
+ 'Katherine Johnson',
+ 'Margaret Hamilton',
+ 'Edsger Dijkstra',
+ 'Donald Knuth',
+ 'Frances Allen',
+ 'Ken Thompson',
+ 'Dennis Ritchie',
+ 'Radia Perlman',
+ 'Leslie Lamport',
+ 'Adele Goldberg',
+ 'Alan Kay',
+ 'Jean Bartik',
+ 'Tim Berners-Lee',
+ 'Sophie Wilson',
+ 'Vint Cerf',
+ 'Karen Spärck Jones',
+ 'Niklaus Wirth',
+ 'Shafi Goldwasser',
+ 'John McCarthy',
+ 'Evelyn Boyd Granville',
+ 'Peter Naur',
+ 'Erna Hoover',
+ 'Tony Hoare',
+ 'Mary Kenneth Keller',
+ 'Douglas Engelbart',
+ 'Lynn Conway',
+ 'Bjarne Stroustrup',
+ 'Anita Borg',
+ 'Rasmus Lerdorf',
+ 'Carol Shaw',
+ 'Guido van Rossum',
+ 'Roberta Williams',
+ 'James Gosling',
+ 'Elizabeth Feinler',
+ 'Brendan Eich',
+ 'Susan Kare',
+ 'Linus Torvalds',
+ 'Kathleen Booth',
+ 'Yukihiro Matsumoto',
+ 'Ruzena Bajcsy',
+ 'Brian Kernighan',
+ 'Irene Greif',
+ 'Rich Hickey',
+ 'Barbara Grosz',
+ 'Anders Hejlsberg',
+ 'Éva Tardos',
+ 'John Backus',
+ 'Nancy Lynch',
+ 'Robert Metcalfe',
+ 'Manuela Veloso',
+ 'Butler Lampson',
+ 'Deborah Estrin',
+ 'Fernando Corbató',
+ 'Cynthia Dwork',
+ 'Ivan Sutherland',
+ 'Maria Klawe',
+ 'Whitfield Diffie',
+ 'Jeannette Wing',
+ 'Martin Hellman',
+ 'Dina Katabi',
+ 'Ronald Rivest',
+ 'Susan Landau',
+ 'Adi Shamir',
+ 'Bonnie Berger',
+ 'Michael Stonebraker',
+ 'Andrea Goldsmith',
+ 'Jim Gray',
+ 'Daphne Koller',
+ 'Charles Bachman',
+ 'Kunle Olukotun',
+ 'Jeff Dean',
+ 'Fei-Fei Li',
+ 'Sanjay Ghemawat',
+ 'Regina Barzilay',
+ 'Doug Cutting',
+ 'Latanya Sweeney',
+]
+
+const LAST_ACTIVE = [
+ '2 minutes ago',
+ '20 minutes ago',
+ '1 hour ago',
+ '3 hours ago',
+ 'yesterday',
+ '2 days ago',
+ 'last week',
+ '3 weeks ago',
+]
+
+/** `Ada Lovelace` → `ada.lovelace` — unique per row, since every name is. */
+function slug(name: string): string {
+ return name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[̀-ͯ]/g, '')
+ .replace(/[^a-z]+/g, '.')
+ .replace(/^\.|\.$/g, '')
+}
+
+function roleAt(i: number): string {
+ if (i % 17 === 0) return 'Owner'
+ if (i % 3 === 1) return 'Admin'
+ return 'Member'
+}
+
+function statusAt(i: number): HomeUser['status'] {
+ if (i % 11 === 6) return 'invited'
+ if (i % 19 === 9) return 'suspended'
+ return 'active'
+}
+
+export const homeUsers: HomeUser[] = NAMES.map((name, i) => {
+ const status = statusAt(i)
+ return {
+ id: i + 1,
+ name,
+ email: `${slug(name)}@example.com`,
+ role: roleAt(i),
+ status,
+ // A suspended seat is a released seat; an invite has not claimed one yet.
+ seats: status === 'active' ? ((i * 7) % 23) + 1 : 0,
+ lastActive: status === 'invited' ? 'never' : LAST_ACTIVE[i % LAST_ACTIVE.length],
+ }
+})
diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts
index 8b93f08..86535d5 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 DocsLayout from './components/DocsLayout.vue'
import NpmVersion from './components/NpmVersion.vue'
import TokenGrid from './components/TokenGrid.vue'
import './tokens.css'
@@ -17,6 +18,7 @@ import './tokens.css'
*/
export default {
extends: DefaultTheme,
+ Layout: DocsLayout,
enhanceApp({ app }) {
for (const [name, value] of Object.entries(rowkit)) {
// Every component export is PascalCase; the composables, the `cn` helper
diff --git a/docs/.vitepress/theme/tokens.css b/docs/.vitepress/theme/tokens.css
index b487e91..c9d671d 100644
--- a/docs/.vitepress/theme/tokens.css
+++ b/docs/.vitepress/theme/tokens.css
@@ -8,7 +8,14 @@
*
* Tailwind and rowkit's theme are imported here too, because the live demos on
* component pages are real rowkit components and need real rowkit utilities.
+ *
+ * The `vp-theme` layer order is *not* set here. It rides on the wrapped
+ * VitePress CSS in `config.ts`, which lands above this file in the bundle — an
+ * order statement in this file arrives too late to count. See the note there.
*/
+
+@import '@fontsource-variable/geist/wght.css';
+@import '@fontsource-variable/geist-mono/wght.css';
@import 'tailwindcss';
@import '../../../packages/ui/src/styles/index.css';
@@ -26,99 +33,46 @@
/*
* A table inside a demo is a table, not a block.
*
- * VitePress sets `.vp-doc table { display: block; overflow-x: auto }` so a long
- * markdown table scrolls instead of stretching the page. A block-level table
- * generates an anonymous inner table box that is shrink-to-fit, so `width: 100%`
- * sizes the *block* and leaves the cells at their content width — the table
- * measures full width in JavaScript while rendering with several hundred pixels
- * of empty space to its right.
- *
- * `DataTable` brings its own scroll container, so it neither needs nor wants
- * VitePress's treatment.
- */
-/*
- * Controls inside a demo keep the styling their component gave them.
- *
- * VitePress's default theme ships an **unlayered** reset:
+ * VitePress sets `.vp-doc table { display: block; overflow-x: auto }` and
+ * styles markdown tables (borders, padding, zebra, grey headers). Live demos
+ * sit inside `.vp-doc`, so those rules hit real components unless we isolate.
*
- * button, input, optgroup, select, textarea { border: 0; padding: 0; … }
- * button { background-color: transparent; background-image: none }
- *
- * Tailwind v4 puts every utility in `@layer utilities`, and unlayered CSS beats
- * layered CSS whatever the order or the specificity. So `bg-primary-solid`,
- * `px-4` and `border` lost on every button and input in a demo, while `h-9` and
- * `rounded-md` — properties the reset does not name — came through. The result
- * was a live demo of an invisible button.
- *
- * `all: revert-layer` from an unlayered rule rolls every property back to the
- * layered value underneath — the component's own utility where it set one, and
- * Tailwind's preflight default where it did not.
- *
- * `all`, rather than a list of properties, because a list has to guess which
- * property each utility writes and gets it wrong. Reverting `padding` restores
- * the *physical* longhands, while `px-4` writes `padding-inline`; the unlayered
- * shorthand then beat the layered logical property and the controls came back
- * with correct backgrounds, correct borders and no padding at all.
- *
- * The blunt alternative, `@import 'tailwindcss' important`, makes every utility
- * important and collides with Tailwind's own `container` utility — a class
- * VitePress uses for its layout — and for important declarations layer order is
- * reversed, so a layered `!important` beats an unlayered one and the collision
- * is unwinnable.
+ * VitePress CSS is wrapped in `@layer vp-theme` (see `config.ts`) below
+ * Tailwind utilities — Safari-safe, no `revert-layer`. The block below zeroes
+ * the leftover markdown-table chrome inside `.rk-demo` so dropping a component
+ * into a DemoBox is enough.
*/
-.rk-demo :is(button, input, optgroup, select, textarea) {
- all: revert-layer;
-}
-
.rk-demo table {
display: table;
margin: 0;
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;
-}
+@layer vp-theme {
+ /*
+ * Neutralise `.vp-doc` markdown-table chrome on demo hosts only.
+ *
+ * Do not zero `padding` / `font-*` here. Those belong to the component
+ * utilities; resetting them in this layer is redundant when cascade layers
+ * work, and harmful if a browser ever lets higher-specificity theme rules
+ * leak past utilities (the Safari failure mode that collapsed demos).
+ *
+ * Borders and zebra backgrounds are the ones that still paint *on top of*
+ * component chrome when left alone — clear only those.
+ */
+ .rk-demo :is(th, td) {
+ border: none;
+ background-color: transparent;
+ color: inherit;
+ }
-/*
- * 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;
+ .rk-demo thead tr,
+ .rk-demo tbody tr,
+ .rk-demo tbody tr:nth-child(2n) {
+ background-color: transparent;
+ border-top: none;
+ transition: none;
+ }
}
/*
@@ -157,6 +111,167 @@
}
}
+/*
+ * Custom home composition. Stock VP hero / feature cards are hidden; brand +
+ * live money shot live in HomePage via `home-hero-before`.
+ */
+.VPHome .VPHero,
+.VPHome .VPFeatures {
+ display: none;
+}
+
+.rk-home {
+ --rk-gutter: 24px;
+
+ box-sizing: content-box;
+ max-width: min(1152px, 100% - 2 * var(--rk-gutter));
+ margin-inline: auto;
+ padding: 2.5rem var(--rk-gutter) 1rem;
+}
+
+@media (min-width: 640px) {
+ .rk-home {
+ --rk-gutter: 48px;
+ padding-top: 3.5rem;
+ }
+}
+
+@media (min-width: 960px) {
+ .rk-home {
+ --rk-gutter: 64px;
+ padding-top: 4rem;
+ }
+}
+
+.rk-home__intro {
+ max-width: 40rem;
+ margin-bottom: 2.5rem;
+}
+
+.rk-home__brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin: 0 0 1.5rem;
+}
+
+.rk-home__mark {
+ display: block;
+ width: 3rem;
+ height: 3rem;
+ border-radius: 0.75rem;
+}
+
+.rk-home__wordmark {
+ font-size: 2rem;
+ font-weight: 500;
+ letter-spacing: -0.03em;
+ line-height: 1;
+ color: var(--color-foreground);
+}
+
+.rk-home__headline {
+ margin: 0 0 0.75rem;
+ font-size: clamp(1.75rem, 4vw, 2.5rem);
+ font-weight: 500;
+ letter-spacing: -0.03em;
+ line-height: 1.15;
+ color: var(--color-foreground);
+}
+
+.rk-home__lede {
+ margin: 0 0 1.5rem;
+ font-size: 1.0625rem;
+ line-height: 1.55;
+ color: var(--color-muted-foreground);
+}
+
+.rk-home__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.rk-home__cta {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 2.25rem;
+ padding: 0 1rem;
+ border-radius: var(--radius-md, 0.375rem);
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1;
+ text-decoration: none;
+ transition:
+ background-color 150ms ease,
+ border-color 150ms ease,
+ color 150ms ease;
+}
+
+.rk-home__cta--brand {
+ border: 1px solid var(--color-primary-solid);
+ background: var(--color-primary-solid);
+ color: var(--color-primary-on-solid);
+}
+
+.rk-home__cta--brand:hover {
+ border-color: var(--color-primary-solid-hover);
+ background: var(--color-primary-solid-hover);
+ color: var(--color-primary-on-solid);
+}
+
+.rk-home__cta--alt {
+ border: 1px solid var(--color-input);
+ background: var(--color-card);
+ color: var(--color-foreground);
+}
+
+.rk-home__cta--alt:hover {
+ background: var(--color-accent);
+}
+
+.rk-home__preview {
+ margin-bottom: 2.5rem;
+}
+
+.rk-home__preview .rk-demo {
+ margin: 0;
+}
+
+.rk-home__points {
+ display: grid;
+ gap: 1.25rem;
+ margin: 0 0 1rem;
+ padding: 0;
+ list-style: none;
+}
+
+@media (min-width: 768px) {
+ .rk-home__points {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 1.5rem;
+ }
+}
+
+.rk-home__points li {
+ margin: 0;
+ font-size: 0.9375rem;
+ line-height: 1.5;
+ color: var(--color-muted-foreground);
+}
+
+.rk-home__points strong {
+ display: block;
+ margin-bottom: 0.35rem;
+ font-weight: 500;
+ color: var(--color-foreground);
+}
+
+.rk-home__points code {
+ font-size: 0.8125rem;
+}
+
/*
* A demo's sticky table header stays inside the demo.
*
@@ -178,6 +293,9 @@
}
:root {
+ --vp-font-family-base: var(--font-sans);
+ --vp-font-family-mono: var(--font-mono);
+
--vp-c-brand-1: var(--color-primary-700);
--vp-c-brand-2: var(--color-primary-600);
--vp-c-brand-3: var(--color-primary-500);
diff --git a/docs/agents.md b/docs/agents.md
index 72e2f38..f66381f 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -94,9 +94,8 @@ handling.
**Props**
-- `variant: 'primary' | 'danger' | 'secondary' | 'ghost'` — default `'primary'`. Visual weight and intent.
-- `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.
+- `variant: 'outline' | 'default' | 'secondary' | 'ghost' | 'destructive' | 'link'` — default `'default'`. Visual weight and intent.
+- `size: 'sm' | 'default' | 'xs' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'` — default `'default'`. Control height and text size.
- `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.
@@ -112,6 +111,20 @@ handling.
- `#leading` — Icon before the label. Replaced by the spinner while loading.
- `#trailing` — Icon after the label.
+### ButtonGroup
+
+`import { ButtonGroup } from 'rowkit'`
+
+**Props**
+
+- `orientation: 'horizontal' | 'vertical'` — default `'horizontal'`. Layout axis.
+- `ariaLabel: string`. Accessible name for the group.
+- `class: string`. Additional classes, merged so a consumer's utility wins.
+
+**Slots**
+
+- `#default` — Buttons (or nested groups) to join.
+
### DataTable
`import { DataTable } from 'rowkit'`
@@ -127,6 +140,7 @@ handling.
- `loadingLabel: string` — default `'Loading'`. Announced while loading.
- `emptyTitle: string` — default `'Nothing to show'`. Title for the built-in empty state.
- `emptyDescription: string`. Description for the built-in empty state.
+- `emptyReason: 'no-data' | 'no-results' | 'error'`. Why the built-in empty state is empty.
- `selectable: 'single' | 'multiple'`. Adds a selection column.
- `rowLabel: (row: TRow, index: number) => string`. Accessible name for each row's selection control.
- `selectionLabel: string` — default `'Select'`. Accessible name for the selection column.
@@ -207,7 +221,7 @@ handling.
- `error: string`. Validation message. Its presence is what puts the field into the error state; there is no separate `invalid` flag to keep in sync.
- `required: boolean` — default `false`. Marks the control required and shows the required indicator.
- `disabled: boolean` — default `false`. Disables the control inside.
-- `size: 'sm' | 'md' | 'lg'` — default `'md'`. Sizes the label, hint and error together with the control.
+- `size: 'sm' | 'md' | 'lg'` — default `'md'`. Sizes the label, hint, error and — via field context — the nested control when that control omits its own `size`.
- `id: string`. Id for the control. Generated when omitted — supply one only when something outside the field needs to reference it.
- `labelSrOnly: boolean` — default `false`. Hides the label visually while leaving it available to screen readers. For a search box in a toolbar whose purpose is obvious from context.
- `class: string`. Additional classes, merged so a consumer's utility wins.
@@ -258,7 +272,7 @@ handling.
**Props**
-- `size: 'sm' | 'md' | 'lg'` — default `'md'`. Control height and text size.
+- `size: 'sm' | 'md' | 'lg'`. Control height and text size. Inherited from a surrounding `Field` when omitted.
- `type: 'number' | 'text' | 'email' | 'password' | 'search' | 'tel' | 'url' | 'date'` — default `'text'`. Native input type. Deliberately excludes `checkbox`, `radio` and `file`, which need different markup and a different control.
- `placeholder: string`. Short example of the expected value. Never a substitute for a label.
- `disabled: boolean` — default `false`. Disables the input. A surrounding disabled `Field` also disables it.
@@ -320,7 +334,7 @@ handling.
- `manualFilter: boolean` — default `false`. Hands filtering to the consumer.
- `loading: boolean` — default `false`. Shows a loading row in place of the list. For async options.
- `loadingText: string` — default `'Loading…'`. Text shown while `loading`.
-- `size: 'sm' | 'md' | 'lg'` — default `'md'`. Control height and text size.
+- `size: 'sm' | 'md' | 'lg'`. Control height and text size. Inherited from a surrounding `Field` when omitted.
- `disabled: boolean` — default `false`. Disables the control. A surrounding disabled `Field` also disables it.
- `invalid: boolean` — default `false`. Marks the value invalid. A `Field` with an `error` also sets it.
- `required: boolean` — default `false`. Marks the control required. A required `Field` also sets it.
diff --git a/docs/components/button-group.md b/docs/components/button-group.md
new file mode 100644
index 0000000..a38e16d
--- /dev/null
+++ b/docs/components/button-group.md
@@ -0,0 +1,59 @@
+# ButtonGroup
+
+**Stage:** 🟢 Stable
+
+Joins related buttons into one control — shared edges, outer corners only —
+the Archive / Report / Snooze pattern.
+
+```vue
+
+
+
+
+```
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Nest groups to space separate units. Children keep their own `variant` and
+`size`; the group only merges borders and radii.
+
+## When to use
+
+- Split actions that belong together (Archive + Report, Snooze + overflow).
+- Icon + label pairs that should read as one segment.
+
+## When not to use
+
+- **For mutually exclusive toggles.** That is a toggle group, not a button
+ group — different selection semantics.
+- **To replace spacing.** If buttons should sit apart, use a flex gap, not a
+ group with one child each.
+
+## Props
+
+
+
+| Prop | Type | Default | Description |
+| ------------- | ---------------------------- | -------------- | -------------------------------------------------------- |
+| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Layout axis. |
+| `ariaLabel` | `string` | — | Accessible name for the group. |
+| `class` | `string` | — | Additional classes, merged so a consumer's utility wins. |
+
+
+
+## Accessibility
+
+- The root has `role="group"`. Pass `aria-label` (or `aria-labelledby`) so the
+ joined controls announce as a unit.
+- Tab still visits each button inside the group.
diff --git a/docs/components/button.md b/docs/components/button.md
index 5ad3776..8488ce1 100644
--- a/docs/components/button.md
+++ b/docs/components/button.md
@@ -2,11 +2,12 @@
**Stage:** 🟢 Stable
-The action control. Four variants, three sizes, and a loading state that does
-not move the furniture.
+The action control. Soft-ink default, outline / secondary / ghost chrome,
+soft `destructive`, and `link` — plus a loading state that does not move the
+furniture.
```vue
-
+
```
-
-
+
+
+
-
-
+
+
Press **Save changes**. The button stays focused and stays clickable: loading
@@ -48,42 +50,43 @@ grows by the width of the spinner.
## When to use
- Anything that performs an action: submit, delete, retry, open a dialog.
-- `variant="primary"` for the one action the screen is for. One per view.
-- `variant="secondary"` for the alternatives, `ghost` for tertiary actions in
- toolbars and table rows, `danger` for destructive ones.
+- Default (no `variant`) for the one action the screen is for. One per view.
+- `outline` for bordered alternatives, `secondary` for a muted fill, `ghost`
+ for tertiary actions and dialog Cancel, `destructive` for soft delete-style
+ actions, `link` when the control should read as text.
## When not to use
- **For navigation.** A thing that changes the URL is a link. Use
`