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,111 @@
1
+ # Performance
2
+
3
+ A phone runs the app on a battery, in one hand, on hardware picked for a price, at whatever thermal budget is left over from whatever the user did before opening it. That turns performance into a design constraint rather than an engineering one, because past a certain wait the user cannot tell a slow screen from a broken screen: they decide nothing happened and press again.
4
+
5
+ The device this fails on is never the device it was built on. It is a few years old, its storage is slow, its memory is shared with everything else the user left open, and it has been warm since before the app launched.
6
+
7
+ Row recycling is `list-virtualise`. Which properties an animation may move is `motion-cheap`. Request weight, timeouts and retries are `heuristics/network.md`. What a sensor or a location subscription is allowed to do, and what stops it, is `heuristics/sense.md`; this file owns only what holding one costs. What the screen shows while any of this is happening is `state-loading`, and the launch surface is `splash-system`.
8
+
9
+ ## <Rule id="perf-cold-start" description="Only what the first screen draws happens before the first screen draws" />
10
+
11
+ Three ways in, and they cost different things: cold, with the process built from nothing; warm, with the first screen recreated while the process or part of the app is still resident; hot, with the app returning and its interface intact. Cold is the one that is designed for and the one that is never measured, because the phone on the desk is always warm.
12
+
13
+ Two numbers exist per launch. Time to initial display ends the moment a frame is on screen, and both platforms report it without the app doing anything. Time to full display ends when the screen holds real content, and it exists only if the app says so: `reportFullyDrawn()` on Android, a signpost on the points-of-interest log on iOS. An app whose first frame is a placeholder and that reports only the first number is timing the placeholder; where the first frame already carries real content the two are the same moment and nothing extra is owed. Android's store calls a cold start of 5s or a warm start of 2s excessive, which is the bar for bad rather than a target.
14
+
15
+ Everything else starts on first use, or on the first idle frame. What is found on the launch path and does not belong there:
16
+
17
+ - analytics beyond installing the crash handler, remote config, and feature flags;
18
+ - attribution, advertising and payment SDKs;
19
+ - database open and migration, and any synchronous file or preference read the first screen does not need; the small local reads that legitimately hold the launch surface are named in `splash-hold`;
20
+ - font and image preloads for screens nobody has opened yet;
21
+ - location, and any sensor or radio the first screen does not display;
22
+ - anything imported at module scope that the first render never touches, since it is parsed and evaluated before that render.
23
+
24
+ One dependency is usually most of the cost, so the cost is attributed per dependency rather than to launch in general. Each stack then has its own lever. On Android a baseline profile listing the startup path gets that code compiled ahead of time instead of interpreted, which is roughly 30% faster code execution from the first launch. On iOS the equivalent levers are the number of dynamic frameworks and everything that runs before `main`: static constructors, `+load`, and constructor attributes. On Flutter and React Native the engine or runtime start is a fixed cost underneath all of the above, so what the first screen does not need is deferred or lazily loaded rather than carried in the first bundle.
25
+
26
+ ## <Rule id="perf-main-thread" description="The thread that draws does nothing else" />
27
+
28
+ Under 100ms a discrete tap reads as instant. A main thread busy for 250ms is what the iOS tools start reporting as a hang. At 5s of undelivered input Android raises an ANR, and iOS terminates an app whose main thread has stopped answering. On a phone there is no second window to look at while it recovers, so every one of those is a crash as far as the user is concerned.
29
+
30
+ What keeps turning up there and does not belong:
31
+
32
+ - JSON parsing, and any deserialization of a response big enough to page;
33
+ - database queries, file reads and preference reads, which are all slower on the storage a cheap phone ships with;
34
+ - image decode, crypto, and a regular expression run over a long string;
35
+ - sorting or filtering a whole collection to render a screenful of it;
36
+ - a state update scoped so wide that one keystroke rebuilds the screen.
37
+
38
+ Each stack names the way off, and the move is written at the call site rather than assumed: a dispatcher on Android, an actor boundary and an async context on iOS, an isolate for anything long in Flutter, work kept off the JS thread in React Native. A background thread that then hops back to publish a result once per element of a list has moved the problem rather than solved it.
39
+
40
+ ## <Rule id="perf-frame" evidence="device" description="The budget is one refresh interval, and 60 is not a constant" />
41
+
42
+ One refresh interval is 16.6ms at 60Hz and 8.3ms on a 120Hz panel. What follows from it is that no value in the code may assume it. A 16ms timer, a frame count used as a duration, or an animation stepped by a fixed interval is a build tuned for one panel that stutters on the next one, and phone panels now run at 60Hz, 90Hz and 120Hz in the same product line. Motion comes from the stack's own frame callback or from `motion-cheap`.
43
+
44
+ Two ceilings sit above the budget. No frame in the app may take longer than 700ms, which is the point a frame stops being slow and reads as the app having stopped. And Android's vitals dashboard counts frames against a fixed 16ms whatever the panel is doing, so a build that meets a 120Hz deadline still reads as slow there. That is a reporting convention rather than a bar the store enforces, and the number to design against stays the device's own refresh interval.
45
+
46
+ ## <Rule id="perf-overdraw" description="The look has a per-frame price, and a still screen pays it too" />
47
+
48
+ Blur, translucency, shadow and gradient are decisions taken in the design and settled in GPU time, charged on every frame the screen is up rather than only while something moves. `motion-cheap` bounds them during an animation; this rule is the screen sitting still.
49
+
50
+ - One blurred or translucent layer over scrolling content, never two stacked. The second has nothing left to reveal and doubles the sampling behind it.
51
+ - Elevation is a token count rather than a per-card decision. Forty rows each carrying a soft shadow is forty extra passes, and a divider, a spacing step or a tonal surface says the same thing for nothing.
52
+ - A gradient or a shader is bounded to the region doing the work (`color-gradient`), not stretched behind the whole screen.
53
+ - Backgrounds do not stack. An opaque window under an opaque container under an opaque card paints the same pixel three times, and on a mid-range device those repeats are the frame.
54
+
55
+ ## <Rule id="perf-decode" description="An image costs its decoded size, and its decoded size is not its file size" />
56
+
57
+ A decoded bitmap is width times height times four bytes, taken from the pixels it was decoded to. A 4000 by 3000 photo is roughly 48MB resident whether it fills the screen or sits in a 48dp avatar, and a handful of those is more than the process is given. A 300KB file on disk says nothing about that number.
58
+
59
+ So the decode target is the box the image is drawn into. Where the loader derives that box from the layout it already satisfies the rule and owes nothing at the call site, which is the ordinary Android case with Coil or Glide. Where it cannot, the size is declared where the image is loaded: `cacheWidth` and `cacheHeight` in Flutter, the thumbnail size option on iOS, explicit dimensions on a React Native `uri` source, a width parameter on the URL where the pictures come from a service that can resize. A decode with no layout bound behind it is the defect this hunts. The decode itself never runs on the drawing thread, whatever sized it. Reserving the space before the bytes arrive is `icon-reserve`, and for a row it is `list-images`.
60
+
61
+ ## <Rule id="perf-memory" description="Every cache states a ceiling, because the system decides who dies" />
62
+
63
+ A phone app rarely runs out of memory. It gets killed, usually while backgrounded, sometimes to protect a different app entirely. That is a state the app returns from, not an error, and it never shows one: where it returns to is `nav-restore`.
64
+
65
+ What makes this app the one chosen: an image or response cache with no bound, a list holding every page it has ever loaded, a screen whose objects outlive it through a listener, timer, subscription or observer that nothing removed, and assets preloaded for a screen that has since closed. So every cache the app wrote itself carries a maximum in entries or in bytes and an eviction rule. A library cache satisfies the same requirement where its default bound is left in place or set deliberately, so what review is looking for is the hand-rolled dictionary and the loader someone configured with its bound removed. Every listener, observer and subscription a screen registers is removed with it, and the ones whose cost is power rather than retention are `perf-power`.
66
+
67
+ One trap specific to Android: the trim-memory warning levels are deprecated and have not been delivered since API 34, leaving only the background and UI-hidden levels live. Code written to free memory when the system warns is code that now runs never, and it reads in review as memory pressure being handled.
68
+
69
+ ## <Rule id="perf-power" evidence="device" description="A warm device makes every other number worse" />
70
+
71
+ Thermal throttling is the failure that hides all the others: the same code that met the frame budget a minute ago misses it once the device has been working, and no profiling run on a cold phone will show it. A screen's cost is therefore not what it does once, it is what it keeps doing.
72
+
73
+ - Which accuracy and which sampling rate a location or sensor subscription may take is `perm-scope` and `sense-motion`, and stopping one with the screen that started it is `sense-running`. What lands here is the price of holding hardware open at all: continuous scanning over a short range radio, a connection kept alive, a subscription still sampling behind a screen nobody is looking at.
74
+ - A repeating timer or a polling loop is a design decision with an interval to defend, not an implementation detail, and it is cancelled when its screen stops being visible.
75
+ - Anything that keeps the device awake or a service alive ends on a deterministic path including the failure path: a wake lock or a foreground service on Android, a background task assertion whose end handler runs on every branch and `isIdleTimerDisabled` on iOS. Android's store reports excessive partial wake locks by name.
76
+ - Both platforms publish a thermal level and both are read: the Android thermal status, whose severe level is where the experience is largely affected, and `ProcessInfo.thermalState` with its change notification on iOS. Anything doing sustained work reads that level and does less, instead of waiting to be throttled into jank.
77
+ - Doze and the standby buckets are respected rather than worked around, because work scheduled to defeat them is deferred anyway, at the cost of the battery figure the user sees with the app's name next to it. What the app changes under battery saver and Low Power Mode is `state-offline`.
78
+
79
+ ## <Rule id="perf-size" description="Download size is a number the user sees before any of the design" />
80
+
81
+ The install is a conversion step taken on a cellular connection, on a device that is often nearly full. Google Play shows a warning on a mobile data connection above 200MB and reports uninstalls on devices with under 2GB free; iOS flags any device variant above its 200MB over-the-air download limit. Staying far below either is the normal case rather than an achievement.
82
+
83
+ The store builds already do most of the work: an app bundle and app thinning each send one density and one architecture to the device. What still fails is the build that goes around them, a universal or fat binary made for sideloading or for CI carrying every density and every ABI at once, which is what `flutter build apk` produces until it is split per ABI. The assets are authored at every density the platform asks for (`icon-vector`) and exactly one of them lands on the phone. Fonts ship the weights the type scale actually names and no others, which is usually two or three. Anything large that the first session does not open is downloaded after install rather than carried inside the build.
84
+
85
+ ## <Rule id="perf-measure" description="The slowest supported device, a release build, and production-scale data" />
86
+
87
+ All three conditions, or the reading is not evidence. The slowest device the app supports, not the emulator and not the phone on the desk. A release or profile build, because every stack here runs its debug build slower than the one that ships, which makes a debug measurement useless in both directions. And production-scale data, because a list is smooth at twenty rows in any implementation.
88
+
89
+ Then the reading is compared against something. `STACK.md` names that device and the budget each measurement has to beat: cold start, frames on the longest list, memory after a few screens. The measurements themselves live wherever the tooling writes them, each with the date it was taken, because a number with no date stops being evidence and keeps looking like it. A feeling is not a reading either, and an optimisation with a number on only one side of it is a guess that cost code. Each stack already ships the instrument:
90
+
91
+ - **iOS:** the App Launch, Time Profiler and Hangs instruments, the Launch Time, Hitches and Hangs panes for what shipped, and MetricKit for the field.
92
+ - **Android:** Macrobenchmark for startup and scrolling, JankStats for jank in the field, and the vitals the store reports back for the devices nobody tested on.
93
+ - **Flutter:** `flutter run --profile`, with the UI thread and the raster thread read as two separate numbers.
94
+ - **React Native:** the performance monitor, with the JS frame rate and the UI frame rate read as two separate numbers.
95
+ - **Mobile web inside the app:** 2.5s to the largest contentful paint, 200ms interaction to next paint, and 0.1 of cumulative layout shift, each measured at the 75th percentile rather than as an average.
96
+
97
+ <Check>
98
+
99
+ <Verify rule="perf-cold-start">Nothing initializes on the launch path that the first screen does not draw, and any launch whose first frame is a placeholder reports its own time to full display.</Verify>
100
+ <Verify rule="perf-main-thread">Zero parses, queries, file reads, decodes or whole-collection sorts run on the drawing thread, and each one names where it moved to.</Verify>
101
+ <Verify rule="perf-frame">No timer, duration or step count assumes 60Hz, and every hand-written animation is driven by the stack's frame callback.</Verify>
102
+ <Verify rule="perf-overdraw">No two translucent or blurred layers stack over the same content, elevation comes from a token count rather than a per-card decision, and no gradient or shader runs behind a whole screen.</Verify>
103
+ <Verify rule="perf-decode">Every decode targets the box the image is drawn into, declared at the load site wherever the loader cannot derive it, and no decode runs on the drawing thread.</Verify>
104
+ <Verify rule="perf-memory">Every cache the app wrote states a maximum and an eviction rule and no library cache has had its bound removed, every listener, observer and subscription a screen registers is removed with it, and nothing depends on a trim-memory level the platform no longer sends.</Verify>
105
+ <Verify rule="perf-power">Every repeating timer, scanning subscription, wake lock and background assertion names the interval it defends and has exactly one path that ends it, including the failure path, and sustained work reads the thermal level.</Verify>
106
+ <Verify rule="perf-size">The shipped build sends one density and one architecture per device rather than a universal binary, only the font weights the type scale names, and nothing large that the first session does not open.</Verify>
107
+ <Verify rule="perf-measure">`STACK.md` names the slowest supported device and a budget for cold start, for frames on the longest list, and for memory after a few screens.</Verify>
108
+
109
+ <Device>`perf-measure` is the one answered by whether the budgets exist at all, and a missing budget is the violation rather than a slow number. `perf-frame` and `perf-power` are answered in the source first, where a hardcoded 16ms and a subscription with no stop are both visible, and then again on hardware that has been working long enough to get warm, because thermal behaviour and sustained frame pacing exist nowhere else.</Device>
110
+
111
+ </Check>
@@ -0,0 +1,125 @@
1
+ # Permissions
2
+
3
+ A permission is a question the operating system asks on the app's behalf, in a dialog the app cannot restyle, usually once. The app writes one sentence inside it and gets a word back. A wrong answer is expensive to reverse: the user has to leave the app, find it in a system settings list and come back, so most of them never do.
4
+
5
+ That makes the ask itself a design object. What is asked for at all, at which moment, after what explanation, and what the app becomes when the answer is no or half a yes.
6
+
7
+ `state-permission` already covers denial as a screen state. This file covers the request.
8
+
9
+ ## <Rule id="perm-inventory" description="Every permission traces to a feature the user can point at" />
10
+
11
+ The declared set is public. On Android the Play listing shows it before install, on both platforms the system permission screen shows it after, and store review reads it against what the app claims to do. Each entry is a cost paid whether or not the prompt ever fires.
12
+
13
+ Name the feature behind each entry, and hold each class to its own standard: a runtime permission needs a feature the user can point at, an install-time one only has to be used at all. Anything left unnamed goes, including whatever a library, a template or a starter project dragged in: a dependency that declares location does not give the app a reason to have it. A permission for a feature that was removed leaves with the feature.
14
+
15
+ The source manifest is not the answer, because library permissions arrive through manifest merging and never appear in it. Read the merged manifest report under the build outputs, or the effective list on an installed build, and the `Info.plist` inside the built app rather than the one in the project.
16
+
17
+ ## <Rule id="perm-ask-less" description="The right answer is usually a component that asks for nothing" />
18
+
19
+ Both platforms ship system UI that runs outside the app, hands back exactly what the user picked, and needs no permission at all. Reaching past it for the permission is the most common way an app asks for more than it needs.
20
+
21
+ | What the app needs | What it uses instead of a prompt |
22
+ |---|---|
23
+ | existing photos or videos | the system photo picker: no library authorization on iOS, none of the media permissions on Android, available from Android 11 and backported below it |
24
+ | a document or a file | the system document picker |
25
+ | a location for one task | the system location button on iOS; or, inside the standard dialog, the user's own "Allow Once" on iOS and "Only this time" on Android from Android 11 |
26
+
27
+ Raw access is for when the capture surface is the feature: a scanner drawing its own frame, a recorder showing its own level. Picking an existing image is never that.
28
+
29
+ Where library access is genuinely needed it is still partial: iOS has a limited state where the user chooses the visible subset, and Android 14 adds a selected-photos grant beside allow-all and deny. In both, what the app can see is a subset the user can change later, so the code reads a set that may shrink between launches.
30
+
31
+ - A subset is grown in place, not by asking again for everything. Re-open the picker for more items on Android; on iOS 18 `ContactAccessButton` and `contactAccessPicker(isPresented:completionHandler:)` widen a limited contacts grant with no prompt at all. Widening is the third answer beside grant and deny, and the only route out of a subset that turned out too small.
32
+ - An app on limited photo access owns its own re-prompt. Set `PHPhotoLibraryPreventAutomaticLimitedAccessAlert` and raise the selection change at the point the user is looking for more photos, or the system raises that alert at launch on the app's behalf and the user reads it as the app nagging.
33
+
34
+ ## <Rule id="perm-scope" description="Ask for the level the feature uses, not the level that would be convenient" />
35
+
36
+ Every permission with a strong and a weak form gets the weak one first.
37
+
38
+ - Location, declared: coarse always, fine only where the feature genuinely benefits from it. Precise is defensible for turn-by-turn and pointless for a weather panel or a nearest-store list.
39
+ - Location, requested: accuracy is the user's choice inside one system dialog on both platforms, never a staged pair of asks. Android takes `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION` in a single runtime request and ignores a fine-only one. Escalating to precise is that same paired request again on Android, and `requestTemporaryFullAccuracyAuthorization(withPurposeKey:)` on iOS, each carrying its own reason.
40
+ - Location, scope: when-in-use before always. Background location is its own later ask from Android 10, and it never rides along with the first one.
41
+ - Photos: add-only when the app only saves. That is a distinct key on iOS, and on Android from 10 writing through `MediaStore` needs no permission at all.
42
+ - Notifications and tracking are their own asks and travel with nothing.
43
+
44
+ An upgrade is requested the first time the stronger level is actually used, not at the moment the weaker one is granted.
45
+
46
+ ## <Rule id="perm-rationale" description="The screen before the dialog is what earns the dialog" />
47
+
48
+ The system dialog is a yes or a no with one app-written sentence in it. Everything else the user needs in order to decide has to arrive before it, on a screen the app owns. The dialog is modal over the one surface the phone has: the feature it is asking about cannot be shown behind it, and a user who declines has nowhere else on screen to go, so the reduced form has to already be on the screen they were standing on.
49
+
50
+ That screen says three things: what the feature does, what the data is used for, and what the user gets. Then it offers two exits that both continue: a control that triggers the real dialog, and a decline that returns to the app with the feature in its reduced form.
51
+
52
+ - Read the current status before drawing anything. A rationale shown to someone who already granted is noise, and one shown to someone who can no longer be prompted is a lie. On Android `shouldShowRequestPermissionRationale` returning true means show the educational screen, and nothing more: it is false before the first ask as well as after a permanent denial, so it is not a test for either. Permanent denial is read from the request returning with no dialog shown, or from the platform status on iOS.
53
+ - It does not imitate the system alert. A fake dialog with Allow and Don't Allow teaches the user to dismiss the real one behind it.
54
+ - One rationale screen per feature, covering the permissions that one feature needs, and never a queue of asks chained across unrelated features. Where the platform requires or documents a bundle it goes out as one ask: paired location, or camera plus microphone for a single capture surface, through `RequestMultiplePermissions` on Android.
55
+ - It sits at the feature. A permission without which the app has no first screen at all may be asked for earlier, and then that screen has to make the reason obvious before the dialog appears.
56
+
57
+ ## <Rule id="perm-purpose-string" description="The sentence inside the dialog is written, not generated" />
58
+
59
+ iOS drops the app's usage description into the system alert. The Android request API takes permission strings and nothing else, so there is nowhere for an app sentence to go and the rationale screen carries the entire explanation. Either way somebody writes copy.
60
+
61
+ An active sentence naming the feature and the use: "Records at night to detect snoring." Not "needed for a better experience", which says nothing, and not "Turn on microphone access", which restates the button.
62
+
63
+ On iOS every protected resource has its own key, and a missing one is not a warning: the access fails, the app is terminated on the spot, and review rejects the build. The seven in common use are `NSCameraUsageDescription`, `NSMicrophoneUsageDescription`, `NSPhotoLibraryUsageDescription`, `NSPhotoLibraryAddUsageDescription`, `NSLocationWhenInUseUsageDescription`, `NSLocationAlwaysAndWhenInUseUsageDescription` and `NSUserTrackingUsageDescription`. They are user-visible strings, so they localize, and they are as long as the language makes them.
64
+
65
+ ## <Rule id="perm-answers" evidence="device" description="A permission has more than two answers" />
66
+
67
+ Granted or denied is the branch most code has. The states that exist:
68
+
69
+ - **granted**, and at what scope, because a yes to approximate is not a yes to precise;
70
+ - **denied and still askable**, which exists on Android and only there: the first Deny leaves a second chance, the second one spends it;
71
+ - **denied permanently**, where the dialog never appears again and the request call does nothing at all. On iOS a single Deny produces this, so the two denial branches are written once per platform rather than once for both;
72
+ - **not determined**, nobody asked yet, which is also where an expired one-time grant lands;
73
+ - **restricted**, where the device does not allow the user to grant it and no copy the app writes will change that.
74
+
75
+ Each is a different screen. Permanent denial is the one that gets folded into plain denied and produces a control that silently fails: the user taps Allow, no dialog appears, nothing moves, and the app has no explanation for it.
76
+
77
+ Partial grants belong here too. An approximate location, a single session of access, a subset of a library. The feature either runs on what it was given or names the part of itself that is missing, and a partial yes is never handled as a no.
78
+
79
+ ## <Rule id="perm-recheck" evidence="device" description="A grant is a current value, not a fact" />
80
+
81
+ Check immediately before each access instead of caching the answer at launch.
82
+
83
+ The user can revoke anything from system settings while the app sits in the background, a one-time grant ends with the task and reverts to not determined rather than to denied, so the next touch of that feature is a fresh ask and not a settings route, the visible subset of a library changes without a prompt, and from Android 11 the system resets the runtime permissions of an app nobody has opened for a few months. A returning user can arrive without something they granted, through no decision either of you made.
84
+
85
+ The screen that assumes otherwise crashes, or shows an empty list where the content used to be and blames the server.
86
+
87
+ ## <Rule id="perm-no-coercion" evidence="device" description="A no is an answer the app has to live with" />
88
+
89
+ `state-permission` owns the degraded screen and the route back into system settings. What that route must not turn into:
90
+
91
+ - No re-ask while the status is denied, and no ask the user did not trigger. On Android a prompt after a refusal spends the last chance the app had. A status of not determined at the next launch, including one an expired one-time grant left behind, is a first ask and belongs to `perm-rationale`.
92
+ - Nothing is held hostage. Content, a paid feature or a reward cannot be priced at a permission, and an unrelated feature is never gated on an unrelated permission. The App Store rules name notifications, location and tracking specifically; Play states the same prohibition generally and adds that the app must accommodate the user who says no.
93
+ - The note about what is missing sits where that feature's results would have been and names only the feature affected.
94
+ - Consent is withdrawn where it was given. Every permission the app holds is reachable from inside the app, as a link to the system page for it rather than a second switch, which is `set-system-owned`, and any consent the app stores itself, a tracking flag or an analytics opt-in, is turned off where `set-account-exit` puts it. Where the user has turned tracking off in Settings, a shortcut back there is allowed.
95
+
96
+ ## <Rule id="perm-notify-ask" evidence="device" description="The notification prompt comes after the user makes something worth being told about" />
97
+
98
+ Consent is required before a single notification is sent, and this ask follows the same rule as the others: it belongs to the moment the user places the order, sets the reminder or follows the thread, not to the first screen.
99
+
100
+ - On Android it is a runtime permission from Android 13. An app targeting anything older loses control of the timing completely: the system raises the dialog itself the first time an activity starts once a notification channel exists, with no context at all, and a refusal there stands until the app is reinstalled or its target level reaches 33. Raising the target level is the fix, not a workaround.
101
+ - iOS has a provisional level that sends with no prompt, delivering quietly to the notification list where the user can keep it or turn it off. That is the honest way to earn the loud one.
102
+ - Promotional messages get their own opt-in inside the app, and the system grant is not it.
103
+
104
+ ## <Rule id="perm-tracking" evidence="device" description="The tracking prompt exists only if the app actually tracks (iOS)" />
105
+
106
+ From iOS 14.5, linking this app's data to data other companies collected, or passing it to a data broker, needs the tracking prompt and its own usage description. Without a grant the advertising identifier comes back as all zeros.
107
+
108
+ An analytics or advertising SDK that pools users across other developers' apps counts even when the app never asks it to, so the dependency list decides this, not intent. No tracking means no prompt and no key. Tracking means the app still works whole when the answer is no: nothing withheld, nothing asked twice.
109
+
110
+ <Check>
111
+
112
+ <Verify rule="perm-inventory">Every entry in the merged manifest and in the built app's `Info.plist` names the feature that uses it, and every runtime one names a feature the user can point at.</Verify>
113
+ <Verify rule="perm-ask-less">No permission is requested for something a system picker or access button already returns without one, a partial grant is widened in place rather than re-asked, and the automatic limited-access alert is suppressed and replaced.</Verify>
114
+ <Verify rule="perm-scope">Each request asks for the weakest usable level, location goes out as the paired request, and always, precise and background are separate later asks.</Verify>
115
+ <Verify rule="perm-rationale">Each request is preceded by an app-owned screen stating use and benefit, gated on the current status, with a decline that continues except on the first-run required-resource screen `onboard-ask-order` defines, and one screen per feature rather than a queue.</Verify>
116
+ <Verify rule="perm-purpose-string">Every usage description is an active sentence naming the feature and the use rather than restating the button.</Verify>
117
+ <Verify rule="perm-answers">The code branches on permanent denial and on partial grants, not on a granted boolean.</Verify>
118
+ <Verify rule="perm-recheck">Permission status is read at the point of access, never cached from launch.</Verify>
119
+ <Verify rule="perm-no-coercion">No re-prompt while the status is denied, no feature or content gated on an unrelated grant, and every permission and stored consent the app holds is reversible from inside it.</Verify>
120
+ <Verify rule="perm-notify-ask">The notification request follows a user action that creates something to notify about, not app start.</Verify>
121
+ <Verify rule="perm-tracking">On iOS a tracking prompt exists if and only if a dependency tracks, and denial changes no feature; a codebase that ships only to Android answers this not applicable.</Verify>
122
+
123
+ <Device>Run the last five with the permission revoked and the app cold started, because every one of them passes on a device where the grant is already in place. Reach each state on purpose rather than waiting to meet it: on Android, `adb shell dumpsys package PACKAGE_NAME` reports the flags per permission, where `USER_SET` is one denial and `USER_FIXED` is the permanent one, and `adb shell pm clear-permission-flags PACKAGE_NAME PERMISSION_NAME user-set user-fixed` resets between runs; on iOS, Reset Location & Privacy returns every permission to not determined. A state nobody can enter deliberately gets answered from the granted device every time, which is the same as not running the check.</Device>
124
+
125
+ </Check>
@@ -0,0 +1,112 @@
1
+ # Privacy on screen
2
+
3
+ A phone is used on a train, in a queue, and across a table. The person beside the user is close enough to read a six inch screen, has no reason to look away, and is part of the threat model in a way no desktop design assumes. On top of that the operating system photographs the app without asking, to draw the app switcher.
4
+
5
+ This file covers two things: what a stranger standing there can see, and what leaves the device as a record of what the user did. Asking for access to data is `permissions.md`. Identity and the session are `auth.md`. What a notification shows over a locked screen is `notify-lockscreen`; here the screen is unlocked and the app is the one drawing it.
6
+
7
+ Two of the platform capabilities below are weaker than they are usually assumed to be, and one does not exist at all on iOS. Design so the screen is safe without them, then add them.
8
+
9
+ ## <Rule id="priv-shoulder" evidence="device" description="Show the shortest form of a value that still does the job" />
10
+
11
+ Take the inventory per screen: amounts and balances, one-time codes, card and account numbers, tokens and recovery phrases, health figures, home and precise addresses, legal or immigration status, and message bodies shown in a preview.
12
+
13
+ - The resting state is the shortest form that identifies the thing. The last four digits, the initials, a band instead of a figure. That short form is for a value sitting beside something else: a value the screen exists to show is already at its shortest form when it is shown in full, so the balance on the account screen somebody opened to read it is drawn plainly and gets a hide control, not a band.
14
+ - A value the interface never needs in full is not masked, it is truncated in the model before it reaches the view, and then there is nothing to leak.
15
+ - Where the whole value is genuinely needed sometimes, it rests masked and is revealed on request, which is `priv-reveal`.
16
+ - The mask is a fixed shape at a fixed width, not the real string with dots painted over it. Six dots against a six digit balance has masked the glyphs and published the magnitude.
17
+ - Masking follows the value everywhere it is drawn: the list row, the summary card, the search result, the share preview and the sample data in an empty state.
18
+ - Copying takes the whole value off the screen whatever the field was showing. The system preview drawn after the copy and the next app to read the clipboard both get it, so a copy control on one of these values marks the copy as sensitive, which is `share-copy`.
19
+ - Content the user wrote and opened on purpose is not masked. Blurring someone's own messages until they tap is theatre, it slows down the only person entitled to read them, and it is the version of this rule that gets the whole thing switched off.
20
+
21
+ ## <Rule id="priv-reveal" evidence="device" description="Revealing is a deliberate act, and it ends by itself" />
22
+
23
+ - The control is a real target at the platform floor (`touch-floor`) and it carries its state, so a screen reader says hidden or shown rather than naming an eye (`a11y-name`).
24
+ - Nothing reveals on scroll, on a long press with no affordance, or because the screen finished loading. The user asks, every time.
25
+ - It reverts on leaving the screen, on the app going to the background, and after an idle period the product decides once. No platform publishes a number for that period, so choose it from what the screen holds and record it in `STACK.md` beside the re-authentication window `auth-reauth` keeps there.
26
+ - Revealing puts the value on the screen and nowhere else: not into a toast, not into a log, not into an announcement fired by an unrelated event.
27
+ - Where the reveal is itself the sensitive act, a recovery phrase or a full card number, put `priv-gate` in front of it instead of a toggle.
28
+
29
+ ## <Rule id="priv-switcher" evidence="device" description="The switcher snapshot is taken without asking, so the cover goes up first" />
30
+
31
+ The system captures the last frame to represent the app in the switcher. The user never consented to that capture, cannot see it happen, cannot tell which frame was taken, and the image is written to storage rather than held for a moment. A balance left visible there is readable by anyone who picks up the unlocked phone.
32
+
33
+ - On any screen holding something from the `priv-shoulder` inventory, draw an opaque cover as the app leaves the foreground and take it down on return. A screen with nothing on that list owes no cover. Blur is not a cover: at thumbnail size a blurred number is still a number shaped mass in the right place, and a hand rolled blur ignores the reduce transparency setting.
34
+ - Which lifecycle callback runs before the capture is not something to assume. Background the app from the sensitive screen, open the switcher, and look at the thumbnail. That is the only result that counts.
35
+ - On iOS the cover is entirely app authored and hangs from the transition to the background. `applicationDidEnterBackground` on the app delegate, or the matching scene callback, is where to start, and the thumbnail check above is what settles whether it ran early enough. There is no API that suppresses the snapshot and no platform guidance on the subject, so a screen that needs a cover and does not draw one simply leaks.
36
+ - On Android, `FLAG_SECURE` already blanks the Overview thumbnail, so a screen carrying it for `priv-capture-block` needs no second mechanism. `Activity.setRecentsScreenshotEnabled(false)` (API 33) is the narrower control: it suppresses the Overview representation and nothing else, leaving the user's own screenshot untouched. `android:excludeFromRecents` drops the task from Overview altogether, which is a decision about how the app is re-entered rather than a privacy control.
37
+ - A cover is not a gate. Coming back through it restores the screen exactly as it was, so a screen that must not return unlocked needs `priv-gate` as well.
38
+ - Coming back after the process was killed is `nav-restore`, and the restored screen starts masked like any other.
39
+
40
+ ## <Rule id="priv-capture-block" description="Blocking capture is a partial Android capability and no iOS capability at all" />
41
+
42
+ - Android's `FLAG_SECURE` keeps a window's content out of screenshots and off non secure displays. Google puts it at around 70% of devices reliably on Android 11 and lower, and says it is not reliable against an overlay attack. It raises the cost. It is not a guarantee, and a design that assumes it is has no fallback.
43
+ - It applies per window, so set it entering the sensitive screen and clear it leaving. Flagging the whole app also blocks every legitimate screenshot the user wanted, and Google suggests a setting that lets the user toggle the flag. Where the product ships that row, its default and the reason for it go down with the others (`set-default-first`).
44
+ - From API 35 `View.setContentSensitivity(CONTENT_SENSITIVITY_SENSITIVE)` marks one view rather than the window, and the hosting window is treated as secure for the duration of a media projection session. `CONTENT_SENSITIVITY_AUTO` reaches the same place from autofill hints, so tagging the username, password and card fields for autofill is also what hides them during a screen share (`form-autofill`).
45
+ - iOS publishes no equivalent. `isSecureTextEntry` hides the characters and disables copying, and Apple's own wording says it prevents recording and broadcasting only in some cases. That is a hedge, not a promise, and there is nothing else.
46
+ - Android 15 already hides password input from a remote viewer, redacts notification content during a screen share, and from QPR1 gives the user a status bar chip that stops the projection. None of that is worth rebuilding. What is left to the app is its own screen.
47
+ - The control that always works is composition. A full card number beside its security code, or a recovery phrase beside the account it belongs to, is a capture problem no flag repairs. Split the screen instead.
48
+
49
+ ## <Rule id="priv-capture-detect" description="Screenshot detection lands after the pixels are gone, mirroring is known while it happens" />
50
+
51
+ - iOS posts a notification once a screenshot has been taken. Android 14 offers a per activity capture callback behind the install time `DETECT_SCREEN_CAPTURE` permission, and it fires only for the hardware button screenshot.
52
+ - Neither of those hands over the image, neither can refuse it, and both arrive after the shot was already taken. Detection is a notice. An app that treats it as protection has a security model made of a toast.
53
+ - Android shows the user its own notice when that callback fires, so tell them first, in context, as they enter the screen that watches. A system message nobody was expecting reads as an accusation.
54
+ - Whether the screen is being mirrored or recorded right now is a different question, and both platforms answer it while it is happening. iOS exposes the state as `UITraitCollection.sceneCaptureState` from iOS 17, superseding the deprecated `UIScreen.isCaptured`. Android 15 calls back as the app becomes visible or invisible inside a screen recording, through `addScreenRecordingCallback` and `SCREEN_RECORDING_STATE_VISIBLE`.
55
+ - What to do with that signal is the app's call, and the default is to hide the sensitive region rather than end the session under someone who is in a meeting. Playback is the exception Apple documents: a media app pausing and saying why is the right answer there, and it stays with `media.md`.
56
+ - Recording that a capture happened is instrumentation and obeys `priv-instrument`. It never records what was on the screen at the time.
57
+
58
+ ## <Rule id="priv-gate" description="A second gate covers an area, never the whole app" />
59
+
60
+ The mechanism is `auth-biometric-session`: a device prompt re-authorizes a session that already exists. Which actions have to ask again is already `auth-reauth`, which lists revealing a full card or document number among them and keeps the window in `STACK.md`. What is left here is a privacy decision, and it is three questions.
61
+
62
+ - **What it covers.** The sensitive area, not the app: the account tab, the document, the phrase. Locking the whole app for the sake of one screen makes the frequent case pay for the rare one, and the user turns it off in a week.
63
+ - **When it re-locks.** On leaving the area, on the app coming back from the background, and after an idle period, on the window `STACK.md` already holds. A gate that only fires at cold start is decoration.
64
+ - **What it does not cover.** A gate with no `priv-switcher` cover is read straight off the switcher thumbnail of the screen behind it. Both, or neither is worth having.
65
+ - The way out stays open when the check cannot run. Sign out, deletion and support are reachable with the sensor unavailable or unenrolled, which is `sense-biometric`.
66
+
67
+ ## <Rule id="priv-instrument" description="Nothing the user typed leaves the device in a log, a crash report or an event" />
68
+
69
+ Apple requires explicit consent and a clear visual or audible indication when an app records or logs user activity, and names screen recordings and other user inputs in that requirement. Session replay is therefore a store rule before it is a taste question.
70
+
71
+ - Write the event schema down and name every field. No field carries free text, the contents of a masked value, a token, a precise coordinate, or a full identifier where a stable hash does the job.
72
+ - Analytics and crash SDKs capture screens, taps and the view hierarchy by default, and that default is the whole failure. Put the sensitive views in the SDK's redaction list at the same moment the screen decides to mask them, and confirm it by replaying a captured session rather than by reading the configuration.
73
+ - A crash report carries state with it. Strip request bodies, credential bearing headers and every field the screen masks before it is sent.
74
+ - The debug log ships. A line that prints a response body is a leak the moment the phone is plugged into a laptop, and it is the cheapest of these to remove.
75
+ - Whether any of it crosses to another company is `perm-tracking`. The answer to that prompt does not change what is inside the payload.
76
+
77
+ ## <Rule id="priv-delete-data" evidence="device" description="Deleting data and deleting the account are two different actions" />
78
+
79
+ `auth-delete` owns the account route and its obligations. This rule owns what the word delete promises the person tapping it.
80
+
81
+ - Where the app holds things the user can point at, a history, a document, a conversation, a downloaded set, deleting those is offered on its own. An app whose only delete is the account is asking someone to burn it down to clear a search box.
82
+ - Say which copy went. Dropping the row locally while the server keeps it is a lie the user discovers on their next device, and deleting server side while the phone keeps a cached copy is the same lie facing the other way.
83
+ - The on device leftovers are the part that gets missed: caches, thumbnails, drafts, search history, downloaded media and anything still sitting in the outbound queue. `off-cache-policy` says what is stored, and the delete has to reach all of it.
84
+ - Delete means gone, not hidden. On the account route Play requires the associated user data to be removed rather than the account frozen, and a per-object delete that flips a flag and leaves the row in place is the same failure at smaller scale.
85
+ - Anything retained is named as a thing with its reason beside it, in a sentence: the invoices stay because tax law keeps them. A link to a policy page is not an answer to somebody whose thumb is already on the button.
86
+ - It is a one way row and sits with the other one way rows (`set-destructive`). A short window in which the work can still be called back beats a second confirmation dialog (`fb-undo`).
87
+
88
+ ## <Rule id="priv-declared" evidence="device" description="The store declaration is derived from the code, not from intent" />
89
+
90
+ Both stores require this and both require it to be accurate, and each asks for two separate things. Play requires a complete data safety section for every app, consistent with the privacy policy. Apple's counterpart is the privacy details submitted with the app, which the store then shows on the product page: what is collected and what it is used for. Alongside that, Apple requires a privacy policy linked in the store metadata and reachable inside the app, identifying what is collected, how, every use of it, and the retention and deletion terms.
91
+
92
+ - Derive it from the requests the app actually makes and from the dependency list, never from what the feature was meant to do. Every SDK collects on its own account, and that collection is the app's.
93
+ - Both forms live in the consoles, where nothing in the repository can be compared against them, so what was filed is written into `STACK.md` beside the dependency list it came from: one line per data type, naming the SDK or the endpoint it comes from and the use declared for it.
94
+ - Adding an analytics, advertising, attribution or crash dependency is a change to the filing. A diff that adds one and leaves the declaration alone ships out of date.
95
+ - Collection with no system prompt in front of it still owes the user a disclosure, and Play requires it inside the app during normal use rather than in the listing or behind a settings screen. The permission case is `perm-rationale`; this is the case with no dialog to attach to.
96
+ - The policy is reachable from inside the app and not only from the store listing; the row that holds it is `set-account-exit`.
97
+
98
+ <Check>
99
+
100
+ <Verify rule="priv-shoulder">Every sensitive value on the screen is drawn in the shortest form that identifies it, and the mask is a fixed shape rather than the real string covered up.</Verify>
101
+ <Verify rule="priv-reveal">Reveal is an explicit, named, labelled action that reverts on leaving, on backgrounding and on a written idle period.</Verify>
102
+ <Verify rule="priv-switcher">Every screen holding something from the inventory covers itself as the app leaves the foreground: an opaque cover removed on return on iOS, that cover or `FLAG_SECURE` on Android, and somebody has looked at the switcher thumbnail to confirm it.</Verify>
103
+ <Verify rule="priv-capture-block">Capture blocking is scoped to the screen that needs it, is not relied on as a guarantee, and no screen puts two halves of one secret together.</Verify>
104
+ <Verify rule="priv-capture-detect">Capture detection is used as a notice the user was warned about, never as protection, and the live mirroring signal is read on both platforms and answered by hiding the region rather than by ending the session.</Verify>
105
+ <Verify rule="priv-gate">The gate covers an area rather than the app, re-locks on background and idle, and ships together with the switcher cover.</Verify>
106
+ <Verify rule="priv-instrument">No log, crash report, analytics event or replay session carries typed text, masked values, tokens or full identifiers.</Verify>
107
+ <Verify rule="priv-delete-data">Data deletion exists separately from account deletion, removes the row rather than hiding it, says which copies went, names what is retained and why, and reaches the on device leftovers.</Verify>
108
+ <Verify rule="priv-declared">`STACK.md` records what both stores were told, that record matches the dependency list and the requests in the diff, and the in-app disclosure and the policy route both exist.</Verify>
109
+
110
+ <Device>Check the first three on a running build rather than in the source: the cover, the mask on every route onto the screen, and the reveal reverting are all things a screen can be written to do and still fail to do. Two more do not come out of the app at all. Open the data safety form in the Play console and the privacy details in App Store Connect and read both against what `STACK.md` records, and confirm on the server, not in the app, that a delete took the row away rather than flagging it.</Device>
111
+
112
+ </Check>
@@ -0,0 +1,114 @@
1
+ # Scrolling
2
+
3
+ Scrolling is the movement a phone gets the most of. The screen is a few hundred points tall, so nearly everything past the first card is reached by dragging, and the drag is the one interaction the hand pays for directly. That is why a scroll that stutters, jumps, or loses somebody's place is felt within a second, and why the defects here are the ones users describe as the app being broken rather than as a design they dislike.
4
+
5
+ This file is the scroll itself: its axis, its position over time, the chrome that moves with it, and the effects the platform owns. The collection inside the scroll is `heuristics/lists.md`. The column it runs in, the bars pinned over it and the insets around it are `heuristics/layout.md`.
6
+
7
+ ## <Rule id="scroll-nest" evidence="device" description="Same-axis nesting needs a wired handoff, and the fling is part of it" />
8
+
9
+ Whether a same-axis nest is allowed at all is `layout-column`. This rule is what has to hold once one is: the two scrollers are connected, so at every moment a delta has one owner rather than two competing for it. Android's collapsing app bar is that arrangement and works for exactly that reason, while a scroll view hand-placed inside another of the same orientation is the same shape with nothing joining the halves.
10
+
11
+ The connection is the whole rule. Deltas travel up to the outermost parent before the child moves, the child consumes what is left, the remainder goes back up, and a fling repeats that cycle with its own pre and post phases. Those fling phases run for touch gestures only, so a handoff that feels correct under a thumb does nothing under an accessibility or hardware scroll.
12
+
13
+ Three places the wiring is missing and the arrangement still compiles:
14
+
15
+ - In Compose, `verticalScroll`, `horizontalScroll`, `scrollable`, the `Lazy` APIs and `TextField` join the nested-scroll chain on their own. A `Box` or a `Column` does not, until `Modifier.nestedScroll` is added.
16
+ - Across the interop boundary, `RecyclerView` and `ViewPager2` do not implement the nested-scrolling interfaces, so a Compose parent receives nothing from them however it is configured.
17
+ - In mobile web and in wrapper stacks there is no nested-scroll contract to opt into. Two same-axis scrollers there are simply two scrollers, and which one moves is settled by where the finger landed.
18
+
19
+ A windowed list inside a plain scroller is a different failure and `list-virtualise` owns it.
20
+
21
+ ## <Rule id="scroll-affordance" evidence="device" description="The edge says there is more" />
22
+
23
+ Touch scroll indicators appear during the drag and fade, so on a still screen there is nothing telling the user the region moves. The content has to say it: let the next item be cut by the edge it continues past, rather than ending the visible set flush against the margin. A horizontal row of cards whose last card lands exactly at the padding reads as a complete set, and most people never drag it.
24
+
25
+ A paged horizontal scroll is the exception that needs a control instead. On a phone the paged region takes the full width and nothing else on a still screen says where in the set the user is, so it gets a page indicator. Where the platform also draws a scroll indicator on that axis, drop it rather than report the same fact twice.
26
+
27
+ The stock page indicator is a control, not a read-out: it handles its own touches, and its hit area is the whole control rather than one dot, so nothing in it needs inflating and working tap-to-page behaviour is not a defect. A row of dots assembled by hand has only the target its author gave it, and that one owes `touch-floor`.
28
+
29
+ ## <Rule id="scroll-collapse" description="What collapses is chrome, never the last way out" />
30
+
31
+ The two platforms hand you opposite starting points. On iOS a large title shrinks to a standard title as scrolling begins and returns at the top, with no work. On Android nothing collapses unless it is asked to: the scroll behaviour parameter on every Material 3 top app bar defaults to none, and in the view system the scroll flags default to `noScroll`, while Google's own layout guidance says the bar should collapse. So on Android this is a decision, and which behaviour is chosen has consequences.
32
+
33
+ An enter-always bar comes back on any downward drag. An exit-until-collapsed bar only re-expands once the content is scrolled all the way to the top. Put the only route to an action inside the second kind and the user has to travel back through the entire list to reach it.
34
+
35
+ - What may not collapse to zero is the only route out. A small top app bar taking its back arrow fully offscreen is a supported Material configuration and costs nothing, because the system back gesture is untouched by it. A modal close, a cancel, or an action that exists nowhere but that bar is the case that has to survive the collapse, since scrolling it away leaves the screen with no exit at all.
36
+ - The screen's primary action does not live in the collapsing region (`button-one-primary`).
37
+ - The collapse position is saved state, so rotation does not re-expand a bar over content the user had scrolled past. Material's app bar state carries the offset for this.
38
+ - Material disables the scroll behaviour on its bottom app bar while touch exploration is running, and applies no such guard to the top bars, so write that guard for a top bar that hides a control. A collapse is reached by dragging, and a control that only returns after a drag has no route for someone who does not drag (`a11y-gesture`).
39
+
40
+ ## <Rule id="scroll-edge" description="The line between content and chrome is drawn by the platform" />
41
+
42
+ A bar pinned over a scroll has two conditions, content resting at the edge and content passing underneath, and both platforms already decide what each looks like. iOS gives a bar a separate scroll edge appearance and switches to it the moment scrolled content reaches the bar, so a bar transparent at rest picks up its background by itself. Android's app bar lifts when content scrolls under it, taking a container surface color as it does, and that behaviour is on by default.
43
+
44
+ Take the transition from there. A shadow painted by hand, a divider pinned under the bar, and a bar left permanently opaque all trade a conditional behaviour for a fixed one: the opaque bar spends the edge-to-edge look while nothing is even scrolled, and the drawn line stays put at the top where the platform would have removed it. Apply one such effect per scroll view, and leave the status bar area translucent so content reads as passing under the bar rather than being cut off by it.
45
+
46
+ ## <Rule id="scroll-anchor" evidence="device" description="Nothing arrives above the reading position" />
47
+
48
+ This is the defect that makes people lose their place. An image finishing its decode, a banner resolving, a consent strip appearing, or a page of older messages prepending: each one inserts height above the viewport and pushes the sentence being read off the top. One column means the insertion has nowhere to go sideways, so it moves the whole screen at once, and it lands while the thumb is still travelling.
49
+
50
+ Reserve the space before the content exists, which `icon-reserve` covers for pictures and boxes. A placeholder at the final height makes the arrival a change of pixels rather than a change of layout.
51
+
52
+ Where the insertion is real rather than late, the anchoring is the framework's job and stable keys are what it needs to do it. A keyed lazy list holds the row that was first visible when rows arrive above it, so the index moves and the visible content does not. What review has to check is the cases nothing covers: a list whose items have no keys, a plain scroller with the rows laid out by hand, and mobile web where scroll anchoring is switched off. A thread that loads history upward is the case that ships broken most often.
53
+
54
+ Content appended below the viewport is free. Refreshing in place keeps the row under the thumb, which is `list-refresh`.
55
+
56
+ ## <Rule id="scroll-restore" evidence="device" description="The place comes back keyed to the item, not to the index" />
57
+
58
+ Restoration is ordinal by default, and that default is the bug. A Compose lazy list does keep the key of its first visible item in memory, which is what lets it stay on the same row when items are added or removed above it while the screen is alive. What it writes to saved state is two integers: the index of the first visible item and its pixel offset. So the identity is there until the process dies and absent after it, and in a list whose items were never given keys it is absent from the start. Either way the list comes back at whatever now sits at that index, which after a re-sort or an insertion at the top is a different piece of content. RecyclerView admits the same problem from the other side with a restoration policy that withholds state until the adapter has items, because index 40 is meaningless during the first layout of an asynchronously loaded list.
59
+
60
+ - Give the items stable keys, `key = { it.id }` on a lazy list, so there is an identity to hold at all. This is the one-line fix and it is the one most often missing.
61
+ - Persist that identity yourself, because the framework does not write it down. A position held only in a view model survives rotation and dies with the process, and the process gets killed without anyone asking (`state-interrupt`).
62
+ - Restore by resolving the identity, and fall back to the top when the item no longer exists.
63
+ - Restore after the data is there, never during the first empty layout.
64
+
65
+ Which screen comes back is `nav-restore`. Unfinished input is `form-persist`.
66
+
67
+ ## <Rule id="scroll-top" description="Returning to the top is a system gesture on iOS and yours to build on Android" />
68
+
69
+ On iOS the status bar tap does it, it is on by default, and it breaks quietly: on iPhone the gesture has no effect when more than one scroll view on screen still has it enabled. A horizontal carousel inside a feed is enough to kill it. Turn it off on every scroller except the one the screen is about.
70
+
71
+ Android publishes no equivalent gesture, so a screen whose scroll has no fixed end builds the affordance: a tap on the already-selected tab, or a control that appears once the user is far enough down. It is sized to the touch floor (`touch-floor`), and it arrives at the top without a long animated ride through everything in between.
72
+
73
+ ## <Rule id="scroll-programmatic" description="Code never moves the screen under a finger that is moving it" />
74
+
75
+ A touch becomes a scroll after a small amount of travel, 8 dp by the Android default and adjustable per device, which means the user is scrolling well before anything looks like a scroll. A programmatic scroll issued in that window takes the screen away from a hand that is already using it.
76
+
77
+ - Fire only when no drag is in progress. Where the animation runs through the container's own scroll state, a user drag outranks it and cancels it already. Where it does not, an imperative scroll aimed at a different container, a `scrollTo` in mobile web landing mid-touch, that guard has to be written.
78
+ - Move as far as is needed to bring the thing the user just acted on back into view, and no further. Auto-scroll restores context; it does not relocate people.
79
+ - Animate only across a short distance. An animated scroll to a distant index rides through everything between and lasts as long as the distance. Jump instead, and let the destination be the first frame the user sees.
80
+ - Reduced motion turns the animated ones into jumps (`motion-reduced`).
81
+
82
+ ## <Rule id="scroll-overscroll" description="The end-of-content effect belongs to the operating system" />
83
+
84
+ Android 12 replaced the edge glow with a stretch that bounces back on drag and on fling, for every app on the device. iOS bounces elastically and expects apps to keep that behaviour. In both cases the scrolling container already provides it, so a hand-built rubber band is a second bounce arguing with the first: it starts at a different velocity and settles on a different curve, and it reads as a rendering fault rather than as a style.
85
+
86
+ - Do not remove it. On a screen with no persistent scrollbar it is often the only signal that the content has ended, and what the end then says is `list-end`. The switches to look for are `android:overScrollMode` set to never on Android and a scroll view with its bounce turned off on iOS.
87
+ - Do not switch it off to quiet a nesting fault. That hides `scroll-nest` instead of fixing it.
88
+ - Android publishes a hook for replacing the effect, an `OverscrollEffect` applied with `Modifier.overscroll` or supplied for the whole theme. iOS publishes no equivalent, so there the effect is kept rather than restyled. On neither is it rebuilt by intercepting touches.
89
+ - Anything driven by scroll offset is recomputed on every frame of the drag, so it stays on transform and opacity (`motion-cheap`) and never triggers layout.
90
+
91
+ ## <Rule id="scroll-keyboard" description="The keyboard shortens the scroll, it does not cover it" />
92
+
93
+ Half the screen disappears with no warning, and the scrolling container has to lose that height rather than keep it underneath. A container that stays full height while the keyboard sits over its bottom third makes everything below the focused field unreachable at the exact moment it is wanted.
94
+
95
+ - The keyboard inset is applied to the scroll container itself, not simulated with a spacer view whose height is guessed. On Android that is the IME inset, `WindowInsets.ime` or `Modifier.imePadding`, with the window's soft input mode set to resize. On iOS it is the keyboard layout guide, or a content inset driven by the keyboard frame.
96
+ - Dragging the content dismisses the keyboard, interactively where the platform offers it, which on iOS is the scroll view's interactive dismiss mode. Nobody should have to aim at a done button before they can read.
97
+ - The focused field staying visible is `touch-keyboard`.
98
+
99
+ <Check>
100
+
101
+ <Verify rule="scroll-nest">Every same-axis nesting names the connection that wires it, and no scrollable was placed inside another simply because its content did not fit.</Verify>
102
+ <Verify rule="scroll-affordance">Each scrollable region shows content cut by the edge it continues past, and a paged one carries a page indicator rather than a scroll indicator on that axis.</Verify>
103
+ <Verify rule="scroll-collapse">Every collapsing bar names its behaviour, leaves a route out of the screen that does not depend on it, holds no primary action, saves its position, and pins under a screen reader.</Verify>
104
+ <Verify rule="scroll-edge">The bar over a scroll takes its resting and scrolled appearances from the platform rather than from a drawn shadow, a pinned divider or a permanently opaque background.</Verify>
105
+ <Verify rule="scroll-anchor">Nothing above the current position changes height after it renders, and anything that prepends is either keyed or anchors the first visible row itself.</Verify>
106
+ <Verify rule="scroll-restore">List items carry stable keys, the position is persisted as an item identity that survives the process, restored after the data loads, and falls back to the top when the item is gone.</Verify>
107
+ <Verify rule="scroll-top">On iPhone exactly one scroll view per screen keeps scroll-to-top enabled; on Android any unbounded scroll offers a built return-to-top at the touch floor.</Verify>
108
+ <Verify rule="scroll-programmatic">Every programmatic scroll is guarded against an in-progress drag, moves the minimum needed, and animates only over a short distance.</Verify>
109
+ <Verify rule="scroll-overscroll">No hand-written bounce, overscroll is not disabled, and scroll-linked effects move only transform and opacity.</Verify>
110
+ <Verify rule="scroll-keyboard">The scroll container consumes the keyboard inset, and dragging the content dismisses the keyboard.</Verify>
111
+
112
+ <Device>Check nesting, the scroll affordance, anchoring and restoration on a device with real data rather than in the layout code. All four look correct in a short mock list and fail only once the content outruns the screen.</Device>
113
+
114
+ </Check>