Care, together, for the small things that matter.
A short shared daily plan for the person in a family who has quietly become the organiser.
Open the live app · Project site · Docs · Release runbook
Collos is not medical advice. It coordinates everyday care between people — who checked in, who is bringing what, what still needs doing. It holds no medical records, offers no diagnosis, and does not replace a qualified care professional. In an emergency, contact local emergency services.
Status, plainly. The app is fully working and self-hostable today, and this repository is its complete history. It is not in the App Store or Google Play yet:
RELEASE.mdis the runbook andeas.jsonholds the build profiles, so the path is finished, but no listing is live and there is no store URL. A care circle shared between phones needs a backend and is not built. Both of those sentences appear on the marketing site too — nothing in this project describes a feature the code cannot do.
In one paragraph. Collos is a React Native app — iOS, Android and a browser build from the same codebase — that gives a family carer a short daily plan for the person they look after: confirm each moment, skip it, or move it to tomorrow in one tap, with the circle showing who checked in and the notes left on the device. It matters because caring for a relative is usually invisible work: it lives in one person's head or a group chat, so nobody can tell what has already been done and the same question gets asked three times. Collos writes the small, repeating things down so the next person does not have to ask. No account, no backend, no medical claims.
Someone in every family becomes the organiser. They are not a nurse and they did not apply. One sibling does the morning call, another does the shopping, a neighbour drops by on Thursdays — and the plan for all of it lives in one person's head, in a group chat, or on a paper note on a fridge.
The failure mode is not dramatic. It is the small, repeating uncertainty:
- "Did anyone check in this morning?" — asked by a sibling who does not want to call and find out they are the third person to call.
- "Was that already done, or did I imagine it?" — the invisible care work that never gets credit because it is never recorded.
- "I can't remember if I asked her about the water." — the same question, asked again, because it was never written down.
The existing tools either want to be a medical record (intimidating, and wrong for this), or a group chat (fine for talking, useless for knowing what is done). The gap is not a bigger app. It is a short, shared, daily plan that survives restarts — and it is small enough that today actually happens.
A care circle around one person, and a plan for today that is short on purpose.
| Setup | Name the person you care for, pick a relationship and a face. About ten seconds, no account, no email address, no password. |
| A plan for today | Four starter moments — a check-in, a water break, fresh air, an evening note — or add your own. Short enough that a day is finishable. |
| Confirm, skip, or move it to tomorrow | Every moment is one tap to confirm. Skipping it is not the end of it: a skipped moment offers Move to tomorrow in one tap, and the deferred day is remembered. |
| A skip you can see | The plan header counts what left the day — "1 moment moved to tomorrow." — and on the day it moved to, the moment returns wearing a Moved here chip. Both halves are on screen, so later never reads as lost. |
| Recurring moments | Any moment can be set to every day, weekdays, or weekends, and today's plan filters on the rule. |
| Revise anything | The ⋯ on every row opens an editor: rename it, change the time, change the rule, move it, or delete it (behind a two-tap arm). |
| A circle that tells the truth | Who is checked in today, and every note kept on this device — with the organiser's own row marked as the owner rather than counted as a check-in, so the number never flatters itself. |
| Notes | One shared note a day per person on the free plan, unlimited with Pro. Written to the same store as the plan, so a note survives a restart exactly like a confirmed moment does. |
| A real purchase path | Offerings, purchase, restore and entitlement gating through RevenueCat — one entitlement, collos_pro, doing one thing. |
| Honest numbers | Progress, counts and stats are derived from stored state on every render. There is no hard-coded "2 of 4" anywhere, and the stats do not count the organiser as a check-in. |
Six captures of the running app, at 1179×2556, taken by npm run screenshots:devpost.
| Onboarding | Today | Circle (free) |
|---|---|---|
![]() |
![]() |
![]() |
| Paywall | Circle (Pro) | Settings |
|---|---|---|
![]() |
![]() |
![]() |
Every line below is a decision, not a gap that nobody noticed. Each one is also stated in the app, on the site, or both — a feature that is described but absent is an App Store rejection, and it is the same drift that once left a stale mockup on the landing page.
- No medical anything. No dosages, no diagnoses, no reminders to take
medication, no "record symptom" fields. Collos coordinates care; it does not
practise it. No
SymptomorAllergyfield exists, and the paywall is not allowed to imply one. - No backend, and therefore no shared circle between phones. The plan lives on one device. The honest way to use Collos together today is one device handed over, which is exactly what the evening note is for.
- No reminders or notifications. They were advertised once, with no notification code behind them, and every mention was removed rather than left as a promise. Re-adding a line is part of shipping the feature it names.
- No accounts, no sync, no analytics, no third-party SDKs beyond RevenueCat. Nothing leaves the device.
- No sample data in the app. Setup creates the first person and the day starts genuinely empty. The only populated plan is one the user asked for.
One React Native codebase, three targets: iOS, Android, and a browser build
through react-native-web. There is no state library, no navigation library and
no UI kit — the shell is App.tsx, the state is a plain object, and every
transition on it is a pure function.
flowchart TB
App["App.tsx<br/>device stage · tab routing · load on mount, save on change"]
Onboarding["OnboardingScreen"]
Today["TodayScreen"]
Circle["CircleScreen"]
Settings["SettingsScreen"]
Overlays["TaskCard · NavItem · Avatar<br/>PaywallModal · AddMomentSheet · AddPersonSheet · NotesSheet"]
Store["storage/careStore.ts<br/>pure transitions over StoredState"]
Shape["data/care.ts<br/>CareRecipient · CareTask · RepeatRule · STARTER_MOMENTS"]
Files["storage/fileStore.ts / fileStore.web.ts<br/>one primitive, two platforms"]
Disk[("Device storage<br/>expo-file-system on native · localStorage on web")]
Hook["purchases/usePro.ts"]
RC["purchases/revenuecat.ts<br/>the only module that imports the SDK"]
Config["purchases/config.ts<br/>platform key · entitlement · preview detection"]
SDK["react-native-purchases"]
RevenueCat[("RevenueCat")]
Theme["theme.ts<br/>colours · spacing · type · motion · elevation"]
Device["platform/device.ts"]
Motion["platform/motion.ts"]
App --> Onboarding
App --> Today
App --> Circle
App --> Settings
App --> Overlays
Onboarding --> Store
Today --> Store
Circle --> Store
Settings --> Store
Overlays --> Store
Store --> Shape
Store --> Files
Files --> Disk
Hook --> RC
RC --> Config
RC --> SDK
SDK --> RevenueCat
App --> Theme
Settings --> Device
Overlays --> Motion
Hook -.->|"a pro boolean and a plan list"| Overlays
Hook -.->|"the notes cap"| Store
The one behaviour worth reading the code for: a skipped moment is not a lost one.
stateDiagram-v2
direction LR
[*] --> Open : setup, the starter plan, or Add
Open --> Confirmed : Confirm
Confirmed --> Open : Confirm again
Open --> Skipped : Skip
Skipped --> Open : Skip again
Skipped --> Deferred : Move to tomorrow
Open --> Deferred : Move to tomorrow, from the editor
Deferred --> Confirmed : Confirm on the day it moved to
Deferred --> [*] : that day passes, and it is an ordinary moment again
Four details behind that diagram, all of them in src/storage/careStore.ts:
- A move resets the status to open. A moment that has been moved has not been
done, and carrying a
confirmedacross a move would let someone tick a future task by skipping a present one. withDeferredMoment(…, null)brings it back to today. The move is a value in a map, not a deletion, so it is reversible with the same function.- A day that has passed un-defers the moment rather than losing it. The plan filters on the date string, so it reappears on its ordinary rule.
- A moment whose day arrives says where it came from. When the deferred day is today, the row carries a Moved here chip, and the header counts what left the current day. The store always kept the fact; until the app showed it, a skip and a deletion looked identical on screen.
sequenceDiagram
autonumber
actor U as Person using the app
participant P as PaywallModal
participant H as usePro()
participant R as purchases/revenuecat.ts
participant RC as react-native-purchases
participant S as App Store, Google Play or RevenueCat Billing
U->>P: taps Continue with Pro
P->>H: purchase(plan)
H->>R: purchasePlan(plan)
R->>RC: Purchases.purchasePackage(...)
RC->>S: store checkout
S-->>RC: receipt
RC-->>R: CustomerInfo
R-->>H: { pro: entitlement collos_pro is active }
H-->>P: pro = true
P-->>U: the notes cap lifts, and the live packages are reloaded
Note over R,S: With no key set for the platform, none of this runs.<br/>Preview mode returns a fully walkable flow that charges nothing<br/>and says so on screen.
| Module | Responsibility |
|---|---|
App.tsx |
The shell: the device stage, tab routing, the nav, the paywall, and the wiring that loads state on mount and saves it on every change. |
src/screens/ |
OnboardingScreen, TodayScreen, CircleScreen, SettingsScreen. Each takes its data and its handlers as props; none of them reach for state directly. |
src/components/ |
TaskCard, NavItem, Avatar, LaunchScreen, PaywallModal, AddMomentSheet, AddPersonSheet, NotesSheet, PersonFields. |
src/storage/careStore.ts |
StoredState and every transition on it — withTaskStatus, withMoment, withEditedMoment, withRemovedMoment, withDeferredMoment, withStarterMoments, withResetDay, withAddedNote — plus FREE_NOTES_PER_DAY and the day-key helpers. Pure functions: state in, state out. |
src/storage/fileStore.ts / fileStore.web.ts |
The only platform-specific storage code: expo-file-system on native, localStorage on web, behind one three-function interface (read, write, clear). |
src/data/care.ts |
The vocabulary: CareRecipient, CareTask, TaskStatus, RepeatRule, plus STARTER_MOMENTS, PORTRAITS and RELATIONSHIPS. It holds no plan. |
src/purchases/config.ts |
Resolves the platform key, the entitlement id (collos_pro) and the project id; decides whether payments are configured at all. |
src/purchases/revenuecat.ts |
The single boundary with the SDK: init, offerings, purchase, restore, entitlement read, and mapping SDK errors to sentences a person can act on. |
src/purchases/usePro.ts |
The hook the UI consumes, so no screen ever imports the SDK. |
src/platform/device.ts |
The rows Settings prints — platform, OS, device, React Native runtime, app identifier — read from whichever build is running. |
src/platform/motion.ts |
useReduceMotion(), so animation can be turned off where the reader asked for that. |
src/theme.ts |
Colours, spacing, type, motion, elevation, and the numeric style that gives figures tabular digits. |
One JSON object per version, written on every change. There is no server-side copy.
version is checked on read and older payloads are upgraded in place, so a plan
written by an earlier build is not thrown away. Reset in Settings clears it,
and the app says out loud that this is permanent, because there is nowhere else
for the data to be.
Every tracked file, with what it is for. node_modules/, dist/ and .expo/ are
generated and ignored.
collos/
├── App.tsx the whole shell: stage, tabs, nav, persistence wiring
├── app.json Expo config: name, scheme, icons, bundle id com.collos.app
├── eas.json build profiles: preview (internal) and production (stores)
├── package.json scripts, eleven runtime dependencies, three dev dependencies
├── tsconfig.json TypeScript config, strict, no emit
├── babel.config.js the Expo preset, unchanged
├── vercel.json builds the browser bundle into dist/ for the app domain
├── package-lock.json the locked dependency tree CI installs from
├── .env.example every environment variable, with empty values
├── .gitignore node_modules, dist, .expo, .env*, .vercel
├── brand.md the palette, the logo sources, the voice
├── RELEASE.md runbook: empty RevenueCat project to a submitted listing
├── SHIPATON.md the Shipaton 2026 entry pack: script, form fields, story
├── SECURITY.md threat scope, what is out of scope, how to report
├── CONTRIBUTING.md the verification contract and the honesty rules
├── CODE_OF_CONDUCT.md how people are expected to behave here
├── LICENSE MIT, © 2026 Sithu Nyein
│
├── .github/
│ ├── workflows/ci.yml typecheck + browser export on every push and PR
│ ├── ISSUE_TEMPLATE/ bug_report.yml · feature_request.yml
│ └── pull_request_template.md the checklist a change has to satisfy
│
├── src/
│ ├── theme.ts colours, space, type, motion, elevation, insets
│ ├── data/care.ts types, starter moments, portraits, repeat rules
│ ├── storage/
│ │ ├── careStore.ts StoredState and every pure transition on it
│ │ ├── fileStore.ts native storage: expo-file-system
│ │ ├── fileStore.web.ts web storage: localStorage
│ │ └── fileStoreKey.ts the one key both platforms write under
│ ├── purchases/
│ │ ├── config.ts platform key, entitlement id, preview detection
│ │ ├── revenuecat.ts the only module that imports the SDK
│ │ └── usePro.ts the hook the UI consumes
│ ├── platform/
│ │ ├── device.ts the device rows Settings prints
│ │ └── motion.ts useReduceMotion()
│ ├── components/ TaskCard.tsx · NavItem.tsx · Avatar.tsx · LaunchScreen.tsx
│ │ PaywallModal.tsx · AddMomentSheet.tsx · AddPersonSheet.tsx
│ │ NotesSheet.tsx · PersonFields.tsx
│ └── screens/ OnboardingScreen.tsx · TodayScreen.tsx
│ CircleScreen.tsx · SettingsScreen.tsx
│
├── scripts/
│ ├── capture-app-screenshots.mjs drives the running app and writes every PNG
│ └── check-readme.mjs lints this file: links, anchors, diagrams, JSON
│
├── tests/
│ ├── careStore.test.mjs 27 behaviour tests over the store's real source
│ ├── smoke.test.mjs the loader runs src TypeScript in Node (2 tests)
│ └── support/ load-ts.mjs · hook-cjs.mjs · fileStore-double.mjs
│
├── assets/
│ ├── logo-source.png the mark, transparent — the source of truth
│ ├── logo-mark.png the mark on its periwinkle square
│ ├── favicon-source.png the flat periwinkle square the icons derive from
│ ├── icon.png iOS/app icon, generated
│ ├── adaptive-icon.png Android adaptive foreground, generated
│ ├── favicon.png browser tab, generated
│ ├── splash.png splash mark, generated
│ ├── avatars/ daniel.png · margaret.png · sithu.png — the bundled portraits
│ ├── make-icons.py resamples the sources into every icon size
│ └── make-og-image.py composes the 1200x630 social preview card
│
├── landing/ the static site — no dependencies, no build step
│ ├── index.html the product page
│ ├── docs.html the written half: architecture, env, troubleshooting
│ ├── privacy.html the policy both app stores require
│ ├── terms.html the terms
│ ├── 404.html self-contained, root-absolute, no stylesheet
│ ├── styles.css one stylesheet for every page
│ ├── script.js nav, hero depth, pointer light, arrival animation
│ ├── vercel.json cleanUrls for the site deployment
│ ├── app-*.png four captures of the running app, one per screen
│ ├── og.png the social preview card
│ ├── logo.png the header mark
│ ├── logo-mark.png the mark on its square
│ ├── favicon.png the tab icon
│ ├── .gitignore ignores .vercel and .env* for the site deploy
│ └── avatars/ daniel.png · margaret.png · sithu.png — the site's copies
│
├── submission/
│ └── screenshots/ 6 PNGs at 1179x2556 — onboarding, today, circle free,
│ paywall, circle pro, settings
└── store/
├── listing.md paste-ready store copy and questionnaires
└── screenshots/
├── appstore/ 6 PNGs at 1320x2868
└── playstore/ 6 PNGs at 1080x1920
- Node.js 20.19.4+, 22.13+, or 24.3+ — the range React Native 0.86 declares
(
^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0); CI runs 20. - For a native run: the iOS Simulator (macOS), an Android emulator, or the Expo Go app on a physical phone.
- Nothing else. No store account, no Apple or Google developer account, and no RevenueCat keys — see preview mode below.
git clone https://github.com/thesithunyein/collos.git
cd collos
npm install
npm start # Expo dev server; press w / i / a, or scan the QR code
npm run web # straight to the browser buildThe browser build is the fastest way to see the app, and it is a real target
rather than a preview shim: it is the same React Native code through
react-native-web, and it is what runs at
collos-app.vercel.app.
npm run typecheck # tsc --noEmit, strict
npm run build:web # the export that CI runs
npm test # behaviour tests over the store's real sourcenpm test runs the behaviour suite over src/storage/careStore.ts — the real
TypeScript source, loaded into Node by tests/support/, with the storage layer
swapped for an in-memory double that honours the same contract. Read
Testing and quality for what is verified, and what the
suite deliberately does not cover.
Copy .env.example to .env and fill in the public SDK keys from your RevenueCat
project. Every value is EXPO_PUBLIC_*; every one of them is public by design
and ships inside the client. Never put a secret key in this file, and never commit
.env.
| Variable | Purpose |
|---|---|
EXPO_PUBLIC_REVENUECAT_IOS_KEY |
App Store purchases. |
EXPO_PUBLIC_REVENUECAT_ANDROID_KEY |
Google Play purchases. |
EXPO_PUBLIC_REVENUECAT_WEB_KEY |
Web purchases, billed through RevenueCat Billing (Stripe). Separate from the native keys. |
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT |
The entitlement that unlocks Pro. Must match the dashboard; the Collos project uses collos_pro, which is also the built-in fallback. |
EXPO_PUBLIC_REVENUECAT_PROJECT_ID |
The proj… id from the dashboard URL. Shown in the app's Settings screen, which is how it is copied onto a submission form. |
In the RevenueCat dashboard you need: a project, a product per store, an
entitlement named to match EXPO_PUBLIC_REVENUECAT_ENTITLEMENT, and a default
offering holding a monthly and an annual package.
EAS builds do not read the local .env. The same five values have to be set as
EAS environment variables per environment, or a store build ships silently in
preview mode.
Subscriptions run through react-native-purchases.
Since SDK 9.7.6 the same SDK covers React Native Web, so the app, the browser
build and the native builds share one entitlements system instead of three.
Pro sells exactly one thing, enforced in code. The free plan keeps one shared
note a day per person (FREE_NOTES_PER_DAY in src/storage/careStore.ts); the
entitlement lifts that cap. Nothing else changes, and every line on the paywall
maps to a behaviour you can test on the paywall screen itself.
If no key is present for the current platform, the app runs in preview mode
rather than failing: the purchase flow is fully walkable, and the UI says plainly
that no payment is taken. This is what keeps the public web demo working, and it is
why a contributor needs no keys. Nothing in src/purchases/ throws — a broken store
connection degrades to preview instead of taking the app down.
Settings → Store connection prints the state a reviewer needs:
| Row | What a configured build shows |
|---|---|
| Status | Connected |
| Billing | Test Store when the build carries a test key, else RevenueCat Billing (web) or the store (native) |
| Entitlement | collos_pro |
| App user ID | $RCAnonymousID:…, assigned by the server |
| RevenueCat project | projaa1359ce |
From a locally built web bundle those rows read Preview mode instead, because no
keys are compiled in. That difference is deliberate and documented in
SHIPATON.md: the frames used for evidence are captured against
production, and the one frame that needs Pro active is captured in preview mode and
labelled as such.
react-native-purchases contains native code, so a real purchase needs a
development build (npx expo install expo-dev-client, then eas build). Inside
Expo Go the SDK falls back to JavaScript-level behaviour, which is enough to check
the UI and the entitlement logic. Restore purchases is not supported on the web at
all, and the app says so instead of showing a confusing failure.
- The plan is written the moment it changes. Every confirmation, added moment,
edit, move, deletion and note goes to device-local storage —
localStoragein the browser,expo-file-systemon a phone — through three functions insrc/storage/fileStore.tsandsrc/storage/fileStore.web.ts. - There is no backend. No account, no sync, no analytics, no crash reporting,
no third-party SDK other than RevenueCat, and no
fetchto anywhere in the app. - Deleting the app deletes the data. There is no copy anywhere else, which is the privacy answer and the data-loss warning at the same time. The app says so, the FAQ says so, and the terms say so.
- The organiser is not counted as a check-in. The circle screen marks their own row as the owner and counts only real confirmations, because a stat that includes the person holding the phone would always look better than it is.
The app and the site share one palette and one type family on purpose: it is what
lets a capture of the running app sit inside the page that describes it without
either looking borrowed. src/theme.ts is the source for the app,
landing/styles.css repeats the same values as CSS variables, and brand.md
holds both plus the reasoning.
Every value was read off the logo, so the mark and the interface cannot drift.
| Token | Value | Used for |
|---|---|---|
| Collos blue | #2F63D6 |
Primary action, brand accents, the hero card |
| Ink navy | #183468 |
Headings, high-emphasis text |
| Periwinkle | #9ABFF3 |
Fills on brand blue, the logo's own square |
| Blush | #DD6B6B |
The heart on the cat's tag; notes and skipped states |
| Muted slate | #5D7099 |
Supporting copy |
| Soft cloud | #F3F7FE |
App background |
| Backdrop | #EDF3FE |
The surround behind the device frame on wide screens |
| Semantic mint | #4FA98A |
Completed care moments |
| Lilac | #7B76DE |
The fourth moment tone, kept distinct from the blues |
| Semantic red | #C75353 |
Recoverable errors only |
src/theme.ts exports one scale per axis, and screens read from it rather than
from raw numbers: space, type, motion, shape, elevation, insets, and
numeric — the last being the tabular-figures style every number is set in, so a
progress value does not jitter as it counts.
- Reduce motion is honoured everywhere, through
useReduceMotion(): entrance animations, the drifting light on the landing page and the sheet transitions all stop, and the pointer listeners are never attached at all. - Text scales, within a bound. Nav labels and pills cap
maxFontSizeMultiplierso a large system font cannot break a row, and rows are laid out to survive the rest. - Every control is announced. The ⋯ and skip buttons are icon-only, and
each carries an
aria-labelnaming the moment it acts on — including the two-tap delete, which announces that it is armed before it will delete. - The tab bar says which tab is selected to assistive technology, not only in
colour:
aria-selectedis set explicitly, becauseaccessibilityStateon atabrole is not translated byreact-native-web. - No medical colour-coding. Mint means a moment is done. It never means a person is well.
The first version of this app set 51 styles at weight 800 and six at 900 against two at 600, and put uppercase letterspaced micro-labels on four different screens. That combination is the visual signature of a generated UI: every element shouts at the same volume, so nothing has hierarchy and the eye finds no calm surface.
The range is now 600 for row titles and labels, 700 for screen titles, numbers and
the one hero card, and nothing above it. Uppercase labels became sentence case
(Care circle, Members, Store connection), and display sizes came down with
them — screen titles 27 to 25, section titles 20 to 18, the hero card title 22 to 19.
The other half of "this looks generated" is density, which is a product decision rather than a styling one. Today carried a greeting, a switcher, a hero card, a section header with two actions, four moment cards each with two bordered buttons, a notes row, a Pro row and a medical disclaimer — eight buttons on one screen, plus a control that wipes the day sitting as a peer of the one button people actually press. Four things moved or went:
| Was on Today | Where it is now |
|---|---|
A second bordered Skip button on every moment card |
An icon-only control pinned to the card's trailing edge. The action is unchanged and still announced to a screen reader; only the chrome went. |
Reset day, beside Add |
Settings, under "Your data", directly above the destructive reset — the reversible one and the destructive one are only distinguishable side by side. |
| The medical disclaimer | Settings, where the same sentence already runs on onboarding, the FAQ, the footer and the docs page. |
| A Pro row 18px below the notes row | The same row below a dashed rule, so the plan's own rows stop reading as one monetisation stack. |
The trade to be aware of: an icon-only Skip is less discoverable than a labelled button, and it is a deliberate bet that one primary action per row reads better than two.
A behaviour suite now exists, and it is deliberately small and deliberately
honest about what it does not cover: the React screens are not rendered in a
test, and nothing here is a UI test. What src/storage/careStore.ts does —
the part of the app that decides what a moment is, when it appears, and what
skipping it means — is tested against its real source:
npm test # 29 tests, Node's built-in runner, zero new dependenciesThe suite runs the shipping TypeScript directly — a module loader strips types
with the app's own typescript devDependency, so there is no build step and no
copy of the code that could drift — and pins the behaviours the app's promises
depend on: a move resets a status, a skipped moment reappears when its day
arrives, an edit keeps its id so a rename cannot untick, a corrupt payload reads
as no state, a failed write never throws. What is verified beyond it, and how:
| Check | Command | What it proves |
|---|---|---|
| Behaviour | npm test |
The store's real TypeScript in Node, storage swapped for a contract-identical in-memory double: recurrence, the deferred map, edit/remove/defer transitions, the never-throws storage guarantees, the one-note-a-day limit. |
| Types | npm run typecheck |
Strict TypeScript across the app: imports resolve, props line up, the stored-state shape has not drifted. |
| Browser bundle | npm run build:web |
The app exports for the target with no native code behind it — the one that breaks first when a dependency drifts. This is also what catches the expo-font trap. |
| Screens | npm run screenshots |
The capture script drives the real app the way a person does — setup, the starter plan, confirm, and the moment editor — and fails if a screen never reaches the state it is supposed to photograph. That makes it a smoke test with a useful side effect. |
| Server state | Settings → Store connection | A production build prints Connected, collos_pro, projaa1359ce and a server-assigned $RCAnonymousID, none of which can be produced by a mock. |
| Persistence | Manual | Confirm a moment, close the tab, reopen it: the confirm is still there, because the payload is written on change. |
The typecheck, the behaviour suite, the documentation check and the export are the
CI job (.github/workflows/ci.yml), so they run on
every push and every pull request.
The suite's limits are stated in its own file: the React screens are not
rendered in a test, and the storage double stands in for expo-file-system and
localStorage behind the same three-function contract. Extending it follows
CONTRIBUTING.md — name the behaviour in the test title, and
run the real source through tests/support/load-ts.mjs rather than a copy.
Both deployments are Vercel, and neither needs a secret in the repository.
npm run build:web # expo export --platform web, into dist/The repository-root vercel.json runs that command and serves dist/. In Vercel the
collos-app project imports this repository with Root Directory ., the Other
framework preset and npm run build:web as its build command — which is what makes
https://collos-app.vercel.app the app. It needs no custom domain: the
*.vercel.app URL is the public address.
Web purchases bill through RevenueCat
Billing, so a configured EXPO_PUBLIC_REVENUECAT_WEB_KEY makes the browser build
transact through RevenueCat; without it the build stays in preview mode. All three
EXPO_PUBLIC_REVENUECAT_* values must be set on this project for the Production
environment — with none of them present it still builds a finished-looking app that
quietly takes no money.
The site has no dependencies and no build step, which is why it lives in landing/
and deploys on its own.
cd landing && vercel --prodIn Vercel the collos project uses Root Directory landing, the Other
preset, no build command and . as the output directory, which is what makes
https://collos.vercel.app the site rather than the app. landing/vercel.json sets
cleanUrls, so the docs page resolves at /docs and the legal pages without
.html. Nothing here depends on a custom domain — the *.vercel.app URLs are
the public addresses, so a DNS lapse at the registrar cannot take the site down.
To preview the site locally, serve the directory (python -m http.server 8123 --directory landing) — opening index.html from the filesystem works for the
markup but not for the root-absolute links.
Every phone frame in this repository is a capture of the running app, taken by
scripts/capture-app-screenshots.mjs. Nothing is drawn by hand, and the populated
plan in the images is one the tour actually created rather than one the build ships
with — because the hero of the landing page used to be a hand-built impression of
the plan screen, and it kept advertising a percentage the app had stopped showing.
The script launches local Chrome or Edge headless, drives the web app over the DevTools protocol, and writes the PNGs. It has no npm dependencies and needs no secrets.
npm run screenshots # landing/ — the frames the site shows
npm run screenshots:devpost # submission/screenshots/ — 1179x2556
npm run screenshots:appstore # store/screenshots/appstore/ — 1320x2868
npm run screenshots:playstore # store/screenshots/playstore/ — 1080x1920Useful flags:
| Flag | Why it exists |
|---|---|
--base-url http://localhost:8081 |
Capture against a local build instead of production. A local build has no keys, so its Settings screen shows preview mode — useful for UI work, useless as evidence. |
--only 04-paywall |
Run the tour only as far as the screens named, and write only those. This is how the paywall shot is taken against production without completing a live checkout. |
--skip-pro |
Drops the paywall and the Pro steps, because Continue with Pro opens RevenueCat's checkout and this script does not drive it — the run would abort on the open modal. |
--set landing |
The four frames the site uses, one per screen. |
The six frames do not all come from the same build, and the split is deliberate:
node scripts/capture-app-screenshots.mjs --set devpost --skip-pro # 01, 02, 03, 06 from production
node scripts/capture-app-screenshots.mjs --set devpost --only 04-paywall # the live offering
node scripts/capture-app-screenshots.mjs --set devpost --base-url http://localhost:8081 --only 05-circle-proFive of the six come from collos-app.vercel.app, because that is the only
place the Settings screen can show a real store row: a local build renders
Preview mode, collos_pro and RevenueCat project: Not set in this build, which
is no evidence of anything. The Pro-state frame is the exception — unlocking Pro
against production means finishing a real card checkout, so it is captured from a
preview build where the unlock is simulated, and the app labels that state on
screen rather than hiding it.
Store sizes are not interchangeable with each other: App Store Connect rejects a
set that skips the largest supported display, which is why 1179×2556 is not
accepted there, and Google Play refuses any screenshot whose longest side exceeds
twice its shortest, which rules out both 1179×2556 and 1320×2868. See
store/listing.md §5.
Icons and the social card are generated from the same two source images:
python assets/make-icons.py # app icon, adaptive icon, both favicons, splash mark
python assets/make-og-image.py # landing/og.png, 1200x630make-icons.py only ever resamples the supplied logo; it never draws it. The
generator it replaced could regenerate a placeholder heart over the real mark — a
trap worth not repeating. The social card exists because a link preview crops to
about 2:1, and a portrait phone screenshot served to one arrives as the middle of
an unlabelled screen.
The site also carries the privacy and terms pages both app stores require,
linked from the footer of every page.
Use --only to run a single frame before a full set. The tour throws
Never reached <step> rather than photographing a screen it could not actually
reach, so a wrong step name fails loudly on one screenshot instead of quietly
producing six frames with one of them wrong.
No listing is live. Everything needed to submit is in the repository; the account work is not done. Say so before quoting a release date to anyone — a first App Store review is typically days, and a first Google Play review of a new developer account can take longer.
RELEASE.md is the full runbook, from an empty RevenueCat project to
a submitted listing, including the mistakes this project has already made once.
store/listing.md holds the paste-ready copy and the privacy,
data-safety and age-rating answers.
| File | What it does |
|---|---|
eas.json |
A preview profile for internal distribution and a production profile that auto-increments build numbers. |
app.json |
ios.buildNumber, android.versionCode, the collos scheme, and the export-compliance answer so the App Store encryption question needs no manual step. |
store/listing.md |
Store metadata, pre-flight gates, questionnaires. |
RELEASE.md |
The runbook, including the traps in §10. |
npm run release:preview # internal build you can hand to someone
npm run release:ios # App Store build
npm run release:android # Google Play build
npm run submit:ios # upload to App Store Connect
npm run submit:android # upload to Google PlayKept because each one cost an afternoon, and a comment that records a trap is worth more than one that restates the line beneath it.
- A floated
expo-fontblanks the web build.@expo/vector-iconsdeclaresexpo-fontas a wildcard peer dependency, so a floating range resolves to the newest release, and anexpo-fontfrom a different SDK callsregisterWebModule, which does not exist in the pinnedexpo-modules-core. The bundle then builds successfully and throws at runtime, leaving a blank white page. It first bit on SDK 51;expo-fontis pinned to~57.0.4for SDK 57 and additionally held by anoverridesentry. The override is part of the SDK upgrade, not a constant: npm refuses to install while it names a version the SDK no longer expects (EOVERRIDE), so it has to move with the pin. Moveexpo,expo-fontand@expo/vector-iconstogether, or not at all. - Centring with
transformbreaks arotateanimation on the same element. The individual transform properties compose withtransformrather than replacing it, andtransformis applied innermost — so atranslate(-50%, -50%)written there is rotated along with everything else, and the element orbits its own anchor instead of turning on the spot. Centre with thetranslateproperty and lettransformcarry only the movement. - A
fill-mode: bothopacity animation silently overwrites the declared opacity. Ending a fade onopacity: 1made a 40%-opacity layer paint at full strength. Each layer animates to its own--fade-tovalue instead. - A capture script can only press what it can name. The element finder tested
innerText || aria-label, which meant an icon-only control — every ⋯ — was unreachable because the fallback never ran. Testing both ways fixed it. - An unrun gate is not a gate. The landing page's arrival animation was on an
IntersectionObserverthat never reported in one environment, which left every section below the fold atopacity: 0forever. It now sweeps on scroll, and the classes that hide content are added by script rather than written in markup, so a reader without JavaScript gets a fully composed page. - A date key must be local.
toISOString()shifts the day for anyone east of UTC, which moves the plan under the user at 5pm.dayKey()buildsYYYY-MM-DDfrom local parts, and dates are compared as strings, which sorts chronologically with nothing to parse and no timezone to get wrong.
There is no backend, no account and no server-side data, so the honest threat model
is a device, not an API. The full policy — scope, what is explicitly not a
vulnerability, and how to report — is in SECURITY.md. In short:
- The
EXPO_PUBLIC_REVENUECAT_*values are public SDK keys, designed to ship in a client; the app rejects secret keys by design. A secret key in this repository would be a critical finding; a public one in the bundle is the intention. .envand every.env.*file is ignored, and.env.examplecontains comments and empty values only.- Report anything exploitable to sithunyein.mailto@gmail.com with the subject
SECURITY — Collos, not in a public issue.
Ideas, bug reports and pull requests are welcome. The two things that matter here
are run the checks and do not describe anything the code does not do;
CONTRIBUTING.md states the full contract, the house style, and
the rules this project holds itself to. By taking part you agree to the
code of conduct.
A pull request is ready when npm run typecheck is clean, npm run build:web
succeeds, and any screen whose layout changed has had its captures re-run and
committed with the change.
In order, and each one is a real piece of work rather than a slogan:
- A shared circle between phones. A backend, and the only thing standing
between this and the product the site describes. The storage module is the seam
it plugs into —
careStoreis pure transitions over a plain object, so the UI does not change. - Scheduled reminders. Deliberately advertised nowhere until the notification
code exists.
expo-notificationsplus the store's notification declarations. - Test coverage beyond the store. The behaviour suite covers the state engine; the React screens are not rendered in a test, and that is the seam to widen next.
- Store listings. The runbook is written and the build profiles exist; what is missing is the account work and the reviews.
- More than one plan a day. Weekly and monthly views of what has been done, for the person who wants to see that the small things added up.
MIT © 2026 Sithu Nyein. Use it, fork it, run it for your own family.
- Expo and React Native — one codebase, three targets, no native modules written by hand.
- RevenueCat — the reason a purchase, a restore and an entitlement do not need a bespoke subscription service.
- Vercel — both deployments.
- DM Sans and Material Symbols on the site.
- The cat is drawn by hand, and the logo is the source of truth for every icon, every colour and every size in this project.







{ "version": 2, "seenOnboarding": true, "organiserName": "Sithu", "activeRecipientId": "person-…", "recipients": [{ "id": "person-…", "name": "Margaret", "relationship": "Mum", "portraitId": "margaret" }], "moments": { "person-…": [ { "id": "morning-check-in", "title": "Morning check-in", "time": "8:00 AM", "tone": "blue" }, { "id": "water-break", "title": "Water break", "time": "10:30 AM", "tone": "orange", "repeat": "weekdays" } ] }, "statuses": { "person-…": { "morning-check-in": "confirmed", "water-break": "skipped" } }, "deferred": { "person-…": { "water-break": "2026-10-01" } }, "notes": [{ "id": "note-…", "recipientId": "person-…", "day": "2026-09-30", "text": "She slept badly.", "createdAt": "2026-09-30T19:04:00.000Z" }] }