Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
13c5aa7
Switch the build from tsup to tsdown
karngyan Aug 8, 2026
44992ef
Drop the cx class-name helper from the plan
karngyan Aug 8, 2026
99e7a03
Scaffold the pnpm workspace and dowel package
karngyan Aug 8, 2026
3dd48b5
Format pre-existing docs with prettier so format:check passes
karngyan Aug 8, 2026
909cb47
Restore corrupted className literal in plan; add .prettierignore
karngyan Aug 8, 2026
375ce2e
Add the dowel token layer with light and dark parity tests
karngyan Aug 8, 2026
b69145b
Guard the duplicated dark.css copies with a value-level parity test
karngyan Aug 8, 2026
5899ef9
Add the Lightning CSS and tsdown build pipeline with a token contract…
karngyan Aug 8, 2026
e0eb435
Add Button and the component authoring pattern
karngyan Aug 8, 2026
da79295
Harden Button: spread order, nativeButton forwarding, aria-disabled s…
karngyan Aug 8, 2026
cba3f77
Extend hover guards to aria-disabled; correct two comments
karngyan Aug 8, 2026
6c1f0f6
Add IconButton with a required accessible label
karngyan Aug 8, 2026
3ba2fa5
Add Badge and Kbd
karngyan Aug 8, 2026
5dde919
Add Input and Field with automatic label association
karngyan Aug 8, 2026
4b12a01
Derive Field part props from Base UI; omit htmlFor; fix Error ref type
karngyan Aug 8, 2026
e61a078
Fix the Props helper in the plan for Tasks 8-10
karngyan Aug 8, 2026
303a573
Add Dialog on the modal elevation tier
karngyan Aug 9, 2026
bb981a3
Guard Dialog's structural parts: Portal/Trigger/Close channels, focus…
karngyan Aug 9, 2026
e863270
Add Menu with keyboard navigation
karngyan Aug 9, 2026
302e738
Add Tooltip, completing the phase 1 component slice
karngyan Aug 9, 2026
bdad413
Pin the tooltip trigger's accessible-name contract
karngyan Aug 9, 2026
b14bbdc
Add the CI workflow
karngyan Aug 9, 2026
2f2eac9
Add changesets and the npm release workflow
karngyan Aug 9, 2026
140e736
Address Task 12 review: empty changeset ignore, derive accent hover
karngyan Aug 9, 2026
c91b2a0
Add the docs site on TanStack Start
karngyan Aug 9, 2026
df4912c
Deploy the docs to dowel.sh via Cloudflare Workers
karngyan Aug 9, 2026
2be396f
Address the final whole-branch review
karngyan Aug 9, 2026
2d0c2ef
Change the accent from azure to teal
karngyan Aug 9, 2026
8941efb
Rebuild the docs site as a real documentation site
karngyan Aug 9, 2026
bc11e4f
Add a D keyboard shortcut for the theme toggle
karngyan Aug 9, 2026
b5c4daf
Hold the release and give the docs a favicon
karngyan Aug 9, 2026
84c0bd0
Record the repo house rules in CLAUDE.md
karngyan Aug 9, 2026
c512d80
Read the docs version badge from the package
karngyan Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -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"]
}
2 changes: 2 additions & 0 deletions .changeset/no-release-yet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
@@ -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"
56 changes: 56 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 2 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Keep the lockfile honest in CI; mise pins the pnpm version itself.
engine-strict=true
10 changes: 10 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"printWidth": 80,
"trailingComma": "all"
}
64 changes: 64 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 30 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<html>` 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
Expand Down
27 changes: 27 additions & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Binary file added apps/docs/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/public/favicon.ico
Binary file not shown.
Loading
Loading