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 @@ + + dowel + + + + + 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 ( +
+
+ {lang} + +
+
+        
+          {tokens.map((t, i) =>
+            t.kind === "plain" ? (
+              t.text
+            ) : (
+              
+                {t.text}
+              
+            ),
+          )}
+        
+      
+
+ ); +} 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 ( +
+

+ + {title} + +

+ {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 ( + + ); +} 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
  • ) then wins on a plain class rather than needing an + override chain. */ +:where(.docs-article :where(p, li, td) a) { + color: var(--dowel-text-1); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + text-decoration-color: var(--dowel-accent); +} + +:where(.docs-article :where(p, li, td) a:hover) { + text-decoration-color: var(--dowel-text-1); +} + +/* Inline code only. `pre code` is scoped out so the block keeps its own + colours instead of turning into a row of chips. */ +.docs-article :where(p, li, td, th, .docs-note) code { + padding: 0.1em 0.35em; + font-family: var(--dowel-mono); + font-size: 0.875em; + border-radius: var(--dowel-radius-sm); + background-color: var(--dowel-bg-3); + color: var(--dowel-text-1); +} + +.docs-note { + margin: var(--dowel-space-8) 0; + padding: var(--dowel-space-7) var(--dowel-space-8); + border-radius: var(--dowel-radius); + background-color: var(--dowel-bg-2); + box-shadow: inset 2px 0 0 var(--dowel-accent); + font-size: var(--dowel-fs-small); + line-height: 1.65; + color: var(--dowel-text-3); +} + +.docs-inline-sample { + margin: 0; + max-inline-size: 48ch; + font-size: var(--dowel-fs-base); + line-height: 1.7; + color: var(--dowel-text-2); +} + +/* ------------------------------------------------------------------ demo */ + +.docs-demo { + margin-block: var(--dowel-space-8) 0; + border: 1px solid var(--dowel-border-1); + border-radius: var(--dowel-radius-lg); + overflow: hidden; + background-color: var(--dowel-bg-1); +} + +.docs-demo-preview { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--dowel-space-6); + min-block-size: 8.5rem; + padding: 2rem var(--dowel-space-8); +} + +.docs-demo-preview[data-layout="stack"] { + flex-direction: column; + align-items: stretch; + gap: var(--dowel-space-8); + max-inline-size: 22rem; + margin-inline: auto; +} + +.docs-demo-preview[data-layout="start"] { + justify-content: flex-start; +} + +/* The code block is welded to the preview: no radius of its own, one shared + border. */ +.docs-demo .docs-code { + border: 0; + border-block-start: 1px solid var(--dowel-border-1); + border-radius: 0; +} + +.docs-dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--dowel-space-4); + margin-block-start: var(--dowel-space-2); +} + +/* ------------------------------------------------------------------ code */ + +.docs-code { + border: 1px solid var(--dowel-border-1); + border-radius: var(--dowel-radius-lg); + overflow: hidden; + background-color: var(--docs-code-bg); +} + +.docs-code-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--dowel-space-2) var(--dowel-space-2) var(--dowel-space-2) + var(--dowel-space-7); + border-block-end: 1px solid var(--dowel-border-1); +} + +.docs-code-lang { + font-family: var(--dowel-mono); + font-size: var(--dowel-fs-micro); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--dowel-text-3); +} + +.docs-code-pre { + margin: 0; + padding: var(--dowel-space-7) var(--dowel-space-8); + overflow-x: auto; + font-family: var(--dowel-mono); + font-size: var(--dowel-fs-mini); + line-height: 1.75; + letter-spacing: 0; + color: var(--dowel-text-2); + tab-size: 2; +} + +.tk-comment { + color: var(--docs-code-comment); + font-style: italic; +} +.tk-string { + color: var(--docs-code-string); +} +.tk-number { + color: var(--docs-code-string); +} +.tk-keyword { + color: var(--docs-code-keyword); +} +.tk-tag, +.tk-type { + color: var(--docs-code-tag); +} +.tk-attr { + color: var(--docs-code-attr); +} + +/* ----------------------------------------------------------------- table */ + +.docs-table-wrap { + margin-block: var(--dowel-space-8); + overflow-x: auto; +} + +.docs-table { + inline-size: 100%; + border-collapse: collapse; + font-size: var(--dowel-fs-small); +} + +.docs-table th { + padding: var(--dowel-space-4) var(--dowel-space-6) var(--dowel-space-4) 0; + text-align: start; + font-weight: var(--dowel-fw-medium); + color: var(--dowel-text-3); + border-block-end: 1px solid var(--dowel-border-2); + white-space: nowrap; +} + +.docs-table td { + padding: var(--dowel-space-6) var(--dowel-space-6) var(--dowel-space-6) 0; + vertical-align: top; + color: var(--dowel-text-2); + border-block-end: 1px solid var(--dowel-border-1); +} + +/* ------------------------------------------------------------------- toc */ + +.docs-toc { + display: none; +} + +@media (min-width: 80rem) { + .docs-toc { + display: block; + } +} + +.docs-toc-inner { + position: sticky; + inset-block-start: calc(var(--docs-header-h) + 2.5rem); + padding-block: 2.5rem 3rem; +} + +.docs-toc-title { + margin: 0 0 var(--dowel-space-5); + font-size: var(--dowel-fs-small); + font-weight: var(--dowel-fw-semibold); + color: var(--dowel-text-1); +} + +.docs-toc ul { + margin: 0; + padding: 0; + list-style: none; + border-inline-start: 1px solid var(--dowel-border-1); +} + +.docs-toc a { + display: block; + margin-inline-start: -1px; + padding: var(--dowel-space-3) 0 var(--dowel-space-3) var(--dowel-space-7); + border-inline-start: 1px solid transparent; + font-size: var(--dowel-fs-small); + line-height: 1.4; + color: var(--dowel-text-3); + text-decoration: none; +} + +.docs-toc a:hover { + color: var(--dowel-text-2); +} + +.docs-toc a[data-active] { + color: var(--dowel-text-1); + border-inline-start-color: var(--dowel-accent); +} + +/* --------------------------------------------------------------- pagenav */ + +.docs-pagenav { + display: flex; + justify-content: space-between; + gap: var(--dowel-space-8); + max-inline-size: 72ch; + margin-block-start: 4rem; +} + +.docs-pagenav-link { + display: flex; + flex-direction: column; + gap: var(--dowel-space-1); + padding: var(--dowel-space-6) var(--dowel-space-8); + border: 1px solid var(--dowel-border-1); + border-radius: var(--dowel-radius); + text-decoration: none; + transition: var(--dowel-transition); +} + +.docs-pagenav-link:hover { + background-color: var(--dowel-bg-2); + border-color: var(--dowel-border-2); +} + +.docs-pagenav-link[data-dir="next"] { + text-align: end; + margin-inline-start: auto; +} + +.docs-pagenav-label { + font-size: var(--dowel-fs-mini); + color: var(--dowel-text-4); +} + +.docs-pagenav-title { + font-size: var(--dowel-fs-small); + font-weight: var(--dowel-fw-medium); + color: var(--dowel-text-1); +} + +/* ----------------------------------------------------------------- cards */ + +.docs-cards { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--dowel-space-6); + margin: var(--dowel-space-8) 0 0; + padding: 0; + list-style: none; +} + +/* Container queries, not viewport queries: this grid renders both inside the + narrow docs column and across the full-width landing page, and what it + needs to know is how much room it has, not how wide the window is. */ +@container (min-width: 34rem) { + .docs-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@container (min-width: 58rem) { + .docs-cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.docs-card { + display: flex; + flex-direction: column; + gap: var(--dowel-space-3); + block-size: 100%; + padding: var(--dowel-space-8); + border: 1px solid var(--dowel-border-1); + border-radius: var(--dowel-radius-lg); + text-decoration: none; + transition: var(--dowel-transition); +} + +.docs-card:hover { + background-color: var(--dowel-bg-2); + border-color: var(--dowel-border-2); +} + +.docs-card-title { + display: flex; + align-items: center; + gap: var(--dowel-space-3); + font-size: var(--dowel-fs-base); + font-weight: var(--dowel-fw-medium); + color: var(--dowel-text-1); +} + +.docs-card-title .docs-icon { + color: var(--dowel-text-4); +} + +.docs-card:hover .docs-card-title .docs-icon { + color: var(--dowel-accent); +} + +.docs-card-summary { + font-size: var(--dowel-fs-small); + line-height: 1.55; + color: var(--dowel-text-3); +} + +/* --------------------------------------------------------------- landing */ + +.docs-landing { + max-inline-size: var(--docs-max); + margin-inline: auto; + padding-inline: var(--docs-gutter); +} + +.docs-hero { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 2.5rem; + padding-block: 3.5rem 1rem; +} + +@media (min-width: 64rem) { + .docs-hero { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: center; + gap: 4rem; + padding-block: 5.5rem 2rem; + } +} + +.docs-hero-copy h1 { + margin: var(--dowel-space-7) 0 0; + max-inline-size: 18ch; + font-size: clamp(2.25rem, 1.5rem + 3vw, 3.25rem); + font-weight: var(--dowel-fw-semibold); + letter-spacing: var(--dowel-tracking-title); + line-height: 1.05; + color: var(--dowel-text-1); + text-wrap: balance; +} + +.docs-hero-copy .docs-lead { + max-inline-size: 52ch; +} + +.docs-hero-copy code { + padding: 0.1em 0.35em; + font-family: var(--dowel-mono); + font-size: 0.875em; + border-radius: var(--dowel-radius-sm); + background-color: var(--dowel-bg-3); + color: var(--dowel-text-2); +} + +.docs-hero-actions { + display: flex; + flex-wrap: wrap; + gap: var(--dowel-space-6); + margin-block-start: var(--dowel-space-8); +} + +.docs-hero-panel { + border: 1px solid var(--dowel-border-2); + border-radius: var(--dowel-radius-lg); + background-color: var(--dowel-bg-elevated); + box-shadow: var(--docs-panel-shadow); + overflow: hidden; +} + +.docs-hero-panel-bar { + display: flex; + align-items: center; + gap: var(--dowel-space-3); + padding: var(--dowel-space-5) var(--dowel-space-7); + border-block-end: 1px solid var(--dowel-border-1); + background-color: var(--dowel-bg-2); +} + +.docs-hero-dot { + inline-size: 8px; + block-size: 8px; + border-radius: var(--dowel-radius-pill); + background-color: var(--dowel-border-3); +} + +.docs-hero-panel-body { + display: flex; + flex-direction: column; + gap: var(--dowel-space-8); + padding: var(--dowel-space-8); +} + +.docs-hero-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--dowel-space-6); +} + +.docs-section-block { + container-type: inline-size; + padding-block: 3.5rem; + border-block-start: 1px solid var(--dowel-border-1); +} + +.docs-section-block h2 { + margin: 0; + font-size: var(--dowel-fs-title2); + font-weight: var(--dowel-fw-semibold); + letter-spacing: var(--dowel-tracking-title); + color: var(--dowel-text-1); +} + +.docs-section-lead { + margin: var(--dowel-space-5) 0 0; + max-inline-size: 58ch; + font-size: var(--dowel-fs-base); + line-height: 1.6; + color: var(--dowel-text-3); +} + +.docs-install { + display: grid; + gap: var(--dowel-space-6); + margin-block-start: var(--dowel-space-8); +} + +@container (min-width: 52rem) { + .docs-install { + grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); + align-items: start; + } +} + +.docs-install-side { + display: grid; + align-content: start; + gap: var(--dowel-space-8); +} + +.docs-fineprint { + margin: 0; + max-inline-size: 68ch; + font-size: var(--dowel-fs-small); + line-height: 1.6; + color: var(--dowel-text-3); +} + +.docs-fineprint code { + font-family: var(--dowel-mono); + color: var(--dowel-text-2); +} + +.docs-principles { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--dowel-space-8); + margin: var(--dowel-space-8) 0 0; + padding: 0; + list-style: none; +} + +@container (min-width: 52rem) { + .docs-principles { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 2.5rem; + } +} + +.docs-principles h3 { + margin: 0 0 var(--dowel-space-4); + font-size: var(--dowel-fs-base); + font-weight: var(--dowel-fw-semibold); + color: var(--dowel-text-1); +} + +.docs-principles p { + margin: 0; + font-size: var(--dowel-fs-small); + line-height: 1.65; + color: var(--dowel-text-3); +} + +/* ---------------------------------------------------------------- footer */ + +.docs-footer { + border-block-start: 1px solid var(--dowel-border-1); + padding-block: 2.5rem 4rem; +} + +.docs-footer-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--dowel-space-8); +} + +.docs-footer p { + margin: 0; + max-inline-size: 62ch; + font-size: var(--dowel-fs-small); + line-height: 1.6; + color: var(--dowel-text-3); +} + +.docs-footer a { + color: var(--dowel-text-2); + font-weight: var(--dowel-fw-normal); +} + +.docs-footer-cta { + display: flex; + align-items: center; + gap: var(--dowel-space-3); + font-size: var(--dowel-fs-small); + font-weight: var(--dowel-fw-medium); + color: var(--dowel-text-1); + text-decoration: none; + white-space: nowrap; +} + +.docs-footer-cta:hover { + color: var(--dowel-accent); +} diff --git a/apps/docs/src/lib/highlight.ts b/apps/docs/src/lib/highlight.ts new file mode 100644 index 0000000..cae1d01 --- /dev/null +++ b/apps/docs/src/lib/highlight.ts @@ -0,0 +1,101 @@ +/** + * A ~60-line TSX tokenizer, in place of a syntax-highlighting dependency. + * + * Shiki and Prism are both larger than every other dependency in this app put + * together, and the docs only ever highlight snippets we wrote ourselves. So + * this trades generality for size: it is a lexer, not a parser, it will + * mis-colour code it was never given, and that is an acceptable deal for a + * fixed set of hand-written samples. + * + * It is a pure string -> array function with no DOM access, which is what + * lets the prerender run it on the server. + */ + +export type TokenKind = + | "comment" + | "string" + | "keyword" + | "tag" + | "attr" + | "type" + | "number" + | "plain"; + +export type Token = { kind: TokenKind; text: string }; + +const KEYWORDS = [ + "import", + "export", + "from", + "const", + "let", + "var", + "function", + "return", + "type", + "interface", + "extends", + "as", + "default", + "new", + "async", + "await", + "if", + "else", + "true", + "false", + "null", + "undefined", +].join("|"); + +// Alternation order IS precedence: a `//` inside a string must lose to the +// string rule, so strings and comments come before everything structural. +const PATTERN = new RegExp( + [ + String.raw`(\/\/[^\n]*|\/\*[\s\S]*?\*\/)`, // 1 comment + String.raw`("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|` + + "`(?:[^`\\\\]|\\\\.)*`)", // 2 string + String.raw`(<\/?[A-Za-z][\w.]*)`, // 3 JSX tag, incl. Tooltip.Root + String.raw`\b(${KEYWORDS})\b`, // 4 keyword + // An identifier immediately before `=` is a JSX attribute or an + // assignment target. `=>` and `==` are excluded so arrow params and + // comparisons are not mistaken for attributes. + String.raw`([A-Za-z_$][\w$]*)(?=\s*=(?![=>]))`, // 5 attribute + String.raw`\b([A-Z][A-Za-z0-9]*)\b`, // 6 component / type name + String.raw`\b(\d+(?:\.\d+)?)\b`, // 7 number + ].join("|"), + "g", +); + +const KIND_BY_GROUP: TokenKind[] = [ + "comment", + "string", + "tag", + "keyword", + "attr", + "type", + "number", +]; + +export function tokenize(code: string): Token[] { + const tokens: Token[] = []; + let last = 0; + PATTERN.lastIndex = 0; + + for (let m = PATTERN.exec(code); m !== null; m = PATTERN.exec(code)) { + if (m.index > last) { + tokens.push({ kind: "plain", text: code.slice(last, m.index) }); + } + const group = KIND_BY_GROUP.findIndex((_, i) => m[i + 1] !== undefined); + tokens.push({ + kind: group === -1 ? "plain" : (KIND_BY_GROUP[group] as TokenKind), + text: m[0], + }); + last = m.index + m[0].length; + } + + if (last < code.length) { + tokens.push({ kind: "plain", text: code.slice(last) }); + } + return tokens; +} diff --git a/apps/docs/src/lib/nav.ts b/apps/docs/src/lib/nav.ts new file mode 100644 index 0000000..738f40b --- /dev/null +++ b/apps/docs/src/lib/nav.ts @@ -0,0 +1,82 @@ +import type { LinkProps } from "@tanstack/react-router"; + +/** + * `to` is the router's own union of known paths, not `string`, so a typo in + * this table is a typecheck failure rather than a dead link discovered in a + * screenshot. It is also what the prerender crawler follows: every page the + * build must emit is reachable from the sidebar, so adding an entry here is + * the only step needed to get a route prerendered. + */ +export type NavItem = { + title: string; + to: LinkProps["to"]; + /** One line, shown on the components index and the landing grid. */ + summary: string; +}; + +export type NavSection = { + title: string; + items: NavItem[]; +}; + +export const componentNav: NavItem[] = [ + { + title: "Badge", + to: "/components/badge", + summary: "A compact status or metadata pill in five tones.", + }, + { + title: "Button", + to: "/components/button", + summary: "The default control. Four variants, two sizes.", + }, + { + title: "Dialog", + to: "/components/dialog", + summary: "A modal on the modal elevation tier, labelled by its title.", + }, + { + title: "Icon Button", + to: "/components/icon-button", + summary: "A square control for a single icon, with a required label.", + }, + { + title: "Input", + to: "/components/input", + summary: "A text field, and the Field parts that label and describe it.", + }, + { + title: "Kbd", + to: "/components/kbd", + summary: "A keyboard shortcut rendered one key per cap.", + }, + { + title: "Menu", + to: "/components/menu", + summary: "A dropdown with keyboard navigation and typeahead.", + }, + { + title: "Tooltip", + to: "/components/tooltip", + summary: "A hover and focus label on the popover elevation tier.", + }, +]; + +export const nav: NavSection[] = [ + { + title: "Getting started", + items: [ + { + title: "Introduction", + to: "/", + summary: "What dowel is, and what it refuses to be.", + }, + { + title: "All components", + to: "/components", + summary: "Everything the package exports, in one list.", + }, + ], + }, + { title: "Components", items: componentNav }, +]; diff --git a/apps/docs/src/lib/version.ts b/apps/docs/src/lib/version.ts new file mode 100644 index 0000000..a6ad8b8 --- /dev/null +++ b/apps/docs/src/lib/version.ts @@ -0,0 +1,27 @@ +/** + * What the docs are allowed to say about dowel's version. + * + * The number itself is read from packages/dowel/package.json at build time + * (see the `define` in vite.config.ts), so the badge cannot drift from the + * package the way a hand-typed one did. + */ + +/** + * changesets has not assigned a version yet, so the manifest still carries + * the placeholder every unpublished package starts on. Nothing is on npm at + * this point, which makes "v0.0.0" a number no reader can install. + */ +const UNPUBLISHED = "0.0.0"; + +/** The raw version from the library manifest, for example `0.2.0`. */ +export const dowelVersion = __DOWEL_VERSION__; + +/** True once a real version exists, meaning the first publish has happened. */ +export const isPublished = dowelVersion !== UNPUBLISHED; + +/** + * The badge copy. "unreleased" while the placeholder is in place, `v` plus + * the number afterwards. It is a condition on the value rather than a second + * string to edit, so the first release flips it with no docs change. + */ +export const versionLabel = isPublished ? `v${dowelVersion}` : "unreleased"; diff --git a/apps/docs/src/routeTree.gen.ts b/apps/docs/src/routeTree.gen.ts new file mode 100644 index 0000000..21ebcba --- /dev/null +++ b/apps/docs/src/routeTree.gen.ts @@ -0,0 +1,290 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ComponentsRouteRouteImport } from './routes/components/route' +import { Route as ComponentsIndexRouteImport } from './routes/components/index' +import { Route as ComponentsBadgeRouteImport } from './routes/components/badge' +import { Route as ComponentsButtonRouteImport } from './routes/components/button' +import { Route as ComponentsDialogRouteImport } from './routes/components/dialog' +import { Route as ComponentsIconButtonRouteImport } from './routes/components/icon-button' +import { Route as ComponentsInputRouteImport } from './routes/components/input' +import { Route as ComponentsKbdRouteImport } from './routes/components/kbd' +import { Route as ComponentsMenuRouteImport } from './routes/components/menu' +import { Route as ComponentsTooltipRouteImport } from './routes/components/tooltip' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ComponentsRouteRoute = ComponentsRouteRouteImport.update({ + id: '/components', + path: '/components', + getParentRoute: () => rootRouteImport, +} as any) +const ComponentsIndexRoute = ComponentsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsBadgeRoute = ComponentsBadgeRouteImport.update({ + id: '/badge', + path: '/badge', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsButtonRoute = ComponentsButtonRouteImport.update({ + id: '/button', + path: '/button', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsDialogRoute = ComponentsDialogRouteImport.update({ + id: '/dialog', + path: '/dialog', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsIconButtonRoute = ComponentsIconButtonRouteImport.update({ + id: '/icon-button', + path: '/icon-button', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsInputRoute = ComponentsInputRouteImport.update({ + id: '/input', + path: '/input', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsKbdRoute = ComponentsKbdRouteImport.update({ + id: '/kbd', + path: '/kbd', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsMenuRoute = ComponentsMenuRouteImport.update({ + id: '/menu', + path: '/menu', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsTooltipRoute = ComponentsTooltipRouteImport.update({ + id: '/tooltip', + path: '/tooltip', + getParentRoute: () => ComponentsRouteRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/components': typeof ComponentsRouteRouteWithChildren + '/components/badge': typeof ComponentsBadgeRoute + '/components/button': typeof ComponentsButtonRoute + '/components/dialog': typeof ComponentsDialogRoute + '/components/icon-button': typeof ComponentsIconButtonRoute + '/components/input': typeof ComponentsInputRoute + '/components/kbd': typeof ComponentsKbdRoute + '/components/menu': typeof ComponentsMenuRoute + '/components/tooltip': typeof ComponentsTooltipRoute + '/components/': typeof ComponentsIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/components/badge': typeof ComponentsBadgeRoute + '/components/button': typeof ComponentsButtonRoute + '/components/dialog': typeof ComponentsDialogRoute + '/components/icon-button': typeof ComponentsIconButtonRoute + '/components/input': typeof ComponentsInputRoute + '/components/kbd': typeof ComponentsKbdRoute + '/components/menu': typeof ComponentsMenuRoute + '/components/tooltip': typeof ComponentsTooltipRoute + '/components': typeof ComponentsIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/components': typeof ComponentsRouteRouteWithChildren + '/components/badge': typeof ComponentsBadgeRoute + '/components/button': typeof ComponentsButtonRoute + '/components/dialog': typeof ComponentsDialogRoute + '/components/icon-button': typeof ComponentsIconButtonRoute + '/components/input': typeof ComponentsInputRoute + '/components/kbd': typeof ComponentsKbdRoute + '/components/menu': typeof ComponentsMenuRoute + '/components/tooltip': typeof ComponentsTooltipRoute + '/components/': typeof ComponentsIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/components' + | '/components/badge' + | '/components/button' + | '/components/dialog' + | '/components/icon-button' + | '/components/input' + | '/components/kbd' + | '/components/menu' + | '/components/tooltip' + | '/components/' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/components/badge' + | '/components/button' + | '/components/dialog' + | '/components/icon-button' + | '/components/input' + | '/components/kbd' + | '/components/menu' + | '/components/tooltip' + | '/components' + id: + | '__root__' + | '/' + | '/components' + | '/components/badge' + | '/components/button' + | '/components/dialog' + | '/components/icon-button' + | '/components/input' + | '/components/kbd' + | '/components/menu' + | '/components/tooltip' + | '/components/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ComponentsRouteRoute: typeof ComponentsRouteRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/components': { + id: '/components' + path: '/components' + fullPath: '/components' + preLoaderRoute: typeof ComponentsRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/components/': { + id: '/components/' + path: '/' + fullPath: '/components/' + preLoaderRoute: typeof ComponentsIndexRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/badge': { + id: '/components/badge' + path: '/badge' + fullPath: '/components/badge' + preLoaderRoute: typeof ComponentsBadgeRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/button': { + id: '/components/button' + path: '/button' + fullPath: '/components/button' + preLoaderRoute: typeof ComponentsButtonRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/dialog': { + id: '/components/dialog' + path: '/dialog' + fullPath: '/components/dialog' + preLoaderRoute: typeof ComponentsDialogRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/icon-button': { + id: '/components/icon-button' + path: '/icon-button' + fullPath: '/components/icon-button' + preLoaderRoute: typeof ComponentsIconButtonRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/input': { + id: '/components/input' + path: '/input' + fullPath: '/components/input' + preLoaderRoute: typeof ComponentsInputRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/kbd': { + id: '/components/kbd' + path: '/kbd' + fullPath: '/components/kbd' + preLoaderRoute: typeof ComponentsKbdRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/menu': { + id: '/components/menu' + path: '/menu' + fullPath: '/components/menu' + preLoaderRoute: typeof ComponentsMenuRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/tooltip': { + id: '/components/tooltip' + path: '/tooltip' + fullPath: '/components/tooltip' + preLoaderRoute: typeof ComponentsTooltipRouteImport + parentRoute: typeof ComponentsRouteRoute + } + } +} + +interface ComponentsRouteRouteChildren { + ComponentsBadgeRoute: typeof ComponentsBadgeRoute + ComponentsButtonRoute: typeof ComponentsButtonRoute + ComponentsDialogRoute: typeof ComponentsDialogRoute + ComponentsIconButtonRoute: typeof ComponentsIconButtonRoute + ComponentsInputRoute: typeof ComponentsInputRoute + ComponentsKbdRoute: typeof ComponentsKbdRoute + ComponentsMenuRoute: typeof ComponentsMenuRoute + ComponentsTooltipRoute: typeof ComponentsTooltipRoute + ComponentsIndexRoute: typeof ComponentsIndexRoute +} + +const ComponentsRouteRouteChildren: ComponentsRouteRouteChildren = { + ComponentsBadgeRoute: ComponentsBadgeRoute, + ComponentsButtonRoute: ComponentsButtonRoute, + ComponentsDialogRoute: ComponentsDialogRoute, + ComponentsIconButtonRoute: ComponentsIconButtonRoute, + ComponentsInputRoute: ComponentsInputRoute, + ComponentsKbdRoute: ComponentsKbdRoute, + ComponentsMenuRoute: ComponentsMenuRoute, + ComponentsTooltipRoute: ComponentsTooltipRoute, + ComponentsIndexRoute: ComponentsIndexRoute, +} + +const ComponentsRouteRouteWithChildren = ComponentsRouteRoute._addFileChildren( + ComponentsRouteRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ComponentsRouteRoute: ComponentsRouteRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/apps/docs/src/router.tsx b/apps/docs/src/router.tsx new file mode 100644 index 0000000..5fa2dba --- /dev/null +++ b/apps/docs/src/router.tsx @@ -0,0 +1,25 @@ +import { createRouter } from "@tanstack/react-router"; + +import { routeTree } from "./routeTree.gen"; + +/** + * TanStack Start's entry points import `getRouter` from this module by + * convention (`src/router` inside the app's `srcDirectory`). It runs once per + * request on the server and once on the client, so it must build a fresh + * router each time rather than share one instance. + */ +export function getRouter() { + return createRouter({ + routeTree, + // The docs are prerendered to static HTML and hydrate on load; scrolling + // to the top on navigation matches how a docs site is read. + scrollRestoration: true, + defaultPreload: "intent", + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/apps/docs/src/routes/__root.tsx b/apps/docs/src/routes/__root.tsx new file mode 100644 index 0000000..2504311 --- /dev/null +++ b/apps/docs/src/routes/__root.tsx @@ -0,0 +1,291 @@ +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from "@tanstack/react-router"; +import { Badge, IconButton, Kbd, Tooltip } from "dowel"; +import { useCallback, useEffect, useState } from "react"; + +import { + CloseIcon, + GitHubIcon, + MenuIcon, + MoonIcon, + SunIcon, +} from "../components/icons"; +import { SidebarNav } from "../components/sidebar-nav"; +import { versionLabel } from "../lib/version"; + +// The docs self-host Inter; dowel itself ships no typeface, it only names +// "Inter Variable" first in --dowel-font. +import "@fontsource-variable/inter"; +import "dowel/dowel.css"; +import "../docs.css"; + +const REPO = "https://github.com/karnstack/dowel"; + +/** The bare key that flips the theme. Matched case-insensitively. */ +const THEME_KEY = "d"; + +/** + * True when the keystroke belongs to something the user is typing into. The + * docs are full of live Input and Field demos, so a bare-letter shortcut has + * to yield to them or it eats characters. `isContentEditable` is computed + * rather than read off the attribute, so it is also true for a node nested + * inside an editing host. + */ +function isTypingTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + +/** + * True while a Dialog or Menu is open. An open overlay is its own keyboard + * context: Menu spends bare letters on typeahead, and a shortcut firing + * under a modal would restyle a page the user cannot see. Base UI carries + * `data-open` on the popup for exactly as long as it is open — it is gone + * again during the closing animation — so the attribute is the honest + * signal where the popup merely being in the DOM is not. + */ +function isOverlayOpen() { + return ( + document.querySelector( + '[role="dialog"][data-open], [role="alertdialog"][data-open], [role="menu"][data-open]', + ) !== null + ); +} + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { title: "dowel — an opinionated React component library" }, + { + name: "description", + content: + "dowel is an opinionated React component library. One look, well made.", + }, + ], + // Declared on the root route so every prerendered page carries them. + // The order is the one browsers resolve best from: the ICO first for the + // ones that only read ICO, then the SVG, which anything modern prefers + // and which is the only file that follows the OS colour scheme. The + // assets themselves are static, so they live in public/ and are served + // from the site root. See public/icon.svg for the mark. + links: [ + { rel: "icon", href: "/favicon.ico", sizes: "32x32" }, + { rel: "icon", href: "/icon.svg", type: "image/svg+xml" }, + { rel: "apple-touch-icon", href: "/apple-touch-icon.png" }, + ], + }), + component: RootDocument, +}); + +/** A dowel: the small turned pin that joins two pieces of wood. */ +function Wordmark() { + return ( + + + dowel + + ); +} + +function ThemeToggle({ + theme, + onToggle, +}: { + theme: "light" | "dark" | null; + onToggle: () => void; +}) { + return ( + + + {/* Both icons ship; CSS shows the one matching the resolved + theme. That keeps the prerendered markup correct under either + OS setting, which a JS-chosen icon could not be. */} + + + + + + + + } + /> + + + + {/* The shortcut is advertised where the control already + explains itself, rather than as a second thing in the + header. Kbd carries no className, so the row is a span + the docs own. */} + + {theme === null ? "Theme: system" : `Theme: ${theme}`} + + + + + + + ); +} + +function RootDocument() { + // `null` means "no explicit choice": the attribute is left off and + // dowel's prefers-color-scheme rule decides. That is both the better + // default and the hydration-safe one — the server cannot read the OS + // setting, so the only initial value that always matches the client is the + // one that asserts nothing. + const [theme, setTheme] = useState<"light" | "dark" | null>(null); + const [navOpen, setNavOpen] = useState(false); + + // The media query is read outside the updater so the updater stays pure, + // which leaves the toggle depending on nothing: the keydown listener below + // is attached once instead of being torn down and rebound on every flip. + const toggleTheme = useCallback(() => { + const systemDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + setTheme((current) => { + const resolved = current ?? (systemDark ? "dark" : "light"); + return resolved === "dark" ? "light" : "dark"; + }); + }, []); + + // `D` toggles the theme. The listener lives in an effect so it only ever + // exists in the browser — the site is prerendered, and there is no + // document to listen on while the HTML is being generated. + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + // Bare `D` only. A held Meta/Ctrl/Alt means the user is reaching for a + // browser or OS shortcut and this must never shadow one. Shift is not + // in the list: it is how a capital D gets typed. + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (event.key.toLowerCase() !== THEME_KEY) return; + // Someone closer to the keystroke already claimed it. + if (event.defaultPrevented) return; + if (isTypingTarget(event.target) || isOverlayOpen()) return; + toggleTheme(); + } + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [toggleTheme]); + + return ( + // The theme hooks onto rather than a wrapper div so the page + // background reaches the viewport edges instead of stopping at the + // content box. On :root the attribute also wins over the + // prefers-color-scheme rule, which is guarded with + // :not([data-dowel-theme="light"]). + + + + + + {/* + Tooltip.Provider belongs once at the app root: it is what lets + adjacent tooltips share a single delay, so the second one a pointer + reaches opens instantly instead of waiting again. + */} + +
    +
    +
    + setNavOpen((open) => !open)} + > + {navOpen ? : } + +
    + + + + + {versionLabel} + + + +
    + + + } + > + + + } + /> + + + GitHub + + + + + +
    +
    + + {/* The mobile disclosure. It is always in the DOM so the + aria-controls reference always resolves; CSS hides it when + the header's toggle is not expanded. */} +
    + setNavOpen(false)} /> +
    +
    + + +
    + + + + ); +} diff --git a/apps/docs/src/routes/components/badge.tsx b/apps/docs/src/routes/components/badge.tsx new file mode 100644 index 0000000..320fae0 --- /dev/null +++ b/apps/docs/src/routes/components/badge.tsx @@ -0,0 +1,66 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Badge } from "dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/badge")({ + component: BadgeDocs, +}); + +const toc = [ + { id: "tones", title: "Tones" }, + { id: "in-context", title: "In context" }, +]; + +function BadgeDocs() { + return ( + +
    +

    + Tone carries meaning, not decoration. neutral is the + default and the right answer most of the time; the four coloured tones + all read as state. +

    + Neutral +Accent +Shipped +Deprecated +Breaking`} + > + Neutral + Accent + Shipped + Deprecated + Breaking + +
    + +
    +

    + A badge sits at 20px so it aligns to a line of body text rather than + to a control. It renders a span, so it flows inline. +

    + + dowel is pre-1.0 — the API will + change between minor versions. +

    `} + > +

    + dowel is pre-1.0 — the API will change + between minor versions. +

    +
    +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/button.tsx b/apps/docs/src/routes/components/button.tsx new file mode 100644 index 0000000..2837843 --- /dev/null +++ b/apps/docs/src/routes/components/button.tsx @@ -0,0 +1,177 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Button } from "dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/button")({ + component: ButtonDocs, +}); + +const toc = [ + { id: "variants", title: "Variants" }, + { id: "sizes", title: "Sizes" }, + { id: "states", title: "States" }, + { id: "as-a-link", title: "As a link" }, + { id: "props", title: "Props" }, +]; + +function ButtonDocs() { + return ( + +
    +

    + Variant is visual weight, not colour choice. Use one primary per + screen; everything else is secondary or ghost, and danger is reserved + for an action that destroys something. +

    + Primary + + +`} + > + + + + + +
    + +
    +

    + Two, and only two. md is the 28px control height every + other dowel control shares; sm is 24px for dense rows and + toolbars. +

    + Small +`} + > + + + +
    + +
    +

    + A disabled button keeps its variant and drops to half opacity. Hover + derives from the accent, so a retheme carries into it automatically. +

    + Disabled +`} + > + + + +
    + + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PropTypeDefault
    + variant + + + "primary" | "secondary" | + "ghost" | "danger" + + + "secondary" +
    + size + + "sm" | "md" + + "md" +
    + render + + ReactElement +
    + nativeButton + + boolean + + true +
    +
    +

    + className and style are absent from{" "} + ButtonProps and neutralised at runtime. If you need a + different button, dowel is the wrong library — that is the point. +

    +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/dialog.tsx b/apps/docs/src/routes/components/dialog.tsx new file mode 100644 index 0000000..f669d93 --- /dev/null +++ b/apps/docs/src/routes/components/dialog.tsx @@ -0,0 +1,117 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Button, Dialog, Field, Input } from "dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/dialog")({ + component: DialogDocs, +}); + +const toc = [ + { id: "anatomy", title: "Anatomy" }, + { id: "with-a-form", title: "With a form" }, +]; + +function DialogDocs() { + return ( + +
    +

    + Compose Root, Trigger, Portal,{" "} + Backdrop, Popup, Title,{" "} + Description and Close. Focus trapping, + scroll locking and Escape handling come from Base UI. +

    + + Delete workspace} /> + + + + Delete workspace + + This removes every project in it. It cannot be undone. + + Cancel} /> + Delete} /> + + +`} + > + + Delete workspace} /> + + + + Delete workspace + + This removes every project in it. It cannot be undone. + +
    + Cancel} /> + Delete} + /> +
    +
    +
    +
    +
    +

    + Dialog.Portal renders a plain div. Never put + an inline transform or filter on it — either + one creates a containing block and silently breaks the popup's{" "} + position: fixed. +

    +
    + +
    +

    + The popup is a flex column with a fixed gap, so a field stack drops + straight in without a wrapper. +

    + + New project + + Name + + + Create} /> +`} + > + + New project} + /> + + + + New project + + Name + + +
    + Cancel} /> + Create} + /> +
    +
    +
    +
    +
    +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/icon-button.tsx b/apps/docs/src/routes/components/icon-button.tsx new file mode 100644 index 0000000..d211bf1 --- /dev/null +++ b/apps/docs/src/routes/components/icon-button.tsx @@ -0,0 +1,117 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { IconButton, Tooltip } from "dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; +import { + CheckIcon, + CloseIcon, + CopyIcon, + MenuIcon, +} from "../../components/icons"; + +export const Route = createFileRoute("/components/icon-button")({ + component: IconButtonDocs, +}); + +const toc = [ + { id: "label", title: "The label is required" }, + { id: "variants", title: "Variants and sizes" }, + { id: "with-a-tooltip", title: "With a tooltip" }, +]; + +function IconButtonDocs() { + return ( + +
    +

    + An icon alone never names a control, so label is a + required prop and aria-label is omitted from the type. + There is no way to render one of these without a name. +

    + + +`} + > + + + + +
    + +
    +

    + Ghost by default, because these usually sit in a toolbar where a + filled control would shout. secondary gives it a surface + when it needs to read as a discrete target. +

    + + + + + + + +`} + > + + + + + + + + + + + + + +
    + +
    +

    + A Tooltip is a visual label only — it is not announced. Pairing it + with an IconButton is the supported combination precisely because the + button already carries its own name. +

    + + } + /> + + + Copy code + + +`} + > + + + + + } + /> + + + Copy code + + + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/index.tsx b/apps/docs/src/routes/components/index.tsx new file mode 100644 index 0000000..dff30ae --- /dev/null +++ b/apps/docs/src/routes/components/index.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ComponentGrid, DocsPage } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/")({ + component: ComponentsIndex, +}); + +function ComponentsIndex() { + return ( + + + + ); +} diff --git a/apps/docs/src/routes/components/input.tsx b/apps/docs/src/routes/components/input.tsx new file mode 100644 index 0000000..c6c1d7b --- /dev/null +++ b/apps/docs/src/routes/components/input.tsx @@ -0,0 +1,100 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Field, Input } from "dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/input")({ + component: InputDocs, +}); + +const toc = [ + { id: "sizes", title: "Sizes" }, + { id: "field", title: "Field" }, + { id: "validation", title: "Validation" }, +]; + +function InputDocs() { + return ( + +
    +

    + md is the 28px control height shared with Button.{" "} + lg is 36px, for a field that is the point of the screen + rather than one cell in a dense row. The native size{" "} + attribute is omitted from the type so dowel's visual scale can + take the name. +

    + +`} + > + + + +
    + +
    +

    + A dowel Input inside Field.Root is + associated with its label automatically. Field.Label{" "} + omits htmlFor on purpose: a hand-written one would win + over the generated association, which is the exact bug Field exists to + remove. +

    + + Workspace name + + + Lowercase letters and dashes only. + +`} + > + + Workspace name + + + Lowercase letters and dashes only. + + + +
    + +
    +

    + invalid sets aria-invalid and turns the + border to the danger tone. It is applied conditionally, so leaving it + unset never clobbers an aria-invalid that Field + validation computed — add Field.Error with a{" "} + match to surface the message Field computes. +

    + + Email + + + +`} + > + + Email + + + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/kbd.tsx b/apps/docs/src/routes/components/kbd.tsx new file mode 100644 index 0000000..6fb886b --- /dev/null +++ b/apps/docs/src/routes/components/kbd.tsx @@ -0,0 +1,58 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Kbd } from "dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/kbd")({ + component: KbdDocs, +}); + +const toc = [ + { id: "keys", title: "Keys" }, + { id: "why-an-array", title: "Why an array" }, +]; + +function KbdDocs() { + return ( + +
    +

    + Each entry becomes its own kbd element at a fixed 17px + cap, so a two-key shortcut and a four-key shortcut sit on the same + baseline. +

    + + +`} + > + + + + +
    + +
    +

    + Because a string would put the separator in the caller's hands, + and then half the app writes Cmd+K and the other half + writes Cmd K. children is omitted from{" "} + KbdProps for the same reason. +

    +

    + dowel does not translate key names. Meta renders as + "Meta", not as a platform glyph — mapping to the right + symbol per OS is application knowledge, and it needs the user agent to + get right. +

    +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/menu.tsx b/apps/docs/src/routes/components/menu.tsx new file mode 100644 index 0000000..0bd39c6 --- /dev/null +++ b/apps/docs/src/routes/components/menu.tsx @@ -0,0 +1,105 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Button, Menu } from "dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/menu")({ + component: MenuDocs, +}); + +const toc = [ + { id: "anatomy", title: "Anatomy" }, + { id: "groups", title: "Groups and separators" }, +]; + +function MenuDocs() { + return ( + +
    +

    + The highlighted item is styled through data-highlighted{" "} + rather than :hover, so the mouse and the keyboard produce + exactly the same state — arrow down and hover cannot disagree. +

    + + Open menu} /> + + + + Duplicate + Rename + Move to… + + + +`} + > + + Open menu} /> + + + + Duplicate + Rename + Move to… + + + + + +
    + +
    +

    + Group and GroupLabel name a run of related + items; Separator is the hairline between runs. Use one or + the other — a labelled group that also has a rule above it is saying + the same thing twice. +

    + + + This project + Duplicate + Archive + + + Delete +`} + > + + Actions} /> + + + + + This project + Duplicate + Archive + + + Delete + + + + + +

    + Menu.Positioner defaults to a 4px sideOffset + . It is set before the prop spread, so it is a default you can + override, not a mandate. +

    +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/route.tsx b/apps/docs/src/routes/components/route.tsx new file mode 100644 index 0000000..6848548 --- /dev/null +++ b/apps/docs/src/routes/components/route.tsx @@ -0,0 +1,27 @@ +import { Outlet, createFileRoute } from "@tanstack/react-router"; + +import { SidebarNav } from "../../components/sidebar-nav"; + +/** + * A layout route, so the sidebar is mounted once for the whole `/components` + * subtree instead of re-rendering per page. That is what lets the sidebar + * keep its scroll position when you move between components. + */ +export const Route = createFileRoute("/components")({ + component: ComponentsLayout, +}); + +function ComponentsLayout() { + return ( +
    + + {/* The page supplies two grid cells: the content column and the table + of contents. See DocsPage. */} + +
    + ); +} diff --git a/apps/docs/src/routes/components/tooltip.tsx b/apps/docs/src/routes/components/tooltip.tsx new file mode 100644 index 0000000..c06a55c --- /dev/null +++ b/apps/docs/src/routes/components/tooltip.tsx @@ -0,0 +1,113 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { IconButton, Tooltip } from "dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; +import { CheckIcon, CopyIcon, MenuIcon } from "../../components/icons"; + +export const Route = createFileRoute("/components/tooltip")({ + component: TooltipDocs, +}); + +const toc = [ + { id: "anatomy", title: "Anatomy" }, + { id: "shared-delay", title: "The shared delay" }, + { id: "visual-only", title: "A tooltip is a visual label only" }, +]; + +function TooltipDocs() { + return ( + +
    +

    + Compose Root, Trigger, Portal,{" "} + Positioner and Popup. The positioner + defaults to a 6px sideOffset. +

    + + } + /> + + + Copy link + + +`} + > + + + + + } + /> + + + Copy link + + + + +
    + +
    +

    + A single Tooltip.Provider at the app root is what lets + adjacent tooltips share one delay: the first waits, and the next one + the pointer reaches opens instantly instead of waiting again. Hover + across this row to feel it. +

    + + +`} + > + {[ + { label: "Copy", icon: }, + { label: "Confirm", icon: }, + { label: "More", icon: }, + ].map((t) => ( + + + {t.icon} + + } + /> + + + {t.label} + + + + ))} + +
    + +
    +

    + Base UI, which dowel builds on, deliberately does not associate the + popup with its trigger — there is no aria-describedby and + no touch affordance. So the tooltip text does not reach a screen + reader, and the trigger has to carry its own accessible name.{" "} + IconButton enforces exactly that with its required{" "} + label prop, which is why every example above wraps one. + If the hover content is information a user cannot do without, it does + not belong in a Tooltip — reach for a Popover, which is announced. +

    +
    +
    + ); +} diff --git a/apps/docs/src/routes/index.tsx b/apps/docs/src/routes/index.tsx new file mode 100644 index 0000000..fd54ab6 --- /dev/null +++ b/apps/docs/src/routes/index.tsx @@ -0,0 +1,172 @@ +import { Link, createFileRoute } from "@tanstack/react-router"; +import { Badge, Button, Field, Input, Kbd, Tooltip, IconButton } from "dowel"; + +import { CodeBlock } from "../components/code-block"; +import { ComponentGrid } from "../components/docs-page"; +import { ArrowRightIcon, CopyIcon, GitHubIcon } from "../components/icons"; +import { versionLabel } from "../lib/version"; + +export const Route = createFileRoute("/")({ + component: Home, +}); + +const INSTALL = `pnpm add dowel`; + +const USAGE = `import "dowel/dowel.css"; +import { Button } from "dowel"; + +export function Save() { + return ; +}`; + +const PRINCIPLES = [ + { + title: "A real package", + body: "Import components, bump a version, get the fixes. Your UI does not drift across apps, because there is no copy of it in your repo to drift.", + }, + { + title: "Opinionated on purpose", + body: "No per-component override API. className and style are omitted from every prop type and neutralised at runtime, so one look survives contact with a deadline.", + }, + { + title: "Light and dark from day one", + body: "One stylesheet, three activation paths, and an explicit choice that always beats the OS. Retheming is three CSS variables on :root.", + }, +]; + +function Home() { + return ( +
    +
    +
    + Pre-1.0 · MIT +

    One look, well made.

    +

    + dowel is an opinionated React component library. Every component + ships its own appearance and refuses className and{" "} + style. What you compose is behaviour and content; what + you get back is one coherent surface. +

    +
    + + +
    +
    + + {/* Not a screenshot: the hero panel is the library, running. */} +
    +
    + + + +
    +
    +
    + + + +
    + + Workspace + + +
    + {versionLabel} + Passing + + + + + + } + /> + + + Copy + + + +
    +
    +
    +
    + +
    +

    Install

    +

    + One package, one stylesheet, no build config. dowel is ESM-only. +

    +
    +
    + +

    + dowel ships no typeface — its --dowel-font token + names Inter Variable first and the app supplies it. + This site self-hosts it via{" "} + @fontsource-variable/inter. +

    +
    + +
    +
    + +
    +

    What dowel is

    +
      + {PRINCIPLES.map((p) => ( +
    • +

      {p.title}

      +

      {p.body}

      +
    • + ))} +
    +
    + +
    +

    Components

    +

    + Eight of them. Behaviour comes from Base UI; every one is + keyboard-tested and axe-checked. +

    + +
    + +
    +
    + ); +} diff --git a/apps/docs/src/vite-env.d.ts b/apps/docs/src/vite-env.d.ts new file mode 100644 index 0000000..7adb44c --- /dev/null +++ b/apps/docs/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/** + * dowel's version, replaced with a string literal at build time by the + * `define` in vite.config.ts. Declared rather than imported because the + * substitution happens in the bundler, so there is no module to import from. + */ +declare const __DOWEL_VERSION__: string; diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json new file mode 100644 index 0000000..e5780ff --- /dev/null +++ b/apps/docs/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["vite/client"], + // Root `pnpm typecheck` runs before `pnpm build` in CI, so dowel's + // dist/index.d.ts does not exist yet. Point the docs at dowel's source + // instead: the docs then typecheck against what the library actually is, + // and the gate stops depending on build order. Vite still resolves the + // workspace link through the package's exports map at build time. + "paths": { + "dowel": ["../../packages/dowel/src/index.ts"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts new file mode 100644 index 0000000..04092eb --- /dev/null +++ b/apps/docs/vite.config.ts @@ -0,0 +1,46 @@ +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// The version the docs advertise comes from the library's own manifest, so a +// release can never leave the badge behind. The path is relative on purpose: +// dowel's exports map does not expose ./package.json, so the bare specifier +// "dowel/package.json" would not resolve. +import dowelPackage from "../../packages/dowel/package.json" with { type: "json" }; + +export default defineConfig({ + // Baked in as a literal by both the client and the SSR build, which is what + // keeps it available to a prerendered page: there is no server at runtime to + // read a file, and the HTML is generated before one could. See + // src/lib/version.ts for the reader and src/vite-env.d.ts for the type. + define: { + __DOWEL_VERSION__: JSON.stringify(dowelPackage.version), + }, + // TanStack Start builds two Vite environments. The defaults would put them + // at dist/client and dist/server; the docs flatten the client one to dist/ + // so the deploy target is exactly "upload dist/" with no server bundle + // sitting next to the HTML. The SSR build is a prerender intermediate, not + // an artifact, so it goes to a scratch directory outside dist/. + environments: { + client: { build: { outDir: "dist" } }, + ssr: { build: { outDir: ".tanstack/ssr" } }, + }, + plugins: [ + tanstackStart({ + // Prerender to static HTML: docs discovery is search-driven, and a + // static build means the deploy target is an assets-only Worker with + // no runtime. `pages` seeds the crawler; `crawlLinks` follows the nav + // from there, so a new route linked from the shell is picked up + // without editing this file. + pages: [{ path: "/" }], + prerender: { + enabled: true, + crawlLinks: true, + // A page that throws during prerender must fail the build rather + // than silently ship a missing route. + failOnError: true, + }, + }), + viteReact(), + ], +}); diff --git a/apps/docs/wrangler.jsonc b/apps/docs/wrangler.jsonc new file mode 100644 index 0000000..901b59a --- /dev/null +++ b/apps/docs/wrangler.jsonc @@ -0,0 +1,20 @@ +// dowel.sh — the docs site, served as Cloudflare Worker static assets. +// Assets-only: no worker script, because the site is fully prerendered. +// +// www.dowel.sh cannot be redirected from an assets-only worker; that is a +// Cloudflare dashboard Redirect Rule (manual follow-up). +{ + "name": "dowel-sh", + "compatibility_date": "2026-08-09", + "assets": { + "directory": "./dist", + // The build emits sub-pages as directories (components/button/index.html), + // so /components/button must resolve to that file. auto-trailing-slash + // serves /components/button/ directly and 307s /components/button to it. + "html_handling": "auto-trailing-slash", + // No 404.html is emitted, so keep the plain null-body 404 for misses. + "not_found_handling": "none", + }, + "workers_dev": false, + "routes": [{ "pattern": "dowel.sh", "custom_domain": true }], +} diff --git a/docs/linear-audit-glossary.md b/docs/linear-audit-glossary.md index c9e394f..a5f0530 100644 --- a/docs/linear-audit-glossary.md +++ b/docs/linear-audit-glossary.md @@ -24,39 +24,44 @@ Three rules reproduce ~80% of the look: ## 2. Colour system (dark, measured) ### Surfaces -| token | value | -|---|---| -| `bg-primary` | `lch(5.52% 0.4 272)` | -| `bg-secondary` | `lch(7.32% 0.85 272)` | -| `bg-tertiary` | `lch(8.22% 1.3 272)` | -| `bg-quaternary` | `lch(9.345% 0.85 272)` | -| app canvas / sidebar | `lch(2.595% 0.4 272)` | + +| token | value | +| ---------------------- | ---------------------- | +| `bg-primary` | `lch(5.52% 0.4 272)` | +| `bg-secondary` | `lch(7.32% 0.85 272)` | +| `bg-tertiary` | `lch(8.22% 1.3 272)` | +| `bg-quaternary` | `lch(9.345% 0.85 272)` | +| app canvas / sidebar | `lch(2.595% 0.4 272)` | | elevated (menu, modal) | `lch(12.72% 0.85 272)` | ### Borders -| token | value | -|---|---| + +| token | value | +| ------------------ | ---------------------- | | `border-primary` | `lch(9.84% 1.48 272)` | | `border-secondary` | `lch(14.16% 1.48 272)` | | `border-tertiary` | `lch(16.32% 1.48 272)` | | popover border | `lch(25.68% 1.93 272)` | ### Text -| token | value | use | -|---|---|---| -| `text-primary` | `lch(100% 0 272)` | titles, selected | -| `text-secondary` | `lch(90.451% 1.2 272)` | body, control labels | + +| token | value | use | +| ----------------- | ---------------------- | --------------------------- | +| `text-primary` | `lch(100% 0 272)` | titles, selected | +| `text-secondary` | `lch(90.451% 1.2 272)` | body, control labels | | `text-tertiary` | `lch(61.803% 1.2 272)` | metadata, idle tabs — **φ** | -| `text-quaternary` | `lch(36.975% 1.2 272)` | disabled | +| `text-quaternary` | `lch(36.975% 1.2 272)` | disabled | `61.803` is the golden ratio. The ramp is φ-derived, not hand-picked. ### Accent + - brand / primary action: `#5e6ad2` - focus ring: `#5e69d1`, `1px solid`, also seen as `0 0 0 1px` - accent ramp: `#636fd7 #6974e1 #6b75df #727ce6 #6f7ffe` ### Light-theme anchors (exposed, not fully readable) + `bg base #f9f9fa` · `sidebar #efeff0` · `border #e2e2e2` · `text #23252a` · `elevated #fefeff` · `muted #b0b5c0` @@ -71,16 +76,16 @@ theme. dowel derives its own light ramp from the same hue-272 rule. Font: **Inter Variable** (`InterVariable.woff2`), mono: Berkeley Mono (licensed — dowel must substitute). -| step | size | px | -|---|---|---| -| micro | .6875rem | 11 | -| mini | .75rem | 12 | -| small | .8125rem | 13 | -| regular | .9375rem | 15 | -| large | 1.125rem | 18 | -| title3 | 1.25rem | 20 | -| title2 | 1.5rem | 24 | -| title1 | 2.25rem | 36 | +| step | size | px | +| ------- | -------- | --- | +| micro | .6875rem | 11 | +| mini | .75rem | 12 | +| small | .8125rem | 13 | +| regular | .9375rem | 15 | +| large | 1.125rem | 18 | +| title3 | 1.25rem | 20 | +| title2 | 1.5rem | 24 | +| title1 | 2.25rem | 36 | Weights: `300 / 450 / 500 / 600 / 700` — **normal is 450**. @@ -95,33 +100,33 @@ body/editor copy. ## 4. Shape & elevation -| thing | value | -|---|---| -| control radius | `8px` | -| pill / tab / property button | `9999px` | -| popover, modal, input | `12px` | -| kbd, small chip | `4px` | -| editor block | `6px` | -| settings row | `10px` | -| avatar (20px) | `8px` — **not a circle** | -| hairline border | `0.5px` | +| thing | value | +| ---------------------------- | ------------------------ | +| control radius | `8px` | +| pill / tab / property button | `9999px` | +| popover, modal, input | `12px` | +| kbd, small chip | `4px` | +| editor block | `6px` | +| settings row | `10px` | +| avatar (20px) | `8px` — **not a circle** | +| hairline border | `0.5px` | Two elevation tiers only: ```css /* popover, dropdown, select, context menu */ box-shadow: - 0 3px 8px lch(0 0 0/.125), - 0 2px 5px lch(0 0 0/.125), - 0 1px 1px lch(0 0 0/.125); + 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); /* modal, command palette */ box-shadow: - 0 4px 40px lch(0 0 0/.10), - 0 3px 20px lch(0 0 0/.125), - 0 3px 12px lch(0 0 0/.125), - 0 2px 8px lch(0 0 0/.125), - 0 1px 1px lch(0 0 0/.125); + 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); ``` Both pair with `border: 0.5px solid `. The 0.5px hairline plus @@ -137,7 +142,11 @@ easing set (`ease-out-quad` = `cubic-bezier(.25,.46,.45,.94)` is the workhorse). **Measured on real controls: `0.15s`** — and never `all`: ```css -transition: border .15s, background-color .15s, color .15s, opacity .15s; +transition: + border 0.15s, + background-color 0.15s, + color 0.15s, + opacity 0.15s; ``` Only these four properties animate. Nothing transitions transform or size on @@ -148,10 +157,12 @@ hover. This is a deliberate restraint worth copying. ## 6. Component measurements ### Control heights + `24px` small icon button · `28px` **everything else** · `32px` menu option · `36px` filter input · `40px` command-palette input · `46px` palette row ### Segmented tabs (Assigned / Created / …) + ``` height 28 · padding 0 10 · radius 9999 · font 12/500 idle bg lch(10.149 0.593 272) text lch(61.803 1.2 272) @@ -160,6 +171,7 @@ border 0.5px solid transparent ``` ### Sidebar item + ``` height 28 · padding 0 9 0 8 · radius 8 · font 13/500 idle text lch(60.621 1.2 272) @@ -170,15 +182,18 @@ sidebar width 244px ``` ### Property button (status, assignee, label — the pill) + ``` height 28 · padding 0 10 0 6 · radius 9999 · font 13/500 text lch(90.451 1.2 272) open bg lch(14.006 0.593 272) transition border, background-color, color, opacity ``` + Asymmetric padding: 6px left (icon side), 10px right. ### Icon button + ``` 28×28 · padding 0 2 · radius 9999 · border 0.5px solid transparent 24×24 · padding 0 4 · radius 9999 (compact) @@ -186,6 +201,7 @@ hover bg lch(10.149 0.689 272) ``` ### Popover / Select + ``` container role=dialog · radius 12 · bg lch(12.72 0.85 272) border 0.5px solid lch(25.68 1.93 272) · 3-layer shadow @@ -197,6 +213,7 @@ filter input height 36 · padding 10 0 9 · transition color .1s ease-in-out ``` ### Command palette + ``` dialog 720×450 · radius 12 · bg lch(12.72 0.85 272) border 0.5px solid lch(25.68 1.93 272) · 5-layer shadow @@ -207,6 +224,7 @@ row height 46 · padding 0 12 · gap 12 · font 15/400 ``` ### Kbd + ``` 24×17 · padding 2 · radius 4 · font 11/400 border 0.5px solid lch(20.28 1.93 272) @@ -214,6 +232,7 @@ text lch(64.714 1.425 272) · gap 3px between keys ``` ### List row (issue) + ``` id 13/450 · tracking -0.26px · text tertiary title 13/500 · text primary @@ -223,6 +242,7 @@ avatar 20×20 radius 8 ``` ### Editor / composer + ``` title 24/600 · tracking -0.1px body 15/450 · line-height 1.6 · tracking -0.00667em diff --git a/docs/superpowers/plans/2026-08-09-dowel-phase-1.md b/docs/superpowers/plans/2026-08-09-dowel-phase-1.md index 737329f..c3f6ec6 100644 --- a/docs/superpowers/plans/2026-08-09-dowel-phase-1.md +++ b/docs/superpowers/plans/2026-08-09-dowel-phase-1.md @@ -4,9 +4,9 @@ **Goal:** Ship `dowel@0.1.0` to npm with a working token system, a compiled-CSS build pipeline, release automation, a live docs site at dowel.sh, and 8 components proving the pattern end-to-end. -**Architecture:** pnpm workspace with one publishable package (`packages/dowel`) and one docs app (`apps/docs`). Components are React wrappers over `@base-ui/react` primitives, styled by hand-authored plain CSS with `dowel-` prefixed classes. Lightning CSS bundles per-component CSS into a single `dist/dowel.css`; tsup emits ESM + types. Variants are expressed as `data-*` attributes, never className props. +**Architecture:** pnpm workspace with one publishable package (`packages/dowel`) and one docs app (`apps/docs`). Components are React wrappers over `@base-ui/react` primitives, styled by hand-authored plain CSS with `dowel-` prefixed classes. Lightning CSS bundles per-component CSS into a single `dist/dowel.css`; tsdown emits ESM + types. Variants are expressed as `data-*` attributes, never className props. -**Tech Stack:** React 19.2, `@base-ui/react` 1.7, TypeScript 5.9.3, Lightning CSS 1.33, tsup 8.5, vitest 4.1, changesets 2.31, TanStack Start 1.168, Cloudflare Workers. +**Tech Stack:** React 19.2, `@base-ui/react` 1.7, TypeScript 5.9.3, Lightning CSS 1.33, tsdown 0.22, vitest 4.1, changesets 2.31, TanStack Start 1.168, Cloudflare Workers. ## Global Constraints @@ -24,6 +24,7 @@ - **Never ship** Linear's logo, icons, or Berkeley Mono. Mono stack is JetBrains Mono / `ui-monospace`. **Reference documents (read before starting):** + - Spec: `docs/superpowers/specs/2026-08-09-dowel-design.md` - Measured values: `docs/linear-audit-glossary.md` @@ -35,7 +36,7 @@ packages/dowel/ ├── package.json name "dowel", exports . and ./dowel.css ├── tsconfig.json -├── tsup.config.ts ESM + d.ts +├── tsdown.config.ts ESM + d.ts ├── scripts/build-css.mjs Lightning CSS bundle+minify ├── src/ │ ├── index.ts barrel: re-exports every component @@ -44,7 +45,6 @@ packages/dowel/ │ │ ├── scale.css non-colour tokens (type, size, motion, shape) │ │ ├── light.css :root colour tokens │ │ └── dark.css dark overrides (class, attr, media) -│ ├── lib/cx.ts tiny class joiner (no clsx dep) │ └── components/ │ ├── button/{index.tsx,button.css,button.test.tsx} │ ├── icon-button/{...} @@ -64,24 +64,27 @@ apps/docs/ TanStack Start, prerendered → dowel.sh .changeset/config.json ``` -**Responsibility boundaries:** one directory per component holding its markup, its styles and its tests together — they change together. Tokens are split by *what changes per theme* (colour) versus *what never does* (scale), because dark mode overrides exactly one of those files. +**Responsibility boundaries:** one directory per component holding its markup, its styles and its tests together — they change together. Tokens are split by _what changes per theme_ (colour) versus _what never does_ (scale), because dark mode overrides exactly one of those files. --- ## Task 1: Workspace scaffold **Files:** + - Create: `pnpm-workspace.yaml`, `package.json`, `.npmrc`, `.prettierrc`, `tsconfig.base.json` - Create: `packages/dowel/package.json`, `packages/dowel/tsconfig.json` - Modify: `mise.toml` (already exists, verify contents) **Interfaces:** + - Consumes: nothing (first task) - Produces: workspace where `pnpm install`, `pnpm -r typecheck`, `pnpm format:check` all run clean. Package name `dowel`. Every later task runs commands from repo root. - [ ] **Step 1: Create the workspace manifest** `pnpm-workspace.yaml`: + ```yaml # pnpm 11 no longer reads the "pnpm" key in package.json, and it refuses to # finish an install that silently skipped a package's build script. Declaring @@ -121,12 +124,14 @@ allowBuilds: - [ ] **Step 3: Create .npmrc, .prettierrc and LICENSE** `.npmrc`: + ``` # Keep the lockfile honest in CI; mise pins the pnpm version itself. engine-strict=true ``` `.prettierrc`: + ```json { "semi": true, @@ -164,9 +169,11 @@ SOFTWARE. ``` Also copy it into the package so it ships with the tarball: + ```bash cp LICENSE packages/dowel/LICENSE ``` + and add `"LICENSE"` to the package's `files` array alongside `"dist"`. - [ ] **Step 4: Create tsconfig.base.json** @@ -217,7 +224,7 @@ and add `"LICENSE"` to the package's `files` array alongside `"dist"`. "./dowel.css": "./dist/dowel.css" }, "scripts": { - "build": "tsup && node scripts/build-css.mjs", + "build": "tsdown && node scripts/build-css.mjs", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit" @@ -241,7 +248,7 @@ and add `"LICENSE"` to the package's `files` array alongside `"dist"`. "lightningcss": "^1.33.0", "react": "^19.2.8", "react-dom": "^19.2.8", - "tsup": "^8.5.1", + "tsdown": "^0.22.14", "vitest": "^4.1.10" } } @@ -260,6 +267,7 @@ and add `"LICENSE"` to the package's `files` array alongside `"dist"`. Run: `cat mise.toml` Expected to contain exactly: + ```toml [tools] node = "24.18.0" @@ -274,6 +282,7 @@ node = "24.18.0" pnpm install pnpm format:check ``` + Expected: install completes, format check passes. `pnpm typecheck` will fail — there is no `src` yet. That is expected; Task 2 creates it. - [ ] **Step 9: Commit** @@ -288,6 +297,7 @@ git commit -m "Scaffold the pnpm workspace and dowel package" ## Task 2: Token layer **Files:** + - Create: `packages/dowel/src/tokens/scale.css` - Create: `packages/dowel/src/tokens/light.css` - Create: `packages/dowel/src/tokens/dark.css` @@ -295,12 +305,14 @@ git commit -m "Scaffold the pnpm workspace and dowel package" - Test: `packages/dowel/test/tokens.test.ts` **Interfaces:** + - Consumes: Task 1's workspace. - Produces: the complete `--dowel-*` custom property set. Every component CSS file from Task 4 onward references ONLY these names. Layer order is declared as `@layer dowel.tokens, dowel.base, dowel.components;` — component CSS must live in `dowel.components`. - [ ] **Step 1: Write the failing test** `packages/dowel/test/tokens.test.ts`: + ```ts import { readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -350,6 +362,7 @@ Expected: FAIL — `ENOENT` on `src/index.css`. - [ ] **Step 3: Write scale.css** `packages/dowel/src/tokens/scale.css`: + ```css /* Non-colour tokens. Identical in light and dark — dark.css overrides colour only, so this file must never contain a colour value. */ @@ -425,6 +438,7 @@ Expected: FAIL — `ENOENT` on `src/index.css`. - [ ] **Step 4: Write light.css** `packages/dowel/src/tokens/light.css`: + ```css /* Light is the default theme. Every neutral is one hue at low chroma — greys that are not grey. Changing --dowel-hue retints the entire library. */ @@ -478,6 +492,7 @@ Expected: FAIL — `ENOENT` on `src/index.css`. - [ ] **Step 5: Write dark.css** `packages/dowel/src/tokens/dark.css`: + ```css /* Dark overrides colour only. Three activation paths, in precedence order: explicit class, explicit attribute, then system preference — and the media @@ -569,6 +584,7 @@ spec. The `tokens.test.ts` parity test is what keeps the two copies honest. - [ ] **Step 6: Write index.css** `packages/dowel/src/index.css`: + ```css /* Layer order is declared once, first, before any @import. Consumers' own unlayered styles beat every layer here, so overriding dowel never becomes a @@ -625,6 +641,7 @@ self-hosted or served from a CDN. - [ ] **Step 8: Create the vitest config so tests can run** `packages/dowel/vitest.config.ts`: + ```ts import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; @@ -640,6 +657,7 @@ export default defineConfig({ ``` `packages/dowel/test/setup.ts`: + ```ts // Placeholder until Task 4 adds the axe matcher. Kept as a file so the // vitest config resolves from the very first test run. @@ -663,19 +681,21 @@ git commit -m "Add the dowel token layer with light and dark parity tests" ## Task 3: Build pipeline **Files:** -- Create: `packages/dowel/tsup.config.ts` + +- Create: `packages/dowel/tsdown.config.ts` - Create: `packages/dowel/scripts/build-css.mjs` - Create: `packages/dowel/src/index.ts` -- Create: `packages/dowel/src/lib/cx.ts` - Test: `packages/dowel/test/css-contract.test.ts` **Interfaces:** + - Consumes: Task 2's `src/index.css` and token names. -- Produces: `pnpm --filter dowel build` emitting `dist/index.js`, `dist/index.d.ts`, `dist/dowel.css`. Exports `cx(...parts: Array): string` from `src/lib/cx.ts`, used by every component in Tasks 4+. +- Produces: `pnpm --filter dowel build` emitting `dist/index.js`, `dist/index.d.ts`, `dist/dowel.css`. Build order is `tsdown` then `build-css.mjs` — never the reverse. Produces no class-name helper: components in Tasks 4+ write `className="dowel-x"` as a plain string literal. - [ ] **Step 1: Write the failing build-contract test** `packages/dowel/test/css-contract.test.ts`: + ```ts import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -720,6 +740,7 @@ Expected: FAIL — `dist/dowel.css` does not exist. - [ ] **Step 3: Write the CSS build script** `packages/dowel/scripts/build-css.mjs`: + ```js // Bundles src/index.css (following @import) into one minified dist/dowel.css. // Lightning CSS is used as a library rather than the CLI so the targets and @@ -760,45 +781,48 @@ console.log(`built dist/dowel.css (${(code.length / 1024).toFixed(1)} kB)`); pnpm --filter dowel add -D browserslist@^4.26.0 ``` -- [ ] **Step 5: Write tsup.config.ts** +- [ ] **Step 5: Write tsdown.config.ts** + +`packages/dowel/tsdown.config.ts`: -`packages/dowel/tsup.config.ts`: ```ts -import { defineConfig } from "tsup"; +import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["src/index.ts"], - format: ["esm"], + format: "esm", + platform: "browser", dts: true, clean: true, treeshake: true, - // CSS is built separately by scripts/build-css.mjs; tsup must not try to - // process the .css imports that components do not make at runtime. - external: ["react", "react-dom", "@base-ui/react"], + // No `external` here on purpose: tsdown never bundles `dependencies` or + // `peerDependencies`, so react, react-dom and @base-ui/react are already + // external. (tsdown's `external` option is deprecated in favour of + // `deps.neverBundle`, and neither is needed for this package.) }); ``` -- [ ] **Step 6: Write cx.ts** +`clean: true` wipes `dist/` — which is why `package.json` runs `tsdown` first +and `build-css.mjs` second. Reversing that order silently deletes +`dist/dowel.css` and the CSS contract test fails with a confusing "not built". -`packages/dowel/src/lib/cx.ts`: -```ts -/** - * Joins class names, dropping falsy entries. - * - * dowel has no clsx/tailwind-merge dependency: class names are authored by us - * and never merged with consumer classes, so conflict resolution is not a - * problem this library has. - */ -export function cx( - ...parts: Array -): string { - return parts.filter(Boolean).join(" "); -} +- [ ] **Step 6: (intentionally empty — no class-name helper)** + +dowel ships **no `cx`/`clsx`/`tailwind-merge` helper**. Variants are `data-*` +attributes, so no component ever builds a conditional class name — every +`className` in this library is a single string literal: + +```text +className="dowel-btn" ``` +If a later phase genuinely needs conditional classes, add the helper then. +Do not add one now, and do not wrap these literals in a function. + - [ ] **Step 7: Write the barrel** `packages/dowel/src/index.ts`: + ```ts // Components are appended here by each component task. export {}; @@ -810,6 +834,7 @@ export {}; pnpm --filter dowel build pnpm --filter dowel test css-contract ``` + Expected: build prints a kB size; all 4 contract tests PASS. - [ ] **Step 9: Add dist to .gitignore and commit** @@ -817,7 +842,7 @@ Expected: build prints a kB size; all 4 contract tests PASS. ```bash grep -q '^dist/' .gitignore || echo 'dist/' >> .gitignore git add -A -git commit -m "Add the Lightning CSS and tsup build pipeline with a token contract test" +git commit -m "Add the Lightning CSS and tsdown build pipeline with a token contract test" ``` --- @@ -828,12 +853,14 @@ This task establishes the pattern every later component copies: Base UI primitive, `data-*` variants, colocated CSS, a11y test, both-theme render. **Files:** + - Create: `packages/dowel/src/components/button/{index.tsx,button.css,button.test.tsx}` - Create: `packages/dowel/test/render.tsx` - Modify: `packages/dowel/test/setup.ts` - Modify: `packages/dowel/src/index.ts`, `packages/dowel/src/index.css` **Interfaces:** + - Consumes: `cx` from Task 3, tokens from Task 2. - Produces: - `Button` — `React.forwardRef` @@ -844,6 +871,7 @@ primitive, `data-*` variants, colocated CSS, a11y test, both-theme render. - [ ] **Step 1: Write the shared test helpers** `packages/dowel/test/setup.ts`: + ```ts import axe from "axe-core"; import { expect } from "vitest"; @@ -862,6 +890,7 @@ export async function expectNoA11yViolations(el: HTMLElement): Promise { ``` `packages/dowel/test/render.tsx`: + ```tsx import { render } from "@testing-library/react"; import type { ReactElement } from "react"; @@ -884,6 +913,7 @@ export function renderBoth(ui: ReactElement) { - [ ] **Step 2: Write the failing Button test** `packages/dowel/src/components/button/button.test.tsx`: + ```tsx import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -967,11 +997,11 @@ Expected: FAIL — cannot resolve `./index`. - [ ] **Step 4: Write the Button component** `packages/dowel/src/components/button/index.tsx`: + ```tsx import { Button as BaseButton } from "@base-ui/react/button"; import { forwardRef } from "react"; import type { ComponentPropsWithoutRef, ReactElement } from "react"; -import { cx } from "../../lib/cx"; type NativeButtonProps = Omit< ComponentPropsWithoutRef<"button">, @@ -997,7 +1027,7 @@ export const Button = forwardRef( ( - [ ] **Step 5: Write button.css** `packages/dowel/src/components/button/button.css`: + ```css @layer dowel.components { .dowel-btn { @@ -1093,12 +1124,14 @@ export const Button = forwardRef( - [ ] **Step 6: Wire it into the barrel and the stylesheet** Append to `packages/dowel/src/index.ts`: + ```ts export { Button } from "./components/button"; export type { ButtonProps } from "./components/button"; ``` Append to `packages/dowel/src/index.css` (after the `dowel.base` block): + ```css @import "./components/button/button.css"; ``` @@ -1114,6 +1147,7 @@ Expected: PASS, 9 tests. pnpm --filter dowel build pnpm --filter dowel test ``` + Expected: all suites PASS. If `css-contract` reports a missing token, the button CSS referenced a name absent from Task 2 — fix the reference, do not add an ad-hoc token. - [ ] **Step 9: Commit** @@ -1128,16 +1162,19 @@ git commit -m "Add Button and the component authoring pattern" ## Task 5: IconButton **Files:** + - Create: `packages/dowel/src/components/icon-button/{index.tsx,icon-button.css,icon-button.test.tsx}` - Modify: `packages/dowel/src/index.ts`, `packages/dowel/src/index.css` **Interfaces:** + - Consumes: `cx`, tokens, `renderBoth`, `expectNoA11yViolations`. - Produces: `IconButton`, `IconButtonProps = { variant?: "secondary" | "ghost"; size?: "sm" | "md"; label: string; render?: ReactElement }`. `label` is required and becomes `aria-label` — an icon-only button with no name is the single most common a11y defect in component libraries, so the type system forbids it. - [ ] **Step 1: Write the failing test** `packages/dowel/src/components/icon-button/icon-button.test.tsx`: + ```tsx import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; @@ -1206,11 +1243,11 @@ Expected: FAIL — cannot resolve `./index`. - [ ] **Step 3: Write the component** `packages/dowel/src/components/icon-button/index.tsx`: + ```tsx import { Button as BaseButton } from "@base-ui/react/button"; import { forwardRef } from "react"; import type { ComponentPropsWithoutRef, ReactElement } from "react"; -import { cx } from "../../lib/cx"; type NativeButtonProps = Omit< ComponentPropsWithoutRef<"button">, @@ -1235,7 +1272,7 @@ export const IconButton = forwardRef( ref={ref} render={render} aria-label={label} - className={cx("dowel-icon-btn")} + className="dowel-icon-btn" data-variant={variant} data-size={size} {...props} @@ -1248,6 +1285,7 @@ export const IconButton = forwardRef( - [ ] **Step 4: Write the CSS** `packages/dowel/src/components/icon-button/icon-button.css`: + ```css @layer dowel.components { .dowel-icon-btn { @@ -1296,12 +1334,14 @@ export const IconButton = forwardRef( - [ ] **Step 5: Wire it up** Append to `src/index.ts`: + ```ts export { IconButton } from "./components/icon-button"; export type { IconButtonProps } from "./components/icon-button"; ``` Append to `src/index.css`: + ```css @import "./components/icon-button/icon-button.css"; ``` @@ -1312,6 +1352,7 @@ Append to `src/index.css`: pnpm --filter dowel test icon-button pnpm --filter dowel build && pnpm --filter dowel test ``` + Expected: PASS. - [ ] **Step 7: Commit** @@ -1329,11 +1370,13 @@ Two plain-element components with no Base UI dependency. Paired because each is small and they share the same "static display element" shape. **Files:** + - Create: `packages/dowel/src/components/badge/{index.tsx,badge.css,badge.test.tsx}` - Create: `packages/dowel/src/components/kbd/{index.tsx,kbd.css,kbd.test.tsx}` - Modify: `packages/dowel/src/index.ts`, `packages/dowel/src/index.css` **Interfaces:** + - Consumes: `cx`, tokens, `renderBoth`, `expectNoA11yViolations`. - Produces: - `Badge`, `BadgeProps = { tone?: "neutral" | "accent" | "success" | "warning" | "danger" }` + span props minus className/style. @@ -1342,6 +1385,7 @@ small and they share the same "static display element" shape. - [ ] **Step 1: Write both failing tests** `packages/dowel/src/components/badge/badge.test.tsx`: + ```tsx import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; @@ -1379,6 +1423,7 @@ describe("Badge", () => { ``` `packages/dowel/src/components/kbd/kbd.test.tsx`: + ```tsx import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; @@ -1421,10 +1466,10 @@ Expected: FAIL — modules not found. - [ ] **Step 3: Write Badge** `packages/dowel/src/components/badge/index.tsx`: + ```tsx import { forwardRef } from "react"; import type { ComponentPropsWithoutRef } from "react"; -import { cx } from "../../lib/cx"; export interface BadgeProps extends Omit, "className" | "style"> { @@ -1435,18 +1480,12 @@ export const Badge = forwardRef(function Badge( { tone = "neutral", ...props }, ref, ) { - return ( - - ); + return ; }); ``` `packages/dowel/src/components/badge/badge.css`: + ```css @layer dowel.components { .dowel-badge { @@ -1490,10 +1529,10 @@ export const Badge = forwardRef(function Badge( - [ ] **Step 4: Write Kbd** `packages/dowel/src/components/kbd/index.tsx`: + ```tsx import { forwardRef } from "react"; import type { ComponentPropsWithoutRef } from "react"; -import { cx } from "../../lib/cx"; export interface KbdProps extends Omit< @@ -1509,7 +1548,7 @@ export const Kbd = forwardRef(function Kbd( ref, ) { return ( - + {keys.map((key, i) => ( {key} ))} @@ -1519,6 +1558,7 @@ export const Kbd = forwardRef(function Kbd( ``` `packages/dowel/src/components/kbd/kbd.css`: + ```css @layer dowel.components { .dowel-kbd { @@ -1552,6 +1592,7 @@ export const Kbd = forwardRef(function Kbd( - [ ] **Step 5: Wire both up** Append to `src/index.ts`: + ```ts export { Badge } from "./components/badge"; export type { BadgeProps } from "./components/badge"; @@ -1560,6 +1601,7 @@ export type { KbdProps } from "./components/kbd"; ``` Append to `src/index.css`: + ```css @import "./components/badge/badge.css"; @import "./components/kbd/kbd.css"; @@ -1571,6 +1613,7 @@ Append to `src/index.css`: pnpm --filter dowel test badge kbd pnpm --filter dowel build && pnpm --filter dowel test ``` + Expected: PASS. - [ ] **Step 7: Commit** @@ -1585,10 +1628,12 @@ git commit -m "Add Badge and Kbd" ## Task 7: Input and Field **Files:** + - Create: `packages/dowel/src/components/input/{index.tsx,input.css,input.test.tsx}` - Modify: `packages/dowel/src/index.ts`, `packages/dowel/src/index.css` **Interfaces:** + - Consumes: `cx`, tokens, test helpers. - Produces: - `Input`, `InputProps = { size?: "md" | "lg"; invalid?: boolean }` + native input props minus className/style. @@ -1597,6 +1642,7 @@ git commit -m "Add Badge and Kbd" - [ ] **Step 1: Write the failing test** `packages/dowel/src/components/input/input.test.tsx`: + ```tsx import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -1707,12 +1753,12 @@ matching `id`/`htmlFor` strings — that is the bug Field exists to remove. - [ ] **Step 3: Write the component** `packages/dowel/src/components/input/index.tsx`: + ```tsx 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"; -import { cx } from "../../lib/cx"; export interface InputProps extends Omit, "className" | "style"> { @@ -1728,7 +1774,7 @@ export const Input = forwardRef(function Input( return ( , "className" | "style"> >(function FieldRoot(props, ref) { - return ( - - ); + return ; }), Label: forwardRef< @@ -1755,11 +1799,7 @@ export const Field = { Omit, "className" | "style"> >(function FieldLabel(props, ref) { return ( - + ); }), @@ -1770,7 +1810,7 @@ export const Field = { return ( ); @@ -1781,11 +1821,7 @@ export const Field = { Omit, "className" | "style"> >(function FieldError(props, ref) { return ( - + ); }), }; @@ -1794,6 +1830,7 @@ export const Field = { - [ ] **Step 4: Write the CSS** `packages/dowel/src/components/input/input.css`: + ```css @layer dowel.components { .dowel-input { @@ -1869,12 +1906,14 @@ export const Field = { - [ ] **Step 5: Wire it up** Append to `src/index.ts`: + ```ts export { Input, Field } from "./components/input"; export type { InputProps } from "./components/input"; ``` Append to `src/index.css`: + ```css @import "./components/input/input.css"; ``` @@ -1885,6 +1924,7 @@ Append to `src/index.css`: pnpm --filter dowel test input pnpm --filter dowel build && pnpm --filter dowel test ``` + Expected: PASS. - [ ] **Step 7: Commit** @@ -1899,16 +1939,19 @@ git commit -m "Add Input and Field with automatic label association" ## Task 8: Dialog **Files:** + - Create: `packages/dowel/src/components/dialog/{index.tsx,dialog.css,dialog.test.tsx}` - Modify: `packages/dowel/src/index.ts`, `packages/dowel/src/index.css` **Interfaces:** + - Consumes: `cx`, tokens, test helpers. - Produces: `Dialog` compound — `{ Root, Trigger, Portal, Backdrop, Popup, Title, Description, Close }`. Uses the **modal** shadow tier (`--dowel-shadow-modal`). - [ ] **Step 1: Write the failing test** `packages/dowel/src/components/dialog/dialog.test.tsx`: + ```tsx import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -1975,16 +2018,18 @@ Expected: FAIL — cannot resolve `./index`. - [ ] **Step 3: Write the component** `packages/dowel/src/components/dialog/index.tsx`: + ```tsx import { Dialog as BaseDialog } from "@base-ui/react/dialog"; -import type { ComponentProps } from "react"; -import { cx } from "../../lib/cx"; -/** Strips the appearance escape hatches from any Base UI component's props. */ -type Props unknown> = Omit< - ComponentProps, - "className" | "style" ->; +/** Strips the appearance escape hatches from any Base UI component's props. + * Inferred from the call signature: ComponentProps rejects this + * constraint with TS2344, so infer the props parameter directly. */ +type Props unknown> = T extends ( + props: infer P, +) => unknown + ? Omit + : never; export const Dialog = { Root: BaseDialog.Root, @@ -1992,25 +2037,22 @@ export const Dialog = { Portal: BaseDialog.Portal, Backdrop: function DialogBackdrop(props: Props) { - return ; + return ; }, Popup: function DialogPopup(props: Props) { - return ; + return ; }, Title: function DialogTitle(props: Props) { - return ; + return ; }, Description: function DialogDescription( props: Props, ) { return ( - + ); }, @@ -2021,6 +2063,7 @@ export const Dialog = { - [ ] **Step 4: Write the CSS** `packages/dowel/src/components/dialog/dialog.css`: + ```css @layer dowel.components { .dowel-backdrop { @@ -2081,11 +2124,13 @@ export const Dialog = { - [ ] **Step 5: Wire it up** Append to `src/index.ts`: + ```ts export { Dialog } from "./components/dialog"; ``` Append to `src/index.css`: + ```css @import "./components/dialog/dialog.css"; ``` @@ -2096,6 +2141,7 @@ Append to `src/index.css`: pnpm --filter dowel test dialog pnpm --filter dowel build && pnpm --filter dowel test ``` + Expected: PASS. - [ ] **Step 7: Commit** @@ -2110,16 +2156,19 @@ git commit -m "Add Dialog on the modal elevation tier" ## Task 9: Menu **Files:** + - Create: `packages/dowel/src/components/menu/{index.tsx,menu.css,menu.test.tsx}` - Modify: `packages/dowel/src/index.ts`, `packages/dowel/src/index.css` **Interfaces:** + - Consumes: `cx`, tokens, `Button` (for the trigger in tests). - Produces: `Menu` compound — `{ Root, Trigger, Portal, Positioner, Popup, Item, Separator, Group, GroupLabel }`. Uses the **popover** shadow tier. Item height 32px per the audit. - [ ] **Step 1: Write the failing test** `packages/dowel/src/components/menu/menu.test.tsx`: + ```tsx import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -2196,16 +2245,18 @@ Expected: FAIL — cannot resolve `./index`. - [ ] **Step 3: Write the component** `packages/dowel/src/components/menu/index.tsx`: + ```tsx import { Menu as BaseMenu } from "@base-ui/react/menu"; -import type { ComponentProps } from "react"; -import { cx } from "../../lib/cx"; -/** Strips the appearance escape hatches from any Base UI component's props. */ -type Props unknown> = Omit< - ComponentProps, - "className" | "style" ->; +/** Strips the appearance escape hatches from any Base UI component's props. + * Inferred from the call signature: ComponentProps rejects this + * constraint with TS2344, so infer the props parameter directly. */ +type Props unknown> = T extends ( + props: infer P, +) => unknown + ? Omit + : never; export const Menu = { Root: BaseMenu.Root, @@ -2219,17 +2270,15 @@ export const Menu = { }, Popup: function MenuPopup(props: Props) { - return ; + return ; }, Item: function MenuItem(props: Props) { - return ; + return ; }, Separator: function MenuSeparator(props: Props) { - return ( - - ); + return ; }, Group: BaseMenu.Group, @@ -2237,9 +2286,7 @@ export const Menu = { GroupLabel: function MenuGroupLabel( props: Props, ) { - return ( - - ); + return ; }, }; ``` @@ -2247,6 +2294,7 @@ export const Menu = { - [ ] **Step 4: Write the CSS** `packages/dowel/src/components/menu/menu.css`: + ```css @layer dowel.components { .dowel-menu { @@ -2318,11 +2366,13 @@ export const Menu = { - [ ] **Step 5: Wire it up** Append to `src/index.ts`: + ```ts export { Menu } from "./components/menu"; ``` Append to `src/index.css`: + ```css @import "./components/menu/menu.css"; ``` @@ -2333,6 +2383,7 @@ Append to `src/index.css`: pnpm --filter dowel test menu pnpm --filter dowel build && pnpm --filter dowel test ``` + Expected: PASS. - [ ] **Step 7: Commit** @@ -2347,16 +2398,19 @@ git commit -m "Add Menu with keyboard navigation" ## Task 10: Tooltip **Files:** + - Create: `packages/dowel/src/components/tooltip/{index.tsx,tooltip.css,tooltip.test.tsx}` - Modify: `packages/dowel/src/index.ts`, `packages/dowel/src/index.css` **Interfaces:** + - Consumes: `cx`, tokens, `IconButton`. - Produces: `Tooltip` compound — `{ Provider, Root, Trigger, Portal, Positioner, Popup }`. Consumers must wrap their app in `Tooltip.Provider` once. - [ ] **Step 1: Write the failing test** `packages/dowel/src/components/tooltip/tooltip.test.tsx`: + ```tsx import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -2422,16 +2476,18 @@ Expected: FAIL — cannot resolve `./index`. - [ ] **Step 3: Write the component** `packages/dowel/src/components/tooltip/index.tsx`: + ```tsx import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip"; -import type { ComponentProps } from "react"; -import { cx } from "../../lib/cx"; -/** Strips the appearance escape hatches from any Base UI component's props. */ -type Props unknown> = Omit< - ComponentProps, - "className" | "style" ->; +/** Strips the appearance escape hatches from any Base UI component's props. + * Inferred from the call signature: ComponentProps rejects this + * constraint with TS2344, so infer the props parameter directly. */ +type Props unknown> = T extends ( + props: infer P, +) => unknown + ? Omit + : never; export const Tooltip = { Provider: BaseTooltip.Provider, @@ -2446,7 +2502,7 @@ export const Tooltip = { }, Popup: function TooltipPopup(props: Props) { - return ; + return ; }, }; ``` @@ -2454,6 +2510,7 @@ export const Tooltip = { - [ ] **Step 4: Write the CSS** `packages/dowel/src/components/tooltip/tooltip.css`: + ```css @layer dowel.components { .dowel-tooltip { @@ -2482,11 +2539,13 @@ export const Tooltip = { - [ ] **Step 5: Wire it up** Append to `src/index.ts`: + ```ts export { Tooltip } from "./components/tooltip"; ``` Append to `src/index.css`: + ```css @import "./components/tooltip/tooltip.css"; ``` @@ -2498,6 +2557,7 @@ pnpm --filter dowel build pnpm --filter dowel test pnpm typecheck ``` + Expected: all PASS. This is the complete 8-component slice. - [ ] **Step 7: Commit** @@ -2512,15 +2572,18 @@ git commit -m "Add Tooltip, completing the phase 1 component slice" ## Task 11: CI workflow **Files:** + - Create: `.github/workflows/ci.yml` **Interfaces:** + - Consumes: root scripts `format:check`, `typecheck`, `build`, `test` from Task 1. - Produces: a workflow named exactly **`CI`** — Task 12's release workflow keys off that name in its `workflow_run` trigger. Renaming it breaks releases. - [ ] **Step 1: Write the workflow** `.github/workflows/ci.yml`: + ```yaml name: CI @@ -2587,6 +2650,7 @@ jobs: pnpm install --frozen-lockfile pnpm format:check && pnpm typecheck && pnpm build && pnpm test ``` + Expected: all four succeed. Fix anything red before pushing — a red first CI run is noise. - [ ] **Step 3: Commit and open a PR** @@ -2608,11 +2672,13 @@ Expected: the `CI / build` check passes. ## Task 12: Release automation **Files:** + - Create: `.changeset/config.json` - Create: `.github/workflows/release.yml` - Modify: root `package.json` (add `@changesets/cli`) **Interfaces:** + - Consumes: the workflow named `CI` from Task 11; the org secret `NPM_TOKEN`. - Produces: merging a PR that contains a changeset opens a "Version Packages" PR; merging that publishes `dowel` to npm. @@ -2626,13 +2692,11 @@ pnpm exec changeset init - [ ] **Step 2: Configure it** `.changeset/config.json`: + ```json { "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", - "changelog": [ - "@changesets/changelog-github", - { "repo": "karnstack/dowel" } - ], + "changelog": ["@changesets/changelog-github", { "repo": "karnstack/dowel" }], "commit": false, "fixed": [], "linked": [], @@ -2650,6 +2714,7 @@ pnpm add -Dw @changesets/changelog-github@^0.5.1 - [ ] **Step 3: Write the release workflow** `.github/workflows/release.yml`: + ```yaml name: Release @@ -2716,6 +2781,7 @@ Set `packages/dowel/package.json` `"version"` to `"0.0.0"` (already done in Task ```bash pnpm exec changeset ``` + Choose `dowel`, select **minor**, and use the summary: `First release: token system, build pipeline, and eight components.` @@ -2737,9 +2803,11 @@ Run: `gh pr list` Expected: a `chore: version packages` PR exists. Merging it publishes `dowel@0.1.0`. **Verification after that merge:** + ```bash sleep 60 && curl -s https://registry.npmjs.org/dowel | python3 -c "import sys,json;print(json.load(sys.stdin)['dist-tags'])" ``` + Expected: `{"latest": "0.1.0"}`. --- @@ -2747,17 +2815,20 @@ Expected: `{"latest": "0.1.0"}`. ## Task 13: Docs site **Files:** + - Create: `apps/docs/` — TanStack Start app - Create: `apps/docs/package.json`, `apps/docs/app.config.ts`, `apps/docs/wrangler.jsonc` - Create: `apps/docs/src/routes/{__root.tsx,index.tsx,components/button.tsx}` **Interfaces:** + - Consumes: the built `dowel` package via workspace protocol. - Produces: a prerendered static site in `apps/docs/dist` ready for Task 14's deploy. - [ ] **Step 1: Scaffold the app package** `apps/docs/package.json`: + ```json { "name": "@dowel/docs", @@ -2789,6 +2860,7 @@ Expected: `{"latest": "0.1.0"}`. - [ ] **Step 2: Configure prerendering** `apps/docs/app.config.ts`: + ```ts import { defineConfig } from "@tanstack/react-start/config"; @@ -2808,6 +2880,7 @@ export default defineConfig({ - [ ] **Step 3: Write the root route importing dowel's stylesheet** `apps/docs/src/routes/__root.tsx`: + ```tsx import { Outlet, createRootRoute } from "@tanstack/react-router"; import { Tooltip } from "dowel"; @@ -2830,6 +2903,7 @@ export const Route = createRootRoute({ - [ ] **Step 4: Write the landing route** `apps/docs/src/routes/index.tsx`: + ```tsx import { createFileRoute } from "@tanstack/react-router"; import { Badge, Button, Kbd } from "dowel"; @@ -2857,6 +2931,7 @@ function Home() { - [ ] **Step 5: Write the Button docs route** `apps/docs/src/routes/components/button.tsx`: + ```tsx import { createFileRoute } from "@tanstack/react-router"; import { Button } from "dowel"; @@ -2889,6 +2964,7 @@ pnpm --filter dowel build pnpm --filter @dowel/docs build test -f apps/docs/dist/index.html && echo "prerender OK" ``` + Expected: `prerender OK`. If the file is missing, the preset did not run static generation — check `app.config.ts`. - [ ] **Step 7: Commit and PR** @@ -2906,16 +2982,19 @@ gh pr checks --watch ## Task 14: Cloudflare deploy **Files:** + - Create: `apps/docs/wrangler.jsonc` - Create: `.github/workflows/deploy-docs.yml` **Interfaces:** + - Consumes: `apps/docs/dist` from Task 13; the org secret `CLOUDFLARE_API_TOKEN`. - Produces: dowel.sh serving the docs. - [ ] **Step 1: Write the Worker config** `apps/docs/wrangler.jsonc`: + ```jsonc // dowel.sh — the docs site, served as Cloudflare Worker static assets. // Assets-only: no worker script, because the site is fully prerendered. @@ -2926,16 +3005,17 @@ gh pr checks --watch "name": "dowel-sh", "compatibility_date": "2026-08-09", "assets": { - "directory": "./dist" + "directory": "./dist", }, "workers_dev": false, - "routes": [{ "pattern": "dowel.sh", "custom_domain": true }] + "routes": [{ "pattern": "dowel.sh", "custom_domain": true }], } ``` - [ ] **Step 2: Write the deploy workflow** `.github/workflows/deploy-docs.yml`: + ```yaml # Deploys dowel.sh when the docs or the library change on main. # @@ -3017,6 +3097,7 @@ gh pr checks --watch gh run list --workflow=deploy-docs --limit 1 curl -sS -o /dev/null -w "%{http_code}\n" https://dowel.sh ``` + Expected: workflow `completed success`, and HTTP `200` from dowel.sh. If the custom domain 522s or 404s on the first deploy, the route is still @@ -3030,6 +3111,7 @@ curl -s https://registry.npmjs.org/dowel | python3 -c "import sys,json;print(jso curl -sS -o /dev/null -w "dowel.sh %{http_code}\n" https://dowel.sh gh run list --limit 5 ``` + Expected: `dowel@0.1.0` on npm, `dowel.sh 200`, recent runs green. --- diff --git a/docs/superpowers/specs/2026-08-09-dowel-design.md b/docs/superpowers/specs/2026-08-09-dowel-design.md index 2b18173..5488849 100644 --- a/docs/superpowers/specs/2026-08-09-dowel-design.md +++ b/docs/superpowers/specs/2026-08-09-dowel-design.md @@ -24,16 +24,16 @@ identical, unglamorous, load-bearing. That is what a component is. dowel is an homage to Linear's craft. The README credits them and states clearly that dowel is unaffiliated with and unendorsed by Linear. -We reimplement the *visual language* — colour relationships, spacing, density, +We reimplement the _visual language_ — colour relationships, spacing, density, radii, motion curves — which is not protectable and is fair to learn from. We ship none of their assets: -| never ship | reason | dowel does instead | -|---|---|---| -| Linear logo / wordmark | trademark | own mark | -| Linear's icon set | their original artwork | Lucide, or redraw | -| Berkeley Mono | commercial licence | JetBrains Mono / `ui-monospace` | -| any affiliation claim | false endorsement | explicit disclaimer | +| never ship | reason | dowel does instead | +| ---------------------- | ---------------------- | ------------------------------- | +| Linear logo / wordmark | trademark | own mark | +| Linear's icon set | their original artwork | Lucide, or redraw | +| Berkeley Mono | commercial licence | JetBrains Mono / `ui-monospace` | +| any affiliation claim | false endorsement | explicit disclaimer | Inter Variable is OFL-licensed and safe to self-host. @@ -76,42 +76,42 @@ All public tokens are prefixed `--dowel-`. --dowel-hue: 272; /* surfaces — 4 steps */ - --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-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 — 3 steps */ - --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)); + --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 — 4 steps, phi-derived */ - --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-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 — ours, not Linear's. Same energy (L~49, high chroma, indigo), deliberately a different hue angle: their #5e6ad2 is lch(48.7% 60.8 295) and that exact value is their brand mark, not a design pattern. */ - --dowel-accent: lch(49% 62 285); + --dowel-accent: lch(49% 62 285); --dowel-accent-fg: lch(100% 0 0); - --dowel-focus: var(--dowel-accent); + --dowel-focus: var(--dowel-accent); /* type */ --dowel-font: "Inter Variable", system-ui, sans-serif; --dowel-mono: "JetBrains Mono", ui-monospace, monospace; - --dowel-fs-micro: .6875rem; /* 11 */ - --dowel-fs-mini: .75rem; /* 12 */ - --dowel-fs-small: .8125rem; /* 13 — workhorse */ - --dowel-fs-base: .9375rem; /* 15 */ - --dowel-fs-lg: 1.125rem; /* 18 */ + --dowel-fs-micro: 0.6875rem; /* 11 */ + --dowel-fs-mini: 0.75rem; /* 12 */ + --dowel-fs-small: 0.8125rem; /* 13 — workhorse */ + --dowel-fs-base: 0.9375rem; /* 15 */ + --dowel-fs-lg: 1.125rem; /* 18 */ --dowel-fw-normal: 450; --dowel-fw-medium: 500; --dowel-fw-semibold: 600; - --dowel-tracking: -.02em; + --dowel-tracking: -0.02em; /* shape */ --dowel-radius: 8px; @@ -122,21 +122,21 @@ All public tokens are prefixed `--dowel-`. /* size */ --dowel-h-sm: 24px; - --dowel-h: 28px; + --dowel-h: 28px; --dowel-h-lg: 32px; /* motion */ - --dowel-dur: .15s; - --dowel-ease: cubic-bezier(.25, .46, .45, .94); + --dowel-dur: 0.15s; + --dowel-ease: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* elevation — two tiers, no more */ --dowel-shadow-popover: - 0 3px 8px lch(0 0 0/.125), 0 2px 5px lch(0 0 0/.125), - 0 1px 1px lch(0 0 0/.125); + 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/.10), 0 3px 20px lch(0 0 0/.125), - 0 3px 12px lch(0 0 0/.125), 0 2px 8px lch(0 0 0/.125), - 0 1px 1px lch(0 0 0/.125); + 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); } ``` @@ -147,9 +147,14 @@ was not extractable, and copying it was not the goal. ### Theming contract ```css -.dowel-dark, [data-dowel-theme="dark"] { /* colour overrides */ } +.dowel-dark, +[data-dowel-theme="dark"] { + /* colour overrides */ +} @media (prefers-color-scheme: dark) { - :root:not([data-dowel-theme="light"]) { /* same overrides */ } + :root:not([data-dowel-theme="light"]) { + /* same overrides */ + } } ``` @@ -202,38 +207,38 @@ styles. `sideEffects: ["*.css"]` so JS tree-shakes. ## 5. Components -49 total: 39 Base UI-backed, 10 plain elements *(marked)*. +49 total: 39 Base UI-backed, 10 plain elements _(marked)_. **Actions** Button · IconButton · Toggle · ToggleGroup · Toolbar -**Form** Input · Textarea *(plain)* · NumberField · OTPField · Checkbox · +**Form** Input · Textarea _(plain)_ · NumberField · OTPField · Checkbox · CheckboxGroup · Radio · RadioGroup · Switch · Slider · Field · Fieldset · Form **Selection** Select · Combobox · Autocomplete **Overlay** Dialog · AlertDialog · Drawer · Popover · PreviewCard · Tooltip · Toast **Navigation** Menu · ContextMenu · Menubar · NavigationMenu · Tabs · -Breadcrumb *(plain)* · Pagination *(plain)* +Breadcrumb _(plain)_ · Pagination _(plain)_ **Disclosure** Accordion · Collapsible -**Display** Avatar · Badge *(plain)* · Card *(plain)* · Kbd *(plain)* · -Callout *(plain)* · Code *(plain)* · Table *(plain)* · Separator · Progress · -Meter · Skeleton *(plain)* · ScrollArea +**Display** Avatar · Badge _(plain)_ · Card _(plain)_ · Kbd _(plain)_ · +Callout _(plain)_ · Code _(plain)_ · Table _(plain)_ · Separator · Progress · +Meter · Skeleton _(plain)_ · ScrollArea ### Measured component specs -| component | spec | -|---|---| -| Button / control | `h 28 · radius 8 · 13px/500 · pad 0 10` | -| Button (pill variant) | `h 28 · radius 9999 · pad 0 10 0 6` (asymmetric, icon side) | -| IconButton | `28×28` or `24×24 · radius 9999 · pad 0 2` | -| Tab (segmented) | `h 28 · radius 9999 · 12px/500` | -| Menu / Select option | `h 32 · pad 0 18 0 14 · 13px/400` | -| Popover container | `radius 12 · hairline border · shadow-popover` | -| Dialog | `radius 12 · hairline border · shadow-modal · 13vh from top` | -| Command palette | `720w · input h 40 · group h 30 · row h 46` | -| Kbd | `radius 4 · 11px/400 · hairline border · gap 3` | -| Avatar | `20×20 · radius 8` (**not** a circle) | -| Input | `h 28 · radius 8`; large `h 40 · radius 12` | -| Hover surface | `bg-3` | -| Sidebar item | `h 28 · radius 8 · pad 0 9 0 8 · 13px/500` | +| component | spec | +| --------------------- | ------------------------------------------------------------ | +| Button / control | `h 28 · radius 8 · 13px/500 · pad 0 10` | +| Button (pill variant) | `h 28 · radius 9999 · pad 0 10 0 6` (asymmetric, icon side) | +| IconButton | `28×28` or `24×24 · radius 9999 · pad 0 2` | +| Tab (segmented) | `h 28 · radius 9999 · 12px/500` | +| Menu / Select option | `h 32 · pad 0 18 0 14 · 13px/400` | +| Popover container | `radius 12 · hairline border · shadow-popover` | +| Dialog | `radius 12 · hairline border · shadow-modal · 13vh from top` | +| Command palette | `720w · input h 40 · group h 30 · row h 46` | +| Kbd | `radius 4 · 11px/400 · hairline border · gap 3` | +| Avatar | `20×20 · radius 8` (**not** a circle) | +| Input | `h 28 · radius 8`; large `h 40 · radius 12` | +| Hover surface | `bg-3` | +| Sidebar item | `h 28 · radius 8 · pad 0 9 0 8 · 13px/500` | --- @@ -300,7 +305,7 @@ same account as `karnstack.com`). "compatibility_date": "2026-08-09", "assets": { "directory": "./dist" }, "workers_dev": false, - "routes": [{ "pattern": "dowel.sh", "custom_domain": true }] + "routes": [{ "pattern": "dowel.sh", "custom_domain": true }], } ``` @@ -316,20 +321,21 @@ dashboard Redirect Rule, same manual follow-up flue needed. Today the same credentials are duplicated per repo: -| secret | currently in | needed by | -|---|---|---| -| `NPM_TOKEN` | kino, reins | + dowel | -| `CLOUDFLARE_API_TOKEN` | flue | + dowel | +| secret | currently in | needed by | +| ---------------------- | ------------ | --------- | +| `NPM_TOKEN` | kino, reins | + dowel | +| `CLOUDFLARE_API_TOKEN` | flue | + dowel | Both move to **karnstack org secrets**, so a rotation is one update instead of N. New repos inherit them. -**Precedence caveat:** a repo secret *shadows* an org secret of the same name. +**Precedence caveat:** a repo secret _shadows_ an org secret of the same name. So after setting the org secrets, the per-repo duplicates in kino, reins and flue must be deleted — otherwise those repos keep silently using their old copies and a rotation appears to work while doing nothing. Token scopes: + - `NPM_TOKEN` — npm **granular automation** token, write access to the `dowel`, `@karnstack/*` packages - `CLOUDFLARE_API_TOKEN` — **Workers Scripts: Edit** on the karnstack account @@ -338,6 +344,6 @@ Setting org secrets requires `admin:org` on the `gh` token; the default `repo, read:org, gist` set returns 403. **Plan constraint:** karnstack is on GitHub Free, where org secrets only reach -*public* repositories. This is why dowel is public from the start rather than +_public_ repositories. This is why dowel is public from the start rather than private until launch — it is also the honest posture for a project whose premise is an open homage. diff --git a/package.json b/package.json new file mode 100644 index 0000000..e3e9d29 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "dowel-monorepo", + "private": true, + "type": "module", + "packageManager": "pnpm@11.9.0", + "engines": { + "node": ">=24" + }, + "scripts": { + "build": "pnpm -r build", + "test": "pnpm -r test", + "typecheck": "pnpm -r typecheck", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "devDependencies": { + "@changesets/changelog-github": "^0.5.2", + "@changesets/cli": "^2.31.1", + "prettier": "3.6.2", + "typescript": "5.9.3" + } +} diff --git a/packages/dowel/LICENSE b/packages/dowel/LICENSE new file mode 100644 index 0000000..722270b --- /dev/null +++ b/packages/dowel/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/packages/dowel/README.md b/packages/dowel/README.md new file mode 100644 index 0000000..d800642 --- /dev/null +++ b/packages/dowel/README.md @@ -0,0 +1,119 @@ +# dowel + +An opinionated React component library with Linear's visual language. One +look, well made — no config, no forking, no theme bikeshedding. + +[dowel.sh](https://dowel.sh) · MIT + +## Install + +```bash +pnpm add dowel +``` + +## Use + +```tsx +import "dowel/dowel.css"; +import { Button } from "dowel"; + +; +``` + +That is the whole setup. dowel is **ESM-only** — the `exports` map has no +`require` condition, so it needs a bundler or Node's ESM loader. + +## Components + +`Button` · `IconButton` · `Badge` · `Kbd` · `Input` (with `Field`) · +`Dialog` · `Menu` · `Tooltip` + +Behaviour comes from [Base UI](https://base-ui.com); every component is +keyboard-tested and axe-checked. + +## Theming + +There is no per-component `className` or `style` override API, by design. If +you need a different button, dowel is the wrong library. What you do get is a +root class, a theme switch and three variables. + +### `.dowel-root` + +Put it on an element that wraps your app. It supplies dowel's page defaults: +`--dowel-font`, the 13px `--dowel-fs-small` size, the 450 `--dowel-fw-normal` +weight, `--dowel-tracking`, `--dowel-text-2` for text and `--dowel-bg-1` for +the background. + +```tsx +
    {/* your app */}
    +``` + +Put it on `` or `` if you want that background to reach the +viewport edges instead of stopping at a content box. Any element carrying a +`data-dowel-theme` attribute picks up the same defaults, so a themed wrapper +does not need both. + +### Dark mode + +Light and dark ship in the one stylesheet. Three ways to reach dark: + +- **`.dowel-dark`** — a class on any element. Applies to it and its subtree. +- **`[data-dowel-theme="dark"]`** — an attribute, same scope. This is the one + to drive from a toggle. +- **System preference** — `@media (prefers-color-scheme: dark)` matching + `:root:not(.dowel-light):not([data-dowel-theme="light"])`. It applies at the + document root only, and the `:not()` guards are the light escape hatch: put + `class="dowel-light"` or `data-dowel-theme="light"` on `` to pin light + against a dark OS setting. There is no `.dowel-light` token block — light is + the `:root` default, so that class exists purely as the opt-out. + +### Retheming + +Three custom properties, and **they must be declared on `:root`**: + +```css +:root { + --dowel-hue: 210; /* a number, not a colour — retints every neutral */ + --dowel-accent: lch(49% 62 210); + --dowel-accent-fg: lch(100% 0 0); /* text drawn on the accent */ +} +``` + +`--dowel-accent-hover` (a `color-mix()` against the accent) and `--dowel-focus` +(a plain alias) both derive from `--dowel-accent`, so hover and focus follow a +retint for free. Custom properties resolve on the element that declares them, +which is why `:root` is the supported surface: override `--dowel-accent` on a +nested wrapper and the inherited `--dowel-accent-hover` still resolves against +the default accent, so buttons snap back to teal on hover. + +`--dowel-accent-fg` is the one knob you have to think about, because dowel's +own two themes do not agree on it. The default accent is teal — +`lch(52% 32 195)` in light, lifted to `lch(68% 36 195)` in dark so it still +carries on near-black surfaces. Teal is luminous for its lightness, so the +dark accent leaves white text at 2.4:1; dark mode therefore draws a near-black +ink (`lch(14% 6 195)`, 6.4:1) on the accent instead of white. If you retheme to +a darker or less luminous hue, set `--dowel-accent-fg` to white and check the +result in both themes — the value that works for one is not automatically +right for the other. + +## Typeface + +dowel is designed for Inter but ships no font; it falls back to `system-ui`. +To match the docs: + +```bash +pnpm add @fontsource-variable/inter +``` + +```ts +import "@fontsource-variable/inter"; +import "dowel/dowel.css"; +``` + +## Status + +Pre-1.0. The API will change between minor versions. + +## Licence + +MIT diff --git a/packages/dowel/package.json b/packages/dowel/package.json new file mode 100644 index 0000000..cd5d49f --- /dev/null +++ b/packages/dowel/package.json @@ -0,0 +1,73 @@ +{ + "name": "dowel", + "version": "0.0.0", + "description": "An opinionated React component library. One look, well made.", + "license": "MIT", + "author": "Karn Gyan", + "homepage": "https://dowel.sh", + "repository": { + "type": "git", + "url": "git+https://github.com/karnstack/dowel.git" + }, + "bugs": { + "url": "https://github.com/karnstack/dowel/issues" + }, + "keywords": [ + "react", + "components", + "ui", + "design-system", + "linear" + ], + "type": "module", + "sideEffects": [ + "**/*.css" + ], + "files": [ + "dist", + "LICENSE" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./dowel.css": "./dist/dowel.css" + }, + "scripts": { + "build": "tsdown && node scripts/build-css.mjs", + "pretest": "pnpm build", + "test": "vitest run", + "pretest:watch": "pnpm build", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "dependencies": { + "@base-ui/react": "^1.7.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.0.5", + "axe-core": "^4.13.0", + "browserslist": "^4.28.7", + "jsdom": "^27.0.0", + "lightningcss": "^1.33.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tsdown": "^0.22.14", + "vitest": "^4.1.10" + } +} diff --git a/packages/dowel/scripts/build-css.mjs b/packages/dowel/scripts/build-css.mjs new file mode 100644 index 0000000..2f6516f --- /dev/null +++ b/packages/dowel/scripts/build-css.mjs @@ -0,0 +1,31 @@ +// Bundles src/index.css (following @import) into one minified dist/dowel.css. +// Lightning CSS is used as a library rather than the CLI so the targets and +// the drafts flag stay in version control instead of a shell string. +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { bundle, browserslistToTargets } from "lightningcss"; +import browserslist from "browserslist"; + +const here = dirname(fileURLToPath(import.meta.url)); +const entry = resolve(here, "..", "src", "index.css"); +const out = resolve(here, "..", "dist", "dowel.css"); + +// lch() and cascade layers both need reasonably current browsers; this is the +// floor dowel supports and it is asserted in the README. +const targets = browserslistToTargets( + browserslist(["chrome >= 111", "firefox >= 113", "safari >= 16.4"]), +); + +const { code, warnings } = bundle({ + filename: entry, + minify: true, + targets, + drafts: { customMedia: false }, +}); + +for (const w of warnings) console.warn(`lightningcss: ${w.message}`); + +mkdirSync(dirname(out), { recursive: true }); +writeFileSync(out, code); +console.log(`built dist/dowel.css (${(code.length / 1024).toFixed(1)} kB)`); diff --git a/packages/dowel/src/components/badge/badge.css b/packages/dowel/src/components/badge/badge.css new file mode 100644 index 0000000..363293e --- /dev/null +++ b/packages/dowel/src/components/badge/badge.css @@ -0,0 +1,37 @@ +@layer dowel.components { + .dowel-badge { + display: inline-flex; + align-items: center; + gap: var(--dowel-space-2); + + block-size: 20px; + padding-inline: var(--dowel-space-4); + + font-size: var(--dowel-fs-mini); + font-weight: var(--dowel-fw-medium); + letter-spacing: var(--dowel-tracking); + white-space: nowrap; + + border: var(--dowel-hairline) solid var(--dowel-border-2); + border-radius: var(--dowel-radius-pill); + background-color: var(--dowel-bg-2); + color: var(--dowel-text-3); + } + + .dowel-badge[data-tone="accent"] { + color: var(--dowel-accent); + border-color: var(--dowel-accent); + } + .dowel-badge[data-tone="success"] { + color: var(--dowel-success); + border-color: var(--dowel-success); + } + .dowel-badge[data-tone="warning"] { + color: var(--dowel-warning); + border-color: var(--dowel-warning); + } + .dowel-badge[data-tone="danger"] { + color: var(--dowel-danger); + border-color: var(--dowel-danger); + } +} diff --git a/packages/dowel/src/components/badge/badge.test.tsx b/packages/dowel/src/components/badge/badge.test.tsx new file mode 100644 index 0000000..6c5eaac --- /dev/null +++ b/packages/dowel/src/components/badge/badge.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { expectNoA11yViolations } from "../../../test/setup"; +import { renderBoth } from "../../../test/render"; +import { Badge } from "./index"; + +describe("Badge", () => { + it("renders its children", () => { + render(Backlog); + expect(screen.getByText("Backlog")).toBeDefined(); + }); + + it("defaults to the neutral tone", () => { + render(Backlog); + expect(screen.getByText("Backlog").dataset.tone).toBe("neutral"); + }); + + it("exposes the tone as a data attribute", () => { + render(Done); + expect(screen.getByText("Done").dataset.tone).toBe("success"); + }); + + it("carries the dowel-badge class", () => { + render(Backlog); + expect(screen.getByText("Backlog").className).toContain("dowel-badge"); + }); + + it("ignores className and style smuggled through a spread", () => { + // BadgeProps 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(Go); + const badge = screen.getByText("Go"); + expect(badge.className).toContain("dowel-badge"); + expect(badge.className).not.toContain("evil"); + expect(badge.getAttribute("style")).toBeNull(); + }); + + it("renders in both themes", () => { + const { light, dark } = renderBoth(Backlog); + expect(light.querySelector(".dowel-badge")).not.toBeNull(); + expect(dark.querySelector(".dowel-badge")).not.toBeNull(); + }); + + it("has no accessibility violations", async () => { + const { container } = render(Backlog); + await expectNoA11yViolations(container); + }); +}); diff --git a/packages/dowel/src/components/badge/index.tsx b/packages/dowel/src/components/badge/index.tsx new file mode 100644 index 0000000..cb2d6f1 --- /dev/null +++ b/packages/dowel/src/components/badge/index.tsx @@ -0,0 +1,29 @@ +import { forwardRef } from "react"; +import type { ComponentPropsWithoutRef } from "react"; + +export interface BadgeProps + extends Omit< + ComponentPropsWithoutRef<"span">, + // dowel is opinionated: appearance is not a consumer concern. + "className" | "style" + > { + /** Colour treatment. Defaults to `neutral`. */ + tone?: "neutral" | "accent" | "success" | "warning" | "danger"; +} + +export const Badge = forwardRef(function Badge( + { tone = "neutral", ...props }, + ref, +) { + return ( + + ); +}); diff --git a/packages/dowel/src/components/button/button.css b/packages/dowel/src/components/button/button.css new file mode 100644 index 0000000..ff102ff --- /dev/null +++ b/packages/dowel/src/components/button/button.css @@ -0,0 +1,92 @@ +@layer dowel.components { + .dowel-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--dowel-space-3); + + height: 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-medium); + letter-spacing: var(--dowel-tracking); + white-space: nowrap; + + border: var(--dowel-hairline) solid transparent; + border-radius: var(--dowel-radius); + transition: var(--dowel-transition); + cursor: default; + user-select: none; + /* `render={}` is a supported escape hatch, and an anchor arrives + underlined from the UA sheet. A button that is a link still has to look + like a button. */ + text-decoration: none; + } + + .dowel-btn[data-size="sm"] { + height: var(--dowel-h-sm); + padding-inline: var(--dowel-space-4); + font-size: var(--dowel-fs-mini); + } + + .dowel-btn:focus-visible { + outline: 1px solid var(--dowel-focus); + outline-offset: 1px; + } + + /* :disabled only matches form controls; a Button rendered as an anchor + with nativeButton={false} is disabled via aria-disabled instead. */ + .dowel-btn:disabled, + .dowel-btn[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + } + + /* primary */ + .dowel-btn[data-variant="primary"] { + background-color: var(--dowel-accent); + color: var(--dowel-accent-fg); + } + .dowel-btn[data-variant="primary"]:hover:not(:disabled):not( + [aria-disabled="true"] + ) { + background-color: var(--dowel-accent-hover); + } + + /* secondary */ + .dowel-btn[data-variant="secondary"] { + background-color: var(--dowel-bg-2); + border-color: var(--dowel-border-2); + color: var(--dowel-text-2); + } + .dowel-btn[data-variant="secondary"]:hover:not(:disabled):not( + [aria-disabled="true"] + ) { + background-color: var(--dowel-bg-3); + } + + /* ghost */ + .dowel-btn[data-variant="ghost"] { + background-color: transparent; + color: var(--dowel-text-3); + } + .dowel-btn[data-variant="ghost"]:hover:not(:disabled):not( + [aria-disabled="true"] + ) { + background-color: var(--dowel-bg-3); + color: var(--dowel-text-2); + } + + /* danger */ + .dowel-btn[data-variant="danger"] { + background-color: var(--dowel-danger); + color: var(--dowel-danger-fg); + } + .dowel-btn[data-variant="danger"]:hover:not(:disabled):not( + [aria-disabled="true"] + ) { + opacity: 0.9; + } +} diff --git a/packages/dowel/src/components/button/button.test.tsx b/packages/dowel/src/components/button/button.test.tsx new file mode 100644 index 0000000..9a3d12e --- /dev/null +++ b/packages/dowel/src/components/button/button.test.tsx @@ -0,0 +1,97 @@ +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 { Button } from "./index"; + +describe("Button", () => { + it("renders its label in a real button element", () => { + render(); + expect(screen.getByRole("button", { name: "Ship it" })).toBeDefined(); + }); + + it("defaults to the secondary variant at md size", () => { + render(); + const btn = screen.getByRole("button"); + expect(btn.dataset.variant).toBe("secondary"); + expect(btn.dataset.size).toBe("md"); + }); + + it("exposes the variant and size as data attributes", () => { + render( + , + ); + const btn = screen.getByRole("button"); + expect(btn.dataset.variant).toBe("danger"); + expect(btn.dataset.size).toBe("sm"); + }); + + it("carries the dowel-btn class", () => { + render(); + expect(screen.getByRole("button").className).toContain("dowel-btn"); + }); + + it("ignores className and style smuggled through a spread", () => { + // ButtonProps 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-btn"); + expect(btn.className).not.toContain("evil"); + expect(btn.getAttribute("style")).toBeNull(); + }); + + it("fires onClick", async () => { + const onClick = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button")); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("does not fire onClick when disabled", async () => { + const onClick = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button")); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("renders as another element via render plus nativeButton={false}", () => { + // Base UI swaps native , + ); + const el = screen.getByRole("button", { name: "Docs" }); + expect(el.tagName).toBe("A"); + expect(el.getAttribute("href")).toBe("/docs"); + expect(el.className).toContain("dowel-btn"); + // `type` is a MIME hint on anchors — it must not leak from button mode. + expect(el.hasAttribute("type")).toBe(false); + }); + + it("renders a native ); + expect(screen.getByRole("button").tagName).toBe("BUTTON"); + }); + + it("renders in both themes", () => { + const { light, dark } = renderBoth(); + expect(light.querySelector(".dowel-btn")).not.toBeNull(); + expect(dark.querySelector(".dowel-btn")).not.toBeNull(); + }); + + it("has no accessibility violations", async () => { + const { container } = render(); + await expectNoA11yViolations(container); + }); +}); diff --git a/packages/dowel/src/components/button/index.tsx b/packages/dowel/src/components/button/index.tsx new file mode 100644 index 0000000..a4993e7 --- /dev/null +++ b/packages/dowel/src/components/button/index.tsx @@ -0,0 +1,48 @@ +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. + "className" | "style" +>; + +export interface ButtonProps extends NativeButtonProps { + /** Visual weight. Defaults to `secondary`. */ + variant?: "primary" | "secondary" | "ghost" | "danger"; + /** Control height. `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 `} /> + + + + Delete issue + This cannot be undone. + Cancel} /> + + + + ); +} + +describe("Dialog", () => { + it("is closed until the trigger is activated", () => { + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("opens on trigger click and is labelled by its title", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Open" })); + expect(screen.getByRole("dialog", { name: "Delete issue" })).toBeDefined(); + }); + + it("closes on Escape", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Open" })); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("closes via the Close control", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Open" })); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("carries the dowel classes on every styled part", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Open" })); + const dialog = screen.getByRole("dialog"); + expect(dialog.className).toContain("dowel-dialog"); + expect(document.querySelector(".dowel-backdrop")).not.toBeNull(); + expect(dialog.querySelector(".dowel-dialog-title")).not.toBeNull(); + expect(dialog.querySelector(".dowel-dialog-description")).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 Backdrop + // spread a property in common with the 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 internal + // inline styles on Backdrop (user-select) and Popup (--nested-dialogs), + // 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 and Close are used bare (no `render`): that path renders Base + // UI's own native } /> + + + + 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