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,140 @@
1
+ # Ads
2
+
3
+ Advertising is the one part of a mobile interface designed against the person holding the phone, so most of what governs it is store policy rather than taste. Breaking a rule here is a rejection, a takedown or a suspended ad account, not a critique.
4
+
5
+ A phone hands an ad the whole screen and hands the user one thumb. There is no window frame saying where the app stops and the ad starts, no hover before a tap commits, and every pixel a banner takes is a pixel the content does not get. The rules below all follow from those three facts.
6
+
7
+ An app that carries no ad SDK skips this file. An app that carries one reads all of it, because a single ad unit brings the whole policy surface with it. It starts with the store data declaration: the dependency collects on its own account, so the diff that adds it is the diff that leaves the filing stale, which is `priv-declared`.
8
+
9
+ ## <Rule id="ads-labelled" description="An ad says it is an ad, and never wears the app's clothes" />
10
+
11
+ Both stores require this. Apple's display advertising rule says an ad that interrupts or blocks must clearly indicate that it is an ad and must not manipulate or trick users into tapping it. Play's ads policy bans ads that simulate or impersonate the interface of any app feature, notifications included, and requires that it be clear which app is serving each ad.
12
+
13
+ - On a phone the ad takes the same full-bleed surface every real screen takes, so the container edge is the only thing marking that ownership changed. This skill's implementation of the requirement follows from that: the label lives on the container and is drawn by the app, the plain word, in the app's own type, legible rather than the smallest grey on the screen. Neither store dictates who draws it; drawing it yourself is what makes it survive a creative that would rather it did not.
14
+ - A native unit borrows the row's layout, never its meaning. Styled as a feed item, a search result, a chat message or a system alert, it is the pattern the policies exist to stop.
15
+ - Nothing dressed as a permission prompt, a notification, a download control or a piece of navigation, and nothing the app draws itself pointing at the unit: an arrow, a badge, a count, a caption suggesting a tap.
16
+
17
+ ## <Rule id="ads-close" evidence="device" description="The close control is visible in the first frame and sized like a target" />
18
+
19
+ Apple requires any interrupting ad to provide easily accessible and visible close or skip buttons, large enough to dismiss with ease, and attaches no delay to that anywhere. Play requires ads that interfere with normal use to be easily dismissible without penalty, and only forbids a full-screen interstitial still uncloseable after 15 seconds, dropping to 5 seconds where the app declares a child target audience. Those permissions do not overlap, so build the strict one: an exit present from the first frame.
20
+
21
+ - Where the app draws the surface (a house banner, a sponsored card, an offer), the close control is yours and it takes the platform floor from `touch-floor`. A small X in the corner of a creative is the target that gets missed, and the miss is a click on the ad.
22
+ - Where the SDK draws it, the choice of format and its close configuration is still yours. Run each format on a real device and watch for the frame the control appears in, because some defaults hold the user longer than the strict rule allows.
23
+ - Play states that ads must not interfere with the operation of the device, system or device buttons included. The back gesture keeps working while the ad is up, and it is never the only way out.
24
+
25
+ ## <Rule id="ads-placement" description="The full screen ad marks the end of something, never the start of it" />
26
+
27
+ Play forbids full-screen interstitials that appear unexpectedly, typically when the user has chosen to do something else, and forbids them at the beginning of a level or content segment. Google's own SDK guidance points at the pause between levels. Both are satisfied only when the ad closes the segment the user just finished instead of standing in front of the next one.
28
+
29
+ Where it may not go:
30
+
31
+ - **Launch.** Play bans a full-screen video interstitial before the app's launch surface, and AdMob's placement policy bans interstitials on app load and on exit outright. That ban is about interstitials. The dedicated app-open format is a different product with its own rules, and this file does not settle it: a team using one answers it from that network's own guidance rather than from this bullet. What may hold the launch is `splash-hold`.
32
+ - **Inside a task.** A form being filled, a payment, a message being sent, a video playing. This is where a mis-tap is worth the most, and AdMob names exactly these: a game being played, a form being filled, content being read.
33
+ - **On back.** Returning to a previous screen is navigation, not a break in attention.
34
+ - **Immediately after another interstitial the user just closed.**
35
+ - **Anywhere but this app.** Apple keeps display advertising in the main app binary and out of extensions, App Clips, widgets, notifications, keyboards and watch apps. Play allows an ad only inside the app serving it and names overlays, companion functionality and widgetised ad units as things it may not become.
36
+
37
+ The result is a surface the user did not open. It is not a modal in the sense `nav-modal` means: there is no work to keep, no commit verb to name, and the single exit `ads-close` requires is the whole of it. What does transfer is the system back event, which behaves as `nav-back` describes, and containment plus the return of focus, which is `a11y-focus`.
38
+
39
+ The segment boundary above is the only thing that buys the full screen. Everything else about a surface nobody asked for is `fb-unprompted`, which names this placement as its one exception and keeps the rest: the dismissal remembered for a written period, the plain close rather than a trick one, and the shapes an app may not borrow.
40
+
41
+ ## <Rule id="ads-frequency" description="The cap is a number in the code, not whatever the network sends" />
42
+
43
+ Neither store publishes a frequency figure. AdMob does, as a ceiling on its own publishers: no more than one interstitial after every two user actions. This skill borrows that number as the default wherever the network in use publishes none of its own. Treat it as the maximum and pick something lower.
44
+
45
+ Enforce it at the call site rather than in the mediation dashboard, which changes without a build and is not in the diff. And remember what a session is here: minutes long, interrupted constantly (`state-interrupt`). A counter that resets on every resume is not a cap, so the interval is wall clock time as well as a count.
46
+
47
+ ## <Rule id="ads-reserve" description="The slot is the right size before the ad exists" />
48
+
49
+ An ad comes over the network, so it arrives late, after the reading has started and the thumb is already moving. A container that grows when it fills shoves everything below it and the tap lands on whatever slid into place. Reserve the declared ad size as a fixed height, the same way `icon-reserve` reserves a picture's box. Where the format sizes itself from the device width, the height is available before the request is sent: ask for it and reserve that, rather than letting the container find out on fill.
50
+
51
+ - Decide the no-fill state in advance: keep the space or collapse it, once, not on every refresh. A banner that vanishes and returns on rotation is layout shift on a schedule.
52
+ - A pinned banner shortens the scroll rather than floating over its last row, `layout-chrome`.
53
+ - It also comes out of the first screenful, `layout-fold`. The content budget pays for the banner; the banner does not arrive from somewhere else.
54
+
55
+ ## <Rule id="ads-adjacency" evidence="device" description="Nothing the user aims at shares an edge with an ad" />
56
+
57
+ AdMob's placement policy says an ad may not be placed so that it interferes with navigating or interacting with the app's core content and functionality, and Play's families policy names ads that suddenly appear in areas of the app where the user usually taps for another function. A mis-tap here earns money, and Apple bans both halves of that trade: artificially increasing impressions or click-throughs, and apps designed predominantly to display ads.
58
+
59
+ - `touch-spacing` puts 8dp between two of the app's own targets. An ad edge is worth more, because the mis-tap pays: leave at least 16dp between the ad container and the nearest control, and 24dp where that control is the screen's primary action.
60
+ - The bottom third is where the thumb lands (`touch-reach`) and where the tab bar, the primary button and the banner all want to sit. At least one of the three moves.
61
+ - No ad on a screen that exists for one decision: a confirmation, a payment, a permission rationale, anything destructive.
62
+
63
+ ## <Rule id="ads-rewarded" description="The trade is stated before it starts, and the reward survives the process" />
64
+
65
+ A rewarded ad is one of the two places an ad may interrupt honestly. The other is the end of a segment the user just finished (`ads-placement`); this one qualifies because the user chose it. Tapping the offer is the consent, so the offer has to be complete: what they get, and roughly how long it takes.
66
+
67
+ - Persist the grant on the reward callback, before drawing anything. Google guarantees its own reward callback fires before its dismissal callback; no other network promises that, and a mediation adapter is where the promise goes. So the instruction has to hold without it: grant on the reward callback wherever the SDK raises one, and never on dismissal. The process can be killed while a full-screen ad is up, and the user who watched it is owed the reward either way.
68
+ - What is bought is always an extra. Play states that an app cannot force a user to click an ad, or submit personal information for advertising, before they can fully use it, so the app's own function never sits behind an ad.
69
+ - No dead ends. Declining the offer returns to the screen it was made on, and the exit rules in `ads-close` apply to the rewarded unit exactly as they apply to any other full-screen ad.
70
+
71
+ ## <Rule id="ads-consent" description="The consent state is read when the request is built, and the no path is the normal one" />
72
+
73
+ - **iOS.** An ad SDK that links this app's data to what other companies collected needs the tracking permission and `NSUserTrackingUsageDescription` in the property list; without that key, the app can crash the first time a user opens it. Whether the prompt is owed at all, and how it is introduced, is `perm-tracking`.
74
+ - **Android.** Declare `com.google.android.gms.permission.AD_ID` when targeting Android 13 or above. It is not `androidx.ads.identifier.provider.HIGH_PRIORITY`, which is the provider side of the same feature and belongs to nobody shipping an app. Ask for the ID fresh on every request instead of caching it, and expect a string of zeros from anyone who opted out or deleted theirs.
75
+ - The advertising ID is for advertising and user analytics and nothing else. Play states that outright, so it does not become a device key, a login hint or a join across other data the app holds.
76
+ - Build the request so the non-personalised answer is the default and consent upgrades it. An ad stack that only serves after a yes turns a legitimate refusal into a broken screen.
77
+ - Read the state at request time. Play requires the opt out of interest-based advertising or ads personalisation setting to be verified, and a value captured at launch is stale the moment someone changes it in system settings and comes back.
78
+ - Some signals are barred from targeting whatever the answer was. Apple names health and medical data, school and classroom data, and anything from children. Nothing in those categories reaches an ad request, which is a question about what the app passes to the SDK, not about the consent flag.
79
+
80
+ ## <Rule id="ads-report" evidence="device" description="The user can see why this ad reached them, and report it, without leaving the app" />
81
+
82
+ Apple requires both halves: all the information used to target an ad has to be visible without leaving the app, and the app has to include a way to report an inappropriate or age-inappropriate ad. Where a unit ships an affordance, open it and watch where it lands, because one that opens a browser is the failure rather than the pass, and check it renders and responds on the smallest supported screen at the largest text size. Where a format has none, the app supplies the route itself, next to the reporting path in `set-diagnostics`.
83
+
84
+ Apple also requires the served creatives to suit the app's own age rating. That is a setting on the ad unit, not a property of the ad stack: put the content rating ceiling on the unit rather than leaving it at whatever the network defaults to, which is how a 12+ app ends up carrying 17+ creatives. The child audience case is a harder fork, `ads-children`.
85
+
86
+ ## <Rule id="ads-a11y" evidence="device" description="Someone using a screen reader has to be able to get out" />
87
+
88
+ Third-party creative is content nobody on the team wrote and nobody can relabel, so the app owns the frame around it.
89
+
90
+ - The close control is a real control with a name (`a11y-name`). A full-screen ad is a presented surface, so it takes containment from `a11y-focus` exactly as a sheet does: focus moves into the ad and cannot walk the screen underneath, the close control is reachable from inside it, and focus returns where it was on dismissal. That is the containment move `a11y-focus` already describes, not a new one.
91
+ - The container announces itself as an ad, and the creative under it is one stop rather than a walk through every element inside it.
92
+ - Drive one interstitial and one banner with the reader on before shipping, as `a11y-test` requires of any flow.
93
+
94
+ ## <Rule id="ads-entitlement" description="Someone who paid to remove ads has to stop seeing them everywhere" />
95
+
96
+ An app that sells any removal of advertising (a one-off unlock, a paid tier, a subscription, or a bundle that happens to include it) carries an entitlement, and every ad call site reads it before requesting anything. Not before showing: before requesting, so nothing is fetched, no impression is counted and no data leaves the device.
97
+
98
+ One missed call site is the whole feature failing, because the user only has to see one ad to know they were charged for nothing. That is a refund, a one-star review naming the exact screen, and the store's own complaint route.
99
+
100
+ - The check lives with the ad request, in one place both the banner and the interstitial paths go through, not repeated per screen where a new screen forgets it.
101
+ - The entitlement resolves before the first ad opportunity, including on a cold start with no network. Unknown is not treated as unentitled: a paying user offline is still a paying user, and the last known state is what the app acts on until the store answers.
102
+ - It survives reinstall and a new device through restore, which is `pay-restore`. An entitlement that only lives in local storage is lost with the app.
103
+ - Removing ads removes the space too. A paid user does not get the reserved slot from `ads-reserve` as a blank rectangle, and the layout closes up.
104
+ - Anything the paid tier still shows, a house promo or a cross-sell for another app of yours, is still an ad under `ads-labelled`, and selling their removal and then showing them is the pattern users report.
105
+ - Apple's Developer Code of Conduct, guideline 5.6, names charging for features or content that are not delivered as conduct that terminates the developer account. An ad shown to someone who paid to remove ads is that sentence, with the receipt attached.
106
+
107
+ ## <Rule id="ads-cost" description="The ad stack is paid for in launch time, memory and the user's data" />
108
+
109
+ - Initialising an ad SDK is not launch work. Keep it off the path to the first frame, `perf-cold-start`.
110
+ - The Google Mobile Ads SDK treats a preloaded ad as stale after an hour, and endorses a cache of them cleared and reloaded on that hour. This rule overrides that: hold one at a time, because a pool of full-screen creatives is the largest thing the app keeps for nothing, and `perf-memory` is what the phone kills the app over.
111
+ - Video creatives are the heaviest thing the app fetches and nobody asked for them. Do not prefetch them on a metered connection, `net-metered`.
112
+ - Each SDK is download size the user sees before any of the design does, `perf-size`, and mediation adds one per network.
113
+
114
+ ## <Rule id="ads-children" description="A child audience declaration forks the ad design per store" />
115
+
116
+ Neither store binds on who is observed using the app. It binds on what the app declared: submission to Apple's Kids Category, and a child or mixed target audience declared on Play. An app children use that made neither declaration is not under these rules, and one that made them is, whoever ends up holding the phone.
117
+
118
+ Apple's Kids Category rule keeps third-party advertising out altogether, with a narrow exception for contextual services that publicly document their practices for that category and put human review on the creatives. Play's families policy allows ads and constrains them instead: certified ads SDKs only, no interest-based advertising or remarketing, no interstitial immediately on app launch, nothing uncloseable after 5 seconds including rewarded and opt-in formats, one banner or video per page, and no offerwall or immersive format that is not clearly distinguishable from app content.
119
+
120
+ No single configuration satisfies both. Decide from `PRODUCT.md` whether the app makes either declaration, and where it does, record the per-store ad configuration in `STACK.md` before any unit is added.
121
+
122
+ <Check>
123
+
124
+ <Verify rule="ads-labelled">Every ad container carries a visible label drawn by the app, and no unit is styled as a feed row, a system alert, navigation or a download control.</Verify>
125
+ <Verify rule="ads-close">Every ad the app can show has an exit present in its first frame, meeting the platform touch floor, with the back gesture still working.</Verify>
126
+ <Verify rule="ads-placement">No interstitial call site sits at launch, on exit, on back, inside a task or at the start of a segment, and no ad unit is built outside the app itself, in an extension, a widget, a notification, a keyboard or a watch app.</Verify>
127
+ <Verify rule="ads-frequency">A frequency cap with a written number and a time interval is enforced in code, not in the mediation config.</Verify>
128
+ <Verify rule="ads-reserve">Every ad slot reserves its declared size before it fills, has a decided no-fill state, and shortens the scroll rather than covering it.</Verify>
129
+ <Verify rule="ads-adjacency">At least 16dp separates every ad container from the nearest control and 24dp from a primary action, and screens that exist for one decision carry no ad at all.</Verify>
130
+ <Verify rule="ads-rewarded">A rewarded offer states what is traded, grants on the reward callback and persists it, and nothing the app is for sits behind an ad.</Verify>
131
+ <Verify rule="ads-consent">The consent state is read at request time, the non-personalised request is the default path, `NSUserTrackingUsageDescription` is in the property list and `com.google.android.gms.permission.AD_ID` is declared where the build targets Android 13 or above, and no health, classroom or child data reaches a request.</Verify>
132
+ <Verify rule="ads-report">Targeting information and an ad report route are both reachable without leaving the app, and the unit carries a content rating ceiling matching the app's own age rating; a codebase that ships only to Android answers this not applicable.</Verify>
133
+ <Verify rule="ads-a11y">The close control has a name, the ad contains focus and returns it on dismissal, and the creative is a single stop for the reader.</Verify>
134
+ <Verify rule="ads-entitlement">Where any purchase removes advertising, one entitlement check guards every ad request rather than each display, resolves before the first opportunity, treats unknown as the last known state, survives restore, and collapses the reserved slot.</Verify>
135
+ <Verify rule="ads-cost">Ad SDK initialisation is off the cold start path, the preload pool is one, and video is not prefetched on a metered connection.</Verify>
136
+ <Verify rule="ads-children">Where the app is submitted to the Kids Category or declares a child target audience, the ad configuration is per store and written into `STACK.md`.</Verify>
137
+
138
+ <Device>Four of these are only half answerable from a diff, because the SDK draws what the file cannot show. On a device, run every ad format the app can serve and watch which frame the close control appears in (`ads-close`), open the targeting and report affordances and confirm they stayed inside the app at the largest text size on the smallest supported screen (`ads-report`), drive one interstitial and one banner with the screen reader on (`ads-a11y`), and measure the distance from the rendered ad container to its nearest control (`ads-adjacency`). A call site is where the rest of the file is checked; these four are settled on the running screen.</Device>
139
+
140
+ </Check>
@@ -0,0 +1,130 @@
1
+ # Identity and the session
2
+
3
+ Signing in is the one screen where a phone is worst at everything it does: a keyboard covering half the display, characters that are masked as they are typed, and a device that is handed around, backgrounded mid task and killed without notice. Every character of a password costs more here than anywhere else, so most of this file is about not asking for one.
4
+
5
+ Whether an account is needed before the first useful action is `onboard-look-first`, and what offering account creation obliges the app to do is `onboard-account`. This file is the rest of the life of an identity: the returning sign-in, what a fingerprint result is allowed to mean, the session ending in the middle of a task, which account is acting, getting out, and getting deleted.
6
+
7
+ The field mechanics belong to forms: `form-input` and `form-autofill`. The biometric prompt as a surface, and the eight ways it can end, are `sense-biometric`. System permission prompts are a different thing and live in `permissions.md`.
8
+
9
+ ## <Rule id="auth-methods" evidence="device" description="Every route on the screen reaches the same account" />
10
+
11
+ Count the sign-in routes a returning user can tap without scrolling. The count is not the rule: what fails is two routes that open separate accounts for the same person, which is where somebody taps the wrong logo and lands in a second account holding none of their data. Which routes have to be on that screen at all, and how they are sized against each other, is `onboard-account`. The order they appear in is decided once and does not reshuffle between visits.
12
+
13
+ - On Android the first route is Credential Manager, which `onboard-account` already requires: passkeys, saved passwords and federated accounts arrive inside that one sheet rather than as buttons beside it. The sheet is dismissible, so a persistent control sits beside it to reopen the flow without restarting the app, and that control is the entry point rather than one more provider.
14
+ - On iOS there is no such sheet and nothing federated lives inside the platform route. Saved passwords and passkeys are offered on the username field itself, which is `form-autofill`, and Sign in with Apple is a button sitting beside that field under `onboard-account` rather than an item inside it.
15
+ - Prefer a passkey where the app is not already offering Sign in with Apple, and on Android wherever Credential Manager is the entry point. Where the app still accepts a password, the passkey is offered at the first successful sign-in that does not use one.
16
+ - Registering or asserting a passkey needs the app and the site associated first, and each side has its own file and its own failure: the `webcredentials` associated domain on iOS, where a missing one returns an error, and on Android an `assetlinks.json` at the domain's well-known path carrying the login-credentials relation for the app's package and its release signing fingerprint, where a mismatched package or fingerprint fails both create and get. The association ships with the button or the button does not ship.
17
+
18
+ ## <Rule id="auth-provider-button" description="A provider button is a component, not a style" />
19
+
20
+ Apple ships a component and Google ships artwork, so the two are built differently. Take Apple's wherever the stack can reach it: `ASAuthorizationAppleIDButton` in UIKit, `SignInWithAppleButton` in SwiftUI, or a wrapper that renders one of those. A hand-built copy where the component is reachable loses the approved appearance, the automatic translation and the accessibility label that came with it. Only where the stack cannot reach it, Flutter included, is the button a replica, and then it owes the published specification exactly. Google publishes marks and a specification rather than a button, so that one is always built and always measured against the specification.
21
+
22
+ - Both publish the same three titles: sign in with, sign up with, or continue with. Apple's component enforces them; on Google's side what is unmodifiable is the mark, so a button carrying anything else is checked against the mark rules rather than assumed safe. Both titles translate with the rest of the app, under `l10n-strings`, and localizing them is expected rather than tolerated.
23
+ - Apple's artwork stops being compliant below 140x30pt, with clear space of one tenth of its height around it, and the logo-only form is a PNG only at 44x44pt and vector at every other size. That 30pt is the floor for the artwork, not for the control: 44pt is already Apple's recommended default button height, and either way the hit area answers to `touch-floor` while the artwork keeps its own minimum and its clear space inside that hit area.
24
+ - Google's mark keeps its standard colors at its standard size: never monochrome, never the letter alone without the button around it, and the button preserves its aspect ratio.
25
+ - The narrow column is what breaks these. A full-width stack of buttons stretches a fixed-ratio asset, so scale the button and let the artwork keep its ratio inside it.
26
+
27
+ ## <Rule id="auth-web-flow" description="The provider's page opens outside the app" />
28
+
29
+ A provider sign-in that opens a web page opens it in the system authentication session: `ASWebAuthenticationSession` on iOS, Custom Tabs on Android. `SFSafariViewController` is not one of these. It is a browsing surface: what happens inside it is not visible to the app, and nothing guarantees the callback comes back to the app that opened it rather than to another app registering the same scheme. An embedded web view under the app's own control is worse still, refused outright by the provider, and it puts somebody else's password field inside a surface this app can read.
30
+
31
+ - The return leg lands back on the screen the flow started from, with its state, rather than on a fresh stack or the home screen. It arrives through the session's own callback on iOS, a registered callback scheme or, from iOS 17.4, an https callback, and through a claimed app link on Android.
32
+ - A cancelled or failed return leaves that screen intact and says what happened beside the route that failed, under `form-error`.
33
+
34
+ ## <Rule id="auth-magic-link" description="A sign-in link has to survive the trip through a mail app" />
35
+
36
+ - The link is a claimed universal link or app link, so tapping it opens the app rather than a web view inside the mail client, where the session waiting for it does not exist. Its landing is `nav-deeplink`.
37
+ - The screen that is waiting is going to be backgrounded and is often process-killed before the link is tapped. It comes back under `state-interrupt` and finishes there, rather than restarting the flow with a second link.
38
+ - A link opened on a different device from the one waiting says so and offers the code route in `auth-code-screen` instead of failing silently, and the link is spent when the person acts on it rather than when something fetches it, because a mail scanner or a link preview fetches it first.
39
+
40
+ ## <Rule id="auth-last-used" description="The phone remembers which door this person used" />
41
+
42
+ A phone is one device belonging to one person far more often than a browser is, and that is the fact a sign-in screen should be spending. Store which method succeeded on this device and mark it on the next visit, because a returning user offered four identical buttons and no memory is how one person acquires three accounts and files a ticket saying their data disappeared.
43
+
44
+ - The marker names the method, never the account: "you used Google here" and not the address, because the device gets handed over and the sign-in screen is visible before anyone authenticates.
45
+ - Where a new sign-up arrives with an address that already has an account, offer to link the two instead of creating the second.
46
+
47
+ ## <Rule id="auth-code-screen" evidence="device" description="The code lands on the device that is showing the field" />
48
+
49
+ One field with the one time code content type, paste never blocked, is `form-autofill`. The screen around it is this rule, and it exists because the user has to leave the app to read the code and the app has to still be there when they get back.
50
+
51
+ - Leaving for the messages app and returning restores the code screen with its state, not the start of the flow. The mechanism is `state-interrupt` and `nav-restore`.
52
+ - The screen says where the code was sent and lets that be corrected without restarting the sign-in. Resend exists beside it and is disabled behind a visible countdown, so the control is never dead with no explanation.
53
+ - An autofilled code may submit itself once. A rejected code returns to an editable field with a message beside it, under `form-error`, and never back to the first screen.
54
+
55
+ ## <Rule id="auth-biometric-session" evidence="device" description="Rule on the API, not on the sensor" />
56
+
57
+ The division is the API and not the gesture, and the same face drives both. A credential API assertion, which is what a passkey is, signs the user in: the verification gesture releases a key and the server verifies what comes back. A `LocalAuthentication` or `BiometricPrompt` success does not: it re-authorizes a session that already exists. The credential API is the sign-in on every platform, the prompt is the re-authorization, and the bullets below land on the prompt alone.
58
+
59
+ - A prompt result proves the enrolled owner of this device is present. It tells the server nothing about who the account is, so it is not a second factor and it is never the only way into an account. Locking an app the user is already signed into behind that check is the case it is for, and that lock keeps the device credential behind it.
60
+ - The switch that turns that lock on decides whether this app asks and never whether the device biometric is on: on Android an in-app control for it is the documented pattern, while iOS discourages a standalone opt-in for biometric authentication, so on both it is named for the thing it protects and sits beside it rather than standing alone as a biometric preference.
61
+ - The non-sensor route to the same place, the enrolment route where nothing is enrolled yet, and the eight ways the prompt can end, are `sense-biometric`. What this rule adds is that the route exists in the same session, because a wet hand on the payment screen is not a reason to sign out. Name the fallback control from the platform rather than from one shared string: Android's device credential is the user's PIN, pattern or password, while on iOS a passcode is the device unlock and Apple's own services, so nothing this app owns is called one.
62
+ - Where the check gates money, credentials or identity documents, require the strong class. Android names them Class 3 and Class 2, and accepting whatever is enrolled accepts the weak one.
63
+ - Two authenticator sets do not work on API level 29 and below: `DEVICE_CREDENTIAL` alone, and `BIOMETRIC_STRONG | DEVICE_CREDENTIAL`, which is the usual way to satisfy the bullet above. On those releases check for a PIN, pattern or password with `KeyguardManager.isDeviceSecure()` instead of asking the prompt for it.
64
+
65
+ ## <Rule id="auth-expiry" evidence="device" description="Expiry interrupts the task, it does not restart the app" />
66
+
67
+ Tokens die while the app is backgrounded, which on a phone is most of the time, so the expiry is usually discovered on the way back into a half-finished screen. That screen is what is at stake.
68
+
69
+ - A refresh that could not reach the server is not a sign-out. That distinction is `off-session`.
70
+ - What was typed is still there afterwards: `form-persist`. Where the person lands is the screen they were on, with its scroll offset and its sheet, under `nav-restore`, and never the home screen.
71
+ - Re-authentication arrives over the task as a modal, under `nav-modal`, rather than as a navigation that unwinds the stack the task was living in, and several requests expiring at once produce one prompt over that task rather than one per request. Deduplicating the refresh underneath and replaying what failed is `net-backoff`.
72
+ - The session lifetime is recorded in `STACK.md` and read from one constant at every call site, rather than scattered as literals.
73
+
74
+ ## <Rule id="auth-reauth" description="A sensitive action asks again, and the window is written down once" />
75
+
76
+ An unlocked phone is regularly in someone else's hands, which is why a live session is not proof of anything for the actions below.
77
+
78
+ - Ask again for: changing the password, the email address or the phone number; adding or editing a payout or payment destination; revealing a full card or document number; exporting the data; deleting the account.
79
+ - The re-authentication window is recorded in `STACK.md` and applied from there at every call site, because three literals become three windows.
80
+ - Never draw a surface that imitates the system biometric or credential prompt, which is `sense-biometric`: that is the one thing the user has no way to see through. The app's own password or PIN challenge is a legitimate route, and where the sensor is unavailable the device credential is the fallback rather than a dead end.
81
+ - Ordinary activity does not extend the window. Reading is not proving.
82
+ - A refused re-authentication returns to the screen with the action untaken and says so. It is never a sign-out.
83
+
84
+ ## <Rule id="auth-active-account" description="The screen that acts names the account acting" />
85
+
86
+ A phone has no window title and no persistent chrome to keep an avatar in, so on a device carrying a work account and a personal one, the identity has to be on the screen where the action happens.
87
+
88
+ - Anything that posts, pays, sends, uploads or shares under an identity shows which identity in the surface that confirms it, before the tap and not in a settings screen two levels away.
89
+ - Switching accounts happens in one place and states what is changing.
90
+ - After a switch, nothing from the previous account remains on screen: cached lists, badges, avatars and the contents of the outgoing queue in `off-queue` all belong to the account that made them.
91
+
92
+ ## <Rule id="auth-signout" description="Nobody is signed out quietly" />
93
+
94
+ An app stays signed in for months, so an unasked-for sign-in screen reads as data loss. It is also the only privacy control many users have on a shared device, which is why it has to be findable.
95
+
96
+ - Sign out is a visible control in one place, and it happens because the user asked. An expired token, a failed refresh, a dropped connection and an app update are not sign-outs.
97
+ - Where the server genuinely refuses the session, the sign-in screen carries one sentence saying what happened. A bare sign-in screen with no explanation is the failure this rule exists for.
98
+ - The screen says what leaves the device before it leaves: unsent work is drained or named first, under `off-session`, and then the credential, the cached content, the downloads and that account's pending notifications go together.
99
+ - What survives is a decision rather than an accident. Device-level things stay, such as theme, language and the fact that onboarding was seen.
100
+ - Sign out is not account deletion. They never share a row, a color or an adjacent position, under `touch-destructive`.
101
+
102
+ ## <Rule id="auth-delete" evidence="device" description="Deletion has two routes, a scope and a date" />
103
+
104
+ The stores do not ask for the same shape and an app has to satisfy both: an in-app path to delete the account and its data, plus a web resource where the same request can be made. Shipping only the in-app path passes one store and fails the other. The web route is excused for an app that is permanently private and for one whose job is enterprise device management, so an app claiming any other excuse is guessing.
105
+
106
+ - Where the route sits is `onboard-account`: inside the app, in account settings, and not inside a policy or terms page, and the same rule revokes the federated tokens as part of the deletion.
107
+ - The web route is the same flow rather than a longer one. Its address is declared to Play in the data safety section, which is the store that asks for it; the App Store has no such field and asks instead that the in-app route be reachable rather than buried. A web route Play was never told about is a route that does not count.
108
+ - It deletes the account rather than deactivating it, and the confirmation screen says what is removed, what is kept, and under what obligation it is kept.
109
+ - Say how long it will take, and tell the person when it is done. Where deletion can be scheduled for later, immediate deletion is offered beside it.
110
+ - A subscription bought through a store keeps billing until it is cancelled there, and the deletion screen says so rather than letting the person discover it next month.
111
+ - The confirmation is a re-authentication under `auth-reauth`, not a checkbox.
112
+
113
+ <Check>
114
+
115
+ <Verify rule="auth-methods">Every route on the sign-in screen resolves to one account identifier through one call site, the platform credential entry point is one of them with a persistent control to reopen it on Android, both domain association files are configured for the passkey route, and the order is fixed rather than computed per visit.</Verify>
116
+ <Verify rule="auth-provider-button">Apple's button is the platform component wherever the stack can reach one and matches the published specification where it cannot, Google's is built to its specification with the mark unmodified, and both keep their hit area at the touch floor while the artwork keeps its own minimum and clear space.</Verify>
117
+ <Verify rule="auth-web-flow">Every provider sign-in that leaves the app opens in the system authentication session rather than a browsing surface or an app-owned web view, and returns through that session's own callback to the screen it started from.</Verify>
118
+ <Verify rule="auth-magic-link">The sign-in link is a claimed link that opens the app rather than a mail web view, the waiting screen is restored and finishes there, and the link is spent on the person's action rather than on a fetch.</Verify>
119
+ <Verify rule="auth-last-used">The last successful method is stored per device and marked on return, naming the method and not the account.</Verify>
120
+ <Verify rule="auth-code-screen">The code screen names the destination, restores itself after a trip to another app, and gates resend behind a visible countdown.</Verify>
121
+ <Verify rule="auth-biometric-session">No prompt result is treated as a sign-in or as a factor, any check gating money or credentials requires the strong class, and the device credential path is guarded on API level 29 and below.</Verify>
122
+ <Verify rule="auth-expiry">Expiry produces one interruption over the current screen, the typed values and the scroll position survive it, and the lifetime is recorded once.</Verify>
123
+ <Verify rule="auth-reauth">Each sensitive action calls re-authentication, all of them read the same recorded window, no surface imitates the system prompt, and a refusal leaves the session intact.</Verify>
124
+ <Verify rule="auth-active-account">Every screen that acts under an identity displays that identity, and a switch clears the previous account's content, badges and queue.</Verify>
125
+ <Verify rule="auth-signout">Sign out happens only on the user's request, states what it clears, drains unsent work first, and sits away from deletion.</Verify>
126
+ <Verify rule="auth-delete">Account deletion is reachable in-app, the web route's address is held as a recorded constant rather than a literal, and the screen states scope and timing and confirms through re-authentication.</Verify>
127
+
128
+ <Device>Five of these do not come out of a diff. Open the sign-in screen at the largest text size to see what is actually reachable without scrolling, background the code screen and come back to it, force a token to expire with a form half typed, read on a device which biometric class the prompt was granted rather than which constant was passed to it, and check in the Play console that the deletion URL declared there is the one the app ships.</Device>
129
+
130
+ </Check>
@@ -0,0 +1,129 @@
1
+ # Background work
2
+
3
+ The moment the app leaves the screen its own execution is on a countdown. iOS suspends it. Android leaves it a window of a few minutes and then stops its services as though `stopSelf` had been called. Either way the operating system decides whether the app wakes again, when, and for how long, and it decides that against battery, thermals, and how often this person actually opens the app. Almost every generated background feature is written as though none of that were true: a timer that keeps ticking, a poll every thirty seconds, a sync that assumes the process is still there.
4
+
5
+ This file covers whether work may run at all and under what constraint. What sits in the pending queue is `off-queue`, what the work costs in battery and heat is `perf-power`, and how the notification attached to it is written is `notifications.md`. Audio that keeps playing while the app is away is `media-background`, and a transfer the user started and is watching is `net-upload`.
6
+
7
+ ## <Rule id="bg-not-running" description="The app is not running, so nothing the app schedules by itself will fire" />
8
+
9
+ A timer, an interval, a countdown or a polling loop only runs while the process is alive, and the process is not alive. So none of those is ever the mechanism. Work meant to happen while the user is elsewhere is handed to something the platform owns and wakes on its own terms. The scheduler is the default of those, `WorkManager` on Android and `BGTaskScheduler` on iOS, whichever of the two the cross-platform wrapper reaches. The others are named as they come up: a transfer session (`net-upload`), a queue the system drains (`off-queue`), a push that wakes the app (`bg-wake-push`), a location trigger (`bg-location`), a media session (`media-background`), a foreground service where nothing narrower fits (`bg-service-last`).
10
+
11
+ The tell in a diff is a repeating callback registered in a screen, a store, or app startup, with a comment about keeping data fresh. Whatever a screen left running on its way out is the other half of the same bug, and `perf-power` owns it.
12
+
13
+ ## <Rule id="bg-now-or-later" description="Sort every unit of work into must-finish-now and can-wait, and there is no third pile" />
14
+
15
+ Now means the user just started it and is watching or expects a result: an upload they tapped, a payment, an export. Later means everything else, and everything else is the majority.
16
+
17
+ Later work is expressed as conditions the system evaluates, never as a clock the app invented. "When the device is charging and on unmetered network" is a constraint. "At 2am" is a timer, and it will not fire at 2am.
18
+
19
+ The two platforms do not offer the same conditions, and the longer list does not degrade into the shorter one by itself.
20
+
21
+ - **Android.** A network type (connected, unmetered, not roaming), charging, battery not low, storage not low, device idle.
22
+ - **iOS.** A processing task takes network connectivity and external power, and nothing else. A refresh task takes no constraints at all, only the earliest time it may begin, which is what most apps schedule. Anything finer is a check the app runs itself once the task has started.
23
+
24
+ Runtime is budgeted on both. A scheduled refresh on iOS gets up to 30 seconds, so it fetches and stores one thing rather than running a whole sync. Android budgets by standby bucket: 10 minutes of job runtime per rolling 4 hours in the working set, per 12 hours in the frequent bucket, per 24 hours in the rare one.
25
+
26
+ Either pile can have its process killed between the schedule call and the run, so nothing about a unit lives only in memory across that gap, and every unit carries the identity `off-queue` defines: an id generated before the first attempt and reused on every attempt after it, so a second arrival reads as the first one coming round again rather than as a second charge.
27
+
28
+ ## <Rule id="bg-leaving" description="Work already in flight when the user leaves is finished or handed over, never dropped" />
29
+
30
+ This is the other half of the now pile, and it is the most common background moment in a real app: the request is open, the write is half done, and the user goes to answer a message. Both platforms give the app a short assertion to finish what is already running, `beginBackgroundTask` with its expiration handler on iOS and a promoted unit or expedited work on Android, and neither publishes how long it lasts. So the window is unknown and the expiration is certain, which leaves one safe design: finish or persist. The assertion is spent on the unit that was nearly done, the expiration handler writes whatever is left into the durable queue (`off-queue`) or onto the scheduler instead of cancelling it into nothing, and the assertion is ended on every branch (`perf-power`). What the screen has to remember on the way out is `state-interrupt`; this rule is the work, not the view.
31
+
32
+ ## <Rule id="bg-periodic" description="A repeat interval is a floor, and the count of them is the real number" />
33
+
34
+ On Android the shortest repeat WorkManager accepts is 15 minutes, and 15 minutes is the earliest the work may run rather than a promise that it will. The system stretches that interval as the app is used less, batches it with other apps' work, and defers it to the next maintenance window while the device sits idle off charger, and those windows get rarer the longer the idle lasts. iOS publishes no minimum and no cadence at all: `BGAppRefreshTaskRequest.earliestBeginDate` is a floor the app sets, with no ceiling and no promised frequency, so any "refreshes every N minutes" figure is invented.
35
+
36
+ So: count the recurring jobs the app schedules, list them in `STACK.md`, and give each one a sentence there saying what breaks if it does not run today. An app with six periodic jobs has six wakeups it cannot justify and one bug report about battery.
37
+
38
+ ## <Rule id="bg-visible-stoppable" description="Work that keeps running while the app is away is visible, and the stop actually stops it" />
39
+
40
+ Long user-started background work goes behind a system surface the person can see and cancel: on Android a foreground service with its required notification, on iOS 26 and up a continued-processing task whose progress the system shows in a Live Activity with a cancel control the user can hit. Below that iOS floor no such surface exists, and a user-started transfer belongs in a background `URLSession` instead (`net-upload`).
41
+
42
+ That surface is an interface, not a formality.
43
+
44
+ - It reports real progress. On iOS the system prioritises terminating the tasks that report little or none once resources tighten, so a fake indeterminate spinner is also a shorter task.
45
+ - Its stop cancels the work. The cancel affordance built into that surface, the stop action in the notification (`createCancelPendingIntent` behind it) and the cancel control in the Live Activity, ends the unit itself rather than hiding the surface it was watched through. A job that carries on writing after its own stop was hit is the failure users notice on the battery screen.
46
+ - After a stop, the partial result is either kept and marked as partial, or discarded and said to be discarded. See `state-partial` and `state-queued`.
47
+ - What the notification says, and what a swipe away means, are both `notify-ongoing`.
48
+
49
+ ## <Rule id="bg-service-last" description="A foreground service is the last route, and it arrives with a declared type and a budget" />
50
+
51
+ Check the narrower API first: a user-initiated data transfer job instead of a generic data sync, picture-in-picture instead of a media playback service, the companion device manager instead of a connected device service. Each of those exists precisely so the service does not have to.
52
+
53
+ Where the service is genuinely right, it comes with hard edges:
54
+
55
+ - On Android 14 and up it declares one of the published service types in the manifest and requests the permission matching that type, and the store reviews the type and the stated use before the app ships.
56
+ - A long-running worker promoted with `setForeground` is a foreground service underneath, so it declares the same type, requests the same permission and meets the same review. Reaching it through WorkManager avoids none of that.
57
+ - A service started with `startForegroundService` has 5 seconds from being created to call `startForeground` or the app crashes. Where the start came from does not extend that, so a service started from a visible screen owes the call just as fast.
58
+ - A short service runs about 3 minutes and cannot start another service. For apps targeting API 35 and up, data sync and media processing services share 6 hours per rolling 24 across every service of that type, and the clock resets only when the user brings the app to the front.
59
+ - From Android 16, jobs started from a foreground service still count against the app's ordinary job quota, so wrapping work in a service no longer buys unlimited scheduling.
60
+ - An app already in the background may not start one at all on Android 12 and up, outside a short exemption list. The start point is a user action inside the app, or a high-priority push.
61
+ - The boot broadcast may not launch several of the declared types, so a receiver that runs at boot schedules the work and lets the scheduler pick it up rather than starting a service on the spot.
62
+
63
+ ## <Rule id="bg-declared" description="Every declared background capability names a feature that uses it" />
64
+
65
+ The declaration is a list and the list is reviewed. On iOS it is `UIBackgroundModes`: audio, location, voip, external-accessory, bluetooth-central, bluetooth-peripheral, fetch, processing, remote-notification and the rest. On Android it is the foreground service types in the manifest, each with the permission it requires.
66
+
67
+ - Each entry maps to a shipping feature that uses it for the purpose the entry names. Both stores review background use against its stated purpose, and a mode declared for convenience, inherited from a template, or held open to keep the process alive is a rejection rather than a warning.
68
+ - An entry with no feature behind it is deleted from the manifest rather than left in and ignored, and the app reaches for the alternative wherever one exists.
69
+ - Audio is the common legitimate case, and `media-background` owns its paperwork.
70
+
71
+ ## <Rule id="bg-location" description="Continuous background location is the last form to try, not the first" />
72
+
73
+ Both stores police this harder than any other background capability and both ask the same question: which shipping feature needs it, and what does the person holding the phone get from it.
74
+
75
+ - Significant-change monitoring and geofences wake the app on the events a feature actually reacts to, at a fraction of the power, and they cover most of what continuous updates get used for. Continuous updates are for a feature that follows a moving position while the user is away from the screen, such as turn-by-turn or an active recording.
76
+ - Where it is right it is declared: the location background mode on iOS, the `location` foreground service type with its permission on Android, and the indicator the platform draws stays visible (`sense-running`).
77
+ - The feature that needs it is named in the same place the permission is asked for, and that ask is the separate, later one `perm-scope` describes.
78
+
79
+ ## <Rule id="bg-exact-time" description="Exact timing is expensive, gated, and almost never what the feature needs" />
80
+
81
+ Default to an inexact window. For an app targeting Android 12 and up, a requested window shorter than 10 minutes is normally widened to 10, so the design assumes 10 rather than the number it asked for, and an alarm permitted to fire through device idle may fire at most once per 9 minutes per app.
82
+
83
+ Two Android permissions cover exact alarms and they are not interchangeable. `SCHEDULE_EXACT_ALARM` is granted by the user, revocable, not pre-granted to a fresh install targeting Android 13 and up, and open to a broader set of uses. `USE_EXACT_ALARM` is granted automatically and cannot be revoked, and store policy restricts it to alarm, timer and calendar apps.
84
+
85
+ The thing that earns exact timing is a time the user themselves set, and `setAlarmClock` is the form that serves it: it is the alarm device idle does not defer, and it is the case those permissions exist for. Where the alarm only has to fire while the app is alive, the `OnAlarmListener` form needs no exact alarm permission at all. A refresh, a reminder to come back, a cache expiry and a nightly cleanup earn none of it. iOS has no exact alarm to ask for at all: a time the user set is a local notification scheduled for that time, and firing it runs no app code, so the notification carries what the user has to read, and anything that has to be computed at that moment is computed when the app is next opened or when the notification is acted on.
86
+
87
+ ## <Rule id="bg-wake-push" description="A push that wakes the app to fetch is a budget, and the budget is small" />
88
+
89
+ iOS rate limits an app that sends more than 3 background pushes per hour, and gives 30 seconds of runtime when one is delivered. Android downgrades an app's high-priority messages once it notices them arriving without a notification following.
90
+
91
+ So a silent wake-up is spent on content the user is actually waiting for, and content they must be told about arrives as a notification that stands on its own: see `notify-earns-it`. An app that pushes on every server-side change to keep a cache warm loses the channel it will need later.
92
+
93
+ ## <Rule id="bg-restricted" description="The user restricting the app is a state to degrade in, not a bug to route around" />
94
+
95
+ Read the state before promising anything. Background refresh is a switch the user owns on iOS, and where the system reports it as restricted rather than off the app says nothing about it at all. On Android there are two restricted states and they are not the same one. The battery state the user sets, which the system itself offers after the app holds a partial wake lock for an hour with the screen off, runs no jobs, fires no alarms and reaches no network except while the app is in the foreground, starts no foreground service and demotes any already running, and while the app targets Android 13 and up does not even deliver the boot broadcast. That is the stock behaviour, and the manufacturer decides what its own build does on top of it, which is why the same scheduling code that runs on one phone never fires on another. The restricted standby bucket is the system's own classification of a rarely used app: one job a day for up to 10 minutes, one alarm a day, and no network or push delivery in the background at all.
96
+
97
+ A force quit is the same answer in a blunter form. Swiped out of the switcher, the app is simply not running: its scheduled work does not fire, wake-up pushes do not reach it, and its transfers stay stopped until the person opens it again (`net-upload`). So no screen anywhere promises that a background feature keeps going, and every feature that leans on one is designed to be found stale on return (`bg-failed-away`).
98
+
99
+ Degrading means the feature that depends on background work says what it can still do, where that feature lives, in one line: content is current as of when the app was last open. It does not mean an interstitial on launch, and it is never a retry loop, for the reason `sense-off-system` gives about any switch sitting above the app. A grant here is a current value and not a fact, same shape as `perm-recheck`.
100
+
101
+ ## <Rule id="bg-exemption" description="Asking for a battery exemption is a last resort that has to name its reason" />
102
+
103
+ Store policy prohibits requesting a direct exemption from power management unless the app's core function is impaired without it, and the accepted reasons are a short published list. For everything else the app may only open the battery settings screen, never prompt for the exemption directly, and only after the user has hit the limitation and been told plainly what it costs them. On iOS there is no equivalent to ask for, and instructing the user to change a system setting unrelated to the app's core function is a rejection.
104
+
105
+ This is never the first answer to work not running. Almost every time, the work was scheduled wrong, ran too long, or should have been deferred. See `perf-power`.
106
+
107
+ ## <Rule id="bg-failed-away" description="Work that failed while nobody was looking is visible when they look" />
108
+
109
+ The sync that failed at 3am is a state the screen shows at 8am, not a log line. Work the app retries itself gives up at the attempt ceiling `net-backoff` owns, which exists as a named constant rather than as a number buried in a loop, then writes a state the UI reads: the affected content marked stale with its age (`state-stale`), anything unsent marked as pending rather than done (`state-queued`), and a route to retry that does not lose what was queued (`state-retry`). A transfer the platform session retries on the app's behalf is the exception `net-timeout` names, and an attempt ceiling stacked on top of it counts the attempts twice.
110
+
111
+ Silent failure is worse here than anywhere else in the app, because the user had no way to see it happen and every reason to believe it worked.
112
+
113
+ <Check>
114
+
115
+ <Verify rule="bg-not-running">No timer, interval or polling loop is the mechanism for work expected to happen while the app is away, and every such unit is handed to a platform-owned mechanism instead: the scheduler, a transfer session, a drained queue, a push, a location trigger or a declared service.</Verify>
116
+ <Verify rule="bg-now-or-later">Every deferrable job declares at least one system constraint where the platform offers them, no job encodes a wall clock time the app chose, and every unit carries an id generated before the first attempt that makes a retry recognisable as the same request.</Verify>
117
+ <Verify rule="bg-leaving">Work in flight when the app is backgrounded takes the platform's short assertion, and the expiration hands what is left to the queue or the scheduler rather than dropping it.</Verify>
118
+ <Verify rule="bg-periodic">Count the recurring jobs the app schedules: each one has its reason written in `STACK.md`, and none assumes its interval is a schedule.</Verify>
119
+ <Verify rule="bg-visible-stoppable">Every long-running background unit reports real progress through the platform's visible surface and carries a stop that cancels the work itself.</Verify>
120
+ <Verify rule="bg-service-last">Each foreground service names a declared type with its matching permission, calls `startForeground` within 5 seconds of the service being created wherever the start came from, and was chosen only after the narrower API was ruled out.</Verify>
121
+ <Verify rule="bg-declared">Every declared background mode and foreground service type maps to a shipping feature that uses it for that purpose, and anything else is deleted from the manifest.</Verify>
122
+ <Verify rule="bg-location">Background location uses significant-change monitoring or a geofence unless a moving position has to be followed, and the feature that needs it is named where the permission is asked for.</Verify>
123
+ <Verify rule="bg-exact-time">Count the exact alarms: each one is a time the user set, and everything else uses an inexact window.</Verify>
124
+ <Verify rule="bg-wake-push">Wake-up pushes are sent for content the user is waiting on, and the app does not send them per server-side change.</Verify>
125
+ <Verify rule="bg-restricted">The app reads the restriction state, no screen promises that background work keeps running, and the dependent feature degrades in place, in one line, rather than blocking or nagging.</Verify>
126
+ <Verify rule="bg-exemption">No code path prompts for a battery optimisation exemption, and any settings route is reached after the user hits the limit and reads why.</Verify>
127
+ <Verify rule="bg-failed-away">Every background job's failure path writes a state a screen reads, and every retry the app runs itself stops at an attempt ceiling held in a named constant.</Verify>
128
+
129
+ </Check>
@@ -0,0 +1,99 @@
1
+ # Buttons and controls
2
+
3
+ Everything a user taps to act or to choose: buttons, the floating action button, chips, tabs and segmented controls. Navigation structure is a separate question; this file is about the controls themselves and about which one wins.
4
+
5
+ A phone shows one screen at a time to someone who is usually doing something else. The whole point of a control vocabulary here is that the answer to "what do I do now" arrives before any reading happens.
6
+
7
+ ## <Rule id="button-one-primary" description="One primary action per screen. One." />
8
+
9
+ This is the rule the rest of the file exists to protect, and it is the one generated screens break most often.
10
+
11
+ Emphasis is relative and nothing else. A filled button reads as *the* action only because the controls around it are not filled. Put two filled buttons side by side and neither one is primary any more: the visual system says both are the answer, so the user has to stop and decide which, and that decision is one you were supposed to make for them.
12
+
13
+ **Count them.** Look at the screen as the user sees it and count the filled, prominent or otherwise heaviest controls visible at once. The answer is one, or zero on a screen that only reads or browses. Two is a defect, not a preference.
14
+
15
+ What counts in that number:
16
+
17
+ - the button pinned to the bottom, the one in the top bar, and the one inside the content, all at the same time;
18
+ - the FAB, which is a primary action and not an exception to the count;
19
+ - controls that live in different files or different components but land on the same glass. The count is per screen, not per widget.
20
+
21
+ A sheet, a dialog or a full-screen modal is its own decision point, so it gets its own single primary, and while it is open it owns the count.
22
+
23
+ When two actions genuinely feel equal, that is the screen telling you it has two jobs. Split it, or pick the one the product wants and demote the other. Save and "save and add another" are not two primaries: one is the primary and the other is a secondary, a menu item, or a checkbox next to the first.
24
+
25
+ Cancel, Back, Skip and Dismiss are never the emphasized control. They are the exit, and the exit does not need to be sold. A destructive action is not the top of the ladder either: it is a role of its own, it keeps its distance from the frequent controls, and it is never the primary of a screen the user opened to do something else.
26
+
27
+ ## <Rule id="button-ladder" description="Use the ladder the platform already defines" />
28
+
29
+ Below the primary there are two useful rungs, no more: a secondary for the alternative, and a quiet tertiary for the optional. Reach for them by name instead of inventing a parallel set of styles.
30
+
31
+ - **Material:** filled, then filled tonal, then elevated, then outlined, then text, in that order of weight, with the FAB sitting above all of them for the one action a screen exists for.
32
+ - **iOS:** prominent, then bordered, then plain. On recent versions the same ladder is expressed through the system glass styles, which is where translucency comes from; a hand-built blur behind a button is not the same control.
33
+
34
+ The iOS convention worth knowing: the primary action there is frequently a navigation bar item or a plain tinted row of text, not a large filled block in the middle of the content. A full-width filled button dropped into an iOS settings screen is an Android accent, and it reads as one.
35
+
36
+ Three different button styles at the same rank on one screen is the composite look of a screen assembled from parts. Pick a style per rank and hold it, then style it once in the theme rather than at each call site, because twenty locally styled buttons are twenty future inconsistencies. The same action wears the same control everywhere in the app: if Save is filled on one screen and a text button on the next, one of them is wrong.
37
+
38
+ Button labels sit at the label role of the type scale, which on Material is 14sp at weight 500. Not 400, and not bold.
39
+
40
+ ## <Rule id="button-target" description="The drawn height is not the touch target" />
41
+
42
+ Material's default button is 36dp tall, with a size range that runs from 32dp to 56dp, and the framework component quietly extends its own touch area to reach the 48dp floor. Draw that button by hand at 36dp with no extended area and it is a target that fails, while looking identical to the one that passes.
43
+
44
+ The whole control is tappable, not the text inside it. A full-width primary at the bottom of the screen is a good fit for a thumb, and it is also a large accidental target, so nothing destructive belongs beside it.
45
+
46
+ ## <Rule id="button-label" description="The label says what will happen" />
47
+
48
+ Write the verb of the action and the object it acts on: Send, Pay 42, Delete photo. OK, Submit and Yes describe nothing, and Yes in particular forces the user back up to reread the question.
49
+
50
+ - No trailing period, and never "click" on a device with no cursor.
51
+ - Keep it short enough to survive translation and a 200% text size, and decide now what a long label does: wrap, or shrink the container, never quietly truncate the verb.
52
+ - An icon-only button carries an accessibility label saying the action, not the picture. On Android, navigation destinations always carry a visible text label as well.
53
+
54
+ ## <Rule id="button-state" description="A button has four states, and two of them are usually missing" />
55
+
56
+ Rest and pressed are covered by the touch rules. The two that get skipped:
57
+
58
+ **Disabled.** A primary that starts disabled at the top of an empty form gives the user nothing to act on and no reason why. Prefer leaving it enabled and answering on tap with what is still missing, pointed at the field that is missing it. Where disabled is genuinely right, the reason has to be visible next to it, not inferred.
59
+
60
+ **In flight.** The moment it is tapped, the control stops accepting taps and says that work is happening, in place, at the same width, so the layout does not jump under the finger that is still there. A button that looks identical during a three second request gets pressed again, and the second press is a duplicate order.
61
+
62
+ ## <Rule id="button-fab" description="One FAB, for the action the screen exists for" />
63
+
64
+ The FAB is Material. It does not belong in an iOS build, where the same action goes in the navigation bar or the toolbar.
65
+
66
+ Where it is right, it *is* the primary action of that screen, which means there is no second filled button underneath it competing for the same job. One per screen. Stacked FABs, a FAB spent on something secondary, and a speed dial that unfolds into four more FABs are all the same mistake: a menu wearing the costume of a primary action.
67
+
68
+ Use the extended form when the action needs a word to be understandable, since an icon alone rarely carries a verb. And remember it floats over content: the scrolling list underneath needs bottom padding equal to the FAB plus its margin, or the last row spends its life beneath it.
69
+
70
+ ## <Rule id="button-chips" description="Chips are not small buttons" />
71
+
72
+ Four kinds, four jobs. Assist chips offer an action in context. Filter chips narrow a set and can be multi-selected. Input chips represent something the user already entered and can remove. Suggestion chips offer content the system is proposing.
73
+
74
+ - Selection has to be legible without color: a check mark, a filled container, a shape change. A chip that only changes tint when selected disappears for a good share of users.
75
+ - A row of chips scrolls horizontally. Letting them wrap turns a filter row into a growing wall that pushes the content it filters off the screen.
76
+ - Chips do not carry the primary action, and they are not a navigation control. A chip that changes screen is a tab wearing the wrong clothes.
77
+
78
+ ## <Rule id="button-tabs" description="Tabs are navigation, not action" />
79
+
80
+ Three to five destinations, and they are the top level of the app rather than a place for actions. They belong at the bottom, where the thumb is.
81
+
82
+ - Every destination is labelled. Icon-only navigation asks the user to guess, and the guess is wrong often enough to matter.
83
+ - The selected destination is obvious without relying on color alone: an indicator, a filled icon variant, a weight change.
84
+ - The set does not change from screen to screen, and it does not vanish when a screen is pushed. Tabs that come and go stop being a map.
85
+ - Top tabs and bottom tabs on the same screen is two navigation systems arguing. Pick one.
86
+ - A segmented control is not a tab bar. It filters or switches the view in front of the user, it holds about three options, and past that it becomes a menu or a filter screen.
87
+
88
+ <Check>
89
+
90
+ <Verify rule="button-one-primary">Exactly one primary action is visible on the screen, counting the bottom bar, the top bar, the content and the FAB together, and any sheet or dialog carries its own single primary.</Verify>
91
+ <Verify rule="button-ladder">Emphasis comes from the platform's ladder, one style per rank, styled in the theme rather than per call site, and the same action looks the same across screens.</Verify>
92
+ <Verify rule="button-target">Every button's touch area reaches the platform floor even where the drawn height is smaller.</Verify>
93
+ <Verify rule="button-label">Labels name the action and its object, are capitalised as `copy-case` requires, and survive the longest translation at the largest text size.</Verify>
94
+ <Verify rule="button-state">Disabled states explain themselves, and every button that starts work becomes unpressable and shows it, without resizing.</Verify>
95
+ <Verify rule="button-fab">At most one FAB, holding the screen's defining action, absent on iOS, with the list underneath padded to clear it.</Verify>
96
+ <Verify rule="button-chips">Chip selection is visible without color, chip rows scroll instead of wrapping, and no chip is doing a tab's job.</Verify>
97
+ <Verify rule="button-tabs">Three to five labelled destinations, a selected state that is not color alone, and one navigation system per screen.</Verify>
98
+
99
+ </Check>