@hypersignals/design-system
v0.16.0
Published
Stackframe design system. Tokens and components shared by the myStacks wealth manager, client and admin dashboards.
Readme
@hypersignals/design-system
The Stackframe design system. Tokens and components shared by the myStacks wealth manager, client and admin dashboards.
| Consumer | Status | | --- | --- | | mystacks-fe-wm | consuming | | mystacks-fe-admin | not started | | mystacks-fe-client | not started |
Installing
bun add @hypersignals/design-systemNo token, no .npmrc, nowhere. The package is public on npm, so it installs with no
credential locally, in CI, or in a Vercel build. That is the whole reason it is published
publicly rather than privately, and it is worth knowing what the alternatives cost: a
private git dependency needs an SSH key or a PAT plus a URL rewrite in every CI and in
Vercel, and GitHub Packages requires a token even for a public package, since its npm
registry has no anonymous read.
Public does not mean freely reusable. The licence is UNLICENSED: the source is readable
and installable, and no rights to use it are granted.
Peer dependencies you supply: react ^19, react-dom ^19, tailwindcss ^4,
lucide-react >=1.
Wiring it up
Four steps. The third is the one people miss, because skipping it produces no error.
1. Transpile the package
// next.config.ts
transpilePackages: ["@hypersignals/design-system"],Required. The package ships TypeScript source rather than a bundle, so Next has to compile
it. That is deliberate: build tools routinely strip "use client" directives, which breaks
React Server Component boundaries in a way that produces correct-looking markup that does
nothing when clicked.
2. Import the tokens
/* src/app/globals.css */
@import "tailwindcss";
@import "@hypersignals/design-system/theme.css";Every token, both themes, and the dark variant definition come from that one file. Do not
redeclare a token in the app.
3. Provide the two font variables
theme.css reads --font-fraunces and --font-arimo:
--font-display: var(--font-fraunces, "Fraunces"), Georgia, serif;
--font-body: "Helvetica Neue", Helvetica, var(--font-arimo, "Arimo"), Arial, sans-serif;Nothing here sets them, because loading a font is Next's job and this package has no next
dependency. The consuming app loads both with next/font/google and puts the generated
variables on <html>.
Both fallbacks resolve, which is why this is easy to get wrong. Miss this step and
nothing errors: headings quietly render in Georgia and body text in Arial. Copy
src/app/fonts.ts from an existing dashboard rather than writing it fresh, and read the
comments there about why weight is omitted and why Arimo is present at all.
4. Let Tailwind scan the package
Usually nothing to do. theme.css carries @source "../", which points Tailwind at this
package's own files.
That directive is load-bearing now in a way it was not before. While the design system was a
workspace folder inside the WM app, packages/ was an ordinary tracked directory that
Tailwind's automatic source detection already covered, so @source was belt and braces.
Installed from npm the package lives in node_modules, which that detection deliberately
skips, so @source is the only reason its classes reach the build at all. Verified working
across that boundary: all 33 package-exclusive classes reach the WM dashboard's stylesheet.
The failure mode is missing styles, not an error, so do not trust a green build. Each
dashboard runs bun run check:tailwind after its build, which asserts that
package-exclusive classes reached the emitted stylesheet.
Importing components
Granular subpaths, one per component:
import { Button } from "@hypersignals/design-system/button";
import { buttonVariants } from "@hypersignals/design-system/button.variants";
import { cn } from "@hypersignals/design-system/cn";There is no index barrel, on purpose: with one, a server component importing Button would
pull every client component in the package into its module graph.
Never import @hypersignals/design-system/src/.... Use a declared subpath. Anything not in
the exports map is internal and may move without a major version.
Adding a component
- Start from shadcn if there is one, using
components.json. That file came across with the extraction and has not been exercised since, so on first use check where the CLI actually put the files. - Restyle it to Stackframe. A component pulled in from shadcn becomes ours; nothing is exempt from the rules below.
- One component per file. A variant definition goes in
<name>.variants.tsand a helper in its own module, so each can be unit tested alone. - Add a test next to it.
- Add the subpath to
exportsinpackage.json. bun run check.
Step 5 is enforced. check-package-exports.ts fails on an entry pointing at a missing file
and on a component that exists but is not exported, because the first breaks a consumer's
build and the second reads as a missing feature rather than a missing line of config. It was
written after a ./logo entry pointed at a file nobody ever wrote.
Rules
- No hardcoded values. Every size, space, radius, colour, shadow, duration and easing
comes from a token or a Tailwind scale step. A bracket is for what the scale cannot say:
a ratio, a percentage, a CSS function, a character measure, a variant selector. Never a
measurement. When the design's value is not on the scale, force the nearest step; 13px is
py-3.25and 52px ish-13.check:scaleenforces this. - No environment. This package cannot read
process.env. A component coupled to one app's configuration cannot be shared. eslint enforces it. - No imports from a consuming app, and none by its own package name. Relative paths within the package. eslint enforces both.
- Filenames are kebab-case; the exported component stays PascalCase.
- No em dashes or en dashes anywhere.
check:em-dashenforces it. - Comments explain why, never what.
Developing
bun install
bun run check # typecheck, biome, eslint, em dash, hardcoded values, exports map
bun run test # vitest with the coverage gate
bun run test:watch # iteration without the gate
bun run verify # everything CI runsTwo TypeScript programs, and the split matters. tsconfig.json checks shipped code the way
a consumer compiles it: React types only, no Node, no test globals, tests excluded.
tsconfig.tools.json checks everything that never ships, which is where @types/node and
the jest-dom matcher augmentation live. Both run under bun run typecheck.
While building a dashboard against it
Do not develop a new dashboard against pinned tags. Every screen will want a component that does not exist yet, and bumping a tag per component across repos is miserable. Point the consumer at a local checkout instead:
cd mystacks-design-system && bun link
cd ../mystacks-fe-admin && bun link @hypersignals/design-systemPin a real tag once the component set settles.
Releasing
Bump the version, tag it, push the tag. .github/workflows/publish.yml does the rest.
npm version minor # or patch / major, which writes package.json and commits
git push origin main --follow-tagsThe workflow runs the full verify before publishing, refuses if the tag and
package.json version disagree, prints the tarball contents to the log, and publishes with
--provenance so the package is linked to the workflow run that built it. It needs one
secret, NPM_TOKEN, an npm automation token; that is the only credential in this whole
setup, and it is needed only to publish, never to install.
A publish is close to irreversible, which is why verify runs first rather than after. npm allows unpublishing only within 72 hours, and refuses entirely once something depends on the version. Prefer releasing a new patch over trying to withdraw one.
Then bump the range in each consuming dashboard. Renovate handles this as an ordinary npm dependency now, so those pull requests arrive on their own.
Coordinated releases are not needed for this package: a dashboard sitting two versions behind
on a Button has a cosmetic lag and nothing worse. That will not be true of the platform
kernel, the Privy and API code that is the next candidate for extraction. The aud pin in
its token verification is the only thing keeping one dashboard's tokens from authenticating
against another's surface, so a stale copy there is a tenant isolation failure rather than a
visual bug. When that package exists, pin it exactly, never automerge it, and treat a release
as unfinished until all three dashboards are bumped. It should almost certainly be private,
for the same reason: an auth kernel is not a palette.
Why public npm
This was consumed as a private git dependency first, under the @mystacks scope. Recording
why it changed, because the reasons are not obvious and the alternatives look cheaper than
they are.
Every private option needs a credential in three places. Not just CI: a Vercel build
runs bun install too, and so does each developer's laptop. A private git dependency needs
an SSH key or a PAT with a URL rewrite. GitHub Packages needs a token even for a public
package, because its npm registry has no anonymous read, unlike ghcr.io for containers.
Public npm needs none, anywhere. With three dashboards, that is nine places not to configure
and nine ways for a build to fail for a reason that has nothing to do with the code.
Git dependencies have sharp edges beyond the token. The github:owner/repo shorthand and
git+https both resolve through the GitHub tarball API, which 404s on a private repo and
honors no token environment variable, so only git+ssh:// works at all. There are no semver
ranges, so every bump is a hand-edited pin in each consumer. And moving a tag does not
invalidate bun's cache: a consumer silently keeps installing the old commit until someone
runs bun pm cache rm, which is a genuinely nasty way to lose an hour.
The cost was the scope rename, @mystacks to @hypersignals, the npm organisation.
That came to 100 import lines across 44 files in the WM dashboard, done with one sed and
verified by typecheck. Cheap, and it only happens once.
Note the npm scope is @hypersignals while the GitHub organisation is hsignals. Nothing
requires those to agree. Only GitHub Packages ties a scope to the repository owner, and that
registry is not used here for exactly the reason above.
The remaining cost is the real one: the source is world readable. The package deliberately carries no brand assets, no business logic, no endpoints and no secrets, so what is exposed is the palette, the two typefaces, and a set of radix wrappers.
Why the package is the repo
Originally forced. A git dependency installs a whole repo, and neither bun nor npm can
point at a subdirectory, so a consumer aimed at a monorepo installed the workspace root,
whose package.json has no exports map, and every subpath import failed to resolve.
Publishing to a registry removes that constraint, because a tarball is built from whatever directory you publish from. So a monorepo holding this, the platform kernel and the test kit is possible again. Left as one repo per package on purpose: this works, and the shape is worth revisiting when there is an actual second shared package rather than in anticipation of one.
Known defect: light theme contrast
White on cyan, the label of every primary button, measures 2.67:1 and fails every WCAG threshold including the 3:1 non-text floor. The dark theme already solves it with near-black on cyan at 6.99:1.
src/styles/contrast.test.ts records the full measured set and is a record as much as a
test: if a pair changes category, the test fails and someone has to update the expectation
deliberately. There is no axe exception, so this will fail a dashboard's e2e accessibility
test as soon as a primary button renders on a tested page. That is intentional, and it is
a recorded decision rather than an open question.
Decided 14 August 2026: it stays. That closes the ambiguity but not the consequence, and the two are worth keeping apart. The pair still measures 2.67:1, a consuming dashboard's axe run will still fail the first time a primary button renders on a tested page, and the fix is still the one dark theme already uses: near-black on cyan, which lands near 5.6:1 in light while keeping both the palette and "cyan is the only action colour" intact.
So a consumer that hits this needs a scoped axe exception citing the decision, in the same shape
as the ones for muted-foreground and the link colour. What it must not do is disable the
contrast rule, which would take every other pair with it.
Test coverage
28 files, 194 tests. The coverage gate sits just under what the suite achieves: 82 statements, 76 branches, 81 functions, 82 lines.
Those numbers are measured here and are deliberately not WM's, which are roughly twelve points higher because they were computed over the app's services and lib alongside these components.
What is still uncovered is the same category it always was, thin radix wrappers where the behaviour worth testing is focus or portalling and belongs in a browser rather than in happy-dom: select, tooltip, dropdown-menu, textarea, skeleton, progress-bar, checkbox, character-count, google-mark, and now dialog.
Thresholds are a ratchet. Raise them; never lower one to make a change pass. They have moved twice on the dashboard component set, from 78/70/75/78 to 82/76/81/82.
