diff --git a/.changeset/config.json b/.changeset/config.json
new file mode 100644
index 0000000..edca366
--- /dev/null
+++ b/.changeset/config.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
+ "changelog": ["@changesets/changelog-github", { "repo": "karnstack/dowel" }],
+ "commit": false,
+ "fixed": [],
+ "linked": [],
+ "access": "public",
+ "baseBranch": "main",
+ "updateInternalDependencies": "patch",
+ "ignore": ["@dowel/docs"]
+}
diff --git a/.changeset/no-release-yet.md b/.changeset/no-release-yet.md
new file mode 100644
index 0000000..a845151
--- /dev/null
+++ b/.changeset/no-release-yet.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f73c331
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,57 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+# Cancel superseded runs on the same ref to save CI minutes.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ # Installs Node and pnpm at the exact versions pinned in mise.toml.
+ - name: Setup toolchain (mise)
+ uses: jdx/mise-action@v4
+ with:
+ cache: true
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
+
+ - name: Cache pnpm store
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Format check
+ run: pnpm format:check
+
+ - name: Typecheck
+ run: pnpm typecheck
+
+ # Build must precede test: css-contract.test.ts asserts against
+ # dist/dowel.css, which does not exist until the build runs.
+ - name: Build
+ run: pnpm build
+
+ - name: Test
+ run: pnpm test
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
new file mode 100644
index 0000000..370273d
--- /dev/null
+++ b/.github/workflows/deploy-docs.yml
@@ -0,0 +1,62 @@
+# Deploys dowel.sh when the docs or the library change on main.
+#
+# Uses the karnstack org secret CLOUDFLARE_API_TOKEN. The secret is treated as
+# optional: without it the job skips rather than fails, so a fork's CI is not
+# a wall of red.
+name: deploy-docs
+
+on:
+ workflow_dispatch:
+ push:
+ branches: [main]
+ paths:
+ - "apps/docs/**"
+ - "packages/dowel/**"
+ - ".github/workflows/deploy-docs.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Setup toolchain (mise)
+ uses: jdx/mise-action@v4
+ with:
+ cache: true
+
+ - name: Check for the deploy token
+ id: token
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ run: |
+ if [ -n "$CLOUDFLARE_API_TOKEN" ]; then
+ echo "present=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "present=false" >> "$GITHUB_OUTPUT"
+ echo "CLOUDFLARE_API_TOKEN is not set; skipping the deploy."
+ fi
+
+ - name: Install dependencies
+ if: steps.token.outputs.present == 'true'
+ run: pnpm install --frozen-lockfile
+
+ - name: Build the library then the docs
+ if: steps.token.outputs.present == 'true'
+ run: |
+ pnpm --filter dowel build
+ pnpm --filter @dowel/docs build
+
+ - name: Deploy
+ if: steps.token.outputs.present == 'true'
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ workingDirectory: apps/docs
+ # The action's bundled default predates wrangler v4 and cannot read
+ # an assets-only config ("Missing entry-point"). Pin the major that
+ # wrangler.jsonc is written for.
+ wranglerVersion: "4"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..2fd98c3
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,56 @@
+name: Release
+
+# Runs only AFTER the CI workflow succeeds on main, so a red main never
+# publishes. The changesets flow then takes over:
+# 1. add a changeset in your PR, merge to main
+# 2. CI passes -> this opens/updates a "Version Packages" PR
+# 3. merging THAT PR re-runs CI -> this publishes to npm
+on:
+ workflow_run:
+ workflows: [CI]
+ branches: [main]
+ types: [completed]
+
+concurrency: ${{ github.workflow }}-${{ github.ref }}
+
+permissions:
+ contents: write
+ pull-requests: write
+ id-token: write
+
+jobs:
+ release:
+ if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - name: Setup toolchain (mise)
+ uses: jdx/mise-action@v4
+ with:
+ cache: true
+
+ - name: Configure npm registry
+ run: |
+ echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm build
+
+ - uses: changesets/action@v1
+ with:
+ version: pnpm exec changeset version
+ publish: pnpm exec changeset publish
+ commit: "chore: version packages"
+ title: "chore: version packages"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ # changesets/action looks for NPM_TOKEN specifically; without it the
+ # action falls back to OIDC trusted publishing. Set both so npm auth
+ # is deterministic regardless of which path the action takes.
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 7841de3..033aa3d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@ dist/
.DS_Store
*.log
.turbo/
+# TanStack Start's scratch directory: the SSR bundle it builds only to render
+# the prerendered HTML, plus the route generator's temp files.
+.tanstack/
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..53b788d
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+# Keep the lockfile honest in CI; mise pins the pnpm version itself.
+engine-strict=true
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..22579fc
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,10 @@
+pnpm-lock.yaml
+# Anchored. An unanchored `docs/` is a gitignore-style pattern that matches a
+# directory of that name at ANY depth, which silently swallowed the whole
+# apps/docs app — the docs site was never formatted or format-checked.
+/docs/
+.superpowers/
+# Generated by TanStack Router on every build; its own header says to exclude
+# it from the formatter. It is committed because CI typechecks before it
+# builds, so the file has to exist in a fresh checkout.
+apps/docs/src/routeTree.gen.ts
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..c5d3910
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "semi": true,
+ "singleQuote": false,
+ "printWidth": 80,
+ "trailingComma": "all"
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..c9074c2
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,64 @@
+# dowel - agent notes
+
+## Writing style
+
+- **Never use em dashes (`—`) or en dashes (`–`) as punctuation.** Not in code
+ comments, commit messages, PR descriptions, docs prose, README copy, or
+ replies to the user. Rewrite the sentence, or use a comma, colon,
+ parenthesis, or full stop instead.
+- A hyphen inside a compound word (`build-time`, `zero-runtime`) is fine. The
+ rule is about dashes standing in for punctuation.
+- This applies to text you generate anywhere in this repo and to anything you
+ say about it.
+
+## Workflow
+
+- `main` is protected. Never commit to or merge into `main` locally.
+- All work happens on a branch and lands via a pull request, even for one-line
+ fixes. Push the branch, open the PR with `gh pr create`, hand back the URL.
+- Package manager is pnpm. Never `npm` or `npx`. Node and pnpm versions are
+ pinned in `mise.toml`; CI installs them via `jdx/mise-action`.
+- TypeScript is pinned to 5.9.x. Do not move to 7.x (the Go port) without a
+ deliberate decision: it generates the `.d.ts` every consumer depends on.
+
+## Library rules (packages/dowel)
+
+- **No override API.** `className` and `style` are omitted from every public
+ prop type and neutralised at runtime. Do not add them back. If someone needs
+ a different button, dowel is the wrong library. That is the point.
+- Spread `{...props}` FIRST, then `className`, `style={undefined}`, `data-*`.
+ Spreading last lets a consumer spread strip the class and it typechecks
+ clean, because JSX spreads skip excess-property checks.
+- Variants are `data-*` attributes, never props that map to class names.
+- All custom properties are prefixed `--dowel-`, all classes `dowel-`.
+- Only `border`, `background-color`, `color`, `opacity` may transition. Never
+ `all`, never transform or size on hover.
+- Hairlines are `0.5px`. Controls are 28px. Base font weight is 450, UI labels
+ 500, workhorse size 13px.
+- Hover rules need `:hover:not(:disabled):not([aria-disabled="true"])`, since
+ `:not(:disabled)` is true for an anchor.
+- No Tailwind, no CSS-in-JS, no class-name helper (`cx`/`clsx`). Hand-authored
+ plain CSS, bundled by Lightning CSS.
+- Component `@import`s go in the import block at the TOP of `src/index.css`.
+ Lightning CSS errors on a late `@import`.
+
+## Testing
+
+- jsdom cannot verify styling. It ignores every rule inside `@layer` and never
+ substitutes `var()`. Do not write assertions about computed colour, geometry
+ or hover: they cannot fail. Verify CSS against the built `dist/dowel.css`.
+- Vitest intercepts `console`. Grepping the run log cannot observe
+ `console.error`. Use `vi.spyOn(console, "error")`.
+- Base UI overlays open asynchronously. Use `findByRole`/`waitFor`, never a
+ synchronous `getByRole` after a click: the synchronous form does not just
+ fail, it can make the whole test pass vacuously.
+- `pnpm test` requires a build first (the CSS contract test asserts against
+ `dist/`). A `pretest` script handles this locally; CI builds before testing.
+
+## Release
+
+- changesets. A pending changeset in `.changeset/` triggers an npm publish on
+ merge to main. If a change should not release, add an empty changeset.
+- Secrets `NPM_TOKEN` and `CLOUDFLARE_API_TOKEN` live at the **karnstack org**
+ level with visibility ALL. Never create repo-level copies: a repo secret
+ shadows the org one and silently breaks rotations.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..722270b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Karn Gyan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index b26a8c6..bb21fd2 100644
--- a/README.md
+++ b/README.md
@@ -26,26 +26,52 @@ import { Button } from "dowel";
```
That is the whole setup. No Tailwind, no PostCSS config, no preset, no
-`components.json`, no copy-in generator.
+`components.json`, no copy-in generator. dowel is ESM-only.
## What dowel is
- **A real package.** Import components, bump a version, get the fixes. Your
UI does not drift across apps.
- **Opinionated on purpose.** There is no per-component override API. Retheming
- is two CSS variables: `--dowel-hue` and `--dowel-accent`.
-- **Light and dark from day one**, in one stylesheet, by class, data attribute
- or system preference.
+ is three CSS variables declared on `:root`: `--dowel-hue`, `--dowel-accent`
+ and `--dowel-accent-fg`. Hover and focus derive from the accent
+ automatically.
+- **Light and dark from day one**, in one stylesheet. Dark comes on via
+ `.dowel-dark`, via `data-dowel-theme="dark"`, or from
+ `prefers-color-scheme` — and `.dowel-light` / `data-dowel-theme="light"` on
+ `` pins light against a dark OS.
- **Accessible by construction.** Behaviour comes from
[Base UI](https://base-ui.com); every component is keyboard-tested and
axe-checked.
+Wrap your app in `.dowel-root` for the type and surface defaults. The full
+theming reference — every selector, every knob — lives in
+[`packages/dowel/README.md`](packages/dowel/README.md), and
+[dowel.sh](https://dowel.sh) has a light/dark toggle in the nav.
+
## What dowel is not
Not headless, not framework-agnostic, not a Tailwind plugin, not customisable
per component. If you need a different button, dowel is the wrong library —
that is the point.
+## Typeface
+
+dowel is designed for Inter. It falls back to `system-ui`, which works but
+looks different. To match the docs:
+
+```bash
+pnpm add @fontsource-variable/inter
+```
+
+```ts
+import "@fontsource-variable/inter";
+import "dowel/dowel.css";
+```
+
+Inter is OFL-licensed. dowel does not bundle it, so you control whether it is
+self-hosted or served from a CDN.
+
## Credit
dowel is an homage to the craft of [Linear](https://linear.app). Their
diff --git a/apps/docs/package.json b/apps/docs/package.json
new file mode 100644
index 0000000..a8f9989
--- /dev/null
+++ b/apps/docs/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@dowel/docs",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "typecheck": "tsc --noEmit",
+ "test": "echo \"no tests in docs\" && exit 0"
+ },
+ "dependencies": {
+ "@fontsource-variable/inter": "^5.3.0",
+ "@tanstack/react-router": "^1.170.23",
+ "@tanstack/react-start": "^1.168.40",
+ "dowel": "workspace:*",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^6.0.5",
+ "typescript": "5.9.3",
+ "vite": "^8.2.1"
+ }
+}
diff --git a/apps/docs/public/apple-touch-icon.png b/apps/docs/public/apple-touch-icon.png
new file mode 100644
index 0000000..e36a55d
Binary files /dev/null and b/apps/docs/public/apple-touch-icon.png differ
diff --git a/apps/docs/public/favicon.ico b/apps/docs/public/favicon.ico
new file mode 100644
index 0000000..bf7f107
Binary files /dev/null and b/apps/docs/public/favicon.ico differ
diff --git a/apps/docs/public/icon.svg b/apps/docs/public/icon.svg
new file mode 100644
index 0000000..613bd5d
--- /dev/null
+++ b/apps/docs/public/icon.svg
@@ -0,0 +1,39 @@
+
diff --git a/apps/docs/src/components/code-block.tsx b/apps/docs/src/components/code-block.tsx
new file mode 100644
index 0000000..27729bd
--- /dev/null
+++ b/apps/docs/src/components/code-block.tsx
@@ -0,0 +1,89 @@
+import { IconButton, Tooltip } from "dowel";
+import { useEffect, useState } from "react";
+
+import { tokenize } from "../lib/highlight";
+import { CheckIcon, CopyIcon } from "./icons";
+
+function CopyButton({ value }: { value: string }) {
+ const [copied, setCopied] = useState(false);
+
+ // The confirmation is a timer, so it has to be cleaned up: without this a
+ // copy immediately before navigating away sets state on an unmounted tree.
+ useEffect(() => {
+ if (!copied) return;
+ const id = setTimeout(() => setCopied(false), 1600);
+ return () => clearTimeout(id);
+ }, [copied]);
+
+ async function copy() {
+ // Guarded rather than assumed: the Clipboard API is absent on insecure
+ // origins, and a docs page should not throw because it is being read
+ // over plain http on someone's LAN.
+ if (!navigator.clipboard) return;
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ } catch {
+ // A denied clipboard permission is not worth an error state here.
+ }
+ }
+
+ return (
+
+
+ {copied ? : }
+
+ }
+ />
+
+
+ {copied ? "Copied" : "Copy"}
+
+
+
+ );
+}
+
+export type CodeBlockProps = {
+ code: string;
+ /** Shown in the block's title bar. Also picks the tokenizer's mood. */
+ lang?: string;
+};
+
+export function CodeBlock({ code, lang = "tsx" }: CodeBlockProps) {
+ const source = code.trim();
+ // Shell snippets have no TSX to find; running the tokenizer over them only
+ // produces false positives, so they render as one plain token.
+ const tokens =
+ lang === "bash" || lang === "css"
+ ? [{ kind: "plain" as const, text: source }]
+ : tokenize(source);
+
+ return (
+
+ );
+}
diff --git a/apps/docs/src/components/demo.tsx b/apps/docs/src/components/demo.tsx
new file mode 100644
index 0000000..78722bc
--- /dev/null
+++ b/apps/docs/src/components/demo.tsx
@@ -0,0 +1,33 @@
+import type { ReactNode } from "react";
+
+import { CodeBlock } from "./code-block";
+
+export type DemoProps = {
+ /** The live components. Real dowel, never a mock-up of one. */
+ children: ReactNode;
+ /** The source that produces exactly what is in the preview. */
+ code: string;
+ /**
+ * `row` centres a wrapping row of controls — right for buttons and badges.
+ * `stack` is for things with width, like a field or a menu. `start` is for
+ * a single trigger that should not float in the middle of the surface.
+ */
+ layout?: "row" | "stack" | "start";
+};
+
+/**
+ * A demo is a preview surface and its source welded together, because the
+ * two drifting apart is the failure mode of every component doc. They share
+ * one bordered container so the code reads as the caption to the picture
+ * rather than as an unrelated block that happens to sit underneath.
+ */
+export function Demo({ children, code, layout = "row" }: DemoProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/docs/src/components/docs-page.tsx b/apps/docs/src/components/docs-page.tsx
new file mode 100644
index 0000000..43e8d71
--- /dev/null
+++ b/apps/docs/src/components/docs-page.tsx
@@ -0,0 +1,185 @@
+import { Link, useRouterState } from "@tanstack/react-router";
+import type { ReactNode } from "react";
+import { useEffect, useState } from "react";
+
+import { componentNav } from "../lib/nav";
+import { ArrowRightIcon } from "./icons";
+
+export type TocEntry = { id: string; title: string };
+
+/**
+ * A section heading that the table of contents can point at. The `id` is
+ * supplied rather than derived from the title so the two lists cannot drift:
+ * the same string is the anchor target and the TOC href, and a mismatch is a
+ * dead link the moment a heading is reworded.
+ */
+export function Section({
+ id,
+ title,
+ children,
+}: {
+ id: string;
+ title: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+/**
+ * Highlights the heading currently in view. Runs only in an effect, so the
+ * prerender never touches IntersectionObserver, and it degrades to a plain
+ * list of links if the API is missing.
+ */
+function useActiveHeading(toc: TocEntry[]) {
+ const [active, setActive] = useState(null);
+
+ useEffect(() => {
+ if (toc.length === 0 || typeof IntersectionObserver === "undefined") return;
+
+ const seen = new Map();
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const e of entries) seen.set(e.target.id, e.isIntersecting);
+ // First visible heading in document order wins, so scrolling up and
+ // down through a section does not flip the highlight around.
+ const first = toc.find((t) => seen.get(t.id));
+ if (first) setActive(first.id);
+ },
+ // Bias the band towards the top of the viewport: the heading you are
+ // reading under is the one that just left the top, not the one in the
+ // vertical middle of the screen.
+ { rootMargin: "-80px 0px -70% 0px", threshold: 0 },
+ );
+
+ const nodes = toc
+ .map((t) => document.getElementById(t.id))
+ .filter((n): n is HTMLElement => n !== null);
+ for (const n of nodes) observer.observe(n);
+ return () => observer.disconnect();
+ }, [toc]);
+
+ return active;
+}
+
+function PageFooterNav() {
+ const pathname = useRouterState({
+ select: (s) => s.location.pathname,
+ });
+ // Trailing slashes appear on the prerendered pages but not in dev, so
+ // normalise before comparing or every page loses its prev/next in one of
+ // the two environments.
+ const current = pathname.replace(/\/+$/, "");
+ const i = componentNav.findIndex((item) => item.to === current);
+ if (i === -1) return null;
+
+ const prev = componentNav[i - 1];
+ const next = componentNav[i + 1];
+
+ return (
+
+ );
+}
+
+export type DocsPageProps = {
+ eyebrow?: string;
+ title: string;
+ lead: string;
+ toc?: TocEntry[];
+ children: ReactNode;
+};
+
+/**
+ * Returns a fragment, not a wrapper: the content column and the table of
+ * contents are two cells of the shell's grid, so putting a div around them
+ * would collapse the three-column layout into two.
+ */
+export function DocsPage({
+ eyebrow = "Components",
+ title,
+ lead,
+ toc = [],
+ children,
+}: DocsPageProps) {
+ const active = useActiveHeading(toc);
+
+ return (
+ <>
+
+
+
+
{eyebrow}
+
{title}
+
{lead}
+
+ {children}
+
+
+
+
+
+ >
+ );
+}
+
+/** The card grid used by the landing page and the components index. */
+export function ComponentGrid() {
+ return (
+
+ {componentNav.map((item) => (
+
+
+
+ {item.title}
+
+
+ {item.summary}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/docs/src/components/icons.tsx b/apps/docs/src/components/icons.tsx
new file mode 100644
index 0000000..a5f8965
--- /dev/null
+++ b/apps/docs/src/components/icons.tsx
@@ -0,0 +1,96 @@
+/**
+ * The docs' own icon set. dowel ships components, not icons, and the docs
+ * deliberately take no icon dependency — so these are hand-authored at a
+ * single 16px grid with one stroke weight, which is what keeps them looking
+ * like one set rather than a pile of clip art.
+ *
+ * All of them inherit `currentColor` and carry `aria-hidden`: every icon here
+ * sits inside a control that already has a text label or an IconButton
+ * `label`, so none of them is ever the accessible name.
+ */
+
+type IconProps = { size?: number };
+
+const stroke = {
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 1.25,
+ strokeLinecap: "round",
+ strokeLinejoin: "round",
+} as const;
+
+function Svg({
+ size = 16,
+ children,
+}: IconProps & { children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+export const SunIcon = (p: IconProps) => (
+
+);
+
+export const MoonIcon = (p: IconProps) => (
+
+);
+
+export const MenuIcon = (p: IconProps) => (
+
+);
+
+export const CloseIcon = (p: IconProps) => (
+
+);
+
+export const CopyIcon = (p: IconProps) => (
+
+);
+
+export const CheckIcon = (p: IconProps) => (
+
+);
+
+export const ArrowRightIcon = (p: IconProps) => (
+
+);
+
+/** The GitHub mark is a filled glyph, so it opts out of the stroke preset. */
+export const GitHubIcon = ({ size = 16 }: IconProps) => (
+
+);
diff --git a/apps/docs/src/components/sidebar-nav.tsx b/apps/docs/src/components/sidebar-nav.tsx
new file mode 100644
index 0000000..66682f0
--- /dev/null
+++ b/apps/docs/src/components/sidebar-nav.tsx
@@ -0,0 +1,35 @@
+import { Link } from "@tanstack/react-router";
+
+import { nav } from "../lib/nav";
+
+/**
+ * One nav, rendered twice: once in the sticky desktop sidebar and once in the
+ * mobile disclosure panel. Sharing the component is what stops the two from
+ * listing different components, which is the usual way a mobile menu rots.
+ */
+export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
+ return (
+
+ );
+}
diff --git a/apps/docs/src/docs.css b/apps/docs/src/docs.css
new file mode 100644
index 0000000..122197b
--- /dev/null
+++ b/apps/docs/src/docs.css
@@ -0,0 +1,983 @@
+/* The docs site's own layout. Deliberately not part of dowel: page chrome is
+ an application concern, and dowel ships components, not a shell.
+
+ Everything here is plain CSS on dowel's public tokens — no Tailwind, no
+ preprocessor — because the docs are the library's first consumer, and a
+ consumer that needs a build pipeline to use dowel would be evidence against
+ the whole premise. Colour, type, radius and motion all come from
+ --dowel-*. The only values defined locally are the ones dowel has no token
+ for: page-scale spacing (dowel's space scale tops out at 18px — it is sized
+ for the inside of a control, not for a page), layout widths, and syntax
+ highlighting colours. Those live in --docs-*. */
+
+/* ---------------------------------------------------------------- tokens */
+
+:root {
+ /* Lets native scrollbars and form controls follow the theme. The explicit
+ overrides below mirror dowel's own precedence: an explicit choice on
+ :root beats the OS. */
+ color-scheme: light dark;
+
+ --docs-header-h: 3.5rem;
+ --docs-sidebar-w: 15rem;
+ --docs-toc-w: 14rem;
+ --docs-max: 88rem;
+ --docs-gutter: 1.25rem;
+
+ /* Elevation is a light-mode affordance; the dark block drops it. */
+ --docs-panel-shadow: var(--dowel-shadow-popover);
+
+ /* Syntax colours. Low chroma on purpose — a code block that out-colours
+ the components it documents is a code block competing with the page. */
+ --docs-code-bg: lch(97.5% 0.85 var(--dowel-hue));
+ --docs-code-comment: var(--dowel-text-4);
+ --docs-code-keyword: lch(46% 52 310);
+ --docs-code-string: lch(45% 42 145);
+ --docs-code-tag: lch(45% 34 195);
+ --docs-code-attr: lch(48% 42 55);
+}
+
+:root[data-dowel-theme="light"] {
+ color-scheme: light;
+}
+
+.dowel-dark,
+[data-dowel-theme="dark"] {
+ color-scheme: dark;
+
+ --docs-panel-shadow: none;
+
+ --docs-code-bg: lch(7.9% 1 var(--dowel-hue));
+ --docs-code-comment: var(--dowel-text-4);
+ --docs-code-keyword: lch(73% 38 310);
+ --docs-code-string: lch(73% 36 145);
+ --docs-code-tag: lch(72% 34 195);
+ --docs-code-attr: lch(77% 40 70);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not(.dowel-light):not([data-dowel-theme="light"]) {
+ color-scheme: dark;
+
+ --docs-panel-shadow: none;
+
+ --docs-code-bg: lch(7.9% 1 var(--dowel-hue));
+ --docs-code-comment: var(--dowel-text-4);
+ --docs-code-keyword: lch(73% 38 310);
+ --docs-code-string: lch(73% 36 145);
+ --docs-code-tag: lch(72% 34 195);
+ --docs-code-attr: lch(77% 40 70);
+ }
+}
+
+@media (min-width: 40rem) {
+ :root {
+ --docs-gutter: 2rem;
+ }
+}
+
+/* ------------------------------------------------------------------ base */
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100dvh;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* Anchor links jump to a heading that would otherwise land under the sticky
+ header. */
+:target,
+[id] {
+ scroll-margin-top: calc(var(--docs-header-h) + 1.5rem);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ html {
+ scroll-behavior: smooth;
+ }
+}
+
+.docs-icon {
+ flex: none;
+ display: block;
+}
+
+/* ---------------------------------------------------------------- header */
+
+.docs-header {
+ position: sticky;
+ inset-block-start: 0;
+ z-index: 40;
+ background-color: color-mix(in srgb, var(--dowel-bg-1) 88%, transparent);
+ backdrop-filter: blur(12px);
+ border-block-end: 1px solid var(--dowel-border-1);
+}
+
+.docs-header-inner {
+ display: flex;
+ align-items: center;
+ gap: var(--dowel-space-6);
+ block-size: var(--docs-header-h);
+ max-inline-size: var(--docs-max);
+ margin-inline: auto;
+ padding-inline: var(--docs-gutter);
+}
+
+.docs-header-mobile {
+ display: flex;
+}
+
+.docs-brand {
+ text-decoration: none;
+ color: var(--dowel-text-1);
+}
+
+.docs-wordmark {
+ display: flex;
+ align-items: center;
+ gap: var(--dowel-space-3);
+ font-size: var(--dowel-fs-lg);
+ font-weight: var(--dowel-fw-semibold);
+ letter-spacing: var(--dowel-tracking);
+ line-height: 1;
+}
+
+.docs-mark {
+ color: var(--dowel-accent);
+}
+
+.docs-header-nav {
+ display: none;
+ align-items: center;
+ gap: var(--dowel-space-8);
+ margin-inline-start: var(--dowel-space-6);
+}
+
+.docs-header-nav a {
+ color: var(--dowel-text-3);
+ text-decoration: none;
+ font-size: var(--dowel-fs-small);
+}
+
+.docs-header-nav a:hover,
+.docs-header-nav a[data-status="active"] {
+ color: var(--dowel-text-1);
+}
+
+/* dowel components accept no className, so every layout hook is a wrapper
+ the docs own. */
+.docs-header-end {
+ display: flex;
+ align-items: center;
+ gap: var(--dowel-space-2);
+ margin-inline-start: auto;
+}
+
+/* Both icons ship in the markup; CSS picks the one matching the resolved
+ theme, so the prerendered HTML is correct under either OS setting. The
+ selector chain mirrors dowel's: explicit attribute, then system. */
+.docs-theme-icon {
+ display: none;
+}
+.docs-theme-icon[data-icon="moon"] {
+ display: block;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not([data-dowel-theme="light"]) .docs-theme-icon[data-icon="moon"] {
+ display: none;
+ }
+ :root:not([data-dowel-theme="light"]) .docs-theme-icon[data-icon="sun"] {
+ display: block;
+ }
+}
+:root[data-dowel-theme="dark"] .docs-theme-icon[data-icon="moon"] {
+ display: none;
+}
+:root[data-dowel-theme="dark"] .docs-theme-icon[data-icon="sun"] {
+ display: block;
+}
+:root[data-dowel-theme="light"] .docs-theme-icon[data-icon="moon"] {
+ display: block;
+}
+:root[data-dowel-theme="light"] .docs-theme-icon[data-icon="sun"] {
+ display: none;
+}
+
+/* A tooltip that ends in a Kbd: the label and the cap sit on one baseline
+ with a gap, instead of the cap hanging off the end of a text run. */
+.docs-tooltip-hint {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--dowel-space-3);
+}
+
+/* ------------------------------------------------------------ mobile nav */
+
+.docs-mobile-nav {
+ display: none;
+ max-block-size: calc(100dvh - var(--docs-header-h));
+ overflow-y: auto;
+ padding: var(--dowel-space-8) var(--docs-gutter) 2rem;
+ background-color: var(--dowel-bg-1);
+ border-block-start: 1px solid var(--dowel-border-1);
+}
+
+:root[data-nav-open] .docs-mobile-nav {
+ display: block;
+}
+
+/* ------------------------------------------------------------------- nav */
+
+.docs-nav-section {
+ margin-block-end: var(--dowel-space-8);
+}
+
+.docs-nav-heading {
+ margin: 0 0 var(--dowel-space-3);
+ padding-inline-start: var(--dowel-space-5);
+ font-size: var(--dowel-fs-small);
+ font-weight: var(--dowel-fw-semibold);
+ color: var(--dowel-text-1);
+}
+
+.docs-nav ul {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.docs-nav a {
+ display: block;
+ padding: var(--dowel-space-3) var(--dowel-space-5);
+ border-radius: var(--dowel-radius-sm);
+ font-size: var(--dowel-fs-small);
+ color: var(--dowel-text-3);
+ text-decoration: none;
+ transition: var(--dowel-transition);
+}
+
+.docs-nav a:hover {
+ color: var(--dowel-text-2);
+ background-color: var(--dowel-bg-2);
+}
+
+/* The current page is marked with a muted surface and an accent rule, never
+ a filled accent background — and the weight never changes between states,
+ so the list does not reflow as you navigate. */
+.docs-nav a[data-status="active"] {
+ color: var(--dowel-text-1);
+ background-color: var(--dowel-bg-3);
+ box-shadow: inset 2px 0 0 var(--dowel-accent);
+}
+
+/* ----------------------------------------------------------------- shell */
+
+.docs-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0;
+ max-inline-size: var(--docs-max);
+ margin-inline: auto;
+ padding-inline: var(--docs-gutter);
+}
+
+.docs-sidebar {
+ display: none;
+}
+
+@media (min-width: 64rem) {
+ .docs-shell {
+ grid-template-columns: var(--docs-sidebar-w) minmax(0, 1fr);
+ gap: 3rem;
+ }
+
+ .docs-sidebar {
+ display: block;
+ position: sticky;
+ inset-block-start: var(--docs-header-h);
+ block-size: calc(100dvh - var(--docs-header-h));
+ overflow-y: auto;
+ margin-inline-start: calc(var(--docs-gutter) * -1);
+ padding: 2rem 1rem 3rem var(--docs-gutter);
+ border-inline-end: 1px solid var(--dowel-border-1);
+ }
+
+ :root[data-nav-open] .docs-mobile-nav {
+ display: none;
+ }
+
+ .docs-header-mobile {
+ display: none;
+ }
+
+ .docs-header-nav {
+ display: flex;
+ }
+}
+
+@media (min-width: 80rem) {
+ .docs-shell {
+ grid-template-columns:
+ var(--docs-sidebar-w) minmax(0, 1fr)
+ var(--docs-toc-w);
+ }
+}
+
+/* --------------------------------------------------------------- content */
+
+.docs-content {
+ /* Grid children default to min-width:auto and would refuse to shrink below
+ a long line of code, pushing the whole layout wide. */
+ min-inline-size: 0;
+ container-type: inline-size;
+ padding-block: 2.5rem 5rem;
+}
+
+.docs-article {
+ max-inline-size: 72ch;
+}
+
+.docs-article-head {
+ padding-block-end: 1.75rem;
+ border-block-end: 1px solid var(--dowel-border-1);
+}
+
+.docs-eyebrow {
+ margin: 0 0 var(--dowel-space-4);
+ font-family: var(--dowel-mono);
+ font-size: var(--dowel-fs-mini);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--dowel-text-3);
+}
+
+.docs-content h1 {
+ margin: 0;
+ font-size: clamp(1.875rem, 1.4rem + 1.6vw, var(--dowel-fs-title1));
+ font-weight: var(--dowel-fw-semibold);
+ letter-spacing: var(--dowel-tracking-title);
+ line-height: 1.15;
+ color: var(--dowel-text-1);
+ text-wrap: balance;
+}
+
+.docs-lead {
+ margin: var(--dowel-space-7) 0 0;
+ max-inline-size: 62ch;
+ font-size: 1.0625rem;
+ line-height: 1.6;
+ color: var(--dowel-text-3);
+ text-wrap: pretty;
+}
+
+.docs-section {
+ margin-block-start: 3.5rem;
+}
+
+.docs-section h2 {
+ margin: 0 0 var(--dowel-space-7);
+ font-size: var(--dowel-fs-title2);
+ font-weight: var(--dowel-fw-semibold);
+ letter-spacing: var(--dowel-tracking-title);
+ color: var(--dowel-text-1);
+}
+
+.docs-anchor {
+ color: inherit;
+ text-decoration: none;
+}
+
+.docs-anchor::after {
+ content: "#";
+ margin-inline-start: var(--dowel-space-4);
+ color: var(--dowel-text-4);
+ opacity: 0;
+}
+
+.docs-anchor:hover::after {
+ opacity: 1;
+}
+
+.docs-article p {
+ margin: 0 0 var(--dowel-space-8);
+ /* 16px on mobile, dropping to dowel's own 15px base once there is room —
+ body copy below 16px on a phone is not readable. */
+ font-size: 1rem;
+ line-height: 1.65;
+ color: var(--dowel-text-2);
+ text-wrap: pretty;
+}
+
+@media (min-width: 40rem) {
+ .docs-article p {
+ font-size: var(--dowel-fs-base);
+ }
+}
+
+/* Prose links only. The whole selector sits in :where() so it carries zero
+ specificity — a component with its own link styling (the card grid, which
+ renders
whose inline
+ // style could create a containing block (transform/filter) and break the
+ // popup's position: fixed, so its channels matter structurally.
+ render(
+
+ Open
+
+
+
+ Delete issue
+
+ This cannot be undone.
+
+ Cancel
+
+
+ ,
+ );
+ for (const [id, dowelClass] of [
+ ["s-trigger", null],
+ ["s-portal", null],
+ ["s-backdrop", "dowel-backdrop"],
+ ["s-popup", "dowel-dialog"],
+ ["s-title", "dowel-dialog-title"],
+ ["s-description", "dowel-dialog-description"],
+ ["s-close", null],
+ ] as const) {
+ // The surviving `id` is how each part is found: it proves functional
+ // props pass through while the appearance channels are stripped.
+ const el = document.getElementById(id);
+ expect(el, id).not.toBeNull();
+ if (dowelClass) {
+ expect(el!.className, id).toContain(dowelClass);
+ }
+ expect(el!.className, id).not.toContain("evil");
+ expect(el!.style.color, id).toBe("");
+ }
+ });
+
+ // Full tab-cycle focus trapping is deliberately NOT tested: userEvent.tab()
+ // bypasses Base UI's focus guards, so such a test could not prove anything.
+ // Focus return is assertable and is a top-three dialog accessibility defect.
+ it("moves focus into the popup on open and returns it on Escape", async () => {
+ render();
+ const trigger = screen.getByRole("button", { name: "Open" });
+ await userEvent.click(trigger);
+ const dialog = screen.getByRole("dialog");
+ // Base UI moves initial focus asynchronously, so poll. The trigger sits
+ // outside the dialog, so containment failing (or timing out) means focus
+ // never left it — the assertion cannot pass vacuously.
+ await waitFor(() =>
+ expect(dialog.contains(document.activeElement)).toBe(true),
+ );
+ await userEvent.keyboard("{Escape}");
+ await waitFor(() => expect(document.activeElement).toBe(trigger));
+ });
+
+ it("returns focus to the trigger when closed via the Close control", async () => {
+ render();
+ const trigger = screen.getByRole("button", { name: "Open" });
+ await userEvent.click(trigger);
+ await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ await waitFor(() => expect(document.activeElement).toBe(trigger));
+ });
+
+ it("opens without console errors or warnings", async () => {
+ // Vitest 4 intercepts console output, so a visually clean run proves
+ // nothing — spy and assert.
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Open" }));
+ expect(error.mock.calls).toEqual([]);
+ expect(warn.mock.calls).toEqual([]);
+ } finally {
+ error.mockRestore();
+ warn.mockRestore();
+ }
+ });
+
+ it("has no accessibility violations when open", async () => {
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Open" }));
+ await expectNoA11yViolations(screen.getByRole("dialog"));
+ });
+});
diff --git a/packages/dowel/src/components/dialog/index.tsx b/packages/dowel/src/components/dialog/index.tsx
new file mode 100644
index 0000000..a24b326
--- /dev/null
+++ b/packages/dowel/src/components/dialog/index.tsx
@@ -0,0 +1,93 @@
+import { Dialog as BaseDialog } from "@base-ui/react/dialog";
+
+/**
+ * Public props for a Dialog part: the corresponding Base UI component's own
+ * props (so `initialFocus`, `finalFocus`, `keepMounted`, … stay reachable)
+ * minus appearance, which is not a consumer concern. Props are inferred from
+ * the component's call signature — `ComponentProps` rejects this loose
+ * constraint (its own requires a `ReactNode` return), and the result is
+ * identical.
+ */
+type Props unknown> = T extends (
+ props: infer P,
+) => unknown
+ ? Omit
+ : never;
+
+/**
+ * A modal dialog on the modal elevation tier. Compound component: compose
+ * `Root`, `Trigger`, `Portal`, `Backdrop`, `Popup`, `Title`, `Description`
+ * and `Close`. `Title` labels the dialog for assistive tech automatically —
+ * no hand-rolled `aria-labelledby`.
+ */
+export const Dialog = {
+ Root: BaseDialog.Root,
+
+ // Trigger, Portal and Close are structural — dowel gives them no class of
+ // their own — but they still render real elements (Portal a
, Trigger
+ // and Close a native when no `render` is given), so their
+ // className/style channels must be neutralised like the styled parts'.
+ // Portal is the sharp edge: an inline `transform`/`filter` on its
+ // creates a containing block that silently breaks the popup's
+ // `position: fixed`. An element passed via `render` still carries its own
+ // attributes — that escape hatch is by design.
+ Trigger: function DialogTrigger(props: Props) {
+ return (
+
+ );
+ },
+
+ Portal: function DialogPortal(props: Props) {
+ return (
+
+ );
+ },
+
+ Backdrop: function DialogBackdrop(props: Props) {
+ return (
+
+ );
+ },
+
+ Popup: function DialogPopup(props: Props) {
+ return (
+
+ );
+ },
+
+ Title: function DialogTitle(props: Props) {
+ return (
+
+ );
+ },
+
+ Description: function DialogDescription(
+ props: Props,
+ ) {
+ return (
+
+ );
+ },
+
+ Close: function DialogClose(props: Props) {
+ return (
+
+ );
+ },
+};
diff --git a/packages/dowel/src/components/icon-button/icon-button.css b/packages/dowel/src/components/icon-button/icon-button.css
new file mode 100644
index 0000000..5682369
--- /dev/null
+++ b/packages/dowel/src/components/icon-button/icon-button.css
@@ -0,0 +1,49 @@
+@layer dowel.components {
+ .dowel-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ inline-size: var(--dowel-h);
+ block-size: var(--dowel-h);
+ padding: 0;
+
+ color: var(--dowel-text-2);
+ background-color: transparent;
+ border: var(--dowel-hairline) solid transparent;
+ border-radius: var(--dowel-radius-pill);
+ transition: var(--dowel-transition);
+ cursor: default;
+ user-select: none;
+ /* `render={}` is a supported escape hatch, and an anchor arrives
+ underlined from the UA sheet. */
+ text-decoration: none;
+ }
+
+ .dowel-icon-btn[data-size="sm"] {
+ inline-size: var(--dowel-h-sm);
+ block-size: var(--dowel-h-sm);
+ }
+
+ .dowel-icon-btn[data-variant="secondary"] {
+ background-color: var(--dowel-bg-2);
+ border-color: var(--dowel-border-2);
+ }
+
+ .dowel-icon-btn:hover:not(:disabled):not([aria-disabled="true"]) {
+ background-color: var(--dowel-bg-3);
+ }
+
+ .dowel-icon-btn:focus-visible {
+ outline: 1px solid var(--dowel-focus);
+ outline-offset: 1px;
+ }
+
+ /* :disabled only matches form controls; an IconButton rendered as an anchor
+ with nativeButton={false} is disabled via aria-disabled instead. */
+ .dowel-icon-btn:disabled,
+ .dowel-icon-btn[aria-disabled="true"] {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
diff --git a/packages/dowel/src/components/icon-button/icon-button.test.tsx b/packages/dowel/src/components/icon-button/icon-button.test.tsx
new file mode 100644
index 0000000..ab41751
--- /dev/null
+++ b/packages/dowel/src/components/icon-button/icon-button.test.tsx
@@ -0,0 +1,115 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { expectNoA11yViolations } from "../../../test/setup";
+import { renderBoth } from "../../../test/render";
+import { IconButton } from "./index";
+
+const Icon = () => ;
+
+describe("IconButton", () => {
+ it("names the button from the required label prop", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Close" })).toBeDefined();
+ });
+
+ it("is a circle by default at 28px", () => {
+ render(
+
+
+ ,
+ );
+ const btn = screen.getByRole("button");
+ expect(btn.className).toContain("dowel-icon-btn");
+ expect(btn.dataset.size).toBe("md");
+ });
+
+ // The docs site's theme toggle is an IconButton, so onClick surviving the
+ // spread-then-override ordering is load-bearing, not incidental.
+ it("fires onClick", async () => {
+ const onClick = vi.fn();
+ render(
+
+
+ ,
+ );
+ await userEvent.click(screen.getByRole("button"));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("supports the sm size", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button").dataset.size).toBe("sm");
+ });
+
+ it("ignores className and style smuggled through a spread", () => {
+ // IconButtonProps Omits className/style, but JSX spreads skip
+ // excess-property checks, so a wider object typechecks. The runtime must
+ // hold the line.
+ const smuggled = { className: "evil", style: { color: "red" } };
+ render(
+
+
+ ,
+ );
+ const btn = screen.getByRole("button");
+ expect(btn.className).toContain("dowel-icon-btn");
+ expect(btn.className).not.toContain("evil");
+ expect(btn.getAttribute("style")).toBeNull();
+ });
+
+ it("keeps the label prop's aria-label over a smuggled one", () => {
+ // aria-label is Omitted from the props for the same reason label is
+ // required: the accessible name must come from `label`, always.
+ const smuggled = { "aria-label": "evil" };
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Close" })).toBeDefined();
+ });
+
+ it("renders as another element via render plus nativeButton={false}", () => {
+ // Base UI swaps native semantics for role="button" here, so the
+ // anchor is queried by that role, not "link".
+ render(
+ } nativeButton={false}>
+
+ ,
+ );
+ const el = screen.getByRole("button", { name: "Docs" });
+ expect(el.tagName).toBe("A");
+ expect(el.getAttribute("href")).toBe("/docs");
+ expect(el.className).toContain("dowel-icon-btn");
+ // `type` is a MIME hint on anchors — it must not leak from button mode.
+ expect(el.hasAttribute("type")).toBe(false);
+ });
+
+ it("renders in both themes", () => {
+ const { light, dark } = renderBoth(
+
+
+ ,
+ );
+ expect(light.querySelector(".dowel-icon-btn")).not.toBeNull();
+ expect(dark.querySelector(".dowel-icon-btn")).not.toBeNull();
+ });
+
+ it("has no accessibility violations", async () => {
+ const { container } = render(
+
+
+ ,
+ );
+ await expectNoA11yViolations(container);
+ });
+});
diff --git a/packages/dowel/src/components/icon-button/index.tsx b/packages/dowel/src/components/icon-button/index.tsx
new file mode 100644
index 0000000..0dfc1f7
--- /dev/null
+++ b/packages/dowel/src/components/icon-button/index.tsx
@@ -0,0 +1,52 @@
+import { Button as BaseButton } from "@base-ui/react/button";
+import { forwardRef } from "react";
+import type { ComponentPropsWithoutRef, ReactElement } from "react";
+
+type NativeButtonProps = Omit<
+ ComponentPropsWithoutRef<"button">,
+ // dowel is opinionated: appearance is not a consumer concern. aria-label is
+ // omitted too — the accessible name comes from the required `label` prop.
+ "className" | "style" | "aria-label"
+>;
+
+export interface IconButtonProps extends NativeButtonProps {
+ /** Accessible name. Required — an icon alone never names a control. */
+ label: string;
+ /** Visual weight. Defaults to `ghost`. */
+ variant?: "secondary" | "ghost";
+ /** Control size. `sm` is 24px, `md` is 28px. Defaults to `md`. */
+ size?: "sm" | "md";
+ /** Render as a different element, e.g. `render={}`. */
+ render?: ReactElement;
+ /**
+ * Whether the rendered element is a native ``. Rendering a
+ * non-button element via `render` (e.g. an anchor) requires
+ * `nativeButton={false}` so Base UI applies button semantics instead of
+ * native-button attributes. Defaults to `true`.
+ */
+ nativeButton?: boolean;
+}
+
+export const IconButton = forwardRef(
+ function IconButton(
+ { label, variant = "ghost", size = "md", render, ...props },
+ ref,
+ ) {
+ return (
+
+ );
+ },
+);
diff --git a/packages/dowel/src/components/input/index.tsx b/packages/dowel/src/components/input/index.tsx
new file mode 100644
index 0000000..bb5fa43
--- /dev/null
+++ b/packages/dowel/src/components/input/index.tsx
@@ -0,0 +1,123 @@
+import { Field as BaseField } from "@base-ui/react/field";
+import { Input as BaseInput } from "@base-ui/react/input";
+import { forwardRef } from "react";
+import type { ComponentPropsWithoutRef } from "react";
+
+export interface InputProps
+ extends Omit<
+ ComponentPropsWithoutRef<"input">,
+ // dowel is opinionated: appearance is not a consumer concern. The native
+ // `size` attribute (a character count) is omitted so dowel's `size`
+ // (a visual scale) can take the name.
+ "className" | "style" | "size"
+ > {
+ /** `md` is the 28px control height, `lg` is the 36px field height. */
+ size?: "md" | "lg";
+ /** Marks the input invalid for assistive tech via `aria-invalid`. */
+ invalid?: boolean;
+}
+
+export const Input = forwardRef(function Input(
+ { size = "md", invalid, ...props },
+ ref,
+) {
+ return (
+
+ );
+});
+
+/**
+ * Public props for a Field part: the corresponding Base UI component's own
+ * props (so `invalid`, `validate`, `validationMode`, `match`, … stay
+ * reachable) minus appearance, which is not a consumer concern. Props are
+ * inferred from the component's call signature — `ComponentProps` rejects
+ * this loose constraint (its own requires a `ReactNode` return), and the
+ * result is identical.
+ */
+type Props unknown> = T extends (
+ props: infer P,
+) => unknown
+ ? Omit
+ : never;
+
+/**
+ * Field wires a label, description and error message to a control, so the
+ * association is never hand-rolled with matching id strings. A dowel `Input`
+ * placed inside `Field.Root` is associated automatically — Base UI's Input
+ * is the control Field owns.
+ */
+export const Field = {
+ Root: forwardRef>(
+ function FieldRoot(props, ref) {
+ return (
+
+ );
+ },
+ ),
+
+ Label: forwardRef<
+ HTMLLabelElement,
+ // htmlFor is omitted on top: Field generates the association, and a
+ // hand-written htmlFor would win over it — the exact bug Field removes.
+ Omit, "htmlFor">
+ >(function FieldLabel(props, ref) {
+ return (
+
+ );
+ }),
+
+ Description: forwardRef<
+ HTMLParagraphElement,
+ Props
+ >(function FieldDescription(props, ref) {
+ return (
+
+ );
+ }),
+
+ // Base UI's Field.Error renders a
, not a
— the ref type says so.
+ Error: forwardRef>(
+ function FieldError(props, ref) {
+ return (
+
+ );
+ },
+ ),
+};
diff --git a/packages/dowel/src/components/input/input.css b/packages/dowel/src/components/input/input.css
new file mode 100644
index 0000000..d73a280
--- /dev/null
+++ b/packages/dowel/src/components/input/input.css
@@ -0,0 +1,69 @@
+@layer dowel.components {
+ .dowel-input {
+ inline-size: 100%;
+ block-size: var(--dowel-h);
+ padding-inline: var(--dowel-space-5);
+
+ font-family: var(--dowel-font);
+ font-size: var(--dowel-fs-small);
+ font-weight: var(--dowel-fw-normal);
+ letter-spacing: var(--dowel-tracking);
+
+ color: var(--dowel-text-2);
+ background-color: var(--dowel-bg-2);
+ border: var(--dowel-hairline) solid var(--dowel-border-2);
+ border-radius: var(--dowel-radius);
+ transition: var(--dowel-transition);
+ }
+
+ .dowel-input[data-size="lg"] {
+ block-size: var(--dowel-h-field);
+ border-radius: var(--dowel-radius-lg);
+ padding-inline: var(--dowel-space-6);
+ }
+
+ .dowel-input::placeholder {
+ color: var(--dowel-text-4);
+ }
+
+ .dowel-input:hover:not(:disabled):not([aria-disabled="true"]) {
+ border-color: var(--dowel-border-3);
+ }
+
+ .dowel-input:focus-visible {
+ outline: 1px solid var(--dowel-focus);
+ outline-offset: -1px;
+ border-color: var(--dowel-focus);
+ }
+
+ .dowel-input[aria-invalid="true"] {
+ border-color: var(--dowel-danger);
+ }
+
+ .dowel-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .dowel-field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--dowel-space-3);
+ }
+
+ .dowel-field-label {
+ font-size: var(--dowel-fs-small);
+ font-weight: var(--dowel-fw-medium);
+ color: var(--dowel-text-2);
+ }
+
+ .dowel-field-description {
+ font-size: var(--dowel-fs-mini);
+ color: var(--dowel-text-3);
+ }
+
+ .dowel-field-error {
+ font-size: var(--dowel-fs-mini);
+ color: var(--dowel-danger);
+ }
+}
diff --git a/packages/dowel/src/components/input/input.test.tsx b/packages/dowel/src/components/input/input.test.tsx
new file mode 100644
index 0000000..6660382
--- /dev/null
+++ b/packages/dowel/src/components/input/input.test.tsx
@@ -0,0 +1,143 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import { expectNoA11yViolations } from "../../../test/setup";
+import { renderBoth } from "../../../test/render";
+import { Field, Input } from "./index";
+
+describe("Input", () => {
+ it("renders a textbox", () => {
+ render();
+ expect(screen.getByRole("textbox", { name: "Title" })).toBeDefined();
+ });
+
+ it("accepts typing", async () => {
+ render();
+ const input = screen.getByRole("textbox");
+ await userEvent.type(input, "hello");
+ expect((input as HTMLInputElement).value).toBe("hello");
+ });
+
+ it("marks invalid inputs for assistive tech", () => {
+ render();
+ expect(screen.getByRole("textbox").getAttribute("aria-invalid")).toBe(
+ "true",
+ );
+ });
+
+ it("defaults to md size", () => {
+ render();
+ expect(screen.getByRole("textbox").dataset.size).toBe("md");
+ });
+
+ it("carries the dowel-input class", () => {
+ render();
+ expect(screen.getByRole("textbox").className).toContain("dowel-input");
+ });
+
+ it("ignores className and style smuggled through a spread", () => {
+ // InputProps Omits className/style, but JSX spreads skip excess-property
+ // checks, so a wider object typechecks. The runtime must hold the line.
+ const smuggled = { className: "evil", style: { color: "red" } };
+ render();
+ const input = screen.getByRole("textbox");
+ expect(input.className).toContain("dowel-input");
+ expect(input.className).not.toContain("evil");
+ expect(input.getAttribute("style")).toBeNull();
+ });
+
+ it("renders in both themes", () => {
+ const { light, dark } = renderBoth();
+ expect(light.querySelector(".dowel-input")).not.toBeNull();
+ expect(dark.querySelector(".dowel-input")).not.toBeNull();
+ });
+
+ it("has no accessibility violations", async () => {
+ const { container } = render();
+ await expectNoA11yViolations(container);
+ });
+});
+
+describe("Field", () => {
+ it("associates the label with the control", () => {
+ render(
+
+ Issue title
+
+ ,
+ );
+ expect(screen.getByRole("textbox", { name: "Issue title" })).toBeDefined();
+ });
+
+ it("associates the description with the control", () => {
+ render(
+
+ Title
+
+ Keep it short
+ ,
+ );
+ const input = screen.getByRole("textbox");
+ const describedBy = input.getAttribute("aria-describedby");
+ expect(describedBy).toBeTruthy();
+ expect(document.getElementById(describedBy!)?.textContent).toBe(
+ "Keep it short",
+ );
+ });
+
+ it("renders Field.Error on an invalid field and associates it", () => {
+ // `invalid` on Root marks the whole field invalid through Base UI state;
+ // `match` makes Error display without a validation run (dowel has no
+ // Form yet, and Base UI validates onSubmit by default). Both props exist
+ // only because Field's types derive from Base UI, not from "div"/"p".
+ render(
+
+ Title
+
+ Title is required
+ ,
+ );
+ const input = screen.getByRole("textbox");
+ // The invalid field state reaches the control's ARIA without any prop
+ // on Input itself.
+ expect(input.getAttribute("aria-invalid")).toBe("true");
+ const error = document.querySelector(".dowel-field-error");
+ expect(error).not.toBeNull();
+ expect(error!.textContent).toBe("Title is required");
+ // The error is wired into the control's accessible description.
+ const describedBy = input.getAttribute("aria-describedby") ?? "";
+ expect(describedBy.split(" ")).toContain(error!.id);
+ });
+
+ it("ignores className and style smuggled through a spread", () => {
+ const smuggled = { className: "evil", style: { color: "red" } };
+ const { container } = render(
+
+ Title
+
+ Keep it short
+ ,
+ );
+ for (const selector of [
+ ".dowel-field",
+ ".dowel-field-label",
+ ".dowel-field-description",
+ ]) {
+ const el = container.querySelector(selector);
+ expect(el).not.toBeNull();
+ expect(el!.className).not.toContain("evil");
+ expect(el!.getAttribute("style")).toBeNull();
+ }
+ });
+
+ it("has no accessibility violations", async () => {
+ const { container } = render(
+
+ Title
+
+ Keep it short
+ ,
+ );
+ await expectNoA11yViolations(container);
+ });
+});
diff --git a/packages/dowel/src/components/kbd/index.tsx b/packages/dowel/src/components/kbd/index.tsx
new file mode 100644
index 0000000..5291779
--- /dev/null
+++ b/packages/dowel/src/components/kbd/index.tsx
@@ -0,0 +1,35 @@
+import { forwardRef } from "react";
+import type { ComponentPropsWithoutRef } from "react";
+
+export interface KbdProps
+ extends Omit<
+ ComponentPropsWithoutRef<"span">,
+ // dowel is opinionated: appearance is not a consumer concern. children is
+ // omitted too — the `keys` array is the only content source.
+ "className" | "style" | "children"
+ > {
+ /** One entry per key, e.g. `["Meta", "K"]`. */
+ keys: string[];
+}
+
+export const Kbd = forwardRef(function Kbd(
+ { keys, ...props },
+ ref,
+) {
+ return (
+
+ {keys.map((key, i) => (
+ {key}
+ ))}
+
+ );
+});
diff --git a/packages/dowel/src/components/kbd/kbd.css b/packages/dowel/src/components/kbd/kbd.css
new file mode 100644
index 0000000..5d3babc
--- /dev/null
+++ b/packages/dowel/src/components/kbd/kbd.css
@@ -0,0 +1,27 @@
+@layer dowel.components {
+ .dowel-kbd {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ }
+
+ .dowel-kbd kbd {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ min-inline-size: 17px;
+ block-size: 17px;
+ padding: var(--dowel-space-1);
+
+ font-family: var(--dowel-font);
+ font-size: var(--dowel-fs-micro);
+ font-weight: var(--dowel-fw-normal);
+ line-height: 1;
+
+ color: var(--dowel-text-3);
+ background-color: var(--dowel-bg-2);
+ border: var(--dowel-hairline) solid var(--dowel-border-2);
+ border-radius: var(--dowel-radius-sm);
+ }
+}
diff --git a/packages/dowel/src/components/kbd/kbd.test.tsx b/packages/dowel/src/components/kbd/kbd.test.tsx
new file mode 100644
index 0000000..1fd0ede
--- /dev/null
+++ b/packages/dowel/src/components/kbd/kbd.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { expectNoA11yViolations } from "../../../test/setup";
+import { renderBoth } from "../../../test/render";
+import { Kbd } from "./index";
+
+describe("Kbd", () => {
+ it("renders one kbd element per key", () => {
+ const { container } = render();
+ const keys = container.querySelectorAll("kbd");
+ expect(keys).toHaveLength(2);
+ expect(keys[0]?.textContent).toBe("Meta");
+ expect(keys[1]?.textContent).toBe("K");
+ });
+
+ it("renders a single key", () => {
+ render();
+ expect(screen.getByText("S").tagName).toBe("KBD");
+ });
+
+ it("ignores className, style, and children smuggled through a spread", () => {
+ // KbdProps Omits className/style/children, but JSX spreads skip
+ // excess-property checks, so a wider object typechecks. The runtime must
+ // hold the line: the keys array stays the only content source.
+ const smuggled = {
+ className: "evil",
+ style: { color: "red" },
+ children: "hijacked",
+ };
+ const { container } = render();
+ const kbd = container.querySelector(".dowel-kbd");
+ expect(kbd).not.toBeNull();
+ expect(kbd?.className).not.toContain("evil");
+ expect(kbd?.getAttribute("style")).toBeNull();
+ expect(kbd?.textContent).toBe("S");
+ });
+
+ it("renders in both themes", () => {
+ const { light, dark } = renderBoth();
+ expect(light.querySelector(".dowel-kbd")).not.toBeNull();
+ expect(dark.querySelector(".dowel-kbd")).not.toBeNull();
+ });
+
+ it("has no accessibility violations", async () => {
+ const { container } = render();
+ await expectNoA11yViolations(container);
+ });
+});
diff --git a/packages/dowel/src/components/menu/index.tsx b/packages/dowel/src/components/menu/index.tsx
new file mode 100644
index 0000000..60ce8fd
--- /dev/null
+++ b/packages/dowel/src/components/menu/index.tsx
@@ -0,0 +1,104 @@
+import { Menu as BaseMenu } from "@base-ui/react/menu";
+
+/**
+ * Public props for a Menu part: the corresponding Base UI component's own
+ * props (so `sideOffset`, `keepMounted`, `closeOnClick`, … stay reachable)
+ * minus appearance, which is not a consumer concern. Props are inferred from
+ * the component's call signature — `ComponentProps` rejects this loose
+ * constraint (its own requires a `ReactNode` return), and the result is
+ * identical.
+ */
+type Props unknown> = T extends (
+ props: infer P,
+) => unknown
+ ? Omit
+ : never;
+
+/**
+ * A dropdown menu on the popover elevation tier. Compound component: compose
+ * `Root`, `Trigger`, `Portal`, `Positioner`, `Popup`, `Item`, `Separator`,
+ * `Group` and `GroupLabel`. Keyboard navigation, typeahead and highlight
+ * management come from Base UI; the highlighted item is styled via
+ * `data-highlighted` so mouse and keyboard states are identical.
+ */
+export const Menu = {
+ // Root renders no HTML element of its own — it is pure context, so there
+ // are no className/style channels to neutralise.
+ Root: BaseMenu.Root,
+
+ // Trigger, Portal, Positioner and Group are structural — dowel gives them
+ // no class of their own — but they still render real elements (a native
+ // for Trigger when no `render` is given, a
for the rest),
+ // so their className/style channels must be neutralised like the styled
+ // parts'. Portal is the sharp edge: an inline `transform`/`filter` on its
+ //
creates a containing block that silently breaks the popup's
+ // positioning. An element passed via `render` still carries its own
+ // attributes — that escape hatch is by design.
+ Trigger: function MenuTrigger(props: Props) {
+ return (
+
+ );
+ },
+
+ Portal: function MenuPortal(props: Props) {
+ return (
+
+ );
+ },
+
+ Positioner: function MenuPositioner(
+ props: Props,
+ ) {
+ return (
+
+ );
+ },
+
+ Popup: function MenuPopup(props: Props) {
+ return (
+ // Everything after the spread stays AFTER it so props spread onto the
+ // component cannot override appearance.
+
+ );
+ },
+
+ Item: function MenuItem(props: Props) {
+ return (
+
+ );
+ },
+
+ Separator: function MenuSeparator(props: Props) {
+ return (
+
+ );
+ },
+
+ Group: function MenuGroup(props: Props) {
+ return (
+
+ );
+ },
+
+ GroupLabel: function MenuGroupLabel(
+ props: Props,
+ ) {
+ return (
+
+ );
+ },
+};
diff --git a/packages/dowel/src/components/menu/menu.css b/packages/dowel/src/components/menu/menu.css
new file mode 100644
index 0000000..b03324c
--- /dev/null
+++ b/packages/dowel/src/components/menu/menu.css
@@ -0,0 +1,65 @@
+@layer dowel.components {
+ .dowel-menu {
+ min-inline-size: 180px;
+ padding-block: var(--dowel-space-2);
+
+ background-color: var(--dowel-bg-elevated);
+ border: var(--dowel-hairline) solid var(--dowel-border-3);
+ border-radius: var(--dowel-radius-lg);
+ box-shadow: var(--dowel-shadow-popover);
+
+ transition:
+ opacity var(--dowel-dur-fast) var(--dowel-ease),
+ translate var(--dowel-dur-fast) var(--dowel-ease);
+ }
+ .dowel-menu[data-starting-style],
+ .dowel-menu[data-ending-style] {
+ opacity: 0;
+ translate: 0 -2px;
+ }
+
+ .dowel-menu-item {
+ display: flex;
+ align-items: center;
+ gap: var(--dowel-space-4);
+
+ block-size: 32px;
+ padding-inline: var(--dowel-space-7) var(--dowel-space-8);
+
+ font-size: var(--dowel-fs-small);
+ font-weight: var(--dowel-fw-normal);
+ letter-spacing: var(--dowel-tracking);
+ color: var(--dowel-text-2);
+
+ cursor: default;
+ user-select: none;
+ outline: none;
+ transition: var(--dowel-transition);
+ }
+
+ /* Base UI marks the active descendant with data-highlighted; styling that
+ rather than :hover keeps mouse and keyboard states identical. */
+ .dowel-menu-item[data-highlighted] {
+ background-color: var(--dowel-bg-3);
+ color: var(--dowel-text-1);
+ }
+
+ .dowel-menu-item[data-disabled] {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .dowel-menu-separator {
+ block-size: var(--dowel-hairline);
+ margin-block: var(--dowel-space-2);
+ background-color: var(--dowel-border-2);
+ }
+
+ .dowel-menu-label {
+ display: block;
+ padding: var(--dowel-space-4) var(--dowel-space-7) var(--dowel-space-2);
+ font-size: var(--dowel-fs-mini);
+ font-weight: var(--dowel-fw-medium);
+ color: var(--dowel-text-3);
+ }
+}
diff --git a/packages/dowel/src/components/menu/menu.test.tsx b/packages/dowel/src/components/menu/menu.test.tsx
new file mode 100644
index 0000000..b297085
--- /dev/null
+++ b/packages/dowel/src/components/menu/menu.test.tsx
@@ -0,0 +1,197 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { expectNoA11yViolations } from "../../../test/setup";
+import { Button } from "../button";
+import { Menu } from "./index";
+
+function Example({ onSelect = () => {} }: { onSelect?: () => void }) {
+ return (
+
+ Actions} />
+
+
+
+ Duplicate
+
+ Delete
+
+
+
+
+ );
+}
+
+describe("Menu", () => {
+ it("is closed until triggered", () => {
+ render();
+ expect(screen.queryByRole("menu")).toBeNull();
+ });
+
+ // Base UI opens the menu one animation frame after mousedown (useClick
+ // defers setOpen to a rAF), so the popup is NOT in the DOM when
+ // userEvent.click resolves. findByRole polls; its timeout is a failure, so
+ // none of these can pass without the menu actually opening.
+ it("opens on trigger click", async () => {
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Actions" }));
+ expect(await screen.findByRole("menu")).toBeDefined();
+ expect(screen.getAllByRole("menuitem")).toHaveLength(2);
+ });
+
+ it("invokes the item handler on click", async () => {
+ const onSelect = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Actions" }));
+ await userEvent.click(
+ await screen.findByRole("menuitem", { name: "Duplicate" }),
+ );
+ expect(onSelect).toHaveBeenCalledOnce();
+ });
+
+ it("closes on Escape", async () => {
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Actions" }));
+ // Wait for the open to land first — otherwise this passes vacuously.
+ await screen.findByRole("menu");
+ await userEvent.keyboard("{Escape}");
+ expect(screen.queryByRole("menu")).toBeNull();
+ });
+
+ it("moves focus with the arrow keys", async () => {
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Actions" }));
+ await screen.findByRole("menu");
+ await userEvent.keyboard("{ArrowDown}");
+ // Base UI moves item focus asynchronously, so poll. A timeout is a
+ // failure, so the assertion cannot pass vacuously.
+ await waitFor(() =>
+ expect(document.activeElement?.textContent).toBe("Duplicate"),
+ );
+ await userEvent.keyboard("{ArrowDown}");
+ await waitFor(() =>
+ expect(document.activeElement?.textContent).toBe("Delete"),
+ );
+ });
+
+ it("returns focus to the trigger on Escape", async () => {
+ render();
+ const trigger = screen.getByRole("button", { name: "Actions" });
+ await userEvent.click(trigger);
+ await screen.findByRole("menu");
+ await userEvent.keyboard("{ArrowDown}");
+ // Focus must actually enter the menu first, or the final assertion could
+ // pass without focus ever having left the trigger.
+ await waitFor(() =>
+ expect(document.activeElement?.textContent).toBe("Duplicate"),
+ );
+ await userEvent.keyboard("{Escape}");
+ await waitFor(() => expect(document.activeElement).toBe(trigger));
+ });
+
+ it("carries the dowel classes on every styled part", async () => {
+ render(
+
+ Actions} />
+
+
+
+
+ Edit
+ Duplicate
+
+
+ Delete
+
+
+
+ ,
+ );
+ const menu = screen.getByRole("menu");
+ expect(menu.className).toContain("dowel-menu");
+ expect(menu.querySelectorAll(".dowel-menu-item")).toHaveLength(2);
+ expect(menu.querySelector(".dowel-menu-separator")).not.toBeNull();
+ expect(menu.querySelector(".dowel-menu-label")).not.toBeNull();
+ });
+
+ it("ignores className and style smuggled through a spread", async () => {
+ // Part props Omit className/style, but JSX spreads skip excess-property
+ // checks, so a wider object typechecks. The runtime must hold the line.
+ // `id` rides along for two reasons: it gives the otherwise-empty spreads
+ // a property in common with a part's all-optional props (TS2559 rejects a
+ // spread with none), and it proves functional props survive the spread
+ // while appearance is stripped. Base UI sets its own inline styles on
+ // Positioner (floating-ui placement) and Popup, so assert the smuggled
+ // DECLARATION is absent rather than that the style attribute is empty.
+ const smuggle = (id: string) => ({
+ id,
+ className: "evil",
+ style: { color: "red" },
+ });
+ // Trigger is used bare (no `render`): that path renders Base UI's own
+ // native , where a smuggled className/style would land directly.
+ // Portal renders a real
whose inline style could create a
+ // containing block (transform/filter) and break the popup's positioning,
+ // so its channels matter structurally even though it has no class.
+ render(
+
+ Actions
+
+
+
+
+ Edit
+ Duplicate
+
+
+ Delete
+
+
+
+ ,
+ );
+ for (const [id, dowelClass] of [
+ ["s-trigger", null],
+ ["s-portal", null],
+ ["s-positioner", null],
+ ["s-popup", "dowel-menu"],
+ ["s-group", null],
+ ["s-label", "dowel-menu-label"],
+ ["s-item", "dowel-menu-item"],
+ ["s-separator", "dowel-menu-separator"],
+ ] as const) {
+ // The surviving `id` is how each part is found: it proves functional
+ // props pass through while the appearance channels are stripped.
+ const el = document.getElementById(id);
+ expect(el, id).not.toBeNull();
+ if (dowelClass) {
+ expect(el!.className, id).toContain(dowelClass);
+ }
+ expect(el!.className, id).not.toContain("evil");
+ expect(el!.style.color, id).toBe("");
+ }
+ });
+
+ it("opens without console errors or warnings", async () => {
+ // Vitest 4 intercepts console output, so a visually clean run proves
+ // nothing — spy and assert.
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Actions" }));
+ await screen.findByRole("menu");
+ expect(error.mock.calls).toEqual([]);
+ expect(warn.mock.calls).toEqual([]);
+ } finally {
+ error.mockRestore();
+ warn.mockRestore();
+ }
+ });
+
+ it("has no accessibility violations when open", async () => {
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Actions" }));
+ await expectNoA11yViolations(await screen.findByRole("menu"));
+ });
+});
diff --git a/packages/dowel/src/components/tooltip/index.tsx b/packages/dowel/src/components/tooltip/index.tsx
new file mode 100644
index 0000000..155996d
--- /dev/null
+++ b/packages/dowel/src/components/tooltip/index.tsx
@@ -0,0 +1,87 @@
+import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
+
+/**
+ * Public props for a Tooltip part: the corresponding Base UI component's own
+ * props (so `delay`, `closeDelay`, `keepMounted`, … stay reachable) minus
+ * appearance, which is not a consumer concern. Props are inferred from the
+ * component's call signature — `ComponentProps` rejects this loose
+ * constraint (its own requires a `ReactNode` return), and the result is
+ * identical.
+ */
+type Props unknown> = T extends (
+ props: infer P,
+) => unknown
+ ? Omit
+ : never;
+
+/**
+ * A hover/focus label on the popover elevation tier. Compound component:
+ * compose `Root`, `Trigger`, `Portal`, `Positioner` and `Popup`, with a
+ * single `Provider` wrapping the app so adjacent tooltips share one delay
+ * and open instantly once the first is shown. Open/close timing, hover
+ * intent and focus-visible handling come from Base UI.
+ */
+export const Tooltip = {
+ // Provider and Root render no HTML element of their own — Provider is pure
+ // context (children/delay/closeDelay/timeout only) and Root's props carry
+ // no className/style — so there are no appearance channels to neutralise.
+ Provider: BaseTooltip.Provider,
+ Root: BaseTooltip.Root,
+
+ // Trigger, Portal and Positioner are structural — dowel gives them no
+ // class of their own — but they still render real elements (a native
+ // for Trigger when no `render` is given, a
for the rest),
+ // so their className/style channels must be neutralised like the styled
+ // parts'. Portal is the sharp edge: an inline `transform`/`filter` on its
+ //
creates a containing block that silently breaks the popup's
+ // positioning. An element passed via `render` still carries its own
+ // attributes — that escape hatch is by design.
+ Trigger: function TooltipTrigger(props: Props) {
+ return (
+
+ );
+ },
+
+ Portal: function TooltipPortal(props: Props) {
+ return (
+
+ );
+ },
+
+ Positioner: function TooltipPositioner(
+ props: Props,
+ ) {
+ return (
+
+ );
+ },
+
+ Popup: function TooltipPopup(props: Props) {
+ return (
+ // Everything after the spread stays AFTER it so props spread onto the
+ // component cannot override appearance.
+
+ );
+ },
+};
diff --git a/packages/dowel/src/components/tooltip/tooltip.css b/packages/dowel/src/components/tooltip/tooltip.css
new file mode 100644
index 0000000..9b5cb17
--- /dev/null
+++ b/packages/dowel/src/components/tooltip/tooltip.css
@@ -0,0 +1,22 @@
+@layer dowel.components {
+ .dowel-tooltip {
+ padding: var(--dowel-space-2) var(--dowel-space-4);
+
+ font-size: var(--dowel-fs-mini);
+ font-weight: var(--dowel-fw-medium);
+ letter-spacing: var(--dowel-tracking);
+ white-space: nowrap;
+
+ color: var(--dowel-text-1);
+ background-color: var(--dowel-bg-elevated);
+ border: var(--dowel-hairline) solid var(--dowel-border-3);
+ border-radius: var(--dowel-radius-sm);
+ box-shadow: var(--dowel-shadow-popover);
+
+ transition: opacity var(--dowel-dur-fast) var(--dowel-ease);
+ }
+ .dowel-tooltip[data-starting-style],
+ .dowel-tooltip[data-ending-style] {
+ opacity: 0;
+ }
+}
diff --git a/packages/dowel/src/components/tooltip/tooltip.test.tsx b/packages/dowel/src/components/tooltip/tooltip.test.tsx
new file mode 100644
index 0000000..c3fc638
--- /dev/null
+++ b/packages/dowel/src/components/tooltip/tooltip.test.tsx
@@ -0,0 +1,146 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { IconButton } from "../icon-button";
+import { Tooltip } from "./index";
+
+function Example() {
+ return (
+
+
+
+
+
+ }
+ />
+
+
+ Copy link
+
+
+
+
+ );
+}
+
+describe("Tooltip", () => {
+ it("is hidden until the trigger is hovered", () => {
+ render();
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ });
+
+ it("keeps the trigger independently labelled while the tooltip is hidden", () => {
+ // Base UI's documented design stance (their shipped
+ // docs/react/components/tooltip.md, "Usage guidelines"): a tooltip is a
+ // visual label only — not accessible to touch or screen reader users —
+ // so the TRIGGER must carry an accessible name closely matching the
+ // tooltip's content. In the canonical usage IconButton's required
+ // `label` guarantees that structurally; this pins the guarantee. The
+ // role+name query runs the full accessible-name computation and throws
+ // for an unlabelled trigger, so a regression fails here. Asserted with
+ // the tooltip closed deliberately: the name must exist without the
+ // popup, because assistive tech never sees the popup.
+ render();
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ expect(screen.getByRole("button", { name: "Copy link" })).toBeDefined();
+ });
+
+ // Base UI opens the tooltip asynchronously (the trigger's hover delay runs
+ // on a real timer; focus opens on the next tick). findByRole polls and its
+ // timeout is a failure, so none of these can pass without the tooltip
+ // actually appearing.
+ it("appears on hover", async () => {
+ render();
+ await userEvent.hover(screen.getByRole("button", { name: "Copy link" }));
+ expect(await screen.findByRole("tooltip")).toBeDefined();
+ });
+
+ it("appears on keyboard focus", async () => {
+ render();
+ await userEvent.tab();
+ expect(await screen.findByRole("tooltip")).toBeDefined();
+ });
+
+ it("disappears on unhover", async () => {
+ render();
+ const trigger = screen.getByRole("button", { name: "Copy link" });
+ await userEvent.hover(trigger);
+ // The open must land BEFORE the unhover — otherwise the final null
+ // assertion passes vacuously against a tooltip that never appeared.
+ await screen.findByRole("tooltip");
+ await userEvent.unhover(trigger);
+ // waitFor polls until the popup unmounts; while the trigger stays
+ // hovered the tooltip never closes, so this times out (fails) if the
+ // unhover above is removed.
+ await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
+ });
+
+ it("ignores className and style smuggled through a spread", async () => {
+ // Part props Omit className/style, but JSX spreads skip excess-property
+ // checks, so a wider object typechecks. The runtime must hold the line.
+ // `id` rides along for two reasons: it gives the otherwise-empty spreads
+ // a property in common with a part's all-optional props (TS2559 rejects a
+ // spread with none), and it proves functional props survive the spread
+ // while appearance is stripped. Base UI sets its own inline styles on
+ // Positioner (floating-ui placement) and Popup, so assert the smuggled
+ // DECLARATION is absent rather than that the style attribute is empty.
+ const smuggle = (id: string) => ({
+ id,
+ className: "evil",
+ style: { color: "red" },
+ });
+ // Trigger is used bare (no `render`): that path renders Base UI's own
+ // native , where a smuggled className/style would land directly.
+ // Portal renders a real
whose inline style could create a
+ // containing block (transform/filter) and break the popup's positioning,
+ // so its channels matter structurally even though it has no class.
+ render(
+
+
+ Copy
+
+
+ Copy link
+
+
+
+ ,
+ );
+ await screen.findByRole("tooltip");
+ for (const [id, dowelClass] of [
+ ["s-trigger", null],
+ ["s-portal", null],
+ ["s-positioner", null],
+ ["s-popup", "dowel-tooltip"],
+ ] as const) {
+ // The surviving `id` is how each part is found: it proves functional
+ // props pass through while the appearance channels are stripped.
+ const el = document.getElementById(id);
+ expect(el, id).not.toBeNull();
+ if (dowelClass) {
+ expect(el!.className, id).toContain(dowelClass);
+ }
+ expect(el!.className, id).not.toContain("evil");
+ expect(el!.style.color, id).toBe("");
+ }
+ });
+
+ it("opens without console errors or warnings", async () => {
+ // Vitest 4 intercepts console output, so a visually clean run proves
+ // nothing — spy and assert.
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ render();
+ await userEvent.hover(screen.getByRole("button", { name: "Copy link" }));
+ await screen.findByRole("tooltip");
+ expect(error.mock.calls).toEqual([]);
+ expect(warn.mock.calls).toEqual([]);
+ } finally {
+ error.mockRestore();
+ warn.mockRestore();
+ }
+ });
+});
diff --git a/packages/dowel/src/index.css b/packages/dowel/src/index.css
new file mode 100644
index 0000000..7a16e2d
--- /dev/null
+++ b/packages/dowel/src/index.css
@@ -0,0 +1,32 @@
+/* Layer order is declared once, first, before any @import. Consumers' own
+ unlayered styles beat every layer here, so overriding dowel never becomes a
+ specificity fight. */
+@layer dowel.tokens, dowel.base, dowel.components;
+
+@import "./tokens/scale.css";
+@import "./tokens/light.css";
+@import "./tokens/dark.css";
+
+/* Component sheets. CSS requires every @import before the first style rule,
+ and each sheet declares its own @layer, so listing them here cannot change
+ the cascade — the @layer statement above pins the order. */
+@import "./components/button/button.css";
+@import "./components/icon-button/icon-button.css";
+@import "./components/badge/badge.css";
+@import "./components/kbd/kbd.css";
+@import "./components/input/input.css";
+@import "./components/dialog/dialog.css";
+@import "./components/menu/menu.css";
+@import "./components/tooltip/tooltip.css";
+
+@layer dowel.base {
+ .dowel-root,
+ [data-dowel-theme] {
+ font-family: var(--dowel-font);
+ font-size: var(--dowel-fs-small);
+ font-weight: var(--dowel-fw-normal);
+ letter-spacing: var(--dowel-tracking);
+ color: var(--dowel-text-2);
+ background-color: var(--dowel-bg-1);
+ }
+}
diff --git a/packages/dowel/src/index.ts b/packages/dowel/src/index.ts
new file mode 100644
index 0000000..b25c3ab
--- /dev/null
+++ b/packages/dowel/src/index.ts
@@ -0,0 +1,16 @@
+// dowel's public surface. Everything importable from "dowel" is listed here;
+// anything not exported below is internal and may change without a major bump.
+// The stylesheet is a separate entry point: import "dowel/dowel.css" once.
+export { Button } from "./components/button";
+export type { ButtonProps } from "./components/button";
+export { IconButton } from "./components/icon-button";
+export type { IconButtonProps } from "./components/icon-button";
+export { Badge } from "./components/badge";
+export type { BadgeProps } from "./components/badge";
+export { Kbd } from "./components/kbd";
+export type { KbdProps } from "./components/kbd";
+export { Input, Field } from "./components/input";
+export type { InputProps } from "./components/input";
+export { Dialog } from "./components/dialog";
+export { Menu } from "./components/menu";
+export { Tooltip } from "./components/tooltip";
diff --git a/packages/dowel/src/tokens/dark.css b/packages/dowel/src/tokens/dark.css
new file mode 100644
index 0000000..f71653b
--- /dev/null
+++ b/packages/dowel/src/tokens/dark.css
@@ -0,0 +1,95 @@
+/* Dark overrides colour only. Three activation paths, in precedence order:
+ explicit class, explicit attribute, then system preference — and the media
+ query is guarded so an explicit light choice always wins over the OS. */
+@layer dowel.tokens {
+ .dowel-dark,
+ [data-dowel-theme="dark"] {
+ --dowel-bg-1: lch(5.52% 0.4 var(--dowel-hue));
+ --dowel-bg-2: lch(7.32% 0.85 var(--dowel-hue));
+ --dowel-bg-3: lch(8.22% 1.3 var(--dowel-hue));
+ --dowel-bg-4: lch(9.345% 0.85 var(--dowel-hue));
+ --dowel-bg-elevated: lch(12.72% 0.85 var(--dowel-hue));
+
+ --dowel-border-1: lch(9.84% 1.48 var(--dowel-hue));
+ --dowel-border-2: lch(14.16% 1.48 var(--dowel-hue));
+ --dowel-border-3: lch(25.68% 1.93 var(--dowel-hue));
+
+ --dowel-text-1: lch(100% 0 var(--dowel-hue));
+ --dowel-text-2: lch(90.451% 1.2 var(--dowel-hue));
+ --dowel-text-3: lch(61.803% 1.2 var(--dowel-hue));
+ --dowel-text-4: lch(36.975% 1.2 var(--dowel-hue));
+
+ /* Same teal, lifted to sit on near-black surfaces: 7.71:1 against bg-1.
+ Chroma tops out around here — lch(64% 40 195) and anything more
+ saturated at this lightness clips the sRGB green channel. */
+ --dowel-accent: lch(68% 36 195);
+ /* Dark hovers LIGHTEN. Derived from the accent so overrides follow. */
+ --dowel-accent-hover: color-mix(in oklch, var(--dowel-accent) 85%, white);
+ /* Inverted from light on purpose. Teal is luminous for its lightness, so
+ a teal bright enough to read on near-black leaves white at 2.43:1 —
+ unusable. A near-black ink from the accent's own hue gives 6.42:1 at
+ rest and 7.44:1 on hover. */
+ --dowel-accent-fg: lch(14% 6 195);
+ --dowel-focus: var(--dowel-accent);
+
+ --dowel-danger: lch(58% 68 28);
+ --dowel-danger-fg: lch(100% 0 0);
+ --dowel-success: lch(64% 55 145);
+ --dowel-warning: lch(80% 78 82);
+
+ --dowel-shadow-popover:
+ 0 3px 8px lch(0 0 0 / 0.125), 0 2px 5px lch(0 0 0 / 0.125),
+ 0 1px 1px lch(0 0 0 / 0.125);
+ --dowel-shadow-modal:
+ 0 4px 40px lch(0 0 0 / 0.1), 0 3px 20px lch(0 0 0 / 0.125),
+ 0 3px 12px lch(0 0 0 / 0.125), 0 2px 8px lch(0 0 0 / 0.125),
+ 0 1px 1px lch(0 0 0 / 0.125);
+ --dowel-overlay: lch(0 0 0 / 0.6);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root:not(.dowel-light):not([data-dowel-theme="light"]) {
+ --dowel-bg-1: lch(5.52% 0.4 var(--dowel-hue));
+ --dowel-bg-2: lch(7.32% 0.85 var(--dowel-hue));
+ --dowel-bg-3: lch(8.22% 1.3 var(--dowel-hue));
+ --dowel-bg-4: lch(9.345% 0.85 var(--dowel-hue));
+ --dowel-bg-elevated: lch(12.72% 0.85 var(--dowel-hue));
+
+ --dowel-border-1: lch(9.84% 1.48 var(--dowel-hue));
+ --dowel-border-2: lch(14.16% 1.48 var(--dowel-hue));
+ --dowel-border-3: lch(25.68% 1.93 var(--dowel-hue));
+
+ --dowel-text-1: lch(100% 0 var(--dowel-hue));
+ --dowel-text-2: lch(90.451% 1.2 var(--dowel-hue));
+ --dowel-text-3: lch(61.803% 1.2 var(--dowel-hue));
+ --dowel-text-4: lch(36.975% 1.2 var(--dowel-hue));
+
+ /* Same teal, lifted to sit on near-black surfaces: 7.71:1 against bg-1.
+ Chroma tops out around here — lch(64% 40 195) and anything more
+ saturated at this lightness clips the sRGB green channel. */
+ --dowel-accent: lch(68% 36 195);
+ /* Dark hovers LIGHTEN. Derived from the accent so overrides follow. */
+ --dowel-accent-hover: color-mix(in oklch, var(--dowel-accent) 85%, white);
+ /* Inverted from light on purpose. Teal is luminous for its lightness,
+ so a teal bright enough to read on near-black leaves white at
+ 2.43:1 — unusable. A near-black ink from the accent's own hue gives
+ 6.42:1 at rest and 7.44:1 on hover. */
+ --dowel-accent-fg: lch(14% 6 195);
+ --dowel-focus: var(--dowel-accent);
+
+ --dowel-danger: lch(58% 68 28);
+ --dowel-danger-fg: lch(100% 0 0);
+ --dowel-success: lch(64% 55 145);
+ --dowel-warning: lch(80% 78 82);
+
+ --dowel-shadow-popover:
+ 0 3px 8px lch(0 0 0 / 0.125), 0 2px 5px lch(0 0 0 / 0.125),
+ 0 1px 1px lch(0 0 0 / 0.125);
+ --dowel-shadow-modal:
+ 0 4px 40px lch(0 0 0 / 0.1), 0 3px 20px lch(0 0 0 / 0.125),
+ 0 3px 12px lch(0 0 0 / 0.125), 0 2px 8px lch(0 0 0 / 0.125),
+ 0 1px 1px lch(0 0 0 / 0.125);
+ --dowel-overlay: lch(0 0 0 / 0.6);
+ }
+ }
+}
diff --git a/packages/dowel/src/tokens/light.css b/packages/dowel/src/tokens/light.css
new file mode 100644
index 0000000..0736068
--- /dev/null
+++ b/packages/dowel/src/tokens/light.css
@@ -0,0 +1,54 @@
+/* Light is the default theme. Every neutral is one hue at low chroma —
+ greys that are not grey. The hue itself lives in scale.css. */
+@layer dowel.tokens {
+ :root {
+ /* surfaces */
+ --dowel-bg-1: lch(99% 0.4 var(--dowel-hue));
+ --dowel-bg-2: lch(97% 0.85 var(--dowel-hue));
+ --dowel-bg-3: lch(94.5% 1.3 var(--dowel-hue));
+ --dowel-bg-4: lch(92% 0.85 var(--dowel-hue));
+ --dowel-bg-elevated: lch(100% 0 var(--dowel-hue));
+
+ /* borders */
+ --dowel-border-1: lch(91% 1.48 var(--dowel-hue));
+ --dowel-border-2: lch(87% 1.48 var(--dowel-hue));
+ --dowel-border-3: lch(82% 1.93 var(--dowel-hue));
+
+ /* text */
+ --dowel-text-1: lch(14% 0 var(--dowel-hue));
+ --dowel-text-2: lch(28% 1.2 var(--dowel-hue));
+ --dowel-text-3: lch(48% 1.2 var(--dowel-hue));
+ --dowel-text-4: lch(61.803% 1.2 var(--dowel-hue));
+
+ /* accent — teal, karnstack's house colour. Deliberately not Linear's 295
+ indigo: hue 195 at a restrained chroma reads as ours, not as a borrowed
+ brand. #198b89 in sRGB, comfortably inside the gamut. */
+ --dowel-accent: lch(52% 32 195);
+ /* Derived from the accent so a --dowel-accent override retheming stays
+ coherent on hover. Light hovers DARKEN: 92% toward black. */
+ --dowel-accent-hover: color-mix(in oklch, var(--dowel-accent) 92%, black);
+ /* White on this teal is 4.12:1 at rest and 5.00:1 on hover. Black would
+ trade those round — 5.10:1 at rest, 4.20:1 on hover — so no single ink
+ clears 4.5:1 in both states against an L=52 background. White is the
+ one that reads clean at 13px and holds up on the state the pointer is
+ actually over. Dark mode inverts this; see dark.css. */
+ --dowel-accent-fg: lch(100% 0 0);
+ --dowel-focus: var(--dowel-accent);
+
+ /* status */
+ --dowel-danger: lch(52% 68 28);
+ --dowel-danger-fg: lch(100% 0 0);
+ --dowel-success: lch(58% 55 145);
+ --dowel-warning: lch(76% 78 82);
+
+ /* elevation — exactly two tiers */
+ --dowel-shadow-popover:
+ 0 3px 8px lch(0 0 0 / 0.08), 0 2px 5px lch(0 0 0 / 0.08),
+ 0 1px 1px lch(0 0 0 / 0.08);
+ --dowel-shadow-modal:
+ 0 4px 40px lch(0 0 0 / 0.06), 0 3px 20px lch(0 0 0 / 0.08),
+ 0 3px 12px lch(0 0 0 / 0.08), 0 2px 8px lch(0 0 0 / 0.08),
+ 0 1px 1px lch(0 0 0 / 0.08);
+ --dowel-overlay: lch(0 0 0 / 0.4);
+ }
+}
diff --git a/packages/dowel/src/tokens/scale.css b/packages/dowel/src/tokens/scale.css
new file mode 100644
index 0000000..4a46575
--- /dev/null
+++ b/packages/dowel/src/tokens/scale.css
@@ -0,0 +1,73 @@
+/* Non-colour tokens. Identical in light and dark — dark.css overrides colour
+ only, so this file must never contain a colour value. */
+@layer dowel.tokens {
+ :root {
+ /* hue — a number, not a colour. Both themes read it, so it lives with
+ the shared tokens; changing it retints the entire library. */
+ --dowel-hue: 272;
+
+ /* type */
+ --dowel-font:
+ "Inter Variable", system-ui, -apple-system, "Segoe UI", sans-serif;
+ --dowel-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
+
+ --dowel-fs-micro: 0.6875rem; /* 11 */
+ --dowel-fs-mini: 0.75rem; /* 12 */
+ --dowel-fs-small: 0.8125rem; /* 13 — the workhorse */
+ --dowel-fs-base: 0.9375rem; /* 15 */
+ --dowel-fs-lg: 1.125rem; /* 18 */
+ --dowel-fs-title3: 1.25rem; /* 20 */
+ --dowel-fs-title2: 1.5rem; /* 24 */
+ --dowel-fs-title1: 2.25rem; /* 36 */
+
+ --dowel-fw-light: 300;
+ --dowel-fw-normal: 450; /* not 400 — this is the Linear signature */
+ --dowel-fw-medium: 500;
+ --dowel-fw-semibold: 600;
+ --dowel-fw-bold: 700;
+
+ --dowel-tracking: -0.02em;
+ --dowel-tracking-title: -0.004em;
+ --dowel-leading: 1.6;
+
+ /* shape */
+ --dowel-radius-sm: 4px;
+ --dowel-radius: 8px;
+ --dowel-radius-lg: 12px;
+ --dowel-radius-pill: 9999px;
+ --dowel-hairline: 0.5px;
+
+ /* size — every interactive control is 28px unless explicitly compact */
+ --dowel-h-sm: 24px;
+ --dowel-h: 28px;
+ --dowel-h-lg: 32px;
+ --dowel-h-field: 36px;
+
+ /* space */
+ --dowel-space-1: 2px;
+ --dowel-space-2: 4px;
+ --dowel-space-3: 6px;
+ --dowel-space-4: 8px;
+ --dowel-space-5: 10px;
+ --dowel-space-6: 12px;
+ --dowel-space-7: 14px;
+ --dowel-space-8: 18px;
+
+ /* motion — only border, background-color, color, opacity may transition */
+ --dowel-dur: 0.15s;
+ --dowel-dur-fast: 0.1s;
+ --dowel-ease: cubic-bezier(0.25, 0.46, 0.45, 0.94);
+ --dowel-transition:
+ border var(--dowel-dur) var(--dowel-ease),
+ background-color var(--dowel-dur) var(--dowel-ease),
+ color var(--dowel-dur) var(--dowel-ease),
+ opacity var(--dowel-dur) var(--dowel-ease);
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ :root {
+ --dowel-dur: 0.01ms;
+ --dowel-dur-fast: 0.01ms;
+ }
+ }
+}
diff --git a/packages/dowel/test/css-contract.test.ts b/packages/dowel/test/css-contract.test.ts
new file mode 100644
index 0000000..efd62a3
--- /dev/null
+++ b/packages/dowel/test/css-contract.test.ts
@@ -0,0 +1,44 @@
+import { existsSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const DIST = resolve(import.meta.dirname, "..", "dist", "dowel.css");
+
+// This suite asserts against build output, not source. `pretest` builds first,
+// so a fresh clone cannot land here with dist/ missing; the message is the
+// backstop for anyone invoking vitest directly and bypassing the hook.
+const NOT_BUILT =
+ `${DIST} is missing. This suite asserts against build output.\n` +
+ `Run \`pnpm --filter dowel build\` first (\`pnpm --filter dowel test\` does it for you).`;
+
+function readDist(): string {
+ if (!existsSync(DIST)) throw new Error(NOT_BUILT);
+ return readFileSync(DIST, "utf8");
+}
+
+describe("dowel.css build contract", () => {
+ it("has been built", () => {
+ expect(existsSync(DIST), NOT_BUILT).toBe(true);
+ });
+
+ it("resolves every var() it references", () => {
+ const css = readDist();
+ const defined = new Set(
+ [...css.matchAll(/(--dowel-[\w-]+)\s*:/g)].map((m) => m[1]),
+ );
+ const used = new Set(
+ [...css.matchAll(/var\(\s*(--dowel-[\w-]+)/g)].map((m) => m[1]),
+ );
+ // A typo'd token silently renders as nothing. This is the guard.
+ const missing = [...used].filter((t) => !defined.has(t));
+ expect(missing).toEqual([]);
+ });
+
+ it("inlines every @import", () => {
+ expect(readDist()).not.toContain("@import");
+ });
+
+ it("keeps the cascade layer names", () => {
+ expect(readDist()).toContain("@layer");
+ });
+});
diff --git a/packages/dowel/test/render.tsx b/packages/dowel/test/render.tsx
new file mode 100644
index 0000000..077c305
--- /dev/null
+++ b/packages/dowel/test/render.tsx
@@ -0,0 +1,20 @@
+import { render } from "@testing-library/react";
+import type { ReactElement } from "react";
+
+/**
+ * Renders `ui` once under each `data-dowel-theme` attribute and returns both
+ * wrappers. jsdom applies no author stylesheet (it ignores rules inside
+ * `@layer`, where every dowel rule lives, and does not inherit custom
+ * properties), so this checks only that the component renders under both
+ * theme attributes without throwing. Light/dark token parity is guarded by
+ * test/tokens.test.ts, not here.
+ */
+export function renderBoth(ui: ReactElement) {
+ const light = render(
{ui}
);
+ const lightEl = light.container.firstElementChild as HTMLElement;
+
+ const dark = render(