@hypercerts-org/ui-react
v0.2.0
Published
Token-based React component library for Hypercerts. Brand-blind primitives; a theme supplies the values.
Readme
@hypercerts-org/ui-react
Token-based React component library. The primitives are brand-blind: they reference a closed set of semantic utilities and nothing else, and a theme decides what those mean. Re-branding is a token file, not a fork.
Three themes ship: Hypercerts (the default), Certified, and a template for a white-label partner.
Installing it
npm install @hypercerts-org/ui-reactTo develop against this repository and a consuming app at the same time, a
local path dependency is easier than republishing —
"@hypercerts-org/ui-react": "file:../hypercerts-design/packages/react" — or
npm pack here and install the tarball.
There is a Svelte package alongside this one, @hypercerts-org/ui-svelte, with
the same components and the same props. The two are compared on every pull
request by scripts/parity.mjs, and again before either can publish.
Upgrading from 0.1.0
0.2.0 changes the API. Pre-1.0 the minor is the breaking position, so a
^0.1.0 range will not pull it — you have to ask for it, which is
deliberate.
Popoversplit intoPopoverandMenu. The oldPopoverhardcodedrole="menu", so it was a dropdown menu under the wrong name and there was no way to float arbitrary content.Menuis a list of actions with the menu keyboard;Popoveris arole="dialog"panel that intercepts nothing, so a form or a date picker inside it works.PopoverItemis nowMenuItem.- Renamed for parity with the Svelte package:
aria-label→labelonInfoTooltip,SearchInputandMenuButton.InfoTooltip's oldlabel(the visible trigger text) is nowtriggerLabel— the two packages had a prop calledlabelmeaning different things. - Removed:
SignInDialogandFeedbackDialog(compositions that encoded a product decision — rebuild either fromResponsiveDialog+Field+Button),TabPanelTransition(now<Tabs transition>), andTableCaption(Tablerenders its own from the requiredcaptionprop). - Added: twenty-one primitives, including
Table,Calendar,DatePicker,Slider,Accordion,BreadcrumbandFileDropzone. Seedocs/maearth-intake.md.
Install and use
The library does not bundle the brand system — it declares a dependency on
it, so that theme/hypercerts.css and assets/fonts/ stay the single source
of truth for brand values instead of being copied in here. Import both, in this
order:
/* your app's stylesheet — or a plain <link>, no build step required */
@import "@hypercerts-org/ui-react/styles.css";That is the whole requirement. Tailwind is not needed: the utilities are
compiled into that file, and the brand tokens are resolved into it at build
time. It defines nothing outside the components it renders — no reset, no
body rule, no restyled headings.
If you do want the document styled to match — page background, headings in the display face, Tailwind's reset — that is a second, opt-in import:
@import "@hypercerts-org/ui-react/styles.css";
@import "@hypercerts-org/ui-react/base.css"; /* optional */Fonts are not included and cannot be. Switzer's licence prohibits
redistribution (see assets/fonts/README.md), so the package ships font
stacks, never files. Self-host Instrument Serif and Switzer yourself —
both are free — and the components pick them up. Without them everything
renders correctly in a fallback face.
Inside this repo, one import does both:
@import "../../src/styles/with-brand.css";That file is repo-internal — it reaches up to theme/hypercerts.css, which
sits outside the package, so it is deliberately not a package entry point. From
another project, vendor theme/ and assets/ as the root README describes and
import your own copy.
Then pick a theme by setting one attribute on the root element:
<html data-hc-theme="hypercerts">import { Button, Card, Heading, Eyebrow, Badge } from "@hypercerts-org/ui-react";
<Card>
<Eyebrow>Round 21</Eyebrow>
<Heading level={3} size="heading">Restoring the Kinabatangan floodplain</Heading>
<Badge variant="success">Verified</Badge>
<Button>Endorse</Button>
</Card>;Forgetting the brand import fails loudly — every value resolves to nothing and the page renders unstyled. That is deliberate; a silent fallback to system-default styling is how a half-configured app ships looking almost right.
The one thing to understand
Two themes here contradict each other on nearly everything: Hypercerts is 12px-radius, cream-and-serif, light-only, one accent; Certified is 2px-radius, monochrome, dark-mode-required. Neither set of values lives in a component.
docs/tokens.md the closed vocabulary, and how it maps to both systems
docs/decisions.md every conflict between them, and how it resolved
docs/white-labeling.md what a partner changes, and what they may notIf you are about to write a hex value, a font name, a px radius or a dark:
variant in src/components/, the answer is a token — and npm run lint will
say so before review does.
What's here
Foundations — Heading (with the italic turn), Eyebrow, Text, Prose.
The first two carry brand rules 1 and 2, which are content conventions no token
can express. Prose styles rendered Markdown, where there are no elements to
put classes on.
Primitives — Button, Badge, Card, Avatar, Skeleton,
LoadingSpinner, Tooltip, InfoTooltip, ErrorMessage, EmptyState,
Separator, Progress.
InfoTooltip is the one to reach for when the content is the only copy: it
opens on tap as well as hover, and is announced. Tooltip is CSS-only and
aria-hidden, for a hint that repeats something already on screen.
Forms — Field, Input, Textarea, Select, Checkbox, Radio,
Switch, NumberInput, SearchInput, InputGroup (+ InputGroupAddon,
InputGroupInput), Slider, CharacterCount, FileDropzone, Calendar,
DatePicker.
DatePicker's text field is the control and the calendar is an accelerator:
typing 2026-09-15 works without ever opening the popover, which matters for
any date more than a month out.
Checkbox, Radio and Switch render their own labels. The bare controls go
through Field, which wires label, description and error to the control — so
nothing can ship without an accessible name.
Navigation — Tabs / TabList / Tab / TabPanel, SegmentedControl,
Pagination, Breadcrumb, Stepper, MenuButton, LoadMoreSentinel.
The direction-aware panel slide is <Tabs transition>, not a separate
component — its only correct usage was beside Tabs, and the direction needs
the tab order Tabs already holds.
Layout and data — Container, ScrollArea, Carousel +CarouselItem,
Reveal, Accordion + AccordionItem, ExpandableText, and the Table
family (Table, TableHeader, TableBody, TableFooter, TableRow,
TableHead, TableCell).
Overlays — Dialog, ConfirmDialog, ConfirmByTypingDialog,
ResponsiveDialog, Drawer, BottomSheet, Menu (+ MenuTrigger, MenuContent, MenuItem), Popover
(+ PopoverTrigger, PopoverContent), Banner, ToastProvider + useToast.
Every overlay traps focus while open, locks page scroll, closes on Escape, and
restores focus to whatever opened it.
Menu and Popover are different patterns and the difference matters.
Menu is a list of actions: role="menu", one tab stop, arrow keys and
typeahead between items. Popover is a panel of arbitrary content:
role="dialog", and nothing intercepted, so a form or a date picker inside it
actually works. Putting a form in a menu is invalid ARIA and a keyboard trap —
the menu's arrow keys swallow the ones the inputs need.
Data entry and lists — Combobox (typeahead: state machine and ARIA, no
data layer) + ComboboxOption, LoadMoreSentinel, SettingRow, IdentityRow, OwnerByline,
CopyableId, ThemeToggle.
There is no SignInDialog or FeedbackDialog, and that is deliberate. Both
existed and were removed: they are compositions of the primitives above that
encode a product decision, and a library shipping a sign-in shell is telling
every consumer what their sign-in looks like. ui/sidebar was left out of the
Ma Earth intake on exactly that argument, so keeping these would have been
inconsistent. Either is about twenty lines of ResponsiveDialog + Field +
Button in an app.
Routing — UIProvider takes a link component so Button href=…,
Pagination and the row primitives render real anchors that navigate through
your router. Without it they fall back to a plain <a>.
Hooks — useFocusTrap, useScrollLock, useDismiss, useMounted,
useIsDesktop, useCanHover, useReducedMotion.
Where the newer half came from
Twenty of the components above were derived from
MaEarth/maearth-app — read for
behaviour and API, then re-implemented against the token vocabulary rather than
copied. That app's own ui/ folder is shadcn on Radix with brand values
hardcoded into it; taking it would have brought eighteen runtime dependencies
into a package that has one, and ended the Svelte package's parity. What was
worth having was the layer above it, where components had been written under
real product pressure and carried comments explaining the bug they existed to
fix.
docs/maearth-intake.md is the record: what was
taken, what was already covered, what was deliberately left, and why. It is
worth reading before adding to that set.
The date arithmetic behind both lives in packages/shared/date/calendar.ts and
is exported (toISODate, fromISODate, addDays, addMonths, …). It is all
local-time and day-by-day rather than by milliseconds — toISOString() reports
yesterday for anyone west of Greenwich, and adding 86,400,000ms lands on the
wrong day across a DST boundary. Both are in the tests.
Still not built: the funding-platform patterns — contribution input, funding progress, project card, contributor list, round status. These are compositions of the primitives above rather than gaps in them, and they want a real consumer to shape them.
Scripts
| | |
|---|---|
| npm run storybook | the component gallery, with a live theme switcher |
| npm run build | the library — dist/index.js + dist/hypercerts-ui.css |
| npm run build-storybook | the static gallery |
| npm run lint | ESLint, including the no-literal-design-values rule |
| npm run typecheck | tsc --noEmit |
| npm test | vitest — behaviour and accessibility contracts |
| npm run test:a11y | axe over every story in a real browser (needs Storybook running) |
What the tests cover
npm test is not a smoke test. It pins the behaviour that is expensive to get
right and silent when it breaks: the focus trap wrapping in both directions,
the roving tabindex, arrow keys skipping a disabled tab, End landing on the
last enabled tab, scroll lock applying and releasing, focus returning to the
trigger, overlays actually portalling, popover dismissal by outside press /
Escape / selection, and every status badge carrying its icon.
Each of those was mutation-checked — the assertion was confirmed to fail when the behaviour is removed. One of them didn't at first: "focus is still inside the dialog" passes even with the trap gone, so it now asserts the exact element focus lands on.
npm run test:a11y runs axe in a real browser, which is the only place colour
contrast can be checked at all — jsdom has no layout. That is how
ui-grey #999999 was caught at 2.85:1.
Hosting it, and catching visual drift
Storybook runs locally, but it is also built as a static site on every PR — the
point of a component gallery is a link, not a clone. npm run build-storybook
produces storybook-static/, which is plain HTML and can be hosted anywhere.
The published gallery lives at https://6a9ffc58071e7b7e9f862cfb-jplneoagws.chromatic.com/ — that link is the one to send someone rather than asking them to clone the repo.
It is published by Chromatic, which hosts the gallery and screenshots every story on every PR, so an unintended visual change shows up as a diff to accept or reject rather than as something noticed months later. With three contradictory themes in play, a single token edit can restyle forty components at once; this is what makes that visible.
Every story is snapshotted in three modes — hypercerts, certified and
certified-dark — configured in .storybook/preview.tsx. Snapshots cost
stories x modes, roughly 195 a build. If that outgrows the plan, drop
certified-dark first: it shares a token vocabulary with certified and only
the values differ.
Setup is done — the project exists and CHROMATIC_PROJECT_TOKEN is set as
a repository secret. The workflow skips itself when that secret is absent
rather than failing, so a clone or a fork does not go red for a secret it was
never going to have.
Both triggers are path-filtered so an unrelated commit does not spend
snapshots, which also means an empty commit triggers nothing. Run it by hand
with gh workflow run chromatic.
To run it by hand: CHROMATIC_PROJECT_TOKEN=… npm run build-storybook && npm run chromatic.
Publishing
Tagged releases only:
npm version patch # or minor / major
git push --follow-tagsA ui-react-v* tag runs .github/workflows/release.yml, which checks the tag
matches package.json, then publishes. prepublishOnly runs lint, typecheck,
tests and the build first, so a broken publish has to get past all of them.
Needs an NPM_TOKEN secret with rights on the @hypercerts-org scope; without
it the job stops rather than failing.
Without npm provenance, which needs a public source repository — this one is
private, and cannot be made public while assets/fonts/Switzer-Variable.woff2
is in it, because the ITF Free Font License does not permit redistribution. The
workflow says what to change if the font ever moves out.
The package name carries -react deliberately, so that the Svelte
implementation is a peer rather than an afterthought and neither becomes "the"
library by accident. @hypercerts-org/ui is left unclaimed for the same
reason.
Where this sits
packages/react/ and packages/svelte/ are the only parts of this repository
with a build step, over the shared source in packages/shared/. The root stays
buildless on purpose: brand/, theme/, tokens/, assets/ and
examples/ are copy-two-directories-and-go, and every non-React deliverable
made from this system depends on that. Do not dilute it.
