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,95 @@
|
|
|
1
|
+
# Sound
|
|
2
|
+
|
|
3
|
+
Sound the app makes on its own: a tap tone, a success chime, an error beep, a loop under a screen. It is the only output the app has that reaches people who never installed it, so the cost is paid by the room and the benefit is collected by one person. A phone is carried into meetings, waiting rooms, buses and bedrooms, and silencing it is what people do on the way in.
|
|
4
|
+
|
|
5
|
+
The player and the audio somebody pressed play on are `media-system-player` and `media-unasked-sound`. The sound attached to a notification is fixed on its channel, which is `notify-channels` and `notify-level`. Haptics are `touch-feedback` and `sense-haptic`. What is left, the noise the interface makes by itself, is this file, and for most apps the right size of it is zero.
|
|
6
|
+
|
|
7
|
+
## <Rule id="sound-inventory" description="A short list with fixed meanings, and an empty list is a legitimate answer" />
|
|
8
|
+
|
|
9
|
+
Write the set down in `STACK.md` before any of it is coded: the event, what the sound means, and what the screen shows at the same moment. Every play site in the code maps to one entry, and nothing plays that is not in it.
|
|
10
|
+
|
|
11
|
+
- One sound per meaning and one meaning per sound. Two samples that both mean "done" teach nothing, and one sample doing duty for saved and for deleted teaches the wrong thing.
|
|
12
|
+
- Frequency decides what survives. The more often an action happens, the worse a sound on it ages, so the first cuts are the ones on taps, scroll, keystrokes, screen changes and content arriving. What is left is rare and consequential: a payment sent, a scan matched, a timer that has run out while the phone was face down. The exception is a control whose platform behaviour already includes a sound, a keyboard key, a dialpad or a shutter: that one is played through the platform's own gated API (`sound-system-sound`) and counts as one entry rather than one per press.
|
|
13
|
+
- A sound is worth its slot only when the user's eyes may be elsewhere. Anything confirming something already visible on screen is decoration, and `fb-silent-success` has already ruled on it.
|
|
14
|
+
- Nothing plays while the app is not the thing on screen. Sound coming out of a screen nobody is looking at is a notification wearing the wrong clothes, and a notification is silenceable per kind while this is not.
|
|
15
|
+
|
|
16
|
+
## <Rule id="sound-silenced" description="One platform settles it with a category, the other leaves most of it to the app" />
|
|
17
|
+
|
|
18
|
+
On iOS the answer is the audio session category, chosen once for the app rather than branched on at each play site.
|
|
19
|
+
|
|
20
|
+
| Category | Hardware silent switch | Other apps' audio |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `.ambient` | silences the app | mixes with it |
|
|
23
|
+
| `.soloAmbient`, the default | silences the app | takes the output |
|
|
24
|
+
| `.playback` | keeps playing | mixes only if the option is set |
|
|
25
|
+
|
|
26
|
+
Interface sound is `.ambient`. `.playback` survives the hardware switch and belongs to the two cases `media-unasked-sound` owns, neither of which is interface sound. Picking it for a chime is the app deciding the hardware switch does not apply to it. Note that the silent-switch half of `.ambient` is already what the `.soloAmbient` default gives you, so the thing `.ambient` is actually chosen for is mixing, which is `sound-mixes`.
|
|
27
|
+
|
|
28
|
+
Android has no session category, and no single user gesture answers the same question for an app's own sound. Do Not Disturb at total silence is the case that does reach it: under `INTERRUPTION_FILTER_NONE` every audio stream except a phone call is muted along with the notifications and the vibration. The ringer mode and the volume keys act on streams rather than on a category the app declared once, so between those the discipline is largely the app's own. Two things exist to use:
|
|
29
|
+
|
|
30
|
+
- `Settings.System.SOUND_EFFECTS_ENABLED`, the user's own toggle for interface sound. `View.playSoundEffect` and the `AudioManager.playSoundEffect(int)` overload fire only when it is on. The overload that takes an explicit volume, `playSoundEffect(int, float)`, carries no such gate, so it is read against the setting like any sample the app plays through its own player. That reading happens before the sound plays, or the app is the one noise left on a phone where the user switched interface sound off.
|
|
31
|
+
- `AudioAttributes` built with `USAGE_ASSISTANCE_SONIFICATION` and `CONTENT_TYPE_SONIFICATION`, which is how the system is told this is interface sound. Untagged, it is filed as media and treated as media.
|
|
32
|
+
|
|
33
|
+
Sound and haptics are not gated the same way there, and a helper that switches both on one flag is wrong on one of them. Sound still asks: the framework's own click checks the user's setting, and an app-played sample has to. Vibration no longer does, because the setting behind it was deprecated at API 33 in favour of vibration usages the system applies on the app's behalf.
|
|
34
|
+
|
|
35
|
+
The app never sets the system volume or the ringer mode. On iOS the system volume governs what leaves the speaker, and repurposing the volume buttons or defeating the Ring/Silent switch is an App Store rejection rather than a taste question. On Android the APIs to move both exist and only a ringer change that would toggle Do Not Disturb is gated, behind Notification Policy Access, so there the rule is discipline rather than a wall and a reviewer should expect to find the call rather than a compile error. Balancing one of the app's own sounds against another is fine, so a sound that is too quiet is mixed too quiet rather than fixed by turning the device up.
|
|
36
|
+
|
|
37
|
+
The decision is made once, at the level where the platform exposes it, and never as a runtime test of whether the phone happens to be silenced right now. Code that plays a sound only after inspecting the ringer or the mute state has moved a system guarantee into a branch that will be wrong on some device, some launch, or some version.
|
|
38
|
+
|
|
39
|
+
## <Rule id="sound-mixes" evidence="device" description="A chime lands on top of whatever is already coming out of the speaker" />
|
|
40
|
+
|
|
41
|
+
The failure is loud and the documented default is the one that produces it. `.soloAmbient` is the default iOS category and it does not mix, so an app that never states a category can put its first chime through a podcast that then does not come back. Say which category the app uses rather than inheriting one.
|
|
42
|
+
|
|
43
|
+
- Interface sound mixes. On iOS that is the `.ambient` category. On Android it is a sonification-tagged sample short enough to be over before it could duck anything, so it rides alongside the music rather than competing for it. Nothing outside a player is worth taking the output for.
|
|
44
|
+
- A UI sound never activates and deactivates a session around itself. Whether a sample has to hold audio focus at all on a current Android is a playback question, and `media-focus` owns it along with the gains, the losses and the ducking.
|
|
45
|
+
- A sound has no route back. If the other app's music is quieter after the chime, or gone, the category is the first place to look: a non-mixing `.soloAmbient`, a `.playback` with the mixing option unset, or a focus request that should not have been made. Where a session really is being activated around the sound, the missing deactivation belongs to `media-unasked-sound`.
|
|
46
|
+
- Test it the way it will happen: start music in another app, then use the screen. Every sound the app makes should land on top of that music without changing it, and the music should be at the same level when the screen is closed.
|
|
47
|
+
|
|
48
|
+
## <Rule id="sound-system-sound" description="Play the platform's sound before shipping one" />
|
|
49
|
+
|
|
50
|
+
The system already has the sounds for the actions the system invented, and its versions are the ones the user recognises from every other app on the phone.
|
|
51
|
+
|
|
52
|
+
- iOS: Audio Services for short sounds. Android: `View.playSoundEffect` with `SoundEffectConstants.CLICK`, which arrives already gated on the user's setting. Flutter reaches a deliberately small set through `SystemSound.play`, where `click` is the value that carries on phones and the others are documented as platform-limited, so confirm one runs on both before an action depends on it.
|
|
53
|
+
- Shipping a sample for a key press, a lock, a shutter or a click replaces something recognised with something that is not, and every asset is download size the user sees before any of the design (`perf-size`).
|
|
54
|
+
- The constraints on the short-sound path are real: through iOS Audio Services the file is at most 30 seconds, linear PCM or IMA4, packaged as `.caf`, `.aif` or `.wav`, with no volume control, no looping, no stereo placement, and one sound at a time. An mp3 is not a supported format there. That path also plays on the device speakers without audio routing, so the chime comes out of the phone rather than the headphones the user is wearing, which on its own decides whether it suits the sound. Anything richer is an audio session, which is `media-unasked-sound`.
|
|
55
|
+
- Mobile web has neither a system sound to borrow nor an interface-sound setting to read. Audio there is gated on the user having interacted with the page, so a sound not started from a touch may never fire at all, and a build that ships to the browser as well carries the signal on the screen and treats the sound as the part that may be missing.
|
|
56
|
+
- A sound on a repeated action is varied per play in pitch and level rather than shipped as one identical sample. The system does exactly that for the keyboard, and it is why forty taps in a row stay bearable.
|
|
57
|
+
- A haptic beside a sound is written, not inherited. The iOS alert-sound path vibrates only where the user has switched vibration on for the ringer, and drops the vibration entirely while the session is set to `.record` or `.playAndRecord`, so an app that leans on it for the felt half of a signal loses that half on two ordinary devices. Pair the sound with a feedback generator explicitly, under `sense-haptic`.
|
|
58
|
+
|
|
59
|
+
## <Rule id="sound-never-alone" evidence="device" description="Design the screen muted, then decide whether to add sound" />
|
|
60
|
+
|
|
61
|
+
An install may never hear any of it: the phone is silenced, muted, in a pocket, on a table across the room, or held by somebody who cannot hear it. `a11y-media` and `color-not-alone` set the law; the working rule is the order in which the screen gets built.
|
|
62
|
+
|
|
63
|
+
Build and review it with the device muted first, so nothing on it depends on being heard. A chime firing alongside a snackbar is an addition. A chime firing instead of one is a state that was never drawn, and `fb-reach` calls that undelivered. A sound and a haptic are not substitutes for each other either, since either can be switched off on its own.
|
|
64
|
+
|
|
65
|
+
The two places this breaks are worth naming, because both look finished on a developer's desk with the volume up. A failure that beeps and changes nothing on screen leaves the user tapping again. A long operation that announces its end with a sound and no visible completion leaves someone who put the phone down with no way to find out it worked, which is the exact situation the sound was added for.
|
|
66
|
+
|
|
67
|
+
## <Rule id="sound-unasked" description="Nothing starts making noise because a screen opened" />
|
|
68
|
+
|
|
69
|
+
Sound follows a touch. Launching, arriving at a screen, a card scrolling into view, a fanfare over a result nobody asked to celebrate: each one plays into a room the app cannot see, and it lands on whoever is nearest rather than on the user.
|
|
70
|
+
|
|
71
|
+
- Where a soundtrack or an ambient loop is genuinely the point, it starts from a control the user reaches on the first screen, not before it, and stopping it is one step from anywhere it can be heard.
|
|
72
|
+
- Audio that does start by itself owes a control that stops it, or its own volume separate from the system's. On the web that is an accessibility floor rather than a courtesy, and WCAG puts the line at anything running past 3 seconds. Native publishes no number, so take the same 3 seconds as the working one, and it applies to a splash animation and a game menu alike.
|
|
73
|
+
- Whether a media surface may start by itself is `motion-autoplay`, and claiming the speaker at launch is `media-unasked-sound`. The addition here is that outside a player there is no case for it at all.
|
|
74
|
+
|
|
75
|
+
## <Rule id="sound-off-switch" description="One switch, in the place that already owns the sound" />
|
|
76
|
+
|
|
77
|
+
An app-level control is right only where nothing above it can turn the sound off. A click played through the framework's effect API already obeys the user's system setting, and a notification's sound is not this file's to switch at all: `notify-channels` owns it on both platforms, including the iOS half where the per-kind control does belong inside the app. Duplicating a control the system already offers is the bug `set-system-owned` names.
|
|
78
|
+
|
|
79
|
+
- Samples the app plays itself are the case that needs one: a single switch for the app's own sound, sitting beside what it affects, read by every play site (`set-wired`), and persisted across launches.
|
|
80
|
+
- One switch for the whole set, not one per event. A set small enough to defend under `sound-inventory` is small enough to turn off as a unit, and a sound settings screen with six rows is the inventory admitting it is too long.
|
|
81
|
+
- A better default is cheaper than a switch (`set-default-first`). Where the sound is decoration rather than a signal, that default is off, and the switch exists for the people who want it back.
|
|
82
|
+
|
|
83
|
+
<Check>
|
|
84
|
+
|
|
85
|
+
<Verify rule="sound-inventory">Every play site maps to an entry in a written set of sounds, each with one meaning, and a sound on a frequent event survives only where it is the platform's own behaviour for that control, played through the platform's gated API.</Verify>
|
|
86
|
+
<Verify rule="sound-silenced">iOS interface sound uses `.ambient` rather than `.playback`, Android tags it as sonification and reads the user's interface-sound setting before playing its own sample, and nothing in the app changes the system volume or the volume buttons.</Verify>
|
|
87
|
+
<Verify rule="sound-mixes">The audio session is configured rather than left at the default, no play site activates a session or requests focus around a UI sound, and other apps' audio is at the level it was at before.</Verify>
|
|
88
|
+
<Verify rule="sound-system-sound">Recognised system actions use the platform's own sound, and any shipped asset meets the format limits of the API playing it and varies when it repeats.</Verify>
|
|
89
|
+
<Verify rule="sound-never-alone">Every play site sits beside a visible state change, and the screen was built and reviewed muted.</Verify>
|
|
90
|
+
<Verify rule="sound-unasked">No sound starts on launch, on navigation or on content appearing, and anything self-starting past 3 seconds has a stop control or its own volume.</Verify>
|
|
91
|
+
<Verify rule="sound-off-switch">Sound the system cannot already silence has exactly one in-app switch, wired into every play site and persisted, and nothing duplicates a control the system already offers.</Verify>
|
|
92
|
+
|
|
93
|
+
<Device>Two of those halves are not answerable from a diff. Whether other apps' audio comes back at the level it was at, and whether the muted screen still carries every signal, are settled on a device with music playing and the volume down; what the code can show is the half stated before each of them, that no play site claims a session or focus and that every play site has a visible state change beside it.</Device>
|
|
94
|
+
|
|
95
|
+
</Check>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Launch surface
|
|
2
|
+
|
|
3
|
+
The one surface in a mobile app the app does not draw. The operating system puts it up the moment the app is launched, from the icon or from anywhere else, before a single line of the product has run, and takes it down when the first real frame is ready. It exists to hide that gap. It is not a title card, not a brand moment, and not a place to say anything.
|
|
4
|
+
|
|
5
|
+
It is also the highest frequency screen in the product. It appears on every cold and warm open for the life of the install, which is several times a day, for years. Every millisecond added to it is spent again on each of those opens.
|
|
6
|
+
|
|
7
|
+
One naming trap sits under the whole subject and it breaks rules written from either platform alone. iOS calls this the launch screen and forbids a logo on it, reserving the word splash for a branded graphic shown later, inside the app. Android calls its version the splash screen and puts the app icon on it by default. "Show the logo on the splash" is correct on one platform and a violation on the other.
|
|
8
|
+
|
|
9
|
+
The branded moment that happens once, inside the app, is `onboard-splash`. The wait that continues after this surface is gone is `state-loading`. Per stack keys, attributes and dismissal APIs are in `references/launch-surface.md`, for one lookup rather than a read through.
|
|
10
|
+
|
|
11
|
+
## <Rule id="splash-system" description="The system draws it, the app only configures it" />
|
|
12
|
+
|
|
13
|
+
There is no code running on this surface. On iOS it is a property list dictionary or an inert storyboard with no outlets, no actions and no custom classes. On Android 12 and up it is a set of theme attributes, and the compat library puts the same surface back on older releases from a single theme.
|
|
14
|
+
|
|
15
|
+
A screen the app draws is a different thing wearing the same name. A splash route in the navigator, a dedicated splash Activity, a `<Splash />` component with its own timer: each of those runs *after* launching has already finished, so it adds time to the open rather than covering it. The enter animation belongs to the system and cannot be replaced.
|
|
16
|
+
|
|
17
|
+
## <Rule id="splash-double" description="One surface between the icon and the first screen" />
|
|
18
|
+
|
|
19
|
+
Count them. The answer is one.
|
|
20
|
+
|
|
21
|
+
Two is what ships when a dedicated splash Activity survives into Android 12: it now plays after the system splash instead of being the only one. On the cross platform stacks the same shape appears as a splash component rendered on top of an already dismissed native surface.
|
|
22
|
+
|
|
23
|
+
Where a routing activity has to stay, hold the system surface across it rather than drawing a second one, so the same surface transfers to the destination.
|
|
24
|
+
|
|
25
|
+
## <Rule id="splash-contents" description="What may be on it is narrower than the design assumes" />
|
|
26
|
+
|
|
27
|
+
Two element sets, one per platform. One asset shipped to both is wrong on one of them.
|
|
28
|
+
|
|
29
|
+
- **iOS:** only what is already on the first real screen. A background color, and the empty navigation, tab or tool bars if that screen has them. No text of any kind, no logo, no illustration, unless it is a fixed part of the first screen. If the first screen is a solid color, the launch screen is that solid color and nothing else.
|
|
30
|
+
- **Android:** a single opaque window background color, the app icon as a vector, and optionally a circle behind it. One third of the icon foreground is masked, so anything drawn in the outer third is gone. The window background carries no transparency, and the centre icon is not guaranteed: the platform decides whether it appears unless the app opts in through `windowSplashScreenBehavior`. The branding image slot at the bottom stays empty. Every size the icon and that slot have to hit is in `references/launch-surface.md`.
|
|
31
|
+
|
|
32
|
+
Neither platform gets a tagline, a version string, a copyright line or a loading message.
|
|
33
|
+
|
|
34
|
+
## <Rule id="splash-match" evidence="device" description="It matches the frame that replaces it" />
|
|
35
|
+
|
|
36
|
+
The background is the first real screen's background token, at the same value, in the same appearance. Not the brand color, unless those are the same thing. Anything that differs shows up as a flash on every open, which is the exact opposite of what the surface is for.
|
|
37
|
+
|
|
38
|
+
Orientation follows what the app itself supports. An app that runs in both orientations launches in the one the device is already held in, and an app locked under `layout-orientation` launches in the orientation it is locked to.
|
|
39
|
+
|
|
40
|
+
On Android the background reaches the surface through the splash screen attributes. A launch theme that sets `android:windowBackground` is the pre Android 12 pattern, and from Android 12 the system discards that theme and draws its own default splash instead, so the color that was matched so carefully never appears at all.
|
|
41
|
+
|
|
42
|
+
## <Rule id="splash-no-progress" description="Nothing on it measures anything" />
|
|
43
|
+
|
|
44
|
+
No spinner, no progress bar, no percentage, no status line naming a step. The surface is a static image the system composites, with no access to the work happening behind it, so any number written on it was invented. A staged sequence of stages and percentages is a script driven by a timer, and it reports on nothing.
|
|
45
|
+
|
|
46
|
+
Once the app is stable enough to take the surface down, there is nothing left to spin about. A spinner that feels necessary here is the signal that the surface was held too long: `splash-hold`.
|
|
47
|
+
|
|
48
|
+
## <Rule id="splash-hold" description="Hold it only for work that has a bound" />
|
|
49
|
+
|
|
50
|
+
What legitimately holds it: a session token read from local storage, a theme or token set resolving, a font loading. Local, fast, and finite.
|
|
51
|
+
|
|
52
|
+
What does not: a network request. There is no bound on one, and on iOS a launch that never draws its first frame is killed by the watchdog, while on Android the surface times out and the wait simply becomes visible.
|
|
53
|
+
|
|
54
|
+
The budget the hold is spent from is `perf-cold-start`. The moment the work stops being local and bounded, take the surface down and let the real screen do the waiting with a placeholder, which is `state-loading`. Every hold mechanism takes a condition that has to become false, so write the failure path first: name what flips it when the read fails, returns nothing, or hangs.
|
|
55
|
+
|
|
56
|
+
## <Rule id="splash-no-floor" description="No artificial minimum" />
|
|
57
|
+
|
|
58
|
+
A timer that keeps the surface up for a fixed two seconds so a logo can be admired is time taken from the user on every open, several times a day, forever. Ready in 180 ms means shown for 180 ms.
|
|
59
|
+
|
|
60
|
+
Read the dismissal path. Any duration in it that is not the platform's own fade is a floor, and it is the single most common thing added to a launch surface that should not be there.
|
|
61
|
+
|
|
62
|
+
## <Rule id="splash-appearance" evidence="device" description="It cannot read a theme, translate, or scale" />
|
|
63
|
+
|
|
64
|
+
It resolves before the app runs, which decides three things rather than one.
|
|
65
|
+
|
|
66
|
+
- Dark and light are separate resources: an appearance aware color set on iOS, a night qualified resource on Android, the dark block in the config on the cross platform stacks. A light asset in front of a dark first screen flashes on every open in dark mode.
|
|
67
|
+
- No text is a localization rule, not a taste one. The string layer cannot reach this surface, so anything written on it ships in one language to everyone: `l10n-strings`.
|
|
68
|
+
- Nothing on it responds to the text size setting either, which is the second reason nothing on it is text: `type-scaling`.
|
|
69
|
+
|
|
70
|
+
## <Rule id="splash-animation" description="Movement on it extends the wait it exists to hide" />
|
|
71
|
+
|
|
72
|
+
The iOS launch screen is static and has no mechanism to be otherwise. On Android the centre icon may be an animated vector, under three limits: at most 166 ms of delay before it starts, which the platform bounds; at most 1000 ms of animation, which is this file's ceiling; and a loop rather than a longer one shot if the app is still not ready. The declared duration only reports the animation's length to a custom exit. It changes neither the animation nor how long the surface stays up.
|
|
73
|
+
|
|
74
|
+
Taking over the exit animation makes the app responsible for removing the surface, and a path that skips the removal leaves it on screen permanently. That exit is also the only movement here `motion-reduced` can reach: the enter animation belongs to the system, which answers the device's animation setting on its own, and no app code is running yet to read a flag.
|
|
75
|
+
|
|
76
|
+
## <Rule id="splash-daily" description="It belongs to the cold open, not the first one" />
|
|
77
|
+
|
|
78
|
+
Three kinds of open, and this surface belongs to two of them. Cold, with no process: it shows. Warm, process gone but the app in the recents list: it shows. Hot, coming back from the background with everything alive: it does not, and a build that draws its own splash on resume has turned a free return into a wait.
|
|
79
|
+
|
|
80
|
+
So nothing on it is a first run event. No welcome, no version notice, no changelog, no tip. Whatever appears here appears on the thousandth open as well.
|
|
81
|
+
|
|
82
|
+
## <Rule id="splash-entry" description="It hands over to whatever the launch was for" />
|
|
83
|
+
|
|
84
|
+
Most opens are not an icon tap. A notification, a deep link, a widget and a share sheet all start the same cold launch, and the surface comes down onto whatever the app draws first. Draw the home screen and push the target after it, and the user watches a second transition, which is precisely the transition this surface existed to hide.
|
|
85
|
+
|
|
86
|
+
So the destination is resolved before the first draw, from the intent, the launch URL or the payload, rather than in an effect that runs once a screen is already up. Every cold entry point the app declares is one of these. What sits underneath the destination is `nav-deeplink`, and the payload that names it is `notify-destination`.
|
|
87
|
+
|
|
88
|
+
## <Rule id="splash-first-frame" evidence="device" description="The frame after it is already the screen" />
|
|
89
|
+
|
|
90
|
+
The handoff is invisible only if what replaces the surface is the screen and not a stand in for it. That frame already carries the chrome (navigation bar, tab bar, header), sits inside the safe area (`layout-insets`), and shows the content as placeholders in its real shape (`state-loading`).
|
|
91
|
+
|
|
92
|
+
A blank screen, a centered spinner or a second background color after the launch surface means the surface covered nothing and the wait simply moved.
|
|
93
|
+
|
|
94
|
+
<Check>
|
|
95
|
+
|
|
96
|
+
<Verify rule="splash-system">The launch surface is configured through the platform mechanism, and no route, activity or component draws a second one on the launch path; a first run branded frame is `onboard-splash` and is not this surface.</Verify>
|
|
97
|
+
<Verify rule="splash-double">Exactly one surface sits between the launch and the first real screen.</Verify>
|
|
98
|
+
<Verify rule="splash-contents">The surface holds only the elements its platform allows, and the iOS and Android assets are not the same file.</Verify>
|
|
99
|
+
<Verify rule="splash-match">Its background is the first screen's background token at the same value, declared through the splash screen attributes rather than `android:windowBackground`, and its orientation matches what the app supports (`layout-orientation`).</Verify>
|
|
100
|
+
<Verify rule="splash-no-progress">Zero spinners, progress bars, percentages and status lines on it.</Verify>
|
|
101
|
+
<Verify rule="splash-hold">Everything the hold condition waits on is local and bounded, and each one has a named path that releases it on failure.</Verify>
|
|
102
|
+
<Verify rule="splash-no-floor">The dismissal path contains no duration other than the platform fade.</Verify>
|
|
103
|
+
<Verify rule="splash-appearance">A dark resource and a light resource both exist, and the surface carries zero strings.</Verify>
|
|
104
|
+
<Verify rule="splash-animation">Any icon animation stays within 1000 ms, starts within 166 ms, and loops rather than running longer, and a custom exit removes the surface on every path.</Verify>
|
|
105
|
+
<Verify rule="splash-daily">Nothing on the surface is first run content, and nothing draws it on a hot resume.</Verify>
|
|
106
|
+
<Verify rule="splash-entry">Every cold entry point the app declares resolves its destination before the first draw, so the frame after the surface is the target rather than the home screen.</Verify>
|
|
107
|
+
<Verify rule="splash-first-frame">The first frame after it carries the chrome, the insets and placeholder content, not a spinner.</Verify>
|
|
108
|
+
|
|
109
|
+
<Device>Check `splash-match`, `splash-appearance` and `splash-first-frame` by opening the app cold in both appearances and watching the handoff, rather than by reading the config. A mismatch of one step is invisible in a token table and obvious as a flash.</Device>
|
|
110
|
+
|
|
111
|
+
</Check>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# States
|
|
2
|
+
|
|
3
|
+
A screen has the one state its author looked at and five or six the user meets. Generated screens render as though the data is already there: the list is full, the request succeeded, the radio is on, and nothing was ever interrupted. That screen is finished for the screenshot and unfinished for the device.
|
|
4
|
+
|
|
5
|
+
The phone is where the gap costs most. The connection comes and goes inside a single session, in a lift, a tunnel, a train, a carrier handoff or a hotel portal that connects to nothing. The OS takes the app away for a call and can kill the process while it is gone. And there is one surface, so a region that fails has nowhere else to be.
|
|
6
|
+
|
|
7
|
+
Every state below needs its own words and its own way forward. A generic message is the same as no state at all, because it leaves the user with nothing to do next.
|
|
8
|
+
|
|
9
|
+
## <Rule id="state-set" description="Six states, named before the happy path is written" />
|
|
10
|
+
|
|
11
|
+
For any screen that loads, sends or stores anything, write the line it shows in each of six: **loading**, **empty**, **error**, **offline or stale**, **partial**, and **permission denied or read only**. Produce that list in `flow/spec.md`, where it is the screen brief's six state keys, before the layout exists. A screen changed without a brief produces it while framing in `flow/build.md` instead.
|
|
12
|
+
|
|
13
|
+
A screen that cannot enter a state answers it as not applicable and says which: no network call means no offline and no stale, a single indivisible payload means no partial, no protected capability means no permission state. A screen driving a camera, microphone, location, motion sensor or radio owes the five further states in `sense-states` on top of these. Every state the screen can reach is owed its line, and "not applicable" is a claim a reviewer can check, while a blank is not.
|
|
14
|
+
|
|
15
|
+
Half of the six are conditions the device imposes rather than paths the user chooses, which is why they never show up while writing the happy path and always show up in a hand. A state discovered afterwards arrives as a branch bolted onto a layout built for one case, and it shows.
|
|
16
|
+
|
|
17
|
+
## <Rule id="state-loading" description="A placeholder in the shape of the content, never a spinner over it" />
|
|
18
|
+
|
|
19
|
+
The first load draws the real layout with its content replaced by blocks: same row height, same position for the thing the user is waiting for, and roughly as many as fill the screen, since the length of the response is not known yet. `list-virtualise` owns the count inside a list. iOS has `.redacted(reason: .placeholder)` for exactly this. A placeholder whose geometry does not match shifts the layout at the moment the data lands, and in a narrow column that moves a target sideways under a thumb already coming down.
|
|
20
|
+
|
|
21
|
+
- A spinner is right in two places: inside the control that was tapped, which is `button-state`, and where the layout genuinely is not known yet. A spinner covering the whole screen is not a loading state, it is the absence of one.
|
|
22
|
+
- Under 300ms, show nothing. Once it is shown, hold it 500ms even if the data arrives sooner. The threshold alone produces the flicker it exists to prevent, on every response that lands a moment after it.
|
|
23
|
+
- Past ten seconds, indeterminate stops being honest. Name the stage or count what is done, and offer a way out of the wait, because the only other exit a phone user has is the force quit.
|
|
24
|
+
- The deadline itself is `net-timeout`. The half nobody writes is what happens when it is hit. Write that branch, or the timeout expires into the same animation and the wait has no end after all.
|
|
25
|
+
- Three loads look different: first load fills the screen with the placeholder, refresh keeps the current content and marks it as updating, and loading more is `list-end` and `list-refresh`.
|
|
26
|
+
- The transition is announced, not only drawn. Loading, loaded and failed are silent to VoiceOver and TalkBack unless the region is marked live: `liveRegion` in Compose semantics, `accessibilityLiveRegion` on Android views, an announcement notification on iOS.
|
|
27
|
+
|
|
28
|
+
Acknowledging the tap comes before all of this and belongs to `touch-feedback`.
|
|
29
|
+
|
|
30
|
+
## <Rule id="state-empty" description="Three different empties, three different sentences" />
|
|
31
|
+
|
|
32
|
+
- **Nothing yet.** First run, and the only one of the three that is a teaching screen: say what will live here and give the single action that puts the first item in it.
|
|
33
|
+
- **Nothing matched.** A search or a filter excluded everything. The way out is clearing it, so the filter stays visible and the action offered is removing it, not creating something new.
|
|
34
|
+
- **Genuinely zero.** No unread mail, nothing owed, nothing overdue. This is usually good news and should read like it, with no call to action invented to fill the space.
|
|
35
|
+
|
|
36
|
+
Printing "No results" for all three is the tell. The first leaves a new user with no idea what the app is for, the second hides the filter that is doing the excluding, and the third turns success into a reprimand.
|
|
37
|
+
|
|
38
|
+
## <Rule id="state-error" description="Say what failed, and do not guess at why" />
|
|
39
|
+
|
|
40
|
+
There are four failure classes and they are not interchangeable: the radio has nothing, the request ran out of time, the server answered with a fault, or the server understood and refused. Sort them by the move they leave the user, and write one sentence per move: nothing connected sends the user to the connection, timeout and server fault both land on retry and may share a sentence, and a refusal needs something changed or somebody asked, which retry will never fix. One shared sentence for all four leaves the user with no move to make and, more often, with the wrong one.
|
|
41
|
+
|
|
42
|
+
- A cause you did not verify is a false instruction. Blaming the network for a fault the server reported sends someone to power-cycle a router that is fine.
|
|
43
|
+
- Never dress a failure as an empty. "No messages" and "could not load messages" are opposite claims, and code that returns an empty list on failure makes them identical on screen.
|
|
44
|
+
- The message lands where the failure is: at the field for a field, in the region for a region, on the screen for the screen. `heuristics/forms.md` owns field-level validation. A modal alert for something that could be said inline charges the user an interruption, and whether the message reaches a screen reader at all is `fb-reach`. How it is worded is `copy-error` and `copy-jargon`.
|
|
45
|
+
|
|
46
|
+
## <Rule id="state-retry" description="A retry that loses what was typed is a second failure" />
|
|
47
|
+
|
|
48
|
+
- The manual retry is always present and always visible once something failed. Automatic retry does not replace it.
|
|
49
|
+
- Retrying returns to the same state: the input, the selection, the scroll offset, the sheet that was open. On a phone the typed content is the expensive part, thumbed in one character at a time, and it is never recoverable from anywhere else.
|
|
50
|
+
- Retry only what failed, not the whole screen.
|
|
51
|
+
- Automatic retry backs off and then stops, and it fires on the platform's reconnect signal (`NWPathMonitor`, `ConnectivityManager.NetworkCallback`) rather than on a fixed timer. A loop on an interval spends battery the user will attribute to this app. That signal is allowed to drive retry and prefetch. It is never allowed to drive the message, which is `state-offline`.
|
|
52
|
+
|
|
53
|
+
## <Rule id="state-offline" evidence="device" description="Four network states, not two" />
|
|
54
|
+
|
|
55
|
+
1. **Online and fast.** The one everything was built and demonstrated in.
|
|
56
|
+
2. **Online and slow.** The most common and the least designed. It has no branch of its own, so what carries it is the ceiling in `state-loading` and whatever renders when that ceiling is hit.
|
|
57
|
+
3. **Offline with a cache.** The app still works, in a reduced form, and says so.
|
|
58
|
+
4. **Offline with nothing cached.** The only one that earns a full-screen message, and even that one names what is still possible.
|
|
59
|
+
|
|
60
|
+
Tell the user when what they can do changes, not when the radio changed. The message waits on a failed request and never on the connectivity callback, because a path reporting satisfied means a radio came up, not that a server answered: that is the hotel portal at the top of this file. Most drops are over in seconds, and a bar that appears for each one is a bar the user stops reading by the second day. Which parts of the app keep working without a network is a decision written down, not whatever happens to be in memory.
|
|
61
|
+
|
|
62
|
+
Across all four, the OS may report a constraint the user asked for: Low Data Mode and Data Saver, whose flags `net-metered` reads, and Low Power Mode (`ProcessInfo.isLowPowerModeEnabled`, `PowerManager.isPowerSaveMode()`). Where one is set, autoplay stops, prefetch stops, images come at the smaller size, and the screen says what it is holding back with a way to ask for it anyway. A screen that never reads the flag spends a metered radio the user explicitly asked it not to spend.
|
|
63
|
+
|
|
64
|
+
## <Rule id="state-stale" evidence="device" description="Cached content carries its age" />
|
|
65
|
+
|
|
66
|
+
Show the content and say when it was fetched. "Updated 2 hours ago" beats a spinner, and it beats a stale number presented as current by more than that.
|
|
67
|
+
|
|
68
|
+
How fast a screen goes stale is per screen: a price, a balance or an arrival time is wrong within seconds, an article is not. Pick the threshold, and mark staleness with a word rather than a dimmed color alone, which is `color-not-alone`.
|
|
69
|
+
|
|
70
|
+
## <Rule id="state-queued" evidence="device" description="Anything the server has not confirmed reads as pending, not as done" />
|
|
71
|
+
|
|
72
|
+
Optimistic updates are right on a phone, because waiting for a round trip on a slow radio makes the whole app feel broken. The optimism has to be reversible in the interface as well as in the data.
|
|
73
|
+
|
|
74
|
+
- Draw the item as pending. When it fails, roll it back where the user is looking, keep the content, and offer the fix there. An item that vanishes into a sync error is the worst outcome on this page.
|
|
75
|
+
- Pending is a property of the item, not of a screen somewhere else: the item says it is waiting and offers a way out of it in place. An aggregate queue surface is owed only where more than one action can be outstanding at once.
|
|
76
|
+
- Confirmation lands in something that stays. When the server accepts, the item stops being pending and names what changed, in a form that survives the user looking away. A screen that goes quiet after a submit has confirmed nothing, and neither has a toast that dismisses itself, so nothing irreversible or financial is confirmed by one alone.
|
|
77
|
+
- A queued action survives a force quit, or it was never queued.
|
|
78
|
+
- Destructive actions do not queue silently. A delete that syncs an hour later has outlived its undo, and undo is the mechanism `touch-destructive` relies on.
|
|
79
|
+
|
|
80
|
+
## <Rule id="state-partial" description="Some of it arrived, so show that" />
|
|
81
|
+
|
|
82
|
+
One region failing does not take the screen down. Render what loaded, mark the region that did not, and let that region retry by itself.
|
|
83
|
+
|
|
84
|
+
The phone shows one thing at a time, so replacing the whole screen because an avatar, a price chart or a recommendation strip failed costs the user everything that had already arrived, and the thing they came for is usually in the part that worked.
|
|
85
|
+
|
|
86
|
+
## <Rule id="state-permission" description="Denied is a state with a way forward, never a dead end" />
|
|
87
|
+
|
|
88
|
+
The ask itself, the reason shown before it and how many chances are left belong to `perm-rationale` and `perm-answers`. What this rule owns is the screen the user is left holding once the answer is no.
|
|
89
|
+
|
|
90
|
+
- Denied leaves a working app with less in it: manual entry instead of the camera, a typed address instead of location, and the system picker that needs no permission at all where one exists.
|
|
91
|
+
- The recovery path is the system Settings page, deep linked from the app, next to the sentence saying what to turn on. A permission is not an app preference, which is what `set-system-owned` rules out.
|
|
92
|
+
- Read only belongs here too: viewing allowed and editing not. `button-state` starts by leaving the control live and answering on tap with what is missing; where it genuinely has to be disabled, that rule's fallback applies and the reason sits beside it rather than being left to be inferred.
|
|
93
|
+
- No screen is a wall that cannot be left without granting.
|
|
94
|
+
|
|
95
|
+
## <Rule id="state-interrupt" evidence="device" description="The phone takes the app away mid task" />
|
|
96
|
+
|
|
97
|
+
A call, a notification pulled down and an app switch stop the screen without destroying it, and the OS carries what is in memory through all three for free. Two events do not, and they are the ones this rule is about: a configuration change (rotation, multi-window, and the text size and theme changes `type-scaling` sends you to go and set), and the system killing the process while the app is in the background.
|
|
98
|
+
|
|
99
|
+
- Hold the unsent input and the current step where the system can save them: `rememberSaveable` and `SavedStateHandle` on Android, `@SceneStorage` and the `scenePhase` transitions on iOS, `RestorationMixin` in Flutter. A configuration change loses anything held only in the view; process death loses anything held only in memory.
|
|
100
|
+
- Where the user lands on return is `nav-restore`, which owns the restored place: destination, stack, selection and filters, with the position inside a collection under `scroll-restore`. The values in a form, the focused field and the abandoned draft are `form-persist`. What is left here is the lifecycle: saving where the system says to save, and saying so when something did not make it back.
|
|
101
|
+
- If something was lost, say so. A form silently emptied claims nothing happened, and the user finds out by reading it back.
|
|
102
|
+
- Long work resumes rather than restarting from zero, and a cancelled screen cancels its own requests and timers on the way out.
|
|
103
|
+
|
|
104
|
+
<Check>
|
|
105
|
+
|
|
106
|
+
<Verify rule="state-set">Every state the screen can enter has a line in the code, and each one it cannot is answered as not applicable with the reason.</Verify>
|
|
107
|
+
<Verify rule="state-loading">First load draws a placeholder matching the final layout, no spinner covers the screen, nothing appears under 300ms or leaves within 500ms of appearing, ten seconds names a stage and offers an exit, the timeout has a written branch behind it, and the transitions are announced.</Verify>
|
|
108
|
+
<Verify rule="state-empty">The three empties render three different sentences, with an action on the first two and none invented for the third.</Verify>
|
|
109
|
+
<Verify rule="state-error">There is a message per next action, with connection and refusal never sharing one, no cause is asserted that was not verified, no failure renders as an empty, no message carries a status code, and the message reaches a screen reader.</Verify>
|
|
110
|
+
<Verify rule="state-retry">A visible manual retry exists, retrying restores the input, the selection and the scroll position, and automatic retry backs off, stops, and never writes the message.</Verify>
|
|
111
|
+
<Verify rule="state-offline">Offline is handled as slow, cached and uncached rather than as a boolean, the message is triggered by a failed request rather than by the radio, and the constrained, metered and power-saving flags are read where media and prefetch run.</Verify>
|
|
112
|
+
<Verify rule="state-stale">Content that decays (a price, a balance, an arrival time, a count, availability) carries its age against a threshold that exists as a named constant; screens outside that set answer not applicable.</Verify>
|
|
113
|
+
<Verify rule="state-queued">Unconfirmed actions render as pending with a way out on the item itself, a failed one rolls back on screen with the content kept, and a confirmed one names what changed in something that does not dismiss itself.</Verify>
|
|
114
|
+
<Verify rule="state-partial">A failed region marks itself and retries alone, leaving the rest of the screen.</Verify>
|
|
115
|
+
<Verify rule="state-permission">Denial degrades to a working screen, and the route back is a deep link into the system Settings page.</Verify>
|
|
116
|
+
<Verify rule="state-interrupt">In-progress work survives a configuration change and a system-initiated process death.</Verify>
|
|
117
|
+
|
|
118
|
+
<Device>`state-offline` and `state-stale` are answered on a device with the network actually off. `state-interrupt` is answered against a rotation and a kill the system would have made itself, using Don't keep activities or `adb shell am kill`, never a swipe out of the recents list: that gesture is the user asking for a clean start, and nothing is meant to come back from it. `state-queued` is the one that keeps the recents-swipe test, because persisted work is exactly what has to outlive a dismissal. Nothing in the file proves any of them, and a state that was never entered is unrun rather than passing.</Device>
|
|
119
|
+
|
|
120
|
+
</Check>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Touch
|
|
2
|
+
|
|
3
|
+
A finger is not a cursor, and every rule here follows from three differences.
|
|
4
|
+
|
|
5
|
+
It is blunt: the contact patch is an oval of 16 to 20mm for a fingertip and more for a thumb pad, so the input is a smudge and not a point. It has no hover: there is no state between not touching and committed, so anything a desktop design revealed on the way to a click has nowhere to live. And it is opaque: the finger covers the thing it presses along with a ring around it, so feedback drawn under the contact point did not happen.
|
|
6
|
+
|
|
7
|
+
Add the fourth condition that belongs to the device rather than the hand: the grip changes constantly, often within a single task, so nothing can assume the phone is being held the way it was a moment ago.
|
|
8
|
+
|
|
9
|
+
## <Rule id="touch-floor" evidence="device" description="The target is the hit area, never the drawing" />
|
|
10
|
+
|
|
11
|
+
44pt on iOS, 48dp on Android, for everything a user can activate. Both land under a centimetre of glass, which is already smaller than the finger arriving at it. That is why they are floors and not goals.
|
|
12
|
+
|
|
13
|
+
The drawn control and the target are two different objects. A 24dp icon centred in a 48dp target is right; growing the icon to fill the target and shrinking the target to hug the icon are both wrong. Reach for the mechanism the stack already has:
|
|
14
|
+
|
|
15
|
+
- SwiftUI: a minimum frame plus `.contentShape()`, so the padding is tappable and not just the glyph.
|
|
16
|
+
- Compose: `Modifier.minimumInteractiveComponentSize()`, which Material components already apply.
|
|
17
|
+
- Flutter: `MaterialTapTargetSize.padded`, or a sized box around the gesture detector with an opaque hit test behaviour.
|
|
18
|
+
- React Native: `hitSlop` on the pressable.
|
|
19
|
+
- Mobile web: padding on the control, never margin, since margin does not take taps.
|
|
20
|
+
|
|
21
|
+
Two consequences that get missed. A list row is a target: full width, at least 48dp tall, and the whole row responds rather than the label inside it. And a target that is marginal at the top of the screen is worse at the bottom, where the thumb arrives at a shallow angle and the contact oval stretches.
|
|
22
|
+
|
|
23
|
+
The web accessibility floor is lower than the platform one and does not replace it. WCAG 2.2 asks for 24 by 24 CSS pixels at AA, with exceptions, and 44 by 44 only at AAA. On a phone the platform number wins, every time.
|
|
24
|
+
|
|
25
|
+
## <Rule id="touch-spacing" description="Two correct targets can still produce a wrong tap" />
|
|
26
|
+
|
|
27
|
+
Leave at least 8dp of dead space between neighbouring targets. Adjacent controls that each meet the floor still collect mis-taps, because the contact oval straddles the boundary between them and the system awards the tap to whichever one owns the centre.
|
|
28
|
+
|
|
29
|
+
Watch the places it concentrates: a row of icon buttons in a toolbar, a line of chips, a close control sitting beside another control, and two swipe actions revealed on the same row. When a target has to be smaller than the floor, the distance to its neighbours has to grow to compensate, which is the same trade WCAG makes with its spacing exception.
|
|
30
|
+
|
|
31
|
+
## <Rule id="touch-reach" description="The bottom third is the only easy part of the screen" />
|
|
32
|
+
|
|
33
|
+
Roughly half of phone use is one-handed, and about two thirds of that is a right thumb. A layout that only works for one hand fails a large minority of users, so check the mirror before shipping.
|
|
34
|
+
|
|
35
|
+
Three regions, mirrored for a left hand:
|
|
36
|
+
|
|
37
|
+
- **Easy:** the bottom third, leaning toward the side opposite the thumb. The primary action, the primary navigation and whatever gets used most.
|
|
38
|
+
- **Stretch:** mid screen and across to the far side. Secondary actions.
|
|
39
|
+
- **Hard:** the top corners, worst at the diagonal from the holding thumb. Search, settings, anything used rarely.
|
|
40
|
+
|
|
41
|
+
Past about six inches of screen, the top is not reachable one-handed at all. Anything essential up there needs a second path: a bottom sheet, a pull-down, a duplicate control in reach. A bottom tab bar is not a stylistic preference, it is where the thumb is.
|
|
42
|
+
|
|
43
|
+
Since the grip changes mid-task, the design has to survive the change rather than assume a posture. A control that only works while the phone is cradled in two hands is a control that fails while walking.
|
|
44
|
+
|
|
45
|
+
## <Rule id="touch-destructive" description="Distance is the safety mechanism" />
|
|
46
|
+
|
|
47
|
+
Delete, unsubscribe, cancel the order and send the payment do not belong in the easy region, and never beside something used often. The whole point of the hard region is that reaching it takes a deliberate second movement.
|
|
48
|
+
|
|
49
|
+
Prefer undo over a confirmation dialog. Undo is faster for the person who meant it, recoverable for the person who did not, and it does not train users to dismiss dialogs without reading them. When a dialog is the right vehicle instead is `fb-confirm-test`.
|
|
50
|
+
|
|
51
|
+
## <Rule id="touch-feedback" description="If it happened under the finger, it did not happen" />
|
|
52
|
+
|
|
53
|
+
Every touch gets an acknowledgement inside about 100ms, before the work behind it finishes. Latency between contact and response is the loudest quality signal a phone app has.
|
|
54
|
+
|
|
55
|
+
- The pressed state is mandatory, and it has to be visible with a finger parked over the middle of the control. Change the whole surface, not a small area at the centre.
|
|
56
|
+
- Use the platform's own idiom: a ripple originating at the contact point on Android, a highlight or dim on iOS. Invented feedback that neither platform uses reads as a malfunction.
|
|
57
|
+
- Confirmation of a result belongs above the touch point or in another region entirely, never underneath it.
|
|
58
|
+
- Disable the control while its action runs, or a second tap fires the same request twice.
|
|
59
|
+
- Haptics are a vocabulary, not decoration. One meaning per pattern, distinct signals for success and failure, nothing on scroll. Constant haptic feedback is worse than none.
|
|
60
|
+
|
|
61
|
+
The touch state set is its own thing: rest, pressed, long press where the element has one, dragging where it moves, plus disabled, loading, error and empty. `hover` does not exist here, and `focus` belongs to a hardware keyboard or switch control rather than to a finger. Nothing may hide behind either.
|
|
62
|
+
|
|
63
|
+
## <Rule id="touch-gestures" description="The edges belong to the operating system" />
|
|
64
|
+
|
|
65
|
+
A gesture that starts at a screen edge is competing with the OS and will lose. On iOS that is back from the left edge, the shade from the top left, the control panel from the top right, and home from the bottom. On Android it is back from either side, home from the bottom, and the shade from the top. Where a drag genuinely has to begin at an edge, claim the strip explicitly through the system's gesture exclusion mechanism and keep it as small as possible.
|
|
66
|
+
|
|
67
|
+
- Back works on every screen, sheets included. On Android that means the modern callback rather than an override of the old back method, keeping the predictive animation the system draws while the finger is still down and interpolating any custom transition from its progress. Consuming back without leaving a way out is the most common navigation defect in generated code.
|
|
68
|
+
- Use the standard gesture for the standard meaning. Repurposing pull to refresh, long press or edge swipe costs the user the muscle memory built by every other app on the device.
|
|
69
|
+
- Every gesture needs a visible equivalent. A swipe-only action does not exist for a new user, for a screen reader, or for anyone with a motor impairment.
|
|
70
|
+
- A gesture nobody discovers is a feature nobody has. Leave a partial reveal at rest, a grabber, or a one-time hint.
|
|
71
|
+
- Give a swipe region real height, and keep two swipeable things from overlapping where their gestures begin.
|
|
72
|
+
|
|
73
|
+
Content runs underneath the system bars on current Android targets, so a control pinned to the bottom edge without inset handling ends up beneath the gesture strip: visible, and not tappable. The inset geometry itself is `layout-insets`.
|
|
74
|
+
|
|
75
|
+
## <Rule id="touch-keyboard" description="Half the screen, arriving without warning" />
|
|
76
|
+
|
|
77
|
+
The keyboard is not an overlay that happens to the screen, it is part of the screen for as long as someone is typing, and it deserves the same design attention as anything else that takes up that much room.
|
|
78
|
+
|
|
79
|
+
- The focused field stays visible when it opens. Test the last field of a form, not the first: the first one always passes.
|
|
80
|
+
- Which keyboard opens, and what its return key does, is decided per field rather than globally. `form-input` covers that in full.
|
|
81
|
+
- A fixed bottom action either rises with the keyboard or is reachable above it. Leaving it underneath means the user types and then cannot submit.
|
|
82
|
+
|
|
83
|
+
<Check>
|
|
84
|
+
|
|
85
|
+
<Verify rule="touch-floor">Every interactive element measures at least 44pt or 48dp in its hit area, and list rows are tappable across their full width.</Verify>
|
|
86
|
+
<Verify rule="touch-spacing">Adjacent targets are separated by at least 8dp of dead space.</Verify>
|
|
87
|
+
<Verify rule="touch-reach">Primary action and primary navigation sit in the bottom third, and the layout was checked mirrored for a left thumb.</Verify>
|
|
88
|
+
<Verify rule="touch-destructive">Destructive actions sit outside the easy region, and the vehicle carrying the confirmation is the one `fb-confirm-test` selects.</Verify>
|
|
89
|
+
<Verify rule="touch-feedback">Pressed state is visible under a covering finger, feedback lands within about 100ms outside the occluded area, and nothing depends on hover.</Verify>
|
|
90
|
+
<Verify rule="touch-gestures">Back works on every screen including sheets, no custom gesture starts in a system edge zone, and every gesture has a visible alternative.</Verify>
|
|
91
|
+
<Verify rule="touch-keyboard">With the keyboard open, the focused field is visible and the primary action is reachable.</Verify>
|
|
92
|
+
|
|
93
|
+
<Device>Hit areas are measured, not estimated: read the bounds in the inspector or the layout tree. A target that looks big enough next to a 24dp icon is exactly the one that is not.</Device>
|
|
94
|
+
|
|
95
|
+
</Check>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Typography
|
|
2
|
+
|
|
3
|
+
Type on a phone gets read close up, in a hand that moves, in light nobody chose, at a size the reader picked and you will never see. Two of those are OS settings the app is expected to obey: the text size slider, which runs past 200%, and the system preference for heavier text. Generated type tends to fail one of them long before anyone argues about taste.
|
|
4
|
+
|
|
5
|
+
`DESIGN.md` holds the families and the ramp. This file is how they land in code, and what has to hold before the screen ships. Per-role sizes, weights and line heights sit in `references/type-scales.md`, to be opened for one lookup rather than read through.
|
|
6
|
+
|
|
7
|
+
## <Rule id="type-scale" description="The ramp already exists on both platforms" />
|
|
8
|
+
|
|
9
|
+
Each platform publishes a complete role scale that is optically tuned, wired to the size setting, and understood by the screen reader. Pick the role that matches the job and adjust from there. Inventing a parallel ramp discards those three properties and returns nothing.
|
|
10
|
+
|
|
11
|
+
- Text reaches the screen through a style: `MaterialTheme.typography.bodyLarge`, `.font(.body)`, `Theme.of(context).textTheme`. A size typed into a component is a defect even when the value happens to be right.
|
|
12
|
+
- The unit belongs to the platform as well. Android text is `sp`, and `dp` freezes it. On iOS, `.system(size:)` with no text style behind it is the identical failure in another language.
|
|
13
|
+
- Body lands at 16sp or 17pt. Eleven is the floor, and it belongs to text at the margin of meaning, never to something a user has to read in order to act.
|
|
14
|
+
- When the readers are older or the app lives outdoors, move the whole ramp up rather than granting one role an exception.
|
|
15
|
+
|
|
16
|
+
## <Rule id="type-roles" description="About four jobs per screen" />
|
|
17
|
+
|
|
18
|
+
Material publishes fifteen roles and iOS eleven styles. A single screen usually needs four of them: what this screen is, what it says, what a control is called, and what qualifies the rest. Name each one after its job.
|
|
19
|
+
|
|
20
|
+
A row that shows up on two screens carries the same style in both, or the product reads as though two teams built it without speaking. Seven distinct sizes on one screen is not hierarchy, it is seven unmade decisions.
|
|
21
|
+
|
|
22
|
+
Line height is a property of the role, not of the paragraph: near 1.2 where the type is display sized, 1.4 to 1.5 for body. The ratio has to widen as the type gets smaller, which is why one global multiplier comes out wrong at both ends.
|
|
23
|
+
|
|
24
|
+
## <Rule id="type-weight" description="Weight is structure, and the user has a say in it" />
|
|
25
|
+
|
|
26
|
+
Both systems let someone ask for heavier text: Bold Text on iOS, `fontWeightAdjustment` from API 31 on Android. Styles that come from the theme respond by themselves. A weight typed into a component (`weight: .semibold`, `FontWeight.Bold`) does not, so the preference gets dropped in silence and nothing in the build says a word about it.
|
|
27
|
+
|
|
28
|
+
What the two scales actually do with weight runs against the web instinct:
|
|
29
|
+
|
|
30
|
+
- Large text is not bold text. M3 holds titles at 400 up to 22sp, and iOS sets every title Regular. Size is the signal.
|
|
31
|
+
- 500 and semibold are reserved for the small roles that name controls: buttons, tabs, chips, and iOS `headline`.
|
|
32
|
+
- `headline` and `body` on iOS are both 17pt, and weight is the entire difference between them. Nothing demonstrates better that weight on its own can hold a hierarchy.
|
|
33
|
+
|
|
34
|
+
Two constraints on the ramp itself. Nothing below 400: fine strokes fragment at small sizes, wash out in direct sun, and pull down the effective contrast even where the color ratio passes. And when weight marks a step, jump a grade, because 400 beside 500 looks like a typo while 400 beside 600 or 700 looks intended. A static family only holds the cuts that were drawn for it, so asking for a weight it lacks returns either the nearest cut or a synthetic bold, and the synthetic one always loses.
|
|
35
|
+
|
|
36
|
+
Weight is relative, which means it gets judged across the whole screen and never element by element. It reads as emphasis only while most of the screen is not carrying it. A screen where the title, every row label and every price all sit at 600 has no emphasis anywhere on it: nothing was promoted, the page just got heavier and harder to read, and the reader now has to find the important thing by reading instead of by looking. The regular cut is the ground the screen is written on, and the heavier cut is spent on the few things that have to win.
|
|
37
|
+
|
|
38
|
+
This is countable. Take the distinct text elements on the screen and look at how the weights fall across them. Most of them at 400 with two or three above it is a distributed hierarchy. Most of them above 400 is a flat screen wearing a heavy coat, and the fix is to take weight away rather than to add more of it somewhere else.
|
|
39
|
+
|
|
40
|
+
Build the hierarchy from weight and space before size. The reader has a slider for size and none for the others: at 200% a 24pt title and 17pt body are both large, and the distance that structured the screen at 100% is doing much less of the work.
|
|
41
|
+
|
|
42
|
+
## <Rule id="type-face" description="A typeface nobody chose" />
|
|
43
|
+
|
|
44
|
+
Inter turns up on its own for the same reason violet does: it is the most common interface face in the training data. Inter is a fine typeface, which is exactly why finding it there says nothing about this product.
|
|
45
|
+
|
|
46
|
+
- Shipping the system face is a legitimate decision. SF Pro and Roboto are tuned for their own rendering stack, carry every weight, cover the scripts the product ships in, and load for free. Picking one deliberately is an answer; arriving at Inter by default is not.
|
|
47
|
+
- A brand face gets themed into the scale, taking display and headline where the strings are short, while the system face keeps body, labels and controls. Applying a face component by component throws away the size setting and what the screen reader expects, in one move.
|
|
48
|
+
- Two families is the ceiling, and the second earns its place by doing something the first cannot.
|
|
49
|
+
- A custom face ships only when it scales with the user's setting, carries every weight the roles call for, covers the languages, and has been subset and paid for out of the launch budget. One usable weight leaves size doing all the structural work by itself.
|
|
50
|
+
|
|
51
|
+
What each platform and stack can reach without shipping a file, and what the Google Fonts route costs on each, is in `references/fonts.json`.
|
|
52
|
+
|
|
53
|
+
## <Rule id="type-measure" description="A narrow column, because the device is narrow" />
|
|
54
|
+
|
|
55
|
+
Body copy wants 40 to 60 characters per line, and the lower half of that band is normal here. The 65 to 75 everyone quotes was measured on a wide page at desk distance. A phone sits about a foot away with a column a few inches wide, and the return sweep to the next line is short to match.
|
|
56
|
+
|
|
57
|
+
The usual failure is a paragraph running edge to edge on a large phone held sideways. Cap the column instead.
|
|
58
|
+
|
|
59
|
+
## <Rule id="type-scaling" evidence="device" description="Render it at maximum before calling it done" />
|
|
60
|
+
|
|
61
|
+
No other check in this file surfaces as many genuine defects. Turn the text size to the platform's largest accessibility step, walk every screen, and look for:
|
|
62
|
+
|
|
63
|
+
- a container with a fixed height that its own content has outgrown;
|
|
64
|
+
- a control row that needed to become a column and stayed a row;
|
|
65
|
+
- text drawn on top of a neighbour instead of displacing it;
|
|
66
|
+
- a one-line label now wrapping to two, pushing the primary action under the fold or beneath a fixed bottom bar;
|
|
67
|
+
- a string cut into ambiguity: a button reading "Cont..." has lost its meaning, not merely some letters.
|
|
68
|
+
|
|
69
|
+
Layouts reflow; they do not truncate. A screen that only holds together at 100% has failed exactly the people who moved the setting.
|
|
70
|
+
|
|
71
|
+
## <Rule id="type-strings" description="The text in the layout is not the text you typed" />
|
|
72
|
+
|
|
73
|
+
Real strings come from translators, from an API and from users, and they run longer and stranger than the ones in a mockup.
|
|
74
|
+
|
|
75
|
+
- Size the labels against the longest language the product ships in, not against English. Compounds in German, Finnish and Portuguese set the minimum width.
|
|
76
|
+
- Numbers stacked in a column need tabular figures, or the alignment shifts every time a value updates.
|
|
77
|
+
- Names, titles and anything user-authored need a line limit and a truncation point chosen per role, settled in the design rather than discovered in production.
|
|
78
|
+
- All caps is for a short label at most. Applied to body text it removes the word shapes people actually read by.
|
|
79
|
+
|
|
80
|
+
## <Rule id="type-dark" evidence="device" description="Light on dark reads lighter than it measures" />
|
|
81
|
+
|
|
82
|
+
Pale text on a dark ground bleeds into it, so an identical face at an identical weight looks thinner in dark theme than in light. Low brightness on an OLED panel exaggerates it.
|
|
83
|
+
|
|
84
|
+
Where a screen carries real reading, compensate in the dark theme only: one step of weight if the face has it, a little more tracking, a little more line height. None of this shows up in the token values, so it gets judged on a rendered screen or not at all.
|
|
85
|
+
|
|
86
|
+
<Check>
|
|
87
|
+
|
|
88
|
+
<Verify rule="type-scale">Text styles trace back to the platform scale or to a written-down extension of it, no component carries a literal size, and Android text is in `sp`.</Verify>
|
|
89
|
+
<Verify rule="type-roles">The screen works from about four named roles and carries no more than five distinct sizes, and a role that repeats across screens is identical every time.</Verify>
|
|
90
|
+
<Verify rule="type-weight">Nothing below 400, no weight hardcoded outside a theme style, and every weight step jumps a grade. Weight is distributed across the screen, with most text on the regular cut and the heavy cut spent on a few elements.</Verify>
|
|
91
|
+
<Verify rule="type-face">The typeface choice can be stated as a reason, a brand face stays in the display roles, and any custom face scales.</Verify>
|
|
92
|
+
<Verify rule="type-measure">Body copy runs 40 to 60 characters per line.</Verify>
|
|
93
|
+
<Verify rule="type-scaling">The screen was rendered at the largest accessibility step, and nothing clips, overlaps or truncates into ambiguity.</Verify>
|
|
94
|
+
<Verify rule="type-strings">The longest localized string fits, numeric columns are tabular, and every truncation point was chosen deliberately.</Verify>
|
|
95
|
+
<Verify rule="type-dark">Dark theme text was judged on a rendered screen.</Verify>
|
|
96
|
+
|
|
97
|
+
<Device>`type-scaling` and `type-dark` are answered with a rendered screen or they are not answered at all. Everything else gets a file and a line number.</Device>
|
|
98
|
+
|
|
99
|
+
</Check>
|