@duabalabs/rn-ui
v0.1.1
Published
Shared React Native UI foundation for Duabalabs apps: token contract, themed components and auth flows.
Readme
@duabalabs/rn-ui
Shared React Native UI foundation for Duabalabs apps.
Scope, decided deliberately: DuabaConnect and apps built after it. gob3-app
and truthora/app are out of scope — they are on different styling stacks
(StyleSheet.create and Ignite ThemedStyle) and migrating them costs more than
it returns. That is a decision, not a backlog item; please don't relitigate it
without a new reason.
Status: v0.1 — theme, components, auth and shared pages. Proven by DuabaConnect, which consumes all of it.
Published on npm under @duabalabs/, like the other nine packages.
In use by DuabaConnect.
What's here
| | |
|---|---|
| theme/ | |
| tokens.ts | The token contract — 34 semantic colour roles |
| contrast.ts | WCAG maths and auditTheme |
| defaults.ts | Neutral, brand-free base palette |
| define-theme.ts | Brand spec → complete audited theme |
| store.ts | Theme preference state (the only resolver of "system") |
| provider.tsx | ThemeProvider — injects values, owns the scheme |
| components/ | Text · Heading · Button · Input · PasswordInput · FormField · Pressable |
| auth/ | AuthProvider + AuthAdapter, and four screens: sign-in, sign-up, forgot-password, reset-password |
| shell/ | decideDestination / useSessionDestination, useWhenAllReady, navigationTheme |
| settings/ | SettingsSection · Row · Switch · Segmented · Busy · AppearanceSection |
| screens/ | OnboardingScreen · EmptyState · NotificationCard |
| tva.ts | tailwind-merge fix for the role-named type scale |
| tailwind-preset.js | withRnUi() — class names, type scale, content glob |
What is deliberately not here
- Navigation. Screens take callbacks, never a router. Apps standardise on one router for their own reasons; a UI package that imports one pins every consumer to its version.
- Sessions. The auth adapter has no tokens, no storage, no current user. The app owns the session because the app is what needs it afterwards. That split is why adopting these screens does not mean adopting an auth store.
- Profile editing and account deletion. Shaped entirely by the backend. The settings scaffolding makes them short to write per app, which is the useful half.
- Domain components. Channel avatars, network badges, calendar views. The bar for adding a category beyond the six above should be high.
Install
npm install @duabalabs/rn-ui// tailwind.config.js
const { withRnUi } = require('@duabalabs/rn-ui/tailwind-preset');
module.exports = withRnUi({
presets: [require('nativewind/preset')],
content: ['./app/**/*.{tsx,ts}', './components/**/*.{tsx,ts}'],
});Wrap the config — don't just add the preset. Tailwind's resolveConfig
merges theme and plugins from a preset but drops its content
(verified against tailwind 3.4). So a preset cannot make Tailwind scan this
package's own files, and every class inside it renders unstyled with no error.
withRnUi() appends the glob to the app's config, where it survives.
// babel.config.js — required, see src/tva.ts
alias: { '@gluestack-ui/nativewind-utils/tva': '@duabalabs/rn-ui/tva' }That is all of it. A registry install is a plain directory under node_modules,
so the package resolves react, react-native and nativewind to the app's own
copies — no Metro configuration, no tsconfig paths.
The source lives at apps/duabaforge/packages/rn-ui, beside the product that
owns it, the same shape as apps/sellub/packages/*.
Testing an unpublished change
Link it, and expect three things the registry path does not need:
// package.json
"@duabalabs/rn-ui": "file:../../duabaforge/packages/rn-ui"// metro.config.js — a `file:` dep is a symlink Metro will not otherwise follow,
// and without nodeModulesPaths the package can resolve a second React.
config.watchFolders = [path.resolve(__dirname, '../../duabaforge/packages/rn-ui')];
config.resolver.nodeModulesPaths = [path.resolve(__dirname, 'node_modules')];
config.resolver.unstable_enableSymlinks = true;// tsconfig.json — a symlinked package resolves a SECOND copy of react-native's
// types, and NativeWind's className support is a `declare module`, which
// attaches to one module identity. Without these, every className inside the
// package fails to typecheck while the app's compile fine. Do NOT make it a
// wildcard: mapping `react` the same way bypasses @types/react and breaks all JSX.
"react-native": ["./node_modules/react-native"],
"nativewind": ["./node_modules/nativewind"]A link also inflates the bundle, because the linked package's own
node_modules contributes duplicate modules — worth ~2 MB in DuabaConnect.
Unlink before measuring anything.
This package ships TypeScript source, not a build. NativeWind resolves
className at build time in the consuming app, so a compiled dist/ would
ship components whose className props do nothing. That is the exact failure
that killed the sixteen @duabalabs/sellub-vendure-* packages — "source-only
mirrors that never worked when consumed from npm". Don't add a build step.
Defining a theme
import { defineTheme, ThemeProvider, configureThemeStore } from '@duabalabs/rn-ui';
import * as SecureStore from 'expo-secure-store';
export const theme = defineTheme({
accent: '#612BD3',
accentSecondary: '#C22FC6',
dark: { bg: '#0E0E0E', surface: '#1A1919' },
extend: { slotDayBg: { light: '#F4F4F6', dark: '#212020' } },
});
configureThemeStore({
storage: SecureStore, // any { getItem, setItem }
storageKey: 'myapp.theme',
systemFallback: 'dark', // dark-first apps
});Supply an accent; the rest falls out of the defaults. Anything you set explicitly is never second-guessed by a derivation.
extend tokens become CSS variables and Tailwind classes exactly like contract
tokens — they just aren't referenced by shared components or checked by the
audit. Anything app-specific (calendar slots, tab chrome, brand marks) goes
there rather than into the contract.
The audit
import { auditTheme, formatFindings, hasErrors } from '@duabalabs/rn-ui';
const { light, dark } = auditTheme(theme);
if (hasErrors(light)) throw new Error(formatFindings(light, 'light'));Put this in a test. It is the reason the contract exists: DuabaConnect shipped nine failing pairs that all looked fine by eye, including body text at 3.9:1 and an accent used as link text at 2.57:1.
Two rules worth knowing:
- Foregrounds are measured against the hardest surface they can land on, not
a nominal background. Passing on white and failing on the grey card is a
failure. Writing the neutral defaults, this caught
textTertiaryat 4.38:1 onsurface2— values carried over from a palette that had already been hand-audited once. errorblocks,warninforms. Text, icons and button labels are errors. Hairlines, field outlines and disabled labels are warnings, because a 3.0:1 divider is a visible bar and no design system complies. A noisy audit is an ignored audit.
Why accent and textAccent are separate
A brand colour chosen to carry white text as a button fill is almost never
readable as text on a dark background. #612BD3 is 7.51:1 as a fill and
2.57:1 as dark-mode link text. One value cannot serve both roles.
defineTheme derives textAccent by walking lightness until it clears 4.5:1
against that mode's actual surfaces, scaling saturation as it goes so the hue
survives and neutrals stay neutral. From #612BD3 it produces #9D77ED —
within a few percent of the value that was reached by hand.
Development
npm install
npm run typecheck
npm testThe peer deps are also devDeps, pinned to exactly the versions DuabaConnect
runs. That is not redundancy: a file: dependency is a symlink, and TypeScript
resolves a symlinked package's imports from its REAL location — so without its
own node_modules the package sees no react at all, every import degrades to
any, and the consuming app inherits hundreds of implicit-any errors. Metro is
told to prefer the app's copies at runtime via resolver.nodeModulesPaths.
Tests are plain assertions under sucrase-node — no runner, no React Native
environment. That is why they import only the pure modules (tokens,
contrast, defaults, define-theme) and never provider or vars.
Publishing
Public npm, @duabalabs/ scope, one GitHub repo per package — the convention in
PACKAGES.md. prepublishOnly runs typecheck, tests and the
packaging check, so a broken package cannot go out.
npm run verify-packageThis packs a real tarball, extracts it somewhere unrelated to this working tree,
and checks the things that only break once the package is consumed from a
registry instead of a symlink: that every exports entry actually shipped,
that no dist/ snuck in, that the Tailwind content glob still resolves after
being moved, that nothing under src/ reaches for a Node builtin Metro cannot
bundle, and that no peer dependency got published inside the tarball.
It exists because deferring the first publish defers discovering all of that.
Sixteen @duabalabs/sellub-vendure-* packages were deprecated and deleted for
being "source-only mirrors that never worked when consumed from npm" — every one
of them looked fine locally right up to the end. Run this on every change and
the wait costs nothing.
It does not replace a real install, and 0.1.0 proved it: the first registry
install threw ERR_PACKAGE_PATH_NOT_EXPORTED because the exports map made
package.json itself unreachable, which plenty of tooling reads. Fixed in
0.1.1, and now one of the checks. For anything structural, still do one
end-to-end pass:
npm pack --pack-destination /tmp
cd ../../../duabaconnect/duabaconnect-mobile
npm install /tmp/duabalabs-rn-ui-<version>.tgz
npx tsc --noEmit && npx expo export --platform ios
npm install @duabalabs/rn-ui@latest # restoreConventions this package must keep
- Tokens are named for roles, never colours.
accent, notpurple. The moment a token is named after its value it stops being a contract. - Nothing under
src/may import a Node builtin. Metro bundles this code;tailwind-preset.jsruns in Node and is the only file allowedpath. The type scale lives insrc/theme/type-scale.jsprecisely so both worlds can reach it. - Screens never navigate. They take callbacks and return decisions. The one exception is documented where it occurs: reset-password navigates because resetting creates no session, so no guard takes over.
"system"is resolved in exactly one place —store.ts. DuabaConnect shipped a build that rendered perfectly and was completely invisible because two resolvers disagreed about Android's"unspecified"colour scheme. The provider consumesresolved, neverpreference.- The preset's
TOKENSlist andTOKEN_NAMESmust match. They are separate because the Tailwind CLI can't read TypeScript;test/theme.test.tsfails if they drift. - Consumers wrap with
withRnUi(). A bare preset cannot contributecontent, and the symptom of getting this wrong is silent: unstyled components, no error anywhere.
