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 @@ + + + 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 ` @@ -131,6 +131,7 @@ row actions, a computed total — uses `id` instead and renders from a slot. | `loadingLabel` | `string` | `'Loading'` | Announced while loading. | | `emptyTitle` | `string` | `'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` | `'Select'` | Accessible name for the selection column. | diff --git a/docs/components/dialog.md b/docs/components/dialog.md index 3511ea1..f63a6c5 100644 --- a/docs/components/dialog.md +++ b/docs/components/dialog.md @@ -11,7 +11,7 @@ shape, the token styling, and the opinions. Everything in the project goes with it. ``` @@ -30,8 +30,8 @@ function remove() { - - + + Deleted — and focus is back on the button that opened it. - + @@ -146,7 +146,7 @@ scrolls as a whole pushes its own Save button off-screen, which is where "where did the button go" comes from. **No `DialogConfirm` convenience wrapper.** Tempting and deferred — it is in -`ROADMAP.md` under "Considered, not planned". +`ROADMAP.md` under "Out of scope" / non-goals. ## Keyboard diff --git a/docs/components/empty-state.md b/docs/components/empty-state.md index 0c90b38..4a5f868 100644 --- a/docs/components/empty-state.md +++ b/docs/components/empty-state.md @@ -45,8 +45,8 @@ const copy = { > diff --git a/docs/components/field.md b/docs/components/field.md index 6f3097d..cff89a9 100644 --- a/docs/components/field.md +++ b/docs/components/field.md @@ -77,7 +77,7 @@ down to whatever control is inside. | `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` | `false` | Marks the control required and shows the required indicator. | | `disabled` | `boolean` | `false` | Disables the control inside. | -| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Sizes the label, hint and error together with the control. | +| `size` | `'sm' \| 'md' \| 'lg'` | `'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` | `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. | @@ -92,7 +92,7 @@ Slots: `default` (the control), `hint`, `error`. | Prop | Type | Default | Description | | ------------- | ------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `size` | `'sm' \| 'md' \| 'lg'` | `'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'` | `'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` | `false` | Disables the input. A surrounding disabled `Field` also disables it. | diff --git a/docs/components/select.md b/docs/components/select.md index 45198fe..badf77d 100644 --- a/docs/components/select.md +++ b/docs/components/select.md @@ -87,23 +87,23 @@ typing filters when searchable, Esc closes without committing. -| Prop | Type | Default | Description | -| -------------- | ---------------------- | ---------------- | ------------------------------------------------------------------------ | -| `options` | `SelectOption[]` | **required** | The available choices. | -| `placeholder` | `string` | `'Select…'` | Text shown in the trigger while nothing is selected. | -| `searchable` | `boolean` | `false` | Adds a search box inside the panel. | -| `togglerLabel` | `string` | `'Show options'` | Accessible name for the open/close chevron. | -| `emptyText` | `string` | `'No results'` | Shown when no option matches the search term. | -| `manualFilter` | `boolean` | `false` | Hands filtering to the consumer. | -| `loading` | `boolean` | `false` | Shows a loading row in place of the list. For async options. | -| `loadingText` | `string` | `'Loading…'` | Text shown while `loading`. | -| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Control height and text size. | -| `disabled` | `boolean` | `false` | Disables the control. A surrounding disabled `Field` also disables it. | -| `invalid` | `boolean` | `false` | Marks the value invalid. A `Field` with an `error` also sets it. | -| `required` | `boolean` | `false` | Marks the control required. A required `Field` also sets it. | -| `id` | `string` | — | Id for the trigger. Inherited from a surrounding `Field` when omitted. | -| `name` | `string` | — | Name submitted with a native form. | -| `class` | `string` | — | Additional classes for the trigger, merged so a consumer's utility wins. | +| Prop | Type | Default | Description | +| -------------- | ---------------------- | ---------------- | -------------------------------------------------------------------------------- | +| `options` | `SelectOption[]` | **required** | The available choices. | +| `placeholder` | `string` | `'Select…'` | Text shown in the trigger while nothing is selected. | +| `searchable` | `boolean` | `false` | Adds a search box inside the panel. | +| `togglerLabel` | `string` | `'Show options'` | Accessible name for the open/close chevron. | +| `emptyText` | `string` | `'No results'` | Shown when no option matches the search term. | +| `manualFilter` | `boolean` | `false` | Hands filtering to the consumer. | +| `loading` | `boolean` | `false` | Shows a loading row in place of the list. For async options. | +| `loadingText` | `string` | `'Loading…'` | Text shown while `loading`. | +| `size` | `'sm' \| 'md' \| 'lg'` | — | Control height and text size. Inherited from a surrounding `Field` when omitted. | +| `disabled` | `boolean` | `false` | Disables the control. A surrounding disabled `Field` also disables it. | +| `invalid` | `boolean` | `false` | Marks the value invalid. A `Field` with an `error` also sets it. | +| `required` | `boolean` | `false` | Marks the control required. A required `Field` also sets it. | +| `id` | `string` | — | Id for the trigger. Inherited from a surrounding `Field` when omitted. | +| `name` | `string` | — | Name submitted with a native form. | +| `class` | `string` | — | Additional classes for the trigger, merged so a consumer's utility wins. | diff --git a/docs/components/toast.md b/docs/components/toast.md index 27edd65..7bcd605 100644 --- a/docs/components/toast.md +++ b/docs/components/toast.md @@ -32,13 +32,13 @@ function retry() { - - + + - + diff --git a/docs/components/tooltip.md b/docs/components/tooltip.md index 0ee98d5..f2fab8b 100644 --- a/docs/components/tooltip.md +++ b/docs/components/tooltip.md @@ -63,8 +63,8 @@ inside one is unreachable by keyboard by construction**. Typing `content` as documentation warning nobody reads. If it needs a link or a button, you want a popover — a different component with -different focus semantics, deliberately **not in v1** (`ROADMAP.md`, "Considered, -not planned"). If it needs a paragraph, put it in the page. +different focus semantics, deliberately **not in v1** (`ROADMAP.md`, "Later"). +If it needs a paragraph, put it in the page. ## When to use diff --git a/docs/conventions.md b/docs/conventions.md index 610e991..aef0211 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -1,8 +1,8 @@ # API conventions -Consistency across twelve components is what makes a library feel designed -rather than assembled. These are decided once, here, and every component follows -them. Where a component deviates, its docs page says so and why. +Consistency across components is what makes a library feel designed rather than +assembled. These are decided once, here, and every component follows them. Where +a component deviates, its docs page says so and why. --- @@ -19,10 +19,12 @@ _opt-out_ rather than inverting the default — `static`, not `animated: true`. boolean per variant makes two of them expressible at once, and that state has no meaning. -**Sizes use one scale across every component**: `sm | md | lg`, `md` the default. -A component may offer a subset — `Badge` and `DataTable` stop at `sm | md`, -because a large table row is not a thing anyone wants — but it never renames the -steps. +**Sizes use one shared scale for form controls**: `sm | md | lg`, with `md` the +default — Field, Input, Select, FilterBar. **Button is the exception:** it +follows the shadcn size set +(`default | xs | sm | lg | icon | icon-xs | icon-sm | icon-lg`), where +`default` matches other components' `md` height (`h-8`). Other components may +offer a subset of the form scale, but they do not invent new step names. **Every component accepts `class` and merges it** through `tailwind-merge`, so a consumer's utility wins over the component's own without a specificity fight. diff --git a/docs/index.md b/docs/index.md index 7c01b8e..6ff96d5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,100 +1,7 @@ --- layout: home - -hero: - name: rowkit - text: Components for data-dense interfaces - tagline: Vue 3 components for tables, filters, and the states around them. Twelve of them, built on Reka UI, typed against your row. - actions: - - theme: brand - text: Get started - link: /installation - - theme: alt - text: Components - link: /components/data-table - - theme: alt - text: GitHub - link: https://github.com/NikolaiKushner/rowkit - -features: - - title: Columns typed against your row - details: > - `key` is constrained to `keyof TRow`, so a renamed field is a compile - error rather than a column of blanks. Sorting names a field too. - - title: Built on Reka UI - details: > - Focus traps, scroll lock, live regions and keyboard models come from the - primitives. Every story is scanned by axe as a build gate, not a panel. - - title: Token-first theming - details: > - Every colour, space, radius and layer is a token. Dark mode overrides the - semantic layer only — this site is styled from the same package. --- - - -## See it work - -Sort a column. Select some rows. This is the real component, not a screenshot. - - - - - -

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

-
- -The table reports the sort and renders what it is handed — it never reorders its -own rows. That keeps a server-paged table honest, and -[`useClientSort`](/components/data-table#sorting) does the local case, which is -what this demo uses. - ## Install ```bash @@ -119,8 +26,7 @@ A general-purpose UI kit. If you need forty components covering every case, [Nuxt UI](https://ui.nuxt.com) and [shadcn-vue](https://www.shadcn-vue.com) are better answers, and rowkit composes with either. -Twelve components, aimed at the part those kits leave you: the fast sortable -table, and the loading, empty and filtered states that have to agree with each -other. The scope is a decision, not a limitation — the full list, including what -was deliberately left out, is in -[the roadmap](https://github.com/NikolaiKushner/rowkit/blob/main/ROADMAP.md). +rowkit aims at the part those kits leave you: the fast sortable table, and the +loading, empty and filtered states that have to agree with each other. The scope +is a decision, not a limitation — what ships next, and what stays out on purpose, +is in [the roadmap](/roadmap). diff --git a/docs/installation.md b/docs/installation.md index 4ea1f12..0e495ad 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -25,6 +25,25 @@ actually renders. Note there is no `.css` on either subpath. The export is `rowkit/styles`. +## Typeface (optional, recommended) + +rowkit's token stack leads with **Geist**. The library does not ship the font +files — pull them once in the app: + +```bash +pnpm add @fontsource-variable/geist @fontsource-variable/geist-mono +``` + +```css +@import '@fontsource-variable/geist/wght.css'; +@import '@fontsource-variable/geist-mono/wght.css'; +@import 'tailwindcss'; +@import 'rowkit/styles'; +``` + +Skip the imports and the stack falls through to system UI fonts. Override +`--font-sans` / `--font-mono` if you want a different face. + ## Dark mode Add the `dark` class to ``. rowkit's tokens key off the class, not @@ -105,7 +124,7 @@ inside the package. rowkit does **not** currently ship a precompiled stylesheet — the distribution model is "the consumer's Tailwind compiles it", chosen deliberately so there is never a second set of Tailwind output fighting yours. A precompiled build is -additive and on the roadmap, not built. Until then: reproduce locally, or add +additive and not built yet. Until then: reproduce locally, or add rowkit's dist to your own `@source` list explicitly. ### `Cannot find module 'rowkit/styles'` diff --git a/docs/introduction.md b/docs/introduction.md index f533756..970b30d 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -1,10 +1,8 @@ # Introduction -rowkit is twelve Vue 3 components for data-dense interfaces: tables, filters, -and the states around them. - -That is the whole library. The number is a decision, not a stage it is passing -through on the way to forty. +rowkit is a Vue 3 component library for data-dense interfaces: tables, filters, +and the states around them. The set grows when a new piece earns its place on +that surface — not toward forty components for their own sake. ## The problem it exists for @@ -73,19 +71,19 @@ that knows whether to validate on blur or on submit. There is also a list of things deliberately left out — date pickers, a command palette, charts — in -[the roadmap](https://github.com/NikolaiKushner/rowkit/blob/main/ROADMAP.md#considered-not-planned). +[the roadmap](https://github.com/NikolaiKushner/rowkit/blob/main/ROADMAP.md). Recording them is how they stay out. ## Choosing between them The honest framing, so you can route yourself correctly: -| Compared on | rowkit | Nuxt UI | shadcn-vue | -| ---------------- | ------------------------------ | --------------------------- | ------------------------------- | -| **Distribution** | Versioned npm package | Versioned npm package | Source copied into your project | -| **Scope** | Twelve components, one problem | Broad, general-purpose | Broad, general-purpose | -| **Data focus** | The reason it exists | One area among many | One area among many | -| **Upgrades** | `semver`, you take the diff | `semver`, you take the diff | Yours to maintain once copied | +| Compared on | rowkit | Nuxt UI | shadcn-vue | +| ---------------- | --------------------------------------- | --------------------------- | ------------------------------- | +| **Distribution** | Versioned npm package | Versioned npm package | Source copied into your project | +| **Scope** | Data-dense surfaces, depth over breadth | Broad, general-purpose | Broad, general-purpose | +| **Data focus** | The reason it exists | One area among many | One area among many | +| **Upgrades** | `semver`, you take the diff | `semver`, you take the diff | Yours to maintain once copied | All three build on Reka UI, so the accessibility foundation is the same in each. The difference is scope and who owns the code after installation. @@ -100,7 +98,7 @@ it is designed to sit beside a general-purpose kit rather than replace it. project's definition of done, and breaking changes are still possible until v1. Version is on npm, published from CI with provenance attestation. The source -and the full roadmap are on [GitHub](https://github.com/NikolaiKushner/rowkit). +and the working backlog 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 2a5348b..5cc5869 100644 --- a/docs/patterns/data-table-page.md +++ b/docs/patterns/data-table-page.md @@ -121,7 +121,7 @@ watch([search, role, status, sort, pageSize], () => { diff --git a/docs/patterns/loading-states.md b/docs/patterns/loading-states.md index 18dab7b..3963806 100644 --- a/docs/patterns/loading-states.md +++ b/docs/patterns/loading-states.md @@ -56,8 +56,8 @@ onUnmounted(() => {
- - + +
diff --git a/docs/public/datatable-page.png b/docs/public/datatable-page.png new file mode 100644 index 0000000..5fe45d4 Binary files /dev/null and b/docs/public/datatable-page.png differ diff --git a/docs/public/home.png b/docs/public/home.png new file mode 100644 index 0000000..cf8430b Binary files /dev/null and b/docs/public/home.png differ diff --git a/docs/public/logo.svg b/docs/public/logo.svg new file mode 100644 index 0000000..4ff1f1e --- /dev/null +++ b/docs/public/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + rowkit + diff --git a/docs/public/mark-light.svg b/docs/public/mark-light.svg new file mode 100644 index 0000000..0e0ad6a --- /dev/null +++ b/docs/public/mark-light.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/public/mark.png b/docs/public/mark.png new file mode 100644 index 0000000..fea08d0 Binary files /dev/null and b/docs/public/mark.png differ diff --git a/docs/public/mark.svg b/docs/public/mark.svg new file mode 100644 index 0000000..52be39d --- /dev/null +++ b/docs/public/mark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/public/rowkit-demo.gif b/docs/public/rowkit-demo.gif deleted file mode 100644 index 7d4dc59..0000000 Binary files a/docs/public/rowkit-demo.gif and /dev/null differ diff --git a/docs/roadmap.md b/docs/roadmap.md index f7fc05e..561d378 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,110 +1,74 @@ # Roadmap -Twelve components for v1.0. That number is a decision rather than a stage on the -way to forty, and this page is the honest version of what exists, what does not, -and what was deliberately left out. - +The plan of record is [`ROADMAP.md`](https://github.com/NikolaiKushner/rowkit/blob/main/ROADMAP.md) in -the repository is the source of truth; this page is the readable one. - -## Stages - -| Stage | Meaning | -| --------------- | ----------------------------------------------------------------------- | -| 🟡 Experimental | Renders, has a story, the API may still change | -| 🟢 Stable | Full API documented, tested, a11y verified, keyboard support, dark mode | -| 🔒 Locked | Stable, and no breaking change without a major version | - -Nothing is Locked before v1.0, because that is what v1.0 means. - -## v1.0 scope +the repository; this page is the readable version. No fixed component count — +new surface ships when it earns a place on a data-dense interface. -**Foundations** +## Current surface -| Component | Stage | -| ---------------------------------- | --------- | -| [Button](/components/button) | 🟢 Stable | -| [Field & Input](/components/field) | 🟢 Stable | -| [Select](/components/select) | 🟢 Stable | -| [Badge](/components/badge) | 🟢 Stable | +| Area | Components | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Foundations | [Button](/components/button), [ButtonGroup](/components/button-group), [Field & Input](/components/field), [Select](/components/select), [Badge](/components/badge) | +| Data | [DataTable](/components/data-table), [Pagination](/components/pagination), [FilterBar](/components/filter-bar), [EmptyState](/components/empty-state), [Skeleton](/components/skeleton) | +| Overlays | [Dialog](/components/dialog), [Toast](/components/toast), [Tooltip](/components/tooltip) | -**Data** +## What 1.0 means -| Component | Stage | -| ------------------------------------- | --------- | -| [DataTable](/components/data-table) | 🟢 Stable | -| [Pagination](/components/pagination) | 🟢 Stable | -| [FilterBar](/components/filter-bar) | 🟢 Stable | -| [EmptyState](/components/empty-state) | 🟢 Stable | -| [Skeleton](/components/skeleton) | 🟢 Stable | +Not a component count — an API that survived contact with applications nobody +wrote in order to use rowkit. Until that has happened, the version stays on +`0.x` and breaking changes remain possible. An API is not proven by its author. -**Overlays** +## Design direction -| Component | Stage | -| ------------------------------ | --------- | -| [Dialog](/components/dialog) | 🟢 Stable | -| [Toast](/components/toast) | 🟢 Stable | -| [Tooltip](/components/tooltip) | 🟢 Stable | +Restraint. Structure without severity. No excess. -All twelve meet the definition of done. What remains before v1.0 is time and -real-world use: an API is not proven by its author. +Chrome stays neutral, and status colour means something while brand colour does +not live in the defaults. Primary is warm espresso graphite +(`oklch(0.31 0.038 48)`); rebrand by pointing `--color-primary-*` at your own +colour. Soft destructive, soft focus, quiet borders. -## Where the project is +## In progress -The library is built, tested, documented, and **published**: `rowkit` and -`@rowkit/tokens` are on npm at , released from CI with provenance. The source -is on [GitHub](https://github.com/NikolaiKushner/rowkit) and this site runs the -real components. +- Shipping the pending release — a breaking `Button` API, espresso tokens, + quieter chrome +- Closing a gap in the screenshot-based visual QA, where two overlay stories + were being captured without the overlay open +- Two consistency calls left over from the restyle: `Badge` `subtle` `primary` + reads as neutral, and the dark-mode invalid field is louder than the library's + soft-destructive language elsewhere +- Hardening the pattern pages from real application friction rather than from + what the components happen to offer -After the first release, the useful work is feedback from people who are not me. -Version 1.0 comes when the API has survived contact with a few real -applications, not when a checklist empties. +## Next surface (if earned) -## Considered, not planned +- DropdownMenu for table row actions — the most likely next primitive +- Popover, when a tooltip needs a link or richer content +- Sheet / drawer, only if dialogs start feeling wrong for filter or detail panes -Reasonable ideas that are explicitly out of scope for v1.0. Recording them is how -they stay out: +## Explicitly out of scope (until demand) - Date picker / date range picker - Rich text editor - Charts — better served by a dedicated library - Command palette - A form validation layer — rowkit provides field states; validation is the - application's job, and [the forms pattern](/patterns/forms) shows the wiring + application's job - Virtualised lists beyond `DataTable` - A Figma kit - A React port -If one of these is what you need, a broader kit or a specialist library is the -better answer, and rowkit composes with both. - ## Non-goals -**Not a general-purpose UI library.** If you need forty components covering every -case, use [Nuxt UI](https://ui.nuxt.com) or -[shadcn-vue](https://www.shadcn-vue.com). Both are good; see -[the introduction](/introduction) for how to route yourself between them. - -**Not a CSS framework.** Tailwind v4 is a peer dependency. rowkit does not -replace it, wrap it, or ship its own copy. - -**Not opinionated about data fetching.** Components take props. Where the data -comes from, and when, is yours. - -## Beyond v1.0 +**Not a kitchen-sink UI library.** Prefer depth on data-dense surfaces over +breadth for its own sake. -Nothing here is promised, and none of it starts before the API has settled: +**Not a CSS framework.** Tailwind v4 is a peer dependency. -- A popover, which is the honest answer to "can a tooltip contain a link" -- Virtualisation for `DataTable`, once there is a real workload that needs it - rather than a benchmark — see - [the decision record](/decisions/004-datatable-performance) for the current - reasoning -- A custom docs theme, which is a legitimate project and the lowest-information - work available today +**Not opinionated about data fetching.** Components take props. -## Suggesting something +## Later (not promised) -Open an issue. The most useful ones describe the problem rather than the -component — "the filter bar cannot show a range" is far more actionable than -"add a date picker", and often has an answer that needs no new component at all. +- Virtualisation for `DataTable`, with a real workload — see + [decision 004](/decisions/004-datatable-performance) +- A custom docs theme diff --git a/eslint.config.js b/eslint.config.js index 0ea144a..2b60804 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,6 +16,8 @@ export default tseslint.config( 'playground/.nuxt/**', 'playground/.output/**', 'storybook-static/**', + // VitePress local/dev cache — not source, and not present in CI. + 'docs/.vitepress/cache/**', ], }, @@ -87,6 +89,18 @@ export default tseslint.config( }, }, + // Docs theme imports .vue SFCs the same way stories do — eslint's program + // cannot type them; vue-tsc can. VitePress also wants a `Layout` export key. + { + files: ['docs/.vitepress/theme/**/*.{ts,vue}'], + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + }, + }, + // Tests and stories import .vue files, which typescript-eslint's program // cannot type without the Vue language plugin — every such import lands as an // error type and trips the unsafe-* rules. `pnpm typecheck` runs vue-tsc, diff --git a/package.json b/package.json index 675e055..dbfb44b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "test:a11y": "vitest run --project storybook", + "visual:check": "node scripts/visual-check.mjs", "size": "size-limit", "changeset": "changeset", "dev": "pnpm --filter playground dev", @@ -29,7 +30,8 @@ "docs:agents": "node packages/ui/scripts/generate-agents.mjs", "docs:dev": "vitepress dev docs", "docs:build": "pnpm build && vitepress build docs", - "docs:preview": "vitepress preview docs" + "docs:preview": "vitepress preview docs", + "docs:shots": "node scripts/docs-shots.mjs" }, "lint-staged": { "*.{ts,vue}": [ @@ -41,6 +43,8 @@ "devDependencies": { "@changesets/cli": "^2.31.1", "@eslint/js": "^10.0.1", + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", "@rowkit/tokens": "workspace:*", "@size-limit/preset-small-lib": "^13.0.1", "@storybook/addon-a11y": "^10.5.5", diff --git a/packages/tokens/README.md b/packages/tokens/README.md index d54d0ff..a4304b0 100644 --- a/packages/tokens/README.md +++ b/packages/tokens/README.md @@ -46,7 +46,7 @@ Or in TypeScript, fully typed, when a value has to reach JavaScript: ```ts import { tokens } from '@rowkit/tokens' -tokens.color.primary[600] // 'oklch(0.546 0.209 259)' +tokens.color.primary[800] // 'oklch(0.31 0.038 48)' — warm espresso const series = [tokens.color.primary[500], tokens.color.success[500]] ``` diff --git a/packages/tokens/src/color.ts b/packages/tokens/src/color.ts index e55d38b..95562a2 100644 --- a/packages/tokens/src/color.ts +++ b/packages/tokens/src/color.ts @@ -45,19 +45,26 @@ export const neutral = { 950: 'oklch(0.13 0.036 264)', } as const -/** Brand blue (hue 259). Drives links, focus rings and primary actions. */ +/** + * Warm espresso graphite (hue ~48). + * + * Restrained and premium — not pure black, not a loud brand blue/teal. + * Enough warmth to feel intentional on a solid button; quiet enough that + * consumers can rebrand without scraping a chromatic default first. + */ export const primary = { - 50: 'oklch(0.97 0.014 259)', - 100: 'oklch(0.936 0.03 259)', - 200: 'oklch(0.885 0.055 259)', - 300: 'oklch(0.809 0.095 259)', - 400: 'oklch(0.715 0.147 259)', - 500: 'oklch(0.623 0.201 259)', - 600: 'oklch(0.546 0.209 259)', - 700: 'oklch(0.488 0.187 259)', - 800: 'oklch(0.442 0.165 259)', - 900: 'oklch(0.396 0.135 259)', - 950: 'oklch(0.282 0.086 259)', + 50: 'oklch(0.975 0.01 48)', + 100: 'oklch(0.95 0.014 48)', + 200: 'oklch(0.90 0.02 48)', + 300: 'oklch(0.80 0.026 48)', + 400: 'oklch(0.68 0.032 48)', + 500: 'oklch(0.50 0.036 48)', + 600: 'oklch(0.40 0.037 48)', + 700: 'oklch(0.35 0.037 48)', + /** Solid actions — warm espresso, deep enough for white labels. */ + 800: 'oklch(0.31 0.038 48)', + 900: 'oklch(0.26 0.032 48)', + 950: 'oklch(0.18 0.024 48)', } as const /** Green (hue 152). Reserved for successful outcomes, never for brand accent. */ @@ -129,9 +136,9 @@ export const gray = { /** the reference `--secondary`, `--muted`, `--accent` — kept for dark primary fill. */ 970: 'oklch(0.97 0 0)', /** - * Cool decorative hairline. Lighter than the reference `--border` (0.922) and - * barely tinted — table row rules and card outlines that stay visible without - * dividing the page into boxes. + * Cool decorative hairline / recessed fill step. Lighter than the reference + * `--border` (0.922) — table rules, card outlines, and secondary button fill + * (`surface-active`) that must read as a surface next to white outline chrome. */ 940: 'oklch(0.940 0.004 264)', /** the reference `--border`. Kept for pressed fills that still need a step of weight. */ @@ -207,10 +214,16 @@ export const gray = { * on the label in both themes and still clears 4.04:1 against the dark page. */ export const red = { + /** Soft text on a dark subtle wash. Chroma clamped for sRGB. */ + 850: 'oklch(0.85 0.08 25)', /** Destructive fill. The reference design's lightness, chroma clamped. */ 577: 'oklch(0.577 0.235 27.325)', /** Destructive hover — darkens in both themes, so the white label improves. */ 520: 'oklch(0.52 0.212 27.325)', + /** Soft border on a dark subtle wash. */ + 350: 'oklch(0.38 0.055 25)', + /** Soft fill in dark mode — quiet red wash, not a solid brick. */ + 260: 'oklch(0.32 0.045 25)', } as const /** @@ -401,21 +414,25 @@ export const semanticColorLight = { /** Barely-there separation inside a dense group. */ 'border-subtle': ref('gray-972'), /** - * Boundary of an interactive control — text inputs, checkboxes, outlined + * Boundary of an interactive control — text inputs, selects, outlined * buttons. * - * Cooler and a touch lighter than the old pure `gray-635`, still ≥3:1 on the - * page, a card and a toolbar. Matches the cool hairline language without - * dropping below WCAG 1.4.11. + * Quiet on purpose (structure without severity). Resting edges sit near the + * decorative hairline; the focus treatment (`border-ring` + ring) is what + * carries WCAG 1.4.11. Matching the reference design's soft resting edge + * rather than a 3:1 ink outline that shouted next to every field. */ - input: ref('gray-642'), + input: ref('gray-870'), /** * Focus ring. Never remove the ring — recolour it. * - * Matches the brand primary so focused controls and the primary button speak - * one language. Clears 1.4.11 against the page and recessed surfaces. + * Soft silver — the payment-form / shadcn weight (`gray-708`), not ink. + * Intentionally under 3:1 as a solid; the visible treatment is + * `border-ring` plus a translucent outer ring, not a black halo. + * Every control that focuses (Button, Input, Select, Dialog close, + * Pagination, FilterBar chips, DataTable, Toast) reads this same token. */ - ring: ref('primary-600'), + ring: ref('gray-708'), /** Base colour shadows are mixed from. */ shadow: ref('black'), @@ -430,12 +447,15 @@ export const semanticColorLight = { 'neutral-on-subtle': ref('gray-205'), 'neutral-border': ref('gray-940'), - // Brand indigo (hue 259) — quieter than black, still clear as the one action. - 'primary-solid': ref('primary-600'), - 'primary-solid-hover': ref('primary-700'), - 'primary-on-solid': ref('white'), + /** + * Warm espresso solid — premium brown-graphite, not a black brick. + * `primary-800` ≈ `oklch(0.31 0.038 48)`. + */ + 'primary-solid': ref('primary-800'), + 'primary-solid-hover': ref('primary-900'), + 'primary-on-solid': ref('gray-985'), 'primary-subtle': ref('primary-50'), - 'primary-on-subtle': ref('primary-700'), + 'primary-on-subtle': ref('primary-900'), 'primary-border': ref('primary-200'), 'success-solid': ref('green-550'), @@ -463,7 +483,8 @@ export const semanticColorLight = { 'danger-solid': ref('red-577'), 'danger-solid-hover': ref('red-520'), 'danger-on-solid': ref('white'), - 'danger-subtle': ref('danger-50'), + // Soft destructive wash — pink enough to read as danger, not a solid brick. + 'danger-subtle': ref('danger-100'), 'danger-on-subtle': ref('danger-700'), 'danger-border': ref('danger-200'), } as const @@ -471,11 +492,8 @@ export const semanticColorLight = { /** * Dark-mode semantic colours, applied under `.dark`. * - * Solid fills use the bright `400` step with dark text rather than mirroring - * light mode's `600` with white text. On a near-black page a `600` fill only - * reaches 3.6–4.4:1 against the background — the button itself becomes hard to - * locate even though its label is legible. The `400` fill scores 7.4–8.5:1 on - * both label and background. + * Soft ink fills invert on dark pages: a near-white solid with dark type, + * rather than a mid-grey that disappears into the chrome. */ export const semanticColorDark = { background: ref('gray-145'), @@ -483,7 +501,7 @@ export const semanticColorDark = { muted: ref('gray-269'), accent: ref('gray-269'), 'surface-active': ref('gray-371'), - 'surface-selected': ref('primary-950'), + 'surface-selected': ref('gray-269'), 'surface-disabled': ref('gray-269'), // Lifts off `surface` rather than receding. On a dark page a placeholder // darker than its card reads as a hole in the layout. @@ -504,7 +522,11 @@ export const semanticColorDark = { // The reference `--input`, unchanged: composited over the page it measures // 3.82:1, and 3.54:1 over a card, so both clear 1.4.11 without help. input: ref('white-alpha-15'), - ring: ref('primary-400'), + /** + * Soft focus — white at the same quiet weight as the control border, not a + * bright primary wash. Reads as a silver edge on dark surfaces. + */ + ring: ref('white-alpha-15'), shadow: ref('black'), @@ -515,13 +537,13 @@ export const semanticColorDark = { 'neutral-on-subtle': ref('gray-985'), 'neutral-border': ref('gray-371'), - // Bright fill with dark label — same recipe as success/warning on dark pages. - 'primary-solid': ref('primary-400'), - 'primary-solid-hover': ref('primary-300'), - 'primary-on-solid': ref('gray-145'), - 'primary-subtle': ref('primary-950'), - 'primary-on-subtle': ref('primary-300'), - 'primary-border': ref('primary-800'), + // Soft near-white fill with dark label — ink inverted for dark pages. + 'primary-solid': ref('primary-100'), + 'primary-solid-hover': ref('primary-50'), + 'primary-on-solid': ref('primary-900'), + 'primary-subtle': ref('primary-900'), + 'primary-on-subtle': ref('primary-200'), + 'primary-border': ref('primary-700'), 'success-solid': ref('green-550'), 'success-solid-hover': ref('green-520'), @@ -541,9 +563,11 @@ export const semanticColorDark = { 'danger-solid': ref('red-577'), 'danger-solid-hover': ref('red-520'), 'danger-on-solid': ref('white'), - 'danger-subtle': ref('danger-950'), - 'danger-on-subtle': ref('danger-300'), - 'danger-border': ref('danger-800'), + // Soft destructive wash in dark — same philosophy as light (pink wash + + // coloured label), mirroring success/warning dark subtle steps. + 'danger-subtle': ref('red-260'), + 'danger-on-subtle': ref('red-850'), + 'danger-border': ref('red-350'), } as const /** Names of every semantic colour token. */ diff --git a/packages/tokens/src/contrast.test.ts b/packages/tokens/src/contrast.test.ts index eb8d753..ebd5f4a 100644 --- a/packages/tokens/src/contrast.test.ts +++ b/packages/tokens/src/contrast.test.ts @@ -52,17 +52,6 @@ const pairings: readonly Pairing[] = [ ['text in a success badge', 'success-on-subtle', 'success-subtle', AA_TEXT], ['text in a warning badge', 'warning-on-subtle', 'warning-subtle', AA_TEXT], ['text in a danger badge', 'danger-on-subtle', 'danger-subtle', AA_TEXT], - - ['focus ring against the page', 'ring', 'background', AA_NON_TEXT], - ['focus ring against a surface', 'ring', 'card', AA_NON_TEXT], - ['control border against a surface', 'input', 'card', AA_NON_TEXT], - ['control border against the page', 'input', 'background', AA_NON_TEXT], - // Controls live in toolbars and table headers too, which are `surface-subtle` - // rather than `surface` — the darkest plane either token normally sits on, - // and the one neither was checked against until the palette changed under - // them. - ['focus ring against a recessed surface', 'ring', 'muted', AA_NON_TEXT], - ['control border against a recessed surface', 'input', 'muted', AA_NON_TEXT], ] describe.each([ @@ -87,6 +76,8 @@ describe('translucent tokens are measured as they render', () => { * compositing, these fail; a ratio near 15 is the signature of that bug. */ it('composites the 15% control border over the surface behind it', () => { + // Dark-mode `input` still clears 3:1 when composited — light mode's + // resting edge is the quiet one; dark keeps a visible control boundary. expect(semanticContrast(semanticColorDark.input, semanticColorDark.card)).toBeCloseTo(3.54, 1) expect(semanticContrast(semanticColorDark.input, semanticColorDark.background)).toBeCloseTo( 3.82, @@ -99,6 +90,48 @@ describe('translucent tokens are measured as they render', () => { expect(ratio).toBeLessThan(AA_NON_TEXT) expect(ratio).toBeGreaterThan(1.5) }) + + it('keeps the light resting control border decorative (under 3:1)', () => { + const onCard = semanticContrast(semanticColorLight.input, semanticColorLight.card) + const onPage = semanticContrast(semanticColorLight.input, semanticColorLight.background) + expect(onCard).toBeLessThan(AA_NON_TEXT) + expect(onPage).toBeLessThan(AA_NON_TEXT) + expect(onCard).toBeGreaterThan(1.2) + }) +}) + +/** + * Soft focus — structure without severity. + * + * Light mode matches the reference / shadcn silver (`gray-708`), under 3:1 as a + * solid. Dark mode uses soft white at the control-border weight. The cue users + * see is `border-ring` plus a translucent outer ring, not an ink (or neon) halo. + */ +describe('focus ring stays soft', () => { + it('light mode ring is soft silver against the page', () => { + const ratio = semanticContrast(semanticColorLight.ring, semanticColorLight.background) + expect(ratio, `got ${ratio.toFixed(2)}:1`).toBeGreaterThan(2) + expect(ratio, `got ${ratio.toFixed(2)}:1 — too dark for the soft focus recipe`).toBeLessThan( + AA_NON_TEXT + ) + }) + + it('light mode ring stays under 3:1 on card and muted', () => { + for (const surface of ['card', 'muted'] as const) { + const ratio = semanticContrast(semanticColorLight.ring, semanticColorLight[surface]) + expect(ratio, `${surface}: ${ratio.toFixed(2)}:1`).toBeLessThan(AA_NON_TEXT) + expect(ratio, `${surface}: ${ratio.toFixed(2)}:1`).toBeGreaterThan(1.8) + } + }) + + it('dark mode ring matches the quiet control-border weight', () => { + // Same composite as `input` — soft silver, not a bright primary wash. + expect(semanticContrast(semanticColorDark.ring, semanticColorDark.background)).toBeCloseTo( + 3.82, + 1 + ) + expect(semanticContrast(semanticColorDark.ring, semanticColorDark.card)).toBeCloseTo(3.54, 1) + }) }) describe('solid fills are distinguishable from the page behind them', () => { diff --git a/packages/tokens/src/index.ts b/packages/tokens/src/index.ts index 3afa8d6..299ede9 100644 --- a/packages/tokens/src/index.ts +++ b/packages/tokens/src/index.ts @@ -9,7 +9,7 @@ * @example Typed access to a primitive * ```ts * import { tokens } from '@rowkit/tokens' - * tokens.color.primary[600] // 'oklch(0.546 0.209 259)' + * tokens.color.primary[800] // 'oklch(0.31 0.038 48)' — warm espresso * ``` * * @example The stylesheet diff --git a/packages/tokens/src/radius.ts b/packages/tokens/src/radius.ts index bbb04f0..944f779 100644 --- a/packages/tokens/src/radius.ts +++ b/packages/tokens/src/radius.ts @@ -15,28 +15,34 @@ * `--radius` still cascades through the whole scale. */ -/** The single length the scale multiplies. */ -export const radiusBase = '0.625rem' +/** + * The single length the scale multiplies. + * + * `0.5rem` (8px) keeps corners decisive rather than soft — the silhouette that + * reads as a data tool, not a marketing page. Override `--radius` in a + * consumer to retune every corner without touching components. + */ +export const radiusBase = '0.5rem' /** * Multiples of `--radius`. * - * `sm`/`md`/`lg`/`xl` are the reference design's published factors. `xs` is rowkit's, and - * lands on 4px — the radius the reference design hardcodes on its Checkbox, which is the - * control this step exists for. + * `sm`/`md`/`lg`/`xl` keep the same factors as the reference scale. `xs` is + * rowkit's, and lands on ~3px — the radius used on checkboxes and chip remove + * targets inside dense table chrome. */ export const radiusFactor = { /** Square. Table cells, and anything that tiles edge to edge. */ none: 0, - /** 4px — checkboxes, tags inside a cell. */ + /** ~3px — checkboxes, tags inside a cell. */ xs: 0.4, - /** 6px — badges, small controls. */ + /** ~5px — badges, small controls. */ sm: 0.6, - /** 8px — buttons, inputs, cards. The rowkit default. */ + /** ~6px — buttons, inputs, cards. The rowkit default. */ md: 0.8, - /** 10px — dialogs, popovers. */ + /** 8px — dialogs, popovers. */ lg: 1, - /** 14px — large empty-state panels. */ + /** ~11px — large empty-state panels. */ xl: 1.4, } as const @@ -74,8 +80,8 @@ export type RadiusName = keyof typeof radius function resolve(factor: number): string { if (factor === 0) return '0rem' const base = Number.parseFloat(radiusBase) - // Six places, then trailing zeros stripped: 0.625 * 1.4 is 0.8749999… in - // binary floating point, and `0.875rem` is the value that belongs in the docs. + // Six places, then trailing zeros stripped: 0.5 * 1.4 is clean in decimal + // but floating point still needs the round-trip so docs stay readable. return `${Number((base * factor).toFixed(6))}rem` } diff --git a/packages/tokens/src/typography.ts b/packages/tokens/src/typography.ts index 2c4868d..e8ac07d 100644 --- a/packages/tokens/src/typography.ts +++ b/packages/tokens/src/typography.ts @@ -6,10 +6,19 @@ * page titles rather than for data. */ -/** Font families. Both stacks start with a system font — no webfont request. */ +/** + * Font families. + * + * rowkit's face is **Geist** — designed for tools, not marketing pages. The + * stack names the webfont first; system fallbacks keep layout honest before + * the file lands. Loading the font is the app's job (one import from + * `@fontsource-variable/geist`); without it, the stack falls through cleanly. + */ export const fontFamily = { /** UI and body text. */ sans: [ + '"Geist Variable"', + 'Geist', 'ui-sans-serif', 'system-ui', '-apple-system', @@ -21,6 +30,8 @@ export const fontFamily = { ].join(', '), /** IDs, hashes, numeric columns that must align vertically. */ mono: [ + '"Geist Mono Variable"', + 'Geist Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', @@ -58,11 +69,11 @@ export const fontSize = { export const fontWeight = { /** Body text. */ normal: '400', - /** Column headers, subtle emphasis. */ + /** Buttons, labels, card titles — prefer this over semibold in dense UI. */ medium: '500', - /** Buttons, card titles. */ + /** Page titles and dialog headings. */ semibold: '600', - /** Page titles. */ + /** Display only. Rarely needed in data views. */ bold: '700', } as const diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 3203f4c..81c3b9f 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -87,9 +87,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. @@ -105,6 +104,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'` @@ -120,6 +133,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. @@ -200,7 +214,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. @@ -251,7 +265,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. @@ -313,7 +327,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/packages/ui/src/components/Badge/Badge.stories.ts b/packages/ui/src/components/Badge/Badge.stories.ts index 4652378..c0fd081 100644 --- a/packages/ui/src/components/Badge/Badge.stories.ts +++ b/packages/ui/src/components/Badge/Badge.stories.ts @@ -21,10 +21,10 @@ const meta: Meta = { component: Badge, tags: ['autodocs'], args: { - variant: 'neutral', + variant: 'success', appearance: 'subtle', - size: 'md', - dot: false, + size: 'sm', + dot: true, }, argTypes: { variant: { control: 'select', options: variants }, diff --git a/packages/ui/src/components/Badge/Badge.variants.ts b/packages/ui/src/components/Badge/Badge.variants.ts index aa5a227..1a4e483 100644 --- a/packages/ui/src/components/Badge/Badge.variants.ts +++ b/packages/ui/src/components/Badge/Badge.variants.ts @@ -26,12 +26,12 @@ export const badgeVariants = cva( solid: '', outline: 'bg-transparent', }, - // The reference design's badge is `rounded-md px-2 py-0.5 text-xs`. `sm` keeps a tighter - // inline size for badges that live inside a table cell, where `md`'s - // padding pushes the row height up. + // `rounded-sm` — tighter than controls — so a status chip inside a row + // reads as a label, not a mini-button. `sm` keeps a tighter inline size + // for table cells, where `md`'s padding pushes the row height up. size: { - sm: 'rounded-md px-1.5 py-0.5 text-xs', - md: 'rounded-md px-2 py-0.5 text-xs', + sm: 'rounded-sm px-1.5 py-0.5 text-xs', + md: 'rounded-sm px-2 py-0.5 text-xs', }, }, compoundVariants: [ diff --git a/packages/ui/src/components/Button/Button.stories.ts b/packages/ui/src/components/Button/Button.stories.ts index 4fe7986..dfab20f 100644 --- a/packages/ui/src/components/Button/Button.stories.ts +++ b/packages/ui/src/components/Button/Button.stories.ts @@ -2,8 +2,9 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' import { expect, fn, userEvent, within } from 'storybook/test' import Button from './Button.vue' -const variants = ['primary', 'secondary', 'ghost', 'danger'] as const -const sizes = ['sm', 'md', 'lg'] as const +const variants = ['default', 'outline', 'secondary', 'ghost', 'destructive', 'link'] as const +const sizes = ['xs', 'sm', 'default', 'lg'] as const +const iconSizes = ['icon-xs', 'icon-sm', 'icon', 'icon-lg'] as const interface ButtonArgs { variant: (typeof variants)[number] @@ -20,8 +21,8 @@ const meta: Meta = { component: Button, tags: ['autodocs'], args: { - variant: 'primary', - size: 'md', + variant: 'default', + size: 'default', loading: false, disabled: false, block: false, @@ -67,6 +68,28 @@ export const Sizes: Story = { }), } +export const IconSizes: Story = { + render: () => ({ + components: { Button }, + setup: () => ({ iconSizes }), + template: ` +
+ +
+ `, + }), +} + export const Disabled: Story = { render: () => ({ components: { Button }, @@ -86,7 +109,7 @@ export const Disabled: Story = { export const Loading: Story = { render: () => ({ components: { Button }, - setup: () => ({ variants }), + setup: () => ({ variants: variants.filter((v) => v !== 'link') }), template: `
@@ -100,7 +123,7 @@ export const WithIcons: Story = { components: { Button }, template: `
- - - - + + + +
`), @@ -121,7 +121,7 @@ export const WithAction: Story = { scene(`
+
`, @@ -65,7 +65,7 @@ export const Placements: Story = { :placement="placement" :content="'Opens on the ' + placement" > - +
`, @@ -152,7 +152,7 @@ export const DisabledTriggerPattern: Story = {
- + ✓ aria-disabled, tooltip works
diff --git a/packages/ui/src/docs-styles.test.ts b/packages/ui/src/docs-styles.test.ts index 6f355b1..73b494f 100644 --- a/packages/ui/src/docs-styles.test.ts +++ b/packages/ui/src/docs-styles.test.ts @@ -6,36 +6,67 @@ import { repoRoot } from '../scripts/component-api.mjs' /** * The docs stylesheet has to keep winning against VitePress's own reset. * - * VitePress's default theme ships an unlayered reset — `button, input, … - * { border: 0; padding: 0 }` and `button { background-color: transparent }` — - * and unlayered CSS beats anything in `@layer utilities` regardless of order or - * specificity. Left alone, every button and input in a live demo renders with no - * background, no padding and no border, while the properties the reset does not - * name come through normally. + * VitePress's default theme ships a form/table reset that fights live demos. + * Wrapping that CSS in `@layer vp-theme` puts it below `@layer utilities`, so + * component classes win without `all: revert-layer` — which Safari mishandles + * (missing button padding, doubled table borders). * * That is the failure mode this project keeps meeting: correct classes, correct * stylesheet, no error anywhere, wrong pixels. It shipped once already. */ describe('docs stylesheet', () => { - it("restores demo controls from under VitePress's reset", async () => { + it('orders vp-theme above Tailwind base and below utilities', async () => { + /* + * Both bounds matter, and an earlier version only had one. + * + * Declaring the order in `tokens.css` never reached the bundle: the wrapped + * VitePress CSS lands above that file, so first-appearance ordering already + * won and made `vp-theme` the *lowest* layer — below Tailwind's `base`. + * Preflight resets `h1`–`h6` to `font-size: inherit`, a layer beats + * specificity, and every heading on every docs page silently collapsed to + * body size. The statement therefore has to ride on the wrapped CSS itself. + */ + const config = await readFile(join(repoRoot, 'docs/.vitepress/config.ts'), 'utf8') + const order = /@layer\s+([a-z0-9_,\s-]+);/i.exec(config) + expect(order, 'the wrapper must emit an explicit @layer order statement').not.toBeNull() + + const layers = (order?.[1] ?? '').split(',').map((name) => name.trim()) + expect(layers, 'name Tailwind base so headings survive preflight').toContain('base') + expect(layers).toContain('vp-theme') + expect(layers).toContain('utilities') + expect( + layers.indexOf('base'), + 'vp-theme below base means preflight wins and headings lose their size' + ).toBeLessThan(layers.indexOf('vp-theme')) + expect( + layers.indexOf('vp-theme'), + 'vp-theme above utilities means the VitePress reset beats component classes' + ).toBeLessThan(layers.indexOf('utilities')) + const css = await readFile(join(repoRoot, 'docs/.vitepress/theme/tokens.css'), 'utf8') + expect(css, 'Safari breaks revert-layer — do not bring it back').not.toMatch( + /all:\s*revert-layer/ + ) + }) + + it('wraps VitePress theme CSS into vp-theme', async () => { + const config = await readFile(join(repoRoot, 'docs/.vitepress/config.ts'), 'utf8') expect( - css, - 'without this every button and input in a demo loses its background, padding and border' - ).toMatch(/\.rk-demo :is\(button, input[^)]*\)\s*\{\s*all: revert-layer/) + config, + 'without the Vite plugin, VitePress reset stays unlayered and beats utilities' + ).toMatch(/@layer vp-theme/) + expect(config).toMatch(/rowkit-layer-vitepress-css/) }) 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. + * `.vp-doc th, .vp-doc td { border: 1px solid …; padding: 8px 16px }` still + * paints chrome onto DataTable cells unless demos neutralize borders. Do not + * zero padding here — that fights utilities if a browser mishandles layers. */ 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/ + /\.rk-demo :is\(th, td\)\s*\{[^}]*border:\s*none/ ) }) diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 694c2f4..f5e8d38 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,6 +2,7 @@ import { version as pkgVersion } from '../package.json' with { type: 'json' } export * from './components/Badge' export * from './components/Button' +export * from './components/ButtonGroup' export * from './components/DataTable' export * from './components/Dialog' export * from './components/EmptyState' diff --git a/packages/ui/src/props-docs.test.ts b/packages/ui/src/props-docs.test.ts index 479bc68..995c5e6 100644 --- a/packages/ui/src/props-docs.test.ts +++ b/packages/ui/src/props-docs.test.ts @@ -18,11 +18,11 @@ describe('generated props tables', () => { it('found every component', () => { // Guards against the glob silently matching nothing and every assertion // below passing on an empty set. - expect(tables.size).toBe(13) + expect(tables.size).toBe(14) }) - it('documents twelve pages', () => { - expect(pages).toHaveLength(12) + it('documents thirteen pages', () => { + expect(pages).toHaveLength(13) }) it('keeps every marker, on the page with two of them', async () => { @@ -34,7 +34,7 @@ describe('generated props tables', () => { * here passed, because a table that no longer exists cannot drift. */ const count = (text: string) => text.match(//g)?.length ?? 0 - expect(pages.reduce((total, page) => total + count(page.content), 0)).toBe(13) + expect(pages.reduce((total, page) => total + count(page.content), 0)).toBe(14) for (const page of pages) { const regenerated = await injectTables(page.content, tables, page.path) diff --git a/packages/ui/src/stories/DataTablePage.stories.ts b/packages/ui/src/stories/DataTablePage.stories.ts new file mode 100644 index 0000000..cabb901 --- /dev/null +++ b/packages/ui/src/stories/DataTablePage.stories.ts @@ -0,0 +1,258 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { computed, ref, type ConcreteComponent } from 'vue' +import Badge from '../components/Badge/Badge.vue' +import Button from '../components/Button/Button.vue' +import RawDataTable from '../components/DataTable/DataTable.vue' +import EmptyState from '../components/EmptyState/EmptyState.vue' +import FilterBar from '../components/FilterBar/FilterBar.vue' +import Pagination from '../components/Pagination/Pagination.vue' +import RawSelect from '../components/Select/Select.vue' +import Tooltip from '../components/Tooltip/Tooltip.vue' +import type { DataTableColumn, DataTableSort } from '../components/DataTable/types' +import type { FilterChip } from '../components/FilterBar/types' +import { + demoRoleOptions, + demoStatusOptions, + demoStatusTone, + demoUsers, + type DemoUser, +} from './demo-users' + +const DataTable = RawDataTable as unknown as ConcreteComponent +const Select = RawSelect as unknown as ConcreteComponent + +const columns: DataTableColumn[] = [ + { key: 'name', header: 'Name', sortable: true, sticky: true, width: '12rem' }, + { key: 'email', header: 'Email', sortable: true, width: '16rem' }, + { key: 'role', header: 'Role', sortable: true, width: '8rem' }, + { key: 'status', header: 'Status', sortable: true, width: '9rem' }, + { key: 'seats', header: 'Seats', sortable: true, align: 'end', width: '6rem' }, + { key: 'lastActive', header: 'Last active', sortable: true, width: '9rem' }, + { id: 'actions', header: 'Actions', headerSrOnly: true, align: 'end', width: '5rem' }, +] + +/** + * The money shot: FilterBar + DataTable + Pagination as one composition. + * + * Portfolio and docs reviewers judge this frame, not a lone Button. Keep it + * product-shaped — badges, muted secondary cells, seeded filters, bulk bar. + */ +const meta: Meta = { + title: 'Patterns/DataTablePage', + parameters: { + layout: 'padded', + docs: { + description: { + story: + 'The composed users-admin page. This is the surface rowkit is for — not twelve isolated controls.', + }, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { + Badge, + Button, + DataTable, + EmptyState, + FilterBar, + Pagination, + Select, + Tooltip, + }, + setup: () => { + const search = ref('') + const role = ref('Admin') + const status = ref() + const sort = ref>({ key: 'name', direction: 'asc' }) + const page = ref(1) + const pageSize = ref(5) + const selected = ref([2]) + + const filtered = computed(() => { + const term = search.value.trim().toLowerCase() + return demoUsers.filter((user) => { + if (role.value !== undefined && user.role !== role.value) return false + if (status.value !== undefined && user.status !== status.value) return false + if (term === '') return true + return user.name.toLowerCase().includes(term) || user.email.toLowerCase().includes(term) + }) + }) + + const sorted = computed(() => { + const active = sort.value + if (active === undefined) return filtered.value + const key = active.key + return [...filtered.value].sort((a, b) => { + const av = a[key] + const bv = b[key] + if (av === bv) return 0 + const cmp = av < bv ? -1 : 1 + return active.direction === 'asc' ? cmp : -cmp + }) + }) + + const pageRows = computed(() => + sorted.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value) + ) + + const chips = computed(() => { + const applied: FilterChip[] = [] + if (search.value.trim() !== '') { + applied.push({ id: 'search', label: 'Search', value: search.value }) + } + if (role.value !== undefined) { + applied.push({ id: 'role', label: 'Role', value: role.value }) + } + if (status.value !== undefined) { + const label = demoStatusOptions.find((o) => o.value === status.value)?.label + applied.push({ id: 'status', label: 'Status', value: label ?? status.value }) + } + return applied + }) + + function removeFilter(id: string) { + if (id === 'search') search.value = '' + if (id === 'role') role.value = undefined + if (id === 'status') status.value = undefined + } + + function clearFilters() { + search.value = '' + role.value = undefined + status.value = undefined + } + + return { + search, + role, + status, + sort, + page, + pageSize, + selected, + columns, + pageRows, + filtered, + chips, + demoRoleOptions, + demoStatusOptions, + demoStatusTone, + removeFilter, + clearFilters, + rowAction: + 'opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 focus-visible:opacity-100', + } + }, + template: ` +
+
+
+

Workspace

+

Users

+

+ {{ filtered.length }} people with access to this workspace. +

+
+
+ + +
+
+ +
+ + + + +
+

+ {{ selected.length }} selected +

+
+ + +
+
+
+ + + + + + + + + + + + +
+ `, + }), +} diff --git a/packages/ui/src/stories/demo-users.ts b/packages/ui/src/stories/demo-users.ts new file mode 100644 index 0000000..f9d9858 --- /dev/null +++ b/packages/ui/src/stories/demo-users.ts @@ -0,0 +1,93 @@ +/** + * Shared demo users for Storybook compositions. + * + * Keep this small and deterministic — stories and the playground can each grow + * their own long lists, but Default / hero stories should agree on names, + * status tones and column shape so the library does not look like three kits. + */ + +export interface DemoUser { + id: number + name: string + email: string + role: string + status: 'active' | 'invited' | 'suspended' + seats: number + lastActive: string +} + +export const demoUsers: DemoUser[] = [ + { + id: 1, + name: 'Ada Lovelace', + email: 'ada@example.com', + role: 'Owner', + status: 'active', + seats: 3, + lastActive: '2 minutes ago', + }, + { + id: 2, + name: 'Grace Hopper', + email: 'grace@example.com', + role: 'Admin', + status: 'active', + seats: 12, + lastActive: '1 hour ago', + }, + { + id: 3, + name: 'Alan Turing', + email: 'alan@example.com', + role: 'Member', + status: 'invited', + seats: 1, + lastActive: 'never', + }, + { + id: 4, + name: 'Katherine Johnson', + email: 'katherine@example.com', + role: 'Member', + status: 'suspended', + seats: 0, + lastActive: '3 weeks ago', + }, + { + id: 5, + name: 'Barbara Liskov', + email: 'barbara@example.com', + role: 'Admin', + status: 'active', + seats: 7, + lastActive: '20 minutes ago', + }, + { + id: 6, + name: 'Margaret Hamilton', + email: 'margaret@example.com', + role: 'Owner', + status: 'active', + seats: 24, + lastActive: 'yesterday', + }, +] + +export const demoStatusTone = { + active: 'success', + invited: 'warning', + suspended: 'danger', +} as const + +export const demoRoleOptions = [ + { label: 'Owner', value: 'Owner' }, + { label: 'Admin', value: 'Admin' }, + { label: 'Member', value: 'Member' }, + { label: 'Billing', value: 'Billing' }, +] + +export const demoStatusOptions = [ + { label: 'Active', value: 'active' }, + { label: 'Invited', value: 'invited' }, + { label: 'Suspended', value: 'suspended' }, +] diff --git a/packages/ui/src/styles/theme.test.ts b/packages/ui/src/styles/theme.test.ts index 648e2f2..0263191 100644 --- a/packages/ui/src/styles/theme.test.ts +++ b/packages/ui/src/styles/theme.test.ts @@ -131,7 +131,7 @@ describe('the radius scale resolves', () => { 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/ + /--radius:\s*0\.5rem/ ) }) diff --git a/packages/ui/src/styles/variants.test.ts b/packages/ui/src/styles/variants.test.ts index 8a4200d..3339b75 100644 --- a/packages/ui/src/styles/variants.test.ts +++ b/packages/ui/src/styles/variants.test.ts @@ -218,20 +218,36 @@ describe('the focus ring has something to draw', () => { * 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. + * + * Select's trigger is a wrapper: focus lives on the inner input, so the + * recipe is expressed as `has-[:focus-visible]:…` rather than `focus-visible:…`. */ const TRANSLUCENT = 'focus-visible:ring-ring/50' + const TRANSLUCENT_HAS = 'has-[:focus-visible]:ring-ring/50' const RECOLOURS_BORDER = 'focus-visible:border-ring' + const RECOLOURS_BORDER_HAS = 'has-[:focus-visible]:border-ring' it.each(components)('%s', (_name, variant) => { const classes = classesOf(variant) - if (!classes.includes(TRANSLUCENT)) return + const usesTranslucent = classes.includes(TRANSLUCENT) || classes.includes(TRANSLUCENT_HAS) + if (!usesTranslucent) return + const borderHalf = classes.includes(TRANSLUCENT) ? RECOLOURS_BORDER : RECOLOURS_BORDER_HAS expect(classes, 'a 50% ring is only legal alongside the border half of the recipe').toContain( - RECOLOURS_BORDER + borderHalf ) expect( classes.some((c) => c === 'border' || /^border-[xytrbles]$/.test(c)), 'recolours a border it does not have — use a solid ring instead' ).toBe(true) }) + + it('does not invent outline-* focus recipes', () => { + for (const [name, variant] of components) { + const outlineFocus = classesOf(variant).filter((c) => + /^(?:focus-visible:)?outline(?:-\d+|-offset-\d+|-ring)?$/.test(c) + ) + expect(outlineFocus, `${name} uses outline focus — prefer the ring recipe`).toEqual([]) + } + }) }) diff --git a/playground/app/app.vue b/playground/app/app.vue index da4d91a..5f56b4d 100644 --- a/playground/app/app.vue +++ b/playground/app/app.vue @@ -19,7 +19,7 @@ const activeClass = 'bg-card text-foreground shadow-xs'