trunative 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/dist/cli.js +105 -0
  4. package/dist/commands/build.js +35 -0
  5. package/dist/commands/detect.js +91 -0
  6. package/dist/commands/doctor.js +110 -0
  7. package/dist/commands/graph.js +106 -0
  8. package/dist/commands/install.js +76 -0
  9. package/dist/commands/lint.js +87 -0
  10. package/dist/commands/rubric.js +104 -0
  11. package/dist/commands/spec.js +300 -0
  12. package/dist/compile.js +235 -0
  13. package/dist/detect/rules.js +208 -0
  14. package/dist/detect/types.js +36 -0
  15. package/dist/emit.js +77 -0
  16. package/dist/graph.js +288 -0
  17. package/dist/heuristics.js +185 -0
  18. package/dist/lock.js +18 -0
  19. package/dist/mdx.js +84 -0
  20. package/dist/paths.js +62 -0
  21. package/dist/skill.js +69 -0
  22. package/package.json +51 -0
  23. package/src/skills/SKILL.md +125 -0
  24. package/src/skills/flow/build.md +57 -0
  25. package/src/skills/flow/firebase.md +102 -0
  26. package/src/skills/flow/init.md +116 -0
  27. package/src/skills/flow/review.md +186 -0
  28. package/src/skills/flow/spec.md +149 -0
  29. package/src/skills/heuristics/accessibility.md +124 -0
  30. package/src/skills/heuristics/ads.md +140 -0
  31. package/src/skills/heuristics/auth.md +130 -0
  32. package/src/skills/heuristics/background-work.md +129 -0
  33. package/src/skills/heuristics/buttons.md +99 -0
  34. package/src/skills/heuristics/camera.md +127 -0
  35. package/src/skills/heuristics/chat.md +125 -0
  36. package/src/skills/heuristics/colors.md +129 -0
  37. package/src/skills/heuristics/copy.md +157 -0
  38. package/src/skills/heuristics/data-display.md +124 -0
  39. package/src/skills/heuristics/feedback.md +122 -0
  40. package/src/skills/heuristics/forms.md +124 -0
  41. package/src/skills/heuristics/icons-and-imagery.md +135 -0
  42. package/src/skills/heuristics/layout.md +125 -0
  43. package/src/skills/heuristics/lists.md +129 -0
  44. package/src/skills/heuristics/localization.md +128 -0
  45. package/src/skills/heuristics/maps.md +129 -0
  46. package/src/skills/heuristics/media.md +130 -0
  47. package/src/skills/heuristics/motion.md +113 -0
  48. package/src/skills/heuristics/navigation.md +116 -0
  49. package/src/skills/heuristics/network.md +118 -0
  50. package/src/skills/heuristics/notifications.md +121 -0
  51. package/src/skills/heuristics/offline.md +124 -0
  52. package/src/skills/heuristics/onboarding.md +103 -0
  53. package/src/skills/heuristics/payments.md +138 -0
  54. package/src/skills/heuristics/performance.md +111 -0
  55. package/src/skills/heuristics/permissions.md +125 -0
  56. package/src/skills/heuristics/privacy-ui.md +112 -0
  57. package/src/skills/heuristics/scrolling.md +114 -0
  58. package/src/skills/heuristics/search.md +127 -0
  59. package/src/skills/heuristics/sense.md +128 -0
  60. package/src/skills/heuristics/settings.md +129 -0
  61. package/src/skills/heuristics/sharing.md +102 -0
  62. package/src/skills/heuristics/sound.md +95 -0
  63. package/src/skills/heuristics/splashscreen.md +111 -0
  64. package/src/skills/heuristics/states.md +120 -0
  65. package/src/skills/heuristics/touch.md +95 -0
  66. package/src/skills/heuristics/typography.md +99 -0
  67. package/src/skills/heuristics/updates.md +129 -0
  68. package/src/skills/heuristics/webviews.md +114 -0
  69. package/src/skills/heuristics/widgets.md +128 -0
  70. package/src/skills/references/capability-checks.md +59 -0
  71. package/src/skills/references/fonts.json +339 -0
  72. package/src/skills/references/icon-and-image-assets.md +103 -0
  73. package/src/skills/references/input-fields.md +82 -0
  74. package/src/skills/references/launch-surface.md +94 -0
  75. package/src/skills/references/motion-tokens.md +89 -0
  76. package/src/skills/references/navigation-containers.md +51 -0
  77. package/src/skills/references/search-controls.md +49 -0
  78. package/src/skills/references/type-scales.md +60 -0
  79. package/src/skills/references/wireframe-frame.md +209 -0
