@ubuligan/testapp
v0.1.0
Published
A publishable React UI kit — shadcn/Radix + rc components, token-driven theming, per-component subpath exports.
Readme
UI Kit
A publishable React component library. Restyle it, publish it to npm or a private registry, and your teammates import components one at a time.
pnpm install
pnpm dev # Storybook → http://localhost:6006npm works just as well — npm install, then npm run dev. Every script in this README has an npm run <script> equivalent, and pnpm release shells out to npm internally, so publishing never needs pnpm installed.
Install scripts are allowlisted. Both modern package managers refuse to run a dependency's install script unless you approve it, and
esbuild— the engine behind tsup, Vite and Vitest — needs itspostinstallto place the platform binary. Under pnpm, skipping it makes the build fail; under npm you get a warning. Soesbuildand@parcel/watcherare pre-approved inpackage.json, once per manager:"allowScripts": { "esbuild": true, "@parcel/watcher": true }, // npm 11+ "pnpm": { "onlyBuiltDependencies": ["esbuild", "@parcel/watcher"] } // pnpm 10+If you add a dependency that ships an install/postinstall script, add it to both lists —
npm approve-scripts <pkg>andpnpm approve-buildswrite them for you. Otherwise the install exits 0 and something breaks much later, with no obvious cause.
1. Make it yours
Two edits before anything else:
package.json — set the real package name. Scoped names are strongly preferred; an unscoped name can collide with anything on the public registry.
{
"name": "@your-team/ui",
"version": "0.1.0"
}src/styles/tokens.css — this is where every colour, radius, space and font in the kit comes from. Change --ui-color-brand-500 and the whole library rebrands. No component hardcodes a value.
That is the entire rebranding surface. If you want to drive it from Figma instead of by hand, see Design tokens.
2. Architecture
src/
index.ts root barrel
styles/
tokens.css primitives (--ui-*) ← machine-owned
theme.css semantic roles + @theme inline ← hand-owned
globals.css entry: tailwind + tokens + base layer
rc.css structural skin for rc-* widgets
lib/ cn(), shared variant fragments
hooks/
components/
button/
button.tsx
button.stories.tsx
button.test.tsx
index.ts ← this file is what creates the `/button` subpathOne folder per component. The folder boundary is not cosmetic: scripts/gen-exports.mjs scans src/components/*/index.ts and generates the exports map from it. That is what gives every component its own import path.
Three rules that keep the kit coherent:
- Components use only the semantic tokens (
bg-background,text-primary-foreground,rounded-md). Never reference a--ui-*primitive directly — that is the indirection that makes a Figma re-sync restyle everything at once. - Every component file that uses React state, effects or event handlers starts with
'use client', including itsindex.ts. Without it the kit breaks inside Next.js Server Components. rc-*packages are used for behaviour only. Never import their stylesheets — the skin belongs insrc/styles/rc.css, written against the same tokens.
3. Design tokens
tokens.css holds raw values in oklch, grouped into ramps (neutral, brand, success, warning, danger), plus radius, spacing, type, elevation, motion and z-index scales.
theme.css maps those primitives onto roles (--background, --primary, --muted-foreground, …), defines the .dark overrides, and exposes everything to Tailwind via @theme inline.
The split exists so a design-tool sync can replace the primitives wholesale without touching the semantic mapping:
# with the Figma Dev Mode MCP server connected
/figma-sync <figma-file-url>The figma-sync skill (in .claude/skills/) reads your Figma Variables, rewrites tokens.css, shows you the diff before writing, and updates the theme.css mapping only when a variable name has no counterpart. Without the MCP server it falls back to a Tokens Studio JSON export.
4. What ships
39 components, each with its own import path, story and — where there is behaviour to test — a test suite.
| Group | Components |
| --- | --- |
| Form | button input textarea label checkbox radio-group switch select combobox field form |
| Layout & feedback | card badge avatar alert separator skeleton spinner progress |
| Overlay | dialog sheet drawer dropdown-menu popover tooltip command |
| Navigation | tabs accordion breadcrumb pagination toast |
| Data & input-heavy (rc-*) | data-table virtual-list tree tree-select date-picker range-slider upload input-number |
The last row is where Radix stops. Each of those wraps an rc-* package for behaviour a primitive library does not cover — a table with pinned columns and expandable rows, a windowed list, a calendar that parses loose text input, a multi-thumb slider, an abortable upload. None of them imports its package's stylesheet; the skin is in src/styles/rc.css, written against your tokens like everything else.
Three notes on the edges of that list:
formis the react-hook-form binding.react-hook-formis an optional peer dependency, soformis reachable only at<pkg>/form— it is not in the root barrel, which would otherwise break consumers who do not use it. Everything else works without it. If you are not on react-hook-form,fieldgives you the same layout without the library.toastwraps sonner and takes athemeprop (default'system'); the kit intentionally does not depend on a theme library. Pass the value from your provider if your app lets users override the OS setting.date-pickerexports bothDatePickerandDateRangePicker— they share one date-fns config, so they share one import path.
5. Adding a component
# Claude Code — scaffolds the folder, story, test, index.ts and regenerates exports
/ui-kit-component <name>Or by hand:
npx shadcn@latest add dialog # drops src/components/dialog.tsx
mkdir src/components/dialog && mv src/components/dialog.tsx src/components/dialog/
# add index.ts (with 'use client'), a .stories.tsx and a .test.tsx
pnpm exports:gen # regenerate package.json#exportsshadcn add writes a flat file; the kit uses folders. Moving the file and running exports:gen is the whole difference.
6. Build
pnpm build # exports:gen → tsup → tailwind CLI
pnpm verify # export map is current, every target exists, publint clean
pnpm test # vitest, including the jest-axe smoke suite
pnpm typecheck
pnpm lint # also fails on hardcoded colours and rc-* stylesheet imports
pnpm size # per-subpath bundle budgetpnpm lint is where rules 1 and 3 above stop being conventions. In src/components/**, a --ui-color-* reference or a literal #hex/rgb()/oklch() is an error, and so is importing an rc-* stylesheet. The motion and z-index primitives (--ui-duration-*, --ui-ease-*, --ui-z-*) are allowed directly — nothing re-maps them per theme, so there is no semantic layer for them to skip.
pnpm test runs the per-component tests plus test/a11y.test.tsx, which puts jest-axe over every component in a realistic composition — a control with its label, a table with its header — since the violations that matter (missing accessible name, orphaned label) only appear once things are put together. Contrast is not checked there: jsdom has no layout engine, so that job belongs to @storybook/addon-a11y, which runs in a real browser and is configured to fail the story.
pnpm build produces:
| Output | What it is |
| --- | --- |
| dist/index.js / .cjs / .d.ts | root barrel, ESM + CJS + types |
| dist/components/<name>/index.* | one entry per component |
| dist/styles.css | compiled Tailwind — consumers need no Tailwind setup |
| dist/tokens.css, dist/theme.css | raw token layers for apps that do run Tailwind |
7. Publishing
pnpm changeset # describe the change, pick major/minor/patch
pnpm changeset version # applies the bump + writes CHANGELOG.md
pnpm release # guided publishpnpm release checks the package name, the working tree, and whether that version already exists on the target registry; then builds, verifies, prints the tarball contents, and asks you to type the version number before it publishes. Pass --dry-run to run every check and stop short of publishing.
For a private registry, uncomment the matching block in .npmrc. Never hardcode a token there — use the ${NPM_TOKEN} form so the secret comes from the environment.
8. Consuming the published kit
No Tailwind in the consuming app — import the compiled stylesheet once:
// app entry
import '@your-team/ui/styles.css';
// anywhere
import { Button } from '@your-team/ui/button';Tailwind v4 already in the app — take the tokens instead and let your own build generate the utilities:
@import 'tailwindcss';
@import '@your-team/ui/tokens.css';
@import '@your-team/ui/theme.css';
@source '../node_modules/@your-team/ui/dist';Either way, importing from @your-team/ui/button pulls in Button and nothing else. The barrel (@your-team/ui) works too, but subpath imports keep cold builds and type-checking noticeably faster.
Dark mode is class-driven — put class="dark" on <html> (or let next-themes do it).
