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,127 @@
|
|
|
1
|
+
# Search
|
|
2
|
+
|
|
3
|
+
Search is how somebody finds a thing they already know is in there. On a phone it runs against a keyboard that takes half the screen, a list with no scrollbar, and a connection that may be a cell tower two bars down. Where search sits in the app's structure is `nav-search`. This file is the surface itself, from the field down to the last result row.
|
|
4
|
+
|
|
5
|
+
Two failures account for most of what goes wrong: a plain text field with a magnifier icon standing in for the platform's search control, and a query fired at the server on every keystroke.
|
|
6
|
+
|
|
7
|
+
## <Rule id="search-surface" description="An inline filter and a search screen are two different things" />
|
|
8
|
+
|
|
9
|
+
Decide which one the screen needs before writing the field.
|
|
10
|
+
|
|
11
|
+
- **An inline filter** narrows what is already in front of the user (`nav-search`). It stays with the list it filters rather than moving up into the chrome.
|
|
12
|
+
- **A search screen** reaches past what is in front of the user. While it is open it owns the whole screen: a dropdown panel hanging under the bar is the tablet arrangement, and on a phone it only makes the result list shorter.
|
|
13
|
+
|
|
14
|
+
Focus on open belongs to the search screen, the single-field exception `form-input` already allows, and it forks by platform. On Android the expanded search view raises the keyboard by default and that is correct there; an inline filter that must not cover the list it filters turns it off with `app:autoShowKeyboard="false"` rather than leaving it to the default. On iOS nothing forces focus onto a surface the user did not open in order to type, so the field that arrives focused is the button-style search tab or a screen entered to type into.
|
|
15
|
+
|
|
16
|
+
On Android a screen pairing the search bar with the search view does not resize under the keyboard, because the resize runs during the expand and collapse animation and breaks it. Everywhere else the constraint is the outcome rather than a window flag: the field being typed into stays visible (`touch-keyboard`), and the result rows do not shift under a thumb already on its way down.
|
|
17
|
+
|
|
18
|
+
## <Rule id="search-stock-field" description="Use the platform's search control, do not assemble one" />
|
|
19
|
+
|
|
20
|
+
Both platforms ship the whole control, and every part of it is a part somebody forgets when rebuilding it from a text field: the clear button that appears with the text, the cancel that dismisses the keyboard and the surface together, the search return key, and the expand and collapse transition. `references/search-controls.md` names the control per stack.
|
|
21
|
+
|
|
22
|
+
- The return key is the search action, not Done and not a newline (`form-input`).
|
|
23
|
+
- One clear control, appearing only when the field holds text. Clearing the field is not cancelling the search: clear leaves the user on the search surface with an empty query.
|
|
24
|
+
- Do not restyle the bar into something unrecognisable. The Android search bar refuses a custom background on purpose, and a search field that does not look like one is a field nobody finds.
|
|
25
|
+
- The Android search view takes the screen behind out of the reader's path while it is open and puts it back on hide. Nothing hands that over on iOS or in a hand-built surface, so there it is owed rather than inherited: while search owns the screen, what is underneath is not what the reader walks into (`a11y-hidden`).
|
|
26
|
+
- A voice entry point, where there is one, is the platform's, and it lives in the field rather than as a second control beside it. The keyboard already carries dictation, so a hand-drawn microphone next to it is the same button twice. What is dictated lands in the field as a query the user can read and correct, never as a search that has already run.
|
|
27
|
+
|
|
28
|
+
## <Rule id="search-placement" description="Where the field goes forks by platform" />
|
|
29
|
+
|
|
30
|
+
There is no cross-platform answer here, and shipping one platform's arrangement on the other is visible immediately. One codebase cannot hold both at once, so it either chooses the arrangement at runtime by platform or picks one for both and records the choice and its reason in `STACK.md`.
|
|
31
|
+
|
|
32
|
+
- **Android:** the search bar belongs in the top app bar and behaves as part of it, either fixed, lifting on scroll, or scrolling away with the content. That is the top of the screen, and `nav-search` makes search structural rather than rare once a collection is big enough to need it, so on a large phone the screen owes a second path to it within thumb reach (`touch-reach`).
|
|
33
|
+
- **iOS:** three entry points, and the field belongs to one of them: a tab in the tab bar, a toolbar at the bottom or the top, or an inline field directly above the content it searches. Prefer the bottom toolbar where there is one with room, because that is where the thumb already is (`touch-reach`). Put search at the top instead when the content at the bottom of the screen is what has to be deferred to, or when the screen carries no bottom toolbar at all, and expect the top entry to sit collapsed as a button that opens into a field above the keyboard.
|
|
34
|
+
|
|
35
|
+
An iOS search tab comes in two flavours and they answer different products: a standard tab lands on a search page with suggestions, for browsing and discovery; a button-style tab opens the field focused and returns to the previous tab on exit, for people who arrived knowing what they want. Pick one deliberately.
|
|
36
|
+
|
|
37
|
+
## <Rule id="search-typing" description="A keystroke is not a request" />
|
|
38
|
+
|
|
39
|
+
Filtering a collection already on the device happens as the user types. A network search does not.
|
|
40
|
+
|
|
41
|
+
- At most one search request in flight. Which query wins when two answers race, and how the superseded one is cancelled, is `net-cancel` and is not restated here. The ceiling of one is this file's own: keying requests by their parameters treats every prefix of a word as a different question and lets all of them run.
|
|
42
|
+
- The pause between the last keystroke and the request is one constant, named once in the code and recorded in `STACK.md`, not a number retyped at each call site.
|
|
43
|
+
- Failures back off on the schedule in `net-backoff`. Retrying per keystroke turns one bad connection into a burst that holds the radio up (`perf-power`) and spends somebody's data (`net-metered`).
|
|
44
|
+
- Typing is never blocked by a request. The field accepts input while the previous search is still out.
|
|
45
|
+
|
|
46
|
+
## <Rule id="search-suggest" description="A suggestion says what it will do" />
|
|
47
|
+
|
|
48
|
+
Suggestions come in two kinds and they are not interchangeable: one completes the query into the field, the other opens a result and ends the search. Make which is which readable from the row, because guessing wrong costs a screen and a back gesture.
|
|
49
|
+
|
|
50
|
+
- A suggestion that returns nothing when tapped costs a screen push, a back gesture, and the keyboard coming down and going up again. That is why it is worse here than a wasted click: suggest from what the corpus actually contains.
|
|
51
|
+
- The list does not reorder under a finger already on its way down. Rows that resequence on the next keystroke produce a tap on whatever slid into that spot (`touch-spacing`).
|
|
52
|
+
- The suggestion list is a list: it recycles (`list-virtualise`) and it scrolls. Only the rows above the keyboard get read, so the strongest candidate is first.
|
|
53
|
+
|
|
54
|
+
## <Rule id="search-recent" description="Recent searches are the cheapest query on a phone" />
|
|
55
|
+
|
|
56
|
+
Retyping is the expensive part of searching with two thumbs, so by default the search surface with an empty field shows what this person searched before, and tapping one re-runs it rather than just filling the field.
|
|
57
|
+
|
|
58
|
+
- A search history the user cannot clear is not shippable. One control clears the whole history, and it lives on the search surface, not down a settings trip.
|
|
59
|
+
- The history is on the device and belongs to this app. It does not travel to another surface or another account without the user saying so.
|
|
60
|
+
- A phone screen gets read over a shoulder. Showing history is the default; suppressing it is a decision the app is allowed to make, recorded in `STACK.md` with its reason, and content somebody would not want visible on a bus is that reason. The clearing control above is not a decision either way.
|
|
61
|
+
|
|
62
|
+
## <Rule id="search-scope" description="The screen says what it is searching" />
|
|
63
|
+
|
|
64
|
+
A phone has no sidebar and no visible category tree, so the corpus being searched has to be stated on the surface itself, by the placeholder that names it, the screen title, or a scope statement under the field. Only Apple ships a stock control for that last one, so on Android and in the cross-platform stacks the scope statement is a row of selected filter chips (`button-chips`) instead of a scope bar nobody hands you.
|
|
65
|
+
|
|
66
|
+
- The placeholder names the thing: Search messages, Search saved recipes. The bare word Search says nothing, and on the search screen of an app with several kinds of content it is a guess the user has to make.
|
|
67
|
+
- Default to the widest scope and let people narrow. Somebody who does not know which section holds the thing cannot choose the section first.
|
|
68
|
+
- Changing scope keeps the query that was typed. Retyping to switch scope makes the control cost more than it saves.
|
|
69
|
+
|
|
70
|
+
## <Rule id="search-filters" description="What is narrowing the results stays on screen" />
|
|
71
|
+
|
|
72
|
+
Filters on a phone live in a sheet, and the sheet closes. After it does, nothing tells the user the set is narrowed unless the results screen says so.
|
|
73
|
+
|
|
74
|
+
- The applied filters are visible with the results, as chips (`button-chips`) or as a count on the filter control. Zero is shown as no marker at all, never as a badge reading 0.
|
|
75
|
+
- Each applied filter comes off in one tap, and Clear all is allowed here: it is the exception `form-submit` names, because rebuilding a filter set costs a few taps rather than a retyped form.
|
|
76
|
+
- Whether a new query keeps the current filters or drops them is a decision recorded in `STACK.md`, and the screen shows the answer either way.
|
|
77
|
+
|
|
78
|
+
## <Rule id="search-pending" description="The results on screen stay up while the next query is out" />
|
|
79
|
+
|
|
80
|
+
Between the request leaving and the answer landing is the state a search screen spends most of its life in on a cell connection, and it is the one that gets built by emptying the list.
|
|
81
|
+
|
|
82
|
+
- What is already on screen stays there until the new results replace it, marked as the answer to the previous query (`state-stale`). Clearing to a placeholder on every keystroke is a list that flickers for as long as somebody is typing.
|
|
83
|
+
- The in-flight marker sits in or beside the field, not over the rows. A cover across the results hides the thing the user is reading in order to refine the query.
|
|
84
|
+
- The placeholder in `state-loading`, with its 300ms and 500ms floors, is for the first search of a session, when there is nothing on screen yet to keep. Those floors are never applied per keystroke, where they hold a placeholder over results that have already arrived.
|
|
85
|
+
|
|
86
|
+
## <Rule id="search-result" description="The row says why it matched" />
|
|
87
|
+
|
|
88
|
+
One narrow column, no hover, no preview pane. Everything somebody needs in order to choose between two results is in the row itself.
|
|
89
|
+
|
|
90
|
+
- Show the text that matched, in the field it matched in. A row whose title does not contain the query still has to carry the line that does. How the query is matched against the content, case, accents and character width included, is `l10n-collate`.
|
|
91
|
+
- The match marker is not color alone (`color-not-alone`): weight, a highlight behind the run, or the field label beside it.
|
|
92
|
+
- Most relevant first, and the ordering is one the user could predict. A result set spanning several kinds is grouped by kind rather than interleaved (`list-sections`), and each group header carries how many it holds.
|
|
93
|
+
- The results replace the list without a navigation, so the list declares its length (`a11y-collection`) and the settled count is announced once typing has paused and the results have landed. Never one announcement per keystroke, which is the first thing `a11y-announce` forbids. Nothing on screen otherwise says whether this is 3 results or 300.
|
|
94
|
+
|
|
95
|
+
## <Rule id="search-zero" description="A query that matched nothing offers the next move" />
|
|
96
|
+
|
|
97
|
+
Keeping the query and the filters on screen is `state-empty`'s second empty. What makes it sharper here is the screen: there is one of them and no results pane beside the query, so whatever excluded everything is off screen entirely unless this screen is the thing holding it.
|
|
98
|
+
|
|
99
|
+
- Offer at least one next move: drop a named filter, widen the scope to everything, or the corrected spelling. A dead end with a shrugging illustration is the failure this rule exists for.
|
|
100
|
+
- Nothing matched is not a failure state. A request that could not complete is `state-error` with a retry (`state-retry`), and the two never render as the same screen.
|
|
101
|
+
- Do not fill the space with results that do not match. Related content is allowed below the statement that nothing matched, labelled as what it is.
|
|
102
|
+
|
|
103
|
+
## <Rule id="search-return" evidence="device" description="Coming back from a result comes back to the search" />
|
|
104
|
+
|
|
105
|
+
Opening a result pushes a screen, and back returns to the query, the scope, the filters, the results and the scroll position, with the keyboard still down. Restarting the search is the most expensive thing this surface can do to somebody.
|
|
106
|
+
|
|
107
|
+
- The same state survives the process being killed (`nav-restore`).
|
|
108
|
+
- Cancel is a different move from back: it closes the search surface and returns the user to the screen they opened it from, with that screen as they left it.
|
|
109
|
+
|
|
110
|
+
<Check>
|
|
111
|
+
|
|
112
|
+
<Verify rule="search-surface">Each search field is declared as one of the two surfaces, the expanded one takes the full screen rather than a dropdown, and focus on open answers per platform: the Android search view keeps its default while an inline filter switches it off, and no iOS surface is focused that was not opened in order to type.</Verify>
|
|
113
|
+
<Verify rule="search-stock-field">The field is the platform's own search control, with exactly one clear control that appears only when there is text, a search return key, the screen behind out of the reader's path while search is open, and any voice entry point inside the field rather than beside it.</Verify>
|
|
114
|
+
<Verify rule="search-placement">Search sits in the top app bar on Android and in one of the three sanctioned iOS entry points on iOS, and a single codebase either forks at runtime or records in `STACK.md` which one arrangement it ships.</Verify>
|
|
115
|
+
<Verify rule="search-typing">No more than one search request is in flight, the debounce is a single named constant, and retries are backed off rather than per keystroke.</Verify>
|
|
116
|
+
<Verify rule="search-suggest">Every suggestion row shows whether it completes the query or opens a result, suggestions come from real content, and the list recycles.</Verify>
|
|
117
|
+
<Verify rule="search-recent">Recent searches re-run on tap and any surface showing them carries one control that clears the whole history, with an app that shows none recording why in `STACK.md`.</Verify>
|
|
118
|
+
<Verify rule="search-scope">The corpus being searched is named by the placeholder, the title or a scope statement in the form that platform actually has, the default scope is the widest one, and switching scope keeps the query.</Verify>
|
|
119
|
+
<Verify rule="search-filters">Applied filters are visible with the results, each removable in one tap, with Clear all permitted rather than required, and the query-to-filter behaviour recorded.</Verify>
|
|
120
|
+
<Verify rule="search-pending">Results already on screen survive the next query, the in-flight marker sits at the field rather than over the rows, and the loading placeholder is used for the first search only.</Verify>
|
|
121
|
+
<Verify rule="search-result">The result row carries the matched text, marks the match by something other than color, groups a mixed set by kind, and the count is announced once the results settle rather than per keystroke.</Verify>
|
|
122
|
+
<Verify rule="search-zero">Zero results renders its own screen, keeps the query and filters visible, offers at least one next move, and is never the error screen.</Verify>
|
|
123
|
+
<Verify rule="search-return">Back from a result restores the query, scope, filters, results and scroll position, and cancel returns to the originating screen unchanged.</Verify>
|
|
124
|
+
|
|
125
|
+
<Device>The last one is answered by running it, not by reading the diff: leave the search, come back, and check that nothing had to be typed again.</Device>
|
|
126
|
+
|
|
127
|
+
</Check>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Device capabilities
|
|
2
|
+
|
|
3
|
+
The camera, the microphone, location, the motion sensors, the biometric reader, the vibration motor, the short range radios. None of them is a feature. The feature is what the screen shows while the capability runs, when the value it returns is vague, when the device does not have the hardware at all, and when it is returning nothing worth reading.
|
|
4
|
+
|
|
5
|
+
The ask belongs to `permissions.md`: what is requested, at which moment, after which screen, and what a refusal leaves behind. `state-permission` owns the denied screen. This file starts after all of that, because a granted permission is where most integrations stop and where the states below begin.
|
|
6
|
+
|
|
7
|
+
Per-stack presence, status and accuracy APIs are in `references/capability-checks.md`, for one lookup rather than a read through.
|
|
8
|
+
|
|
9
|
+
## <Rule id="sense-states" evidence="device" description="Five states past the grant, and a granted boolean covers one" />
|
|
10
|
+
|
|
11
|
+
A capability is not on or off. Five states exist whether or not anybody was ever prompted:
|
|
12
|
+
|
|
13
|
+
1. **Absent.** The device has no such hardware. This is never an error and never a message.
|
|
14
|
+
2. **Switched off above the app.** A system toggle or a radio switch holds it, and the app is usually handed empty data rather than a failure.
|
|
15
|
+
3. **Running.** Live, holding the hardware, and visible to the user through the platform's own indicator.
|
|
16
|
+
4. **Imprecise.** A value arrived with an uncertainty attached to it, and it is still a value.
|
|
17
|
+
5. **Failing.** Calibrating, no fix yet, nothing in range, held by another app, locked out after too many attempts, throttled by heat.
|
|
18
|
+
|
|
19
|
+
Count the branches around every capability the app touches. Granted against denied is one pair, it answers `perm-answers` and nothing here, and it is the whole integration in most generated code. Where the capability returns a value over time (the camera, the microphone, location, the motion sensors, a connected radio) each of the five is a different screen with a different thing for the user to do, so each needs its own branch or its own written reason for being folded into another. The rest answer from what the hardware is rather than from a note in the source: a vibration motor has no imprecise state to design.
|
|
20
|
+
|
|
21
|
+
## <Rule id="sense-absent" evidence="device" description="Ask the device before drawing the entry point" />
|
|
22
|
+
|
|
23
|
+
One binary installs on a phone with three cameras and on one with no gyroscope, no barometer and no biometric reader. There is no build time answer to which; the app asks at runtime and gets a real no often enough to design for.
|
|
24
|
+
|
|
25
|
+
Query first, then decide whether the surface exists. When the answer is no, the entry point is not drawn: no disabled button, no dialog explaining that the device is not supported, no empty screen where the feature used to be. The app is simply smaller on that device, and every neighbouring screen still adds up.
|
|
26
|
+
|
|
27
|
+
On Android this also decides distribution. A hardware feature declared as required removes the app from the store for every device without it, so anything the app runs without is declared not required and detected at runtime instead. A capability dragged in by a dependency ends up in that declaration too, which is `perm-inventory`.
|
|
28
|
+
|
|
29
|
+
## <Rule id="sense-off-system" evidence="device" description="Off at the system level is a different question from denied, and the app asks both" />
|
|
30
|
+
|
|
31
|
+
From Android 12 a device-wide toggle gives every app a blank camera feed and silent audio while the permission still reads as granted, and rate-limits the motion sensors at the same time. Location services switched off at the OS level makes the last known location null, and switching them off clears the cached fix, so a device that answered a minute ago now answers with nothing.
|
|
32
|
+
|
|
33
|
+
Permission granted and service available are two reads, and the code that only does the first blames the network, the server or the user's grant for a state none of them caused.
|
|
34
|
+
|
|
35
|
+
- Each answer has its own sentence and its own destination, and neither is a retry button. The permission route is the one `state-permission` already owns. Location services are a second route to a different page, the system's location settings, deep linked the same way. The device-wide camera and microphone toggle is a quick settings tile with nothing to link to, so that sentence names where the tile is instead of promising a link nobody can write.
|
|
36
|
+
- A retry loop is the failure mode here. Nothing the app can do changes the answer, so a spinner that keeps trying is a screen that never resolves.
|
|
37
|
+
|
|
38
|
+
## <Rule id="sense-running" evidence="device" description="Say it is running, agreeing with the indicator the system already drew" />
|
|
39
|
+
|
|
40
|
+
From iOS 14 the microphone shows an orange dot, the camera or camera with microphone shows a green one, and the orange becomes a square when Differentiate Without Color is on. From Android 12 the same use puts an icon in the status bar, moved into the top right corner when the app is immersive. The user sees these before they see anything the app draws.
|
|
41
|
+
|
|
42
|
+
- The capturing surface carries its own running state and a way to stop, so the platform indicator and the app say the same thing at the same moment.
|
|
43
|
+
- Nothing is placed where the indicator lands. Android hands back those bounds; a full bleed capture screen that puts the shutter, the timer or the close control under them loses the control.
|
|
44
|
+
- The indicator is never imitated. A dot of the same colour drawn somewhere else teaches the user to distrust the real one.
|
|
45
|
+
- A capture the user did not start as an ongoing task ends with the surface that started it. One meant to outlive its screen (a voice recording, a route, a tracked run) carries the platform's ongoing surface for as long as it runs, which is `notify-ongoing`, and ends when the task does. What never ships is the third case: a capability still live with nothing on screen saying so, the platform reporting a capture the app has stopped mentioning. `perf-power` owns what it costs.
|
|
46
|
+
|
|
47
|
+
## <Rule id="sense-interrupted" evidence="device" description="The system takes the hardware back, and the take is what is at stake" />
|
|
48
|
+
|
|
49
|
+
A call arrives during a recording, another app claims the microphone, the headphones come out and the audio route changes, the app goes to the background and the camera is released. `state-interrupt` covers what the view holds through that and the OS carries it for free; the capture session is not carried, and the user meets a recording that stopped without saying so and a take that is gone.
|
|
50
|
+
|
|
51
|
+
- The surface says the capture stopped and what stopped it, at the moment it happens rather than when the user comes back and reads a timer that never moved.
|
|
52
|
+
- What was already captured is kept, named and reachable. A partial take is worth more than a clean start, and a discarded one is unrecoverable.
|
|
53
|
+
- Returning re-establishes the session and offers to continue. Resumption is offered rather than assumed, and never left as a dead preview with a shutter that does nothing.
|
|
54
|
+
|
|
55
|
+
## <Rule id="sense-accuracy" evidence="device" description="The uncertainty arrives with the value, and the screen shows it" />
|
|
56
|
+
|
|
57
|
+
Every fix comes with a horizontal accuracy radius in metres beside the coordinate, and the number is not the same measurement everywhere: Android reports it at the 68th percentile, the web at the 95th, and Apple as a radius of uncertainty. A threshold tuned against one of those is wrong against the others, and code that reads the coordinate and drops the radius is claiming a precision nobody offered.
|
|
58
|
+
|
|
59
|
+
The gap is wide. An approximate grant on Android covers roughly 3 square kilometres, while a precise one is usually within about 50 metres and sometimes a few. Apple publishes no figure for its reduced accuracy, so the 3 km does not travel there.
|
|
60
|
+
|
|
61
|
+
- A map draws the circle it was given, not a pin at the centre of it. Both platforms have a way of saying there is no radius, and both draw as a perfect fix when that check is skipped: a negative `horizontalAccuracy` on iOS means the coordinate is invalid, and Android's `getAccuracy()` returns zero unless `hasAccuracy()` is true. Missing accuracy is the imprecise state.
|
|
62
|
+
- Text states the level it has: approximate, within 50 metres, the neighbourhood instead of the address.
|
|
63
|
+
- Anything that needs precision says so on a reduced fix rather than computing quietly on it. That the feature runs on a reduced grant instead of routing to a denial is `perm-answers`.
|
|
64
|
+
- The same applies to every other estimate: a heading before the compass is calibrated, a step count, a barometric altitude. Where the platform hands over an accuracy field, something on screen is derived from it.
|
|
65
|
+
|
|
66
|
+
## <Rule id="sense-preview" evidence="device" description="A live preview is a surface with a crop, not an image view" />
|
|
67
|
+
|
|
68
|
+
The defaults disagree, so the fit is chosen rather than inherited. CameraX's `PreviewView` fills and crops by default, which shows the user a narrower frame than the one that gets captured and only admits it after the shutter. `AVCaptureVideoPreviewLayer` defaults the other way, fitting the frame inside its bounds, so a crop there is `resizeAspectFill` asked for on purpose; Flutter's `CameraPreview` fits as well. Whichever way it is set, what was framed is what is saved.
|
|
69
|
+
|
|
70
|
+
- The preview keeps the sensor's aspect ratio. Stretching it to a container is visible on every face in it.
|
|
71
|
+
- Coming up is a state. Binding the camera takes time, and what the frame shows while it does is `state-loading`. What belongs here is the shutter: inert until the session is actually live. A capture control that accepts a tap before there is anything to capture is the most common defect on this screen.
|
|
72
|
+
- A surface the user has to aim, and any recording that is running, holds the screen awake for exactly as long as the session lasts, and the path that releases it is `perf-power`. A scanner that dims and locks while the code is still being lined up has failed at the one thing it was on screen for.
|
|
73
|
+
- It takes the safe area like any other content (`layout-insets`), and the shutter sits where a thumb reaches (`touch-reach`).
|
|
74
|
+
- A scanner adds three things: a target to aim at, a bound on how long it tries before offering something else, and a route that does not need the camera at all, such as typing the code or picking an existing photo.
|
|
75
|
+
|
|
76
|
+
## <Rule id="sense-biometric" evidence="device" description="The system prompt is the surface, and the fallback is drawn rather than assumed" />
|
|
77
|
+
|
|
78
|
+
Never build a face or fingerprint screen. The prompt belongs to the platform and it is the only one the user has been trained to trust, so an imitation is a security problem the user has no way to see through. Face authentication in particular runs through the platform's authentication framework rather than an AR or face recognition library, and an account holder under 13 is authenticated some other way.
|
|
79
|
+
|
|
80
|
+
- Name the method the device actually has, read from the platform rather than guessed from the OS. A button offering Face ID on a device with a fingerprint reader is wrong on the one screen where being wrong costs the most.
|
|
81
|
+
- The result set is eight branches, not two: no hardware, hardware busy, nothing enrolled, locked out after repeated failures, no device passcode set, cancelled by the user, cancelled by the system, and a fallback requested where the policy has none. That last one is a dead end the design created by offering a button with nothing behind it.
|
|
82
|
+
- Nothing enrolled has its own answer, which is the system enrolment screen, not the failure copy.
|
|
83
|
+
- Every biometric route has a second route to the same place that does not need the sensor, reachable in the same session. Faces get covered, hands get wet, and readers fail.
|
|
84
|
+
- Whether this app asks for a check at all, and what that check protects, is `auth-biometric-session`. Android refuses to combine a custom negative button with the device credential option, so one of those is the fallback and the other does not ship.
|
|
85
|
+
|
|
86
|
+
## <Rule id="sense-haptic" evidence="device" description="The pattern the device cannot render is the one the design leans on" />
|
|
87
|
+
|
|
88
|
+
`touch-feedback` sets the vocabulary: one meaning per pattern, nothing on scroll. This is the hardware underneath it, which varies more than any other output on the phone.
|
|
89
|
+
|
|
90
|
+
- Use the platform's named feedback rather than an authored waveform. On Android the order is the view's own haptic constants first, which need no vibrate permission and honour the user's touch feedback setting, then a predefined effect, then a composition of primitives. Raw one shot and waveform calls are discouraged even where they run, because they are too loud to read as feedback: a good key click is 10 to 20 milliseconds and the actuator rings on for another 20 to 50 after it.
|
|
91
|
+
- On iOS the named vocabulary is the three feedback generators, impact, notification and selection, each used for the meaning it is documented to carry. Core Haptics is the authored layer below them and needs a reason before it is reached for. iOS gives the app nothing to read about whether the user wants haptics at all, so the app carries its own switch for them and stays usable with it off.
|
|
92
|
+
- Ask the actuator what it supports and design for the answer. That query returns three values, yes, no and unknown, and unknown means the hardware does not report and no call will settle it. Every rich pattern has a plain one behind it.
|
|
93
|
+
- Nothing fires while a reading is in flight: an exposure, a running video or audio recording, a motion sensor sample. The motor shakes the device the sensor is measuring. A confirmation after a scan resolves is the correct use of one, and on a scanner it is the only non-visual confirmation the surface has.
|
|
94
|
+
- Either switch, the system's or the app's, can silence all of it, so a haptic never carries a message on its own.
|
|
95
|
+
|
|
96
|
+
## <Rule id="sense-motion" evidence="device" description="Motion sensors are data, not an input method" />
|
|
97
|
+
|
|
98
|
+
Where the reading is the content the user came for, a heading, a tilt, the orientation of a camera held in space, the sensor drives the view and that is the whole feature. What does not ship is motion standing in for a control the finger already has: those gestures are hard to perform precisely, and they are difficult or impossible for anyone who cannot move the device freely.
|
|
99
|
+
|
|
100
|
+
- Three get generated by reflex: tilt parallax, shake to act, and turn to scroll. Shake carries one meaning, undo, and only where the platform already gives it that meaning. The other two survive neither `motion-reduced` nor `a11y-gesture`, so if they ship at all they are decoration with an off switch and a second route.
|
|
101
|
+
- Sampling has a ceiling. From Android 12 a listener is capped at 200 Hz and a direct channel at about 50 Hz, and exceeding either without the high sampling rate permission throws. Which rate to ask for under that ceiling is `perf-power`.
|
|
102
|
+
- A compass is wrong until it is calibrated, so a heading gets a calibration state before it gets a needle.
|
|
103
|
+
- On mobile web the motion permission request has to be triggered by a real tap and the API is missing in some browsers, so the control that asks is one the user pressed, and the missing API is the absent state rather than a crash.
|
|
104
|
+
|
|
105
|
+
## <Rule id="sense-radio" evidence="device" description="An adapter that is off is not a permission that was denied" />
|
|
106
|
+
|
|
107
|
+
Bluetooth and NFC split the way the camera splits in `sense-off-system`: the permission reads as granted while the adapter is switched off, and that is a different sentence with a different move behind it. iOS raises its own system alert for a powered-off adapter and the app does not draw or control it, and Android answers with its own enable request rather than a screen the app owns.
|
|
108
|
+
|
|
109
|
+
- Paired, in range and connected are three states rather than one flag, and losing the peripheral is ordinary rather than exceptional. The surface says it is gone, keeps working on everything that does not need it, and reconnects without making the user start the task again. Nothing draws a dropped connection as live.
|
|
110
|
+
- A nearby interaction is never the only route to its task. Distance and direction degrade behind a body, a bag or a wall, and direction disappears entirely once the phone is not roughly pointed at the other device, so there is always a way to finish without it.
|
|
111
|
+
|
|
112
|
+
<Check>
|
|
113
|
+
|
|
114
|
+
<Verify rule="sense-states">Every capability that returns a value over time branches on absent, off at the system level, running, imprecise and failing, or names which of the five it folded and why.</Verify>
|
|
115
|
+
<Verify rule="sense-absent">Presence is queried at runtime before the entry point is drawn, absence removes the surface rather than disabling it, and no hardware feature is declared as required unless the app cannot run without it.</Verify>
|
|
116
|
+
<Verify rule="sense-off-system">Permission status and service availability are two separate reads, each with its own sentence, the ones that have a system page are deep linked to it, and neither resolves into a retry loop.</Verify>
|
|
117
|
+
<Verify rule="sense-running">The capturing surface shows its own running state with a stop, draws nothing inside the platform indicator's bounds, imitates no indicator, and ends with its screen unless it carries an ongoing notification or Live Activity.</Verify>
|
|
118
|
+
<Verify rule="sense-interrupted">A capture the system stops says so on the surface as it happens, keeps and names what was already captured, and re-establishes the session on return rather than leaving a dead one.</Verify>
|
|
119
|
+
<Verify rule="sense-accuracy">The accuracy radius is read, checked for validity and rendered, no accuracy threshold is shared across platforms, and a reduced fix runs the feature in a stated form.</Verify>
|
|
120
|
+
<Verify rule="sense-preview">The preview's fit is set deliberately, keeps the sensor aspect ratio, has an inert shutter until the session is live, holds the screen awake while it aims or records (`perf-power`), and any scanner has a target, a time bound and a route that does not use the camera.</Verify>
|
|
121
|
+
<Verify rule="sense-biometric">The platform prompt is used unmodified, named for the method the device reports, with an enrolment path and a non-biometric route in the same session.</Verify>
|
|
122
|
+
<Verify rule="sense-haptic">Haptics use each platform's named feedback with a plain fallback where support is unknown, iOS carries an in-app switch for them, none fires while a reading is in flight, and none carries a meaning alone.</Verify>
|
|
123
|
+
<Verify rule="sense-motion">The motion sensors drive the view only where the reading is the content, no gesture stands in for a control, and a heading has a calibration state.</Verify>
|
|
124
|
+
<Verify rule="sense-radio">An adapter switched off is answered separately from a denied permission, pairing, range and disconnection each have a state, and no nearby interaction is the only route to its task.</Verify>
|
|
125
|
+
|
|
126
|
+
<Device>Run these on a device that is missing something on purpose: location services off, the system camera toggle off, no biometric enrolled, Bluetooth off, a call placed mid recording. Each of those states passes on a fully equipped device with everything granted, which is the only device the code was written against.</Device>
|
|
127
|
+
|
|
128
|
+
</Check>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Settings
|
|
2
|
+
|
|
3
|
+
A setting is a decision the product declined to make. Somebody else will now make it, from a two word label, with less information than the team that gave up on it had.
|
|
4
|
+
|
|
5
|
+
On a phone that costs more than it costs anywhere else. Opening settings suspends whatever the person came to do; the screen is one column, so six rows is already most of it; every subscreen is a full navigation with nothing left beside it to compare against; and the label has to land at a glance, one-handed, on a moving bus. So the first question about a row is never where to file it. It is whether a better default deletes it.
|
|
6
|
+
|
|
7
|
+
Two neighbours own things that look like they belong here. Text size, bold text, contrast, reduced motion, language and region are system settings the app reads, under `a11y-settings` and `l10n-per-app`, and a second copy inside the app is the defect `set-system-owned` names. How a row is worded is `copy.md`.
|
|
8
|
+
|
|
9
|
+
## <Rule id="set-default-first" description="A setting is a default nobody was willing to pick" />
|
|
10
|
+
|
|
11
|
+
For every row, write down the value most people would keep, and why, in `STACK.md` beside the sync marking `set-sync` already asks for there, so one table holds the key, its default, the reason, and device-local against account-level. The row survives only if two reasonable people would keep different values and nothing the app can observe says which of them is in front of it.
|
|
12
|
+
|
|
13
|
+
- The app does not ask for what it can detect: the connected accessory, the current appearance, the locale, whether the connection is metered, whether this is the device that already has the data.
|
|
14
|
+
- A default is not a coin toss. It is the value that is quiet, cheap in battery and data, safe to be wrong about, and reversible with one tap.
|
|
15
|
+
- Count the rows in the whole tree, subscreens included. That count is the number of decisions handed back to the user, and it is a finding about the product before it is a problem with the screen.
|
|
16
|
+
|
|
17
|
+
## <Rule id="set-in-context" evidence="device" description="What gets changed often is not a setting" />
|
|
18
|
+
|
|
19
|
+
Sort order, filter, list density, playback speed, muting this one conversation, the unit on this one chart: each belongs on the screen it changes, where the result is visible while the choice is made. Filed under settings instead, it makes somebody leave the thing, guess, and come back to find out what happened.
|
|
20
|
+
|
|
21
|
+
Settings holds the rare and the app-wide. The test runs one way only: a control whose effect is visible on one particular screen belongs on that screen. Run backwards it deletes settings entirely, because a notification preference, a unit, a data saver or a privacy choice has no single screen to show its effect on, and those are exactly what the screen is for.
|
|
22
|
+
|
|
23
|
+
## <Rule id="set-system-owned" description="A copy of a system setting is a bug, not a convenience" />
|
|
24
|
+
|
|
25
|
+
An app-level switch for something the OS already owns tells the user that the system's own choice may not apply here, and the two go out of sync the first time either one is touched.
|
|
26
|
+
|
|
27
|
+
Never given an app-wide duplicate in the settings screen: text size, bold text, contrast, reduced motion and transparency (`a11y-settings`), permissions (`state-permission`), the device's biometric enrolment (`auth-biometric-session`). The exemption is a control over the app's own content, sitting on the screen it affects under `set-in-context` and layering on top of the system value rather than replacing it: the type size inside a reader, not a second global text size row in settings. Language is a different shape again. The app never keeps its own language preference, and an in-app language row is allowed where it writes through the platform API, which is `l10n-per-app`.
|
|
28
|
+
|
|
29
|
+
Where the app cannot change the thing itself, the row is a route out to the system rather than a control:
|
|
30
|
+
|
|
31
|
+
- iOS: `UIApplication.openSettingsURLString` for the app's own page, and `openNotificationSettingsURLString` from iOS 16 for its notification page.
|
|
32
|
+
- Android: `ACTION_APPLICATION_DETAILS_SETTINGS` with a `package:` data URI, `ACTION_APP_NOTIFICATION_SETTINGS` with `EXTRA_APP_PACKAGE`, `ACTION_CHANNEL_NOTIFICATION_SETTINGS` with `EXTRA_APP_PACKAGE` and `EXTRA_CHANNEL_ID` both, since without the channel id it lands nowhere, `ACTION_APP_LOCALE_SETTINGS`. Resolve each intent before drawing the row that uses it, because the matching activity is not guaranteed to exist on a given device and a row that does nothing is worse than no row.
|
|
33
|
+
|
|
34
|
+
Each of those lands on the app's own page or on the one setting the feature needs, and a row that sends somebody off to turn off Wi-Fi or a security feature the app does not own fails review on iOS and deserves to. Which side of the line a preference lives on forks by platform too: iOS lets a handful of the most rarely changed ones be published into the system Settings app through a settings bundle, Android has no equivalent, so on a product that ships to both, the screen inside the app is the one that has to be complete.
|
|
35
|
+
|
|
36
|
+
One override is worth building rather than reading: appearance. Both platforms accept an app-level light or dark choice, `overrideUserInterfaceStyle` on iOS and `AppCompatDelegate.setDefaultNightMode` on Android, so where the product wants one the row carries three values with Match system as the default, never two, or somebody who set the system to dark cannot get back. The stored value is read and applied before the first frame is drawn, because applied any later it opens every cold start in the system appearance and then flips, which is the seam `splash-appearance` rules out from the other side.
|
|
37
|
+
|
|
38
|
+
One of these is not optional. An app that asked for the notification permission carries an in-app place where that answer can be changed, and on both platforms that place is a link into the system's notification settings rather than a second switch sitting beside the real one. What the channels behind it are is `notify-channels`.
|
|
39
|
+
|
|
40
|
+
## <Rule id="set-shape" evidence="device" description="Ten rows is the ceiling, and frequency is the order" />
|
|
41
|
+
|
|
42
|
+
- Group under a heading with a divider, around a job the user recognises rather than the module that implements it. A group of one is not a group. Sections are `list-sections`.
|
|
43
|
+
- Order by how often something is changed, most changed at the top. Alphabetical and source order are both the absence of a decision.
|
|
44
|
+
- At ten rows on one screen the remainder moves to a subscreen, and the parent row then carries that group's own status so the level above still reads. Fifteen is not a judgement call, it is the failure: on one column that is two screenfuls of decisions before anything has been read.
|
|
45
|
+
- An Advanced section hides at least three rows or it does not exist, and its single line of subtext names what is inside it. A collapsed section with no preview is a locked drawer.
|
|
46
|
+
- A feature screen whose whole feature can be turned off carries one main switch, at the top, above everything it governs. The rows under it stay visible and disabled rather than vanishing and reflowing the screen under a thumb already on its way down, and a disabled row says what turns it back on.
|
|
47
|
+
- Repeating one setting in two places is allowed when two different situations send people looking in two different places. It is one setting on one subscreen with two entry points, never two controls writing the same value, and where what is repeated is a whole feature, that one control is its main switch.
|
|
48
|
+
|
|
49
|
+
## <Rule id="set-status" description="Every row shows its current value without being opened" />
|
|
50
|
+
|
|
51
|
+
Title, then the value it is currently set to, on the row itself. In one column this is the whole difference between reading the screen and opening six subscreens to find out how the app is configured.
|
|
52
|
+
|
|
53
|
+
- The value is a value, not the title again. Sync, Wi-Fi only. Not Sync, On.
|
|
54
|
+
- A switch is its own value, and anything that opens a subscreen states its value beside the chevron. The row is one target and any control on it is another, under `list-row`.
|
|
55
|
+
|
|
56
|
+
## <Rule id="set-controls" description="Two shapes carry nearly all of it" />
|
|
57
|
+
|
|
58
|
+
- **On or off:** a switch on the row. A checkbox is for the negative case, restricting or blocking something, where a switch would have to be labelled with a "don't" and read backwards.
|
|
59
|
+
- **One of several:** a subscreen or a sheet with the options as rows. A menu that drops open under the finger is covered by that same finger, and it hides how many options exist until it is opened.
|
|
60
|
+
- Sliders and free text fields are the exceptions, each one costing a fine gesture or a keyboard, and each shows its current value as text next to it.
|
|
61
|
+
- A row that leaves the app for a web page says so before it is tapped. A settings screen assembled out of links is a website wearing a title bar.
|
|
62
|
+
|
|
63
|
+
## <Rule id="set-effect" description="Instant or saved, and never both on one screen" />
|
|
64
|
+
|
|
65
|
+
- **Instant:** the change is stored and applied as it is made, with no Save. There is no Cancel either, so nothing that cannot be undone by moving the control back belongs on an instant screen.
|
|
66
|
+
- **Saved:** for values that only mean something as a set, such as an address or a server and its credentials. One commit action, the typed input surviving a failed commit under `form-submit`, and leaving with uncommitted changes asks first.
|
|
67
|
+
- The mixed screen is the defect: a switch that applies immediately sitting above a Save button, where nothing on the screen says which of the two rules the switch is following. It is what a generated settings screen produces by default, and the back gesture makes it worse, because the user can leave at any moment with no OK button in the way.
|
|
68
|
+
- A write that failed reports at the control that failed, under `fb-place`, and that control returns to the value actually stored rather than sitting on the one that did not take.
|
|
69
|
+
|
|
70
|
+
## <Rule id="set-wired" evidence="device" description="A control nothing reads is a picture of a control" />
|
|
71
|
+
|
|
72
|
+
A settings screen generated from a feature list is a column of switches bound to screen-local state. They move under the thumb, they store nothing, and no code anywhere asks what they are set to. The screen looks finished, which is why this one survives to release.
|
|
73
|
+
|
|
74
|
+
- Every control writes to the preference store the stack actually uses, and at least one place outside the settings screen reads that key. A key nothing reads is a row to delete, not a row to wire up later.
|
|
75
|
+
- Every read states the value to use when the store answers with nothing, because it will: first launch, a reinstall, a store not ready yet. What came back empty is never written back as though the person had chosen it.
|
|
76
|
+
- Kill the process and open the screen again. A preference that did not survive that was never stored, whatever the switch was showing.
|
|
77
|
+
|
|
78
|
+
## <Rule id="set-sync" description="Say what follows the account and what stays on this phone" />
|
|
79
|
+
|
|
80
|
+
A preference that silently appears on the other device, or silently does not, is a bug report either way.
|
|
81
|
+
|
|
82
|
+
- Decide it per setting and record it in `STACK.md`: device-local, such as appearance, downloads, and which notifications this device shows, against account-level, such as units, content preferences and privacy choices.
|
|
83
|
+
- A synced group says so once, in a few words, on the group. A device-local row inside an otherwise synced group says so on the row. An account-level row waits in `state-loading` until its stored value has arrived, rather than sitting interactive at a coded guess somebody will flip believing it was theirs.
|
|
84
|
+
- Two devices will write the same preference at different moments, so last write wins is a decision to make rather than a default to inherit, under `off-conflict`.
|
|
85
|
+
- What survives signing out is already ruled by `auth-signout`.
|
|
86
|
+
|
|
87
|
+
## <Rule id="set-destructive" description="The one-way rows sit apart from the weekly ones" />
|
|
88
|
+
|
|
89
|
+
Clear cache, remove downloads, reset settings, leave the group, sign out, delete the account. Distance is the mechanism and the confirmation rules are `touch-destructive`.
|
|
90
|
+
|
|
91
|
+
- They are grouped at the end or on a subscreen of their own, never next to a switch somebody flips weekly, and never next to each other when one is recoverable and the next one is not. Sign out and delete do not share a group, under `auth-signout`.
|
|
92
|
+
- Each one names what it removes and how much of it, in the unit the person counts in: delete 1.2 GB of downloaded episodes, not clear data.
|
|
93
|
+
- Reset states its scope and keeps to it, meaning this group of settings rather than everything the app holds.
|
|
94
|
+
|
|
95
|
+
## <Rule id="set-search" description="A settings tree that needs search is telling you something first" />
|
|
96
|
+
|
|
97
|
+
The trigger is depth, not taste: the moment one row sits three levels below the root, nobody navigates to it any more, they hunt for it. Depth that `set-shape` produced by itself does not count toward that, because an overflow subscreen and an Advanced section are its fix for a crowded screen rather than evidence of a deep tree. Then the root gets a field that matches row titles, group names and current values, and lands on the subscreen with the row it found marked. Which surface that field is and how it behaves is `search-surface`. Read the finding before shipping the fix, though: search makes a deep settings tree survivable, it does not make it right, and the row count that drove it there is the count `set-default-first` is asking about.
|
|
98
|
+
|
|
99
|
+
## <Rule id="set-account-exit" description="Settings is where people go when they want out" />
|
|
100
|
+
|
|
101
|
+
Whatever else it holds, this is the screen somebody opens to stop paying, stop being sent things, or stop having an account. Burying any of them costs goodwill, and burying most of them costs a store review as well.
|
|
102
|
+
|
|
103
|
+
- The account row names who is signed in, under `auth-active-account`, and this screen also carries the entry point for signing out (`auth-signout`) and the one for deleting the account (`auth-delete`), placed apart from each other by `set-destructive`. All three belong here and none of them is redesigned here.
|
|
104
|
+
- A subscription sold inside the app carries a row here that manages and cancels it. Play names the missing link on the account settings screen or its equivalent as a violation, and takes either the Subscription Center at `play.google.com/store/account/subscriptions` or that same address carrying `sku` and `package` for the one subscription the row is about; on iOS the row opens the system's own sheet through `AppStore.showManageSubscriptions(in:)` from iOS 15.
|
|
105
|
+
- The privacy policy is reachable from inside the app rather than only from the store listing, and that one is a review requirement rather than a courtesy. The terms and a way to withdraw any consent the app collected sit here too, by this skill's placement decision: what is required of the withdrawal is that it is easy to reach and easy to understand, and settings is where this skill puts it.
|
|
106
|
+
|
|
107
|
+
## <Rule id="set-diagnostics" description="The version, and a way to report something" />
|
|
108
|
+
|
|
109
|
+
The version and build are the first thing a support reply asks for, and somebody has to be able to read them out loud off a phone they are holding at arm's length. They go on an About subscreen with the licences, one level down, rather than taking a row at the top from something adjustable. Beside them sits one route to support that attaches the version, the device and the locale by itself: a report typed with a thumb will not carry them, and without them it cannot be answered.
|
|
110
|
+
|
|
111
|
+
<Check>
|
|
112
|
+
|
|
113
|
+
<Verify rule="set-default-first">Every setting has a line in `STACK.md` naming its default and why that default could not settle the row, nothing is asked for that the app can detect, and the total row count across the tree is reported.</Verify>
|
|
114
|
+
<Verify rule="set-in-context">No frequently changed option, filter or sort lives in settings instead of on the screen it changes.</Verify>
|
|
115
|
+
<Verify rule="set-system-owned">No app-wide row duplicates a system setting, no language preference is stored outside the platform API, every row the app cannot fulfil itself is a deep link resolved before it is drawn and aimed at the app's own page, including one in-app place to change the notification answer, and any appearance row offers three values with Match system as the default and applies the stored one before the first frame.</Verify>
|
|
116
|
+
<Verify rule="set-shape">No screen holds more than ten rows, groups carry headings, order runs by frequency, any Advanced section hides at least three rows behind one line of subtext, and a feature that can be switched off entirely has one main switch above dependent rows that stay visible and disabled.</Verify>
|
|
117
|
+
<Verify rule="set-status">Every row shows its current value, and no subscreen has to be opened to find out what the app is set to.</Verify>
|
|
118
|
+
<Verify rule="set-controls">On and off is a switch, one of several is a subscreen or sheet rather than a dropdown, and every slider or text field shows its value.</Verify>
|
|
119
|
+
<Verify rule="set-effect">The screen is instant or saved, stated by what it shows, with no instant control on a screen that has a commit action.</Verify>
|
|
120
|
+
<Verify rule="set-wired">Every control writes to the preference store, every key is read somewhere outside the settings screen, every read declares a default, and the values survive killing the process.</Verify>
|
|
121
|
+
<Verify rule="set-sync">Every preference is marked device-local or account-level, the screen says which, an account-level row waits for its stored value instead of offering a guess, and the collision rule for two devices is written down.</Verify>
|
|
122
|
+
<Verify rule="set-destructive">Destructive rows are grouped away from frequent ones, each names what it removes and how much, and sign out is not adjacent to delete.</Verify>
|
|
123
|
+
<Verify rule="set-search">No row sits three levels below the root without a search field on the root, counting depth the shape rules did not create, and the tree's row count is reported as a finding alongside it.</Verify>
|
|
124
|
+
<Verify rule="set-account-exit">The signed-in account, a subscription route that works on both stores, the policy links, the consent withdrawal and the entry points for sign out and deletion are all present and reachable in one screen.</Verify>
|
|
125
|
+
<Verify rule="set-diagnostics">Version and build are on an About subscreen, and the support route carries version, device and locale without the user typing them.</Verify>
|
|
126
|
+
|
|
127
|
+
<Device>Three of these are not in the diff. Kill the process from outside the app, with Don't keep activities or `adb shell am kill`, then reopen the screen to find out which of `set-wired`'s values were really stored. The frequency judgements in `set-in-context` and `set-shape` are answered by which controls the app's own screens change often, which is a question for the product rather than for the settings file.</Device>
|
|
128
|
+
|
|
129
|
+
</Check>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Sharing
|
|
2
|
+
|
|
3
|
+
Sharing is how the app leaves the phone. Something inside it becomes a message, a post, a file in someone else's app, and then comes back the other way when this app is the destination. Sending a copy out and taking one in is the whole of the subject here. Inviting someone into a document to work on it alongside you is collaboration, which is a different problem and is not covered.
|
|
4
|
+
|
|
5
|
+
Two things make it a phone problem rather than a general one. The share surface is not yours: the system sheet is drawn by the OS, ranked by the OS, and populated from apps you cannot enumerate. And the app is suspended the moment it opens, so whatever gets handed over has to be finished, small, and correct before the sheet appears. Coming back in, a share arrives on a device already showing something else, on top of work the user was in the middle of.
|
|
6
|
+
|
|
7
|
+
## <Rule id="share-sheet-only" description="The system sheet is the share UI" />
|
|
8
|
+
|
|
9
|
+
A drawn row of service logos is the pattern to delete. Both platforms land in the same place from opposite sides: no app-drawn list of share targets and no variation on the sheet, because the Share control is expected to open the system activity view and anything else in its place only confuses. The sheet is the only surface that knows which apps are installed on this phone, which conversations are recent, and which system destinations exist at all. Six hardcoded logos are a guess about a stranger's device, and they rot every time one of those apps changes a URL scheme.
|
|
10
|
+
|
|
11
|
+
| Stack | Entry point |
|
|
12
|
+
|---|---|
|
|
13
|
+
| SwiftUI | `ShareLink` |
|
|
14
|
+
| UIKit | `UIActivityViewController` |
|
|
15
|
+
| Compose and Android views | `Intent.createChooser()` wrapping `ACTION_SEND`, or `ShareCompat` |
|
|
16
|
+
| Flutter, React Native | the bridge to the two rows above, never a drawn list |
|
|
17
|
+
| Mobile web | `navigator.share()`, offered only when `navigator.canShare?.(data)` agrees for the exact payload |
|
|
18
|
+
|
|
19
|
+
Skipping `createChooser()` on Android gets the intent resolver's disambiguation dialog instead of the Sharesheet, which is a different and worse surface. Put the control in the chrome on the platform's own share glyph (`icon-one-set`), sized as a target like anything else (`touch-floor`), and exclude what does not apply: your own targets on Android with `EXTRA_EXCLUDE_COMPONENTS`, system activities the content cannot go to on iOS with `excludedActivityTypes`.
|
|
20
|
+
|
|
21
|
+
On the web the sheet is conditional in a way it never is in a native build: `navigator.share()` exists only in a secure context and is missing from browsers that are still in use, so the copy path (`share-copy`) is the route, not a courtesy. Test the property before calling it and test the payload you are about to send, because `canShare()` with no argument is false everywhere: a bare `if (navigator.canShare())` hides the control on the browsers that support it fully, and reaching the method at all throws on the ones that do not have it.
|
|
22
|
+
|
|
23
|
+
## <Rule id="share-payload" description="One item, one concrete type" />
|
|
24
|
+
|
|
25
|
+
`ACTION_SEND` carries a single item and `ACTION_SEND_MULTIPLE` a list, with the content in `EXTRA_TEXT` or `EXTRA_STREAM`. Declare the MIME type the content actually is. A wildcard is never that type: most receiving apps cannot take anything, so `*/*` fills the sheet with destinations that will fail on the payload. On the web, `navigator.share()` rejects with a `TypeError` unless at least one of `title`, `text`, `url` or `files` is present, so a share assembled from a half-loaded model throws instead of opening. Text and its link are one payload, not two shares, and the wording of that text is `copy-budget`.
|
|
26
|
+
|
|
27
|
+
## <Rule id="share-link-not-shot" description="Share the thing, not a picture of the thing" />
|
|
28
|
+
|
|
29
|
+
A screenshot cannot be opened, followed, or read by anyone using a screen reader. Share a URL that resolves to a real page for someone without the app and opens the app for someone who has it: a universal link declared through the `applinks:` entitlement, an App Link with `android:autoVerify="true"`. Both are verified from a file the domain serves over HTTPS at `/.well-known/`, `apple-app-site-association` and `assetlinks.json`, and neither is live the moment the file goes up. On iOS the association file is fetched through a CDN within 24 hours and devices re-check it about once a week. On Android verification runs at install and at update, inspectable with `pm get-app-links` and re-runnable with `pm verify-app-links --re-verify`. Either way a domain change is not live for anyone already holding the app. A custom scheme such as `myapp://` pasted into a message is plain text on every device that has not installed you. Routing the link once it arrives is `nav-deeplink`.
|
|
30
|
+
|
|
31
|
+
## <Rule id="share-preview" description="Hand the preview over, do not make the destination fetch it" />
|
|
32
|
+
|
|
33
|
+
From Android 10 (API 29) the Sharesheet shows a preview of shared text, built from `EXTRA_TITLE` plus a thumbnail passed as a content URI with read permission granted. On iOS the two entry points behave differently. `ShareLink` with no supplied preview shows a placeholder link icon beside the bare URL while it pulls the metadata over the network, and a supplied preview renders immediately with no fetch at all. `UIActivityViewController` derives nothing on its own: the preview comes from `activityViewControllerLinkMetadata(_:)` or an `LPLinkMetadata` handed over at the call site, and without one there is no preview. That fetch runs on the same phone connection the user is already waiting on, in front of a sheet that is already open.
|
|
34
|
+
|
|
35
|
+
The system can only derive a preview for a bare URL or a plain string. Anything else, and anything whose title should read differently from the page's own, is supplied at the call site: the title, the image and the type. The preview is also the last thing the sender sees before the send, so it is the place a wrong image or a stale title gets noticed, which is a reason to get it right rather than a reason to omit it.
|
|
36
|
+
|
|
37
|
+
## <Rule id="share-ready" description="The payload is finished before the sheet opens, and not built on the drawing thread" />
|
|
38
|
+
|
|
39
|
+
The item-source callbacks on iOS run on the drawing thread, so nothing that takes real time to produce belongs inside one: that is what the provider and placeholder forms exist for (`perf-main-thread`). On iOS and Android, prepare the item on tap, show a placeholder while it resolves, and open the sheet on data that exists.
|
|
40
|
+
|
|
41
|
+
The web will not take that shape. `navigator.share()` requires transient activation, so an `await` between the tap and the call spends the gesture and the promise rejects with `NotAllowedError`, and unlike the clipboard write there is no promise the call will accept and wait on. So on the web the payload exists before the tap or the control is not offered yet, and `share()` is called synchronously in the handler. Either path is held to the thresholds in `perf-main-thread`, measured on the slowest device you support (`perf-measure`).
|
|
42
|
+
|
|
43
|
+
## <Rule id="share-file-uri" description="A file leaves as a granted content URI" />
|
|
44
|
+
|
|
45
|
+
On Android, hand out `FileProvider.getUriForFile()` and grant it with `addFlags(FLAG_GRANT_READ_URI_PERMISSION)`, which is the secure route and is preferred over calling `grantUriPermission()` yourself. A `file://` URI from `Uri.fromFile()` needs the receiving app to hold storage permission, which major targets such as Gmail do not, so the share fails on exactly the destinations that matter. Resize and crop before handing over instead of sending the original capture: those bytes cross a phone network first and the destination re-encodes them anyway. WebP or AVIF for images and AV1 or HEVC for video carry the same picture in less (`net-upload`).
|
|
46
|
+
|
|
47
|
+
## <Rule id="share-outcome" description="The app does not get to say where it went" />
|
|
48
|
+
|
|
49
|
+
Web Share is fire and forget: the promise resolves with `undefined` and never names the target, and its rejection is worse than useless, since one and the same `AbortError` covers the user cancelling and the device having no share target at all. Android tells you the chosen component only if you pass an `IntentSender` to `createChooser()` and read `EXTRA_CHOOSER_RESULT` back. So "Shared to WhatsApp" is usually a fiction, and on most paths a cancelled sheet is indistinguishable from a completed share.
|
|
50
|
+
|
|
51
|
+
The sheet closing is the feedback (`fb-silent-success`), and a toast fired on dismissal claims something that did not necessarily happen. Nothing in the product is unlocked, rewarded, counted or advanced on the strength of a share the app cannot observe.
|
|
52
|
+
|
|
53
|
+
## <Rule id="share-payload-clean" description="What goes out is what the user saw" />
|
|
54
|
+
|
|
55
|
+
The code adds things the user did not: EXIF location and device model inside a photo, a session token or account id appended to a share URL, an internal identifier in a filename. Strip metadata the destination has no use for, and build the outgoing link from public identifiers only. Anyone the link reaches can open it, so it may not carry anything that authenticates the person who sent it, and a share is not a hole in `priv-instrument`.
|
|
56
|
+
|
|
57
|
+
The sheet itself is a transfer the user asked for and picked the destination of, so it falls outside the store's data sharing declaration. What does turn that declaration on is anything the app sends alongside or behind the share: an analytics event carrying the content, an SDK handed the same payload, a server-side copy taken on the way past. Those are declared like any other transfer (`priv-declared`).
|
|
58
|
+
|
|
59
|
+
## <Rule id="share-accepts" description="Declare narrowly what the app receives, then distrust all of it" />
|
|
60
|
+
|
|
61
|
+
Being a share target is an intent filter on `ACTION_SEND` or `ACTION_SEND_MULTIPLE` with category `DEFAULT` and a concrete `mimeType`, or a share extension on iOS. Accept the widest range you genuinely handle, and never declare `*/*` unless that claim is true. Then treat everything that arrives as written by a stranger, because it was: the wrong type under the right label, an image far larger than the screen, a file that is not what its type says. Decode it off the UI thread. What cannot be handled gets a real failure state with a way forward (`state-error`), never a crash and never a silent discard.
|
|
62
|
+
|
|
63
|
+
## <Rule id="share-arrives" description="A share lands on a phone that was already busy" />
|
|
64
|
+
|
|
65
|
+
The user was mid-form somewhere else, so the incoming share is an interruption rather than a launch (`state-interrupt`, `form-persist`). Decide before building whether it opens its own task or joins the one in progress, and write that decision where it can be read: the launch mode and task flags on the receiving activity, `documentLaunchMode` where each share becomes its own document, the extension's own dismissal path on iOS. Either way the interrupted work is still there on return (`nav-restore`). Keep the receiving surface to a few steps: a share or action extension finishes the job quickly and stacks no further modal views inside itself. Work that takes real time continues in the background with its status visible in the main app, and finishing it is not by itself worth a notification.
|
|
66
|
+
|
|
67
|
+
## <Rule id="share-targets" description="Do not inject destinations, publish them" />
|
|
68
|
+
|
|
69
|
+
The two directions have opposite answers. As the sender you add no destinations of your own: on Android `EXTRA_CHOOSER_TARGETS` and `EXTRA_INITIAL_INTENTS` are capped at two apiece and discouraged either way, since each one displaces a target the system would have ranked better. As the receiver you expose your own conversations through the Sharing Shortcuts API, which since Android 11 (API 30) is the only mechanism, the older chooser target service having been deprecated there. Publish long-lived shortcuts ordered by importance, report use so ranking has a signal, and drop stale ones, a conversation with no activity in the last 30 days counting as stale.
|
|
70
|
+
|
|
71
|
+
An action is not a destination, and both platforms leave room for one: custom sheet actions on Android 14 (API 34), a custom activity on iOS, which the sheet lists ahead of the system ones. Either is for something your app does to the content, never a second copy of a destination the sheet already carries, and its title is a short verb phrase with no product name in it.
|
|
72
|
+
|
|
73
|
+
## <Rule id="share-copy" description="Copy is the fallback, and its confirmation is per platform" />
|
|
74
|
+
|
|
75
|
+
Copy to clipboard is the answer when there is no destination to share to, when the user needs the raw string such as a code or an address, and on any path where the sheet is unavailable. The confirmation is not one decision. Android 13 (API 33) and later shows a system confirmation with a preview of what was copied, so the app's own toast or snackbar is removed at that level, while API 32 and below still needs the app to say something. iOS shows nothing at all, so there the app owns the feedback (`fb-ladder`). Copying a password, a card number or a recovery code sets `ClipDescription.EXTRA_IS_SENSITIVE`, so the system preview does not put the secret on screen.
|
|
76
|
+
|
|
77
|
+
## <Rule id="share-paste" description="Reading the clipboard is a visible act" />
|
|
78
|
+
|
|
79
|
+
Android 12 (API 31) and later shows the user a toast naming your app when it calls `getPrimaryClip()`. iOS 16 and later puts a permission alert in front of a programmatic read, and exactly three routes skip it: the system Paste menu item, the keyboard shortcut, and `UIPasteControl`, where the tap itself is the consent. A Paste button you draw yourself and wire to `UIPasteboard.general.string` is a programmatic read and raises the alert like any other. So a clipboard read happens because the user asked for one through one of those routes, never at launch and never to sniff a referral code out of the background. Inspecting without reading raises neither notice: `getPrimaryClipDescription()` on Android and `detectPatterns(for:completionHandler:)` on iOS, and knowing the type is usually enough to decide whether to offer a paste at all.
|
|
80
|
+
|
|
81
|
+
## <Rule id="share-invite" description="An invite link opens a screen, not a login wall" />
|
|
82
|
+
|
|
83
|
+
Where the sign-in is to a specific social network, the App Store requires access without it or through some other mechanism, and it names inviting friends and sharing to a social network as things that do not count as core functionality. A referral programme is not what makes such a sign-in core, and social network credentials and tokens are never stored off the device. Where the gate is your own account and the app has no significant account-based features behind it, the guideline is softer but points the same way: let people in and ask later. The screen someone reaches from an invite shows what they were invited to before it asks for anything (`onboard-look-first`), and it is not the cold-launch home screen: the link named a thing and that thing is what opens (`nav-deeplink`). Attribution comes from the referrer the platform hands you, not from the clipboard (`share-paste`).
|
|
84
|
+
|
|
85
|
+
<Check>
|
|
86
|
+
|
|
87
|
+
<Verify rule="share-sheet-only">Sharing goes through the platform's own sheet, no component draws a list of service targets, and Android calls `createChooser()`.</Verify>
|
|
88
|
+
<Verify rule="share-payload">The payload declares a concrete MIME type, never a wildcard, and cannot be assembled empty.</Verify>
|
|
89
|
+
<Verify rule="share-link-not-shot">What is shared is a link that opens for a stranger and is verified from `/.well-known/`, not a screenshot and not a custom scheme.</Verify>
|
|
90
|
+
<Verify rule="share-preview">Title, thumbnail and type are supplied to the sheet rather than left for the destination to fetch.</Verify>
|
|
91
|
+
<Verify rule="share-ready">Payload construction happens off the drawing thread and completes before the sheet is opened, with no `await` between the tap and a web share.</Verify>
|
|
92
|
+
<Verify rule="share-file-uri">Files are shared as granted content URIs, never as filesystem paths, and are resized before they leave.</Verify>
|
|
93
|
+
<Verify rule="share-outcome">No UI names the destination of a share, and nothing is rewarded or unlocked on a share completing.</Verify>
|
|
94
|
+
<Verify rule="share-payload-clean">The outgoing payload carries no EXIF or device metadata, session token or internal id.</Verify>
|
|
95
|
+
<Verify rule="share-accepts">Declared incoming types match what the app actually handles, and every incoming item is validated and decoded off the UI thread.</Verify>
|
|
96
|
+
<Verify rule="share-arrives">The receiving entry point states its launch mode, task behaviour or dismissal path, and the work the share interrupted is still there afterwards.</Verify>
|
|
97
|
+
<Verify rule="share-targets">No custom chooser targets or initial intents on Android, any iOS custom activity acts on the content instead of duplicating a destination, and conversations the app owns are published as system share shortcuts with stale ones removed.</Verify>
|
|
98
|
+
<Verify rule="share-copy">Copy feedback follows the platform: no app toast where the system already confirms, and sensitive copies are flagged.</Verify>
|
|
99
|
+
<Verify rule="share-paste">The clipboard is read only from a user action, never at launch.</Verify>
|
|
100
|
+
<Verify rule="share-invite">Invites and referrals are reachable without an account, and their link opens the thing it named.</Verify>
|
|
101
|
+
|
|
102
|
+
</Check>
|