@@ -0,0 +1,121 @@
1
+ # Notifications
2
+
3
+ A notification is the only interface this app gets to put on a screen belonging to someone who did not open it. On a phone that screen is in a pocket, on a desk in a meeting, or on a bedside table at 3am, and it is read once, for about a second, by whoever happens to be looking at it.
4
+
5
+ So the default answer to sending one is no, and the cost of getting it wrong is not a bad screen. It is the app being switched off, along with the notifications that mattered.
6
+
7
+ The permission prompt itself is `perm-notify-ask`. This file is about what the app sends once it has one.
8
+
9
+ ## <Rule id="notify-earns-it" description="Every send site names the event, and the event is the user's" />
10
+
11
+ Tolerance here is a single account that cannot be topped up. When it runs out people do not silence the noisy kind, they silence the app, and the transactional notification they actually wanted goes with it.
12
+
13
+ Every place in the code that posts a notification names the event behind it, and that event passes three tests:
14
+
15
+ - **It happened to this user or to something this user owns, or the user asked in advance to be interrupted at this moment.** An alarm, a calendar reminder, a dose reminder and a practice reminder all pass on the second branch, because the user scheduled or subscribed to them. Feature announcements, streaks the app invented, "we miss you" and anything measured in re-engagement fail no matter how they are worded.
16
+ - **It could not wait for the next launch.** If it could, it is `notify-inapp`.
17
+ - **There is something to do about it, or it is something the user is waiting for.** Delivered, landed, paid, approved and the emergency alert all qualify with no action attached. What fails is the send nobody was waiting for and nobody can act on.
18
+
19
+ Cross-promotion and advertising of another product sent through notifications are prohibited by the Play Store, so on Android that part is a shipping question rather than a matter of taste. Promoting this app's own product is this skill's rule instead: it needs its own opt-in inside the app, the system grant is not that opt-in, and nothing goes out until the user turns it on.
20
+
21
+ ## <Rule id="notify-channels" description="One channel per kind, so a user can silence one without silencing the app" />
22
+
23
+ Android has required every notification to carry a channel since API 26: post one without a channel and it does not appear at all, the system logs an error and drops it. That requirement is met by a single channel called General, which is how most apps meet it, and one channel is the same as no channels, because the only control it gives the user is off.
24
+
25
+ - Count the kinds of thing the app sends and create that many channels, named for what the user will recognise (Order updates, Mentions, Delivery status) rather than for the system that emits them.
26
+ - The importance, sound and vibration are fixed at creation and belong to the user afterwards. Nothing in the app can change them again; only the user can, from system settings. A channel created at the wrong importance is permanent for everyone who already installed, and correcting it means creating a different channel.
27
+ - iOS has no system-side equivalent. Categories, registered through `setNotificationCategories(_:)`, carry the actions in `notify-actions` and the hidden-preview text, and give the user no per-kind sound, importance or on switch at all. So on iOS the per-kind control lives on a settings screen inside the app, and the app requests `providesAppNotificationSettings` so the system offers a button straight to it.
28
+ - Do not rebuild the Android channel toggles in the app's own settings. Link to the system page for the channel, for the reason `set-system-owned` gives. A product-level preference is a different thing and stays in the app: which kinds this account wants at all, and the promotional opt-in `notify-earns-it` requires.
29
+
30
+ ## <Rule id="notify-level" description="Pick the quietest level that still does the job" />
31
+
32
+ iOS has exactly four interruption levels. `.passive` adds it to the list without lighting the screen or playing a sound. `.active` is the default and presents immediately. `.timeSensitive` breaks through Focus and the notification summary, but only where the user has allowed it. `.critical` bypasses the mute switch and needs an approved entitlement.
33
+
34
+ Android has five usable importance constants: `IMPORTANCE_HIGH` makes noise and peeks as a heads-up, `IMPORTANCE_DEFAULT` makes noise without intruding, `IMPORTANCE_LOW` is silent but sits in the shade, `IMPORTANCE_MIN` sits below the fold and out of the status bar, `IMPORTANCE_NONE` does not show in the shade. `IMPORTANCE_MAX` is documented as unused and is never the answer.
35
+
36
+ `IMPORTANCE_LOW` and above always reach the drawer and the launcher badge, so quiet is not invisible. `IMPORTANCE_MIN` reaches the drawer below the fold but not the status bar, and `IMPORTANCE_NONE` reaches nothing, so neither is the level for something the user is meant to find later. The two loud settings are for things a person would want to be interrupted for at that moment, which is a much shorter list than it looks. One trap worth knowing: `UNAuthorizationOptions.timeSensitive` is deprecated and is a different symbol from the interruption level that does the work.
37
+
38
+ A foreground service notification is the exception that must not go quiet. From API 26 the level lives on its channel, which is created at `IMPORTANCE_LOW` or higher, with `setPriority(PRIORITY_LOW)` covering 7.1 and earlier. Below that the system adds its own message to the drawer telling the user about the service anyway. What earns one at all is `notify-ongoing`.
39
+
40
+ ## <Rule id="notify-quiet" description="Do not engineer around Do Not Disturb" />
41
+
42
+ Android's Do Not Disturb has three levels: total silence blocks every sound and vibration, alarms only lets alarms through, and priority only lets the user pick which categories may interrupt. iOS Focus filters which people and which apps get through. Both are the user telling the phone what it may do, and every symbol that gets past them is gated or restricted for precisely that reason.
43
+
44
+ - Full-screen intents on Android 14 and above are limited to apps that provide calling and alarms, and Play revokes the default grant for anything else. Check `canUseFullScreenIntent()` rather than assuming it.
45
+ - A locally scheduled notification is scheduled against the device's time zone. Where the send comes from a server the client still has a job: report the current time zone alongside the push token, and report it again when it changes, or one UTC send time is the middle of the night for a share of the users every time.
46
+ - Something the user snoozed, muted or filtered stays that way. Reposting the same event on a louder channel to get past a filter is the move that ends with the whole app switched off.
47
+
48
+ ## <Rule id="notify-lockscreen" evidence="device" description="The first line is the whole notification, and a stranger can read it" />
49
+
50
+ Write it to stand alone: what happened, and who or what it concerns. There is no second glance.
51
+
52
+ - Do not open with the app's name. The system already shows it, and repeating it spends the only line there is.
53
+ - Name the specific thing. "You have a new update" is a notification that told nobody anything.
54
+ - Both platforms let the user hide previews while locked. iOS reports it through `showPreviewsSetting` and shows `hiddenPreviewsBodyPlaceholder` from the category in place of the body; Android takes a per-notification `VISIBILITY_PUBLIC`, `VISIBILITY_PRIVATE` or `VISIBILITY_SECRET`, and under private the icon and the content title can still be shown.
55
+ - So the title is written as though a stranger reads it, and the redaction is a variant written on purpose: the alternative notification attached with `setPublicVersion()`, and the placeholder on iOS. Without them the full text leaks because no visibility was ever set.
56
+ - The text still has to survive the longest translation and the largest text size, which is `type-strings`.
57
+
58
+ ## <Rule id="notify-destination" evidence="device" description="The tap lands on the thing the notification named" />
59
+
60
+ - The payload carries the destination and the identifier of its subject, so routing needs no second network call. On Android that arrives through the `PendingIntent` given to `setContentIntent()`, which every notification needs to respond to a tap at all. A push that says "new message" and opens the inbox threw away what it already knew.
61
+ - The handler reads that payload before the app renders its default destination on the warm path, and the cold one is `splash-entry`. Building the stack above the destination, the cold-start test and what happens when the subject is gone all belong to `nav-deeplink`; this rule owns the payload and the tap.
62
+ - Tapping it removes it: `setAutoCancel(true)` on Android, and on iOS the system removes a delivered notification on tap by itself. Whatever the tap resolves settles the badge with it.
63
+
64
+ ## <Rule id="notify-actions" description="Design for two actions, because two is what fits" />
65
+
66
+ Actions belong to the category on iOS and to the builder on Android, and they exist so the common answer does not require opening the app: reply, mark read, accept, snooze. iOS shows up to ten where there is unlimited room and at most two where there is not; Android shows up to three. The constrained presentation is the one people actually see, so two is the number to design for on both.
67
+
68
+ - Order them so the first is the safe one, which on Android is also the first button under the thumb. On a paired watch it stops being decoration entirely: the hardware gesture invokes the first non-destructive action directly.
69
+ - A conversation carries `NotificationCompat.MessagingStyle` and the direct reply action, so the answer is typed from the shade rather than in the app.
70
+ - An action completed from the shade updates the notification and the badge too, or the user does the same work twice inside the app.
71
+ - Nothing destructive and unconfirmed sits on a notification. `touch-destructive` applies with more force on a surface people tap half awake.
72
+
73
+ ## <Rule id="notify-shade" evidence="device" description="One event, one line, cleared once it is dealt with" />
74
+
75
+ - Group related notifications and post a summary: on Android, `setGroup()` on each child plus a separate summary carrying `setGroupSummary(true)` at a constant id; on iOS, the same `threadIdentifier` on every request, with `categorySummaryFormat` naming the stack. Without one, recent Android releases group on the app's behalf and the result is whatever the system decides. Group only where each child is worth reading on its own.
76
+ - Every child in an Android group carries `GROUP_ALERT_SUMMARY`, so the summary is the only thing that makes a sound. The default alerts on all of them, which turns five children plus a summary into six interruptions.
77
+ - Ten notifications about one conversation is one notification about a conversation. Update in place, by posting the same Android id again or by reusing the iOS request identifier, instead of adding another.
78
+ - One event produces one notification across all of the user's devices. Three phones buzzing at once is one bug, not three notifications, and it is settled in the service that sends rather than in the client.
79
+ - When the user handles it somewhere else, on another device or in the app itself, cancel it: by id on Android, `removeDeliveredNotifications(withIdentifiers:)` on iOS. A shade full of things already done teaches people to clear the app without reading it.
80
+ - Something that stops being true removes itself. A queue position, an offer, a match or an arrival time gets a `setTimeoutAfter()` for its lifetime, and anything already stale is dismissed rather than left there to be read as current.
81
+
82
+ ## <Rule id="notify-ongoing" evidence="device" description="A notification that will not go away is for work that is actually happening" />
83
+
84
+ Only a live event holds a permanent place in the shade: playback, a call, navigation, an upload, a delivery on its way. Anything else that stays is a banner the user is not allowed to close.
85
+
86
+ - The work runs in a foreground service posting under a non-zero id, on a channel that follows `notify-level`.
87
+ - From Android 14 the user can swipe that notification away while the service keeps running. Dismissal is not a stop signal and not a crash: the work continues, the notification is reposted when the state changes, and the app never treats its presence as where the state is stored.
88
+ - A journey with a start and an end shows how far along it is rather than repeating one static line. Android 16 has `Notification.ProgressStyle` for exactly this, which is the delivery, the ride and the route.
89
+ - iOS does this job with a Live Activity, which can only be started while the app is in the foreground. It stays active for up to eight hours and on the Lock Screen for up to twelve, so it carries a `staleDate` for the point its content stops being trustworthy and an `ActivityUIDismissalPolicy` for how it leaves. End it when the work ends instead of letting the ceiling end it.
90
+
91
+ ## <Rule id="notify-badge" description="A badge is a count of things waiting, or it is nothing" />
92
+
93
+ - The number is countable and actionable: unread, waiting, needing this person. A badge raised for a promotion is how badges get turned off for the whole app.
94
+ - The number is true. If it says 3 there are 3, and it matches what the app shows on opening. A count nobody believes is worse than no count.
95
+ - Using the app normally clears it. iOS sets an absolute value through `setBadgeCount(_:)`, so the app owns the number and has to set it down as well as up. Android draws its dot from the active notifications by itself, so clearing means cancelling them and a counted number needs `setNumber()`, and channels that should never count (ongoing status, media controls, alarms) carry `setShowBadge(false)`.
96
+
97
+ ## <Rule id="notify-inapp" description="Someone who never allowed notifications still has to find out" />
98
+
99
+ Notifications are off by default for new installs on Android 13 and above, and iOS has always required consent, so a large part of the user base receives nothing. An app that only announces things through push announces them to a fraction of its users.
100
+
101
+ - Everything the app would send has a home inside it: an activity list, an unread mark on the row, a count on a section. That surface is the product, and the notification is a shortcut to it.
102
+ - A notification arriving while the app is open is not a banner. The user is already looking, so it lands where the content lives, quietly, without taking the screen away from the task in hand. On iOS that means returning no banner or sound option from `willPresent`, which is also what happens with no delegate at all; on Android a heads-up does appear over the app's own foreground, so it is suppressed or routed into the screen instead.
103
+ - Check `areNotificationsEnabled()` on Android, and `getNotificationSettings` with its `authorizationStatus` on iOS, before treating a send as delivered.
104
+
105
+ <Check>
106
+
107
+ <Verify rule="notify-earns-it">Every call that posts a notification names its triggering event, that event happened to the user or was scheduled by the user, and anything promotional sits behind its own in-app opt-in.</Verify>
108
+ <Verify rule="notify-channels">Each kind the app sends has its own Android channel, named for the user rather than the sender and not duplicated as an in-app toggle, and on iOS the per-kind switches live in the app with `providesAppNotificationSettings` requested.</Verify>
109
+ <Verify rule="notify-level">Each channel and each payload states its importance or interruption level, none uses `IMPORTANCE_MAX`, and the foreground service's channel is created at `IMPORTANCE_LOW` or higher.</Verify>
110
+ <Verify rule="notify-quiet">Nothing escalates to get past Focus or Do Not Disturb, full-screen intent use is checked at runtime, and locally scheduled sends use the device's time zone while the token registration carries one.</Verify>
111
+ <Verify rule="notify-lockscreen">Every notification's first line names the event without the app name, and anything private sets an explicit visibility with a `setPublicVersion()` or a `hiddenPreviewsBodyPlaceholder` behind it.</Verify>
112
+ <Verify rule="notify-destination">The payload carries a destination and a subject id reached through `setContentIntent()`, the routing runs on a cold launch from a killed process, and the tap clears the notification.</Verify>
113
+ <Verify rule="notify-actions">No more than two actions are relied on and none exceeds the platform's ceiling, the first is non-destructive, conversations use `MessagingStyle` with direct reply, and completing an action from the shade updates the notification and the badge.</Verify>
114
+ <Verify rule="notify-shade">Related notifications share a group or thread with a summary, children alert with `GROUP_ALERT_SUMMARY`, repeat events update in place, and anything handled elsewhere or no longer true is cancelled or times out.</Verify>
115
+ <Verify rule="notify-ongoing">Anything persistent is a real ongoing event, the app keeps working when the user dismisses it, and a Live Activity carries a stale date and an explicit end.</Verify>
116
+ <Verify rule="notify-badge">The badge counts something the user can act on, matches what the app shows, and reaches zero through normal use.</Verify>
117
+ <Verify rule="notify-inapp">Every notification the app sends has an in-app equivalent that works with notifications denied, and a foreground arrival lands in the content instead of presenting as a banner.</Verify>
118
+
119
+ <Device>`notify-destination`, `notify-lockscreen`, `notify-shade` and `notify-ongoing` are answered on a device with the app force stopped and the screen locked, not by reading the payload builder.</Device>
120
+
121
+ </Check>
@@ -0,0 +1,124 @@
1
+ # Offline and the local copy
2
+
3
+ A phone loses the network as a normal condition of use: lifts, tunnels, basements, aircraft, rural roads, a hotel portal that answers every request with its own login page, and a full bar of signal attached to nothing. Sessions that start, end or spend their middle in one of those are a predictable share of all sessions, not an edge case.
4
+
5
+ This file is about the copy the app keeps on the device: what is stored, for how long, what is deliberately never stored, what happens to a write made while disconnected, and what the app becomes when that copy is gone. The request itself, its timeout and its retry, is `heuristics/network.md`. How the screen says offline, pending or stale is `state-offline`, `state-queued` and `state-stale`. Everything here is the layer underneath those three.
6
+
7
+ ## <Rule id="off-local-first" description="The screen reads the store, and the network writes to the store" />
8
+
9
+ The local store is the source of truth for the content the app is expected to be able to show again: what the person already opened, and the core of what they come back for. A response updates the store, and the store updates the screen. For that content, nothing in the view layer waits on a request to draw its first frame.
10
+
11
+ That ordering is what makes the app usable in a lift: rendering runs at disk speed and the radio is free to answer late, or never. A screen whose state starts at loading and is only ever filled by a fetch has no offline behaviour to design, it has a spinner.
12
+
13
+ The scope is the critical subset, not every byte. A collection the device has never held still draws the placeholder in `state-loading`, and a live price, a video call or a search against a server has nothing to render from a store. What the rule forbids is content already sitting on the device, hidden behind a spinner anyway.
14
+
15
+ - Relational or paged content goes in a database (Room, SQLite, Core Data). Small settings go in a key-value store. Blobs go in files.
16
+ - The view model observes the store. A repository that hands the network result back to the caller and writes the cache on the side is keeping two truths, and they disagree the first time a write fails.
17
+ - What is deliberately not stored is a decision rather than an oversight, and it is written down beside the rest of the storage policy in `off-cache-policy`.
18
+
19
+ ## <Rule id="off-sync-scope" description="Decide what is kept on the device, how far back, and what fills it" />
20
+
21
+ `off-local-first` makes the store the thing the screen reads. This rule decides what is in it, which is otherwise whatever the user happened to open. Per collection in `STACK.md`: kept complete or only what was visited, how far back it goes as a count or a window, and which of two ways it fills.
22
+
23
+ - **Pull.** The app asks as a screen needs a page and a paging source backed by the store fills the gaps (`RemoteMediator` on Android). It is the simpler half and the more expensive one, and it fails in exactly the situation this file is about: a long stretch with no signal ends at a screen whose store is stale or empty.
24
+ - **Push.** The server says what changed, so the device can stay offline indefinitely on far less data. It needs a backend that supports synchronisation, which makes it a decision taken with the API rather than inside the app.
25
+
26
+ A bulk fill is deferred work: unmetered, and left to the system to run when it suits the battery (`NetworkType.UNMETERED`, `isDiscretionary`). A fill the user asked for is neither deferred nor budgeted against `net-prefetch`, which spends on the next screen of this session rather than on the working set that has to survive a tunnel.
27
+
28
+ ## <Rule id="off-fresh-marks" description="Every stored record knows when it arrived and whether the server has seen it" />
29
+
30
+ Two fields, not one: the time the value was written, and its origin, meaning confirmed by the server or written on this device and not yet sent. `state-stale` renders the first and `state-queued` renders the second, and neither can render what the schema does not hold.
31
+
32
+ On a device that spends part of every session disconnected, those two fields are the only things separating a cached row from a live one, and there is one screen, so the difference has nowhere else to be shown. The time comes from the record, not from a file's modification date, which says when this device wrote the file rather than when the server produced the value.
33
+
34
+ Add the version or timestamp that `off-conflict` needs at the same time. Retrofitting it is a migration that runs on data already sitting on people's phones, with no earlier value to backfill it from.
35
+
36
+ ## <Rule id="off-cache-policy" description="What is cached, for how long, and what is never cached" />
37
+
38
+ This rule is about what sits on disk. Write it per collection in `STACK.md`: what is stored, its lifetime, and what evicts it. A store with no lifetime grows until the OS deletes all of it at once, which is the worst moment for it to happen. The in-memory tier of the same cache is `perf-memory`, and an image cache with both tiers owes both rules.
39
+
40
+ The never-cached list goes in the same entry: a one-time code, a live price, anything the product is not allowed to keep, named once per collection rather than defended at each read. It is a privacy decision as much as a storage one, because a phone is lost, lent and handed across a counter in a way a desk machine is not.
41
+
42
+ - **Tokens are not content.** On iOS the auth token goes in the Keychain. On Android the Keystore holds keys rather than secrets, so the token is stored encrypted under a key the Keystore holds, and the sign-in credential itself belongs to Credential Manager. Either way it is never a row in the offline store.
43
+ - **Encryption at rest is already the default** for app-private storage on current versions of both platforms. What the app owes is the part the default does not do: raising the protection class where content has to stay unreadable while the device is locked (`FileProtectionType.complete` rather than the class applied automatically), and covering the two places outside app-private storage, which are anything written to shared or external storage and anything the app syncs to a backend.
44
+ - **Settings sync stores are for settings.** The platform key-value store that syncs between a person's devices is sized for preferences (iCloud's holds 1 MB in total), so content does not go in it and neither does anything the queue depends on.
45
+
46
+ None of this becomes a user-facing setting. People expect their content to be available and do not want to manage the storage of individual items.
47
+
48
+ ## <Rule id="off-reclaimable" description="Discardable storage gets discarded, and the app has to survive it" />
49
+
50
+ Both platforms reclaim cache locations under storage pressure: `Library/Caches` and the `URLCache` on iOS, `getCacheDir()` on Android, best-effort buckets on the web. Every read of a cached file checks that the file is still there before using it.
51
+
52
+ So nothing the user made, and nothing the queue needs, lives in a cache directory. Queued writes, drafts and downloads the user asked for go in durable app storage.
53
+
54
+ Backup follows the same line silently. Android Auto Backup always excludes `getCacheDir()`, `getCodeCacheDir()` and `getNoBackupFilesDir()` and cannot be told to include them, with 25 MB per app per user for everything else. On iOS `isExcludedFromBackupKey` resets during common file operations, so it is set on every save rather than once. On the web `navigator.storage.persist()` is a request the browser may refuse, and `estimate()` returns an approximation, so neither is a guarantee to design against.
55
+
56
+ A phone with a full camera roll is the ordinary device, not the low-storage one.
57
+
58
+ ## <Rule id="off-write-mode" description="Every write declares which of three things it is" />
59
+
60
+ Decide per action, in the code that performs it:
61
+
62
+ - **Online only.** It has to reach the server now: a payment, a transfer, a booking. The request still goes out and the failure is the answer, said plainly, with everything the user entered still on screen. Before the tap the control may say what the action needs, a connection, but it is not disabled by a reachability flag: `net-reachability` keeps the check out from between the tap and the socket, and `button-state` prefers a live control that answers when it is pressed.
63
+ - **Local first, then queued.** The write lands in the store immediately and is queued for the server. This is the default for anything the user authored, because the alternative is losing it.
64
+ - **Queued and droppable.** Analytics and logs. Queued, trimmed when the queue is trimmed, never surfaced to the user.
65
+
66
+ A screen where every mutation is optimistic will eventually tell someone in a tunnel that their transfer went through.
67
+
68
+ ## <Rule id="off-queue" evidence="device" description="The queue is durable, identified, and drained by the platform's own scheduler" />
69
+
70
+ - **Durable.** Rows in the database. Not an array in a view model, and not a cache directory, which `off-reclaimable` can empty between the write and the drain.
71
+ - **Identified.** The device generates the entry's id before the first attempt and reuses it on every retry, so a reply lost on the way back becomes one order rather than two.
72
+ - **Drained by the platform.** WorkManager unique work constrained to `NetworkType.CONNECTED` with `Result.retry()` on Android, a background `URLSession` or `BGTaskScheduler` on iOS, the service worker `sync` event on the web with a foreground drain behind it because Background Sync is not available in every browser. A foreground timer does not run while the app is not running, which is most of the day.
73
+ - **Ordered.** Entries drain in the order they were made, and one that depends on an earlier entry never goes before it. A create, an edit and a delete of the same record either collapse to their final state before anything is sent, or carry that dependency explicitly. Drained out of order they produce a 404 on the edit, a row resurrected after its delete, and a child whose parent never landed.
74
+ - **On the scheduler's schedule.** WorkManager backs off exponentially from 30 seconds by default, floors at 10 seconds, and ceilings at 5 hours, with the floor on repeating work under `bg-periodic`. `BGTaskRequest.earliestBeginDate` is a floor with no ceiling. Nothing in the interface promises a time.
75
+
76
+ Cancelling an entry is two operations rather than one: the entry leaves the queue, and the local write it made is reversed. Whether pending work is shown on the item or on a surface of its own is `state-queued`.
77
+
78
+ ## <Rule id="off-session" evidence="device" description="A token that could not be refreshed is not a sign-out" />
79
+
80
+ Refreshing needs a server, so a token that expires on a disconnected device says nothing about whether the person is still signed in. Treating it as a sign-out clears the store the rest of this file rests on and turns a lost signal into lost work.
81
+
82
+ - An expired token with no path to the server leaves the app in its cached form: content still renders, writes still queue, and the credentials are exchanged again on the next request that reaches. Signing the user out is what happens when the server refuses the refresh, never when it cannot be asked. `net-backoff` owns the refresh itself.
83
+ - Sign-out is the other half. A queue with entries in it is drained first, or discarded with the user told what is going, and only then is local data cleared. Wiping the store on the way out deletes work the person watched the app accept.
84
+
85
+ ## <Rule id="off-destructive-offline" evidence="device" description="A delete is answered by how far it reaches, not by whether it is queued" />
86
+
87
+ `state-queued` settles that destructive actions do not queue silently. What is left is which of two mechanisms covers a given delete, and they are not both owed on the same one.
88
+
89
+ The default is the local hold. Mark the record deleted on the device, take it out of the list, and let the drain be what makes it final. While it is still there the undo `touch-destructive` prefers is still open, so nothing has to be asked, and the queue entry is cancelled the way `off-queue` cancels any other.
90
+
91
+ What earns a confirmation is reach. A removal on a synced account lands on every device the person owns, which is as true on a full signal as it is in a tunnel, so it is asked at the tap in both, and only where the hold cannot take it back. The wording of that confirmation is `touch-destructive`. What is forbidden is the silent version: the row disappears, the queue carries it away, and the user finds out on another device a day later.
92
+
93
+ ## <Rule id="off-conflict" evidence="device" description="Somebody's edit loses, and it is never the one still on screen" />
94
+
95
+ Two devices, one account, both edited. Resolve automatically wherever the shape of the data allows it, and design the rest.
96
+
97
+ - The strategy is written down per collection. Last write wins is a choice that needs version or timestamp metadata to work at all, not the accident of which request arrived first.
98
+ - The losing version is kept, and the person is told about it in a quiet, non-blocking marker with a way to open both. Silently discarding the copy the user typed is the failure this rule exists to prevent.
99
+ - Resolution happens as early as the app can detect the collision, before more work is poured into a version that is about to lose.
100
+ - Not an alert, and not at launch. An app that opens onto a modal about sync has spent the user's first tap on its own plumbing. Show the stored copy with the marker on it.
101
+
102
+ ## <Rule id="off-no-cache" evidence="device" description="The empty store happens twice, and one screen answers both" />
103
+
104
+ It happens on first launch, and it happens again after the OS reclaimed everything under `off-reclaimable`. That is the same screen, reached by the same code path, and it must be reachable in testing by clearing app storage rather than only by reinstalling.
105
+
106
+ What it renders is one of two states, and which depends on whether a request can still be made: `state-empty`'s nothing yet on a first launch with a working connection, and `state-offline`'s fourth state when there is no connection to fill it from. What this rule adds is on the storage side: the path is not gated on a first-run flag, and whatever that screen stands on (seeded rows, the app's own help) ships inside the binary, so it is there to be read at the moment the store is not.
107
+
108
+ <Check>
109
+
110
+ <Verify rule="off-local-first">The view layer observes the local store, and content the device already holds renders without waiting on a request.</Verify>
111
+ <Verify rule="off-sync-scope">Every collection names what is kept on the device and how far back, whether it fills by pull or by push, and bulk fills run deferred and unmetered while a user-requested one does not.</Verify>
112
+ <Verify rule="off-fresh-marks">Every cached record carries a written-at time and a synced or pending origin, plus the version conflict resolution needs.</Verify>
113
+ <Verify rule="off-cache-policy">Every collection on disk has a written lifetime and eviction rule, every collection deliberately not stored is named beside them, the token sits in the Keychain or encrypted under a Keystore key rather than in the store, content that must be unreadable on a locked device raises its protection class, and none of it is exposed as a user setting.</Verify>
114
+ <Verify rule="off-reclaimable">No queued write, draft or user-requested download lives in a cache directory, and every read of a cached file handles the file being gone.</Verify>
115
+ <Verify rule="off-write-mode">Every mutation is one of the three modes, and the online-only ones attempt the request and fail with the input kept rather than being disabled ahead of the tap.</Verify>
116
+ <Verify rule="off-queue">Queue entries are rows in durable storage with device-generated ids reused across retries, drained in order by the platform scheduler with dependent writes collapsed or chained, with no promised time in the interface, and cancel removes the entry and reverses its local write.</Verify>
117
+ <Verify rule="off-session">A refresh that fails for want of a network leaves the session and the store intact, and a sign-out drains or explicitly discards the queue before clearing local data.</Verify>
118
+ <Verify rule="off-destructive-offline">A delete stays reversible on the device until the drain makes it final, and a removal that reaches the person's other devices is confirmed at the tap whether or not there is a connection.</Verify>
119
+ <Verify rule="off-conflict">The conflict strategy is written down per collection, the losing version is kept and surfaced quietly, and nothing resolves by discarding what the user typed.</Verify>
120
+ <Verify rule="off-no-cache">Clearing app storage lands on the same screen as a first launch, and that screen shows bundled content rather than a blank.</Verify>
121
+
122
+ <Device>Test the last five with the device actually offline. Airplane mode on a warm app, a write made in it, a force quit, then reconnect, is the one pass that exercises the store, the queue and the drain together. What it proves differs by platform, so read the result accordingly: WorkManager carries on with the app gone, while a kill by the user on iOS stops background transfers until the app is opened again, which is `net-upload`, so there the pass is that the drain resumes at the next launch with nothing lost.</Device>
123
+
124
+ </Check>
@@ -0,0 +1,103 @@
1
+ # Onboarding
2
+
3
+ The first run is the only session where the user has no reason to stay. The app was installed a minute ago, it is competing with everything else on that home screen, and deleting it costs one long press. Every screen between the icon and the first real action is a screen someone can quit on.
4
+
5
+ So the first run is designed as a sequence and measured as one: what the system draws before the app exists, what gets explained, what gets deferred, when identity is asked for, and what the user is finally standing on when it ends.
6
+
7
+ The shape of a permission request is `perm-rationale`. The screen a new account lands on is `state-empty`. The surface the system draws before any of this runs is `heuristics/splashscreen.md`.
8
+
9
+ ## <Rule id="onboard-splash" evidence="device" description="A branded moment goes inside the app, never in front of it" />
10
+
11
+ If the product genuinely needs a branded frame, it belongs at the head of the first run, after launching has finished. This is a screen the app draws, so everything about it is the app's decision, and there is only one thing to decide well: how little of the user's time it takes.
12
+
13
+ - It is short enough that skipping it would not be a feature, and nothing the user came for is waiting behind it. A branded sequence played out before the content is reachable is the failure case in `motion-blocks`.
14
+ - It runs on the first run and not on later launches. A brand gate paid once is a decision; paid five times a day it is the slowest part of the product.
15
+
16
+ ## <Rule id="onboard-screens" description="Three panels of explanation is the ceiling, and skip is on every one" />
17
+
18
+ Count the full-screen panels between launching and the first real screen. Three is the ceiling this file sets, no platform states a number, and zero is a legitimate answer for an app whose home screen explains itself.
19
+
20
+ - Each panel shows the product actually doing the thing. A drawing and a slogan is what a generated first run reaches for by default, which is why the three-dot pager of stock illustrations is the single most recognisable first-run shape there is. If a panel would work unchanged in a competitor's app, it is not carrying anything.
21
+ - Skip is visible on every panel, meets `touch-floor`, and lands on the app rather than on a sign-in screen.
22
+ - Skipping is permanent. The flow does not come back on the next launch, and it stays reachable from settings or help for whoever wants it later.
23
+ - A first run of more than one step says where the user is in it, through a pager or a progress indicator that cannot be mistaken for decoration or for something to tap. Someone who can see two steps left finishes them; someone counting an unmarked sequence quits. Where the steps are fields, `form-steps` owns the rest.
24
+ - The panels are content, so they reflow at the largest text setting rather than clipping the button off the bottom: `type-scaling`.
25
+
26
+ ## <Rule id="onboard-in-place" description="A tour is what gets built when the interface does not explain itself" />
27
+
28
+ Nobody remembers a slideshow about an interface they have not used yet, and a phone has no hover to hang a hint on. Teach at the control, at the moment it first matters.
29
+
30
+ - One tip at a time, one or two sentences, pointing at something visible on the screen the user is already on.
31
+ - A feature that needs more than three actions explained is not a tip. It is a screen that needs redesigning.
32
+ - A tip is dismissible and never blocks the thing it describes. Coach marks that have to be tapped through in order are a tour with a spotlight on it.
33
+ - Do not teach the phone. Scrolling, tabs, back and the share sheet were understood before the app was installed.
34
+
35
+ ## <Rule id="onboard-defer" description="Only what the first use needs happens before the first use" />
36
+
37
+ Sort the setup into two lists: what the app cannot start without, and what can be defaulted now or answered later. The second list is longer than it first looks, and everything on it that stays in the flow is a screen paying rent it does not earn. These screens are read one at a time on a device where the exit is a home swipe, and anything the first run downloads arrives on whatever data the user is standing in.
38
+
39
+ - Ship a working default instead of asking, which is `set-default-first`.
40
+ - No rating prompt and no purchase ask before the user has seen the product work.
41
+ - The first run does not wait on a download. Content packs, models and offline data arrive in the background while the app is already usable.
42
+ - Terms and licensing are not one of the three panels. Where consent is legally required it is one line with a link at the point it applies, not a wall to scroll to the bottom of.
43
+
44
+ ## <Rule id="onboard-ask-order" description="A permission dialog during the first run is the exception, and it costs a screen" />
45
+
46
+ Both platforms want the ask attached to the feature, and the system gives the app roughly one chance per permission. What the explanation says is `perm-rationale` and `perm-purpose-string`. What this rule owns is when it happens, and what the one screen allowed in front of a first-run dialog is shaped like.
47
+
48
+ - Count the system dialogs raised between the app icon and the first real screen. The answer is zero, unless the resource is required for the app to function, in which case it is one, with its explanation in front of it.
49
+ - The notification prompt is not that dialog. It attaches to the moment the user makes something worth being told about, which is after the first real screen by definition: `perm-notify-ask`.
50
+ - The one explanation screen this rule puts in front of a first-run dialog has a single button, worded under `copy-rationale`, and no exit that skips the alert. A decline control sitting there is a rehearsal for dismissing the system alert behind it.
51
+ - A refusal reaches the reduced app that `state-permission` defines, not a wall and not a retry: `perm-no-coercion`.
52
+
53
+ ## <Rule id="onboard-look-first" description="Let them look before they sign up" />
54
+
55
+ The account screen is the most expensive screen in the app: it arrives before anything has been earned, it needs a keyboard on a device that is bad at typing, and uninstalling is one tap away.
56
+
57
+ - An account is required only where the core job needs an identity: syncing, paying, posting, or anything involving another person. Reading, browsing, searching and trying are not on that list, and a store requires access without a login where they are the app.
58
+ - The ask attaches to the moment: sign in at the save, at the checkout, at the post. In a shop the account comes after the purchase, not before the catalogue.
59
+ - Whatever was made before signing in is still there afterwards. Losing the first note to the sign-up is the last thing that app ever gets to do.
60
+ - The sign-in screen says in one sentence why the account exists and what it gets them. "Sign in to continue" is not that sentence.
61
+
62
+ ## <Rule id="onboard-account" description="Offering account creation signs the app up for the rest of it" />
63
+
64
+ - Deleting the account happens inside the app, and the route to it is findable rather than buried in a policy page. Where a social or federated login is offered, disconnecting it is in the app too, and deleting the account revokes the tokens that login issued. This is a store requirement, not a courtesy.
65
+ - The App Store requires that a third-party or social login not be the only option: an equivalent has to sit beside it that takes only a name and an email address, lets that address stay private, and does not collect in-app behaviour for advertising. That requirement lifts for an app whose users sign in with an existing enterprise or education account, for a government or industry-backed citizen ID, and for a client whose whole job is one named third-party service the user signs into to reach their own content.
66
+ - Get there through the platform's own credential UI rather than a hand-built form: Sign in with Apple and passkeys on iOS, Credential Manager on Android, which puts passkeys, saved passwords and federated accounts behind one entry point. What appears first on that screen is `form-autofill`. Neither platform wants an invented authentication scheme, and password-only is below the floor on both.
67
+ - The two platforms want different arrangements and both are satisfiable: on iOS the Sign in with Apple button is no smaller than any other sign-in button on the screen and is visible without scrolling to it; on Android one button opens the system sheet and the providers live inside it.
68
+ - Collect the minimum at creation. Anything else is asked later, marked optional, and refusing it locks no feature.
69
+ - Recovery is on the screen, not behind a support address. A password or a device is going to be lost.
70
+ - Someone who already has an account and is setting up a new phone arrives signed in and past the first run: Restore Credentials through Credential Manager on Android, the platform credential store on iOS. Making an existing user re-authenticate by hand on a new device is the same defect as showing them the first run twice on the old one.
71
+
72
+ ## <Rule id="onboard-first-action" evidence="device" description="The first run ends on the product's own action" />
73
+
74
+ Run it on a device from a clean install and count the taps between the icon and something real. That count is a design decision somebody makes on purpose or inherits by accident, and every screen this file argues about is one line of it.
75
+
76
+ - Real means the product's action, not the app's: a note written, a track played, a receipt scanned. Finishing the tour is not an action.
77
+ - Where the law puts a gate in front of that, identity or age verification, the gate is named as one and designed as one. It is not onboarding to be trimmed, and it is the one thing `onboard-defer` cannot defer.
78
+ - A first run that completes into a blank home screen has failed with a perfect completion rate. That landing screen is the first-use empty from `state-empty`.
79
+
80
+ ## <Rule id="onboard-resume" evidence="device" description="A killed process resumes the step, never the flow" />
81
+
82
+ The system reclaims backgrounded apps without asking, and a setup flow is exactly where a user leaves to fetch a code from another app.
83
+
84
+ - Persist the step as it completes, not a single flag at the end. Coming back to panel one after finishing panel three is how a half finished setup turns into an uninstall.
85
+ - The done flag is written the moment the flow is completed or skipped, and it is read before anything is drawn, so nobody sees the first run twice.
86
+ - Where the user lands is `nav-restore`, what they typed is `form-persist`, and the save points are `state-interrupt`. What this rule owns is the step index and the flag.
87
+ - A notification or a link can arrive before the first run has ever happened. That path opens its destination without replaying the flow: `nav-deeplink`.
88
+
89
+ <Check>
90
+
91
+ <Verify rule="onboard-splash">Any branded frame sits after launching, is brief, blocks nothing, and does not run on later launches.</Verify>
92
+ <Verify rule="onboard-screens">Three or fewer explanation panels, each showing the real product, with skip on every one, skip permanent, progress shown wherever there is more than one step, and the flow findable afterwards.</Verify>
93
+ <Verify rule="onboard-in-place">Teaching happens at the control it applies to, one tip at a time, dismissible, never blocking, and nothing explains the phone.</Verify>
94
+ <Verify rule="onboard-defer">Every setup step left in the flow is one the app cannot start without; the rest have defaults, and no rating, purchase, download or licensing wall sits in the path.</Verify>
95
+ <Verify rule="onboard-ask-order">Zero system permission dialogs before the first real screen, or exactly one for a resource the app cannot function without, behind a single-button explanation screen, with a refusal reaching the reduced app.</Verify>
96
+ <Verify rule="onboard-look-first">The app can be used before an account exists, the sign-in attaches to the feature that needs it, work made beforehand survives it, and the screen says why.</Verify>
97
+ <Verify rule="onboard-account">Account deletion and social disconnection are both reachable in the app, any social login has an equivalent beside it or falls under a named exemption, credentials go through the platform's own UI, a new device restores the session, and only required data is collected at creation.</Verify>
98
+ <Verify rule="onboard-first-action">The first run ends on the product's own action rather than a blank screen, with any legal gate named as one.</Verify>
99
+ <Verify rule="onboard-resume">The step is persisted as it completes and the done flag is read before the first draw, so a process kill resumes the step and a second launch shows nothing.</Verify>
100
+
101
+ <Device>`onboard-splash`, `onboard-first-action` and `onboard-resume` are answered from a clean install on a device, in both appearances, with the process killed mid flow the way the system would kill it. None of them can be settled by reading the router, because a flow that is correct in the file is exactly the one that starts over from panel one.</Device>
102
+
103
+ </Check>
@@ -0,0 +1,138 @@
1
+ # Payments
2
+
3
+ Taking money on a phone is the one design problem where a wrong decision is not a bad experience but a rejected build. Two store policies decide which payment rail is even legal for a given item, and they decide it by what is being sold rather than by what the team would prefer to integrate.
4
+
5
+ Everything after that is a form on a small screen, held by someone who is about to hand over money and is looking for a reason not to.
6
+
7
+ ## <Rule id="pay-rail" evidence="device" description="What is being sold decides the rail, and it is not a preference" />
8
+
9
+ Answer this before drawing anything, because it determines the whole screen.
10
+
11
+ - Digital content or functionality consumed inside the app, which includes subscriptions, in-game currency, levels, premium content and unlocking a full version, goes through the store's own billing. Both stores require it. A licence key, a QR code or a redeemed voucher used to unlock the same thing is the same violation wearing a costume.
12
+ - Physical goods, and services consumed outside the app such as transport, delivery, cleaning, tickets to a live event or a gym membership, must NOT use store billing. These take a normal processor, a wallet or a card.
13
+ - Real-time services between two individuals, such as tutoring or a consultation, may use another method. One-to-many does not.
14
+
15
+ Getting it backwards is a rejection in both directions: store billing on a taxi ride is as wrong as a card form on a game level. When the app sells both kinds, it carries both rails and picks per item rather than per screen.
16
+
17
+ ## <Rule id="pay-steering" evidence="device" description="A link to your own checkout is a storefront question, not a design choice" />
18
+
19
+ On the United States storefront an iOS app may include buttons, external links and calls to action pointing at the developer's own purchase page, with no entitlement. In other storefronts the same link is either gated behind an entitlement or prohibited outright, and Play runs its own enrolment programs for leading users out.
20
+
21
+ So the link is conditional on the storefront the app is actually running in, decided at runtime. An app that ships one link everywhere passes review in one country and fails in the rest, which is the common way this rule is broken. Record in `STACK.md` which storefronts the build enables it for.
22
+
23
+ ## <Rule id="pay-wallet-first" description="The wallet is why paying on a phone is bearable" />
24
+
25
+ A saved card in the platform wallet turns a two minute typing session into one authentication. Treat it as the default path, not as one option among several.
26
+
27
+ - Where wallet credentials exist, the wallet button is the primary payment option, not a peer sitting beside a card form.
28
+ - It is not a separate step or a separate flow reached from somewhere else.
29
+ - It is no smaller than the other payment buttons and does not require scrolling to find.
30
+ - Use the platform's own button API. A redrawn copy of it is both a policy violation and the shape a phishing screen takes.
31
+ - Every choice the purchase depends on, such as size, colour, shipping method or pickup location, is settled before the sheet appears, because the sheet is not the place to go back and change one.
32
+
33
+ ## <Rule id="pay-sheet" description="The system purchase sheet belongs to the system" />
34
+
35
+ The confirmation sheet exists to stop accidental purchases, and the platform is explicit that it must not be modified or replicated. A hand-built screen that looks like it is asking for the store password is the single most dangerous thing in this file.
36
+
37
+ The app's job is what comes before the sheet and what happens after it. The sheet itself is not styled, not wrapped, not preceded by a lookalike, and not dismissed programmatically.
38
+
39
+ ## <Rule id="pay-total" description="The total is visible before the commitment, not after it" />
40
+
41
+ State the full amount to be billed for anything on offer, of any type. On a phone the surprise arrives late, because the screen is short and the fee lands at the bottom.
42
+
43
+ - Shipping, tax and every fee are visible on the screen where the user commits, not one step later.
44
+ - Currency, grouping and decimals come from the locale, which is `l10n-format`, and figures in a column align under `type-strings`.
45
+ - Rounding never flatters: `data-precision` owns what a displayed number is allowed to imply.
46
+ - The price is not smaller than the button next to it, and it does not need a scroll to reach.
47
+
48
+ ## <Rule id="pay-price-source" description="The price is read from the store, never written in the app" />
49
+
50
+ Every price on screen comes from the product object the store returns for that user, already formatted for their storefront. Not a constant, not a config file, not a string in the component.
51
+
52
+ A price typed into the code is wrong in every currency but one, and it is wrong in that one too the moment the price changes, a regional price is set, a sale runs, or tax is included differently. What the user reads and what the system sheet charges then disagree, which is a rejection, a refund, and a support thread.
53
+
54
+ It is the easiest defect to introduce and the hardest to see in review, because a hardcoded price renders perfectly on the reviewer's device.
55
+
56
+ - The amount, the currency and the formatting are the store's, taken from the product query. The app does not build the string, convert a currency, or append a symbol.
57
+ - A struck-through "was" price is only the store's own reference or introductory price. Inventing one to show a discount is promoting a false price, which guideline 2.3.1(a) makes grounds for removing the app and terminating the account, and `pay-honest-paywall` owns the rest of that shape.
58
+ - The product query is a network call that is slow, fails, and returns fewer products than asked for. The screen needs a loading state and a failure state under `state-loading`, and neither of them is a placeholder amount.
59
+ - A product the store did not return is not for sale on this device, and it does not render as a disabled row with a price beside it. Separate that from a query that failed: Play reports each product it could not fetch with a reason for the failure, and a transient one is `state-error` on the paywall rather than an item silently vanishing from it.
60
+ - The same rule holds for anything derived from the price, including the per-period figure on an annual plan and any "save 30%" badge.
61
+
62
+ Search for a currency symbol in the view layer. Every hit is either this defect or a comment.
63
+
64
+ ## <Rule id="pay-subscription-terms" description="A subscription screen has required contents" />
65
+
66
+ Before anyone can subscribe, the screen carries the subscription name, the period, what the money buys during each period, and the billing amount localised for the storefront being sold to. Also on that screen, not one level deeper, a way for an existing subscriber to sign in or restore.
67
+
68
+ - A free trial says plainly that a payment starts automatically when it ends, and when that is.
69
+ - An introductory price states the intro amount, how long it lasts, and the standard price that follows.
70
+ - Changing an existing app to a subscription does not take away what current users already paid for.
71
+
72
+ ## <Rule id="pay-cancel" description="Cancelling is easy or the app is hostile" />
73
+
74
+ The platform owns the actual cancellation screen, so the app links to the system page rather than rebuilding it, which is `set-system-owned`. What the app owns is whether that link is findable.
75
+
76
+ A manage-subscription entry buried several levels down reads as obstruction, and it is called out as such by the platform. It belongs where someone looks for it, next to the account exit that `set-account-exit` places. Cancelling a subscription and deleting an account are different actions with different consequences, and `auth-delete` owns the second.
77
+
78
+ ## <Rule id="pay-restore" description="Restore exists, or a paying customer is locked out" />
79
+
80
+ A reinstall, a new device, a factory reset and a sign-in from another platform are all normal. Any restorable purchase needs a restore path, and the paywall is one of the places it is reachable from.
81
+
82
+ Restore is a labelled control, not a hidden gesture, and it reports what happened: restored, nothing to restore, or failed with a reason under `state-error`. A paywall with no restore turns an existing customer into someone being asked to pay twice.
83
+
84
+ ## <Rule id="pay-card" description="When a card form is the legal rail, it is still a form on a phone" />
85
+
86
+ Typing sixteen digits with a thumb is the most expensive interaction in the app.
87
+
88
+ - Autofill and card scanning are the primary route, which is `form-autofill`, and the fields carry the right keyboard and content type under `form-input`.
89
+ - One column, the number field first, expiry and code side by side only if both stay above the width `layout-column` allows.
90
+ - Nothing typed is lost when a payment is declined, which is `form-persist`. Re-entering a card after a failure is where the sale dies.
91
+ - Ask for what the network actually needs and nothing else. A billing address collected out of habit is three more fields.
92
+
93
+ ## <Rule id="pay-handoff" description="The payment that leaves the app has to come back" />
94
+
95
+ Strong authentication, a bank app, a wallet redirect or a browser step takes the user out of the process mid transaction, and the app may be killed while they are gone.
96
+
97
+ - Treat it as an interruption that must survive, which is `state-interrupt`, with the pending order held where the system can save it.
98
+ - The return arrives as a link and is routed under `nav-deeplink`, landing on the outcome rather than on the home screen.
99
+ - Design for the user who never comes back. The payment may have succeeded anyway, so the app reconciles on next launch rather than assuming failure.
100
+
101
+ ## <Rule id="pay-outcome" description="Pending is a real answer, and a retry must not charge twice" />
102
+
103
+ A payment has four outcomes, not two: succeeded, failed, still pending, and reversed later by a refund or a chargeback. Each gets a state under `state-set`.
104
+
105
+ - A result the app does not yet know is shown as pending with what happens next, never as success and never as a spinner without an end, which `net-timeout` bounds.
106
+ - Every attempt carries an idempotency key so a retry, a double tap or a reconnect cannot bill twice. This is the payment case of `net-dedupe`, and here the cost of getting it wrong is money.
107
+ - A receipt is reachable after the fact, from inside the app, without searching an inbox.
108
+
109
+ ## <Rule id="pay-honest-paywall" description="The paywall is where scam patterns get apps removed" />
110
+
111
+ Tricking someone into a subscription is grounds for removal from the store, and the patterns are well known enough to be worth naming.
112
+
113
+ - The close control is visible, reachable and meets `touch-floor`. A paywall that has to be escaped by a system gesture is the pattern the rule exists for.
114
+ - Terms are legible at the size everything else is, not in the smallest type on the screen.
115
+ - The selected plan is the one the user picked, not a pre-selected annual with the monthly option one tap away in grey.
116
+ - No invented urgency: a countdown that resets on relaunch is a lie the screen tells.
117
+ - What the user gets is described before the price is asked for, and `onboard-look-first` already rules that people may see the product first.
118
+ - Purchases involving children carry the same restraint `ads-children` demands.
119
+
120
+ <Check>
121
+
122
+ <Verify rule="pay-rail">Each purchasable item is on the rail its type requires, store billing for digital goods consumed in the app and an outside processor for physical goods and outside services, with both present where the app sells both.</Verify>
123
+ <Verify rule="pay-steering">Any link to an external purchase page is conditional on the storefront at runtime, and the enabled storefronts are recorded.</Verify>
124
+ <Verify rule="pay-wallet-first">The wallet button is the primary payment option where credentials exist, drawn by the platform API, no smaller than the alternatives and visible without scrolling, with all purchase options settled before the sheet.</Verify>
125
+ <Verify rule="pay-sheet">No screen imitates, wraps or precedes the system purchase sheet with a lookalike.</Verify>
126
+ <Verify rule="pay-total">The full billed amount, including shipping, tax and fees, is on the screen where the user commits, formatted for the locale.</Verify>
127
+ <Verify rule="pay-price-source">Every displayed price, and everything derived from one, comes from the store's product object, with a loading and a failure state instead of a placeholder amount and no invented reference price. No currency symbol appears in the view layer.</Verify>
128
+ <Verify rule="pay-subscription-terms">The subscription screen carries name, period, what is included, the localised price, restore or sign-in, and the automatic charge at the end of any trial.</Verify>
129
+ <Verify rule="pay-cancel">A link to the system cancellation page is reachable from the account area rather than buried.</Verify>
130
+ <Verify rule="pay-restore">A labelled restore control exists, is reachable from the paywall, and reports its result.</Verify>
131
+ <Verify rule="pay-card">Card fields use autofill and the right keyboards, sit in one column, and survive a declined payment with the input intact.</Verify>
132
+ <Verify rule="pay-handoff">A payment that leaves the app holds its pending order across the trip, routes the return to the outcome, and reconciles on next launch when the user does not return.</Verify>
133
+ <Verify rule="pay-outcome">Pending is a rendered state, and every attempt carries an idempotency key so no retry can bill twice.</Verify>
134
+ <Verify rule="pay-honest-paywall">The paywall has a visible close control meeting the touch floor, legible terms, no pre-selected plan the user did not choose, and no countdown that resets.</Verify>
135
+
136
+ <Device>`pay-rail` and `pay-steering` are answered against the store policy that applies to the item and the storefront, not against taste. When the two rails disagree with a product decision, the policy wins and the product decision changes.</Device>
137
+
138
+ </Check>