Context
0302 makes@stackra/ui one contract with a HeroUI v3
implementation for the web and a HeroUI Native implementation for native, but leaves open what the first components may build on.
Before Phase 2 of STA-350 adds components, seven questions needed an answer: where router-aware UI lives, whether React Aria and
@internationalized/date are used directly, which icon set the components use, whether the form stack of
0055 is in force, what web and native must match, how thinly the
components wrap HeroUI, and what the props of every component are called. HeroUI Pro ships some of the missing pieces (a DropZone among them), but free packages never depend on it
(0165 decision 5, 0197).
The owner answered the first six on 2026-10-23, in reply to the component catalogue review recorded in STA-350. The seventh,
the props vocabulary, came out of the wave-1 design cross-check (Icon, Button, Link, Text and Heading: STA-354 to STA-357). The
owner accepted it on 2026-10-23, and on the same day answered the link underline and heading scale questions that the cross-check
raised. This page records the answers.
Decision
-
@stackra/uiis router-free. Router-aware UI (route overlays, table state held in the URL, breadcrumbs built from routes) lives in@stackra/navigation. No@stackra/uientry importsreact-routeror another router. -
React Aria is used directly, at HeroUI’s versions.
react-aria-componentssits incatalog:frontend, pinned to the exact version that the catalogued@heroui/reactresolves for it, so HeroUI and@stackra/uishare one React Aria instance.@internationalized/datejoinscatalog:frontend, with the same pin, when its first component (DateField) imports it.- A HeroUI bump moves these pins in the same change.
- Our
DropZoneis built on React Aria’sDropZoneandFileTrigger. HeroUI Pro’s DropZone is not used in@stackra/ui.
-
Icons are Lucide.
- The components use one icon set: Lucide (ISC licence, official packages for web and native).
- An icon slot accepts an icon name or any React element, so an app may pass its own SVG without a change to the component (decision 10 sets the name model).
@stackra/uidraws them from wave 1: itsIconrenderslucide-reacton the web andlucide-react-nativeon native, both optional peers of@stackra/uiand pinned incatalog:frontendandcatalog:mobile.@stackra/iconsholds the Lucide maps for both platforms (decision 10), so@stackra/uiresolves a name through it.
-
Forms follow 0055, now Accepted. Form state is react-hook-form,
validation is Zod and the HeroUI field adapters live in
@stackra/ui(0165 decision 2).react-hook-formjoinscatalog:frontendwithFormField, not in wave 1. -
Parity.
- Every component has the same contract on web and native.
- Native may render an adapted form of a component (a sheet for a popover, for example) when its documentation page names the adaptation.
- A component that exists only on the web is listed in a
webOnlyallow-list. The list starts empty, and an entry needs an ADR or an owner decision cited beside it.
-
Thin contract wrappers.
- Apps import UI only from
@stackra/ui, never@heroui/reactorheroui-nativedirectly. - A primitive is a thin wrapper over free HeroUI v3 on the web and HeroUI Native on native: one props API, unknown props pass through to HeroUI, and no second styling layer.
- A component that exists on one platform only and has nothing to unify keeps HeroUI’s own API, re-exported.
- HeroUI Pro is imported directly, and only in Stackra’s own apps (0197).
- The first components are
Icon,TextandHeading,Button(withIconButtonandCloseButton) andLink(wave 1).
- Apps import UI only from
-
One props vocabulary. A concept has one prop name across the kit, and the runtime’s own name for it is left out of the
wrapper’s type:
-
Link underline. A link’s underline is explicit, and a hover alone never carries it in running text.
underlineisalwaysby default and inside running text. It ishoveronly for a standalone link, one that does not sit inside a sentence or paragraph.- On native, where nothing hovers,
hovermeans no underline, and no colour changes on hover. - The
stackralink colour stays the text colour. A hover-only underline on that colour fails WCAG 1.4.1 (use of colour) in running text, so the underline carries the link. A distinct link hue is a brand decision and is not made here.
-
The
stackratype scale coversh4toh6. The theme stops ath3, so anh4falls back to HeroUI’s 20 px, the size of ourh3. The theme gains these styles, each written as size in px / line height / weight / letter spacing:h4: 18 / 1.4 / 600 / -0.01emh5: 16 / 1.5 / 600 / 0h6: 14 / 1.5 / 600 / 0- Each has an Arabic twin (
-ar), applied to Arabic text only, at +1 px and +0.2 line height.
-
Icons are drawn by name. Owner, 2026-10-23.
- A declaration (a resource, menu, widget, setting, action or document, and backend code) names an icon by
IconName, a plain string in the manifest and on the wire. It never imports a component. @stackra/iconsis the one home of the model. Its./contractsentry exportsIconName: a Lucide name from the pinned set (kebab-case, generated fromlucide-reactand committed, with a test that fails when it drifts), a Stackra alias (ordersresolves toshopping-cart), or a namespaced custom name (loyalty::points-badge, the template type${string}::${string}).- Resolution is per platform in
@stackra/icons:./reactmaps a name to alucide-reactcomponent and./nativeto alucide-react-nativeone. A registry is fed in, throughdefineIconRegistryand anIconRegistryProvider; gen will later emit a registry of only the names used, and turn an unknown name into a gen error. The full Lucide map is an explicit opt-in entry (./react/lucide-registry,./native/lucide-registry), because it is an estimated 180 KB gzipped on web (an estimate, not a measurement) and would use most of the entry budget of 0183; the playground registers only the icons it draws. The review measured the playground entry at 237.66 KB gzipped with the icons tree-shaken (no unused Lucide name in the output). The pre-icons baseline is still to be recorded, so how much of it is HeroUI is not yet known. Custom and brand icons (SVGs under a package’s or app’sassets/icons/) join under their namespace and draw through the sameIcon. Icontakesname: IconName, not a component. The icon slots of Button, IconButton, CloseButton and Chip take anIconName, drawn throughIcon, or an element, drawn as given. A name no registry holds draws nothing.- Nothing outside
@stackra/iconsand@stackra/uiimports a Lucide package. Swapping the library changes only the maps in@stackra/icons. - Data-driven icons (a category icon a tenant picks) store an
IconNamestring, checked with the exportedisIconNameguard.
- A declaration (a resource, menu, widget, setting, action or document, and backend code) names an icon by
Consequences
@stackra/uistays usable in any React app, whatever its router;@stackra/navigationowns the router coupling.- A HeroUI bump is one reviewed change that moves three pins together. A mismatch would load two React Aria copies and break
context such as
I18nProvider. - We maintain our own
DropZoneand the form adapters against HeroUI and React Aria changes. - Swapping the icon set is cheap for an app and costs nothing in the components. Lucide becomes a peer of
@stackra/uinow, not when@stackra/iconsstarts. - Every native adaptation is documented, so a reviewer can tell an adaptation from a parity gap.
- Wrappers stay small: a HeroUI prop works through
@stackra/uiwithout being redeclared. The cost is that a HeroUI prop rename can reach our users unless the contract pins it. pnpm-workspace.yamlgainslucide-react(catalog:frontend) andlucide-react-native(catalog:mobile) in wave 1, and@internationalized/dateandreact-hook-form(catalog:frontend) with the first component that imports them (DateField, FormField). The file is the owner’s (concurrent sessions).- A declaration carries an icon as a string, so the manifest, the wire and the backend need no component import, and swapping the library touches one package.
- Every later component brief starts from the vocabulary of decision 7, so web and native, and one component and the next, read alike.
- The link underline and the heading sizes are checked in a browser, by the playground’s end-to-end tests, so a HeroUI class rename or a theme change is caught by a test rather than by a reviewer’s eye.
Enforcement
tools/scripts/validate-repository.mjs(catalog references,catalogMode: strict) refuses a version outside the catalog.- planned —
packages/tooling/oxlint/src/boundaries.jsonc: no router import in@stackra/ui; no@heroui/react,@heroui/stylesorheroui-nativeimport in an app; no@heroui-pro/*import in a@stackra/*package. - planned — a test in
@stackra/uithat the cataloguedreact-aria-componentsand@internationalized/dateequal the versions@heroui/reactresolves. - planned — the
@stackra/uiparity test of 0112 and 0302, reading thewebOnlyallow-list. - planned — the
@stackra/uitype tests of each component assert that the runtime props extend the contract and refuse the raw HeroUI names decision 7 replaces (isPending,fullWidth,aria-label,accessibilityLabel). - planned —
apps/playground/e2e/__tests__/link.e2e.test.ts: a link in running text shows its underline at rest, and ahoverlink shows none until it is hovered on web. - planned —
apps/playground/e2e/__tests__/typography.e2e.test.ts: the computed size, line height, weight and letter spacing of every heading level and Arabic twin equal thestackratheme’s values, which decision 9 sets. - Until these exist, review (
architecture-guardian) checks this page.
Alternatives
- Router-aware components in
@stackra/ui. Not chosen: it ties every user of the kit to one router. - HeroUI Pro’s DropZone. Not allowed in a free package (0165 decision 5). Pro’s is built on the same React Aria parts.
toneeverywhere, danger included. Not chosen for buttons: HeroUI has danger as two button variants and no other button colour, so a buttontonewould accept values neither runtime draws.- Lucide and Heroicons both in the components. Not chosen: two sets in the components double the surface, and Heroicons has no official native package. Apps may still pass Heroicons through the icon props.
- Our own props and a styling layer over HeroUI. Not chosen: it duplicates HeroUI’s API and theming, which
0302 already drives from
@stackra/tokens. - A distinct link hue in place of the underline. Not chosen now: it is a brand decision, and the owner did not ask for it.