An assistant building a phone app on Harakumo follows this standard by default, as it follows the Build standard for a website. The AI connector's get_build_standard and get_starter_files with platform 'mobile' give it this standard, the extras for the kind of app, and the Expo starter kit; check_launch with platform 'mobile' checks the app's config before it goes to the stores.
Harakumo is the app's backend and its companion website: the app ships through the App Store and Google Play, built with Expo (see Use Harakumo from a phone app). The last sections say what the starter kit does on Harakumo today, and what is not on Harakumo yet.
Stack
- Expo with Expo Router and TypeScript, shipped through the App Store and Google Play. Checked 2026-10-04: Expo SDK 57, React Native 0.86, iOS 16.4+ and Android 7+, target API 36. Use the latest stable SDK.
- The backend is one Harakumo project per app: its edge program is the app's API under /v1/, its store pages and its deep-link files, and it holds every secret. The app bundle knows only EXPO_PUBLIC_API_URL.
How to work
- Decide the platform first. It is a phone app when the idea says: app store, play store, Google Play, iPhone, iPad, Android, iOS, phone app, mobile app, Expo, React Native, Flutter, SwiftUI, Kotlin, TestFlight or APK. 'Mobile-friendly website' is a website. A bare 'app' with no other hint: ask 'A website, a phone app, or both?'. Build both only when the person asks for both. Why: A phone app and a website have different meanings of done. A web page in a wrapper is rejected by both stores. Check: get_build_standard returns platform 'mobile'. The plan names iOS, Android or both. On Harakumo: get_build_standard picks the platform from the description when none is given.
- Know how you are working. With a terminal: run Expo, simulators and EAS yourself. Chat-only connector: put the app source in a Harakumo repo or the starter tgz, deploy only the api folder, give the owner a short checklist to build and submit with EAS. Never deploy the Expo folder as a site. Why: A chat-only assistant can't build a phone app. An Expo folder deployed as a site turns into a broken server app. Check: The api folder has worker.js at its root, and only that folder was deployed. On Harakumo: push_files, get_starter_files, then deploy_files on the api folder. A deploy of the Expo app folder itself is refused, with a note that phone apps ship through the stores.
- Plan before writing code, in a few lines: who uses the app and the app type; the style preset, then the tabs and each screen with its archetype; the data (tables and fields) and the roles; what is pushed and what is emailed; each paid item's payment class; which Harakumo service each part uses. Ask at most three short questions when something essential is unclear. Otherwise write down your assumptions and go on. Why: Generated apps lose track of shared state and screens after the first five. Check: The plan lists every tab, every screen with its archetype and data source, and every paid item's payment class, before the first file is written.
- Make it this business's app. The starter is scaffolding, never the product. Each app gets its own brand, icon, copy, screenshots, and at least one feature built for this business. Remove the starter's sample names, strings and comments. A business with several locations gets one app with a location picker, never one app per branch. The owner submits from their own developer account. Harakumo never submits for them. Why: Apple rejects apps that look like other apps (4.3 spam) or that come out of a template or app generator (4.2.6). Check: check_launch with appSource finds no starter default color, onboarding text or empty-state text left unchanged.
- Create the app icon and launch screen first, before any screen (see 'First run'). Why: They are what assistants forget most, and no build can be submitted without them. Check: The expo config has icon, ios.icon, android.adaptiveIcon and the expo-splash-screen plugin before the first screen file exists.
- Set up the backend in one call: create_project, then setup_app_backend with the app type and platform 'mobile'. Then deploy the api folder from the starter kit, so every key stays on the server. Confirm the workspace is on a paid plan before store review. Why: A phone app can't keep secrets, so its server side must exist before any screen calls it. Hobby stops at its limits, and a stopped backend fails review. Check: list_env_vars shows HARAKUMO_API_KEY, HARAKUMO_AUTH_* and APP_SESSION_SECRET. GET https://<slug>.harakumo.app/v1/app-config answers 200. check_launch reports no plan-room warnings. On Harakumo: setup_app_backend (platform 'mobile'), deploy_files.
- Start from get_starter_files with platform 'mobile'. Fetch the index first, then each part: shell, kit, auth, states, settings, push, api, store-kit. Don't write these from memory. Why: The kit already passes the store rules and this standard, and fetching part by part keeps each tool result small. Check: The app folder has src/components/PasswordInput.tsx and src/app/(tabs)/settings/delete-account.tsx. The api folder has worker.js. On Harakumo: get_starter_files (platform 'mobile', part).
- Build every item of this standard without being asked. Then propose what the app clearly needs and wasn't asked for, and build it: push for status changes, email receipts, an admin dashboard whenever the app has users or orders. Why: Nobody should have to ask for an eye button, an offline state or a delete-account screen. Check: Walk the Done checklist at the end.
- Real data only. Never ship mock data, lorem ipsum, 'coming soon' tabs, dead buttons or links that go nowhere. Seed two separate data sets: a store-review demo account with realistic data; a worst-case set for testing: 40-character names, 7-digit prices, missing images, emoji, and a right-to-left sample. Why: Placeholder content and a backend that is switched off cause most app-completeness rejections (Apple 2.1). Worst-case data is where generated screens fall apart. Check: A search for 'lorem', 'TODO', 'mock', 'coming soon' and 'example.com' in the app finds nothing. Every button has a handler.
- Do a visual pass before calling the app done. Screenshot every screen: on an iOS simulator and an Android emulator; on the smallest and largest phones, and the iPhone build on an iPad; in light and dark mode; at the largest text size; with worst-case data and with a brand-new empty account. Review each shot: margins line up and spacing follows the scale; nothing is cut off or hidden under a bar; icons share one weight and contrast holds. Fix every issue. Why: Misaligned margins, cut-off labels and overlapping bars only show up when someone looks. Searching the code can't find them. Check: The shots are saved under qa/, and qa/visual-pass.md lists each issue and its fix.
- Test every main flow on a development build in these conditions: a small phone, a large phone, and an iPad running the iPhone build; light and dark mode; the largest text and display size; airplane mode and a slow network; every permission denied; gesture navigation and Android 3-button navigation. Why: Reviewers test iPhone-only apps on iPads (Apple 2.4.1), and real users hit these cases first. Check: The README's test section records which checks passed on iOS and on Android.
- After every deploy of the app's API, run check_launch with platform 'mobile' and the output of
npx expo config --json --type introspect. Fix every 'must' before saying the app is done. Why: It catches missing store pages, broken deep-link files, permission strings added by plugins, and missing settings before a reviewer does. Check: check_launch reports mustFix 0. On Harakumo: check_launch (platform 'mobile', appConfig, optional appSource).
First run: icon, launch screen, onboarding and permissions
- App icon. iOS: prefer an Icon Composer file with default, dark, clear and tinted variants; Otherwise use a 1024×1024 PNG with no alpha channel and no rounded corners, plus dark and tinted variants. Only the main icon must be a full square; dark has a transparent background and tinted is grayscale; Android: an adaptive icon, with a 108 dp foreground whose mark sits inside the central 66 dp, a background color, and a monochrome layer. Why: Any alpha in the main PNG fails upload (ITMS-90717). Android crops icons that aren't adaptive, and themed icons look broken without a monochrome layer. Check: Each config has the right icon field set: ios.icon (an .icon path or {light, dark, tinted}) and android.adaptiveIcon with foregroundImage, monochromeImage and backgroundColor. The main PNG has no alpha. On Harakumo: The starter kit ships the SVG layers and
npm run icons, which renders the PNGs and flattens the main icon's alpha. - No logo given? Build the mark from the app type's symbol (leaf, calendar check, dumbbell, ticket), alone or with the initial. Use a subtle two-stop gradient of the brand color, with the glyph at about 60% of the canvas. Keep the foreground and background as separate layers. Show the owner three options before building. Never use SF Symbols in the icon itself. Why: A flat letter on a flat color looks unfinished next to real apps. Separate layers feed the glass, dark, tinted and adaptive variants. Check: assets/brand has separate foreground and background SVGs, and the owner picked one of three options.
- Home-screen name: at most 12 characters. The full name, up to 30 characters, lives only in the store listing. Why: The label under the icon is cut off at about 12 characters. Check: check_launch with appConfig warns above 12 characters and fails above 15.
- Launch screen: the expo-splash-screen plugin. The mark is a 1024×1024 transparent PNG, imageWidth 200. On Android, the mark fits inside the inner circle: 192 dp across within the 288 dp icon area. The brand background, with a dark variant. No text, slogan or ads. Keep it up with preventAutoHideAsync until fonts and the saved session load. Then fade it out with SplashScreen.setOptions({ fade: true }) and hideAsync. Why: Both platforms show the launch screen while the app starts, and Android 12+ always draws the icon, masked to a circle, so a wide logo gets cut. Holding the launch screen until the session loads stops a signed-in person from seeing the sign-in screen flash. Check: The plugin config has image, backgroundColor and dark. The root _layout calls hideAsync after the session check. Test a cold start in a preview build.
- Onboarding: shown on first launch only, never again once skipped or finished. At most three slides, each showing what the person gets: a crop of the real app or a symbol composition. A headline of at most 6 words and at most one line of body text. Page dots, Skip at the top right, and the main button at the bottom. No auto-advance. The last button leads to the app ('Get started'), not to a sign-up wall. Why: Apple's guidance is that onboarding is fast and optional. Long tours make people uninstall, and auto-advance loses screen-reader users. Check: An onboarding-done flag is stored. Every slide has Skip. A second launch skips onboarding.
- Design the first day, not only the demo account. Content, community and fitness apps get a personalize step: pick interests or goals, follow suggestions. Home shows a setup checklist card until it's done ('Add your first expense'). Every empty state has a symbol illustration and one main action. Why: A brand-new account that is empty everywhere feels broken. Check: The visual pass includes a brand-new account.
- Ask for camera, photos, location, microphone, contacts, calendar or notifications only when a feature needs them, never at launch. Before the system prompt, show one screen that explains the benefit, with a single 'Continue' button. No cancel and no reward. After a denial, the rest of the app still works, and the feature shows 'Turn on in Settings' (Linking.openSettings()). AI data consent is different: it needs a real 'Not now' (see Settings). Why: Apple forbids pressuring people into consent (5.1.1(iv)). Play requires a prominent disclosure first. iOS asks only once. Check: No request*PermissionsAsync call in the root layout, app start or onboarding. Every request goes through PermissionPrimer.
- Purpose sentences and unused permissions: Every permission the app uses has a purpose sentence that names the feature and ends with a period: 'Fruitly uses your camera to scan product barcodes.'. Override every default string that a plugin adds. Turn off what plugins add but the app doesn't use: expo-image-picker cameraPermission: false and microphonePermission: false; no 'Always' location strings unless the app needs background location. Block unused Android permissions (android.blockedPermissions). Why: Plugin defaults such as 'Allow $(PRODUCT_NAME) to access your camera' get rejected (5.1.1(ii)). A missing key crashes iOS. Unused Android permissions trigger Play declarations. Check: Run on the introspected config, not the raw app.json: each NS…UsageDescription has at least 30 characters, ends with '.', and names the app or feature; the merged Android permission list matches the features used.
- Pick photos with the system photo picker (expo-image-picker), which needs no library permission. Ask for full library access only in a gallery app. Why: Google Play requires the photo picker for apps that aren't galleries. On iOS the picker needs no permission. Check: No READ_MEDIA_IMAGES or READ_MEDIA_VIDEO in the merged manifest unless the app is a gallery.
- Calendar: add events through the system add-event sheet, which needs no permission, or with write-only access. Ask for full access only when the app reads the calendar. Why: Full calendar access just to add one event asks for more than the app needs (Apple 5.1.1(iii)). Check: No NSCalendarsFullAccessUsageDescription unless the app reads events.
- Ask for a rating only after a real success (third completed order, a finished workout), at most once per version, with expo-store-review. No 'Enjoying the app?' question before it. Never reward a rating. Never ask after an error. Why: Custom review prompts (Apple 5.6.1) and rating gating or incentives (Play policy) are forbidden. Check: requestReview is called only from a success handler, behind a counter, with no dialog before it.
Sign-in and accounts
- The first screen for a signed-out person is a welcome screen: the brand mark and a one-line value statement; 'Create account' as the main button and 'Sign in' as the second; 'Continue as guest' when browsing is allowed. Once social sign-in exists, those buttons go above email, all the same size, with Apple first on iOS. Why: Opening straight into an email form looks unfinished. Check: src/app/(auth)/welcome.tsx exists and is the signed-out entry.
- Delay sign-in. Let people browse or try the app first when it can work that way. Ask for an account when one is needed (to save, buy, book or post), then return them to what they were doing. Why: Apple's account guidance. A sign-in wall on first launch loses most new users. Check: The first tab renders signed out, unless the app is private by nature (internal tool, finance, health).
- Use the project's sign-in pool through the app's own API. The app posts to {API}/v1/auth/<action>, and the API calls the pool with the client secret. Why: The pool already handles password hashing, codes and tokens. Going through the API keeps the secret off the phone and avoids per-IP limits on carrier networks. Trade-off: until the platform takes the end user's address from the API, that path has no per-IP abuse limit, so the API limits sign-ups and codes itself. Check: The app folder never contains the client secret. Every sign-in request goes to EXPO_PUBLIC_API_URL. On Harakumo: POST https://harakumo.com/api/auth-pools/<poolId> with Bearer HARAKUMO_AUTH_CLIENT_SECRET.
- Sign-up asks only for what the app needs. Default: full name, email, password and confirm password. Add a username only when users see each other, and a phone number only when the app texts or calls. Show the password rule up front ('At least 8 characters') with a strength hint. Show 'By continuing you agree to the Terms and Privacy Policy', with links. Marketing email is a separate switch, off by default. Why: Every extra field loses sign-ups. Hidden password rules cause repeated errors. Check: The sign-up screen has exactly the fields in the plan, the visible rule text, and the Terms line.
- Every password field has a show/hide eye button: an IconButton from the kit with accessibilityRole 'button'; accessibilityLabel 'Show password' or 'Hide password'; a target of at least 44×44; it toggles secureTextEntry. Confirm password must match before submitting, with 'Passwords do not match.' under the field. Why: Typing a password on a phone keyboard is error-prone. Builders asked for this on every project. Check: Every password field uses PasswordInput. Submit stays disabled while the two passwords differ.
- Fields help the keyboard and the password manager. Every field sets textContentType and autoComplete: emailAddress/email, password/current-password, newPassword/new-password, oneTimeCode/one-time-code, name. Email uses keyboardType email-address and autoCapitalize none, and suggests fixes for common domain typos (gmial.com → gmail.com). Return moves to the next field, and Done submits. Why: It turns on Password AutoFill, strong-password suggestions and code autofill, and later makes passkeys possible. Check: Every TextInput on the sign-in screens has textContentType, autoComplete and returnKeyType.
- One rule for what errors reveal: Sign-up may say 'That email already has an account. Sign in instead?'. This is accepted for usability, and the API limits it per device and address. Sign-in always says 'Email or password is incorrect.'. Forgot password shows the same message for known and unknown emails. No connection, too many tries (429) and a server error each have their own kind message. Why: The rules must not contradict each other. A generic 'Something went wrong' makes people give up. Check: The API client maps network failures, 401, 402, 409 and 429 to different messages.
- Forgot password happens inside the app: Ask for the email and mail a 6-digit code. Ask for the code, a new password and confirm password. Sign the person in with a short 'Password changed' note. Why: A code needs no link that has to open the app, so reset works with every phone and email app. Check: forgot-password and reset-password screens exist. On Harakumo: Pool actions forgot-password and reset-password.
- Verify the email with a 6-digit code after sign-up. The screen says 'We sent a code to a…@gmail.com' and offers 'Open mail app' and 'Change email'. The code field is one hidden TextInput behind six boxes, with one-time-code autofill. It accepts a pasted code and submits on its own at the sixth digit. 'Resend code' has a 60-second countdown. Why: Verified emails keep receipts and resets deliverable. Leaving the app to find a code must not lose the person's place. Check: verify-email screen exists. Resend is disabled while the countdown runs. VoiceOver announces one field. On Harakumo: Pool actions send-verification and verify-email.
- When an email must carry a link (an invite, a shared item), use an https link on the app's own domain. It opens the app through universal links and app links, and falls back to the website. Never put a custom scheme (myapp://) in an email. Why: Custom schemes do nothing in many email apps and on computers. Check: ios.associatedDomains and android.intentFilters autoVerify are set. check_launch finds both /.well-known files.
- Sessions that last. After a pool login, the app's API starts its own device session: a 1-hour access token signed with APP_SESSION_SECRET, plus a random refresh token stored hashed in a sessions table (device name, created, last used, revoked_at). The app refreshes silently through {API}/v1/auth/refresh, which rotates the refresh token. Sign-out, password change, deletion and a ban revoke the session rows. Settings shows 'Signed-in devices', with sign out one or all. Why: A token that runs out with no refresh means a weekly forced sign-out, one of the most visible signs of an amateur app. Check: An access token expires within an hour. Refresh rotates the refresh token, and reusing an old one revokes the session. On Harakumo: In the kit's worker.js today. Sign-in pools also have refresh tokens of their own (refresh: true at sign-in, rotated, ending after 60 idle days or a year), which the kit does not use yet.
- Face ID unlock. After the first password sign-in, offer once: 'Unlock with Face ID' (or Touch ID, or fingerprint, naming what the phone has). It guards the refresh token saved on the phone; the server never sees it. Check hasHardwareAsync and isEnrolledAsync, fall back to the passcode, and don't ask again after a no. Settings shows the status with a way to turn it off. Money and health apps also lock on reopen. Why: Apple asks apps to name the method, and to avoid making people opt into biometrics in a separate setting. Check: faceIDPermission is set in the expo-local-authentication plugin. The offer appears only after a successful password sign-in.
- Sign in with Apple. An app with only its own email and password needs nothing else. If any Google or other social sign-in is added, Sign in with Apple is also offered on iOS: the system AppleAuthenticationButton, the same size and position as the others, visible without scrolling, never ask for a name or email after an Apple sign-in; save them from the first one, since Apple sends them only once, accept relay addresses (@privaterelay.appleid.com, @private.icloud.com), test on an iPad too. Why: Apple 4.8 requires an equal private option, and Apple 4.0 rejects asking again for data Apple already gave. Check: If any Google sign-in package is installed: ios.usesAppleSignIn is true and the Apple button is on the sign-in screen. On Harakumo: Sign-in pools take Apple and Google ID tokens (the sign-in-with action, once the app's ids are listed on the pool), which the kit's worker.js does not call yet.
- Keep the tokens only in expo-secure-store (Keychain or Keystore), never in AsyncStorage or files. Clear them on the first launch after a reinstall, since iOS keeps the Keychain across uninstalls. Keep them out of Android backups (android.allowBackup false, or the secure-store backup option). On a 401 that refresh can't fix: keep any unsaved form, go to sign-in with 'Please sign in again.', then return to the same screen with the form restored. Why: Tokens in plain storage leak from backups and rooted phones. A stale token after a reinstall can sign in the wrong person. Check: No AsyncStorage import touches a token. lib/session.tsx clears tokens when its first-launch flag is missing.
- Protect signed-in screens with Stack.Protected guard={!!session} in the root layout, and keep the launch screen up while the session loads. Why: Without the guard, signed-out users can deep-link into private screens. Check: The root _layout.tsx wraps the private groups in Stack.Protected.
- Change password and profile through the app's API. Password: the API checks the current password with a pool login, sets the new one through the admin reset-password action with the project key, signs in again, and revokes the other sessions. The display name and photo live in the app's profiles table and are read from there, not from the token. Don't offer email change yet. Why: The kit's own sessions keep no pool token, and the pool's account actions for end users (change-password, update-profile, change-email with a code) each take the user's own pool token, so the kit's API makes these changes with the project key and leaves email change out. Check: change-password.tsx calls {API}/v1/account/password. The profile screen has no email field to edit. On Harakumo: PATCH https://harakumo.com/api/auth-pools/<poolId>/users/<id> (reset-password) with HARAKUMO_API_KEY. The pool's change-email action exists; the kit adds email change once its sessions carry the pool token.
Every screen: states, lists, keyboard and bars
- Every screen that loads data has designed loading, empty and error states. Loading: skeletons the size of the final layout, shown at once. Empty: a friendly line and the next action ('No bookings yet. Book your first visit.'). Error: what went wrong, plus Try again. Requests time out after about 15 seconds into the error state. Never a blank screen or an endless spinner. Why: Blank and frozen screens are the commonest sign of an unfinished app (Apple 2.1). Check: Each data screen renders Skeleton, EmptyState and ErrorState. Test with the API stopped.
- Partial failure, staleness and timing: A dashboard section that fails shows its own inline error with Try again. The rest stays. Data refreshes when the app comes back to the front and when the connection returns: TanStack Query's focusManager is wired to AppState and onlineManager to NetInfo. Relative times tick every minute while on screen. Spinners wait about 300 ms before appearing and then stay at least 500 ms. A 402 (plan limit) gets a kind message, never a blank screen. Why: One failed section must not blank the whole screen. Stale data after resume, and spinners that flash, feel broken. Check: lib/query.ts wires focusManager and onlineManager.
- Offline: a slim banner says 'You're offline. Showing saved data.' Keep the last loaded data on screen from the persisted cache, with 'Updated 10 min ago'. Disable or queue actions that need the network, and retry when the connection returns. Why: Phones lose signal all the time. An app that wipes its screen offline feels broken. Check: OfflineBanner is in the root layout. Airplane mode shows cached lists, not errors.
- Pull to refresh on every list, and on detail screens that show server data, with the platform's spinner. Why: People expect it on both platforms. It is also the manual retry. Check: Every FlashList and ScrollView with server data has a RefreshControl.
- Lists that can grow past about 20 items load page by page: PagedList (FlashList); 20 items per request with a cursor, the next page on onEndReached; a footer spinner, and 'You're all caught up' at the end; the scroll position kept when coming back; each list row memoized, with a FlashList item type for each kind of row. Never fetch everything. Why: Loading thousands of rows freezes phones and costs data. Check: API list routes take cursor and limit (max 50). Every list uses PagedList. On Harakumo: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20, with an index on (user_id, created_at).
- Bottom space on every scrolling screen equals the sum of: the tab bar height; the bottom safe-area inset; the height of any floating bar or floating button; 16. The last item, the submit button and 'You're all caught up' are never hidden. On iOS, use contentInsetAdjustmentBehavior 'automatic'. Why: Content hidden under the tab bar, a floating button or the home indicator is the commonest layout bug in generated apps. Translucent iOS 26 bars make it worse. Check: Screen and PagedList compute the bottom space themselves. Scroll every list to the end on the smallest phone, with the floating button or sticky bar showing.
- Keyboard: use react-native-keyboard-controller. KeyboardAwareScrollView for forms. KeyboardStickyView for chat composers and sticky submit bars. Every field sets returnKeyType. The iOS number, decimal and phone pads get a toolbar with Done (and previous/next). Tapping outside closes the keyboard. Why: Android 15+ is edge-to-edge, so the window no longer shrinks for the keyboard. The iOS number pads have no return key, so the keyboard can't be closed. Check: Test every numeric field on an iPhone. Test sign-up and chat on Android 15+ with gesture and with 3-button navigation.
- Safe areas and bars. Text and controls stay inside the safe area: the notch or Dynamic Island, the home indicator, the Android navigation bar. Backgrounds, images and scroll content run under the bars. Use native stack headers and native tabs, so iOS 26 draws its glass bars and Android draws Material bars. Never pass a custom tabBar or paint header backgrounds on iOS. Never set UIDesignRequiresCompatibility. Why: Android 15+ forces edge-to-edge. Glass bars need content running under them. Custom opaque bars look dated and template-made. Apple has said the compatibility opt-out is temporary. Check: The root layout has a SafeAreaProvider. Nothing tappable sits under the status bar. An iOS 26 screenshot shows glass bars.
- Sizes: From the smallest phone (375×667 pt, 360 dp wide) to the largest, plus Android tablets and foldables, where Android 16 ignores orientation locks; Center content at a 640 pt maximum width on wide screens; iPad support stays off by default, but the iPhone build must work on an iPad; Turn iPad support on for education, productivity, content and internal tools. Then layouts resize and work in every orientation, with two columns where useful, and iPad screenshots are needed. Why: Apple reviews iPhone-only apps on iPads (2.4.1). Android 16 ignores resize locks on large screens. Check: ios.supportsTablet is set on purpose. Each screen is checked at 360 dp and 840 dp wide, and on an iPad simulator.
- Buttons that change data: Show progress while working, can't be pressed twice, and keep their width while loading. Orders, bookings and payments send an idempotency key, so a retry never doubles them. Likes, toggles and checkmarks update at once, and roll back with a message if the server refuses. Why: Double orders come from double taps and retries on slow networks. Check: Submit buttons are disabled while the mutation is pending. The create routes for orders, bookings and payments read an Idempotency-Key header.
- Back always works. The Android back gesture, with predictive back turned on in app.json, and the iOS swipe-back both go to the previous screen. On a tab other than the first, Back goes to the first tab, then exits. Never 'press back again to exit'. Each stack _layout sets unstable_settings anchor to its list screen, so a detail screen opened from a push or a link has its list below it. A form with unsaved changes asks before leaving. Why: A detail screen opened cold from a push otherwise closes the app on Back. Check: Kill the app, tap a push for /orders/123, press Back: the Orders list appears. Forms use usePreventRemove.
- Search: The field is focused on open. Before typing: recent searches stored on the phone (with clear) and suggestions. Requests wait about 300 ms after typing stops. The return key says Search, and iOS shows Cancel. Results are grouped by type with matches highlighted. No results: a spelling hint and a way to clear filters. The query and results survive going back. On iOS 26, a Search tab uses the native search-role tab. Why: Generated search is usually blank on open, sends a request on every keystroke, and goes blank when nothing matches. Check: The search screen has the recent, debounced, no-results and kept-state behaviors.
- Toasts and Undo: One Toast component sits above the tab bar and home indicator, never over a sticky button. It shows for about 4 seconds with at most one action, queues rather than stacks, can be swiped away, and is announced to screen readers. Reversible actions (archive, remove from cart, complete) use Undo. Irreversible actions confirm with a native alert in the destructive style, with named buttons ('Cancel booking' / 'Keep booking'), never Yes, No or OK. Why: A stream of alerts feels amateur. Mature apps offer Undo. Check: A search for 'OK' or 'Yes' buttons in src/app finds none on destructive alerts.
- Sheets: Quick tasks use presentation 'formSheet' with sheetAllowedDetents (for example [0.5, 1]) and a grabber. Full flows use 'modal'. A sheet with unsaved changes blocks swipe-down and asks 'Discard changes?'. SheetHeader places buttons per platform. iOS: Cancel on the left, the action on the right. Android: a close X on the left and Save on the right, or a full-width button at the bottom. Why: Losing input on a swipe-down, or Save on the wrong side, looks amateur. Check: Every sheet uses SheetHeader.
Design: component kit, styles, layouts and motion
- Build screens only from the component kit: Button (primary, secondary, tertiary, destructive; small, medium, large; loading; disabled). TextField, FormField, PasswordInput, CodeInput. ListRow (icon, title, subtitle, value, chevron or Switch), SectionHeader, SectionFooter. Card, Avatar, Badge, Chip, SegmentedControl, SearchBar. SheetHeader, Toast, Divider, StatTile, Screen. No raw Pressable or TouchableOpacity with inline styles outside src/components. Why: Consistency is most of what makes an app look professional. Hand-built buttons and rows drift in height, radius and padding. Check: A search for Pressable or TouchableOpacity in src/app finds none.
- Pick one style preset from the app type and the brand, as a token set: Clean: system font, radius 12, color only on actions. Bold: large display type, full-bleed color headers, radius 20. Editorial: serif headings, warm surfaces, image-led. Utility: compact rows, radius 8, dense; for internal and finance apps. Each preset sets density, radius, type pairing, image treatment and elevation. Why: Without presets, every app comes out in the same generic white-cards-and-blue-button look. Check: src/theme/tokens.ts names its preset, and screens read only tokens.
- Compose each screen from a named archetype: Home dashboard: greeting, one main card, one or two horizontal carousels, a recent list. Catalog grid: two columns, fixed 4:5 images. Feed. Detail with hero: stretchy image, sticky bottom action bar. Form sheet. Settings: grouped list. Profile: header, stats, segmented content. Chat thread. Map and list toggle. Agenda: calendar and list. Flow stepper: checkout, booking. Player: full-screen media or timer. The app type lists which archetypes its screens use. Why: The founder asked for different layouts and templates. Named compositions stop the AI inventing a new layout for every screen. Check: The plan names the archetype for every screen.
- Navigation by app type: A tab bar for 3 to 5 top-level sections, with labeled icons that are never hidden, and navigation only (never a '+' action tab). Stacks to drill down, sheets for create, edit and quick tasks, and one stack for single-flow apps. Each tab keeps its own stack and scroll position. Tapping the active tab scrolls to the top or goes back to its root (useScrollToTop). Why: Apple's tab bar rules and Material 3 navigation. Familiar layouts read as professional. Check: The tabs _layout has 3 to 5 tabs, each with a title and an icon. Create actions live in the header or a floating button.
- Design tokens live in one theme file: brand, neutrals, success, warning and danger colors, each with light, dark and high-contrast values; pressed, selected and disabled overlay colors; surfaces: base, elevated, grouped; spacing (4, 8, 12, 16, 24, 32), radii and a type scale. Screens use only tokens. Why: Tokens make the app consistent and make dark mode and Increase Contrast cheap. Check: A search finds no '#' color literals or raw font sizes outside src/theme.
- Brand color: With no color given, pick from a vetted palette for the app type. Work out the text color on the brand fill (white or near-black) by contrast. When the brand color is below 4.5:1 against the background, add a darker brand-text token for links and tinted text, and keep the brand color for fills. Why: Bright brand colors (yellow, lime, light teal) fail as text. Check: The theme unit test checks every text and fill pair.
- Typography follows each platform. iOS text styles: Large Title 34, Title 1 28, Title 2 22, Title 3 20, Headline 17 semibold, Body 17, Callout 16, Subhead 15, Footnote 13, Caption 12; Android: the Material 3 scale, with body at 16 sp; A brand font for display and headlines only. Body text and controls use the system font; Line height about 1.3× for body, at most two weights per screen; Prices, timers and counters use tabular numbers. Why: Body text in a brand font loses the native feel, and numbers that change width jitter. Check: Text components take a variant, never a raw size.
- Text that scales: It grows with the phone's text size up to 200% without clipping. No fixed heights on text, and maxFontSizeMultiplier only on tab labels and badges. At accessibility sizes, side-by-side label and value rows stack vertically. Long text uses numberOfLines with an ellipsis, and long bodies get 'Read more'. Why: Many people use larger text. Clipped labels fail accessibility review. Check: Test at the largest accessibility size. allowFontScaling is never false on body text.
- One icon set for the whole app, in one weight: expo-symbols (SF Symbols on iOS, Material Symbols on Android) or one vector set. Never emoji as icons, never mixed sets. Why: Mixed or emoji icons look amateur. Check: Every icon goes through components/Icon.tsx.
- Every touchable has visible feedback: iOS: opacity of about 0.6 on buttons and a background highlight on rows; Android: android_ripple clipped to the shape, borderless on icon buttons; Chips, segments and rows have a selected state; Disabled keeps label contrast at 3:1 or better. Why: Taps with no response feel dead. Check: Every kit Pressable uses the style({pressed}) callback or android_ripple.
- Platform conventions: iOS: large titles, swipe actions, separators inset to where the text starts; Android: a top app bar, Material components, rows that run edge to edge; Item actions go in a long-press context menu (with link preview on iOS where available), always mirrored in a visible ⋯ button; Each screen sets its StatusBar style ('light' over dark hero images); System pickers, alerts and the share sheet stay native; Section headers stick while scrolling. Why: Apps that ignore platform habits feel foreign. Dark status-bar text over a dark image is a classic bug. Check: Date and time pickers, alerts and sharing use the system components. Hero screens set StatusBar.
- Dark mode done right: userInterfaceStyle 'automatic', following the system; Surfaces show elevation with lighter grays, not shadows; The brand color gets a lighter, less saturated dark value; Separators are faint hairlines, and illustrations have dark variants; An in-app Light / Dark / System choice only on Android. Why: Inverting the light theme gives glowing brand colors and cards that merge into the background. Apple advises against an app-only appearance setting. Check: The config has userInterfaceStyle 'automatic'. Screenshots are taken in both modes.
- Touch targets are at least 44×44 pt on iOS and 48×48 dp on Android, using hitSlop for small icons, and at least 8 pt apart. Why: Small targets cause mis-taps and fail both platforms' guidelines. Check: IconButton enforces a minimum size or hitSlop.
- Images and people: Avatars fall back to initials on a token color derived from the user id. Avatar photos are cropped square (allowsEditing, aspect [1, 1]). Each card type has a fixed aspect ratio (products 4:5 or 1:1, banners 16:9) with contentFit 'cover'. A broken image becomes a neutral tile with the type's symbol. Empty-state illustrations come from the icon set: no stock clip art, no AI-drawn people. Why: Grey circles, mixed ratios and broken images are where generated screens fall apart. Check: The worst-case seed passes the visual pass.
- Haptics with expo-haptics only for moments that mean something: success after a payment or booking, a selection change, a warning before a destructive confirm. On Android, use performAndroidHapticsAsync, which respects the user's settings. Never on every tap. Why: Both platforms ask for haptics used sparingly and consistently. Check: Haptics calls appear only in success, selection and warning handlers.
- Motion: Native screen transitions. Short (150–300 ms) ease-out Reanimated animations on transform and opacity. Layout animations on list inserts and removals. A small scale bump on badges and likes. With Reduce Motion on (useReducedMotion), these become fades or no motion. Nothing flashes more than three times a second, and no animation runs on the JS thread. Why: Motion should guide, not slow down. Items that pop in without a transition feel unfinished. Check: Animated components read useReducedMotion. No animation in the main flows runs longer than 400 ms.
- Writing: Buttons are specific verbs ('Book visit', 'Place order'), not 'Submit'. An alert's title is the question, and its buttons name the actions. No 'Oops' and no exclamation marks in errors. Capitalization is consistent. No developer words on screen: null, undefined, 500, timeout, 'fetch failed'. Why: Developer leakage makes an app feel amateur faster than any visual flaw. Check: A search for 'Submit', 'Oops', '>OK<' and 'Error:' in src/app finds none.
Notifications: push, inbox and email
- Push for the events that matter to this app type (order shipped, booking tomorrow, new message, reply). After sign-in, the app registers its push token with {API}/v1/push/register. Remove it on sign-out, and when the push service reports the device gone. Why: Push brings people back at the moment that matters. Stale tokens can reach the wrong person after sign-out. Check: lib/push.ts registers after sign-in and deletes the token on sign-out. On Harakumo: Harakumo's push API delivers with the owner's own keys: the app registers each phone's device token with the sign-in pool (the register-push action, with the user's token), the server sends to users by id (POST /api/push/<poolId>/send with the client secret), and a device the service reports gone is switched off. The kit's worker.js instead keeps a devices table and sends through the Expo push service with Expo push tokens, since its own sessions hold no pool token.
- Push credentials belong to the owner and are set up before the first push test: iOS: the APNs key (.p8) in EAS credentials for the Expo push service, or saved on the pool's Auth page for Harakumo's push API; Android: your Google project's push key (FCM V1, a service account file) uploaded to EAS or saved on the pool's Auth page, and google-services.json referenced from android.googleServicesFile either way. Why: Without them, Android push fails silently. Check: check_launch with appConfig: android.googleServicesFile is set whenever expo-notifications is used.
- Ask for notification permission after a meaningful action, never at launch. For example, after the first order: 'Get a message when your order ships?', with one Continue button first. On Android 13+, create the channel before asking; iOS provisional (quiet) delivery suits apps that send many updates. Why: Notifications are off by default on Android 13+, and iOS asks only once. Check: requestPermissionsAsync is called only from PermissionPrimer after an action. setNotificationChannelAsync runs before the token is fetched.
- The app works fully with push off. Every pushed event also lands in an in-app Notifications screen: a bell with an unread badge; a paged list with mark as read; each row opens its screen. Why: Push can't be required (Apple 4.5.4). An inbox means nothing is lost when permission is denied. Check: An inbox tab or screen exists, backed by a notifications table.
- Tapping a notification opens the exact screen through the payload's route (data.url, handled by expo-router), even from a closed app. A signed-out person goes to sign-in, then to that screen. Why: A push that only opens the home screen feels broken. Check: lib/push.ts handles both the response listener and getLastNotificationResponseAsync on a cold start.
- Notifications while the app is open, plus badges and grouping: setNotificationHandler shows an in-app banner, and nothing at all for the chat or order on screen; The app icon badge equals the inbox unread count: set from the payload, and updated with setBadgeCountAsync as items are read; Group notifications per conversation or order (iOS threadIdentifier, Android group); Opening an item clears its delivered notifications. Why: A badge stuck at 7 forever, or a banner for the chat being read, are classic amateur bugs. Check: Read the inbox to zero: the icon badge clears.
- Notification settings: One toggle per category (orders, reminders, messages, news and offers), stored on the server and honored by every send. A link to the phone's settings when push is denied. Marketing pushes need their own opt-in and are off by default. Why: Marketing needs opt-in plus an in-app opt-out (Apple 4.5.4). People keep notifications on when they can tune them. Check: Send code checks notification_prefs before every send.
- Push text carries no sensitive data: no health values, balances, codes, full addresses, or private message bodies unless previews are on. Use 'Time Sensitive' only for things happening within the hour. Why: Lock screens are public (Apple 4.5.4). Check: Push templates for the health, finance, chat and dating types use generic bodies.
- Reminders about the person's own items (a booking, a habit, an event) are local notifications, scheduled on the phone when the item is saved and cancelled when it changes. Schedule inexactly: no SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM unless the app is an alarm or calendar app; iOS keeps at most 64 pending notifications, so reminder-heavy apps schedule in rolling windows. Why: Reminders work offline and need no server timer: the kit's edge program has none, and an agent's schedule runs at most every ten minutes and up to ten minutes late. Android 14+ denies exact alarms by default. Check: Each reminder keeps its id so it can be cancelled. The merged manifest has no exact-alarm permission.
- Email the important events through Harakumo Mail: receipts and booking confirmations; security notices: password changed, account deleted, new sign-in on a new device. When Sign in with Apple is used, send from the app's own domain, registered with Apple's private email relay. Why: Email is the record people keep. Mail to relay addresses bounces from senders that aren't registered. Check: The API mails a receipt for every paid order, and notices on password change and deletion. No mail goes to example.com, .test or .invalid addresses. On Harakumo: POST https://harakumo.com/api/mail/send with HARAKUMO_API_KEY, the key setup_app_backend pinned to the project. Own domain via add_mail_domain and set_mail_from.
- Android: a 96×96 white-on-transparent notification icon and the brand color in the expo-notifications plugin, and one channel per settings category, with a clear name. Why: Without the icon, Android shows a grey square. Channels let people mute one kind of notification. Check: The plugin has icon and color, and the channels match the settings categories.
Settings and account
- A Settings screen, reached from a tab or the profile picture, with: profile (name, photo) and change password; notification settings, Face ID unlock status, and appearance (Android only); signed-in devices; Privacy Policy, Terms, Help and Support, Send feedback; Rate the app, Share the app, open-source licenses; the app version and build number; Sign out; Delete account. Why: Reviewers look for privacy, support and deletion inside the app, and people look here for every account task. Check: src/app/(tabs)/settings/index.tsx lists every row.
- How Settings looks: iOS: an inset-grouped list on the grouped background, 44 pt rows, optional icons in colored rounded squares, the value on the right, chevrons to drill down, Switch for toggles (never checkboxes), explanations in section footers; Android: full-width rows, section titles in the brand color, no chevrons, Material switches; Sign out sits in its own section. Delete account comes last, in red text, never as a filled button; The version is footer text that copies on long-press. Why: Settings built from stacked custom buttons look amateur, and a big filled red button is alarming. Check: Settings uses ListRow and SectionFooter from the kit.
- Privacy Policy, Terms and Help open the pages hosted on the app's Harakumo site in an in-app browser (expo-web-browser), with ?app=1. That version has no pricing or purchase links. Support offers a real way to reach the owner: a form and an email address. Why: Both stores need the privacy policy inside the app (Apple 5.1.1(i)) and real support contact (Apple 1.5). Purchase links inside the iOS app break Apple 3.1.1. Check: lib/links.ts points to https://<host>/privacy?app=1, /terms?app=1 and /support?app=1. check_launch finds all three live. On Harakumo: The app's worker.js serves the pages.
- Delete account inside the app, at most two levels deep (Settings → Account → Delete account). Say what will be deleted, and what is kept: order records required by law, stripped of identity, with how long they are kept. Confirm with the password or a fresh 6-digit email code ('Use a code instead'). Face ID is only a local gate on top. The server deletes the account and all its data. It is not a deactivation. Sign out and say when deletion is complete. People with a store subscription are told that billing continues until they cancel it in the store, with a button to the store's subscription page. Why: Apple 5.1.1(v) and Google Play require full in-app deletion. People who sign in with Face ID often don't know their password. Check: delete-account.tsx calls {API}/v1/account/delete. Signing in again with the same email fails. On Harakumo: The API deletes the user's rows, files and sessions, then removes the pool user with DELETE https://harakumo.com/api/auth-pools/<poolId>/users/<id> and HARAKUMO_API_KEY (the pinned full key has resource:update).
- A public web page at https://<host>/delete-account, reachable without the app. It: shows the app or developer name exactly as on the store listing; lists the steps; says which data is deleted and which is kept, and how long kept data is kept; lets the person ask by email code. Why: Play's account-deletion policy requires exactly these, and the link goes in the Data safety form. Check: check_launch delete-account-page passes. On Harakumo: Served by worker.js, running the same deletion flow verified by an email code.
- Sign out clears the tokens, the session row, the push token on the server, cached personal data and the query cache. Why: Shared and resold phones must not show the last person's data. Check: signOut() calls {API}/v1/auth/logout, deletes the secure-store tokens and clears the query cache.
- If the app sends personal data to AI (chat, summaries, photo analysis), show a one-time screen before first use. It: says what is sent, why, and who receives it: Harakumo as the processor, plus its AI model providers; has 'Allow' and 'Not now', and the app still works after 'Not now'. Consent is stored per user with a time, and a switch in Settings turns it off. Every generative feature has a Report action. Why: Apple 5.1.2(i) requires naming where personal data goes before asking. Play's AI-generated content policy requires in-app reporting. Check: An ai_consent row with a timestamp exists. AI routes refuse without it. Report exists on AI output. On Harakumo: The AI gateway is called only from the app's API, after the consent check.
- Apps that keep personal records (finance, health, journals) offer 'Download my data' as a file, emailed or shared from the phone. Why: It builds trust, and data-access requests in many countries expect it. Check: Settings has an export row for these types.
- Keep people informed about the app itself: A 'What's New' sheet once after a big update, skippable. A soft-update banner when app-config's latestVersion is newer. A maintenance screen when app-config says so, instead of errors everywhere. Help is an FAQ page on the app's site. Send feedback attaches the app version, the OS and an optional screenshot. Why: These are the small touches that make an app feel looked after. Check: {API}/v1/app-config returns minVersion, latestVersion, message and maintenance.
Accessibility
- Every touchable element has an accessibilityRole and an accessibilityLabel that says what it does ('Add to cart'), and icon-only buttons always have one. On Android, the label doesn't include the word 'button', because TalkBack adds the role. Meaningful images get labels. Decorative ones are hidden. Why: Otherwise VoiceOver and TalkBack read nothing useful. Check: Every IconButton, and every row without a text child, has accessibilityLabel.
- Set accessibilityState on tabs, chips, switches, rows and loading buttons: selected, checked, disabled, expanded or busy. Why: Without it, toggles and tabs never announce their state. Check: Kit components pass accessibilityState from their props.
- Every gesture-only action (swipe, long-press, drag to reorder, double-tap to like, pinch to zoom) is also offered: as accessibilityActions; in a visible menu or button. Why: Screen-reader users can't reach gesture-only actions. Check: Complete every swipe or long-press action with VoiceOver alone.
- Sheets and dialogs set accessibilityViewIsModal. After navigating, focus moves to the screen's header. Why: Otherwise focus leaks behind sheets and lands in random places. Check: SheetHeader sets the modal and focus behavior.
- Reading order matches what is seen. A card's content is one element with a combined label (title, price, status). Section titles have accessibilityRole 'header'. Why: Screen-reader users move by headers and elements. Scattered pieces make lists unusable. Check: Cards set accessible plus a joined accessibilityLabel.
- Contrast is at least 4.5:1 for text and 3:1 for large text, icons and disabled labels, in light, dark and high-contrast modes, including pressed states. Never color alone: errors also have text and an icon, and statuses have words. Why: Low contrast fails outdoors and for low vision. Check: A theme unit test checks every token pair and variant.
- Errors, successes and loading are announced (AccessibilityInfo.announceForAccessibility). Loading indicators are labeled 'Loading'. Why: Otherwise screen-reader users never learn that a form failed. Check: FormField errors and Toast call announceForAccessibility.
- Respect display settings: Reduce Transparency: blurred surfaces become solid. Increase Contrast: high-contrast tokens. Bold Text. Smart Invert: photos and avatars set accessibilityIgnoresInvertColors. Reduce Motion (see Design). Android display size. Why: Glass and blur are unreadable for some people without these. Check: Test with each setting on, at the largest text and display size.
- Before calling it done, walk the main flow with VoiceOver and with TalkBack: sign up, the app's main task, settings, delete account. Answer Apple's Accessibility Nutrition Labels honestly, claiming only features that work across the main tasks. Why: Only a real pass finds the unlabeled button that blocks the flow. Check: The pass is noted in the README's test section.
Data and files
- Uploads go straight from the phone to Harakumo storage: The app asks {API}/v1/uploads for a signed URL, sending the file's type and size; the API checks the user and their quota and signs a link under users/<id>/ with that size. It uploads with progress, cancel and retry. It posts {API}/v1/uploads/complete, and the API records the file. Shrink photos first (longest side about 2048 px, JPEG or WebP at about 0.8), and remove location data unless the app needs it. Why: Sending files through the API is slow. Full-size phone photos are 5–15 MB. A link signed with the file's size takes exactly that many bytes. Location in photos reveals where people live. Check: lib/upload.ts shows progress, cancel and retry. Keys are under users/<id>/. On Harakumo: The storage API's signed PUT links, asked for by the app's API with the file's type and size (the same links create_upload_url gives).
- Images use expo-image: memory and disk cache, blurhash or thumbhash placeholders, recyclingKey in lists, and thumbnails requested at the size they show at. Why: It stops lists from flickering, downloading again, or using too much memory. Check: No React Native Image component in list items.
- All server data goes through one TanStack Query cache, persisted so the last data shows offline. Every change invalidates the lists it affects. Why: One cache keeps screens in sync, and makes offline and pull to refresh simple. Check: lib/query.ts configures persistence. Every mutation calls invalidateQueries.
- Language and formats: All strings live in one locale file (expo-localization plus a small i18n helper), even with one language. Plurals use Intl.PluralRules. Styles use start and end, not left and right. Times are in the phone's time zone. Money uses Intl.NumberFormat with its currency. Dates are relative where it helps ('Tomorrow, 10:30'). Why: Hard-coded strings and left/right layouts make translation and right-to-left a rewrite. A booking in the wrong time zone is a missed appointment. Check: No string literals in JSX outside the locale file. Test with a pseudo-locale 40% longer, and with right-to-left when Arabic, Urdu or Hebrew is planned.
- Forms: Labels above inputs, never placeholder only. Required fields marked. Checks on blur and on submit, with messages next to the field. Drafts kept if the person leaves or the app closes, never for sensitive fields. Why: Lost drafts are among the most hated app behaviors. Check: Long forms save a draft locally.
- The server checks every input again, whatever the app sent, and uses parameterized SQL. Why: Anyone can call the API without the app. Check: Every /v1 route validates its body before touching the database.
Security and privacy
- No secret in the app, ever. The bundle may hold only EXPO_PUBLIC_API_URL, and the pool's public client id when needed. Never HARAKUMO_API_KEY, the client secret, the JWT secret, APP_SESSION_SECRET, or payment or AI keys. Every EXPO_PUBLIC_ value is readable by anyone who downloads the app. Why: App bundles are easy to unpack, and a leaked project key exposes every user's data. Check: A search of the app folder for HARAKUMO_API_KEY, CLIENT_SECRET, JWT_SECRET, SESSION_SECRET, 'as_' and 'sk_' finds nothing. check_launch scans extra and EXPO_PUBLIC_ values. On Harakumo: Secrets live only in project environment variables, read by worker.js as env.NAME.
- Every sensitive call goes through the app's API. Every request: checks the access token (issuer, audience, expiry); checks the user owns what they read or change; validates the input. Sensitive routes (delete account, payments, admin) also check that the session row isn't revoked. Why: Checks done only in the app are bypassed with one curl command. Deleted or banned users must lose access quickly. Check: Every /v1 route except auth, app-config and webhooks starts with requireUser(). Every query filters by user_id.
- The API limits requests per user and per device: sign-ups, codes, sign-ins, writes and uploads. It returns 429 with a retry time, which the app shows kindly. Why: It protects the owner's bill, the mail quota and real users' accounts from bots. Check: The worker has per-route limits, kept in a small counter table until a platform limiter exists. On Harakumo: Not on Harakumo yet: the server sign-in path has no per-IP limit on logins or code requests (sign-ups are limited per pool, and per address when the server passes it), and edge programs have no counter store.
- Network and logs: Only HTTPS. Use hostnames, never IPv4 literals, so IPv6-only networks work. Never log tokens, passwords, codes or personal data. Error reports carry the app version and screen, not the person. Why: Apple tests on IPv6-only networks. Logs end up in places nobody protects. Check: A search for console.log with token, password or code finds nothing.
- Links into the app (universal links, app links, notification routes) are untrusted input. Check their parameters, and never pay, delete or send from a link without the person confirming. Why: Anyone can craft a link that opens the app. Check: [id] screens fetch by id and handle a 404. Nothing happens on open.
- Over-the-air updates: They fix bugs and wording only. New features go through store review. Never download and run code, and never run other apps inside this one (Apple 2.5.2 and 4.7). Freeze updates to the production channel while a build is in review. Why: Changing features through downloaded code is forbidden, and an update during review changes what the reviewer sees. Check: The README's release notes mark each change as a store build or an update.
- No tracking by default: no ad, attribution or third-party analytics packages, so no tracking prompt. If the owner adds one: show the App Tracking Transparency prompt in context (expo-tracking-transparency) with a purpose sentence, and no incentive before it; answer Tracking in the privacy labels; add Play's Advertising ID declaration. Otherwise block com.google.android.gms.permission.AD_ID. Why: Tracking without the prompt is rejected (Apple 5.1.2), and a missing usage string crashes the app. Check: check_launch with appConfig flags NSUserTrackingUsageDescription when there is no tracking package, and AD_ID when it isn't blocked.
- Export compliance: set ios.config.usesNonExemptEncryption to false for apps that only use HTTPS. A chat app that really uses end-to-end encryption needs the export filing instead. Why: Without it, every build waits in 'Missing Compliance'. Check: check_launch with appConfig checks the key.
Payments
- Classify every paid item in the plan. Harakumo checkout is allowed for: physical goods; in-person services; live one-to-one services (tutoring, consultations, personal training); external purchase links on the US App Store only. The store's in-app purchase is required for: live group or online classes and streamed events; recorded content, premium features, credits, in-app currency, and tips on content. Add a Restore purchases button. Why: Apple 3.1.1 and 3.1.3(d), and Play's billing policy. Selling digital goods through web checkout gets the app rejected or removed. Check: The plan marks each item physical, in-person, one-to-one or digital. Digital items never call checkout. On Harakumo: create_checkout_link from the app's API. Harakumo can't check store purchases yet (not on Harakumo yet).
- Donations: Approved nonprofits may collect in the app with Apple Pay. Anyone else opens Safari with Linking.openURL, never an in-app browser, and the app is free. Why: Apple 3.2.2(iv). Check: Donation buttons use Linking.openURL unless the owner is an approved nonprofit.
- Paywall: The title, the length, and the price per period, with the billed amount as the most prominent price. Free-trial terms, and how renewal and cancelling work. Restore purchases. Working links to the Terms of Use (EULA) and the Privacy Policy. The App Store listing also links a Terms of Use. Why: Apple 3.1.2 and Play's subscriptions policy are among the commonest rejection causes. Check: check_launch with appSource finds the Terms and Privacy links and Restore on the paywall. store/listing.md has the EULA line.
- Checkout for physical goods and services: A native order summary first, then 'Opening secure checkout…'; Checkout opens with WebBrowser.openAuthSessionAsync and the https return URL, with the browser colored to the brand; /return/checkout hands off to the app (see web pages); The app refetches the order when the browser closes and when the app comes to the front; A native success screen with a success haptic. Why: Universal links don't fire reliably from inside an in-app browser, and https callbacks need iOS 17.4+. A buyer stuck on a web page after paying fails review. Check: Pay, close the browser at each step, and the order shows its true status.
- Store setup before the first paid build: Apple's Paid Apps Agreement, banking and tax are active. The first in-app purchases are submitted with the build (Ready to Submit, with a review screenshot). The Play merchant profile is set up. Why: Otherwise products don't load for the reviewer, which is a 2.1 rejection. Check: store/review-notes.md lists the products and their state.
Engineering and releases
- Folder structure: src/app for routes (expo-router), src/components (the kit), src/lib (api, session, push, query, upload, i18n), src/theme, src/locales; TypeScript strict; Navigation imports only from expo-router. Why: Since Router v56, app code can't import @react-navigation/*, and assistants trained on older code still write those imports. Check: A search for '@react-navigation/' imports finds none.
- Use the latest stable Expo SDK, add packages with npx expo install, and commit the lockfile. Test on development builds. Why: Mismatched versions are the top build failure. Check:
npx expo-doctorpasses. expo-dev-client is installed. - eas.json has development, preview and production profiles, with autoIncrement on production, the fingerprint runtimeVersion, and channels that match the profiles. The app id (com.brand.appname) is chosen with the owner once, and never changed after the first upload: lowercase segments of letters and digits, at least two; no hyphens or underscores, no Java keywords; at most 155 characters. Why: A new id creates a new app in the store. Hyphens break Android and underscores break Apple. Check: ios.bundleIdentifier and android.package match the store listings and pass the id check.
- Forced-update check: {API}/v1/app-config returns minVersion and latestVersion. Below minVersion, the app shows 'Update required' with a store button. minVersion is never set above the version live in the store. Why: Old app versions keep running for months. A minVersion above the approved build leaves the reviewer stuck. Check: The admin refuses a minVersion above the live store version.
- Crashes and errors: Each route group exports an ErrorBoundary that shows 'Something went wrong. Try again.'. Errors go to {API}/v1/client-errors with the app version, OS and screen, and no personal data. The admin lists them. Why: Without reports, the owner hears about crashes only from one-star reviews. Check: The _layout files export ErrorBoundary.
- Performance budget: Cold start to first content in under 2 seconds on a mid-range Android phone. Lists scroll at 60 fps. Images sized to how they display. No layout shift when data arrives. Why: Jank and slow starts read as amateur before anyone notices the design. Check: Test a release build on a low-end Android phone with a slow-network profile.
- Tests: the API's sign-in, deletion, permission and money paths at least, plus a smoke run on iOS and Android before each store build. Why: These are the paths where bugs cost money or leak data. Check: npm test passes before eas build.
- README sections: what the app is; how to run it on a development build, and its settings; how to build and submit, and the review account; 'What this app collects': every field and every service, including Harakumo, the Expo push service and EAS Update. Why: The owner fills in the stores' privacy forms from this list, and wrong answers get apps removed. Check: README.md has those sections.
Store submission
- The owner's accounts come first, and the AI can't create them: the Apple Developer Program ($99 a year) and a Google Play Console account ($25 once); healthcare, financial and other regulated apps submit from an organization account, which needs a D-U-N-S number that takes days to weeks; new personal Play accounts need 12 testers opted in for 14 days of closed testing before production; Play developer identity checks, and Android developer verification for test APKs sideloaded in some countries (from late September 2026); Apple's EU trader status: a trader's address, phone and email become public. Reserve the App Store name early, since names are unique. Why: These are the longest waits in a launch, and they surprise individual owners. Check: The plan's launch section names both accounts, the account type, the closed-test start date and the trader choice.
- Listing text rules: name 30 characters, subtitle 30, keywords 100, promotional text 170, descriptions 4000; Play short description 80; in the name, icon and developer name: no 'best', '#1', 'top', 'free', 'new', prices, emoji, all caps or other apps' names; never mention the other platform. Why: Apple 2.3.7 and 2.3.10, and Play's metadata policy. AI-written listings often break these. Check: store/listing.md has every field within its limit, and passes the banned-word check.
- Listing images: a Play icon 512×512 (32-bit PNG, at most 1024 KB) and a 1024×500 feature graphic; framed screenshots: a headline of at most 6 words, a device frame, a clean status bar (9:41, full signal), seeded data, with the first three telling the core story; iPhone 6.9-inch 1320×2868 or 1290×2796; iPad 13-inch 2064×2752 when iPad is on; Play: at least 2 phone shots, long side at most twice the short side; no sign-in or splash screens. Why: Raw screen grabs look amateur, and wrong sizes or ratios block the upload. Check: The store/screenshots folder passes the size and ratio check.
- The privacy policy is generated from 'What this app collects'. It covers: what is collected, how, and every use; each third party that receives data (Harakumo, the Expo push service, the AI providers, the payment processor) and that they protect it the same way; retention and deletion, and how to withdraw consent; the developer's legal name and a privacy contact. It is a public HTML page labeled Privacy Policy: not a PDF, not geo-blocked, and it matches the Data safety answers. Why: Apple 5.1.1(i) and Play's User Data policy. Boilerplate AI-written policies are a frequent Play rejection. Check: check_launch privacy-page finds the app name, the developer name, a contact, and third-party and retention sections.
- Privacy answers and manifest: Fill Apple's App Privacy answers and Play's Data safety form from the README, including the deletion web link. Ship a default ios.privacyManifests block: UserDefaults CA92.1, FileTimestamp C617.1, SystemBootTime 35F9.1, DiskSpace E174.1, NSPrivacyTracking false and NSPrivacyCollectedDataTypes. Extend it for any other required-reason API. Why: An incomplete manifest fails with ITMS-91053. Answers that don't match the app are grounds for removal. Check: store/privacy-answers.md matches the README. check_launch with appConfig checks the manifest's content.
- Answer every declaration. Play App content: content rating, target audience, ads, Data safety, financial features, health apps, Advertising ID, news apps, government apps. Apple: the age rating (13+, 16+ and 18+ tiers, plus the social-media questions), Content Rights, Accessibility Nutrition Labels, and App Review contact details. Use defaults worked out from the app type. Why: Play won't send an app to review until every declaration is done, and an unrated app isn't shown. Check: store/listing.md records every answer.
- Review access from anywhere: a demo account with realistic data, put in App Store Connect review notes and in Play's App access, with steps past any email code; a demo address inside the delivery zone, a provider with open slots for the next 30 days, upcoming events, and a test payment path; the API exempts the review account from location limits; role-gated tabs (admin, organizer, staff) are described, with a demo account for each; reviewers are invited to create a new account to test deletion, and the demo account is recreated automatically after deletion. The backend stays on through review. Why: Apple 2.1(a) and Play's App access. Zones, empty calendars, invite-only sign-up and hidden features (2.3.1) are top rejections. Check: store/review-notes.md has every account and step. Sign in to every demo account yourself; check_launch with testSignIn proves sign-up and sign-in work. On Harakumo: A seeded review user in the pool, sample rows, and a review flag on the profile row.
- Apps where people post or message: terms with zero tolerance for objectionable content, accepted before the first post; a filter for words and images; Report on every post, comment, profile and chat; Block takes effect at once, hides the blocked user's content, and notifies the owner; reports are handled within 24 hours, by removing the content and the user, from a moderation queue sorted by age; published contact details. Why: Apple 1.2 and Play's user-generated content rules. A missing report or block is an automatic rejection. Check: Report and Block exist everywhere listed. /admin/reports exists.
- Age: Apps minors may use get the declared age range (Apple's Declared Age Range API, Google Play Age Signals), ask for parental consent where state laws require it, and handle significant changes and withdrawn consent. Check the current list of states. Apps for children follow the Kids rules: a parental gate before links out and purchases (Apple 1.3), no third-party ads or analytics, and Play's Families policy with only self-certified SDKs. Why: US state laws now require age assurance, and Kids violations get apps removed. Check: The community, chat, education, dating and AI types record their age handling in store/listing.md.
- Minimum functionality and other rules: Real native screens, not a website in a WebView. No crashes on the latest iOS and Android. Never require ratings, watching ads or installing other apps to unlock features (3.2.2). Show the odds for any random paid item. Contests need official rules and must say Apple isn't a sponsor (5.3). Ask before large downloads, showing the size (4.2.3). Content is owned or licensed (5.2), and store badges follow their brand guides. Why: Apple 4.2 rejects web wrappers and thin apps, and the others are common rejections. Check: No WebView renders main content.
- Builds meet the current store minimums: Xcode 26 and the iOS 26 SDK (the iOS 27 SDK from April 2027); Android target API 36; 16 KB memory page support. The latest stable Expo SDK meets all three. Why: Uploads that miss them are refused, and Play hides apps that target an old API. Check: The EAS build log shows targetSdkVersion 36.
- Test tracks: TestFlight internal testers first. External testers need Beta App Review, plus test information: a feedback email and the privacy policy. Play internal testing, then the closed test. Put test sign-in details in the settings of Play's pre-launch report. Why: Missing beta information blocks external testers, and the pre-launch crawler stops at sign-in. Check: The README's release section lists the tester groups.
- Release flow: EAS Build (production). EAS Submit to TestFlight and Play internal testing. Real-phone tests. Submit for review. A staged rollout on Play, watching client errors in the admin. Leave time for a rejection and resubmission, and for Play's first review. Why: Testing first catches crashes before a reviewer does. Check: The README lists the commands and the rollout steps.
The app's web pages on Harakumo
- The same worker.js serves the app's web side: a landing page with store badges once live; /privacy, /terms, and /support with a contact form and email; /delete-account; /return/checkout; an ?app=1 version of the legal pages, without purchase links. They follow the website standard: tab icon, phone layout, footer. Why: Both stores need privacy and support URLs, and Play needs a deletion URL. Check: check_launch passes privacy-page, terms-page, support-page and delete-account-page. On Harakumo: https://<slug>.harakumo.app or a connected domain.
- Deep-link files at /.well-known/apple-app-site-association (no extension) and /.well-known/assetlinks.json: application/json, over HTTPS, with no redirect; built from the Apple team id and bundle id, and from the Android package plus the SHA-256 of the Play App Signing key and the EAS test key; the Apple file includes webcredentials for Password AutoFill. Why: Universal links, app links, password autofill and checkout returns all depend on them. A wrong content type or a redirect breaks them silently. Check: check_launch aasa and assetlinks are valid. On Harakumo: Built by worker.js from env.APPLE_TEAM_ID, IOS_BUNDLE_ID, ANDROID_PACKAGE and ANDROID_SHA256.
- If the app's domain is the owner's own (connect_domain), the associated domains use that domain, and the files are served there. Why: iOS checks the file on the exact domain, and only at install and update. Check: The ios.associatedDomains hosts equal the project's live host.
- /return/checkout redirects to the app's scheme and shows a visible 'Back to the app' button. Only the checkout's return URL has to be https. Why: Universal links often don't fire on a redirect inside an in-app browser. Check: Opening /return/checkout?order=1 on a phone opens the app.
Admin dashboard
- The owner manages the app's data in a web admin on the same Harakumo project, built with the website standard's admin rules: the same database and pool, with the admin role checked on the server for every request. Build it whenever the app has users, orders, bookings, posts or messages. Why: Owners run the business from a computer. Check: /admin answers 403 for a normal user and 200 for an admin.
- Phone-specific admin pages: users, with sign-up and active counts; push opt-in rates; send a notification to a group: preview, confirm, and a count of who receives it; deletion requests and their status; client errors by app version; minVersion and the maintenance message. Why: These are the questions an app owner asks in the first week. Check: The overview shows today, 7 days and 30 days.
- A moderation queue (reported posts, users and messages, with hide, ban and restore) for any app where people post or message. Why: The stores require reports to be acted on within 24 hours. Check: Reports appear in /admin/reports within a minute.
- When the owner works from a phone, add an admin tab shown only to admins, with the same server checks. Describe it in the review notes and give an admin demo account. Why: Hiding a tab isn't security, and an undisclosed feature breaks Apple 2.3.1. Check: The admin API routes check the role. store/review-notes.md lists the tab.
Kinds of phone app
Each kind adds its own layout, screens, features and store notes. An assistant asks for one with get_build_standard (platform 'mobile').
| Kind | Layout | First screens |
|---|---|---|
| Bookings and services (booking) | Style: Clean. Tabs: Explore, Bookings, Inbox, Account. Booking is a flow stepper (service → time → confirm). Reschedule and cancel open as sheets. Archetypes: Home dashboard, Detail with hero, Agenda, Flow stepper, Settings. | Services with prices, durations and photos; Provider profile with reviews; Date strip and open slots in the person's time zone, with the venue's zone shown when it differs |
| Shopping and online store (store) | Style: Clean or Bold. Tabs: Shop, Search, Cart (with a badge), Account (orders inside). Browsing is a stack. Filters and variants are sheets. Checkout is a modal flow stepper. Archetypes: Home dashboard, Catalog grid, Detail with hero, Search, Flow stepper. | Home with categories, featured items and recently viewed; Catalog grid with a filter and sort sheet that shows the result count ('Show 124 results'); Product detail: a pinch-zoom gallery, variants, stock, reviews, a size guide, and a sticky Add to cart bar |
| Food and delivery (food-delivery) | Style: Bold. Tabs: Home, Search, Orders, Account. Menu and cart are a stack. Item options are a sheet. A floating cart bar sits above the tab bar. Live status is a full screen that opens from a push. Archetypes: Home dashboard, Catalog grid, Detail with hero, Flow stepper, Map and list. | Address picker (saved addresses, current location in use); Stores nearby, with open, closed and out-of-zone states; Menu with categories and diet and allergen tags |
| Social and community (community) | Style: Clean or Editorial. Tabs: Feed, Discover, Notifications, Profile. Compose is a header or floating button that opens a sheet, never a tab. Archetypes: Feed, Detail, Profile, Form sheet, Search. | Feed with paging, pull to refresh and a 'New posts' pill; Post detail with comments and replies; Compose: photo picker with crop and reorder, an alt text prompt, a character count, mention autocomplete and drafts |
| Chat and messaging (chat) | Style: Clean. Tabs: Chats, People, Settings. A conversation is a full-screen stack with an inverted list and a composer that sticks to the keyboard. Group info and the media viewer are sheets. Archetypes: Chat thread, Search, Settings. | Conversation list with unread badges, last message and time; Chat: bubbles grouped by sender and time, day separators, a 'New messages' divider, a jump-to-latest button with the unread count, sending, sent and failed states with retry, older messages loaded on scroll up; New chat and people search |
| Fitness and wellness tracker (fitness) | Style: Bold. Tabs: Today, Progress, Library, Profile. Logging is a quick sheet. Timers and players are full screen. Archetypes: Home dashboard, Player, Form sheet, Agenda. | Today with goals and a setup checklist; Log entry with fast inputs; Progress charts by week and month, with a 'log more to see trends' state |
| Education and courses (education) | Style: Clean or Editorial. Tabs: Learn, Courses, Progress, Account; schools add Timetable and Notices. The lesson player is full screen. Quizzes are a stack. iPad support on. Archetypes: Home dashboard, Catalog grid, Detail, Player, Agenda. | Continue learning; Catalog with search and filters; Course detail with its lessons and progress |
| Budget and expense tracker (finance) | Style: Utility. Tabs: Overview, Transactions, Budgets, Settings. Add transaction is a sheet with an amount keypad. Archetypes: Home dashboard, Feed (list), Form sheet, Settings. | Overview: balance, spending by category (chart) and recent items; Transactions with search, filters and paging; Add or edit: amount keypad with the currency, category, date, note, receipt photo |
| Events and tickets (events) | Style: Bold. Tabs: Discover, Tickets, Saved, Account. Organizers get a Scan tab, disclosed in the review notes. Archetypes: Catalog grid, Detail with hero, Flow stepper, Map and list. | Events by date and place, with filters; Event detail: venue, lineup, price and refund policy; Ticket selection and checkout |
| Notes, tasks, habits and journals (productivity) | Style: Clean. Tabs: Today, Lists, Search, Settings. The editor is full screen and saves as you type. Quick add is a floating button. iPad support on, with a sidebar. Archetypes: Home dashboard, Feed (list), Form sheet, Search, Agenda. | Today: due tasks and habit check-offs; Lists or notebooks; Editor that saves as you type |
| Content, brand or creator app (portfolio) | Style: Editorial. Tabs: Home, Explore, Saved, About. Content detail is a stack. Audio uses a mini player above the tab bar. Archetypes: Feed, Detail with hero, Player, Search. | Home feed of the newest content; Article, video or episode detail with reader text size and progress; Categories and search |
| Internal team tool (internal-tool) | Style: Utility. One tab per main job (Tasks, Scan, Records) plus Profile, or a single stack. Invite-only sign-in. Archetypes: Feed (list), Detail, Form sheet, Search. | My assigned tasks; Records with search and filters; Record detail and edit form |
| AI assistant app (ai-app) | Style: Clean. Tabs: Chat, History, Account, or one chat screen with a history drawer. Archetypes: Chat thread, Search, Settings. | Chat with streaming answers in markdown (code blocks with Copy, lists, tables), Stop and Regenerate; History of past chats with search; AI data consent before first use |
| Companion app for a web product (saas) | Style: Utility or Clean. Tabs for the product's main objects (Projects, Inbox, Reports) plus Account. Billing stays on the web. Archetypes: Home dashboard, Feed (list), Detail, Settings. | The product's main lists and details; Quick create; Notifications inbox |
| Two-sided marketplace (marketplace) | Style: Clean. Tabs: Browse, Search, Inbox, Account. Sell is a floating button that opens a listing sheet. Archetypes: Catalog grid, Detail with hero, Form sheet, Chat thread, Search. | Listings grid with filters and saved searches; Listing detail with photos, price, seller rating and Make offer; Create listing: photos, title, price, category, area |
| Dating and meeting people (18+) (dating) | Style: Bold. Tabs: Discover, Matches, Chats, Profile. Archetypes: Detail with hero (cards), Chat thread, Profile, Form sheet. | Profile setup with photos and prompts; Discover cards with Like and Pass buttons (swipe as a shortcut); Matches |
| Listings and rentals (rentals) | Style: Editorial or Clean. Tabs: Explore, Saved, Bookings, Inbox, Account. Archetypes: Map and list, Catalog grid, Detail with hero, Flow stepper, Agenda. | Search with a map and list toggle; Listing detail: gallery, amenities, map preview, availability calendar, reviews; Request or book with dates and a price breakdown |
| Travel and itineraries (travel) | Style: Editorial. Tabs: Trips, Explore, Saved, Account. Archetypes: Agenda, Detail with hero, Map and list, Home dashboard. | Trips list; Itinerary by day; Place detail with a map preview, hours and notes |
| Music and audio player (audio) | Style: Bold or Editorial. Tabs: Home, Search, Library, with a mini player above the tab bar and a full-screen player. Archetypes: Home dashboard, Catalog grid, Player, Search. | Home with continue listening; Album, playlist, show or book detail; Full player: scrubber, speed, sleep timer, queue |
| Loyalty and rewards (loyalty) | Style: Bold. Tabs: Home (stamp card), Rewards, My code, Account. Staff get a Scan screen, disclosed in the review notes. Archetypes: Home dashboard, Detail, Catalog grid. | Stamp card or points balance; My QR code for the counter (bright, works offline); Rewards and coupons with expiry |
| Clinic patient app (telehealth) | Style: Clean. Tabs: Home, Appointments, Messages, Records, Account. Face ID lock on reopen. Archetypes: Home dashboard, Agenda, Flow stepper, Chat thread, Detail. | Next appointment card; Book with a doctor (open slots); Visit detail with the clinic's meeting link |
| Driver or courier companion app (driver) | Style: Utility. Tabs: Jobs, Active, Earnings, Account. The active job is a full screen that hands off to the maps app. Archetypes: Feed (list), Detail, Map and list. | Available and assigned jobs; Active job: pickup and drop-off steps, call and message; Proof of delivery (photo or code) |
Style presets
- Clean: system font, radius 12, color only on actions.
- Bold: large display type, full-bleed color headers, radius 20.
- Editorial: serif headings, warm surfaces, image-led.
- Utility: compact rows, radius 8, dense; for internal and finance apps.
Screen archetypes
- Home dashboard: greeting, one main card, one or two horizontal carousels, a recent list.
- Catalog grid: two columns, fixed 4:5 images.
- Feed: a paged list of posts or items, pull to refresh.
- Detail with hero: stretchy image, sticky bottom action bar.
- Form sheet: a sheet with detents and a SheetHeader.
- Settings: grouped list.
- Profile: header, stats, segmented content.
- Chat thread: inverted list, composer stuck to the keyboard.
- Map and list: a map and a list that mirror each other, with a toggle.
- Agenda: calendar strip and the day's list.
- Flow stepper: checkout or booking, one step per screen, progress at the top.
- Player: full-screen media or timer.
The backend on Harakumo
- One Harakumo project per app. Its edge program is one worker.js at https://<slug>.harakumo.app: the only server the app calls (under /v1/) and the app's web side. Edge programs upload one module with no bundling, so worker.js imports nothing.
- Settings arrive as env.NAME and take effect on the next deploy. setup_app_backend with platform 'mobile' stores HARAKUMO_API_KEY (pinned to the project), the HARAKUMO_AUTH_* settings, HARAKUMO_DATABASE_ID, HARAKUMO_BUCKET_ID and a random APP_SESSION_SECRET. The owner adds APPLE_TEAM_ID, IOS_BUNDLE_ID, ANDROID_PACKAGE and ANDROID_SHA256. The app bundle knows only EXPO_PUBLIC_API_URL.
- Sign-in: the app posts to {API}/v1/auth/<action>; worker.js calls the project's sign-in pool with the client secret, then starts its own device session (a 1-hour access token signed with APP_SESSION_SECRET and a rotating refresh token stored hashed).
- Change password: worker.js checks the current password with a pool login, sets the new one with the pool's admin reset-password action and the project key, and revokes the other sessions. The name and photo live in the app's profiles table.
- Account deletion: the app posts {API}/v1/account/delete with the password or a 6-digit code mailed by {API}/v1/account/delete-code. worker.js deletes the user's rows, files under users/<id>/ and sessions, removes the pool user with the project key, and mails a confirmation. The /delete-account web page runs the same flow, verified by an email code.
- Database: every row carries user_id from the session and every query filters on it. Lists page by cursor (created_at plus id, 20 at a time) with an index on (user_id, created_at).
- Files: worker.js signs an upload link for users/<id>/… with the file's type and size, so it takes exactly that file; the phone uploads straight to storage with progress.
- Mail: receipts, notices, support messages and deletion codes go through Harakumo Mail with the project key. Sign-in codes come from the pool under the project's sender name. Reserved test addresses (example.com, .test, .invalid) are never mailed.
- Push: the app registers its push token with {API}/v1/push/register; worker.js keeps it in devices, checks notification_prefs, writes the inbox row and sends. Two ways to deliver: Harakumo's push API, where the app registers each phone's own device token with the sign-in pool (the register-push action, with the user's token) and the server sends to users by id (POST /api/push/<poolId>/send with the client secret) using the owner's APNs key and Firebase service account, saved once on the pool's Auth page; or the Expo push service with an Expo push token, which this kit's worker.js uses, since its own sessions hold no pool token.
- Payments: worker.js creates a Harakumo checkout link for physical goods, in-person and live one-to-one services, returning to https://<host>/return/checkout, which hands back to the app. Digital goods use the store's in-app purchase.
- AI: the app never calls AI directly. worker.js checks the user's consent, then calls the Harakumo AI gateway with the project key.
- Web pages: /, /privacy, /terms and /support (each also with ?app=1), /delete-account, /return/checkout, and the two /.well-known deep-link files served as application/json with no redirect.
- Setup order: create_project; setup_app_backend (platform 'mobile'); get_starter_files (platform 'mobile': the index, then the parts); set the four app-link settings; deploy_files on the api folder only; check_launch (platform 'mobile' with the introspected config); the owner sets push credentials (in EAS for the Expo push service, or on the pool's Auth page for Harakumo's push API), then builds and submits.
Not on Harakumo yet
- Push: Harakumo's push service has no topics, no send-to-everyone, no scheduled sends, no delivery receipts and no per-category opt-outs yet; the kit's API keeps the categories, each person's choice and the inbox itself.
- Sign in with Apple or Google in the kit: sign-in pools take Apple and Google ID tokens (the sign-in-with action), but the kit's API does not call it yet; wire it there when the app offers either.
- Refresh tokens from pools in the kit: the kit's API does not use them yet, and pool tokens last 7 days by default, so it runs its own sessions (1-hour access tokens and rotating refresh tokens).
- Pool account actions in the kit: pools let people change their password and email and delete their account themselves, with a hosted deletion page, but the kit's API still does these with the project key and has no email change yet.
- Abuse limits on the server sign-in path: pools limit sign-ups from a server (600 an hour per pool, and per address when the server passes it), send at most 5 codes of each kind an hour to one email address, and lock an account for 15 minutes after 10 wrong passwords, but put no per-IP limit on server logins or code requests, so the app's API limits sign-ups, logins and codes per IP itself.
- Checking App Store or Google Play purchases on the server.
- Scheduled runs for the app's API: an agent can run on a schedule, but the kit's edge program cannot, so reminders are scheduled on the phone.
- Realtime channels: chat polls while a conversation is open.
- Deleting a folder in storage: the app's API deletes a person's files one call each.
- Database calls from the app's API share the Harakumo API's rate limits; on a 429, wait as long as it says.
Done checklist
- The icon (no alpha; dark, clear and tinted variants; Android adaptive with monochrome), a home-screen name of 12 characters or fewer, and a launch screen with a dark variant. Why: Required by both stores. Check: Install a preview build and check the home screen and a cold start.
- The app is clearly this business's own: its brand, copy, screenshots and one tailored feature, with no starter defaults left. Why: Apple 4.3 and 4.2.6. Check: check_launch with appSource reports no starter defaults.
- Onboarding is skippable and shown once. Permissions are asked in context, with specific purpose sentences. Unused permissions are removed, checked on the introspected config. Why: Store privacy rules. Check: check_launch with appConfig shows no permission warnings.
- Sign-in: the welcome screen, confirm password, eye buttons, verification and reset by code, Face ID unlock, sessions that refresh, tokens in secure storage, and Sign in with Apple when any social sign-in exists. Why: Complete sign-in is what builders keep asking for. Check: check_launch with testSignIn passes. No token is in AsyncStorage.
- Every data screen has skeleton, empty, error and offline states, pull to refresh and paging. Bottom space, the keyboard and the safe areas are right on the smallest phone and on an iPad. Why: Apple 2.1 and 2.4.1. Check: Airplane mode, the API stopped, a 1000-item list, and an iPad all tested.
- Kit components, the style preset and the archetypes are used throughout. Dark mode, 200% text, 44 pt and 48 dp targets, Reduce Motion and Reduce Transparency all work, and VoiceOver and TalkBack labels and states are set. Why: Professional design and accessibility. Check: The visual pass in qa/ is complete.
- Push is asked in context, the credentials are set, a tap opens the right screen with Back working, the inbox and badge match, settings per category work, and the important events are emailed. Why: Both stores' notification rules. Check: Send a test push to the review account on both platforms.
- Settings has every row. Delete account really deletes, accepts the password or a code, and explains what is kept. The /delete-account page has all four elements. Why: Apple 5.1.1(v) and Play account deletion. Check: Delete a test account, then sign in again: it is refused.
- No secret is in the app. Every sensitive call goes through the API with token, session and ownership checks, and rate limits. Why: Security. Check: The secret search finds nothing, and the API tests pass.
- Payments follow the classes in the plan, and the paywall is complete. Why: Apple 3.1 and Play billing. Check: No digital item calls checkout.
- The store pages and both deep-link files are live. The privacy policy matches the README. Every declaration is answered. The review accounts work from anywhere. The workspace is on a paid plan. Why: Store submission. Check: check_launch with platform 'mobile' reports mustFix 0.
- The web admin exists whenever the app has users or data, with server-side role checks and a moderation queue for posting apps. Why: Owners must run the app without code. Check: /admin answers 403 for a normal user and 200 for an admin.