PlatformKit Mobile is an Expo and React Native client for
PlatformKit. It signs in to a
running server, reads GET /api/v1/admin/resources, and derives list, detail,
create and edit screens from the resources returned for that session. The
server remains responsible for authorization and validation on every request.
This repository builds independently; it has no build-time dependency on the
Go repository or on private modules. The source is under the Apache License,
Version 2.0 (LICENSE, NOTICE), as PlatformKit is.
Install Git, the Node.js version in .nvmrc and npm, then run these commands from
this repository. npm ci installs the exact versions in package-lock.json:
npm ci
npm run check
EXPO_PUBLIC_API_URL=https://platformkit.example.com npm startReplace the example URL with the tenant's server origin, without an /api/v1
suffix. The sign-in screen also lets you enter or change the server URL. Use
an existing account on that tenant. To start a local reference server, follow
the PlatformKit quick start.
The device must be able to reach the server; localhost on a phone refers to
the phone itself. EXPO_PUBLIC_API_URL is bundled client configuration and
must contain no secret.
npm start starts Expo's development server. npm run android and
npm run ios build and run native projects using the corresponding local
platform toolchain. npm run web starts a browser preview, but the current
session transport is native: it reads Set-Cookie and sends Cookie
explicitly. Browser cookie restrictions make that preview unsuitable as proof
that sign-in works on a device.
app/_layout.tsx supplies the server URL and renderer pack to Shell, wraps
the screens in the theme that follows the device's appearance, and configures
the native stack whose headers every screen uses.
src/shell.tsx owns session restoration, sign-in, sign-out, catalog loading
and a count of what this app wrote to each resource, so a list or a detail
knows when what it shows is stale.
The resource route files delegate to src/route.tsx, which finds the catalog
entry and chooses a custom or generated screen.
src/core/ holds the catalog types and validator, screen derivation, the
pure value helpers (an instant, a number, what a form may refuse before the
server does) and the lifecycle reducer. These functions run in plain Node
without React.
src/effects/api.ts owns HTTP requests and API error decoding;
src/effects/session.ts orders secure-storage operations and binds the server
URL and session cookie in one versioned record. src/effects/native-session.ts
supplies Expo SecureStore. Each shell operation has a generation; the core
rejects obsolete responses after sign-out, another sign-in or a newer refresh.
src/ui/ is the component library, in one direction of imports that ESLint
enforces (eslint.config.mjs): atoms/ are single controls and words,
molecules/ are rows and fields, organisms/ are the generated list, detail,
form, home and sign-in as pure components of props, and templates/ own the
scroll, the large-title inset, the safe area and the keyboard. Nothing in
src/ui/ reaches for the shell, a transport or the router; effects arrive as
callbacks. src/ui/theme.tsx decides colours and faces from the device's
appearance; its palette, src/ui/tokens.ts, is generated by npm run tokens
from testdata/design-tokens.json, a checked-in
projection of the public repository's design export whose origin
testdata/design-tokens.source.json
records: the upstream commit, the command that projected it and the fixture's
SHA-256. Both files are build inputs the native fingerprint hashes. Refresh
them deliberately, as Verify a change describes; the token
tests fail when the fixture, its provenance and the generated file disagree.
Distances live in src/ui/scale.ts. The gallery
(src/ui/gallery.tsx, served by app/gallery.tsx in development or in a build
with EXPO_PUBLIC_GALLERY=1) shows every atom and molecule in both modes.
Keep UI imports pointed toward the primitives. Atoms may compose other atoms;
molecules add atoms; templates supply layout through children or render callbacks
and may compose atoms and molecules. Organisms combine these layers. Shared
theme, scale and token helpers import no components, and components never import
the gallery. ESLint checks these boundaries along with the UI's effect boundary;
tests/layers.test.ts exercises permitted composition and rejected imports.
src/screens/ is where an effect becomes a prop: one hook per generated
screen (useResourceList, useResourceDetail, useResourceForm) owns its
requests, its generation guard and its phase, and one composition per screen
sets the native header and renders the organism. A form is presented as a
sheet; Save exists only once the row has loaded; a dirty sheet asks before it
is dismissed. A select asks the platform's own chooser, an instant its own
date and time controls, and an instant reads in the device's zone, which is
the one place this shell deliberately reads differently from the web shell.
Only the current atomic session record is supported. Older installations require a fresh server selection and sign-in. Failed saves prevent sign-in completion. Sign-out immediately clears the active client; if secure storage cannot be cleared, the sign-in screen reports the failure and provides a retry. Until clearing succeeds, reopening the app may restore the previous saved record.
A custom screen belongs in the renderer pack passed to Shell in
app/_layout.tsx. The Renderers type maps module/entity
to optional list, detail, form and command components. Each receives an
entry and, when applicable, an id; command screens also receive the verb.
Any omitted component falls back to the generated screen. Custom components
belong in src/screens/, where effects become props for the atomic UI layers.
Add a custom screen when the workflow needs something the resource schema
cannot express.
Custom screens use api.request(path, options) from the current shell API for
module-owned JSON routes. path is an encoded path under /api/v1/, with an
optional query string; the selected server and session stay with the shell.
The method defaults to GET. Writes specify POST, PUT, PATCH or DELETE and may
include a JSON body; an omitted body sends no content. Responses are unknown
(or undefined for an empty body), so validate domain data in src/core/ before
turning it into UI props. The request uses the same cookie, deadline and
ApiError decoding as resource operations.
Pass an AbortSignal to cancel a screen's obsolete request; cancellation rejects
with AbortError. A timeout remains an ApiError with status 0. Neither the
transport nor cancellation proves that a write was rolled back, and the client
does not retry requests automatically. Recover uncertain writes through the
module's persisted read contract before offering another submission. Screen
generation guards still decide whether a response belongs to the current view.
package.json exposes the existing implementation through package
subpaths: platformkit-mobile/shell, renderers, route, effects/api,
core/catalog, core/derive, screens/<component> and
ui/{atoms,molecules,organisms,templates}/<component>. Theme and scale come from
platformkit-mobile/ui/theme and platformkit-mobile/ui/scale. These exports
resolve directly to the TypeScript source used by this reference application.
The consuming Expo application supplies its routes, identity, renderer pack and
product screens, and composes one shared Shell and ThemeProvider.
platformkit-mobile/screens/SignIn takes an optional title, the product's
name over the form, and draws the shell's phases itself: a spinner while a
saved sign-in is restored, and, when a saved sign-in could not be opened, the
reason with Retry and a way to another server. A product that composes its
own sign-in screen gets the same from SignInForm through title, booting,
failed and onRetry; without them it renders the form as before.
Pass palette to ThemeProvider to use the application's complete light and
dark palettes. Pass fonts for native display, body and mono faces already
available on the device; load bundled fonts before rendering the provider.
An undefined face selects the system font. Omitted props use the reference
palette and platform faces. Changing either prop updates the shared components;
keep their objects stable between changes. The provider's mode prop still
overrides the device appearance when the product requires it.
Keep the application's design export in testdata/design-tokens.json. Reuse
render from platformkit-mobile/tools/tokens in its scripts/tokens.ts:
import { readFileSync, writeFileSync } from "node:fs";
import { render } from "platformkit-mobile/tools/tokens";
const document: unknown = JSON.parse(readFileSync("testdata/design-tokens.json", "utf8"));
writeFileSync("src/ui/tokens.ts", render(document));Run that script with the application's locked tsx tool and @types/node
development dependency. Include "types": ["node"] in the script's TypeScript
configuration. Import palette from the generated src/ui/tokens.ts in the
composition root. Type checking
against the provider requires every shared color role in both modes. CSS font
stacks in the export are data; the application supplies their resolved native
faces through fonts. The generator runs during development and verification.
Native runtime code consumes its generated output; ESLint rejects imports of
the build tool or Node APIs into runtime layers.
The application owns its native dependencies and lockfile. Declare the native packages directly in that application at the versions used here so autolinking finds them and React, the router and their contexts have one installed instance. Pin the shared package to an exact published release or an HTTPS source archive whose URL names a full published commit ID. Commit the resulting application lockfile, including the resolved URL and integrity hash; branch archives and local checkout paths do not identify a fixed dependency.
No npm registry release is published; private: true remains set.
For local review, npm pack --ignore-scripts --pack-destination /tmp creates a
source dependency archive. Its contents are src/, scripts/tokens.ts, the
shared scripts/android/ recipe, LICENSE, NOTICE and npm's package
metadata and documentation.
It contains the shared implementation, including generated design tokens. The
reference application's routes, Docker setup and test tooling belong to the
complete application handoff below. npm run check packs the dependency
in a removed temporary directory, verifies public imports from the extracted
package and checks that its relative source dependencies are present. Consumer
type checking, bundling and device journeys remain separate verification steps.
The exported platformkit-mobile/tools/android entry is the same Bash recipe
used by this repository. After installing the consuming app's locked
dependencies, run this from that app's root in the Android build environment:
bash "$(node -p "require.resolve('platformkit-mobile/tools/android')")" "$PWD"The target needs package.json, package-lock.json and an Expo app
configuration. The recipe runs that app's installed Expo CLI and uses Gradle
settings and signing rules from this versioned dependency. It never copies the
reference app's routes or identity. The optional application argument defaults
to the reference app when running its own scripts/android/build.sh.
This command regenerates the target's android/ with expo prebuild --clean,
then builds the Gradle tasks selected by TARGETS. Keep native configuration in
the app config and config plugins. Build a source export outside the development
checkout to leave that checkout's generated native files untouched.
The SDK, JDK, native npm dependencies and Gradle downloads are still build
prerequisites; the pinned image under Build a binary supplies
the toolchain. ABIS, PK_PROFILE, PK_VERSION and signing variables retain
the meanings documented below, with app identity resolved by the target's own
configuration. Outputs stay under the target's
android/app/build/outputs/. A successful build still needs device verification.
The application owner commits its identity in app.config.ts, its screens
through the renderer pack in app/_layout.tsx, and its design export in
testdata/design-tokens.json. Keep native dependencies in this application's
package.json and lockfile. Custom screens use src/screens/, domain decisions
use src/core/, HTTP uses src/effects/, and presentation composes the existing
atomic layers in src/ui/. A branded shell still needs its product workflows
and device verification before it is a complete application.
After npm run check passes, commit the application and export its full commit
ID. Run this from the repository root; the destination's parent must exist and
the destination must be new and outside the workspace:
git rev-parse HEAD
npm run source -- --revision <full-commit-id> --output /tmp/mobile-sourcescripts/source.ts reads the committed files, preserving
their executable modes. It includes routes, source, assets, tests, the lockfile,
native fingerprint and build recipe. Hosting workflows are excluded. Local
dependencies, workspace links, symlinks and tracked local configuration cause
an error; working-tree edits, ignored files and installed dependencies are
never copied. npm run check:source, part of npm run check, applies the
same manifest and lockfile rules to the working tree without exporting, so a
local dependency or an unpinned package is refused on every check rather than
at the handoff. SOURCE.json records the source revision and each delivered
file's SHA-256, byte count and mode. It proves provenance, not publication or
runtime behavior. Verify that revision is published before distributing a
release, and retain the receipt when you archive the exported directory.
From the exported directory, install and verify without the original checkout:
npm ci
npm run check
npm run export:checkInstallation downloads the locked npm packages; native builds also need the
platform toolchain and its dependencies. The export is source, not an offline
dependency cache or a backend server. Both bundle exports must compile; use
the binary recipe below and the device flows to verify the
application's actual journeys. A recipient can edit the exported files directly.
When importing into its own repository, retain the received receipt as
UPSTREAM.json before committing: SOURCE.json is reserved for the next export.
The Android build is one recipe, scripts/android/build.sh, run inside the
pinned build image by Dockerfile.android, so neither a runner nor a
developer machine needs the SDK:
make apk # unsigned arm64 release APK in out/
make apk ABIS=x86_64 PROFILE=ci # the verification build an emulator runs
make aab # unsigned AAB and universal APK, all ABIs
make apk-mirror # the same recipe under the CI runner's 2 CPU / 4 GiBapp.config.ts decides the identity: PK_VERSION=v0.2.0 gives version
0.2.0 and a version code that only grows; PK_PROFILE=ci builds
PlatformKit CI under its own package, with the gallery route, so it can sit
beside a real install. Release builds are unsigned by the recipe
(scripts/android/signing.gradle); make apk-debug-sign signs one with the
SDK's debug key for an emulator, and make sign signs the release outputs
with a keystore named by PK_KEYSTORE and its three companions, verifies the
certificates and writes SHA256SUMS. The key never leaves the machine that
holds it. iOS is built on a Mac with npx expo prebuild --platform ios and
npx expo run:ios --configuration Release; there is no iOS build in CI.
The android workflow builds the verification APK whenever a native input
changed (the fingerprint, the configuration, the lockfile, the recipe) and
keeps it as a run artifact for two weeks. It needs a verification environment
with about 6 GiB of memory: one architecture, built serially with the settings
in scripts/android/, peaks at 4.1 GiB by itself, and a job shares its
allowance with the builder, which is where 4 GiB failed. At 6 GiB a cold job
builds in about seven and a half minutes. The job runs only when the
repository variable ANDROID_BUILD is on, so an environment without the
memory reports nothing rather than failing; make apk builds the same thing
locally either way.
The repository CI runs npm run check on pull requests and main pushes.
npm run check runs eight gates in order, and stops at the first that fails:
check:sdk (EXPO_OFFLINE=1 expo install --check: every installed native
package against the ranges the pinned expo bundles, read from
node_modules/expo/bundledNativeModules.json), typecheck (TypeScript),
lint (ESLint, including the layer
rules), format:check (Prettier), test (the Node suite, tsx --test tests/*.test.ts, then the Jest suite, every tests/**/*.test.tsx rendering
components, the shell, the route dispatcher and the screen hooks),
check:fingerprint, check:flows (every id a device flow names is a testID a
component sets) and check:source (the manifest and lockfile install from the
registry alone). fingerprint.json is the hash of everything
a binary is built from: the app configuration, the native modules in the
lockfile and their config plugins, the Android recipe, the bundler
configuration and the design export. When a change moves it, run
npm run fingerprint and say why in the commit, because that change needs a
new binary. CI also exports both bundles, fails on a high or critical dependency
advisory, and scans the history for secrets; a weekly workflow reports what
drifted without blocking anything. Node is pinned once, in .nvmrc.
Use expo install for native dependencies so they match that SDK. The app owns
the native font, module core, Reanimated and Worklets dependencies used by its
router and tests; their SDK-compatible versions must resolve once at the app
root. Commit the lockfile with every dependency change.
The SDK gate is deliberately offline. expo install --check otherwise asks
Expo's live version service, so a patch published upstream turns an unchanged
commit red: that happened twice in one week and is somebody else's release, not
a defect here. With EXPO_OFFLINE=1 the same command compares the installed
packages against bundledNativeModules.json inside the pinned expo, a file
npm ci reproduces byte for byte, so the gate answers the same for a given
tree on any machine and any day. It still refuses a package that does not match
the pinned SDK, which is what it is for. Only expo itself is outside that
manifest, because it is the pin everything else is compared against.
Asking what Expo now recommends is an owner's action, not a gate:
npm run sdk:latest # the online check: what upstream would change
npx expo install --fix # take it, then commit package.json and the lockfile
npm run fingerprint # a native pin moved, so the binary identity movesDo that deliberately, in its own commit, and say in the commit why the native
project changed. The weekly drift workflow runs the online check too and
reports what it finds without blocking anything.
Starting Expo generates route types under the ignored .expo/ directory,
which TypeScript also checks.
The individual commands are in package.json.
npm run format formats TypeScript in app/, src/ and tests/.
The Node tests cover catalog parsing, screen derivation, the token
generator and its provenance, the distance guard, out-of-order lifecycle
events, interrupted storage writes, HTTP behavior with supplied effects and the
source gates. The Jest tests (npm run test:ui, the Expo preset and React
Native Testing Library) render the atoms, molecules and organisms with sample
props and assert what a screen reader would find (tests/ui/), drive the
shell over an in-memory secure store and a fake fetch (tests/shell.test.tsx),
the route dispatcher over a mocked router (tests/route.test.tsx) and the
screen hooks over a fake Api (tests/screens/). Neither suite launches Expo,
exercises a native device or connects to a live server. For a screen or session
change, also exercise the affected journey on the target platform and report
what you ran.
testdata/catalog.json is a checked-in copy of
PlatformKit's ui/screens/testdata/catalog.json, and
testdata/catalog.source.json records the commit it
was copied from, in the same shape the design-token provenance uses. The suite
checks the bytes against that record offline, so editing the copy to make a test
pass fails instead of fixing anything. npx tsx scripts/catalog.ts refresh rewrites
both files; the pin itself moves only by editing the recorded commit, which is a
diff somebody reviews. What no local check can see is whether the server has moved,
so the contract-drift schedule asks the public repository and the module proxy and
reports without blocking, for the reason at the head of
.gitea/workflows/drift.yml. A proxy it cannot reach is
reported as unknown and never as agreement: the first version of
scripts/catalog.ts asked the proxy for septagon-oss/platformkit instead of
github.com/septagon-oss/platformkit, took a 404, and printed "ok, this is the latest
version".
Unlike the design tokens, this fixture is a test input and not a build input — no app
code reads it, and scripts/fingerprint.ts names its sources rather than sweeping
testdata/, so refreshing the copy is not a binary change and the android
workflow has nothing to rebuild. A stale copy blinds the suite and the review; it
cannot reach the person. What can reach them is a server answering with a shape this
build has never seen, so parseCatalog refuses a catalogVersion newer than
SUPPORTED_CATALOG_VERSION in src/core/catalog.ts rather than
drawing a screen from the fields it happens to recognise; a server old enough not to
stamp is the server this shell was built against, and is accepted.
testdata/design-tokens.json is the palette's
source and a build input: the native fingerprint hashes it, so a token change
is a binary change the android workflow rebuilds for. To refresh it, project
the export at a published PlatformKit commit, record that commit, then
regenerate the palette and the fingerprint. From that repository's root and
this one:
go run ./tools/designexport | jq '{schema, themes}' > "$MOBILE/testdata/design-tokens.json"
git rev-parse HEAD # the commit to record
npm run tokens -- --upstream <full-commit-id> # writes design-tokens.source.json and src/ui/tokens.ts
npm run fingerprintWithout --upstream, npm run tokens regenerates the palette only, and
refuses when the fixture is no longer the export its provenance describes.
Commit the fixture, its provenance, the generated palette and the fingerprint
together, and say in the commit which commit the export came from and what
changed in the palette. Read AGENTS.md before contributing.