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,130 @@
1
+ # Media playback
2
+
3
+ A phone plays media in a pocket, on a commute, on a battery, on a connection somebody is paying for by the megabyte, and over a call that can arrive in the middle of any sentence. The player is the one surface in an app that has to keep working after the screen goes dark and after the user has walked away from it.
4
+
5
+ Here: the player surface, the transport controls, full screen and rotation, picture in picture, background audio, the lock screen, and what happens when something else on the device wants the speaker. Captions and anything an audio track carries alone are `a11y-media`. Whether a clip may start by itself is `motion-autoplay`. Sound the app makes outside a player is not playback and is not here.
6
+
7
+ ## <Rule id="media-system-player" description="Play through the platform's engine, and finish any transport you draw yourself" />
8
+
9
+ The engine is `AVPlayer` on iOS and ExoPlayer on Android, with a wrapper over one of them in every cross-platform stack. Nothing here asks anyone to write a decoder. What varies is who draws the transport, and each system view hands over a different set: `AVPlayerViewController` and SwiftUI's `VideoPlayer` carry the route picker, the caption menu the system caption setting drives, and picture in picture once the capability is on, while Media3's `PlayerView` carries subtitles, artwork and the controls, with picture in picture and Cast wired separately beside it.
10
+
11
+ Drawing your own transport is allowed and common. What is not allowed is dropping what the system view gave you: model each control on the one it replaces, and re-provide route picking, picture in picture, the caption menu and a scrub target a thumb can hit. A control added beside that standard set earns its place by doing something the platform does not offer, such as a chapter list, a per-episode speed or a skip increment of your own. Restyling play and pause is not one. Name every icon-only button (`a11y-name`).
12
+
13
+ ## <Rule id="media-controls" description="Controls retreat, and a tap anywhere brings them back" />
14
+
15
+ - The video is the whole glass and there is no cursor to wake the chrome with, so the reveal target is the surface itself rather than a hotspot in a corner. The system view already reveals on a tap, already holds the controls while playback is paused, and already keeps its hide delay in one place; a custom transport re-provides all three (`media-system-player`).
16
+ - Two cases no system view gets right, and they are where the work is. Controls stay up while the player is buffering, which is the moment the user most wants to see whether anything is moving. And they do not retreat on a timer at all while a screen reader is running, because a control that has already left cannot be found again by exploring the glass.
17
+ - In full screen the controls still sit inside the safe area, which nothing insets for you once the chrome is gone (`layout-insets`).
18
+ - The system volume owns the final level and the app only balances its own tracks against each other, so no in-app master volume competes with the hardware buttons. Where a level control belongs on the screen, iOS has the system volume view for exactly that. Whether the silent switch reaches you at all is an iOS question answered by the audio session category, with no Android counterpart, and that nothing in the app moves the system volume is `sound-silenced`.
19
+
20
+ ## <Rule id="media-scrub" description="The scrubber is a thumb target and the position is text" />
21
+
22
+ - A progress track drawn 2 to 4dp tall is a 2 to 4dp target unless somebody widened the hit area, and this is the control people drag while walking. The scrub area reaches the floor in `touch-floor`, measured on the hit rect and never on the drawn track.
23
+ - Elapsed and total are both printed, because a position along a bar is not readable on a moving train, and both are formatted by the locale (`l10n-format`). While the finger is down the target time is shown, and the frame does not commit until the finger lifts.
24
+ - A skip control prints its increment on itself. Media3 ships 5 seconds back and 15 forward; iOS ships no default, so the number is yours and it is written down once rather than picked per screen.
25
+
26
+ ## <Rule id="media-unasked-sound" description="Claim the speaker at the moment of play, never at launch" />
27
+
28
+ Somebody is already listening to something. Activating the audio session or taking audio focus during startup stops their podcast for a screen that is not playing anything yet.
29
+
30
+ - The activation call sits on the play path: set the category at launch, call `setActive` when playback begins, request focus when the first sample plays. Handing the speaker back is the same path in reverse and it is the half that goes missing: `setActive(false, options: .notifyOthersOnDeactivation)` on iOS and abandoning the focus request on Android, on the stop path and on the error branch alike. Without it the other app's music never comes back, which is the bug the user actually hears.
31
+ - The category matches how the app uses sound. `.ambient` mixes with whatever else is playing and is right for incidental sound; `.playback` is for sound that has to survive the silent switch, and whether it mixes or takes the output alone is an option set on it rather than a property of the category. Media the user chose is one case for it. Occasional spoken audio over someone else's music, turn-by-turn directions or a coach counting reps, is the other, and it is `.playback` with ducking rather than a category of its own.
32
+ - Whether a surface may start by itself is `motion-autoplay`, and that it starts muted when it does is `a11y-media`. What belongs here is where that mute state lives: on the feed, not on the item. Unmuting one card and getting silence on the next is the bug; getting sound the user did not ask for on the next is the other one.
33
+
34
+ ## <Rule id="media-focus" description="Ask for the output, hand it back, and decide what happens after" />
35
+
36
+ `ExoPlayer.Builder` defaults to not handling audio focus, so `setAudioAttributes(attributes, true)` is a line somebody writes or the app never yields the speaker at all. Three kinds of loss, three answers, plus a fourth case: an app targeting Android 15 is refused focus outright unless it is the top app or running a foreground service, and from Android 17 playback held without focus is silenced whatever the app targets, with nothing thrown and nothing logged. The two gates are different, so the branch has to exist in both builds. Handle the refusal, because the failure that reaches the user is silence on a device where the same build sounded fine in front of you.
37
+
38
+ - **Permanent.** Pause and stay paused. No gain callback is coming, so a person is the only thing that starts it again.
39
+ - **Transient with ducking.** From Android 8 the system ducks you silently, except for `CONTENT_TYPE_SPEECH` content and apps that asked to be told instead. Speech cannot be ducked and stay useful, so speech pauses. On iOS the ducking belongs to the app that wants to be heard, not to the app being lowered: it activates its session with `duckOthers`, or with `interruptSpokenAudioAndMixWithOthers` where the sound is occasional speech over someone's music, and the system takes the other level down at activate and restores it at deactivate. The app being ducked gets no callback and writes nothing.
40
+ - **Transient.** Pause, then resume or do not, which is a decision recorded once per kind of content in `STACK.md` rather than a default. iOS publishes whether the interruption was resumable and expects a media app to check first; Android 12 and up mutes for an incoming call and unmutes when it ends. Long-form audio the user chose resumes where it stopped, and anything the user never started does not resume at all.
41
+
42
+ ## <Rule id="media-noisy" description="The headphones came out, and on Android that pause is yours to write" />
43
+
44
+ iOS reroutes to the speaker and pauses; the platform carries it. Android only broadcasts that audio is about to become noisy, and Media3's `handleAudioBecomingNoisy` defaults to `false`, so the shipped default is your podcast playing out loud on a bus. Turn it on, or register for `ACTION_AUDIO_BECOMING_NOISY` when playback starts and unregister when it stops. A player with on-screen controls pauses; something with no controls at all may keep going.
45
+
46
+ ## <Rule id="media-background" description="Background audio is a declared capability, a service type and a store answer" />
47
+
48
+ `bg-service-last` says to look for the narrower API first, and for video that narrower API is picture in picture rather than a service. For audio the service is right, and it arrives with paperwork.
49
+
50
+ - iOS: the background mode covering audio, AirPlay and picture in picture, plus the `.playback` category. Without the capability the lock screen silences you.
51
+ - Android: a `MediaSessionService`, `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, and `android:foregroundServiceType="mediaPlayback"`. From Android 14 that type is also a store declaration stating the user impact of interrupting it. The service starts while the app is still on screen: one started after the user has already left is too late, and from Android 17 background audio with no service behind it is simply muted.
52
+ - The session ends when playback ends. Releasing the player clears the notification and hands back the hardware video decoder another app is waiting for, on the failure branch as well as the happy one (`perf-memory`). A media capability held open to keep the process alive for something else is what `bg-declared` refuses. How the ongoing notification reads is `notify-ongoing`.
53
+
54
+ ## <Rule id="media-remote" description="Once the screen is off, the lock screen is your player" />
55
+
56
+ Fill in the metadata and register the commands; the layout is the system's and you do not get to design it. `MPNowPlayingInfoCenter` with `MPRemoteCommandCenter` on iOS, `MediaSession` with `MediaMetadata` on Android, where background audio also requires a `MediaStyle` notification.
57
+
58
+ - Title, subtitle or artist, artwork and duration are all populated. On iOS that is the whole of the job, because the presentation is the system's and you do not get to choose what it shows: fill in every property you have. A blank tile on a lock screen is the app's fault and not the system's.
59
+ - The counts are Android's. The controls take up to 5 actions and only the first 3 survive the collapsed view, and how the buttons are derived changed at Android 13, so a hardcoded list of five is wrong on one side of that line. Decide which 3 matter.
60
+ - Register only the commands the app supports, and answer a transport command only while this app is the thing playing, because responding to a headset button otherwise stops someone's music from a screen they are not looking at. Tapping the tile returns to the item that is playing, not to the app's home screen.
61
+
62
+ ## <Rule id="media-resume" description="Come back where playback stopped, and say when it is paused" />
63
+
64
+ - The position is persisted per item while it plays rather than on the way out, because the process can be killed without a way out, and returning opens that item where it stopped instead of at zero. A queue keeps its place in the queue as well as its place in the track.
65
+ - A player that comes back paused shows that it is paused, because silent and idle reads as broken and the next move is to leave rather than to press play. On Android the playback resumption callback is answered too, so the system's own tile can restart the last item after a reboot without the app being opened first; what that tile carries once it is playing is `media-remote`.
66
+
67
+ ## <Rule id="media-pip" description="Picture in picture is what leaving the app means, and its button is conditional" />
68
+
69
+ An app that plays video supports picture in picture: the alternative is that a message arriving mid-episode ends the episode. On iOS it also requires the background audio capability, so it is not free.
70
+
71
+ - The affordance is drawn only after the support check passes, on both platforms, because a dead picture in picture button is worse than none. Entering and leaving reuses the same player: a fresh instance costs a black frame, the buffer and the position. Two sources must never mix, so a video moving into the window while a game's soundtrack plays underneath is the failure both platforms warn about, and one of the two has to yield.
72
+ - On Android the window is configured rather than accepted: ask the platform how many actions it takes instead of hardcoding the 3 that are a floor a device may raise, keep play and pause among them, and read back the aspect ratio you were given, because the one you request is clamped. From Android 12, auto-enter on the home gesture with a source rect hint, so leaving mid-episode needs no button at all. On iOS none of that is yours: the system draws the transport and sizes the window, and the two decisions the app makes are the support check and the background audio capability behind it.
73
+ - It is not the only route out. Where it is unsupported or refused, playback continuing while the user moves around inside the app keeps a docked bar carrying the title, play and pause, and a tap back to the full player, with the content beneath it padded to clear it (`layout-chrome`).
74
+
75
+ ## <Rule id="media-away" description="The eyes left, the ears did not" />
76
+
77
+ - Video with no picture in picture and no background capability pauses as the app goes away, and comes back at the same frame. The app switch itself is free (`state-interrupt`), which is exactly why nobody writes the pause and the user returns having missed a minute of the episode. Where picture in picture is available it takes the place of the pause (`media-pip`).
78
+ - Audio the user chose keeps playing, which is the whole point of `media-background`. The same event gets opposite answers on the two kinds of content on purpose, and the platforms lean opposite ways on the video half, so the answer is written down in `STACK.md` per surface rather than inherited from whichever one the code was ported from.
79
+
80
+ ## <Rule id="media-fullscreen" description="Turning the phone does not restart the video" />
81
+
82
+ Rotation is a configuration change, which is `state-interrupt`, and the shape of a sideways phone is `layout-orientation`. What belongs to the player is that one instance survives the turn: recreating it costs the buffer, the position, and on a metered connection the bytes a second time. Full screen keeps the controls reachable (`media-controls`) and an exit that is drawn rather than left to the back gesture.
83
+
84
+ - Never bake letterbox or pillarbox bars into the asset. One phone plays the same file full screen, embedded in a list, rotated and inside a picture in picture window, and baked padding is visible in three of those four.
85
+ - Where a custom transport picks the gravity itself, follow what the system player already does with the ratio: fill for 2:1 through 2.40:1, fit for 4:3, 16:9 and anything up to 2:1, and fit again above 2.40:1. Through the system view there is nothing to write.
86
+
87
+ ## <Rule id="media-awake" description="Keep the screen on for video only, and let it go when playback stops" />
88
+
89
+ This is a window flag on the player's own screen, `FLAG_KEEP_SCREEN_ON` or `android:keepScreenOn` on Android and `isIdleTimerDisabled` on iOS. It is not a wake lock, and a service cannot hold it.
90
+
91
+ - Audio-only playback never keeps the screen on. Not needing the screen is the point of playing audio.
92
+ - It clears on pause, on stop, on leaving the screen and on the error branch, which is the deterministic end `perf-power` asks of anything holding hardware open. ExoPlayer's wake mode is a separate setting whose default is not the one you want in either direction, so set that one explicitly beside the service.
93
+
94
+ ## <Rule id="media-start" description="One second to sound, or to a sign that sound is coming" />
95
+
96
+ Within one second of the tap, either audio is playing or something on screen says it is being prepared. A player that looks identical for four seconds gets tapped again, and the second tap is a stop.
97
+
98
+ - The first load is `state-loading`: a placeholder in the shape of the player, not a spinner over a black rectangle. A rebuffer mid-playback is a different state. It does not tear down the controls, does not reset the position, and does not flip the play control to paused, because the user did not pause. A stream that dies instead says what failed and offers a retry that keeps the position (`state-error`, `state-retry`).
99
+
100
+ ## <Rule id="media-quality" description="The metered ceiling is set once, and the choice sticks" />
101
+
102
+ - `net-metered` decides what counts as metered and rules that a tap is still answered; what is left here is the number. An adaptive stream gets an explicit bitrate cap under the flag instead of being left to find its own ceiling on somebody's data plan.
103
+ - A manual quality choice outlives the item it was made on, and switching quality keeps the position rather than restarting playback.
104
+ - Downloading for later waits for an unmetered connection by default and is queued rather than tied to the screen (`off-queue`); preloading the next item stops entirely under the flag (`net-prefetch`).
105
+
106
+ ## <Rule id="media-live" description="Live has no total duration and its edge keeps moving" />
107
+
108
+ - Live is labelled as live, and the label does not rely on color (`color-not-alone`). No total-duration text and no percentage on a stream with no end, and where seeking back exists, a control returns to the edge and says how far behind the user currently sits.
109
+ - A live video surface the user has navigated away from inside the app, with nothing left playing it, stops rather than spending bytes on something nobody is watching. A recording is paused and kept where it stopped; a live edge cannot be. An app that has done `media-background` properly, with a session and a service behind it, is playing in the background on purpose and this does not touch it. A stall recovers by jumping to the edge rather than replaying what was missed.
110
+
111
+ <Check>
112
+
113
+ <Verify rule="media-system-player">Playback runs through the platform's player component, and any custom control exists for a command the system does not offer.</Verify>
114
+ <Verify rule="media-controls">A tap on the video surface reveals the controls, they stay up while buffering and do not retreat on a timer under a screen reader, they sit inside the safe area, and no in-app volume control competes with the hardware buttons.</Verify>
115
+ <Verify rule="media-scrub">The scrub hit area reaches the touch floor, elapsed and total are both text, and every skip control prints its increment.</Verify>
116
+ <Verify rule="media-unasked-sound">The audio session is activated and focus requested on the play path rather than at startup, the category matches the use, and an auto-started surface starts muted with the mute state held above the item.</Verify>
117
+ <Verify rule="media-focus">Focus is requested at start and abandoned at stop, all three losses have a branch, the refused request has one too, and resuming afterwards is an explicit decision per content type.</Verify>
118
+ <Verify rule="media-noisy">Playback pauses when the output becomes noisy, with the Android default flipped rather than assumed.</Verify>
119
+ <Verify rule="media-background">Background audio declares the capability, the service type and the permissions, starts the service from the foreground, and releases the player on every exit path.</Verify>
120
+ <Verify rule="media-remote">Title, artwork and duration reach the system controls, only supported commands are registered, the Android collapsed view is designed for its first 3 actions, and the tap returns to the playing item.</Verify>
121
+ <Verify rule="media-resume">The position is stored per item as it plays and restored on return, a player that returns paused says so, and the Android resumption callback is answered.</Verify>
122
+ <Verify rule="media-pip">The picture in picture affordance is behind a support check, the window keeps the same player instance, the Android action count is read from the platform with play and pause among them, and playback that continues inside the app keeps a docked bar leading back to the player.</Verify>
123
+ <Verify rule="media-away">Video with no picture in picture and no background capability pauses when the app leaves the foreground and resumes at the same frame, while audio the user chose keeps playing.</Verify>
124
+ <Verify rule="media-fullscreen">One player instance survives rotation and the full-screen transition, no bars are baked into the asset, and any custom transport picks its fit mode from the aspect ratio.</Verify>
125
+ <Verify rule="media-awake">The screen-on flag is set for video only, on the player's own screen, and cleared on pause, stop, exit and error.</Verify>
126
+ <Verify rule="media-start">Sound or a preparing indicator arrives within one second of the tap, and a rebuffer leaves the controls and the position alone.</Verify>
127
+ <Verify rule="media-quality">A metered connection has a written bitrate cap, a manual quality choice persists, and downloads wait for unmetered.</Verify>
128
+ <Verify rule="media-live">A live stream is labelled without relying on color, shows no total duration, offers a return to the edge, and stops once its surface is left with nothing playing it.</Verify>
129
+
130
+ </Check>
@@ -0,0 +1,113 @@
1
+ # Motion
2
+
3
+ A phone shows one screen at a time, so every screen replaces the last one outright. Motion is what stops that from being a cut: it says where a thing came from, where it went, and that the tap registered. That is the whole job. Everything else is time added between a finger and the content it was reaching for, on a device where the same transition plays dozens of times a day and is paid for in battery.
4
+
5
+ Generated screens fail this in a recognisable way: motion appears everywhere except the three places it was needed, an entrance animation on content that never changed, a pulse on a badge, a fade-in on the first screenful, and no fallback at all for a person who turned movement off.
6
+
7
+ Press feedback timing is `touch-feedback`. Loading and skeleton behaviour is `state-loading`. The predictive back gesture is `touch-gestures`. Token values and per-stack API names are in `references/motion-tokens.md`.
8
+
9
+ ## <Rule id="motion-job" description="Every animation answers a question, and there are three questions" />
10
+
11
+ Continuity: this came from that, or it went there. Latency: work is happening and here is the shape of it. Acknowledgement: your touch landed. Point at an animation and name which of the three it serves. If the answer is that the screen felt static, delete it.
12
+
13
+ The frequent interactions are already animated, and the two platforms want different things done about it. On iOS, do not add motion to a switch, a row selection or a tab change: the system tuned those and a hand-written replacement trades something tuned for something invented. On Android those same components move from the theme's motion scheme, so a build that wants them calmer or livelier changes the scheme rather than animating the component where it is used.
14
+
15
+ The count is per screen, and at rest means no work outstanding, no gesture in progress and no media playing. In that condition nothing moves, with one exception: an indicator saying work is still happening, which is `state-loading`.
16
+
17
+ ## <Rule id="motion-platform" description="The transition between screens is not yours to write" />
18
+
19
+ Push, sheet, cover and dismissal come with motion attached. Where the container ends through a gesture, which is the interactive pop, the sheet drag and predictive back, that motion is interruptible and driven by the finger rather than played at it. A custom route transition replaces it with a fixed animation that runs to the end, and one that never reads the gesture's progress leaves the system drawing a back preview the transition itself ignores. Driving it from that progress instead is `touch-gestures`.
20
+
21
+ Custom transition code between screens (`PageRouteBuilder` with a hand-written `transitionsBuilder`, `enterTransition`, `exitTransition`, `popEnterTransition` or `popExitTransition` overriding a `composable()` destination's preset, a `UIViewControllerAnimatedTransitioning` for an ordinary push) needs a reason written next to it. Which container is right in the first place is `nav-container`.
22
+
23
+ ## <Rule id="motion-model" description="Name the platform's model, never a literal value" />
24
+
25
+ Two motion systems, and using the wrong one is what makes a build feel foreign.
26
+
27
+ - **iOS is spring based.** `Animation.spring(response:dampingFraction:)` or `UIView.animate(springDuration:bounce:)`, and the parameter is bounce, not a curve. Apple publishes no duration table for UI motion, so a millisecond figure attributed to iOS was invented by whoever wrote it.
28
+ - **Material ships both.** The Views library still carries sixteen duration tokens and seven easing tokens, and adds six springs beside them. The Compose Material scheme carries no duration and no easing at all: `MaterialTheme.motionScheme` exposes six specs, three spatial and three effects, in a standard or an expressive scheme, and `MaterialExpressiveTheme` defaults to expressive. Material motion on Compose is therefore reached as a spring spec. `tween` and the easing curves stay available for animations outside the Material scheme.
29
+
30
+ Spatial springs move a thing and may overshoot. Effects springs carry color and opacity, where overshoot means the value passes its own target and the color is briefly wrong.
31
+
32
+ The rule the two systems share is that no motion value is invented at the call site, the way `color-roles` allows no hex there. `spring(dampingRatio = 0.4f, stiffness = 120f)` written inline is the motion equivalent of a raw hex, and twenty of them are twenty different feels in one app. Where the value is reached from differs, and only one platform hands you a theme:
33
+
34
+ - **Android** has one, so use it: `MaterialTheme.motionScheme` on Compose, the `?attr/motionSpring*` and duration attributes in Views.
35
+ - **iOS** ships no motion theme, so the app is the one that has to hold the set. Put the named `Animation` constants in a single file and refer to them by name from every call site.
36
+
37
+ ## <Rule id="motion-duration" description="Where duration applies, it is latency, and it scales with distance" />
38
+
39
+ Press feedback has its own deadline, which is `touch-feedback`. What this rule owns is everything after it: a routine transition finishes inside 300ms, and past 400ms the animation stops being motion and becomes a wait, one the user pays on every navigation for the life of the app.
40
+
41
+ Duration rises with the area covered: a chip changing tint and a full screen cover arriving do not share a number. In Material terms the short tokens (50 to 200ms) carry small in-place changes and the medium tokens (250 to 400ms) carry a transition, with a full screen change at the top of that band and nothing above it. The long and extra-long tokens start at 450ms, so they belong only to motion no interaction is waiting on.
42
+
43
+ ## <Rule id="motion-choreo" description="One thing leads" />
44
+
45
+ When several elements move at once, the eye needs one anchor. Give the change a single subject, either an element that persists across the transition (`SharedTransitionLayout` on Compose, `.navigationTransition(.zoom(sourceID:in:))` on iOS 18, `Hero` on Flutter) or one region that moves while the rest holds still. Four independent animations at four different durations is not choreography, it is four animations.
46
+
47
+ Stagger only where the content genuinely arrives as a list, only on first appearance, and only within a budget: at most 30ms of step between rows and at most 200ms of added delay across the visible ones, so the last row is not waiting on the first. A stagger that replays on every scroll, every refresh or every filter change turns the list into a slot machine, and it re-runs on recycled rows, so it fires for rows that were already on screen.
48
+
49
+ ## <Rule id="motion-loop" description="Nothing loops next to something being read" />
50
+
51
+ An animation that repeats without end has no question to answer: the tap already landed, the content already arrived. Beside text, it takes the reading away from everyone and makes it impossible for some.
52
+
53
+ - Motion that starts on its own, runs longer than 5 seconds and sits beside other content needs a control to pause, stop or hide it. Motion the user is waiting on is the exception and needs no such control: a shimmer or a progress indicator is doing the job `state-loading` gives it.
54
+ - Auto-updating information gets the same control at any duration, because a figure that rewrites itself under the eye has no safe length.
55
+ - Nothing flashes more than 3 times in any 1 second.
56
+ - Every animation ends when its reason ends. One still running after its cause is gone is a bug with an animation on it.
57
+ - A parallax or collapsing header tracks the finger and never plays by itself. What is banned outright is an element pulsing to attract attention and an entrance animation on the first screenful (`layout-fold`). Content the user opened the app for is already the reason they are looking; fading it in delays it and says nothing.
58
+
59
+ ## <Rule id="motion-autoplay" description="Video and animated images start because the user started them" />
60
+
61
+ The longest-running motion in a real app is usually not an animation anyone wrote: it is a video preview, a looping clip or an animated image in a feed. Nothing above governs it, and muted autoplay is still motion beside the thing being read. Where the platform publishes a preference, it is read rather than assumed: on iOS `UIAccessibility.isVideoAutoplayEnabled` carries the Auto-Play Video Previews switch, and the animated images setting sits beside it.
62
+
63
+ - Where the setting is off, the asset shows its first frame with a play control and waits.
64
+ - Anything that does autoplay carries a visible stop within one step, never buried behind a long press.
65
+ - A looping asset stops when its screen goes away, rather than playing on behind whatever came next.
66
+ - The sound half is `a11y-media`, and stopping autoplay on a metered or power-saving device is `state-offline`.
67
+
68
+ ## <Rule id="motion-blocks" evidence="device" description="Motion never holds the user still" />
69
+
70
+ Nothing waits for an animation to finish. A second tap during a transition does not queue a second transition, the back gesture interrupts whatever is playing, and no input is gated on a completion callback. This matters more the more often the animation runs: a sequence that charms once is an obstacle by the fiftieth launch.
71
+
72
+ Anything the user cannot skip and did not ask for is the failure case: a splash sequence played out before the content is reachable, a success animation held for a beat after the work is done, a modal that cannot be dismissed until its entrance completes.
73
+
74
+ ## <Rule id="motion-cheap" evidence="device" description="Hand-written animation moves transform and opacity, not layout" />
75
+
76
+ The test is who owns the animation, not which property moves. The framework's own layout animations are tuned and batched, so a shared element or container transform, `AnimatedVisibility`, `Modifier.animateContentSize`, `Modifier.animateItem` and Flutter's implicit `Animated*` widgets are all correct, including where they animate bounds. What this rule bans is the hand-written kind: a value driven per frame on the main thread into width, height, margin, padding or a static offset, which re-runs measurement every frame and is how a smooth-looking animation becomes the dropped-frame complaint. The frame budget it has to fit inside is `perf-frame`, and a 120Hz panel halves it.
77
+
78
+ - React Native: `useNativeDriver: true`, or Reanimated, so the animation is not sitting behind whatever the JS thread is doing. Layout properties do not support the native driver at all.
79
+ - Compose: animating a static offset value re-runs composition and measurement, so take the lambda form of `Modifier.offset`, which defers the read to placement, or `graphicsLayer`, which defers it to draw.
80
+ - Flutter: `FadeTransition` or `AnimatedOpacity` for a fade and `SlideTransition` or `ScaleTransition` for movement. Animating an `Opacity` widget directly rebuilds its subtree every frame; `Transform` is for a static transform.
81
+ - Mobile web: `transform` and `opacity` only.
82
+
83
+ Blur, shadow and shader work stay bounded to a region, and the count of things animating at once is small enough to name.
84
+
85
+ ## <Rule id="motion-reduced" evidence="device" description="The reduced build still communicates, it just does not move" />
86
+
87
+ Reduce Motion on iOS and Remove animations on Android are settings real people turn on because motion makes them ill. Neither is answered by setting duration to zero and calling it done, and the two ask for different things, which is why one implementation cannot serve both.
88
+
89
+ - **iOS asks you to substitute.** A cross fade replaces a slide or a zoom, parallax and depth changes go entirely, springs lose their bounce, and nothing animates into or out of a blur. Check `UIAccessibility.prefersCrossFadeTransitions` before substituting a cross fade; SwiftUI reads that one through UIKit, since there is no environment value for it.
90
+ - **Android asks you to remove.** The signal is the animator duration scale, so a substituted animation will not run either. The requirement is that the screen still reads correctly with nothing animating at all, and on Compose that the scheme drops to `MotionScheme.standard()`, because nothing swaps the expressive one by itself.
91
+
92
+ Feedback survives on both, because a person who turned motion off still needs to know the tap worked.
93
+
94
+ Read the flag per stack, which is the trap: on Flutter, `MediaQuery.disableAnimationsOf` carries Android's setting and iOS Reduce Motion arrives only through `AccessibilityFeatures.reduceMotion`, so reading one silently drops the other platform's users. The names are in `references/motion-tokens.md`.
95
+
96
+ Motion is also never the only carrier of a change. Anything that says its piece by moving says nothing to the person who turned movement off, and nothing to a screen reader either.
97
+
98
+ <Check>
99
+
100
+ <Verify rule="motion-job">Every animation on the screen serves continuity, latency or acknowledgement; and separately, with no work outstanding, no gesture in progress and no media playing, the only thing still moving is a latency indicator.</Verify>
101
+ <Verify rule="motion-platform">No custom transition replaces a platform push, sheet, cover or dismissal without a written reason.</Verify>
102
+ <Verify rule="motion-model">iOS motion is expressed as springs reached from one named set, Android motion through the scheme or the tokens, and no duration, curve or stiffness is a literal at a call site.</Verify>
103
+ <Verify rule="motion-duration">Routine transitions finish under 300ms, nothing an interaction waits on exceeds 400ms, and larger movements take longer than smaller ones.</Verify>
104
+ <Verify rule="motion-choreo">One element or region leads each transition, and any stagger runs on first appearance only, stepping at most 30ms per row and adding at most 200ms overall.</Verify>
105
+ <Verify rule="motion-loop">Nothing pulses to attract attention, nothing animates in on the first screenful, self-starting motion beside other content has a pause control past 5 seconds, auto-updating figures have one at any duration, nothing flashes more than 3 times a second, and every animation ends when its reason ends.</Verify>
106
+ <Verify rule="motion-autoplay">Autoplay is gated on the platform setting, anything that autoplays has a visible stop, and a looping asset stops with its screen.</Verify>
107
+ <Verify rule="motion-blocks">No input, dismissal or back gesture is blocked by an animation, and a repeated tap does not queue a second one.</Verify>
108
+ <Verify rule="motion-cheap">Layout properties are animated only by the framework's own layout animations, hand-written animation stays on transform and opacity, and the React Native animations declare the native driver.</Verify>
109
+ <Verify rule="motion-reduced">The reduced-motion flag is read on every platform the app ships to, iOS substitutes rather than deletes, and the Android screen still reads with nothing animating.</Verify>
110
+
111
+ <Device>Three of these are not answered from the file. `motion-reduced` is answered on a device with the setting turned on, because a reduced-motion path that was written and never wired to the flag reads exactly like one that works. `motion-cheap` is answered half in the source, where the native driver and the animated properties are visible, and half in the frame profiler, which is the only place a dropped frame exists. `motion-blocks` is answered by tapping through a transition and pressing back during one.</Device>
112
+
113
+ </Check>
@@ -0,0 +1,116 @@
1
+ # Navigation
2
+
3
+ There is one surface and nothing around it. No window title, no breadcrumb trail, no second pane still holding the place the user came from: the current screen is the entire map, and whatever it says about where it sits is all the user gets.
4
+
5
+ Then the session breaks. Someone walks away mid task, the system reclaims the process to free memory, a notification drops them into the middle of the app without passing the front door. Structure is the part of the design that has to survive all three, and it is decided in the router long before anyone looks at a screen.
6
+
7
+ `STACK.md` records which navigator this codebase uses and what its destinations are. This file is about the structure those choices produce. The tab bar as a control is `button-tabs`; the back gesture, the predictive animation and the system edge zones are `touch-gestures`. Container and restoration APIs per stack sit in `references/navigation-containers.md`, for one lookup rather than a read through.
8
+
9
+ ## <Rule id="nav-depth" description="Three levels, and the job within two taps" />
10
+
11
+ Destination, list, detail. Three is what someone holds in their head without a map, and a fourth needs a reason written into `STACK.md`. Count it from a top-level destination to the deepest screen reachable under it.
12
+
13
+ The other half is distance: the job this app exists for, the one named in `PRODUCT.md`, is at most two taps from a top-level destination. Depth is cheap to add in a router and expensive in a hand, because every level is another screen to re-recognise after an interruption.
14
+
15
+ A fourth level is usually a filter wearing a screen. If the new screen is the previous list narrowed, narrow the list instead.
16
+
17
+ ## <Rule id="nav-container" description="The container comes from the relationship, not from convenience" />
18
+
19
+ Five containers, five different relationships to what is underneath them:
20
+
21
+ | Container | What it means | How it ends |
22
+ |---|---|---|
23
+ | Pushed screen | more about the thing that was tapped | back |
24
+ | Top-level destination | another section of the app, always available | selecting another one |
25
+ | Bottom sheet | a short task or a set of options belonging to the screen under it | swipe down, scrim, back |
26
+ | Full screen cover | a self-contained task that needs the whole screen | an explicit Cancel and a named commit |
27
+ | Alert or dialog | one decision that genuinely cannot wait | choosing an answer, and on Android a back dismiss unless that was turned off on purpose |
28
+
29
+ The reflex to watch is presenting everything modally. A modal is presented rather than routed, so unless the stack gives it a route of its own it has no address and no history, and someone who leaves it cannot get back to where they were. So a **place** in the app is pushed or is a destination; only a **task**, one the user starts, finishes and returns from, gets a sheet or a cover. Settings presented as a sheet is the usual tell.
30
+
31
+ The sheet, cover or dialog carries its own primary action: `button-one-primary`.
32
+
33
+ ## <Rule id="nav-modal" description="A modal is a task, and its exit is one you drew" />
34
+
35
+ On a phone the modal covers its parent, so there is no visible background to click away to and no window edge to close. The only way out is the one on the screen.
36
+
37
+ - Name both exits. Cancel abandons, and the commit is the verb of the task. A lone X leaves the user guessing whether the work was kept.
38
+ - Interactive dismissal is already on, so the line to look for is the one that turns it off: `interactiveDismissDisabled`, a sheet state that refuses to hide. It belongs only where dismissing loses work, and where it appears the question appears with it, which is not the same as trapping.
39
+ - Never open a modal over a modal. The second one is a pushed screen inside the first.
40
+
41
+ ## <Rule id="nav-back" description="Back unwinds the stack and nothing else" />
42
+
43
+ On Android back is a system event that reaches every screen, sheet, cover and dialog, and the components dismiss the top surface with it already. That it exists and must not be swallowed is `touch-gestures`. iOS sends no such event: a full screen cover and an alert there end only through a control the app drew, which is what `nav-container` and `nav-modal` ask for. What is left here is what back means against the stack.
44
+
45
+ - Back pops the surface on top. It is not bound to a control that moves the user sideways to another destination, forward through a wizard, or backward through an edit.
46
+ - Intercepting it is allowed; ending in nothing is not. An interception resolves into a dismissal or a question, never into a screen that consumes the event and stays where it is.
47
+ - From a top-level destination that is not the start destination, back unwinds to the start destination first, and only leaves the app from there. Dropping someone out of the app from the third tab ends the session by accident. That unwinding is the stack emptying, not back being repurposed.
48
+ - Where an interception guards work that was typed and not saved, what happens to that work is `form-persist`.
49
+
50
+ ## <Rule id="nav-back-control" description="A custom back control keeps the gesture it replaced" />
51
+
52
+ On iOS the edge swipe that pops a screen is wired to the system's own back button. Hide that button or swap it out and the gesture leaves with it, silently: `navigationBarBackButtonHidden`, a custom `leftBarButtonItem`, `setNavigationBarHidden`. The screen still shows something that looks like back, so nothing is visible in a screenshot, while everyone who navigates by thumb has lost the fastest way out.
53
+
54
+ Keep the system control. Where a custom one has to replace it, restore the interactive pop on that screen in the same place, and make sure the replacement still reads as back rather than as a new action.
55
+
56
+ ## <Rule id="nav-location" description="Every screen says where it is" />
57
+
58
+ There is no window title and no breadcrumb, so a screen that names nothing leaves its position to be inferred from the content. That inference fails fastest where it costs most: three levels down, on the screen a notification just opened, in an app that was closed a second ago.
59
+
60
+ Every pushed destination carries a title naming what it is, and every top-level destination carries its own. Where more than one screen leads to this one, the back control names the destination it returns to instead of showing a bare arrow. Which top-level destination is selected stays visible throughout, which is `button-tabs`.
61
+
62
+ ## <Rule id="nav-deeplink" evidence="device" description="Arriving in the middle still needs everything above it" />
63
+
64
+ Phone apps are entered from outside constantly, and usually cold: notification, widget, share sheet, a link in a message, with the process dead and nothing in memory.
65
+
66
+ - Build the stack the user would have walked: start destination at the bottom, the linked screen on top, the ancestors in between. Android synthesises it from `navDeepLink` on the destination and React Navigation from the `linking` config, so there the answer is that the route is declared; on SwiftUI the path is rebuilt by hand, which is where this is real work. A linked screen whose back leaves the app is a dead end.
67
+ - Test from a killed process. The warm path passes by itself and hides the cold one, which is where this fails in production.
68
+ - A link that needs auth remembers where it was going and lands there after sign in, not on the home screen.
69
+ - A target that no longer exists lands on the nearest real screen and says what happened; what that screen says is `state-error`.
70
+ - The toolbar arrow on a screen entered from outside climbs this app's hierarchy. Wiring it to the same dismiss the device back calls hands the user back to the app they came from while pointing at this one.
71
+
72
+ ## <Rule id="nav-tab-stack" description="Each top-level destination keeps its own stack" />
73
+
74
+ Switching away and coming back returns to the screen that was left, not the root of that section. This is how people check one thing mid task and resume, and losing it costs them the work in progress on that branch.
75
+
76
+ The frameworks disagree on the default here, so it is a decision to make rather than a behaviour to inherit. Selecting the destination that is already selected is the way back to its root, and pops to it.
77
+
78
+ The stability of the destination set itself belongs to `button-tabs`.
79
+
80
+ ## <Rule id="nav-drawer" description="A drawer is not primary navigation on a phone" />
81
+
82
+ Navigation behind a hamburger costs a tap before it can even be read, and what people cannot see they do not use. The trigger also lives in the top corner, which is the hardest point on the screen to reach one-handed: `touch-reach`. iOS has no drawer convention at all, so a drawer there reads as a port.
83
+
84
+ Material allows the modal drawer at a phone's width, and this skill overrules that, with the cost stated: a map nobody sees, behind a control in the hard region, paid on every session. Primary destinations are visible without a tap. Where the app has more sections than the bar holds, the overflow is a destination of its own: a screen with a title and a back path, not a panel sliding over the app. A drawer that survives is a secondary surface for account switching, rare settings or a long secondary list, and every job `PRODUCT.md` names is reachable without opening it.
85
+
86
+ ## <Rule id="nav-search" description="Search becomes structure once a collection outgrows the thumb" />
87
+
88
+ There is no sidebar to park a filter tree in, and scanning by thumb gives out long before a list does. The trigger is what the collection is for: once people arrive at it to find one specific item rather than to browse, it needs search. Roughly 50 items is this skill's working number for where that flips, and an app whose content makes it lower or higher sets its own in `STACK.md`.
89
+
90
+ - Search that spans the app is a top-level destination with its own stack, not a screen pushed onto whatever happened to be open. Which surface it becomes, and everything inside it, is `search-surface`.
91
+
92
+ ## <Rule id="nav-restore" evidence="device" description="The process will be killed, and nobody asked for that" />
93
+
94
+ Backgrounded apps get reclaimed, on both platforms, without warning. Coming back to a different screen than the one that was left is a bug even when the process died in between.
95
+
96
+ What comes back is the place: the selected destination, its stack, the selection on the screen that was left, the filters that were applied, and the sheet that was open. Where in a collection the screen resumes is `scroll-restore`. The values in an unfinished form are `form-persist`. Saving at the moment the system says to save, and admitting anything that did not survive, is `state-interrupt`.
97
+
98
+ The cutoff is a decision rather than a default. Restore the exact place when the app was left within about the last day, and open at the root of its top-level destination beyond that, so nobody resumes into week-old content they have to work out. Record the number in `STACK.md`. Content refreshes on the way in; only the place is restored.
99
+
100
+ <Check>
101
+
102
+ <Verify rule="nav-depth">Hierarchy runs at most three levels below a top-level destination, or the fourth is recorded in `STACK.md` with its reason, and the app's main job is within two taps of one.</Verify>
103
+ <Verify rule="nav-container">Nothing that is a place in the app is presented modally: a surface a deep link resolves to, or one that appears in the destination list, belongs in a pushed screen or a destination.</Verify>
104
+ <Verify rule="nav-modal">Every modal names both exits, turns interactive dismissal off only where work would be lost and asks there, and no modal opens over another.</Verify>
105
+ <Verify rule="nav-back">Back pops the surface on top, is not bound to a sideways or forward move, resolves every interception, and does not exit the app from a destination that is not the start destination.</Verify>
106
+ <Verify rule="nav-back-control">No screen hides or replaces the system back control without restoring the interactive pop gesture alongside it.</Verify>
107
+ <Verify rule="nav-location">Every route builder sets a title, and a back control with more than one origin names where it returns to.</Verify>
108
+ <Verify rule="nav-deeplink">Every deep link target opens from a killed process with a full stack above it, survives a sign in, and fails onto a real screen.</Verify>
109
+ <Verify rule="nav-tab-stack">Each top-level destination keeps its own stack across a switch, and re-selecting the current one pops it to its root.</Verify>
110
+ <Verify rule="nav-drawer">Primary destinations are visible without a tap, and every job named in `PRODUCT.md` is reachable without opening a drawer.</Verify>
111
+ <Verify rule="nav-search">A collection people come to search rather than browse either carries a search field on its own screen or is covered by an app-wide search destination that keeps its query.</Verify>
112
+ <Verify rule="nav-restore">The destination, its stack, scroll, selection, filters and the open sheet come back after the process is killed, and the restore cutoff is a set number rather than forever.</Verify>
113
+
114
+ <Device>`nav-deeplink` and `nav-restore` are answered by killing the process and launching from a link, not by reading the router. A graph that looks correct in the file is exactly the one that loses the stack on a cold link.</Device>
115
+
116
+ </Check>
@@ -0,0 +1,118 @@
1
+ # Network
2
+
3
+ The phone is the device where the connection is worst and the person holding it is least patient. The link is high latency, it costs money on most plans, and it changes underneath a running screen: Wi-Fi to cellular at the door, one cell to the next on a train, three bars to nothing in a lift.
4
+
5
+ Online and slow is the problem this file exists for, and it is a different problem from offline. Nothing throws, so no error branch runs. The request is still open, the screen is still waiting, and the only thing that ends it is the user closing the app.
6
+
7
+ This file owns the request and the radio: deadlines, retries, cancellation, how many calls a screen makes and how many bytes each one costs. What is stored on the device and what is queued while disconnected is `heuristics/offline.md`. What the screen shows while any of it happens is `state-loading`, `state-offline` and `state-stale`.
8
+
9
+ ## <Rule id="net-timeout" evidence="device" description="Every request carries a deadline the app chose" />
10
+
11
+ `state-loading` owns what the screen draws when a deadline is reached. This rule owns the deadline itself, which no stack sets on the app's behalf.
12
+
13
+ The stock values bound parts of a call, not the call. On iOS `timeoutIntervalForRequest` defaults to 60 seconds and is an idle timer, reset every time a byte arrives, so a connection dripping one packet at a time never trips it at all; `timeoutIntervalForResource`, the cap on the whole transfer, defaults to seven days. OkHttp bounds connect, read and write at ten seconds each and leaves `callTimeout` at zero, so the call as a whole is unbounded. The trickling connection is the ordinary way a phone link fails, and none of those defaults ends it.
14
+
15
+ So bound the whole call, not just its idle gaps. Give each class of request its own deadline (an interactive read, a submit, a background sync, a media transfer), keep them as named constants in one place instead of per call site, and hand each one to `state-loading` with its branch already written.
16
+
17
+ The two background classes carry an exception. A background `URLSession` transfer is bounded by `timeoutIntervalForResource` rather than by the request timeout, and it retries a timed-out upload or download itself, so the attempt ceiling in `net-backoff` does not apply there and a hand-rolled retry stacked on top of it counts twice.
18
+
19
+ ## <Rule id="net-backoff" evidence="device" description="Retry backs off with jitter, and stops at a written number of attempts" />
20
+
21
+ `state-retry` settles that automatic retry backs off, stops, and fires on the platform's reconnect signal. Three things it leaves open, and this rule owns them.
22
+
23
+ - **Jitter.** That reconnect signal fires on every phone at once: a carriage empties onto a platform, a tower comes back, a train leaves a tunnel. A fixed schedule turns the fleet into one synchronised wave that arrives at a server already struggling. Randomise every delay.
24
+ - **A ceiling that exists as a constant.** Attempts are counted and the count is written down, so the retry ends in a message rather than in a loop nobody watched.
25
+ - **Only what is safe to retry.** A lost connection, a timeout, a 429 and a 5xx are worth another attempt. A 400, 403 or 404 gives the same answer the second time, and spending attempts on it only delays the sentence the user needs. Where the server sends `Retry-After`, it wins over the schedule.
26
+ - **A 401 is the one 4xx that is retried.** It means authenticate and try again, so the app refreshes the token and replays the request once, through the hook built for it (`Authenticator` in OkHttp, a request interceptor on iOS and in Flutter). Deduplicate the refresh, or an hour spent backgrounded ends with every queued request firing its own. Sign the user out only when the refresh itself is refused.
27
+
28
+ A retried write carries the same client-generated key on every attempt, so the server can tell the second arrival is the first one coming round again. Without it, a confirmation that dies on the return trip charges the card twice. `off-queue` holds that key for queued work; a foreground retry of a submit needs it just as much.
29
+
30
+ ## <Rule id="net-cancel" evidence="device" description="Leaving the screen cancels its requests, and a late answer never lands on the current one" />
31
+
32
+ What the first half hunts is the detached request: `GlobalScope`, a bare `Task {}`, a `fetch` with no signal, a client nobody disposes. Owned by nothing, it holds the radio up and delivers into a screen that has been popped. The idiomatic APIs already end on their own, so this is a defect of going around them rather than of forgetting a call: `.task` and `lifecycleScope` die with the screen, an `AbortController` aborts with the effect that made it, a Flutter cancel token is disposed with the widget. `viewModelScope` is the deliberate exception, because a ViewModel survives the rotation that destroys and rebuilds the screen, which is what makes the second half of this rule load-bearing rather than free.
33
+
34
+ That second half is ordering. Two answers to the same question come back out of order, and on a slow link they routinely do: the search for "ma" lands after the search for "mango" and overwrites it. What has to be true is that a response never writes state for a question the user has moved on from. A latest-wins operator gets there by construction (`flatMapLatest` or `collectLatest`, `.task(id:)`, `switchMap`, a token swapped per query) and correct code built that way has no "am I still current" line anywhere in it; an explicit generation check gets there by hand. Either satisfies the rule. The phone makes the failure constant rather than rare, because back is a cheap edge gesture, screens are destroyed and rebuilt, and with a single screen visible the overwrite happens under the eyes of the person who caused it.
35
+
36
+ ## <Rule id="net-dedupe" description="One read in flight per thing being asked for" />
37
+
38
+ Key in-flight reads on the endpoint and its parameters. On a phone the same read is fired twice from ordinary places the user never touched: two components on one screen wanting the same record, a screen that re-requests on every resume, an effect that repeats on a re-render.
39
+
40
+ - A duplicate arriving from code joins the request already running instead of opening a second one.
41
+ - A duplicate arriving from the user preempts it. A pull to refresh is a request for the value as of now, so it cancels the refresh already in flight and fetches again; joining that one answers the gesture with bytes fetched before the thumb moved, which is the single thing the gesture exists to rule out. `list-refresh` owns the gesture itself.
42
+ - Writes are never deduplicated by request shape. Two identical writes are two writes: the same message sent twice, a second unit of the same item, a measurement logged again. What collapses a duplicate write is the client key in `net-backoff`, carried on every attempt so the server can recognise it, with `button-state` stopping the control accepting the second tap in the first place.
43
+
44
+ ## <Rule id="net-fanout" evidence="device" description="Count the calls a screen makes, and the count does not grow with the rows" />
45
+
46
+ A list that fires one request per row is a defect rather than a slow screen. The clients pool connections, so the bill is not twenty handshakes: it is twenty round trips on a link where one round trip is already the slowest thing on the screen, and a radio held at full power across the whole span instead of for a single burst.
47
+
48
+ - The first render of a screen makes a fixed number of calls, and that number is written down. Rows arrive carrying what they draw, or their ids go out in one batched call.
49
+ - Chains pay their latency end to end. Independent calls start together; only a call that genuinely needs the previous answer waits for it.
50
+ - Polling is fan-out spread over time. Where the product needs live data, a subscription or a push costs one connection instead of one per interval. The interval a poll is allowed to keep, and ending it when its screen goes away, is `perf-power`.
51
+
52
+ ## <Rule id="net-payload" description="Ask for the size the screen draws and the fields it renders" />
53
+
54
+ - **Images.** Request the variant sized for the box it lands in, at the device's pixel ratio, from the server or the image CDN. Decoding to the drawn size is `list-images` and `perf-decode`, which are memory; this is the bytes crossing a metered link, and shipping a full-resolution photograph to fill a 48dp circle spends both.
55
+ - **Fields.** Ask for what the screen renders. A row showing a name and a thumbnail does not need the record behind it, and a mobile-shaped response is a server change worth asking for rather than a filter applied after the download.
56
+ - **Pages.** Page size is a constant derived from what fills the viewport plus a screenful of headroom. Page by cursor rather than by offset: a phone feed is re-entered a dozen times across a day of interruptions and each return resumes paging from where the thumb stopped, so rows that shifted in between make an offset repeat some and skip others, in the one place the user is looking.
57
+ - Nothing sets `Accept-Encoding` by hand. The clients add it and decompress the response transparently, and both stop the moment a header interceptor writes that header itself, leaving the app fetching uncompressed bytes and holding a decode it did not ask for.
58
+
59
+ ## <Rule id="net-conditional" description="Refreshing something already held asks whether it changed" />
60
+
61
+ The cheapest answer on a metered radio is the one with no body in it. A record kept locally keeps the validator it arrived with, `ETag` or `Last-Modified`, and the refresh sends it back as `If-None-Match` or `If-Modified-Since`. A 304 then moves the freshness mark that `state-stale` renders and leaves the content on screen untouched.
62
+
63
+ Skip it and every pull to refresh downloads a page the device already holds byte for byte, which is the most expensive way to learn that nothing happened. The validator lives in the record's own row, beside the fields `off-fresh-marks` names, because a header cache the OS is free to reclaim cannot be relied on to still hold it.
64
+
65
+ ## <Rule id="net-metered" description="Metered is a setting the user chose, not a transport you detect" />
66
+
67
+ Read the flag the platform publishes: `NWPath.isConstrained` and `allowsConstrainedNetworkAccess` for Low Data Mode, `getRestrictBackgroundStatus()` and `NET_CAPABILITY_NOT_METERED` for Data Saver and metered networks, `isConnectionExpensive` in React Native, `navigator.connection.saveData` on the web where it exists at all, which is a hint and not a guarantee.
68
+
69
+ - Metered is defined by what the connection costs the person, not by which radio carries it. A tethered hotspot arrives over Wi-Fi and is metered; an unlimited plan is cellular and is not. Code that branches on "is this Wi-Fi" gets both cases wrong.
70
+ - Limit on any metered connection, whether or not the system setting is on, and whether or not the app has been exempted from the restriction. The exemption is permission to keep working, not permission to stop caring.
71
+ - What changes under the flag is listed in `state-offline`. What must not change is the tap: user-initiated work is delivered, in a smaller form if it has to be, and never blocked. Do not put up a dialog asking whether they really meant it. They set the flag on purpose, and the app is being asked to spend less, not to ask more.
72
+
73
+ ## <Rule id="net-reachability" description="A connectivity check defers optional work, it never gates a tap" />
74
+
75
+ Having a network and reaching a server are different states, and the platforms report them separately: on Android `NET_CAPABILITY_INTERNET` means the network is set up while `NET_CAPABILITY_VALIDATED` means it was actually probed, and a captive portal holds the first without the second. React Native splits the same pair into `isConnected` and `isInternetReachable`, and `navigator.onLine` on the web counts a LAN with no route out as online.
76
+
77
+ - The check never decides whether a request the user has committed to is allowed out. Send it, and let the failure be the answer, because a check saying no on a working connection is an outage the app invented. It may shape the control before the tap, which is the online-only mode in `off-write-mode`, and it may skip optional traffic. It may not sit between the tap and the socket.
78
+ - Where it earns its place is deferring the optional: prefetch, analytics and background sync skip rather than sending the radio hunting for a signal that is not there.
79
+ - Subscribe rather than poll: `NWPathMonitor`, `registerDefaultNetworkCallback`. Capabilities change under a running app, and a value read a moment ago is already a guess.
80
+ - There is a third answer besides sent and failed. `waitsForConnectivity` holds a task until a path exists instead of failing it, and reports through `urlSession(_:taskIsWaitingForConnectivity:)`, which is where the app gets to say so. That wait takes the same deadline as everything else and surfaces through `state-offline`, rather than sitting silently inside a loading state with no end. Background sessions ignore the flag and always wait.
81
+
82
+ ## <Rule id="net-prefetch" description="Prefetch spends data on content that may never be read" />
83
+
84
+ It is a trade, so it gets a budget rather than an instinct. Fetch in a shape that needs another download only every 2 to 5 minutes and in the order of 1 to 5 megabytes, and pull large media in chunks on that same interval instead of in one go. What the repeated waking costs the battery is `perf-power`.
85
+
86
+ - Write down what is prefetched, how much of it, and what triggers it. The next page of a list already being scrolled and the detail behind the row under the thumb both earn it. A whole feed of full-size media does not.
87
+ - It never runs during launch, where `perf-cold-start` already keeps preloads for unopened screens off the path to the first frame, and it never runs under the flag in `net-metered`.
88
+
89
+ ## <Rule id="net-upload" description="A transfer outlives the screen, and its progress counts bytes" />
90
+
91
+ `off-queue` owns the durable queue of writes and the scheduler that drains it. What is left here is the transfer the user started and is watching, which runs long enough that the app is suspended before it ends. Hand it to the platform service that matches its shape.
92
+
93
+ - **iOS.** A background `URLSession`. Its upload body has to be a file on disk: a background session refuses one built from a `Data` or a stream, which is the failure that kills the transfer at the first suspension.
94
+ - **Android.** WorkManager where the transfer is short and interruptible. A user-initiated data transfer job (`setUserInitiated(true)`, permission `RUN_USER_INITIATED_JOBS`, API 34 and up) where it is long and the user started it, scheduled while the app is still visible. `DownloadManager` where the thing is a download, since it already retries across connectivity changes and reboots and takes its own metered and roaming limits. The long-running foreground-service worker is what is left below API 34, and for apps targeting API 35 and up its data sync time is capped at 6 hours in any 24, with the timer reset each time the user brings the app back to the foreground.
95
+
96
+ Then:
97
+
98
+ - Chunk it and keep a resume handle in durable storage, so an interruption continues instead of starting over. On iOS a download resumes from `resumeData` only where the request was a GET, the server sent `ETag` or `Last-Modified`, and byte ranges are supported; miss one and the whole file comes down again. An upload has no client-side equivalent: its resume point lives on the server, through a chunked or resumable upload protocol.
99
+ - The transfer does not survive everything, and the interface must not imply it does. Swiping the app out of the switcher cancels iOS background transfers until the person opens the app again.
100
+ - Progress is real bytes moved over bytes total, emitted by the transfer rather than estimated from elapsed time. How that number is drawn, whether pause stands beside cancel, and what the user is told cancelling costs are all `state-loading`.
101
+
102
+ <Check>
103
+
104
+ <Verify rule="net-timeout">Every class of request has a total deadline on the whole call, held as a named constant, and background transfers take the resource timeout rather than the request one.</Verify>
105
+ <Verify rule="net-backoff">Automatic retry randomises its delays, counts attempts against a constant, retries only transient failures, 5xx and one deduplicated token refresh on a 401, and repeats a write under the same client key.</Verify>
106
+ <Verify rule="net-cancel">No request runs on a detached scope, and a response to a question the user has left cannot write state, whether by a latest-wins operator or by an explicit generation check.</Verify>
107
+ <Verify rule="net-dedupe">Reads in flight are keyed and shared, a refresh gesture preempts the request already running rather than joining it, and no write is collapsed by its shape.</Verify>
108
+ <Verify rule="net-fanout">The number of calls a screen makes to render is fixed and written down, does not scale with row count, and independent calls do not run in a chain.</Verify>
109
+ <Verify rule="net-payload">Image requests carry the drawn size, responses carry only rendered fields, pages are cursor-based at a size derived from the viewport, and nothing sets `Accept-Encoding` by hand.</Verify>
110
+ <Verify rule="net-conditional">A refresh of a stored record sends the validator it was stored with, and a 304 updates its freshness mark without touching the content on screen.</Verify>
111
+ <Verify rule="net-metered">The metered and Low Data Mode flags are read from the platform rather than inferred from the transport, and no user-initiated request is blocked or questioned because one is set.</Verify>
112
+ <Verify rule="net-reachability">No connectivity check stands between a committed tap and the request it sends, and the app subscribes to path changes rather than polling them.</Verify>
113
+ <Verify rule="net-prefetch">Prefetch has a written budget and trigger, and runs neither during launch nor on a metered connection.</Verify>
114
+ <Verify rule="net-upload">Long transfers run on the platform service matching their shape, carry a durable resume handle, and report progress from real byte counts.</Verify>
115
+
116
+ <Device>Answer `net-timeout`, `net-backoff` and `net-cancel` on a throttled connection rather than on a fast one, because every one of them passes by accident when the response arrives in 40ms. `net-fanout` is answered by counting the calls on a proxy while one screen opens, not by reading the repository.</Device>
117
+
118
+ </Check>