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.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/cli.js +105 -0
- package/dist/commands/build.js +35 -0
- package/dist/commands/detect.js +91 -0
- package/dist/commands/doctor.js +110 -0
- package/dist/commands/graph.js +106 -0
- package/dist/commands/install.js +76 -0
- package/dist/commands/lint.js +87 -0
- package/dist/commands/rubric.js +104 -0
- package/dist/commands/spec.js +300 -0
- package/dist/compile.js +235 -0
- package/dist/detect/rules.js +208 -0
- package/dist/detect/types.js +36 -0
- package/dist/emit.js +77 -0
- package/dist/graph.js +288 -0
- package/dist/heuristics.js +185 -0
- package/dist/lock.js +18 -0
- package/dist/mdx.js +84 -0
- package/dist/paths.js +62 -0
- package/dist/skill.js +69 -0
- package/package.json +51 -0
- package/src/skills/SKILL.md +125 -0
- package/src/skills/flow/build.md +57 -0
- package/src/skills/flow/firebase.md +102 -0
- package/src/skills/flow/init.md +116 -0
- package/src/skills/flow/review.md +186 -0
- package/src/skills/flow/spec.md +149 -0
- package/src/skills/heuristics/accessibility.md +124 -0
- package/src/skills/heuristics/ads.md +140 -0
- package/src/skills/heuristics/auth.md +130 -0
- package/src/skills/heuristics/background-work.md +129 -0
- package/src/skills/heuristics/buttons.md +99 -0
- package/src/skills/heuristics/camera.md +127 -0
- package/src/skills/heuristics/chat.md +125 -0
- package/src/skills/heuristics/colors.md +129 -0
- package/src/skills/heuristics/copy.md +157 -0
- package/src/skills/heuristics/data-display.md +124 -0
- package/src/skills/heuristics/feedback.md +122 -0
- package/src/skills/heuristics/forms.md +124 -0
- package/src/skills/heuristics/icons-and-imagery.md +135 -0
- package/src/skills/heuristics/layout.md +125 -0
- package/src/skills/heuristics/lists.md +129 -0
- package/src/skills/heuristics/localization.md +128 -0
- package/src/skills/heuristics/maps.md +129 -0
- package/src/skills/heuristics/media.md +130 -0
- package/src/skills/heuristics/motion.md +113 -0
- package/src/skills/heuristics/navigation.md +116 -0
- package/src/skills/heuristics/network.md +118 -0
- package/src/skills/heuristics/notifications.md +121 -0
- package/src/skills/heuristics/offline.md +124 -0
- package/src/skills/heuristics/onboarding.md +103 -0
- package/src/skills/heuristics/payments.md +138 -0
- package/src/skills/heuristics/performance.md +111 -0
- package/src/skills/heuristics/permissions.md +125 -0
- package/src/skills/heuristics/privacy-ui.md +112 -0
- package/src/skills/heuristics/scrolling.md +114 -0
- package/src/skills/heuristics/search.md +127 -0
- package/src/skills/heuristics/sense.md +128 -0
- package/src/skills/heuristics/settings.md +129 -0
- package/src/skills/heuristics/sharing.md +102 -0
- package/src/skills/heuristics/sound.md +95 -0
- package/src/skills/heuristics/splashscreen.md +111 -0
- package/src/skills/heuristics/states.md +120 -0
- package/src/skills/heuristics/touch.md +95 -0
- package/src/skills/heuristics/typography.md +99 -0
- package/src/skills/heuristics/updates.md +129 -0
- package/src/skills/heuristics/webviews.md +114 -0
- package/src/skills/heuristics/widgets.md +128 -0
- package/src/skills/references/capability-checks.md +59 -0
- package/src/skills/references/fonts.json +339 -0
- package/src/skills/references/icon-and-image-assets.md +103 -0
- package/src/skills/references/input-fields.md +82 -0
- package/src/skills/references/launch-surface.md +94 -0
- package/src/skills/references/motion-tokens.md +89 -0
- package/src/skills/references/navigation-containers.md +51 -0
- package/src/skills/references/search-controls.md +49 -0
- package/src/skills/references/type-scales.md +60 -0
- package/src/skills/references/wireframe-frame.md +209 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Updates and migrations
|
|
2
|
+
|
|
3
|
+
The update that matters here is the one the user did not install: it arrived in the background while the phone charged, and the version that opens in a queue the next morning is one nobody chose and nobody read anything about.
|
|
4
|
+
|
|
5
|
+
This file covers what that person meets: a screen that will not let them in, an install that happens under them, and the first launch of a new binary over a store the old one wrote. The surface the app launches onto is `heuristics/splashscreen.md`, the screen a failed migration shows is `heuristics/states.md`, and the first run of a fresh install is `heuristics/onboarding.md`.
|
|
6
|
+
|
|
7
|
+
## <Rule id="upd-block-test" description="A block is earned by what broke, not by a version number being behind" />
|
|
8
|
+
|
|
9
|
+
Four conditions earn a wall: the client speaks a contract the server has stopped honouring, a security fix that has to be everywhere, a bug that damages data while the app runs, or a legal requirement the installed build cannot satisfy. The list is closed. Everything else offers and lets the person carry on, and a fifth reason is a named exception in `STACK.md` saying what it costs the people it locks out.
|
|
10
|
+
|
|
11
|
+
`installedVersion < latestVersion` is not one of those conditions. Written that way every release becomes mandatory, and someone standing at a barrier with a ticket in the app pays for a copy change with a download on whatever signal the platform has. Decide blocking or flexible per release, from the failure, and record the test in `STACK.md`.
|
|
12
|
+
|
|
13
|
+
On Play the urgency travels as a property of the release: an integer from 0 to 5, defaulting to 0, set when the release rolls out and not editable afterwards. A release published as routine can never be promoted later, which is one more reason the decision to block belongs to something the app can still ask.
|
|
14
|
+
|
|
15
|
+
## <Rule id="upd-min-version" description="The floor is an answer from the server, never a constant in the build" />
|
|
16
|
+
|
|
17
|
+
A blocked client is the one you can no longer ship to, so the number that blocks it has to stay changeable after the build has left. A minimum version compiled in is a wall that cannot be lowered when it turns out to be wrong.
|
|
18
|
+
|
|
19
|
+
- The check is one request with a deadline (`net-timeout`) and a stated default, and the default is that the app opens. A gate that fails closed locks out everyone whenever that endpoint is down or the user is on a train.
|
|
20
|
+
- Apple publishes no in-app update flow and no version-check API, so whatever an iOS build knows about its own currency, it asked your backend for. Android can read the store's answer, and that answer says only that a newer build exists. The urgency travelling with it is the developer's own, set at rollout and frozen there, so it is a message from a past release rather than a judgement about this client.
|
|
21
|
+
- The reason arrives with the floor. The server names what stopped working so the wall can say it, rather than the client guessing from a number.
|
|
22
|
+
|
|
23
|
+
## <Rule id="upd-gate-screen" description="The wall states the reason, and it leads somewhere" />
|
|
24
|
+
|
|
25
|
+
It is the shape of `state-permission`: a stated reason and a route out, never a dead end. That rule allows no wall at all, because a refused permission still leaves an app that works with less. This is the one wall the skill allows, because the break is in the client itself and nothing in the build repairs it, so the route out goes to the store rather than to a reduced screen.
|
|
26
|
+
|
|
27
|
+
- The version check never holds the launch surface (`splash-hold`), so the app draws its first real screen with the check still outstanding and the wall is raised over that screen once the answer lands. A slow or failed check therefore leaves a usable app rather than a held launch, which is the same default `upd-min-version` states from the network side.
|
|
28
|
+
- One sentence naming what stopped working and what the move is, written as `copy-error` writes a failure. "This version is no longer supported" names nothing. Where that sentence comes from the server it travels as a reason code the client has strings for, because a wall is the last screen that can afford to be in the wrong language (`l10n-strings`).
|
|
29
|
+
- One action, labelled with where it goes (`button-label`), opening this app's store listing. Check what it does when the store app is absent or the link does not resolve, because that branch is the whole screen.
|
|
30
|
+
- The wall names only what it can also reach. Where the old binary still renders something correctly, a cached ticket, a phone number, a saved pass, a second and quieter action opens it, which makes the gate a sheet over a reduced app rather than a terminal screen. Listing what the user cannot get to is the failure this bullet exists to prevent, so anything unreachable goes unmentioned.
|
|
31
|
+
- The version and build stay legible on the wall itself. Support asks for them first and the About screen that normally holds them (`set-diagnostics`) is behind the wall.
|
|
32
|
+
|
|
33
|
+
## <Rule id="upd-prompt-shape" description="The offer is not an alert, and launch is not the moment" />
|
|
34
|
+
|
|
35
|
+
Someone opened the app to do one thing. A dialog standing in front of the first screen is the interruption `fb-unprompted` ranks lowest, and it arrives before the user has any context for the choice.
|
|
36
|
+
|
|
37
|
+
- The offer appears after the first screen is up (`splash-first-frame`), on a surface that can be left without answering, and never between the user and the task.
|
|
38
|
+
- The dismissal is persisted for a period written in `STACK.md`, which is `fb-unprompted` and needs nothing added here. Re-asking every cold start is the wall built out of a prompt.
|
|
39
|
+
- What is decided here is the second number, how stale an install has to be before the offer returns, and where staleness is measured from. Take it from the store's own count of days since the release became available; where the store publishes no count, the release date comes back beside the floor from the endpoint `upd-min-version` already calls. Both platforms then compare against a date the server owns rather than a timer in the build.
|
|
40
|
+
|
|
41
|
+
## <Rule id="upd-flexible-install" description="The download runs in the background, the install moment is the user's" />
|
|
42
|
+
|
|
43
|
+
Android's flexible flow downloads while the app stays usable, and it hands back three answers rather than one: accepted, cancelled, and failed. Cancelled is an answer, so the app carries on.
|
|
44
|
+
|
|
45
|
+
- When the install state reaches downloaded, offer the restart on a surface the user can ignore. The flow is defined as the one where it is acceptable to keep using the app while the download runs, so calling `completeUpdate()` on your own schedule takes back the thing that made it flexible.
|
|
46
|
+
- Ask whether the flow is allowed on this install before drawing the entry point, since it is not available everywhere, and fall back to the store listing when it is not.
|
|
47
|
+
- Two things are picked up on return and they are not the same check: a blocking flow the app started and that was interrupted is still in progress and is re-entered, while a background download that finished while the user was away is offered its restart. Both are checked at every entry point, not on the one screen that started it, or the user sits in a half installed state with nothing offering to finish it.
|
|
48
|
+
- iOS has none of this. There the offer is a link, and the user leaves to take it.
|
|
49
|
+
|
|
50
|
+
## <Rule id="upd-restart-state" description="The next run is a cold launch of a different binary" />
|
|
51
|
+
|
|
52
|
+
An install ends the process that was running, and whoever opens the app next, the store or the user, opens it cold. The stack, the scroll position, the open sheet and everything typed and not yet saved are gone unless they were already written down.
|
|
53
|
+
|
|
54
|
+
So the update flow starts by committing: `form-persist` for what was typed, `nav-restore` for the place, `state-interrupt` for the save points. A prompt raised in the middle of a form with no save behind it is a data loss the user attributes to the app rather than to the store.
|
|
55
|
+
|
|
56
|
+
The binary that comes back is a different one, so a restored destination has to still exist in it. A route removed in this release opens its root, not a crash.
|
|
57
|
+
|
|
58
|
+
## <Rule id="upd-migration-once" description="The first launch after an update runs the migration once, all of it or none of it" />
|
|
59
|
+
|
|
60
|
+
The stored schema version is the trigger, and the new number is written in the same transaction as the work it describes. Written first, a process killed halfway leaves a store that claims to be migrated and is not. Backgrounded apps are reclaimed without warning, and a slow first launch is exactly when someone switches away.
|
|
61
|
+
|
|
62
|
+
Where the store belongs to the framework, that transaction comes free: the version lands inside the framework's own migration, so a killed process rolls the whole thing back and the next launch starts again from the beginning. The clause bites on migrations written by hand, and on the work that leaves the store entirely: a file moved on disk, a preference key renamed, a keychain item rewritten. Those are the steps that can be found half done, so those are the ones that have to survive being run twice. A step that appends rows doubles them on the second pass, and what crosses that seam is `upd-carry-over`.
|
|
63
|
+
|
|
64
|
+
Each stack names the mechanism: Room's automated migrations with a spec carrying the renames and deletions, or `Migration(startVersion, endVersion)` handed to the builder; Core Data's lightweight migration through `NSMigratePersistentStoresAutomaticallyOption` and `NSInferMappingModelAutomaticallyOption`, with `NSMappingModel.inferredMappingModel(forSourceModel:destinationModel:)` answering whether the change is inferrable at all before you assume it; SwiftData's `SchemaMigrationPlan`, one lightweight or custom stage per version pair.
|
|
65
|
+
|
|
66
|
+
## <Rule id="upd-migration-path" description="Someone opens the app a year late" />
|
|
67
|
+
|
|
68
|
+
The path runs from every version still installed, not from the previous one. Steps chain, and each pair is tested against a store written by that version rather than by the current build.
|
|
69
|
+
|
|
70
|
+
- The installed base is a range by construction. Apple's phased release ramps over seven fixed days at 1, 2, 5, 10, 20, 50 and 100 percent, pausable up to 30 days in total and not otherwise reshapable, while Play's percentage is the developer's and can be halted, which strands everyone already updated on a version nobody else will get. Two versions live at once is the normal state.
|
|
71
|
+
- Missing paths fail differently and neither failure is quiet: Room throws when it cannot find one, and Core Data returns no inferred model.
|
|
72
|
+
- Downgrades happen: a reinstall from the store after a halt or a withdrawal serves the build that was live before it, and a restored device backup or a sideload puts back whatever it was holding. Decide what a store written by a newer version does, because with no path declared in that direction the store simply fails to open.
|
|
73
|
+
|
|
74
|
+
## <Rule id="upd-migration-visible" description="A migration is work with an end, and nobody watches a launch surface for it" />
|
|
75
|
+
|
|
76
|
+
It does not belong on the launch path: `perf-cold-start` puts database open and migration off it, and `splash-hold` takes only bounded local reads.
|
|
77
|
+
|
|
78
|
+
- Fast enough and nothing is shown. Slower, and it happens on a real screen with a placeholder shaped like what is coming, then names the stage or counts what is done once the wait gets long, which is `state-loading` and needs nothing new here.
|
|
79
|
+
- Measure it against the largest store a real user has. The first launch after an update is the one moment the biggest store meets the newest code, and an empty simulator never reproduces it.
|
|
80
|
+
- Failure is a designed screen with a move (`state-error`), never a launch that hangs and never an empty screen implying the data is gone.
|
|
81
|
+
|
|
82
|
+
## <Rule id="upd-no-wipe" description="A schema bump is not permission to delete what the user has" />
|
|
83
|
+
|
|
84
|
+
The destructive escape hatches are one line each and they read like configuration: the destructive migration fallbacks delete every row in the tables, and the widespread raw SQLite upgrade that drops the tables and recreates them does the same thing by hand.
|
|
85
|
+
|
|
86
|
+
On a phone the device is the copy. There is no file the user can put back, and for anything never synced there is no server to fetch it from again.
|
|
87
|
+
|
|
88
|
+
Where a wipe is genuinely right, because the store holds nothing but a cache of server data, say so at the call site and confirm what the next launch shows while it refills: `off-no-cache`.
|
|
89
|
+
|
|
90
|
+
## <Rule id="upd-carry-over" description="Queued work and drafts cross the version boundary" />
|
|
91
|
+
|
|
92
|
+
- The queue is the sharp edge. The new build arrived without anyone asking for it, so those entries were written by a build the user never chose to leave, and on a phone there is no second machine, no export and no earlier install to recover them from. Entries written by the old build have to be readable by the new one, or drained before the schema moves. Dropping them on upgrade discards writes the user was already told had been accepted (`state-queued`, `off-queue`).
|
|
93
|
+
- A renamed preference key or a moved file path with no code to carry the value across is the loss that ships most often, because it looks like a tidy rename in the diff.
|
|
94
|
+
- Cached content is the only one allowed to be discarded. Name what this version invalidates and let it refill (`off-cache-policy`). A draft is not in that category (`form-persist`).
|
|
95
|
+
- There is one way to test it: install the old version, make real data, install the new build over the top. A fresh install passes every time and proves nothing.
|
|
96
|
+
|
|
97
|
+
## <Rule id="upd-whats-new" description="Almost nothing earns a screen" />
|
|
98
|
+
|
|
99
|
+
A release that moved something the user relied on has something to say. A release of fixes does not, and neither does a panel selling a feature nobody asked about, which is the tour `onboard-in-place` exists to refuse. Nobody asked for the sheet either, so `fb-unprompted` has already set the rung it takes and the moment it may appear.
|
|
100
|
+
|
|
101
|
+
Where there is one, it is a single screen, skippable, shown once, and after the first screen is up rather than in front of it; `onboard-screens` sets the ceiling on how many panels anyone sits through. Better than any of that: point at the thing that moved, where it moved to, the first time that screen is opened.
|
|
102
|
+
|
|
103
|
+
The store listing is a separate obligation with published rules. Apple requires the What's New text to describe new features and product changes, allowing a generic line only for bug fixes, security updates and performance work. Play caps release notes at 500 characters per language, and its console guidance is that they inform about the release rather than promote or solicit an action. Neither governs the in-app screen.
|
|
104
|
+
|
|
105
|
+
## <Rule id="upd-store-channel" description="The store is the only thing that changes the binary" />
|
|
106
|
+
|
|
107
|
+
Both stores put this in policy rather than guidance. Apple requires apps to be self contained in their bundles and not to download, install or execute code that introduces or changes features. Play forbids an app distributed through it from modifying, replacing or updating itself by any mechanism other than Play's, with a narrow exception for code running in an interpreter or virtual machine, such as JavaScript in a webview.
|
|
108
|
+
|
|
109
|
+
The case that reaches this rule most often is not a self updating binary but a remote bundle: a JavaScript payload fetched at launch and swapped under the running app. Play's exception is written for interpreted code and Apple names no equivalent, so any interpreted path is checked against the current guidelines before a release plan is built on it. The design rules do not move either way. A payload that changes behaviour is an update the user did not install, crossing a schema boundary with no store install and no version number they can see, so it answers to `upd-restart-state` for the state it destroys, to `upd-migration-once` for the migration it runs, and to `upd-block-test` for whether it may stop anyone at all. It is not a way around that test.
|
|
110
|
+
|
|
111
|
+
So no surface in the app is an update button that updates. It is a link to a listing, and every rule above is written around a channel the app does not own.
|
|
112
|
+
|
|
113
|
+
<Check>
|
|
114
|
+
|
|
115
|
+
<Verify rule="upd-block-test">The blocking condition names a broken contract, a security fix, data damage or a legal requirement, is not a comparison of the installed version against the latest, and anything outside that list is a named exception in `STACK.md`.</Verify>
|
|
116
|
+
<Verify rule="upd-min-version">The minimum version arrives over the network with a deadline, and the app opens when that request fails.</Verify>
|
|
117
|
+
<Verify rule="upd-gate-screen">The gate screen is raised over a drawn screen rather than held launch, states a reason in the reader's language, carries a working route to the store listing with a fallback, shows the version, and gives an action reaching anything it names as still working.</Verify>
|
|
118
|
+
<Verify rule="upd-prompt-shape">The offer is not a launch dialog, its dismissal is persisted, and the interval before it returns is written in `STACK.md`.</Verify>
|
|
119
|
+
<Verify rule="upd-flexible-install">The install completes on the user's tap, the entry point is drawn only where the flow is allowed, and both an interrupted flow and a finished download are picked up at every entry point.</Verify>
|
|
120
|
+
<Verify rule="upd-restart-state">Everything unsaved is committed before the update flow starts, and a restored destination that no longer exists opens a root.</Verify>
|
|
121
|
+
<Verify rule="upd-migration-once">The migration is keyed off a stored schema version, writes that version in the same transaction as the work, and every step that leaves the framework's store is safe to run twice.</Verify>
|
|
122
|
+
<Verify rule="upd-migration-path">A migration path exists from every version still installed, chained and tested pairwise, with a defined behaviour for a newer store.</Verify>
|
|
123
|
+
<Verify rule="upd-migration-visible">Migration is off the launch path, shows a real loading state when it is slow, and has a designed failure screen.</Verify>
|
|
124
|
+
<Verify rule="upd-no-wipe">No destructive migration fallback and no drop-and-recreate upgrade, except over a store that is purely a cache and says so.</Verify>
|
|
125
|
+
<Verify rule="upd-carry-over">Queued entries and drafts written by the previous version are readable or drained, and every renamed key moves its value.</Verify>
|
|
126
|
+
<Verify rule="upd-whats-new">Any what's new screen is single, skippable, shown once, and not in front of the first screen.</Verify>
|
|
127
|
+
<Verify rule="upd-store-channel">Nothing in the app downloads or installs a new version itself, and any remote bundle that changes behaviour is held to the same restart, migration and blocking rules.</Verify>
|
|
128
|
+
|
|
129
|
+
</Check>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Web views
|
|
2
|
+
|
|
3
|
+
Web content inside a native app: an in-app browser, a help page, a checkout, a page the team publishes without shipping a build. This file is about that surface, not about the mobile web stack. A product that ships as a website and not as an app in a store skips this file. An app that ships a store binary wrapping a site reads all of it, starting at `webview-wrapper`.
|
|
4
|
+
|
|
5
|
+
Here: which surface a URL opens in, what the wrapper has to carry, what back does, and how content nobody on the team can restyle behaves against the theme, the text size setting, the safe area and the keyboard. The sign-in flow itself is `auth-web-flow`. A link resolving back into the app is `nav-deeplink`. The load and failure states are `state-loading`, `state-error` and `state-retry`. What the page asks the device for is the `perm-` prefix. Every setting named below is an engine setting: in a Flutter, React Native or Expo codebase the wrapper exposes the same one under its own name, and that property is where the rule is scored.
|
|
6
|
+
|
|
7
|
+
## <Rule id="webview-surface-choice" description="Three surfaces, and the raw web view is the narrowest of them" />
|
|
8
|
+
|
|
9
|
+
A phone shows one thing at a time. There is no second window and no tab strip, so a web surface takes the whole screen and the only browser chrome the user gets is whatever this app handed them. The three are the system browser, the platform in-app browser (`SFSafariViewController` on iOS, Custom Tabs on Android) and a raw web view drawn by the app (`WKWebView`, `android.webkit.WebView`).
|
|
10
|
+
|
|
11
|
+
- The raw web view is for content the team controls: your own origin, your own HTML, or a page the app injects script into. Android splits it on the same line and adds that a URL outside your own domains is likelier right in a Custom Tab.
|
|
12
|
+
- Everything else opens in the in-app browser or the system browser, because that surface is the user's browser. A Custom Tab shares the browser's cookie jar and permission grants, so a site they are already signed into stays signed in, and their browsing session, saved passwords, payment methods and addresses are all there. A raw web view starts from a store the app owns rather than the browser's, so the same person signs in again inside it.
|
|
13
|
+
- The other direction is discouraged rather than forbidden: rebuilding Safari inside a web view repeats what the browser on this phone already does, so a surface people will read several pages in supports forward and back rather than growing an address bar and a tab strip.
|
|
14
|
+
- A raw web view owns every permission the page asks for, and each one lands in the app's own inventory, `perm-inventory` and `perm-purpose-string`.
|
|
15
|
+
- A checkout in a web surface does not change which rail applies, `pay-rail`, and a link out to your own is a storefront question, `pay-steering`.
|
|
16
|
+
|
|
17
|
+
## <Rule id="webview-signin" description="Somebody else's credential field never lives in a web surface this app can read" />
|
|
18
|
+
|
|
19
|
+
The keyboard, the masked characters and the missing address bar all arrive on the same full-bleed screen, so somebody typing a password into a raw web view has no way to see whose page it is. The app drawing that surface can read every keystroke.
|
|
20
|
+
|
|
21
|
+
- No page carrying a credential this app does not own is loaded into `WKWebView` or `android.webkit.WebView`: a bank, a carrier, a partner, an employer's directory. The host app reaches the full credential rather than the grant it was owed, and can record keystrokes, submit forms and copy session cookies. The app's own sign-in page is first-party content and is ruled on by `webview-surface-choice`.
|
|
22
|
+
- The surface also has nowhere to prove whose page it is. Verifying the requested URI and the connection security is what an address bar with a security indicator is for, and the title `webview-chrome` asks for does not supply it, because the app draws that title itself.
|
|
23
|
+
- An identity provider's own sign-in is the same harm with an owner. `auth-web-flow` rules which surface it opens in and how its return leg lands, and it is scored there rather than here. The providers that enforce it refuse an authorization request sent to an embedded user-agent outright, one of them with `disallowed_useragent`.
|
|
24
|
+
|
|
25
|
+
## <Rule id="webview-chrome" description="A raw web view arrives with no chrome, so the app draws where you are and how to leave" />
|
|
26
|
+
|
|
27
|
+
An Android web view carries no navigation controls and no address bar: by default it shows a page and nothing else. Nothing marks that ownership changed from the app to something nobody on the team wrote, and a page that fails to load is a blank full screen with no exit.
|
|
28
|
+
|
|
29
|
+
- Copy the set the platform in-app browser already ships. Apple's carries a read-only address field with a security indicator, a Done button, forward and back, and a button opening the page in Safari. The app's own wrapper carries three of them: the title or the host, a close control, and a route to open this page outside the app.
|
|
30
|
+
- The close control is a real control. It meets `touch-floor`, and icon-only it carries a name (`a11y-name`). The screen still says where it is, `nav-location`, and a share affordance is the system sheet and nothing else, `share-sheet-only`.
|
|
31
|
+
- Do not remove what a Custom Tab gives away: no `OPEN_IN_BROWSER_STATE_OFF`, the close button stays enabled, and showing the title costs one call.
|
|
32
|
+
- What this rule is about is a page opened over the app's own screens. The root surface of an app that is a wrapped site has nothing to close back to and no outside to hand itself to, and it answers `webview-wrapper` instead.
|
|
33
|
+
|
|
34
|
+
## <Rule id="webview-back" description="Back resolves inside the page before it leaves the screen" />
|
|
35
|
+
|
|
36
|
+
Back is the phone's one universal exit and on both platforms it is a gesture, so it gets used without looking. An Android web view hands the first back straight past the page: by default the system exits the activity, throwing away every page read inside it.
|
|
37
|
+
|
|
38
|
+
- Android: wire an `OnBackPressedCallback` calling `goBack()` while `canGoBack()` is true. It is the only route left, because an app targeting `targetSdk` 36 is never dispatched the back key at all. `goBack()` and `goForward()` do nothing at the end of history, so the callback falls through to the stack by itself, where `nav-back` takes over.
|
|
39
|
+
- iOS: `allowsBackForwardNavigationGestures` is false by default. Turn it on, or wire a drawn control to `goBack()` against `canGoBack`. A drawn back control keeps the gesture it replaced (`nav-back-control`), and the system edge zones are `touch-gestures`.
|
|
40
|
+
- The in-app browser and the system browser resolve their own page history and need none of this.
|
|
41
|
+
|
|
42
|
+
## <Rule id="webview-leaving" description="A link that leaves the page leaves the surface" />
|
|
43
|
+
|
|
44
|
+
Other apps on this phone hold the schemes a page hands out. `mailto`, `tel`, a store link and an `intent` URL are not http, so a web view given one loads nothing and the tap dies with no feedback, while an http link to somewhere else turns a one-page surface into an unlabelled browser.
|
|
45
|
+
|
|
46
|
+
- Every raw web view has a navigation policy: a written list of hosts that stay inside, everything else handed to the platform. The decision point is `shouldOverrideUrlLoading` on Android and `WKNavigationDelegate` on iOS. A URL outside the listed hosts opens in the default browser rather than inside the surface. The in-app browser and the system browser have no such list to set.
|
|
47
|
+
- Non-http schemes are dispatched rather than loaded, and a dispatch nothing on the device can receive fails with a message rather than as a dead tap.
|
|
48
|
+
- A link resolving back into this app is `nav-deeplink`, and the link the app hands out is `share-link-not-shot`.
|
|
49
|
+
|
|
50
|
+
## <Rule id="webview-appearance" evidence="device" description="The page did not read the theme, so hand it the appearance or accept the one it has" />
|
|
51
|
+
|
|
52
|
+
A white full-screen page arriving in the middle of a dark app at night is the whole screen going white, on an OLED panel, held close to the face. There is no surrounding window to soften it.
|
|
53
|
+
|
|
54
|
+
- An Android web view sets `prefers-color-scheme` from the app theme's `isLightTheme`, so a page with its own dark styles already follows. Algorithmic darkening is disallowed by default, so for a page with no dark styles `setAlgorithmicDarkeningAllowed(true)` is the only lever, and it needs `targetSdkVersion` 33 or above, where `setForceDark` does nothing at all.
|
|
55
|
+
- A Custom Tab left alone already follows the system: `COLOR_SCHEME_SYSTEM` is the default, so the failure is a colour set once. A tab that sets a toolbar colour sets two, light and dark, through `setColorSchemeParams`, and pins neither `COLOR_SCHEME_LIGHT` nor `COLOR_SCHEME_DARK` against the system.
|
|
56
|
+
- On iOS the page reads the appearance of the view it sits in, so an app forcing an appearance over the web view forces the page's `prefers-color-scheme` with it. What is missing there is the algorithmic lever: a page shipping no dark styles stays light inside a dark app, and a first-party page is where those styles get written. Dark is a second design (`color-dark-composed`), and what happens to weight in it is `type-dark`.
|
|
57
|
+
|
|
58
|
+
## <Rule id="webview-text-size" evidence="device" description="The text size setting reaches the page, or the page can be enlarged" />
|
|
59
|
+
|
|
60
|
+
Android scales system text to 200% on a non-linear curve and iOS carries accessibility sizes above its default, and the web surface is the one screen in the app that can ignore all of it.
|
|
61
|
+
|
|
62
|
+
- An Android web view already follows the system font scale, taking its initial text zoom from the configuration. Calling `setTextZoom()` severs that permanently, so the violation is a line that exists rather than one that is missing.
|
|
63
|
+
- Leave every page loaded from a URL zoomable: `setBuiltInZoomControls(true)` with `setDisplayZoomControls(false)`, because the built-in mechanism is the only supported one and the on-screen buttons are deprecated. A first-party page sets no `user-scalable=no` and no `maximum-scale`, which `form-input` rules on alongside the field size.
|
|
64
|
+
- In `WKWebView` a first-party page asks for the setting instead of inheriting it: size text with the `-apple-system-body`, `-apple-system-headline`, `-apple-system-subheadline`, `-apple-system-caption1`, `-apple-system-caption2` and `-apple-system-footnote` values of the CSS `font` shorthand rather than fixed pixels. They are WebKit values and no other engine implements them, so the Android side is the font scale above and nothing more. The app side is `type-scaling` and `a11y-settings`.
|
|
65
|
+
|
|
66
|
+
## <Rule id="webview-viewport" evidence="device" description="The insets are paid once, and the keyboard shortens the visual viewport only" />
|
|
67
|
+
|
|
68
|
+
The notch, the home indicator and the gesture bar are all on this device, the keyboard takes half the screen, and neither the page nor the native container knows what the other one already paid for. Padding twice and not padding at all land on the same screen.
|
|
69
|
+
|
|
70
|
+
- Insets are applied in exactly one place per surface. Android forwards cutout and system bar dimensions to the page as `safe-area-inset-*`, and only where that system UI overlaps the web view's own bounds: a surface already inset away from the edges reads zeros, which is the documented answer and not a bug. Where they do arrive, a container that also pads from `WindowInsets` without returning the handled types zeroed doubles them, and a 40px status bar becomes an 80px gap. `WindowInsetsCompat.CONSUMED` is worse: the web view is never told the insets changed and keeps the previous padding. Edge-to-edge is enforced at `targetSdk` 35 and cannot be opted out of at 36, so there is no version to wait for. The native half is `layout-insets` and `layout-chrome`.
|
|
71
|
+
- On iOS web content is inset inside the safe area for you. `viewport-fit=cover` turns that off, and a page setting it pads its own four edges with `env(safe-area-inset-*)`, floored through `max()`, or the sensor housing covers content.
|
|
72
|
+
- The keyboard resizes the visual viewport and not the initial one, so nothing derived from `vh` shrinks and a control pinned to a `vh` bottom sits under the keyboard. Android resizes only the bottom edge, by the web view's intersection with the window, and an app opting out manages the resize itself or `scrollIntoView()` fails and the keyboard covers the focused field. A page clearing focus from a resize handler flaps: focus, keyboard, resize, blur, keyboard gone. `touch-keyboard` and `scroll-keyboard` own the native half.
|
|
73
|
+
|
|
74
|
+
## <Rule id="webview-transfers" description="A download and a file picker inside a web surface are the app's work, or they fail silently" />
|
|
75
|
+
|
|
76
|
+
A phone shows no filesystem, so a download producing nothing looks exactly like a slow one, and the second tap is what the user does next. A file input that opens no picker is a dead control on a screen with no cursor to hover it with.
|
|
77
|
+
|
|
78
|
+
- Android cancels every file request by default. `onShowFileChooser` is overridden, returns true and keeps the callback, or the page's file input does nothing at all. The web view reaches every file the app can reach, so the chooser is scoped to what the page asked for and no wider.
|
|
79
|
+
- Android performs no download itself: it notifies the app through a registered `DownloadListener`, carrying the URL, the MIME type and the length. On iOS `WKDownloadDelegate` has one required method and it exists to name the destination the system writes to.
|
|
80
|
+
- Every path ends somewhere the user can reach the file, and a failure says so under `state-error`. A Custom Tab keeps its download button, which is on by default. Handing the file on is `share-file-uri`, and the transfer out is `net-upload`.
|
|
81
|
+
|
|
82
|
+
## <Rule id="webview-session" description="What happens inside a web surface is not the app's session, and the app cannot read it" />
|
|
83
|
+
|
|
84
|
+
The browser on this device already holds this person's sessions. A raw web view starts from a store of the app's own, so it asks them to sign in again by hand, on a keyboard, and whatever it keeps afterwards is the app's to clear.
|
|
85
|
+
|
|
86
|
+
- Nothing reads an outcome out of the platform in-app browser: interactions with that web interface are not visible to the app, which cannot reach autofill data, browsing history or website data. An outcome that matters arrives from the server or through a claimed link, `nav-deeplink`.
|
|
87
|
+
- No access token, session cookie or account identifier is handed to a page. The wider version is `priv-instrument`.
|
|
88
|
+
- Sharing is a decision rather than a default. A Custom Tab shares the browser's cookies unless `setEphemeralBrowsingEnabled` is set, and the iOS authentication session, whose surface belongs to `auth-web-flow`, shares unless `prefersEphemeralWebBrowserSession` is set before `start()`. A raw web view's store is the app's own: the default `WKWebsiteDataStore` writes to disk and `nonPersistent()` does not, so anything holding a signed-in session is cleared on sign-out, `auth-signout` and `off-session`.
|
|
89
|
+
- The trade is worth naming once. The browser gives the saved-credential behaviour of `form-autofill` and a separate authentication context at the same time; a raw web view gives neither.
|
|
90
|
+
|
|
91
|
+
## <Rule id="webview-wrapper" description="A wrapped site inherits none of the floors this file sets" />
|
|
92
|
+
|
|
93
|
+
A site in a frame gets nothing for free. The touch floor, the safe area, the text size setting, the back gesture and the keyboard all stop being the platform's job and become the page's, and the page was drawn for a pointer on a screen that never moved.
|
|
94
|
+
|
|
95
|
+
- Every web surface the app ships is listed in `STACK.md` beside what it does natively, so the next one is a decision rather than a habit. Each one on that list still answers `touch-floor`, `webview-back`, `webview-text-size` and `webview-viewport` on its own account.
|
|
96
|
+
- Apps that browse the web on iOS use WebKit, and an entitlement is the only route to another engine. What may change the shipped binary at all is `upd-store-channel`.
|
|
97
|
+
|
|
98
|
+
<Check>
|
|
99
|
+
|
|
100
|
+
<Verify rule="webview-surface-choice">Every URL outside a domain the team controls opens in the platform in-app browser or the system browser, each raw web view loads a first-party origin or HTML bundled with the app, and no raw web view carries an editable address field or a tab strip.</Verify>
|
|
101
|
+
<Verify rule="webview-signin">No `WKWebView` or `android.webkit.WebView` load call takes a page carrying a third party's credential field, a bank, a carrier or a partner included; an identity provider's sign-in is scored under `auth-web-flow` rather than here.</Verify>
|
|
102
|
+
<Verify rule="webview-chrome">Every raw web view opened over a native screen carries the page title or its host, a close control at the touch floor with a name, and a route to open the page outside the app, and no Custom Tab passes `OPEN_IN_BROWSER_STATE_OFF` or disables its close button.</Verify>
|
|
103
|
+
<Verify rule="webview-back">Every Android web view wires an `OnBackPressedCallback` to `goBack()` while `canGoBack()` is true, and every `WKWebView` either sets `allowsBackForwardNavigationGestures` to true or wires a drawn control to `goBack()`.</Verify>
|
|
104
|
+
<Verify rule="webview-leaving">Each raw web view has a navigation policy naming the hosts that stay inside, non-http schemes are dispatched to the platform rather than loaded, and every dispatch has a no-receiver path that shows a message rather than failing silently.</Verify>
|
|
105
|
+
<Verify rule="webview-appearance">No `setForceDark` call remains, every Android web view loading a page with no dark styles allows algorithmic darkening at `targetSdk` 33 or above, every Custom Tab that sets a toolbar colour sets both `setColorSchemeParams` variants rather than pinning one scheme, and no page arrives white while the system is in dark appearance.</Verify>
|
|
106
|
+
<Verify rule="webview-text-size">No `setTextZoom` call overrides the system font scale, every `android.webkit.WebView` that loads a URL rather than HTML bundled with the app sets `setBuiltInZoomControls(true)` with `setDisplayZoomControls(false)`, no first-party page sets `user-scalable=no` or `maximum-scale`, and at maximum text size no text is clipped, truncated or overlapped and no control has left the screen.</Verify>
|
|
107
|
+
<Verify rule="webview-viewport">Insets are applied in one place per web surface, no web view's inset handler returns `WindowInsetsCompat.CONSUMED`, no page sets `viewport-fit=cover` without `env()` padding of its own, no page pins a control to a `vh` bottom, and with a field inside the page focused no control sits under the keyboard and neither edge is padded twice nor left unpadded.</Verify>
|
|
108
|
+
<Verify rule="webview-transfers">Every `android.webkit.WebView` overrides `onShowFileChooser` returning true and registers a `DownloadListener`, every iOS download path implements `WKDownloadDelegate` with a named destination, no Custom Tab disables its download button, and that destination is either a directory the user can open or a path ending in the system share sheet, `share-file-uri`.</Verify>
|
|
109
|
+
<Verify rule="webview-session">Nothing reads an outcome out of a platform in-app browser, no token or account identifier is handed to a page, every Custom Tab and authentication session either sets `setEphemeralBrowsingEnabled` or `prefersEphemeralWebBrowserSession` explicitly or `STACK.md` records the shared session as intended, and any raw web view holding a signed-in session is cleared on sign-out.</Verify>
|
|
110
|
+
<Verify rule="webview-wrapper">Every web surface the app ships is listed in `STACK.md` beside what it does natively, and each entry on that list is scored against `touch-floor`, `webview-back`, `webview-text-size` and `webview-viewport` on its own account rather than inherited from the native screens.</Verify>
|
|
111
|
+
|
|
112
|
+
<Device>Three of these are only half answerable from a diff, because what the page does with what it was handed is not in the code. Drive one screen three ways on the narrowest supported device: with the system in dark appearance, to see whether the page followed or arrived white (`webview-appearance`); with the text size at maximum, to see whether the page grew with nothing clipped, truncated, overlapped or pushed off the screen (`webview-text-size`); and with a field inside the page focused, to see what the keyboard covers and whether either edge of the screen is padded twice or not at all (`webview-viewport`).</Device>
|
|
113
|
+
|
|
114
|
+
</Check>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Widgets and live surfaces
|
|
2
|
+
|
|
3
|
+
A home screen widget, a Live Activity with its Dynamic Island presentations, and an Android ongoing notification promoted to a live surface are the parts of the app that get drawn while the app is not running. The system decides when each one is redrawn, at what size, and whether it appears at all, so none of them is a small screen: each is a report the app files and then loses control of. All of them are read in a second, from arm's length, by somebody who is not going to open the app to check.
|
|
4
|
+
|
|
5
|
+
Here: the update budget, stating what is stale, what fits at the size the user chose, the sizes themselves, the tap, the states nobody draws, what a stranger reads off the surface, what the surface may carry, the labels, the Dynamic Island, the final frame of a live one, and Android promotion. Whether a persistent live surface may exist at all, and what dismissing one means, is `notify-ongoing`. Scheduling the work behind an update is the `bg-` prefix, resolving the link the tap carries is `nav-deeplink`, and the inventory of what counts as sensitive is `priv-shoulder`.
|
|
6
|
+
|
|
7
|
+
## <Rule id="widget-budget" description="The system decides when the surface is redrawn, so the app declares a policy and never a clock" />
|
|
8
|
+
|
|
9
|
+
The app does not own the clock out here, and every redraw is battery the phone is rationing (`perf-power`). iOS spends a budget per widget instance that it tunes to how often that person looks, typically 40 to 70 refreshes across a day, roughly one every 15 to 60 minutes. Android will not deliver a periodic widget update more than once every 30 minutes and recommends no more than once an hour. A surface asking for a 60 second refresh does not get a fast surface, it gets a throttled one.
|
|
10
|
+
|
|
11
|
+
- Declare a policy, never a timer. On iOS a timeline ends in `atEnd`, `never` or `after(_:)`, with `WidgetCenter.reloadTimelines` from the app when something actually changed; on Android `updatePeriodMillis` is either 0 with WorkManager behind it or an hour or more. Nothing polls, and no code path treats the interval it asked for as the interval it gets.
|
|
12
|
+
- Build on the redraws that cost nothing on iOS: the containing app in the foreground, an active audio or navigation session, a button or toggle running an app intent, an animation, and a locale or text size change.
|
|
13
|
+
- The update is not the work. An Android widget receiver is treated as non-responsive after 10 seconds, so anything slow moves to `goAsync()` or WorkManager, and a Live Activity cannot reach the network or a location at all: its data is pushed in from the app or a server. What may run while the app is away is `bg-not-running` and `bg-periodic`.
|
|
14
|
+
- Live Activity pushes have their own hourly budget and get throttled past it. Send at priority 5, which does not count against it, and keep priority 10 for the update the user would notice missing; frequent updates need the property list flag and the user can switch them off (`bg-wake-push`).
|
|
15
|
+
|
|
16
|
+
## <Rule id="widget-stale" description="The surface states what it is showing and when it was true" />
|
|
17
|
+
|
|
18
|
+
The redraw is the system's decision, so this surface is showing old data by definition. There is no pull to refresh on a home screen and nothing to tap that means try again, and a number with no age on it is read as current by somebody who is about to act on it without opening anything.
|
|
19
|
+
|
|
20
|
+
- The age is on the surface, in the same place on every redraw, and the crossover from a relative age to a date is `data-time-relative`. That cached content carries its age at all is `state-stale`.
|
|
21
|
+
- Dates and times are drawn by the platform's own date facility rather than recomputed per reload, so a clock counting forward does not spend refreshes the surface needs for its content.
|
|
22
|
+
- The state behind a Live Activity's stale date is a drawn state rather than the last frame left standing (`notify-ongoing` owns that the date is set at all). Without it, a delivery frozen at eleven minutes away still reads as eleven minutes away.
|
|
23
|
+
|
|
24
|
+
## <Rule id="widget-fits" description="No scroll, no keyboard, no spinner, so every state fits the size the user chose" />
|
|
25
|
+
|
|
26
|
+
No scrolling API exists for an iOS widget, no text entry exists on either platform, and there is nowhere for a spinner to lead. Whatever does not fit is simply gone, and it is gone at the size that person picked rather than the one in the preview.
|
|
27
|
+
|
|
28
|
+
- The interactive elements iOS documents are a whole-surface link, a `Link`, a `Button(intent:)` and a `Toggle(isOn:intent:)`. No field, no sheet, no nested navigation. Android collection widgets do scroll vertically through the collection views, and that is the only scrolling either platform offers here.
|
|
29
|
+
- The loading state is a placeholder in the shape of the finished content, which is `state-loading` on a surface with nowhere to put a spinner. Android requires an initial layout for exactly this moment.
|
|
30
|
+
- Both surfaces are stateless. The app stores the value and the surface renders it (`off-local-first`); an Android checkbox, switch or radio on a widget carries a look, not a value.
|
|
31
|
+
- Every string is sized at its longest translation (`l10n-expansion`), because it truncates against a fixed cell instead of wrapping into more room. A Live Activity holds inside 4 KB of static plus dynamic data.
|
|
32
|
+
|
|
33
|
+
## <Rule id="widget-sizes" description="Content is authored per size, and a size nobody drew is not offered" />
|
|
34
|
+
|
|
35
|
+
A phone home screen is a grid the user resizes by hand, so the same widget sits in two cells for one person and twelve for another. Stretching one layout to fill a bigger cell is what a window does, and this is not a window: the small size carries one fact and the large one is a different design, not the same design with air around it.
|
|
36
|
+
|
|
37
|
+
- Author per size, then offer only the sizes authored. The iPhone families are small, medium and large on the home screen plus the circular, rectangular and inline lock screen accessories; extra large is not a phone size. Android declares its default in launcher cells through `targetCellWidth` and `targetCellHeight`, with the dp minimums as the fallback for older releases.
|
|
38
|
+
- One layout per size band rather than one per pixel: Glance's responsive size mode maps a set of layouts and lets the system pick, while its exact mode rebuilds the surface on every resize and jumps while it does.
|
|
39
|
+
- The system's own content margins stay, 16pt on iOS and 11pt where a tighter grouping is wanted, and nothing adds a second inset on top of them. A full-bleed background switches the default margins off and re-applies them to the content inside, and the corner radius comes from the container rather than a typed value.
|
|
40
|
+
- Portrait and landscape both, at the narrowest supported device (`layout-width`).
|
|
41
|
+
|
|
42
|
+
## <Rule id="widget-tap" description="The tap is a deep link carrying its subject, and it resolves from a killed process" />
|
|
43
|
+
|
|
44
|
+
The tap arrives from a home screen, so the process is usually dead. There is no hover and no second tap to disambiguate, and on a small or inline widget the whole surface is a single target, which makes whatever it points at the only thing it can point at.
|
|
45
|
+
|
|
46
|
+
- The payload names the destination and the subject, an id in the URL or in the pending intent, never a bare route to the app's home screen. Building the stack above that target, the cold launch, the auth case and the missing target are all `nav-deeplink`.
|
|
47
|
+
- Count the targets. One whole-surface link per widget, because a second one is undefined behaviour on iOS, and an inline accessory has exactly one. Android collection rows take a pending intent template on the collection plus a fill-in intent per row, since a row cannot carry a click intent of its own.
|
|
48
|
+
- A control drawn on the surface is a target with a hit area, and the number comes from `touch-floor`, because neither platform publishes one for this surface.
|
|
49
|
+
- Launched from a lock screen, the destination either requires authentication or declares that it shows when locked. Buttons and toggles on a Live Activity do nothing in CarPlay, so nothing is reachable only through them.
|
|
50
|
+
|
|
51
|
+
## <Rule id="widget-states" description="Signed out, empty and error are drawn states, at the smallest size the surface offers" />
|
|
52
|
+
|
|
53
|
+
These are the states nobody draws, and here they are the ones with no way out: no scroll, no room for a retry control worth the space, no route to a sign-in screen. A blank rectangle sits on somebody's home screen for hours as the app's only visible face, and the next move is to remove it rather than to open the app.
|
|
54
|
+
|
|
55
|
+
- Three states written and fitting the smallest declared size: signed out naming what signing in would show, empty saying which of the three empties this is (`state-empty`), and failed saying what failed without guessing why (`state-error`).
|
|
56
|
+
- A token that could not be refreshed is not a sign-out and is not drawn as one (`off-session`). It draws the last known content with its age.
|
|
57
|
+
- Availability is a state as well. A Live Activity checks that activities are enabled before starting, since the user can switch them off, and telling them so belongs in the app rather than on a surface that never appears.
|
|
58
|
+
- No state resolves to a blank surface or a bare error string.
|
|
59
|
+
|
|
60
|
+
## <Rule id="widget-shoulder" description="Whoever is standing beside the phone reads this surface, and on Android it is on the lock screen unless the app says otherwise" />
|
|
61
|
+
|
|
62
|
+
This is the part of the app drawn on a screen that is switched on without the app being opened: in a queue, on a table, on a locked phone with an always-on display. Android puts a widget on the keyguard by default and the app has to opt out, so a surface designed for a home screen reaches the lock screen with the app never having declared it should.
|
|
63
|
+
|
|
64
|
+
- Every value takes the shortest form that still does its job, which is `priv-shoulder`, and that file owns the inventory of what counts as sensitive.
|
|
65
|
+
- A widget that must not reach the keyguard declares the `not_keyguard` category, which exists from Android 16. It is a request the surface is expected to honour rather than a guarantee, so nothing depends on it alone.
|
|
66
|
+
- Anything a Live Activity would not publish becomes an innocuous summary that opens the app for the rest, or a view marked privacy sensitive so the system redacts it. An iOS lock screen widget is also desaturated to a monochrome vibrant rendering and can be tinted by the user, so nothing there is carried by colour (`color-not-alone`).
|
|
67
|
+
- What a notification's own first line says to a stranger is `notify-lockscreen`.
|
|
68
|
+
|
|
69
|
+
## <Rule id="widget-scope" description="The surface carries this app's own content and nothing else" />
|
|
70
|
+
|
|
71
|
+
This is space on somebody's home screen granted to one app, and the one surface where a promotion cannot be scrolled past or swiped away. Apple's guideline 2.5.16 says widgets, extensions and notifications should be related to the app's own content and functionality, and 2.5.18 says display advertising should be limited to the main app binary, which `ads-placement` already enforces at the call site.
|
|
72
|
+
|
|
73
|
+
- No advertising, no promotion, no cross-sell, no shortcut unrelated to what the surface reports. Android's own list of what does not qualify as a live update opens with ads and promotions and includes quick access to app features.
|
|
74
|
+
- The surface is the summary and the app is the detail, so everything on it exists in more depth one tap away. A surface carrying something the app itself never shows is a second app.
|
|
75
|
+
|
|
76
|
+
## <Rule id="widget-a11y" evidence="device" description="Every element carries a name, and the name changes when the picture does" />
|
|
77
|
+
|
|
78
|
+
These surfaces reach a screen reader without the app being open, and they are mostly icons and bare numbers with nothing around them to supply a label. A widget also has to hold from the default text size up to the largest accessibility size inside a cell it cannot grow, which is a case that only exists because the container is a phone home screen grid.
|
|
79
|
+
|
|
80
|
+
- Every image and icon-only element carries a label (`a11y-name`, `icon-alt`), and a label reporting a status changes when the status changes. A delivery glyph labelled once at build time is wrong for the rest of the run.
|
|
81
|
+
- No text rasterized into an image, on any presentation. Text stays text so it scales and can be read out.
|
|
82
|
+
- Nothing below 11pt, and the layout survives the largest accessibility text size (`type-scaling`) at the smallest size the surface declares.
|
|
83
|
+
- Drive each presentation with the reader on, as `a11y-test` asks of any flow.
|
|
84
|
+
|
|
85
|
+
## <Rule id="widget-island" evidence="device" description="Four presentations, each one designed rather than derived" />
|
|
86
|
+
|
|
87
|
+
The Dynamic Island is the only place this app appears while the user is inside another app, and its shape comes from the camera hardware rather than from a layout. A Live Activity has four presentations, three of them in the Island (compact, minimal, expanded) and one on the lock screen, and an app that only designed the expanded one becomes an unidentifiable dot.
|
|
88
|
+
|
|
89
|
+
- Minimal is 36.67pt tall and 36.67 to 45pt wide, and it is what the system picks once a second Live Activity is running. It has to be recognisable alone, which for a single glyph means the app's own mark rather than a progress ring.
|
|
90
|
+
- Compact leading and trailing run 52.33 to 62.33pt wide by 36.67pt tall. Content stays as narrow as it can and snug against the camera.
|
|
91
|
+
- Expanded opens on touch and hold. It and the lock screen presentation both run 84 to 160pt tall, the lock screen one with a 14pt margin, and the system may truncate anything above 160.
|
|
92
|
+
- No image asset larger than the presentation drawing it, since an oversized one can stop the activity starting at all. Animation caps at two seconds (`motion-duration`) and does not run on an always-on display at reduced luminance, so nothing is legible only while it moves.
|
|
93
|
+
|
|
94
|
+
## <Rule id="widget-live-end" description="The end of a live surface is a final state, not a disappearance" />
|
|
95
|
+
|
|
96
|
+
The surface outlives the event it was reporting: an ended Live Activity stays on the lock screen for up to four more hours, and the frame it stopped on is the last thing that person sees. A phone gets one glance, so that frame has to say the ride arrived rather than freeze eleven minutes out.
|
|
97
|
+
|
|
98
|
+
- The final content states the outcome, arrived, delivered, cancelled, finished, instead of holding the last in-progress frame.
|
|
99
|
+
- A dismissal time is chosen rather than left at the four hour default. The time is proportional to the activity, and 15 to 30 minutes is adequate for most of them.
|
|
100
|
+
- Whether a persistent live surface is allowed at all, what its ceiling is, ending it on the event that ends the work and what a dismissal means are all `notify-ongoing`. Where the surface exists to watch long user-started work, the progress and the stop control belong to `bg-visible-stoppable`.
|
|
101
|
+
|
|
102
|
+
## <Rule id="widget-promoted" description="An Android live update is a request, and it has to read correctly when it is refused" />
|
|
103
|
+
|
|
104
|
+
Android promotes an ongoing notification to a status bar chip, the top of the drawer and the lock screen, and it can decline. The user can demote it, the manufacturer can add criteria, and the app only learns the outcome at runtime, so a surface designed for the promoted presentation alone is a surface most phones never draw.
|
|
105
|
+
|
|
106
|
+
- Its own title and body carry the state, so it reads as an ordinary notification when promotion is refused, and that is the presentation to design first.
|
|
107
|
+
- The requirements are all of these, with no partial pass: the promoted notifications permission declared, promotion requested on the builder, the ongoing flag set, a content title present, one of the standard, big text, call, progress or metric styles, no custom content view, not a group summary, not colorized, and a channel above minimum importance.
|
|
108
|
+
- Ask the platform whether the notification can be promoted and whether the user allows it, and hand the user the app's promoted notification setting rather than guessing. Nothing is reposted after they dismiss it.
|
|
109
|
+
- Four things qualify: active navigation, an ongoing call, rideshare tracking and food delivery tracking. Chat messages, alerts, a calendar event that has not started, package tracking and ambient information do not. Alert only on a critical status change (`notify-level`), and keep the timestamp format identical between the chip and the expanded card.
|
|
110
|
+
|
|
111
|
+
<Check>
|
|
112
|
+
|
|
113
|
+
<Verify rule="widget-budget">Every outside surface refreshes through a declared reload policy or a system-scheduled trigger rather than a timer or a poll, `updatePeriodMillis` is 0 with work behind it or at least an hour, and every ActivityKit push is sent at priority 5 except a state change the user is waiting on, with no routine progress update at priority 10.</Verify>
|
|
114
|
+
<Verify rule="widget-stale">Every outside surface renders the age of what it shows, dates and times come from the platform's own date facility, and every Live Activity draws a designed state behind its stale date.</Verify>
|
|
115
|
+
<Verify rule="widget-fits">No outside surface depends on scrolling, text entry or a spinner, its loading state is a placeholder shaped like the content, its state is stored by the app, and every string is sized against its longest translation rather than the English one.</Verify>
|
|
116
|
+
<Verify rule="widget-sizes">Every size a surface declares has content written for it, none is a smaller layout stretched or a larger one clipped, the Android declaration names its default cells, and the surface keeps the system's default content margins rather than adding a second inset.</Verify>
|
|
117
|
+
<Verify rule="widget-tap">Every tappable region carries a URL or pending intent naming both destination and subject, each widget declares at most one whole-surface link, collection rows use the template plus fill-in form, and drawn controls meet the touch floor.</Verify>
|
|
118
|
+
<Verify rule="widget-states">Each surface has a signed-out, empty and error state written as its own layout branch at the smallest size it declares, a failed token refresh is not drawn as a sign-out, no state resolves to a blank surface, and a Live Activity is started only behind an activities-enabled check.</Verify>
|
|
119
|
+
<Verify rule="widget-shoulder">No value on an outside surface appears in a longer form than `priv-shoulder` allows for its category, any widget that must not reach the keyguard declares `not_keyguard`, and anything sensitive on a Live Activity is a summary or a privacy-marked view.</Verify>
|
|
120
|
+
<Verify rule="widget-scope">No outside surface carries a promotion, a cross-sell or a shortcut unrelated to what it reports, and everything it shows exists in more detail inside the app; an ad unit built into one is scored under `ads-placement`.</Verify>
|
|
121
|
+
<Verify rule="widget-a11y">Every image and icon-only element on every presentation has a label, status labels change with the status, no text is rasterized, nothing is drawn below 11pt, and the layout still holds at the largest accessibility text size at the smallest size the surface declares.</Verify>
|
|
122
|
+
<Verify rule="widget-island">All four Live Activity presentations have their own layout rather than one reused across them, the minimal one draws the app's own mark, expanded and lock screen hold inside 160pt of height, and no image asset exceeds the presentation drawing it; a codebase that ships only to Android answers this not applicable.</Verify>
|
|
123
|
+
<Verify rule="widget-live-end">Every Live Activity sets a chosen dismissal time rather than taking the four hour default, and its final content states the outcome rather than the last in-progress frame.</Verify>
|
|
124
|
+
<Verify rule="widget-promoted">Every promoted notification meets all nine promotion requirements, carries its state in its own title and body so nothing depends on the promoted presentation, checks promotability and user permission at runtime, and is not reposted after a dismissal; a codebase that ships only to iOS answers this not applicable.</Verify>
|
|
125
|
+
|
|
126
|
+
<Device>Two of these cannot be settled from the source. Run the app on a device with the reader on and walk every presentation, confirming each image has a name that follows its status and that the layout survives the largest accessibility text size at the smallest declared size (`widget-a11y`), and start a second Live Activity so the system falls back to the minimal presentation, then touch and hold to expand, and check the surface stays identifiable and untruncated in all four (`widget-island`).</Device>
|
|
127
|
+
|
|
128
|
+
</Check>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Capability presence, status and accuracy, per stack
|
|
2
|
+
|
|
3
|
+
Lookup only. The rules live in `heuristics/sense.md`, and the grant flow lives in `heuristics/permissions.md`. Open this file for one name, not as background reading.
|
|
4
|
+
|
|
5
|
+
Nothing here decides anything. Every entry answers one of three questions: does this device have the hardware, is the capability usable right now, and how good is the value it just returned.
|
|
6
|
+
|
|
7
|
+
## Does the device have it
|
|
8
|
+
|
|
9
|
+
| Capability | iOS | Android | Flutter | Expo and React Native | Mobile web |
|
|
10
|
+
|---|---|---|---|---|---|
|
|
11
|
+
| Any camera | `AVCaptureDevice.DiscoverySession` returns no devices | `PackageManager.hasSystemFeature(FEATURE_CAMERA_ANY)` | `availableCameras()` returns empty | `CameraView` unavailable on the platform | `enumerateDevices()` lists no `videoinput` |
|
|
12
|
+
| Front camera | discovery session with `.front` | `FEATURE_CAMERA_FRONT` | `availableCameras()` lens direction | same | same, by `deviceId` |
|
|
13
|
+
| Microphone | `AVCaptureDevice` for `.audio` | `FEATURE_MICROPHONE` | `camera` package audio flag | `useMicrophonePermissions()` | no `audioinput` in `enumerateDevices()` |
|
|
14
|
+
| Location hardware | always present | `FEATURE_LOCATION`, `FEATURE_LOCATION_GPS`, `FEATURE_LOCATION_NETWORK` | `Geolocator.isLocationServiceEnabled()` | `Location.hasServicesEnabledAsync()` | `navigator.geolocation` undefined |
|
|
15
|
+
| Accelerometer, gyroscope, compass | `CMMotionManager.isGyroAvailable` and siblings | `SensorManager.getDefaultSensor()` returns null; `FEATURE_SENSOR_ACCELEROMETER`, `FEATURE_SENSOR_GYROSCOPE`, `FEATURE_SENSOR_COMPASS` | `sensors_plus` stream errors | `expo-sensors` `isAvailableAsync()` | `DeviceMotionEvent` undefined |
|
|
16
|
+
| Biometric reader | `LAContext.canEvaluatePolicy` plus `biometryType` | `BiometricManager.canAuthenticate()` returning `BIOMETRIC_ERROR_NO_HARDWARE`; `FEATURE_FINGERPRINT`, `FEATURE_FACE`, `FEATURE_IRIS` | `isDeviceSupported()`, `canCheckBiometrics` | `hasHardwareAsync()` | not available |
|
|
17
|
+
| Vibration motor | always present | `Vibrator.hasVibrator()` | `HapticFeedback` is a no-op where absent | `expo-haptics` is a no-op where absent | `navigator.vibrate` undefined |
|
|
18
|
+
| Named haptic feedback | `UIImpactFeedbackGenerator`, `UINotificationFeedbackGenerator`, `UISelectionFeedbackGenerator` | `View.performHapticFeedback()` constants, then `VibrationEffect.createPredefined()` | `HapticFeedback` on `Feedback` | `expo-haptics` impact, notification and selection | `navigator.vibrate` only |
|
|
19
|
+
| Rich haptic patterns | `CHHapticEngine` capabilities | `Vibrator.areEffectsSupported()`, `arePrimitivesSupported()` | not exposed | not exposed | not available |
|
|
20
|
+
|
|
21
|
+
Android's answer for a vibration effect has three values, `VIBRATION_EFFECT_SUPPORT_YES`, `_NO` and `_UNKNOWN`. Unknown means the hardware does not report its effects, so nothing will tell you whether a call produces anything.
|
|
22
|
+
|
|
23
|
+
Presence and distribution are separate. `<uses-feature android:name="android.hardware.sensor.gyroscope" android:required="false" />` keeps the app installable on devices without the sensor; `required="true"` removes it from the store for them.
|
|
24
|
+
|
|
25
|
+
## Is it usable right now
|
|
26
|
+
|
|
27
|
+
| Question | iOS | Android | Flutter | Expo | Web |
|
|
28
|
+
|---|---|---|---|---|---|
|
|
29
|
+
| Permission status | `AVCaptureDevice.authorizationStatus(for:)`, `CLLocationManager.authorizationStatus` | `ContextCompat.checkSelfPermission()` | `Geolocator.checkPermission()` | `getCameraPermissionsAsync()` | Permissions API `query()` |
|
|
30
|
+
| Location services on | `CLLocationManager.locationServicesEnabled()` | `LocationManager.isProviderEnabled()` | `Geolocator.isLocationServiceEnabled()`, `getServiceStatusStream()` | `hasServicesEnabledAsync()` | rejection code `POSITION_UNAVAILABLE` |
|
|
31
|
+
| Device-wide sensor toggle | not present | `SensorPrivacyManager.supportsSensorToggle(Sensors.CAMERA / Sensors.MICROPHONE)` | not exposed | not exposed | not present |
|
|
32
|
+
| Hardware held or broken | `AVCaptureSession` runtime error notification | camera provider fails to bind | `CameraException` | promise rejects | `NotReadableError` |
|
|
33
|
+
| Nothing enrolled | `LAError.biometryNotEnrolled`, `.passcodeNotSet` | `BIOMETRIC_ERROR_NONE_ENROLLED`, then `Settings.ACTION_BIOMETRIC_ENROLL` | `authenticate()` throws | `isEnrolledAsync()` | not available |
|
|
34
|
+
| Locked out | `LAError.biometryLockout` | `BIOMETRIC_ERROR_LOCKOUT` | throws | throws | not available |
|
|
35
|
+
| No such device | discovery session empty | provider throws | `CameraException` | throws | `NotFoundError` |
|
|
36
|
+
| Route to system settings | `UIApplication.openSettingsURLString` | `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`, `ACTION_LOCATION_SOURCE_SETTINGS` | `openAppSettings()`, `openLocationSettings()` | `Linking.openSettings()` | none, the browser owns it |
|
|
37
|
+
|
|
38
|
+
The iOS biometric error set worth branching on: `biometryNotAvailable`, `biometryNotEnrolled`, `biometryLockout`, `passcodeNotSet`, `userCancel`, `systemCancel`, `appCancel`, `userFallback`. The last one fires when the user asks for a fallback the policy does not have.
|
|
39
|
+
|
|
40
|
+
## How good is the value
|
|
41
|
+
|
|
42
|
+
| What | iOS | Android | Flutter and Expo | Web |
|
|
43
|
+
|---|---|---|---|---|
|
|
44
|
+
| Horizontal accuracy | `CLLocation.horizontalAccuracy`, a radius in metres, negative meaning the coordinate is invalid | `Location.getAccuracy()`, a radius in metres at the 68th percentile, valid only where `hasAccuracy()` is true and zero otherwise | `Position.accuracy` in metres, from the platform value | `coords.accuracy` in metres at 95% confidence |
|
|
45
|
+
| Vertical accuracy | `verticalAccuracy` | `getVerticalAccuracyMeters()` | `altitudeAccuracy` | `altitudeAccuracy` |
|
|
46
|
+
| Granted accuracy level | `CLAccuracyAuthorization.fullAccuracy` or `.reducedAccuracy` | `ACCESS_FINE_LOCATION` against `ACCESS_COARSE_LOCATION` | `LocationAccuracy` | not exposed |
|
|
47
|
+
| Requested accuracy | `desiredAccuracy` | `Priority` on the location request | `LocationAccuracy`, `Accuracy.Balanced` and siblings | `enableHighAccuracy` |
|
|
48
|
+
| Upgrade to precise | `requestTemporaryFullAccuracyAuthorization(withPurposeKey:)` with `NSLocationTemporaryUsageDescriptionDictionary` | request the fine and coarse pair again | via the plugin's permission call | not available |
|
|
49
|
+
| Compass calibration | `CLLocationManager` heading calibration display | `SensorManager.SENSOR_STATUS_ACCURACY_LOW` and siblings through `onAccuracyChanged` | `flutter_compass` accuracy field | not available |
|
|
50
|
+
|
|
51
|
+
The three accuracy radii are not the same measurement. A threshold in metres tuned on one platform does not transfer to another.
|
|
52
|
+
|
|
53
|
+
Published figures: an approximate Android grant is accurate to roughly 3 square kilometres, a precise one usually to within about 50 metres and sometimes a few. Apple states no metric figure for reduced accuracy, so there is nothing to compare it against.
|
|
54
|
+
|
|
55
|
+
## Sampling ceilings
|
|
56
|
+
|
|
57
|
+
From Android 12, `SensorManager.registerListener()` is capped at 200 Hz and `SensorDirectChannel` at `RATE_NORMAL`, about 50 Hz. Going past either without `HIGH_SAMPLING_RATE_SENSORS` throws a `SecurityException`. The device-wide microphone and camera toggle rate-limits motion sensors regardless of that permission.
|
|
58
|
+
|
|
59
|
+
On mobile web, `DeviceMotionEvent.requestPermission()` needs a secure context and transient activation, so it has to run inside a tap handler, and it rejects with `NotAllowedError` otherwise. `navigator.vibrate()` needs sticky user activation. Neither is available in every browser.
|