@tomscaria/consumer-fintech-design-system
v2.5.0
Published
Consumer fintech design system — design tokens, a multi-theme runtime (earth, arcade, revenant, primitive/studio, kiosk) with product/marketing expressions, kit CSS primitives, a Tailwind preset, and thin React component wrappers.
Maintainers
Readme
consumer-fintech-design-system
A consumer-fintech design system by Scaria, Inc. — design tokens, a multi-theme runtime, a Tailwind preset, kit CSS primitives, and React component wrappers for the web and for React Native.
One system, encoded in two formats:
agent/— machine-readable atomic files any agentic tool (Claude, Cursor, Code) can read and build from.tokens/+styles.css+preset.cjs— the runtime a product ships: CSS variables, per-theme blocks, and the Tailwind preset.
Themes
Composed on <html>/<body> via data-theme (identity: color + type) and
data-expression (rhythm: spacing, hero scale, motion pace — product | marketing).
| Theme | Register |
|---|---|
| earth-light / earth-dark | Warm consumer-fintech — parchment, graphite, chartreuse |
| arcade-light / arcade-dark | Prediction-market / liveness — indigo, near-black grounds |
| revenant-light / revenant-dark | American-Dynamism ops — concrete, onyx, signal orange |
| primitive / primitive-dark | The Studio — Scaria flagship marketing identity |
| kiosk | High-contrast, touch-first |
<html data-theme="earth-light" data-expression="marketing">Install
npm install @tomscaria/consumer-fintech-design-systemPublished to the public npm registry — no token, no
.npmrc, no auth of any kind. A plainnpm install(orpnpm add/yarn add/bun add) works in any project. (GitHub Packages also carries older mirrored versions; ignore it — npmjs.org is the canonical registry.)
Two setups, one package — read this before the quickstart
This package targets the web and React Native, and the two targets are set up differently on purpose. Getting that straight first will save you an afternoon, because the difference is not cosmetic — it is where the theme lives.
On the web the theme is CSS. You import stylesheets and put data-theme on
<html>. There is no provider component, because there does not need to be: a CSS
custom property already cascades, already re-scopes on any subtree, and already
costs nothing to change. Adding a React context on top of a mechanism the platform
gives you for free would be a second source of truth for the same value.
On React Native the theme is a React context. React Native has no cascade and no
custom properties, so something has to carry resolved values down the tree. That is
ThemeProvider, and it is what makes Box, Stack, Text and Pressable able to
take token names as props.
What both targets share is the component layer. Button, Panel, StatStrip,
MarketCard, Sparkline and the rest are exported under the same names from the
same specifier on both platforms; Metro resolves the native renderers through the
react-native export condition and every other bundler resolves the DOM renderers.
Each pair is a redesign rather than a port — sharing a name is only honest if the
phone version was designed for a phone — but the import and the prop API are one.
So the honest summary is: one component vocabulary, two theming models. The
quickstarts below are separate because the setup is separate, and each one only uses
symbols that actually resolve on its own platform. If you would rather read the
symbol lists than the prose, docs/PLATFORM-INVENTORY.md
is generated from the shipped type declarations on every build.
Quickstart — web
Three stylesheet imports and one attribute. Nothing else is required, and no part of this needs React until you want the components.
// 1. The runtime. `/styles` is fonts + every theme + the brand layer.
import '@tomscaria/consumer-fintech-design-system/styles';
// 2. The component classes. REQUIRED — see "Which CSS an application imports".
import '@tomscaria/consumer-fintech-design-system/css/kit';
// 3. The layout kernel: .surface / .stack / .row / .grid.
import '@tomscaria/consumer-fintech-design-system/css/layouts';
import {
SysHeader, LiveDot, Panel, PanelHeader, PanelBody,
StatStrip, StatCell, Button, Badge,
} from '@tomscaria/consumer-fintech-design-system';
export function App() {
return (
<div className="stack" data-gap="lg" data-pad="lg">
<SysHeader wordmark="ACME MARKETS" status={<LiveDot />} />
<Panel>
<PanelHeader title="Positions" trailing={<Badge tone="live">LIVE</Badge>} />
<PanelBody>
<StatStrip columns={3}>
<StatCell label="Net" value="$12,071.60" delta="+2.4%" deltaTone="pos" />
<StatCell label="Open" value="14" />
<StatCell label="Today" value="$418.02" accent />
</StatStrip>
<Button variant="accent">Review</Button>
</PanelBody>
</Panel>
</div>
);
}Set the theme on the document element, not in JavaScript:
<html data-theme="earth-light" data-expression="product">What the web does not have. There is no ThemeProvider, Box, Stack, Text
or Pressable on this entry, and importing them will not typecheck. Their jobs are
done by data-theme, by the layout kernel classes (.stack, .row, .grid), by
your own type styles or the Tailwind preset, and by <button>. Six symbols exist
only on web — cssVar, cssTint, ScoredBasketTable, ScoredBasketHeader,
SCORED_BASKET_GRID, SCORED_BASKET_MIN_WIDTH — because they are CSS-cascade tools
and a table, and neither idea survives the trip to a phone.
To change theme at runtime, set the attribute:
document.documentElement.dataset.theme = 'earth-dark';Quickstart — React Native
One provider at the root, then the terse prop vocabulary underneath it. Metro resolves the native build from the same specifier the web uses.
import { ThemeProvider, VStack, Text, Box, Pressable, PortalHost } from '@tomscaria/consumer-fintech-design-system';
export function App() {
return (
<ThemeProvider theme={{ light: 'earth-light', dark: 'earth-dark' }} followSystem>
<VStack p="lg" gap="md" bg="bg">
<Text role="header-lg">Positions</Text>
<Box bg="bg-2" p="md" radius="r-lg" gap="sm">
<Text role="body-sm" color="fg-muted">Net</Text>
<Text role="mono-md" tabular>$12,071.60</Text>
</Box>
<Pressable onPress={() => {}} haptic="light">
<Text role="header-caps">Review</Text>
</Pressable>
</VStack>
{/* Sheet, Dialog and ToastStack render through here. Mount it once, last. */}
<PortalHost />
</ThemeProvider>
);
}p, gap, bg, radius and color take token keys, not raw numbers: p="lg"
resolves through the theme's spacing scale under the current expression, and
bg="bg-2" resolves through its colour map. A bare number is still accepted and
means points, so an escape hatch exists — but a screen written in token keys re-themes
and a screen written in numbers does not.
What React Native does not have. No CSS, so no data-theme, no className, and
no stylesheet imports; the /css/* subpaths are web-only and importing one under
Metro is a build error. Button accepts as="a" and href so a shared screen still
typechecks, and ignores them.
Before your first screenshot, read Fonts. React Native has no font fallback chain and no error for an unregistered family, so a skipped font step renders every screen in the system face and looks almost right.
Subpath exports
Every subpath the package publishes. scripts/verify-doc-contracts.mjs fails the
build if this table falls behind package.json.
| Import | What |
|---|---|
| . | React component wrappers — DOM renderers, or the native kernel + native renderers under Metro |
| ./styles | Aggregate CSS: @font-face + every theme block + motion, dataviz, patterns, brand layer |
| ./css | The token layer alone: @font-face + the nine theme blocks, nothing else |
| ./css/kit | Required with the web components. The .panel / .btn / .stat-strip / .badge / .sys-header / .log-row classes they emit |
| ./css/components | Component-state rules the kit needs — disabled, loading, focus. Ships typed as styles.css.d.ts |
| ./css/layouts | The layout kernel: .surface / .stack / .row / .split / .grid / .inline / .center |
| ./css/dataviz | Chart and data-mark classes plus the categorical palette tokens |
| ./css/focus | The keyboard indicator: .focus-ring / [data-focus-ring], a two-colour ring that clears 3:1 on all nine themes. Already inside ./styles; this subpath is for taking it alone |
| ./css/primitives | Deck/slide primitives (frame, vrail, stoplight, matrix, venn). For decks and preview pages, not for an application |
| ./tailwind (alias ./preset) | Tailwind preset (.cjs — require and import both work) |
| ./themes/earth-light, ./themes/earth-dark | One theme's colors_and_type.css, for a page that ships exactly one theme |
| ./themes/arcade-light, ./themes/arcade-dark | As above |
| ./themes/revenant-light, ./themes/revenant-dark | As above |
| ./themes/primitive, ./themes/primitive-dark | As above |
| ./themes/kiosk | As above |
| ./tokens.json | Machine-readable token summary |
| ./tokens/theme | Every theme as resolved JS data — real hex, real numbers, zero var() |
| ./tokens/ansi | The 256-colour ANSI table, for a Node process writing to a TTY. Not for the DOM: there is no index→CSS direction, and the xterm cube is lossy against the hex the theme ships |
| ./tokens/dtcg/* | Raw DTCG token files |
| ./behavior | Platform-free state machines — one build serves web and native. See docs/BEHAVIOR.md |
| ./format | Money (currency + scale), the price/probability duality, sentiment and countdown. Pure, one build serves both, Intl reached only through an injected provider |
| ./flows/cashier | The deposit flow: 11 screens plus the machine that sequences them. Deposit only — see /flows/cashier |
| ./native/kernel | The React Native kernel. Metro gets the renderers; node and web bundlers get the platform-free token/breakpoint/touch-target math |
| ./backtest | Performance analytics + SVG charts on web; analytics only on React Native |
| ./fonts/* | Font files. .woff2 for the web, .otf for iOS and Android |
| ./agent/* | Raw atomic kit files — themes, component specs, visual patterns |
| ./dataviz | Data-viz class layer (same file as ./css/dataviz, kept for existing imports) |
| ./patterns | Operational UI patterns: stat panel, status grid, reveal, orbit |
| ./orbfield, ./orbfield.js | The opaque-coins hover-reveal pattern: stylesheet and its mount script |
| ./leadrow | The hover-to-somewhere list row |
| ./brand-layer | The Scaria constants across every theme: crosshair cursor, paper grain, section dial, kicker, ticker |
| ./botw | Lore-only marketing flair overlay. Opt in with data-flair="botw"; deliberately not in ./styles |
| ./glyphs/ledger | The ledger glyph sheet (SVG sprite) |
| ./motion, ./motion.json | Motion utility classes, and the same durations/easings as data |
| ./magic_trick | The system's own explainer |
| ./brands/lore/magic_trick, ./brands/revenant/magic_trick | Per-brand explainers |
| ./package.json | The manifest |
Which CSS an application imports
Five of the subpaths above are stylesheets, they do different jobs, and two of them have historically been mistaken for each other. The short version:
| You are building | Import |
|---|---|
| An application, using the React components | ./styles and ./css/kit and ./css/layouts |
| An application, using tokens only (Tailwind, your own CSS) | ./styles — or ./css if you do not want motion/patterns/brand-layer |
| A chart-heavy surface | add ./css/dataviz |
| A slide deck or a preview page | ./css/primitives |
Two points worth stating outright, because both have cost consumers a day:
./css/kit is required, not optional. Button, SysHeader, PanelHeader,
PanelBody, StatStrip, StatCell, Badge and LogRow carry no inline
var(--token) of their own — they emit a className and the kit stylesheet does all
the visual work. Without it they render as unstyled bare elements, which looks like a
theming bug and is not one.
./styles does not include the kit or the layout kernel. It is fonts, the nine
theme blocks, motion, dataviz, patterns and the brand layer. Importing ./styles
alone gives you correct tokens and unstyled components.
Scoped theming
[data-theme] is a plain attribute selector at specificity (0,1,0), and every theme
block is written that way. It therefore re-scopes on any element, not just
<html> — which is the cheapest thing in this package and the least obvious.
<body data-theme="earth-light">
<main>…</main>
<!-- A dark panel inside a light page. No provider, no portal, no CSS of your own. -->
<aside data-theme="revenant-dark" data-expression="marketing">
<div class="panel">…</div>
</aside>
</body>Everything inside the aside resolves its tokens from revenant-dark. This is how
you build an embed, a white-label section, a theme gallery, or a side-by-side
comparison. The one caveat is import order: because every theme block and the
primitives layer are all at (0,1,0), the last matching rule takes precedence, so import
./css/primitives before ./styles if you use both.
The layout kernel
./css/layouts is the web answer to native's Box and Stack: seven classes driven
by data-* attributes, with no per-component CSS to write.
| Class | Shape |
|---|---|
| .surface | A padded, themed container |
| .stack | Vertical flow |
| .row | Horizontal flow |
| .split | Two children pushed apart |
| .grid | Column grid |
| .inline | Inline run that wraps |
| .center | Centres its child on both axes |
| Attribute | Values |
|---|---|
| data-gap | none xs sm md lg xl |
| data-pad | none xs sm md lg xl |
| data-align | start center end stretch baseline |
| data-justify | start center end between around |
| data-cols | An integer column count, on .grid |
<section class="stack" data-gap="lg" data-pad="lg">
<div class="row" data-justify="between" data-align="center">…</div>
<div class="grid" data-cols="3" data-gap="md">…</div>
</section>Because these are single class selectors at (0,1,0), one of your own class selectors
overrides them without !important — so a media query that changes a column count is
@media (max-width: 640px) { .my-grid { grid-template-columns: 1fr; } } on your own
class, alongside the DS class.
The behavior layer
./behavior is the part of a screen that is not a renderer: reducers, predicates and
formatters with no React, no DOM and no React Native in them. One build serves both
platforms — there is no .native twin, because there is nothing platform-specific
to twin. It is the least advertised and most reusable thing in the package.
Nine machines: orderTicket, quantity, stepFlow, selection, disclosure,
sheetDetent, notificationQueue, itemQueue, asyncAction. Each ships as an
init* seed, a *Reducer, and a set of pure selectors.
import { useReducer } from 'react';
import {
initOrderTicket, orderTicketReducer, priceOrder, canSubmitOrder, orderTicketErrors,
} from '@tomscaria/consumer-fintech-design-system/behavior';
export function OrderTicket() {
const [state, dispatch] = useReducer(orderTicketReducer, undefined, () =>
initOrderTicket({ side: 'yes', mode: 'buy', orderType: 'market' }),
);
const quote = priceOrder(state);
return (
<form onSubmit={(e) => e.preventDefault()}>
<output>{quote.costCents}</output>
<button type="submit" disabled={!canSubmitOrder(state)}>
Submit
</button>
{orderTicketErrors(state).map((e) => <p key={e}>{e}</p>)}
</form>
);
}That example is a web one on purpose. The layer was written for a React Native app
and its prose lived inside a document titled "RN Architecture", which is why a web
builder shipped an order ticket without ever finding it. The reference is now
docs/BEHAVIOR.md, which is platform-neutral, as the layer is.
/flows/cashier — deposit only
./flows/cashier is a deposit flow: a funding-method chooser, an amount screen,
card entry, 3-D Secure challenge, and the four outcome screens, sequenced by
cashierFlow. It ships 11 screen components and roughly 45 pure functions with their
own test suite, and it works on both platforms.
It is not a withdrawal flow and it will not become one by relabelling. DepositLimits
is the only limit type; FundingMethod enumerates four ways to pay; the fee runs on
top (totalDebitCents 10175 for a creditCents of 10000), where a payout is the one
case in which the opposite convention is the only correct one; and a 3-D Secure
challenge has nothing to challenge on the way out. Every string is overridable through
CashierCopy, so a flow dressed in withdrawal language will look right in review and
reconcile wrong in production. Use it for money in.
import {
cashierFlow, initCashier, initCashierCtx, cashierTotalCents, cashierFeeCents,
} from '@tomscaria/consumer-fintech-design-system/flows/cashier';
const ctx = initCashierCtx({ amountCents: 10_000 });
const fee = cashierFeeCents(ctx); // 175
const total = cashierTotalCents(ctx); // 10175 — amount + fee, debited from the payer
// `cashierFlow` is a StepFlow instance, not a function. The state is external.
const state = initCashier(ctx);
const here = cashierFlow.step(state); // the current StepDef
const blocked = cashierFlow.blockingMessage(state); // why `next` is unavailable, or null
const onwards = cashierFlow.reducer(state, { type: 'next' });CashierEffects.charge is what advances the flow out of processing. Omitting it
leaves the flow on the processing screen indefinitely, which on a phone is a spinner
with no exit.
Money formatters — which one to use
Ten near-identical functions ship with signatures that typecheck in each other's call sites, and they disagree in six places. The table below is generated from the shipped functions on every build, so it cannot go stale and a new formatter cannot be added without appearing here.
Read the columns as: $12,071.60-scale input, a negative, zero, and a
price-scale input. The entry column is where the symbol is importable from.
| Function | Defined in | Importable from | 1207160 | -3840 | 0 | 6300 | Groups 1000s | Negative | Clamps 0–100 |
|---|---|---|---|---|---|---|---|---|---|
| formatAmountCents | components/trade-behind-card/trade-behind-card.logic.ts | root | $12071.60 | -$38.40 | $0 | $63 | no | hyphen | no |
| formatAmountDollars | components/trade-behind-card/trade-behind-card.logic.ts | root | $1207160 | -$3840 | $0 | $6300 | no | hyphen | no |
| formatAumCents | components/vault-card/vault-card.logic.ts | root | $12.1K | −$38.40 | $0 | $63 | no | U+2212 | no |
| formatCents | behavior/quantityInput.ts | /behavior | 12071.60 | -38.40 | 0.00 | 63.00 | no | hyphen | no |
| formatCentsGrouped | behavior/quantityInput.ts | /behavior | 12,071.60 | -38.40 | 0.00 | 63.00 | yes | hyphen | no |
| formatCentsPlain | components/backtest-replay/backtest-replay.logic.ts | root | $12,071.60 | $38.40 | $0 | $63 | yes | sign dropped | no |
| formatChipCents | components/outcome-chip/outcome-chip.logic.ts | root | 100¢ | 0¢ | 0¢ | 100¢ | no | clamped to zero | yes |
| formatDepthPrice | components/depth-book/depth-book.logic.ts | root | 1207160.0¢ | -3840.0¢ | 0.0¢ | 6300.0¢ | no | hyphen | no |
| formatEdgeCents | format/price.ts | not exported | +1207160.0c | −3840.0c | 0.0c | +6300.0c | no | U+2212 | no |
| formatMoneyCents | components/live-stat/live-stat.logic.ts | root | $12,071.60 | -$38.40 | $0 | $63 | yes | hyphen | no |
| formatNetPct | components/trade-behind-card/trade-behind-card.logic.ts | root | +1207160.0% | -3840.0% | 0.0% | +6300.0% | no | hyphen | no |
| formatOddsCents | components/odds-bar/odds-bar.logic.ts | root | 100¢ | 0¢ | 0¢ | 100¢ | no | clamped to zero | yes |
| formatPriceCents | components/market-card/market-card.logic.ts | root | 100¢ | 0¢ | 0¢ | 100¢ | no | clamped to zero | yes |
| formatPriceCents | components/tick-widget-mini/tick-widget-mini.logic.ts | root, as tickFormatPriceCents | 100¢ | 0¢ | 0¢ | 100¢ | no | clamped to zero | yes |
| formatPriceCents | format/price.ts | root | 99¢ | 1¢ | 1¢ | 99¢ | no | clamped to zero | yes |
| formatPriceDecimal | format/price.ts | not exported | 0.99 | 0.01 | 0.01 | 0.99 | no | clamped to zero | yes |
| formatReturnMultiple | format/price.ts | not exported | 1.0x | 100x | 100x | 1.0x | no | clamped to zero | yes |
| formatSignedCents | components/backtest-replay/backtest-replay.logic.ts | root | +$12,071.60 | −$38.40 | +$0 | +$63 | yes | U+2212 | no |
| formatSignedPercent | format/signal.ts | not exported | +1207160% | −3840% | 0% | +6300% | no | U+2212 | no |
| formatSliderCents | components/slider/slider.logic.ts | root | 12,071.60 | -38.40 | 0.00 | 63.00 | yes | hyphen | no |
20 formatter(s), discovered from the AST and measured at build time by scripts/verify-doc-contracts.mjs.
The two you probably want:
formatMoneyCentsfor an amount of money on either platform. It groups thousands, drops a trailing.00, and signs a negative with a plain hyphen.formatSignedCentsfor a P&L figure in a mono column, where the sign is the point. Note that it emits U+2212 MINUS SIGN rather than a hyphen so that gains and losses align on the character the reader is scanning; do not mix it with the others in one column.
The traps, stated once: formatAmountCents is the only one named for amounts and the
only one that does not group thousands. formatCentsPlain discards the sign
rather than restyling it, which is correct for a figure whose direction is carried
elsewhere and wrong everywhere else. formatSignedCents(0) is +$0. The price
formatters clamp to 0–100 because a binary market cannot settle outside it, except
formatDepthPrice, which does not clamp — so a money amount handed to a price
formatter is silently absorbed by three of them and rendered absurdly by the fourth.
React Native
React Native is a first-class target, not a port target. The specifier is the same on both platforms; what resolves behind it is not.
Metro configuration
One flag, and only if you are on Metro < 0.82 (React Native < 0.79 / Expo SDK
< 53), where Metro ignores exports by default:
// metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
module.exports = mergeConfig(getDefaultConfig(__dirname), {
resolver: {
unstable_enablePackageExports: true,
unstable_conditionNames: ['react-native', 'require', 'import'],
},
});The full annotated version ships in the package at
node_modules/@tomscaria/consumer-fintech-design-system/metro.config.js.
The package works with the flag off too. A top-level "react-native" field
covers the bare specifier and generated forwarding stubs
(behavior/, native/kernel/, backtest/, flows/cashier/, tokens/theme.js,
tokens/ansi.js) cover the subpaths, with .native.js twins where a native flavour
exists. Setting the flag is still recommended — conditional exports are how the
ecosystem is moving, and it is the only path on which they work at all.
The root recipe
Sheet, Dialog and ToastStack render their content through a portal, and a
portal with no host renders nothing, silently. Mount one PortalHost as the last
child of your app root:
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Haptics from 'expo-haptics';
import {
ThemeProvider, SafeAreaBridge, PortalHost, installExpoHaptics,
} from '@tomscaria/consumer-fintech-design-system';
installExpoHaptics(Haptics); // once, at module scope
export function Root() {
return (
<SafeAreaProvider>
<SafeAreaBridge useInsets={useSafeAreaInsets}>
<ThemeProvider theme={{ light: 'earth-light', dark: 'earth-dark' }} followSystem>
{children}
<PortalHost />
</ThemeProvider>
</SafeAreaBridge>
</SafeAreaProvider>
);
}SafeAreaBridge takes useSafeAreaInsets as a prop rather than importing it.
That is the seam that keeps react-native-safe-area-context linked by your app
instead of by this library: a native module linked twice is a native module linked
wrong, and a library that reaches for one takes the decision away from the app that
owns the build. The same pattern is why haptics arrive through
installExpoHaptics(Haptics) rather than an import.
With neither module installed everything still runs: haptics fall back to Android's
Vibration, and safe-area insets read zero — flush content, never unreachable
content. Both are deliberate. A missing haptic is imperceptible, and a guessed inset
is wrong on every device but one.
TabBar is a bottom bar
TabBar is bottom-anchored on both platforms and caps at five destinations, per
the iOS HIG and Material 3. That is a considered limit rather than an oversight: past
five, targets fall below the touch floor and labels truncate. It is not a web top nav
and does not have a placement prop. Use safeEdge to absorb the bottom inset in a
PWA or under a collapsed Safari toolbar.
Fonts — read this before your first screenshot
Fonts are the one thing this package cannot finish for you. Loading a font is an app-level act on both platforms, so a library bundle physically cannot do it. If you skip this section nothing throws: React Native has no font fallback chain and no error for an unregistered family, so every screen renders in the system face and looks almost right until somebody who knows the brand sees it.
Step 1 — load the faces this package ships. Use the .otf files, never the
.woff2: WOFF2 is a web wrapper and neither iOS nor Android can load it.
import { useFonts } from 'expo-font';
import { registerFonts, fontAssets } from '@tomscaria/consumer-fintech-design-system';
const [fontsLoaded] = useFonts({
'Aeonik-Regular': require('@tomscaria/consumer-fintech-design-system/fonts/Aeonik-Regular.otf'),
'Aeonik-Medium': require('@tomscaria/consumer-fintech-design-system/fonts/Aeonik-Medium.otf'),
'Aeonik-Bold': require('@tomscaria/consumer-fintech-design-system/fonts/Aeonik-Bold.otf'),
'AeonikMono-Regular': require('@tomscaria/consumer-fintech-design-system/fonts/AeonikMono-Regular.otf'),
'AeonikMono-Medium': require('@tomscaria/consumer-fintech-design-system/fonts/AeonikMono-Medium.otf'),
'LockSerif-Regular': require('@tomscaria/consumer-fintech-design-system/fonts/LockSerif-Regular.otf'),
});The keys are the faces' PostScript names, which is exactly what
theme.text(role).fontFamily returns — because React Native does not pick a
weight out of a family. fontFamily: 'Aeonik' with fontWeight: '700' renders
Regular on iOS and faux-bold on Android; fontFamily: 'Aeonik-Bold' renders Bold.
fontAssets(resolve) builds that same map from FONT_FACES so it cannot drift.
Prefer bundling at build time? npx react-native-asset works too — point
react-native.config.js at the files and nothing further is required at runtime:
// react-native.config.js
module.exports = {
project: { ios: {}, android: {} },
assets: ['./node_modules/@tomscaria/consumer-fintech-design-system/fonts'],
};Step 2 — three families are NOT in this package, and two themes need them.
| Family | Used by | Install | Registers as |
|---|---|---|---|
| Jersey 10 | arcade-* font-display | @expo-google-fonts/jersey-10 | Jersey10_400Regular |
| Anton | arcade-* font-cond | @expo-google-fonts/anton | Anton_400Regular |
| Syne | kiosk font-sans, font-display | @expo-google-fonts/syne | Syne_400Regular … |
All three are Google Fonts under the OFL, so a .ttf for them is freely
redistributable — they are absent because they were never vendored here, not
because they cannot be. Aeonik and Aeonik Mono are different: those are
commercially licensed retail faces, and whether you may redistribute them
inside your own app is your licence question, not this package's.
The trap, and the reason registerFonts() exists: the name your app
registers is not the CSS family name. @expo-google-fonts/jersey-10 registers
the face as Jersey10_400Regular, so asking for "Jersey 10" finds nothing even
after you have loaded the font perfectly. Only your app knows the real key, so
your app hands it over:
import { useFonts } from 'expo-font';
import { Jersey10_400Regular } from '@expo-google-fonts/jersey-10';
import { Anton_400Regular } from '@expo-google-fonts/anton';
import { registerFonts } from '@tomscaria/consumer-fintech-design-system';
const [fontsLoaded] = useFonts({ Jersey10_400Regular, Anton_400Regular });
registerFonts({
'Jersey 10': 'Jersey10_400Regular',
Anton: 'Anton_400Regular',
});What happens if you do nothing. theme.face(role) and
theme.text(role).fontFamily return undefined — the platform's own font, said
out loud — rather than a family name that would render the identical system face
while claiming to be the brand face. Under __DEV__ you also get one
console.warn per family naming the package, the export and the exact
registerFonts() call.
Two functions report on this, and they are not interchangeable.
unresolvedFontFamilies() is an accumulator of misses observed so far, so it
returns [] before any text has rendered — it is for a debug screen or a live log.
fontSetupSteps() returns the complete static instruction list, every external
family, unconditionally; that is the one to assert on if you want a smoke test that
fails on a missing registration. Nothing crashes and nothing is silent.
FONT_FACES, EXTERNAL_FAMILIES, nativeFontFace(family, weight) and
resolveFontFamily(family, weight) from /native/kernel are the machine-readable
form of all of the above.
The backtest chart pack
@tomscaria/consumer-fintech-design-system/backtest exports the same eight chart
names on both targets — EquityCurve, Underwater, MonthlyHeatmap,
RollingSharpe, MonthlyDistribution, AnnualReturns, MetricsTable,
WorstDrawdowns — and Metro resolves the React Native renderers automatically. Both
renderers stand on one platform-free geometry layer, so the charts cannot disagree
about where a mark goes.
The native charts are not shrunk desktop charts. They are laid out at the measured
point width rather than scaled inside a viewBox, they replace hover tooltips with
tap-to-inspect into a persistent readout, and two of the eight change SHAPE on a
phone rather than shrink: AnnualReturns rotates to one full-width row per year,
and MonthlyHeatmap trades its in-cell digits for tap-to-read swatches plus a
year-total column. Rendering a chart on native needs react-native-svg.
Per-chart reasoning and the gates are in
docs/RN-CHARTS.md.
Prop and value vocabulary
Twelve words for "the list", seven for "what is current", four size unions
resolving to four different heights, and three props that can carry an accessible
name. docs/VOCABULARY.md publishes all of it: the
collection/selection table, the resolved-height table per platform, the
cross-platform divergence table (three shipped doc comments cite "divergence 1" and
"divergence 3" by number, and this is the document they mean), and the rule that
--brand is not a data-mark colour — it computes identical to --fg in kiosk,
earth-light and revenant-light, so a price line drawn in it is body text.
Gates
Every gate below runs on npm run build or in CI, and each one exists because
something shipped past the others. scripts/verify-doc-contracts.mjs fails if this
table falls behind the scripts that exist.
| Gate | What it refuses to let through |
|---|---|
| verify-tokens.mjs | A hand-edited generated token file, a var() left in the React Native data target, a brand-IP leak into the tarball, a near-duplicate brand colour |
| verify-contrast.mjs | A foreground/background pairing the kit actually applies that falls below WCAG 2.1 AA, computed as arithmetic over the token data in every theme. No browser, no rendering |
| verify-css.mjs | A stylesheet this package ships that a real CSS parser refuses. patterns.css shipped a var() inside an :nth-child() argument for months — dead code that looked like a feature |
| verify-native-rules.mjs | A native renderer breaking the layering rules; a numeric column without an explicit width and alignment; a spec with no implementation |
| verify-native-resolution.mjs | A bundle where the react-native condition resolves to the DOM renderers, or the default condition resolves to the native ones |
| verify-package.mjs | A subpath in exports that does not resolve from a packed tarball |
| verify-ip-boundary.mjs | Confidential brand tokens reachable from a public entry point |
| verify-numeric-guards.mjs | A canSubmit-class predicate that returns true when an economic figure the machine derived is not a finite integer. Ships with the native-ergonomics pass |
| verify-vocabulary.mjs | bet / wager / stake / any inflection of win in shipped copy or an identifier. This is a finance surface, not a gambling one |
| verify-doc-blocks.mjs | A documentation code block that does not compile against the platform it claims — the gate that would have caught the 2.3.0 quickstart |
| verify-doc-contracts.mjs | A subpath, a gate, a money formatter or an exported component that the documentation does not mention; a generated table that has drifted |
Documentation
| Document | For |
|---|---|
| docs/PLATFORM-INVENTORY.md | Which symbols exist on which entry. Generated from the shipped .d.ts on every build |
| docs/BEHAVIOR.md | The nine platform-free machines, with web and native usage |
| docs/VOCABULARY.md | Prop names, value unions, resolved sizes, cross-platform divergences |
| docs/RN-ARCHITECTURE.md | The native layering rules and the reasoning behind each divergence |
| docs/RN-CHARTS.md | Per-chart native reasoning and gates |
| docs/MOBILE-PATTERNS.md | Screen-level patterns for a phone app |
| agent/components/*/**.spec.md | One agent-readable spec per component, with the exported symbol named in front matter |
Migration
Renaming from @tomscaria/scaria-design-system, and the lore-* / rolr-* themes
were renamed to earth-* / arcade-* in 2.0.0. The old data-theme="lore-*"
values still resolve via deprecated backward-compat aliases, so existing markup keeps
working. See MIGRATION.md.
License
MIT — © 2026 Scaria, Inc. Free to use in commercial and proprietary products; attribution required.
