Design System
What @rising-tide/ui ships, what varies by brand, and how to extend it or fork it honestly.
Rising Tide ships a design system so you do not have to invent one, and it is built on the assumption that you will want to look like yourself. Nothing here is mandatory. An app that takes the tokens and none of the components is a normal, correct Rising Tide app.
The three layers, and what each costs
| what it is | who takes it | |
|---|---|---|
@rising-tide/ui/tokens | the raw --rt-* values | every rt- app; no client app |
@rising-tide/ui/theme | the semantic mapping on top (--color-primary and friends) | same |
@rising-tide/ui/primitives | components | anyone who wants them |
The split matters. Tokens are a look; primitives are mostly rules. A client app that declines
/tokens and /theme because it has its own brand can still take /primitives, as long as it
declares the token names those components read - see the contract below.
Starting a new app
Copy rt-sample. It is a real building app - browse smart products, open one, sign in - and it
carries a /gallery route rendering every component this package ships. It is the reference
because it is deployed and tested, not because it is documented.
Three things in it are not what they look like, and all three are load-bearing:
@stripe/stripe-js in an app that takes no payments. @privy-io/react-auth reaches
@stripe/crypto, which requires it without declaring it. Omit it and next build fails with
Module not found and an import trace naming Privy. Do not remove it because nothing imports it -
nothing is supposed to.
The CSS import order. tokens, then theme, then shell.css, then safe-area.css. Out of
order the app renders unstyled or half-styled and nothing errors.
Two @source lines. Tailwind v4 does not scan node_modules, so a class that appears only
inside a shipped component is never generated. The shell bundle and the primitives bundle are
separate and each needs its own line. A missing one leaves the component rendering unstyled,
which reads as a design decision rather than a build gap.
The adoption contract
A primitive reads semantic token names. Most of them are the shadcn names your app already
declares if it has a token bridge - primary, background, border, muted-foreground and so
on. Measured against grammata, the client app that retints hardest: 17 of the 19 names were
already there.
What is Rising-Tide-specific is small, and scoped to what you actually adopt:
| you adopt | you also declare |
|---|---|
Card, Input | nothing |
Badge | --color-depth-text |
Button | --color-ember-text, --color-depth-text, --rt-radius |
Toaster | the four -fill / -foreground status pairs |
Those are not taste. ember-text and depth-text are the -text calibration steps: the base
accents are tuned as fills at 3:1 and measure 3.7-4.3:1 as type, which fails WCAG AA. The steps
pass. Skipping them ships unreadable destructive and link labels.
Button's typeface is a token too - --rt-font-button, falling back to inherit. Take
/theme and your buttons are mono, which is the house style. Do not, and they are set in your own
body font. The one exception is the money variant, which stays mono unconditionally because its
label is chain data.
Forking is supported
A shared component works when your brand is a retint. It does not work when your brand is a different treatment, and that distinction is real rather than a matter of discipline.
The worked example: grammata's two real button variants are primary
(bg-ink text-paper italic font-serif) and quiet (a hairline-underlined control with no
background). No token substitution produces either from a filled mono control, because the
difference is structural. grammata forks, and that is the right answer.
What a fork must not lose are the rules. However the button looks, it should keep:
- 44 CSS px of hit area on every size, released above the mobile breakpoint so a desktop
console keeps its density.
min-h-11 sm:min-h-<natural>, ormin-h-[44px]- same thing. - A visible focus ring on every variant but
link. This is the one rule with no legitimate exemption. disabled:pointer-events-none. Opacity is not a disabled state; a faded button still fires, and on a protocol app that can spend money or deploy a contract.
Checking your extension
Opt in from your conformance config. Absent this, nothing about the design system runs - a protocol-facts install never mentions your buttons.
{
"app": "your-app",
"src": ["src"],
"extends": "rising-tide-design-system",
"designSystem": {
"primitives": { "button": "src/components/ui/button.tsx" }
}
}npx rt-conform then reports which rules your button lost. It says nothing about your colours,
your radius, your own variants, or the components we have never heard of. You may have things in
your design system that have nothing to do with this protocol; that is the expected case.
If you would rather run it from a test than from the CLI, the same checks are a plain function:
import { checkButtonContract } from "@rising-tide/ui/testing";
import { buttonVariants } from "@/components/ui/button";
const failures = checkButtonContract(buttonVariants, {
sizes: ["default", "sm", "lg", "icon"],
}).filter((r) => !r.ok);What we will not do
Check whether your app looks like ours. The rules above are the complete set, and they exist because each one breaks something for a real person - a thumb that misses, a keyboard user who cannot see focus, a disabled button that fires anyway. Everything else is yours.