@terpjs/react-core
v0.34.0
Published
Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.
Readme
@terpjs/react-core
Stack A (React) of the Terp frontend contract: the provider/hooks that wire a tree to
@terpjs/contract, the auth session, the app shell + TanStack Router adapter, and the
token-styled component surface every app module composes its screens from.
This README is the catalog of that surface. Everything listed here is exported from
the package root (import { … } from "@terpjs/react-core"), except the test-setup helper
under Testing components, which has a subpath of its own; each export
also carries JSDoc, so your editor shows the same guidance inline. Never deep-import from
src/ or dist/ — the boundary lint (@terpjs/eslint-boundaries) refuses it.
Conventions (the lint enforces these)
- Token-styled primitives only — raw
<button>/<input>/<select>/<textarea>are refused; useButton/Input/Select/Textareaplus the higher-level form primitives (Checkbox,RadioGroup,Switch). - Generated client + sanctioned realtime only — raw
fetch/XMLHttpRequest/WebSocket/EventSource/navigator.sendBeaconare refused; useuseTerpClient()(typed from the backend OpenAPI) andunwrapfor request/response, oruseRealtimeChannel()for typed SSE/WebSocket subscriptions. The hook mints a one-use connection ticket through the generated client; bearer tokens never enter URLs. - Design tokens, not inline colours — style with the CSS variables from
@terpjs/contract(var(--color-*),var(--space-*),var(--font-*)). The full list, with each token's per-theme values and the foreground/background pairings the contrast gate enforces, is published as@terpjs/contract/tokens.manifest.json— read it rather than inferring names from the compiled sheet. - The accent is two tokens, and mixing them up is a contrast bug.
--color-brand-primaryis the accent as a filled surface, and the only thing that may sit on it is--color-brand-primary-contrast.--color-fg-accentis the accent as ink or a boundary against one of the app's own surfaces — accent text, a selected-tab underline, a focus ring, a checkbox'saccent-color. One token cannot do both: in a dark theme the surface use needs a value dark enough to hold a white label and the ink use needs one light enough to read on a dark canvas, and there is no value satisfying both. - Static user-facing text is cataloged — use an
{id, message}UiTextdescriptor for text props and<Trans id message />for JSX bodies. Plain strings are reserved for dynamic business data, identifiers and product names. The boundary lint refuses bare static copy and checks every id against every target infrontend/i18n.json. - Dependency-free UI — react-core ships no icon/toast/i18n libraries. Glyphs are
inline SVG; transient feedback goes through
ToastProvider/useToast. - Security defaults —
dangerouslySetInnerHTMLand the DOM HTML-injection sinks (innerHTML/outerHTML/insertAdjacentHTML/document.write) are refused (render text, or useMarkdownfor rich content);eval()/new Function()are refused;javascript:URLs inhref/srcare refused; a statictarget="_blank"link needsrel="noopener".
Bootstrap & providers
| Export | Use |
|---|---|
| renderTerpApp, collectModules, withAdminArea | One-call app bootstrap: glob-import modules/*/module.tsx, merge the packaged admin area (opt out with adminArea: false, or select sections with adminArea: { users, groups, audit }; an app route claiming an admin path overrides that screen), build the router, mount provider + auth gate + shell. Options include logo (sidebar brand) and footer. |
| TerpProvider, useAuth, useTerpClient | The context root: session state + the typed API client. Drop to this + buildAppRouter when you need full control. |
| buildAppRouter, DEFAULT_ROLE_RANKS, PROFILE_PATH | TanStack Router adapter: realises stack-agnostic module manifests (routes + nav + roles) into a real router; throws at build time on a route referencing a missing view. Mounts the built-in ProfileView at /profile unless an app manifest claims that path. |
| createAuthClient | The auth/session contract implementation (login / refresh / currentUser) over the generated client. |
| LoginView | The standard sign-in screen: username/password, plus optional SSO provider buttons via ssoProviders and a dev-only credential-fill button via devCredentials (gate it on import.meta.env.DEV). |
| useSso, parseSsoCallback, fetchSsoAuthorizationUrl, completeSsoCallback | The SSO login seam (ADR 0058): useSso().begin(provider) opens an OIDC flow; TerpProvider completes the /auth/callback/{provider} redirect landing into a normal session on boot. renderTerpApp({ ssoProviders }) wires the buttons in one line. |
| RequireAuth | Renders children only with a session; pairs with the router so the app mounts only when signed in. |
| ThemeProvider, ThemeToggle, useTheme | Theming over the shipped palettes — midday, twilight, evening, night, contrast — plus system to follow the OS preference (its dark half is night). The earlier names light, dark and midnight are still accepted wherever a theme is named and resolve to midday, evening and night; the toggle writes the new names, so per-palette selectors in an app's own theme.css must use them. Applies data-theme on <html> (the token stylesheet carries every palette) and persists the choice. defaultTheme is how an app ships on a named theme — declare it in layout-contract.json so a tool can read and rewrite it, or pass the bootstrap option; both is refused. renderTerpApp mounts it for every app; the shell header uses an icon-only, token-themed variant="inline" menu. |
| LocaleProvider, defineAppLocales, LanguageSwitcher, useLocale, LOCALE_EN, LOCALE_NL | The language seam over UiTextProvider: defineAppLocales(i18n, frameworkCatalogs) validates and merges checked-in app messages with framework chrome, the active locale persists, and the shell offers a picker. Pass sourceLocale with locales; missing/empty target messages, undocumented source copies, invalid locale selection and a non-English locale without a complete framework-string catalog throw rather than silently falling back. A framework string that counts something (dataViewResultsRange, dataViewSelectAllResults, accessUnexplainedRoutes) is a PluralText: one form per plural category, chosen by the count under the active locale's Intl.PluralRules, and a catalog must supply exactly the categories its locale uses (one and other for Dutch; one, few, many and other for Polish). <html lang> follows the active locale from mount and across every switch. |
| UserMenu, userInitials | The signed-in user's menu, pinned by buildAppRouter to the bottom of the sidebar: an initials avatar trigger opening the identity block, Settings (the built-in profile page) and sign-out. Collapses to the avatar in the icon rail. |
| ProfileView | The built-in profile / settings page (/profile): the server-validated identity, theme + language preferences, and sign-out. |
Authorization gates (UI-side; the backend re-checks)
| Export | Use |
|---|---|
| Authorized, useCan | Gate UI on can(module, action) — write buttons, admin panels. |
| usePermissions, useHasPermission | The caller's named grants from GET /me (ADR 0096), for a screen whose write needs definitions.publish rather than a rank. Authorized takes an optional permission alongside action; both must pass, as the server's own guard does. Display only — the backend re-checks. |
| canPerform, DEFAULT_RANK_THRESHOLDS | The role-rank predicate behind the gate. |
| visibleNav | Filter nav items to what the current user may see. |
Page archetypes (the three-level screen pattern)
Every routed view must render one of the archetypes (Page, or OverviewPage /
DetailPage / HubPage / DashboardPage / FormPage / SettingsPage / SplitPage /
WorkspacePage, which compose it) — buildAppRouter refuses an unframed view at
runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/error frame.
| Export | Use |
|---|---|
| AppShell | The responsive level-1 frame: a home-linked brand, icon/label nav and account footer. Desktop collapses to a persisted, scrollbar-free rail with one fixed icon slot; mobile becomes a scroll-locking drawer. The sticky header holds the sidebar toggle and icon-only preferences. Router-agnostic link renderers receive framework-owned expanded/collapsed geometry. |
| Shell geometry (tokens, not props) | --shell-sidebar-width-expanded / -collapsed, --shell-header-height, --shell-content-max-width, --shell-brand-size and --shell-gutter (the content column's inline gutter, read by the app header, main, the footer and the page band, and tightened one step below the mobile breakpoint) are published contract tokens: an app moves them from its own unlayered theme.css with no prop at all, and the Studio gets them from the manifest. |
| logo / logoDark (shell) | The brand mark, rendered in a box of --shell-brand-size so an asset larger than the 4rem icon rail is scaled rather than clipped — which is why there is no separate collapsed-mark slot: logo is the mark, title is the wordmark, and the rail already hides the second. logoDark is for a fixed-colour mark that cannot survive a dark theme; both render and the stylesheet shows one, switched on --appearance-show-light / --appearance-show-dark, which the token build emits from each theme's declared appearance. Resolving it in React would be wrong: the theme is <html data-theme> and an app may set it with no provider mounted. |
| density (shell) | App-wide "comfortable" or "compact", stamped on the shell root; every control height and cell padding follows by token inheritance. No default — omitting it stamps nothing, so an app's own data-density on <html> still wins. A subtree may override it: a DataView density="comfortable" inside a compact shell really is comfortable, because comfortable now has named tokens and a rule rather than being the absence of an attribute. |
| Skip to content | The shell renders the skip link and owns the landmarks it skips: main carries tabIndex={-1} and a per-instance id, so activating the link moves focus rather than only scrolling and two shells on one page get two distinct targets. It is not rendered while the mobile drawer is open — the drawer is aria-modal and its target sits inside the inert column. |
| defaultDrawerOpen (shell) | Opens the mobile drawer on mount. A dev/specimen affordance — an app opening it on load shows every mobile user a menu they did not ask for — and the only way to render the drawer's own geometry and backdrop at all, since below the breakpoint the sidebar exists only while the drawer is open. |
| headerActions (shell) | Extra header content before the theme and language controls. Now forwarded by renderTerpApp and buildAppRouter; it was previously reachable only by dropping to TerpProvider + buildAppRouter by hand. |
| permission (nav + route) | A named grant from CurrentUser.permissions, ANDed with role — the same composition Authorized ships, because a server Policy carrying a Permission enforces the permission's role floor and the grant. Declared on both NavItem and ModuleRoute, so a hidden link never leaves a reachable route. Fails closed three ways: signed out, unknown name, and an app with no grant capability (empty list). Not a combinator — the server's own AuthzRef is one ref per read and one per write, so an any-of could express a gate no Policy can declare. |
| activeNavPath / isNavItemActive | Which nav item is current, as one predicate. Longest segment-aligned match wins across the set, because "at most one is current" cannot be decided one link at a time — a router marks every prefix-active link, so /settings and /settings/users are both current at /settings/users. NavItem.exact narrows a single item to itself. Search and hash are ignored: a tab's identity is its path, so filtering a list must not unhighlight it. |
| groupNav / NavGroup | The app declares named sections ({ id, label, order }); modules point items at them with NavItem.group, and NavItem.order sorts within one. A group spans modules, so no module can own its label or position — which is why this is the one part of the navigation model that is not on a module manifest. Additive: declare none and the sidebar is the flat list it is today. Absent order is 0 and the sort is stable (CSS order semantics), an item naming an undeclared group falls open into the ungrouped bucket rather than vanishing, a group left empty by role/permission filtering renders nothing at all, and the ungrouped bucket is emitted last so the packaged /admin entry stays where it already is. A label: null group renders no heading — pure positioning. Declare the groups in layout-contract.json under shell.navGroups (where label: "" is how a file spells null) so a tool can read and rewrite them, or pass the bootstrap option; both is refused. |
| navPlacement (shell) | Where the primary navigation lives on desktop: "sidebar" (default, stamps nothing) or "header" — a horizontal row in the header with no sidebar at all, for an app whose destinations are few enough that 15rem of permanent chrome is a tax. The header then becomes the sidebar surface, so every --color-sidebar-* an app themes carries over and no property is overridden. Below the mobile breakpoint both placements are the drawer. defaultCollapsed is never under "header": with no sidebar there is nothing to collapse. Available on AppShell, buildAppRouter and renderTerpApp. |
| contentWidth (shell) | "measured" caps routed content at --shell-content-max-width while each page's own header keeps the full track — the subheader band. Default "full" stamps no attribute, so nothing moves until an app asks. Available on AppShell, buildAppRouter and renderTerpApp. |
| NavIcon, Icon, TerpMark, ICON_GLYPHS, ICON_NAMES | The dependency-free icon layer. ICON_NAMES is published by @terpjs/contract as data and IconName is derived from it, so NavItem.icon and Icon's name are checked names — a typo is a typecheck error, not a picture of nothing. NavIcon's name stays a plain string on purpose: its unknown-name behaviour is a designed, visible fallback (the label's initial in a tile), while Icon rendered an empty box. The glyph table is held to the name set exhaustively in both directions by satisfies, so a glyph without a name or a name without a glyph fails to compile. The bundled catalogue covers common UI, action, object and status glyphs (home, list, folder, users, plus, edit, trash, search, check, x, chevron-{left,right,down}, arrow-left, external, logout, user, bell, key, globe, lock, tag, mail, refresh, filter, download, upload, star, heart, database, code, truck, cart, wallet, map-pin, clock, link, grid, book, briefcase, building, clipboard, layers, send, phone, image, video, music, wrench, zap, …). TerpMark is the placeholder brand mark until an app passes its own logo. |
| Page | The base routed screen: a band carrying the breadcrumb trail — whose leaf is the view's single h1 — plus badges, a description lead line and the actions cluster, then the body with loading/error slots. The band places by grid area (ADR 0135): the cluster sits beside the trail, a page with badges or a lead line spends a second row on them, and below the first cutover the cluster takes a row of its own; a page with none of that keeps the single row. A band of more than one row keeps the one-row band's inset once: each row is a control tall, with the inset above the first, between rows and below the last, so its lines sit as far from each other as from the border. Crumbs ellipsise rather than wrap, and the lead line is shown only above the second cutover. Every page renders a trail, even one of a single crumb, so an overview's title sits where a detail's does. Inside a shell the band bleeds to the content column's edge and takes --shell-header-height; a measure="narrow" frame keeps the row and drops the chrome. A page's own figures go in summary: a band directly under the title band, full-bleed inside a shell on the summary fill (--color-bg-summary, the brand's soft tint unless the theme says otherwise), holding a StatGroup, a headline Stat, a StatusHistory, a Badge or a line of Text — data, so it is not rendered while the page loads or fails (ADR 0169 §4). A page that is one item of an ordered series — a record among results, a column among a table's columns, a step among steps — takes sequence: the series' label, previous / next as { label, to } and an optional position, rendered as a bar after the article (ADR 0168). The steps are router links with rel and a direction in their accessible name, an absent neighbour leaves its cell empty so the others never move, and inside a shell the bar sits at the bottom of the content column on a short page and pins to the bottom of the viewport on a long one, so "next" is in one place for the whole series. |
| HubPage, HubCard | Responsive auto-fit landing grid. Cards share equal outer and internal tracks even when descriptions/stats differ; nested hubs use the ordinary breadcrumb contract via parents. |
| OverviewPage | A module's top-level listing screen (level 2); detail pages crumb back to it. |
| DashboardPage | "How is the whole doing" (ADR 0169 §4), where an overview answers "how is each one doing": the figures in its summary band, and a body of Grid sections — written with a template, which collapses on a phone — holding the figure and chart families side by side, the DataView they summarise, a Timeline, an Alert where something needs action. Under the standard contract the body admits Grid, Stack, Card, DataView, the figure family (Stat, StatGroup), the chart family (TrendChart, BarChart, ProportionBar, StatusHistory), Timeline, Divider, Text, Alert, ConfirmDialog and the framework states; the template is the guide's ask, not the contract's. |
| DetailPage | One record's screen (level 3); breadcrumb trail = ancestors + record title. |
| FormPage | A create-or-edit screen. measure="narrow" by default, so the whole frame — header, actions and all — caps at 32rem over a single column of controls. Its body slot takes the form container (Stack as="form"), plus Grid / Card / Divider / Text; a bare Field at the top level is refused, because a run of fields has no <form> and cannot be submitted. |
| SettingsPage | Preferences and account screens: Card sections, also measure="narrow". No DataView, DetailList or Tabs — a settings screen whose body is a collection is an overview with the wrong chrome. parents is optional, unlike FormPage's. |
| SplitPage, SplitPane | A list beside the record it selects. The archetype owns the pane row and admits only SplitPane in it (HubPage's shape, not DetailPage's), so the panes are the governed thing. listWidth is a step — sm / md / lg — not a length; a draggable divider waits for the preference seam. Each pane is a named <section>, and below the mobile breakpoint they stack list-first, so the reading order and the tab order agree at both widths. |
| WorkspacePage, CanvasHost | One canvas that fills the screen, for work done on a surface: a diagram of nodes and connections, a plan, a board (ADR 0179). The page keeps its band, with its trail as parents; below it the body takes every pixel the shell leaves, and its slot admits one CanvasHost, a framework state while it loads or fails (which fills the same box, so the frame does not jump), and a ConfirmDialog. The host is a named <section> painted from tokens, clipped, with a 24rem floor outside a shell, and it takes the full track in a measured shell. It takes one canvas child, drawn in an inner layer of a definite size at 100% of it, so an <svg> scales by its viewBox and a canvas library's root that sizes itself to its container gets all of it; a second child would land below the first and be clipped. A canvas that takes keyboard focus shows the framework's focus ring on the host, where the clip cannot cut it. Nothing on the page needs a style to be sized. |
| Breadcrumbs | The trail itself (used by the archetypes; rarely composed directly). Ancestor crumbs use the router's Link by default — renderLink is only for rendering outside a Terp router. |
| NavLinkContext, useNavLink | The ambient link renderer buildAppRouter publishes (and the layout components default to); provide it yourself in a standalone story/test tree or a bespoke shell. |
| useRouteParam | Read one route param, fail closed: the declared param comes back as a string, an undeclared name throws a directive error instead of silently yielding undefined. Replaces the unchecked useParams({ strict: false }) as {…} cast (ADR 0092). Checked against the generated route table when the app has one. |
| useRouteParams | Read a whole declared route's params, typed exactly: const { recordId } = useRouteParams("/records/:recordId"). With a generated route table, a typo in the path or a param name is a typecheck error. |
| useTerpNavigate | Navigate by manifest path: navigate({ to: "/records/:recordId", params: { recordId } }). An undeclared path is a typecheck error and a parameterised route requires its params — a typo'd path used to be a dead link that shipped green. Takes the manifest's :id spelling and translates to the router's $id. |
| useRouteSearch | Read a declared route's query-string keys, typed: const { status, page } = useRouteSearch("/records") (ADR 0096). Every value is string | undefined, and an undeclared key is a typecheck error — so a filtered list screen stays inside the checked seam instead of reaching for the router's own useSearch. Declare them in the manifest: search: ["status", "page"]. |
| ModuleNav | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
| PageActions | The page header's action cluster: primary, secondary nodes, secondaryActions descriptors and an overflow menu. The cluster follows the viewport AND the count — below the widest region a supporting action with an icon drops its label for it only when the slot holds more than one action, and on a phone the supporting actions fold into the overflow menu only when the slot holds more than two — while the primary action keeps its label at every width. It is the way a page's actions should be written: it wraps rather than running off the screen, and it collapses as room runs out, which a row of plain buttons cannot. Only secondaryActions can follow the viewport: a secondary node's label is inside the caller's tree and can only be shown or hidden. |
Typed route paths and params (generated, ADR 0092)
buildAppRouter realises routes at runtime from manifest data, which leaves TanStack
Router's type registry empty: nothing checks a route path or a param name, so a typo'd
path is a dead link and a typo'd param silently reads undefined. The manifests are
static data, so the check is generated from them:
npm --prefix frontend run routes # or: uv run terp routesterp-routes reads every src/modules/<name>/module.tsx manifest and writes a
committed src/routes.gen.d.ts that augments TerpRouteTable:
declare module "@terpjs/react-core" {
interface TerpRouteTable {
"/records": Record<never, never>;
"/records/:recordId": { recordId: string };
}
}From then on useRouteParams("/records/:recordId") is exact, useRouteParam refuses a
param no route declares, useRouteSearch is keyed to the route's declared query-string
keys, and useTerpNavigate refuses an undeclared path (or an undeclared search key). Regenerate
after changing a manifest route — terp verify's routes-drift check refuses a stale
table and names the command (it runs before the typecheck, so a stale table reads as
"regenerate", not as errors in your own screens). A route whose path is not a plain
string literal is refused with its file and line rather than silently omitted: a partial
table would turn a real path into a type error. Routes a packaged area mounts (the admin
area) are not keyed — the file stays a pure function of the app's own manifests. Without
a generated file every helper falls back to string, so adopting is opt-in: add the
routes script, generate, commit.
Slot-typed layout contracts (opt-in, ADR 0079)
An app can ratchet the archetype control further with a named layout contract,
declared once in a checked-in layout-contract.json next to the frontend sources
({ "contract": "standard" }) and read by both halves: the terp/layout-contract lint
rule finds the file on disk, and main.tsx imports it —
renderTerpApp({ layout }). The same file carries the palette the app opens on
(defaultTheme) and the shell's own shape under shell (density, navPlacement,
contentWidth, the navGroups a module's NavItem.group names by id, and the brand marks
as paths), which is what lets a tool read and rewrite those choices instead of them living
only in TypeScript. Declaring a key in the file AND
passing the matching bootstrap option is refused when the router is composed, with both
sources named; layoutContract: "standard" on its own still works for an app that would
rather write code than check in a file. Each governed archetype's body slot then accepts only the contract's
components — standard: hub bodies hold HubCard only; overview bodies hold
DataView / ResourceList / ModuleNav / Stack / Card / Divider / Text plus
the framework states (EmptyState / ErrorState / LoadingState / Alert) and
ConfirmDialog; detail bodies hold DetailList / DetailListGroup / Stack / Tabs /
ModuleNav / DataView / Card / Grid / Divider / Text plus the same states. The asymmetry is
deliberate and pinned by tests rather than left to the table: Grid joins detail
bodies only (an overview body is a collection, and a grid of summary cards is a hub,
which has its own archetype), while Heading joins neither — a heading in a governed
body must own its section, and Card is how a section is owned. The plain Page's body stays
unconstrained (the sanctioned home for a bespoke screen). Two rules belong to the frame rather
than to a body, so they hold on every page, the plain one included (ADR 0169 §4): a summary
band holds only Stat / StatGroup / StatusHistory / Badge / Text, and at most one figure on a page is its
headline — the lint counts the static JSX of a page element, taking the larger branch of a
conditional, and the runtime counts the rendered page. Only the slot's direct children are governed — an allowed
container's own subtree (a Card body, a Stack of rows) is the app's to compose.
Enforcement is two-layer and fail-closed: the lint rule checks static JSX
children; the archetypes verify the rendered DOM (sanctioned components stamp a
data-terp marker) and refuse a non-conforming view with the same directive
message — contract, slot, what was found, what is allowed, and the fix — so a
failing check tells the author (human or agent) exactly how to build the screen.
LAYOUT_CONTRACTS exports the table; no config means no checks (fully backwards
compatible). Every exported archetype must appear in it or name its reason for not
appearing — an archetype missing from the table is silently ungoverned by both halves,
which used to be a green build. The one opt-out is a justified // terp-allow-layout-contract: <reason>
marker, counted by the escape-hatch budget.
Data
| Export | Use |
|---|---|
| DataView + family | The single sanctioned surface for data collections: repository-driven table/card view with search, sorting, pagination, column management, selection + batch actions, row actions, expandable rows, persisted view preferences, and pointer/keyboard row activation for overview-to-detail navigation. A collection that is one section of a page takes title, its heading with its count beside it once read; a column takes status for a dot before its word, bar for its numbers drawn as bars against the largest shown, or history for each row's recent runs as a StatusHistory (ADR 0169 §5, §6). See src/dataview/README.md for the full guide (client-side and server-side recipes). |
| InMemoryDataViewRepository, HttpDataViewRepository | Data repositories (client-side / server-side); useServerDataView keeps server query state in the URL. |
| InMemoryViewStateRepository, LocalStorageViewStateRepository | Preference persistence seam. |
| useResource | An async collection: rows + loading/error + reload + create-then-reload. |
| useRecord | The singleton counterpart of useResource — the one record a detail screen shows: item (or null) + loading/error + reload + mutate. Deletes the one-element-list wart (list: async () => [unwrap(…)] then items[0]). |
| useRealtimeChannel | The sanctioned typed SSE/WebSocket seam for the optional realtime capability: mints a short-lived one-use ticket via the authenticated generated client, validates every inbound JSON payload with the channel's runtime type guard, and exposes connection state / last message / WebSocket send. App modules never touch raw transports. |
| ResourceList | The standard simple CRUD list screen: titled section, write-gated create form, loading/error/empty states. Composable — screens needing more render their own React. |
| routeFieldErrors | Split a failure's fields into the ones this form renders (shown, ready for Field's error) and whether anything was leftover. The branch that matters is leftover: a reason naming a field the form has no input for would otherwise set state nobody reads and suppress the toast on the way out, so the user presses Save and nothing at all happens. |
| randomUuid | A cryptographically random v4 UUID in every context. crypto.randomUUID is secure-context-only while lib.dom declares it unconditionally, so calling it on a plain-http origin is a synchronous TypeError that type-checks cleanly and never fires on localhost. Falls back to crypto.getRandomValues (not secure-context-gated) for the same entropy, and throws rather than reaching for Math.random. Reach for it wherever the client mints an id -- an Idempotency-Key, an optimistic row key. |
| unwrap, unwrapOptional, ApiError | Turn a generated-client result into data-or-throw; ApiError carries the envelope's code / status / requestId, plus fields — the per-field reasons of a 422, keyed by dotted path, ready to hand to Field's error prop ({} when the failure names no field). unwrapOptional returns null on a 404 instead — for resources whose absence is a normal state (a /latest snapshot not yet published), the client-side analog of BaseService.find beside get. |
| useFormatDate, useFormatDateTime, useFormatNumber (and locale-explicit formatDate / formatDateTime / formatNumber) | Render a date or a number in the app's locale. toLocaleDateString() with no argument asks the browser, so an app shipping one language renders its own tables in whatever the visitor's OS is set to. The hooks read LocaleProvider and are useCallback-stable, so a column list built in a useMemo can depend on one. An absent or unparseable value renders as an em dash rather than throwing. |
| FileUpload, useFileDownload | The files-capability surface (ADR 0056/0057): a token-styled attachment picker that uploads through the typed client, and an authenticated download helper (a raw <a href> would carry no bearer token). |
| useEndpointDownload, saveBlob | Download an artifact the backend generates — an evidence bundle, a CSV export — which has no stored file id (ADR 0096). Goes through the session client, so it carries the base URL and bearer token and rejects a non-2xx instead of saving the error body under the intended filename. |
Feedback & states
| Export | Use |
|---|---|
| LoadingState, InlineSpinner | Full loading block (announces itself) / compact inline glyph. |
| EmptyState | The standard "nothing here yet" block, with an action slot for the next step. |
| ErrorState, describeError | Human-readable failure block for a caught error. |
| ErrorMessagesProvider, useErrorMessage | Map stable backend error codes to copy; falls back to the envelope detail. The platform's own codes are worded by the active locale (errorCode* in TerpStrings), and an app's map wins over them. |
| ToastProvider, useToast | Transient success/error feedback (no toast library). |
| ConfirmDialog | Accessible confirmation modal (native <dialog>); use before any destructive action. A modal is for a confirmation or an explicit post-action moment — an edit form or a detail view belongs in a routed page, or in an expanded row beside the thing it edits (ADR 0096 §4). |
Forms & primitives
| Export | Use |
|---|---|
| Button | Token-styled command: variant (primary / secondary / danger / ghost), size (sm / md / lg, composing with density), loading (spinner in the icon slot, aria-busy, and disabled so a second click cannot start the request twice), fullWidth to fill the container instead of the label, and an optional leading icon. Content-sized by default. |
| Input, Select, Textarea | Token-styled controls with stable framework typography, independent of surrounding display text (raw elements are lint-refused). Numeric inputs suppress unthemeable browser steppers. Input type="password" grows a reveal toggle — the type decides, so there is no second export and no prop; an app could not add one itself, because the toggle needs a positioned wrapper and app source may use neither style nor className. |
| Select options (SelectOption<T>) | Two forms. Pass options={[{ value, label, disabled? }]} with onValueChange when the choices are data — T is inferred from the list, so a closed enum is checked at both ends and the event.target.value as Status cast goes away; placeholder renders the disabled empty-valued leading row. Or pass <option> children as before. The two are mutually exclusive at the type level, so neither can silently ignore the other. |
| Combobox | Accessible autocomplete/typeahead single-select: filterable options, controlled or uncontrolled value, loading state, disabled state, and ARIA combobox/listbox keyboard navigation. |
| DatePicker, DateRangePicker | Locale-aware calendar popover controls with keyboard-navigable month grids, min/max bounds, and range selection for ERP date filters. |
| Checkbox | Labelled checkbox with checked / defaultChecked and boolean onChange. |
| Radio, RadioGroup | Labelled radio and accessible grouped radio options with controlled or uncontrolled value. |
| Switch | Labelled boolean toggle (role="switch") with checked / defaultChecked and boolean onChange. |
| Tabs | In-page (non-routed) tab set with tablist / tab / tabpanel roles, arrow-key navigation, and controlled or uncontrolled value. |
| Avatar | The initials tile: from (an email or a name) or explicit initials, and a closed size of sm (2rem, an account menu) or md (3.5rem, a profile header). aria-hidden, because the name it abbreviates is always rendered beside it. No src: /me carries no avatar URL, so an image slot would be a prop with nothing behind it. |
| Badge | Small status pill: <Badge tone="success">Synced</Badge> (or label="Synced"); tone: neutral / info / success / warning / danger. |
| Meter | One bounded value — a quota used, a score against its range — as a bar with the value printed beside it (ADR 0158). The bar is the native <meter> (value, min default 0, max default 1, and the optional low / high / optimum bands), styled from the sheet, so the browser draws the proportion and nothing renders an inline width. label is required and is the meter's accessible name, not printed: every place a quantity sits already has a visible caption — a DetailList term, a Card title, a HubCard title — so a meter goes inside one, and the standard layout contract refuses one loose in a body. format takes the Intl.NumberFormatOptions the format helpers take and defaults to a percentage, which is always the share of the range (value={74} max={100} prints 74%, not Intl's 7,400%); any other style prints the value itself, unclamped, so an overrun reads 120% against a full bar. The printed copy is aria-hidden and handed to the element as aria-valuetext, so a screen reader hears it once — and so does a HubCard link whose stat is a meter. Bands are opt-in: with none the bar is one accent colour, and with any the fill takes success / warning / danger by region — reinforcing the printed value, never replacing words where the judgement matters. |
| Stat | One figure, with what it needs to be read (ADR 0169): label, a value printed in the app's locale through the format helpers (a string prints as written; null prints the dash), and optionally a delta — { value, sentiment, format?, label? }, a pill carrying its sign whose tone is the declared sentiment ("positive" / "negative" / "neutral", the caller's fact: more rejections is up and bad) and whose sentiment is also read out as a word — a trend of labelled points drawn as a sparkline (SVG geometry, coloured from the sheet) and read out as text, its data alternative, a target drawn as a Meter against its range and bands, and a caption. On its own it is a tile on the surface rung, where data is read; in a StatGroup or a HubCard's stat row the container is its frame. headline fills it with the brand colour: at most one per page, which both halves of the layout contract hold. Phrasing content throughout, so it is valid inside a HubCard's link. |
| StatGroup | Several figures about one subject as one ruled row — unframed wherever it is placed, so four figures read as one statement rather than four tiles. Wraps by itself, two to a line on a phone, and a figure that starts a line carries no rule. The usual content of a page's summary band. |
| TrendChart | Values over time (ADR 0158, ADR 0169 §6): mark "line" (the default), "area" or "columns", an optional comparison series drawn dashed behind it and named in a legend, on a value axis of three round ticks. A line's axis starts where its data does; an area's and columns' keep zero, because their length is the value. Under ADR 0158's contract: SVG geometry with colour from the sheet (no inline style), numbers through the format helpers, no dependency, and the data as a visually hidden table that assistive technology reads in place of the hidden picture. A tile on the surface. |
| BarChart | Categories compared by length, in the order given — rank them first. The chart is its own table: each category a row header, its value printed, its bar a cell drawn for the eye. max overrides the largest value as the scale. |
| ProportionBar | A whole split into its parts along one bar, parted by hairlines, with a legend that says every part — its name, its count and its share. Parts that are states take their tone; the rest take the chart colours in order. |
| Timeline | Events in order on a line that joins them (ADR 0169 §5): each a label in words, a when printed in the app's locale inside a <time> carrying its instant, an optional detail, and a tone for its marker where the event changed a state — the label is always the word. An ordered list named by label. The packaged user and group screens show a record's own history with it, read from the audit trail narrowed to that record. |
| StatusHistory | How the recent runs of something ended, one cell per run, oldest first: each run a { label, outcome }, an outcome a { label, tone } declared once and reused. The latest ending is printed and every run is read out. Phrasing content, so it sits in a page's summary band, a collection's cell (history on a DataView column) or a hub card's link. |
| Tooltip | Accessible focus/hover tooltip that describes its trigger with aria-describedby. The bubble is portalled to the body — or into the dialog its trigger sits in, since nothing outside a modal dialog paints above it — and placed from measurements — above the trigger, or below when there is no room — and kept inside the window, so a scroll container (a DataView cell) cannot clip it and a trigger at the window's edge cannot push it off screen. |
| Popover, Menu, MenuItem | Shared anchored overlay and dropdown-menu primitives: body-portaled, viewport-aware panels that escape scroll/table clipping, with outside-click/Escape close, focus return, selected-item semantics, and roving keyboard navigation. |
| Alert | Inline banner for persistent feedback (tone: neutral / info / success / warning / danger); warnings and danger announce as alert, others as status. actions puts what the reader can do about it — one or two Buttons — inside the alert, under its message, rather than in a row the caller builds beside it (ADR 0169 §5). |
| Markdown | Safe, dependency-free markdown renderer for headings, paragraphs, bold, italic, inline code, code blocks, lists, and safe links; raw HTML is rendered as text and never passed through. |
| Field | Label + control + hint/error wrapper for one form field. Fields side by side in a Grid (or a FieldRow) share a label line and a control line, so a label that wraps or a hint under one control moves no other field's control. The error is announced (role="alert") as well as described, because aria-describedby alone is silent for a rejection that arrives after focus has left the control. |
| FieldRow | Several Fields side by side, with their labels on one shared line and their bodies — each control with whatever its field says under it — on the next. (A Grid whose children are all fields now does this by itself; FieldRow is for a row that also holds an action.) A field is as tall as its label, its control and whatever it has to say, so neither flex alignment is right: align="end" lines up the messages, so a field that grows a hint lifts its own control above its neighbours' and a bare action button sinks to the depth of the longest error on the row; align="start" lines up the labels, which fixes the fields and leaves the button riding above the controls. A flex row can align one edge of a box and the thing that has to line up here is a band in the middle of it, so the row owns two rows and each field becomes a subgrid of them — the instrument DetailListGroup already uses, for the same reason. Anything that is not a field lands on the control line, which is a placement rather than a wrapper, so a remove button keeps its own element and its own name. gap moves the column gap only: the row gap is each field's internal label-to-control measure, shared by every field on the row. Below the viewport cutover it is one column at full width — three controls and an action do not fit a phone, and that is also where the alignment problem stops existing. |
| QuietActions | A value with an action attached, where the action is quiet until someone reaches for it — a copyable identifier, a row's remove, a list item's open. Five copyable digests on one card meant five copy buttons competing with the five values they applied to. The space is reserved, not collapsed: the action fades rather than appears, so nothing moves when a pointer arrives. Three things make it safe rather than merely quiet — the resting state sits behind @media (hover: hover), so a touch screen with no hover to reveal anything gets a visible control instead of an invisible tappable one; it reveals on :focus-within as well as hover, because opacity does not remove an element from the tab order and a keyboard must never land on something it cannot see; and opacity is not visibility, so the action is announced, named and operable throughout. The actions are a slot, the same one Card publishes, rather than a convention about which children count. |
Layout
App code never writes style={} or CSS — the boundary lint refuses the style
attribute anywhere in src/** (ADR 0175). Layout comes from these primitives (gaps index the
token spacing scale, so spacing is themed centrally):
| Export | Use |
|---|---|
| Stack | The layout primitive: a flex container with a token gap. Vertical by default (forms, sections); direction="row" + justify for toolbars; as="form" etc. for semantics. padding insets on the same token scale; direction and gap also take a { narrow, wide } pair, which changes over at the one viewport cutover the shell and the DataView already use. |
| Grid | The two-dimensional primitive, and the one that lifts a real ceiling — a two-column form could not be expressed at all before it. columns takes a fixed 1–4 or "auto" (the default), which reflows to whatever the container can hold with no breakpoint anywhere; minColumn is the track floor for auto; gap indexes the spacing scale; align is a closed four. template lays out a page section instead (ADR 0169 §2): "2:1", "1:2" and "3:1" for a main track beside a narrower one, "1:1", "1:1:1" and "1:1:1:1" for equal ones — and every template gives up its tracks at the one cutover (to one, and the four-track set to two), which a fixed count does not, so a template is how a section is laid out and columns={4} is not. The types refuse a template beside a count. Renders no inline style. No span, and therefore no twelve-column option — asymmetry is a template on the parent, so a row cannot go ragged whatever renders in it. |
| Card | A token-styled frame (a border, a radius, padding and the container fill, --color-bg-subtle — the rung between the page and the data read on it, ADR 0169 §3) grouping one block of a page — the sanctioned visual separation between sections. Optional header, shaped like the page band: title (semantic <h3>) with the actions slot beside it when they fit (they wrap under it, and wrap their own buttons, when they do not), then the muted description on a line of its own at full width — so a sentence of description no longer wraps in a narrow column beside a button. variant="plain" keeps the heading and drops the box, for a titled region inside something that is already a surface — a section whose body is a DataView gets a border inside a border otherwise. There is no separate Section or Surface: both are this element with declarations removed. |
| Divider | A rule between groups, as a semantic <hr> so the separation reaches the accessibility tree — Separator under its other name, shipped once. orientation="vertical" takes its height from its flex or grid line rather than inventing one, so it works between the items of a row Stack and is zero-height in a block parent. |
| DetailList | Token-styled label/value pairs as a semantic <dl> (record metadata, expanded-row summaries). A real grid. The default layout="stacked" puts each label above its value — the one layout with no label-to-value spacing to get wrong; "aligned" puts every label in a shared column so the values line up, "inline" runs Label: value on one line, "grid" is the ruled grid — each pair a cell, as many to a row as fit at 11rem, divided by hairline rules, for a record whose facts are many and short (ADR 0169 §5) — and columns takes two pairs per row. Tracks are floored at zero and long values wrap, so a 64-character digest no longer pushes the list past its container. Both non-inline layouts mute their label to the small, regular step — at the value's own size and weight a card of labelled values is a wall of bold text — and space their rows so a pair groups; gap moves that distance on the spacing scale, and it is the row gap, since the column gap is the label-to-value distance. One pair can leave the shared column with full, which spans it across every track with its label above its value — the escape that stops a single wide value (a paragraph, a payload) from forcing the list to split in two, since two lists measure two label columns. columns also takes "auto", which follows the container rather than the viewport: a pair is at least 9rem of label and 13rem of value, each floor capped at a share of the track so one pair always fits a narrow panel instead of overflowing it. The closed one-or-two keep the reflow at the framework's viewport cutover, where Grid deliberately does not for a fixed count. |
| DetailListGroup | Several DetailLists that share one measured label column. A card that shows a record in sections renders a list per section — the sections have their own headings and their own order, and one <dl> cannot carry that — and each list then measures its own label column, so the values in each land on a different vertical line: four lists, four gutters, on one card. The group owns the track list and each aligned list inside becomes a subgrid of it, so every label in every list is measured against the same track. Explicit rather than something Card infers from its children: a card that wants two different label widths has to be able to say so, and a card has no business knowing about <dl> tracks. Children other than lists span the whole group (a Heading between two sections, a Divider), which is the other half of why it is a wrapper. It shares the measure for layout="aligned" at the default single column — a columns={2} list keeps its own four tracks rather than being quietly folded into two — above the viewport cutover, and it degrades to exactly today's output where subgrid is unsupported. |
| Heading | A section heading inside a page body — h2–h4, with size a separate choice from level so a visually small h2 is expressible without picking the wrong element. No level 1: Page renders the single h1 of every routed view. |
| Text | Body copy with themeable ink — tone (default / muted / subtle), size, and an enumerable measure that caps the line length in ch. What a bare <p> in a module cannot be, since a bare element carries no marker for a rule to reach. |
| Code | An identifier or a snippet in the mono family. block wraps it in a focusable <pre>, which is what preserves the whitespace and what makes a long line scrollable by keyboard. |
| Link | A link with themeable ink, routing in-app paths through the surrounding router and degrading to a plain anchor outside one. An external newTab gets rel="noreferrer". The boundary lint refuses a raw in-app <a href="/…">; this is the thing to use instead. |
Framed and unframed, alternated (ADR 0169 §3). The frame belongs to the data a reader works
with: a DataView, a chart and a lone Stat sit on the surface, the collection brightest. A
boxed Card steps back onto the container fill, for a group of controls or one section of a
record. Much of a page needs no frame at all — a StatGroup, a DetailList, a Timeline, a
line of Text, a titled region as Card variant="plain". A page that boxes every block in a
Card reads as one object repeated; which block a shape of data takes is in
terp guide layouts.
The packaged admin area
Every Terp backend mounts the base-profile admin capabilities (users, groups +
access grants, audit); react-core ships the UI over them, so every app has a
working admin area on day one. renderTerpApp injects it by default: one
admin-gated Admin sidebar entry opens the /admin hub, whose cards lead to
the overviews; each overview breadcrumbs back to the hub (hub → overview →
detail, like every screen). Opt out with adminArea: false; ship only the
screens whose capabilities the app mounts with a sections object —
adminArea: { groups: false } is the users + audit profile, first-class (a
dropped section loses its routes, hub card and stat call) — or override a
single screen by claiming its path from an app module.
| Export | Use |
|---|---|
| adminModule | The whole area as a TerpModule (manifest + views) — spread it manually into an L2 buildAppRouter composition. |
| AdminHub | /admin: cards into users / groups / audit with live totals. |
| UsersAdmin, UserCreate, UserDetail | /admin/users: clickable account overview; /new: dedicated provisioning page; /$userId: details with header actions and confirmation-gated role, status and password changes. |
| GroupsAdmin, GroupCreate, GroupDetail | /admin/groups: clickable group overview; /new: dedicated creation page; /$groupId: details with header deletion, member management and permission grants (destructive changes use confirmation dialogs; deletion cascades memberships + grants, ADR 0074). |
| AuditLogAdmin | /admin/audit: the append-only trail, rows expanding to identifiers + payload. |
Localization
| Export | Use |
|---|---|
| UiTextProvider, Trans, useUiText, useStrings, resolveUiText, DEFAULT_STRINGS | The UiText seam: descriptors for props, Trans for body copy, and framework strings through one resolver. LocaleProvider is the batteries-included catalog layer and refuses missing target-locale entries. A packaged module's nav label is a FrameworkText ({ framework: "admin" }), which useUiText reads from the active TerpStrings rather than the app's messages. |
Testing components
Component tests run under vitest with // @vitest-environment jsdom at the top of the
file plus an explicit afterEach(cleanup) (the default environment is node). Run:
npm run -w @terpjs/react-core typecheck && npm run -w @terpjs/react-core testjsdom has no HTMLDialogElement.showModal/close; vitest.setup.ts calls
installDialogPolyfill() from @terpjs/react-core/testing to supply them, so components may
use the native <dialog> freely.
Testing an app that uses these components
A generated app's frontend/vitest.setup.ts installs the same polyfill, so an app's unit test
can open a ConfirmDialog, click Confirm or Cancel, or press Escape
(fireEvent.keyDown(dialog, { key: "Escape" })) and get what a browser would do — including
a pending dialog refusing to close. An app whose setup file predates it, or has diverged from
the template's, adds the call itself:
import { installDialogPolyfill } from "@terpjs/react-core/testing";
installDialogPolyfill();@terpjs/react-core/testing is for test setup only and is not re-exported from the package
root, so none of it reaches a production bundle (ADR 0155). What the polyfill does not
reproduce — focus moving into the dialog and back to its opener, the inert page behind it — is
the Playwright suite's to test, in a real browser.
