Coseeing brand design system: React primitives plus the Tailwind v4 design tokens they are built on.
src/
components/ one directory per component, with its stories
lib/
cn.ts clsx + tailwind-merge
styles/
tokens.css @theme — colours, spacing, type scale, radii, breakpoints
base.css html/body/element defaults (incl. the 62.5% root size)
typography.css .typography-* classes
theme.css consumer entry for Tailwind v4 apps
index.css build entry for the prebuilt stylesheet
16 primitives, all framework-agnostic:
| Group | Components |
|---|---|
| Layout | Container, Card |
| Actions & nav | Button, Link |
| Form | Field, Label, ValidationMessage, Input, Checkbox, Select |
| Data | Badge, Tag, Table |
| Disclosure & feedback | Accordion, Tabs, Dialog, Toast |
| Icons | 13 brand icons from Icons |
Also exported: cn, and the LinkProps / AnchorLike / TableColumn /
TabItem / AccordionEntry types.
There is no provider to mount and nothing to configure globally. Components take what they need as props.
To install the coseeing packages, please refer to Github Doc.
In short: the app needs an .npmrc pointing the @coseeing scope at the
GitHub registry, plus a personal access token with read:packages.
@coseeing:registry=https://npm.pkg.github.com
Then npm install @coseeing/ui.
Two independent styling paths. Pick one — importing both duplicates every utility.
import "@coseeing/ui/styles.css" // once, at the app root
import { Button, Card } from "@coseeing/ui"dist/styles.css is prebuilt and self-contained: Tailwind preflight, the brand
tokens, the typography classes, and every utility the components use. Nothing
to configure.
/* app/globals.css */
@import "tailwindcss";
@import "@coseeing/ui/theme.css";This registers the tokens in the app's own Tailwind build, so bg-teal-PRIMARY,
px-36, and typography-headline2 work in app markup as well as inside the
components — emitted once, into a single stylesheet.
theme.css carries its own @source "./", which points Tailwind at
node_modules/@coseeing/ui/dist so it scans the compiled components for class
names. Without that scan the components render unstyled, because Tailwind
would tree-shake away every utility that only appears inside the library. It is
built in, so there is nothing to add on the consumer side — but it is the first
thing to check if styles go missing.
Finer-grained imports are available if you want the tokens without the global
element defaults: @coseeing/ui/tokens.css, /base.css, /typography.css.
The whole scale is rem-against-a-10px root, so
base.csssetshtml { font-size: 62.5% }. If you skipbase.css, set that yourself or every size renders 1.6× too large.
Fonts are named but not shipped. Load "Noto Sans TC" (and Inter for display
faces) from the app — e.g. next/font — or the stack falls through to the
system sans-serif.
Link — and Button when given an href — is a styled anchor and nothing
more. It reads no router and no global config:
<Link href="/settings">帳號設定</Link>Two optional props cover the rest. as swaps the underlying element for the
app's router link; current sets aria-current="page":
"use client"
import NextLink from "next/link"
import { usePathname } from "next/navigation"
import { Link } from "@coseeing/ui"
import { appPath } from "@/lib/app-path"
export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname()
return (
<Link as={NextLink} href={appPath(href)} current={pathname === href}>
{children}
</Link>
)
}Pass href already resolved. If the app runs under a base path, prefix it in
the app — Next's basePath does this for next/link but not for a plain
<a>, which is what a helper like appPath() is for. The base path is a
property of the deployment, not of the design system, so it does not live here.
That six-line wrapper is the deliberate trade. An earlier version of this
package shipped a UIProvider that supplied basePath, pathname, and
linkComponent by context, so <Link href="/settings"> "just worked". It cost
six exports, a mandatory app-root wrapper, a "use client" boundary on Link
itself, and implicit action-at-a-distance — to save an app from writing the
wrapper above once. Props won.
The build preserves the module graph, so "use client" stays on just the two
files that actually need it — Dialog and Tabs, the only components with
state. Everything else, including Link and Button, renders on the server.
Importing Button does not drag Table into the bundle.
ESM only. Every current bundler and Node ≥ 18 handles it; there is no CJS build.
examples/vite-app is a real consumer using path B and most of the components:
cd examples/vite-app && npm install && npm run devIt depends on the library via file:../.., so it builds against dist/ — run
npm run build at the root first.
npm install
npx playwright install chromium # one-off, for the story tests
npm run storybook # dev harness — components are built and reviewed here
npm run typecheck
npm run test # unit + every story, with axe
npm run build # js -> types -> css, into dist/npm run build runs three steps:
build:js— tsup, transpile-only (bundle: false), one output file per source file. Bundling would collapse the graph and hoist the per-file"use client"directives into one boundary, making the whole library client-side for RSC consumers.build:types—tsc --emitDeclarationOnly. Separate from tsup because tsup's dts step bundles, and a bundled.d.tscan't describe a preserved module graph.build:css— compilesdist/styles.cssand copies the raw@themepartials. It asserts that the output contains the tokens, the typography classes, the root font-size, and at least one utility that only exists inside a component; that last check is what catches a silently mis-scoped@source, whose only other symptom is unstyled components far downstream.
The package goes to GitHub Packages as @coseeing/ui, via the Publish
package to GitHub Packages workflow (.github/workflows/publish.yml). It is
workflow_dispatch only — nothing publishes on a push or a merge.
-
Bump the version on
main:npm version patch # or minor / majornpm versionwritespackage.json, commits, and tags. Push both:git push && git push --tags -
Actions → Publish package to GitHub Packages → Run workflow.
The workflow installs, installs Chromium, then runs typecheck, test, and
build before npm publish. A failure in any of those stops the run, so a
broken build never reaches the registry. Auth is the workflow's own
GITHUB_TOKEN — there is no separate secret to rotate.
A version can only be published once. Re-running the workflow without a
bump fails at npm publish with a 409, and GitHub Packages does not allow
overwriting or re-using a version after a delete. Always bump first.
npm publish runs prepack, which runs npm run build — so dist/ is always
rebuilt from the checked-out source at publish time and never taken from a
local working tree. files: ["dist"] is what keeps src/, stories, and configs
out of the tarball; npm publish --dry-run prints the exact file list.
npm test runs every story as a test in real Chromium: its play function
runs, and axe runs against the rendered result. npm run test:watch while
developing. The @storybook/addon-vitest panel runs the same tests from inside
the Storybook UI, so a failure is clickable straight to the story.
There is no separate Node project — the components carry no pure logic worth
unit-testing on its own. If that changes, add a second entry under
test.projects in vitest.config.ts with include: ["src/**/*.test.ts"].
Why a real browser and not jsdom. Dialog is built on
<dialog>.showModal() and Accordion on <details>/<summary>. jsdom stubs
both incompletely — it reports open but does not make the dialog modal or
move focus — so tests that passed there would say nothing about a browser.
axe runs on every story. preview.ts sets a11y: { test: "error" }, and
.storybook/vitest.setup.ts loads the a11y addon annotations so that setting
applies under Vitest too. Without those annotations axe never runs and the
suite would silently check only the play functions.
What the story tests actually pin down, beyond "it renders":
- Button —
hrefrenders an<a>and still firesonClick(a bug that had already been fixed once);href+ inert falls back to a real<button disabled>, because a disabled<a>stays focusable and clickable. - Link —
aria-current="page"on exactly the link markedcurrent, rendering through anascomponent, and anchor props (target,rel) passing through.asis the seam that decoupled the library from Next, so it is worth guarding on bothLinkandButton. - Tabs — APG roving tabindex, ←/→ wrap-around at both ends, Home/End, and
the
aria-controls/aria-labelledbypairing. - Dialog —
showModal()moves focus inside; close via ✕ and via backdrop click but not a click on the panel; a browser-driven close reachesonClose; and it reopens, so the effect re-runs rather than firing once. - Accordion — expand/collapse, and items open independently.
- Checkbox — clicking the label text toggles the box, which only works if
the
htmlFor/idpairing holds. - Field — the error message lands in an always-mounted
aria-liveregion and does not also carryrole="alert"(which would announce twice);errorreplaceshintrather than stacking.
Two conventions worth knowing before adding tests:
userEventdispatches synthetic events, so it cannot trigger user-agent behaviour like ESC-to-close on<dialog>, and it refuses to click anything withpointer-events: none. Reach forfireEventin those two cases, and say why in a comment.- Query summaries and roles, not free text. A question and its answer can share
a phrase, which makes
getByTextambiguous the moment an item expands.
Turning axe on over the inherited stories failed the build on two counts, both now fixed:
ValidationMessagesuccess text failed WCAG AA.Field.tsxclaimed "green-700 (4.5:1) meets AA" — true on pure white (4.53), but the brand page background is off-white, where it is 4.21, and beige, where it is 3.66. The ratio had been measured against the wrong surface. Added--color-green-800: #3a7018, the lightest green that clears 4.5:1 on all three (5.98 / 5.55 / 4.83), and pointed the success variant at it. This extends the Figma ramp, which stops at 700 — revert totext-green-700if you would rather keep the exact Figma value and accept the violation.Select's own stories shipped an unnamed control (axeselect-name). Stories are what people copy, so they now carry a real label.
Storybook also logs "use client" in "src/lib/config.tsx" was ignored when
building the static Storybook. That is expected: Storybook bundles an SPA where
the directive is meaningless. It still matters in dist/, which is not bundled.
Relative imports in src/ carry explicit .js extensions. That is what makes
the bundle: false output valid Node ESM; TypeScript resolves them back to the
.ts/.tsx sources.