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,125 @@
|
|
|
1
|
+
# Layout
|
|
2
|
+
|
|
3
|
+
A phone hands the app one narrow column, and the operating system takes part of it back before the first widget renders. Bars at both ends, a cutout, a gesture strip, a keyboard that arrives unannounced, and text at whatever size the reader chose. Layout here is the composition of what is left over, and the part the system reserves is a measurement read at runtime, not a margin guessed at the end.
|
|
4
|
+
|
|
5
|
+
Values already written into `DESIGN.md`, the spacing scale and the screen margin among them, are settled. This file is how a screen is built inside them, and what to use for the ones the brief left unset.
|
|
6
|
+
|
|
7
|
+
Neighbouring rules own the parts that are not geometry: thumb zones are `touch-reach`, the system gesture strips are `touch-gestures`, the keyboard is `touch-keyboard`, and long collections belong to `heuristics/lists.md`.
|
|
8
|
+
|
|
9
|
+
## <Rule id="layout-insets" evidence="device" description="The safe area is geometry, not padding added at the end" />
|
|
10
|
+
|
|
11
|
+
Read the inset at runtime and lay the screen out inside it. A constant copied off one device (34, 44, 48) is right on that phone and wrong on the next, and it is wrong on the same phone the moment a call banner or an expanded status bar changes the number.
|
|
12
|
+
|
|
13
|
+
- SwiftUI respects the safe area already. `.ignoresSafeArea()` belongs to a background fill or an image and never to text or a control, and a pinned bar takes `safeAreaInset(edge:)` so the content behind it scrolls clear.
|
|
14
|
+
- Compose: `enableEdgeToEdge()` with `Scaffold`, which insets its own bars, plus `WindowInsets.safeDrawing` on anything drawn outside it.
|
|
15
|
+
- Flutter: `MediaQuery.paddingOf(context)`, or `SafeArea` with the edges named.
|
|
16
|
+
- React Native: the safe area context hook, read per render, rather than a stored constant.
|
|
17
|
+
- Mobile web inside a shell: `viewport-fit=cover` plus `env(safe-area-inset-*)`, which report zero until that meta tag is set.
|
|
18
|
+
|
|
19
|
+
On a current Android target there is no opt out, since the manifest flag that used to disable edge to edge is ignored, so the inset is a runtime measurement on every build. Nor is it one number: a three-button device reports a taller bottom inset than the same phone using gestures. What a pinned control does when that measurement is skipped is `touch-gestures`.
|
|
20
|
+
|
|
21
|
+
A pinned bar carries the inset inside itself: its own height for the controls, plus the bottom inset underneath them, in one component. Padding the bar from outside leaves a strip of the wrong background color under it, and a sheet or a dialog opened over the screen owes the same treatment, since it becomes the bottom of the screen while it is up.
|
|
22
|
+
|
|
23
|
+
Four edges, not one. The top holds the status bar and the cutout or Dynamic Island. The bottom holds the home indicator or the navigation bar. The side insets are zero in portrait and stop being zero once the phone is turned, where they are applied to both sides and the cutout is on one of them. Scrolling content may pass under any of them and often should, because the content ending in a hard line above the bar wastes the screen. Anything read or tapped may not, and that includes the last row of a list, the buttons inside a sheet, and a snackbar.
|
|
24
|
+
|
|
25
|
+
## <Rule id="layout-grid" description="One spacing scale, and every gap sits on it" />
|
|
26
|
+
|
|
27
|
+
Every value is a multiple of 4, and a multiple of 8 once it is above 16: 4, 8, 12, 16, then 24, 32, 40, 48, 56, 64. A 22 or a 35 landing between those steps is not a fine adjustment, it is a value that came from nudging one component until it looked right, and the next person has nothing to reuse.
|
|
28
|
+
|
|
29
|
+
Where `DESIGN.md` leaves the margin token unset, the default is 16 on both platforms. It applies to the leading edge of the text column and it holds across screens, because two screens whose text starts at different distances from the edge read as two products. Full-bleed content is exempt by definition: a hero image, a map, a media player and a carousel that runs off the edge are meant to reach it. So are the platform list containers, which carry their own row insets (SwiftUI `Form` and `List`, Material `ListItem`) and are not corrected back to 16 by hand.
|
|
30
|
+
|
|
31
|
+
Spacing carries more weight on a phone than anywhere else: in a column around 360 wide it is the only grouping tool available, and there is no spare whitespace to absorb an odd value the way a wide layout does.
|
|
32
|
+
|
|
33
|
+
This is countable. List the distinct vertical gaps on the screen. Four or five is a rhythm. Eleven of them is a screen assembled one component at a time.
|
|
34
|
+
|
|
35
|
+
## <Rule id="layout-grouping" description="Space groups content, a border only draws around it" />
|
|
36
|
+
|
|
37
|
+
Set proximity first and reach for a container only when space alone cannot carry the relationship. Related rows tighten, unrelated blocks separate, and a heading takes more space above it than below so it belongs to what follows it.
|
|
38
|
+
|
|
39
|
+
Every container costs width the phone does not have. A card padded 16 inside a screen margin of 16 pushes its text 32 in from each edge, which on a 320 wide device spends a fifth of the line on nothing. Nested cards, a border plus a divider plus a shadow around the same group, and a card wrapped around the entire screen are all the same move: structure that space was supposed to express.
|
|
40
|
+
|
|
41
|
+
Density follows the situation in `PRODUCT.md`: an app used while walking wants fewer things per screen and larger intervals, a tool someone works in seated can hold more. Fix it as numbers rather than as an intention. One row height and one section gap per kind of screen, written once and identical everywhere that kind appears, so a screen that is generous at the top and cramped at the bottom shows up as two different gaps instead of as a feeling.
|
|
42
|
+
|
|
43
|
+
## <Rule id="layout-shape" description="Radius, edge and crop are one decision" />
|
|
44
|
+
|
|
45
|
+
Corner radius is identity, the same way the palette and the typeface are. `DESIGN.md` carries it as `rounded` and in its Shapes section, and a component that picks its own number is a component that voted on the brand.
|
|
46
|
+
|
|
47
|
+
A screen where a card, a photograph, a chip, a field and a button are all rounded to the same number has no shape language. It has one habit applied nine times, which is the reason so many generated screens read as the same app.
|
|
48
|
+
|
|
49
|
+
- The radii come from the shape language: a small one for controls, a larger one for surfaces, a full round for what is meant to read as a pill or a circle. Three values on a screen is a system, nine is a reflex.
|
|
50
|
+
- A square edge is a choice available to every surface. Photographs, thumbnails, tables and anything that reads as printed are frequently better with the edge the medium gives them, and a hairline rule does work that a rounded card cannot.
|
|
51
|
+
- The radius of a nested surface is smaller than the one containing it, by the padding between them, or the two curves fight along the same corner.
|
|
52
|
+
- Elevation is part of the same decision. Shadow, outline and fill are three ways to lift a surface, and a screen that reaches for all three at once has not decided how depth works.
|
|
53
|
+
|
|
54
|
+
## <Rule id="layout-column" description="One column, one scrolling axis" />
|
|
55
|
+
|
|
56
|
+
There is no second column to escape into, and that changes what happens when something does not fit. Two halves side by side on a 320 wide screen leave each about 140 after the margins and the gap, and at the largest text step the same pair becomes two words per line. Whatever wants a second column is a row that should stack, a table that should be a list, or content that deserves its own screen. The exception is a pair of short fields whose format fixes their length in advance, expiry beside CVC being the one everybody ships: those fit at 140 and go on fitting at the largest step. Two fields on one line is otherwise the version of this that ships most often, and `form-column` owns it.
|
|
57
|
+
|
|
58
|
+
The screen scrolls in one place and along one axis. Put a vertical scroll inside another vertical scroll and the drag has two possible owners, so the inner one, holding the content the finger was aiming at, sits still while the page moves instead. Nesting on the same axis is only ever safe under the platform contract that `scroll-nest` owns. A horizontal strip inside a vertical page needs none of that, precisely because the axes differ. Virtualising what is inside the scroll is `list-virtualise`.
|
|
59
|
+
|
|
60
|
+
## <Rule id="layout-width" evidence="device" description="The narrow device is the one that breaks" />
|
|
61
|
+
|
|
62
|
+
Design against a range. Supported iPhones run about 375 to 440pt wide, the narrow end being installed base rather than anything still on sale, and Android compact devices report from about 320dp upward. The small end is where a layout fails first, and it is on far fewer desks than it is in hands.
|
|
63
|
+
|
|
64
|
+
- Nothing holding content carries a fixed width. Let it fill and constrain it with a maximum, so the same row survives both ends of the range.
|
|
65
|
+
- A row of three fixed cards, a horizontal group of buttons and a label paired with a value are the three that overflow first. Check them at 320dp before anything else.
|
|
66
|
+
- A fixed height is the same defect turned ninety degrees. A container sized to hold two lines holds one and a half as soon as the string is translated or the text scale moves, so heights follow content and only maximums are pinned.
|
|
67
|
+
- Width and text size fail together. Recheck the narrow device at the largest accessibility step, which is `type-scaling`.
|
|
68
|
+
|
|
69
|
+
## <Rule id="layout-chrome" evidence="device" description="Anything pinned covers the content underneath it" />
|
|
70
|
+
|
|
71
|
+
A bar sitting in the platform's own bar slot is already handled: a Compose `Scaffold` reports the padding its top and bottom bars take, for the content to apply, and a SwiftUI `TabView` or `safeAreaInset(edge:)` extends the scroll view's safe area itself. Use the slot and there is no number to invent.
|
|
72
|
+
|
|
73
|
+
Hand-placed chrome is the case that bites: an overlay dropped into a `Box` or a `ZStack`, a floating button, a mini player, a standing banner. It sits on top of the scroll rather than shortening it, so the last row lives underneath and can be read only by overscrolling. Nothing in the code looks wrong, and the screen looks correct until the data is long enough to reach the bottom, which is why it survives review so often. The floating button is `button-fab` and the bottom of a collection is `list-end`.
|
|
74
|
+
|
|
75
|
+
The padding is derived, not typed. Measure the bar, add the inset, and let the scroll read that value, because a hardcoded 80 goes stale the first time the bar gains a second line or the device has a taller gesture area.
|
|
76
|
+
|
|
77
|
+
Chrome is rationed as well as cleared. Besides the system bars, a phone screen carries at most two persistent bars, and each one earns its height by doing something on every screen it appears on. A third is the sign that navigation, a banner and a player are all claiming the same edge, and the one to cut is the one that does nothing on the screen currently in front of the user.
|
|
78
|
+
|
|
79
|
+
## <Rule id="layout-overlays" description="A transient surface stacks above the pinned ones" />
|
|
80
|
+
|
|
81
|
+
A snackbar, a toast or an undo bar arrives over a screen that already has a bottom bar, a floating button, and an inset under both. The stacking order is the whole rule: the transient surface sits above every pinned bar and above the bottom inset, and it pushes the floating button up rather than covering it.
|
|
82
|
+
|
|
83
|
+
Take it from the platform's host, because that is where the displacement is already written: the `snackbarHost` slot of a `Scaffold`, or the equivalent presentation the stack provides. One hand-placed in a `Box` renders under the bottom bar or over the button, and that is the version that ships.
|
|
84
|
+
|
|
85
|
+
One at a time, and never behind something else. Two messages stacked, a toast drawn behind an open sheet, and a snackbar left under a keyboard that has just opened are the same defect: a surface positioned by hand into a stack whose heights it does not know.
|
|
86
|
+
|
|
87
|
+
## <Rule id="layout-fold" evidence="device" description="The first screenful answers what this is and what to do" />
|
|
88
|
+
|
|
89
|
+
At the narrow end, at default text size, with nothing scrolled, three things are visible: what the screen is, the beginning of its real content, and the primary action. That action has two acceptable places and no third. Either it sits inside the first screenful, or it lives in a bar pinned above the bottom inset and is visible at rest, before anything has been scrolled.
|
|
90
|
+
|
|
91
|
+
Scrolling costs more here than the wheel costs on a desk, because it takes the hand that is holding the phone, so the first screenful is the one thing the user gets without paying for it. A header that spends it on promotion or decoration, an illustration, a stack of marketing cards, a brand banner, has pushed the first row of real content past the edge for nothing. Where the media is the subject, a photo detail screen, a listing, an artist page, a full-bleed onboarding screen, the hero is both the subject and the start of the content, and the rule is already met.
|
|
92
|
+
|
|
93
|
+
Where content continues below, saying so is `scroll-affordance`. Everything past that line is a decision the user has to earn, so order the screen by what the job needs first, not by what the API returned first.
|
|
94
|
+
|
|
95
|
+
## <Rule id="layout-short" evidence="device" description="Content that does not fill the height still has a bottom" />
|
|
96
|
+
|
|
97
|
+
Every rule above assumes the screen scrolls. The other case, a three-field form, an empty state, a detail screen holding two rows, is where a bottom action drifts: centred into the empty middle at one content length, and scrolled out of sight as soon as one more field arrives. There is no viewport height to fall back on the way a page has one.
|
|
98
|
+
|
|
99
|
+
Both lengths run the same code. The scaffold's bottom bar slot pins it outright. Where the action belongs to the scrolling content instead, give the scroll a fill-height frame and a spacer that pushes the action down, so short content holds it against the bottom edge and long content lets it scroll away with the rest.
|
|
100
|
+
|
|
101
|
+
The failure has a look, and only the short length shows it: a frame where the content stops a third of the way down, the action floats in the middle, and the bottom of the screen is empty. Nothing in the source says so, because the source is the long case.
|
|
102
|
+
|
|
103
|
+
## <Rule id="layout-orientation" description="Turned sideways the screen loses height, not width" />
|
|
104
|
+
|
|
105
|
+
A large phone held horizontally keeps a wide line and takes its portrait width as its height, about 390 to 440pt, most of which the keyboard takes when a field has focus. Two outcomes are acceptable and nothing between them: the screen locks to portrait for a reason recorded in `STACK.md`, or it reflows.
|
|
106
|
+
|
|
107
|
+
Reflowing means the primary action stays visible without hunting for it, and the reading column keeps its measure rather than running the full width (`type-measure`). A turn is also a configuration change, so what has to survive it is `state-interrupt`. The geometry is the part this rule owns.
|
|
108
|
+
|
|
109
|
+
<Check>
|
|
110
|
+
|
|
111
|
+
<Verify rule="layout-insets">The screen takes its insets from the framework's inset source rather than from a constant, and nothing readable or tappable sits outside them.</Verify>
|
|
112
|
+
<Verify rule="layout-grid">Every gap is a multiple of 4, and of 8 above 16, the text column starts at the same margin on every screen outside full-bleed content and the platform list containers, and the screen uses about five distinct vertical gaps rather than a new one per component.</Verify>
|
|
113
|
+
<Verify rule="layout-grouping">Grouping comes from space before containers, no container is nested inside another that already groups the same content, and the row height and section gap are the numbers this kind of screen uses everywhere else.</Verify>
|
|
114
|
+
<Verify rule="layout-shape">Radius comes from the shape language rather than per component, the screen holds at most three radius values, a nested surface curves less than the one around it, and depth arrives through one of shadow, outline or fill rather than all three.</Verify>
|
|
115
|
+
<Verify rule="layout-column">One column, no same-axis nesting outside what `scroll-nest` permits, and nothing side by side that would leave either half under about 140 wide apart from short fixed-format fields.</Verify>
|
|
116
|
+
<Verify rule="layout-width">No content container carries a fixed width, and the screen was rendered at its own width and again at 320dp with nothing cut at an edge and nothing overflowing sideways.</Verify>
|
|
117
|
+
<Verify rule="layout-chrome">Bars sit in the platform's bar slot, hand-placed chrome derives its padding from the measured bar plus the inset instead of a typed number, no more than two persistent bars stand besides the system ones, and on a rendered screen no line of content sits under a pinned bar, at the top edge or at the bottom one.</Verify>
|
|
118
|
+
<Verify rule="layout-overlays">The snackbar comes from the platform's host slot rather than a hand-placed overlay, it clears the bottom bar and the inset, it moves the floating button rather than covering it, and one is on screen at a time.</Verify>
|
|
119
|
+
<Verify rule="layout-fold">On the narrow device at default text size, the screen's subject and the start of its content are visible unscrolled, and the primary action is either in that screenful or in a bar pinned above the bottom inset and visible at rest.</Verify>
|
|
120
|
+
<Verify rule="layout-short">Rendered at its shortest content, the screen holds its action against the bottom rather than centred above an empty lower third, through the same code that lets it scroll once the content grows.</Verify>
|
|
121
|
+
<Verify rule="layout-orientation">Landscape is either locked with a reason recorded in `STACK.md` or reflows, with the action still visible and the measure still capped at the shorter height.</Verify>
|
|
122
|
+
|
|
123
|
+
<Device>`layout-insets`, `layout-width`, `layout-fold` and `layout-chrome` are answered on a rendered screen at the narrow end of the range, on a device using three-button navigation as well as gestures. The token table and the component tree both look correct while the bottom bar is sitting under the navigation bar. `layout-chrome` needs the screen scrolled to both ends with enough content to reach the pinned bars, because the collision is invisible until a row arrives under one of them, and the top bar hides the first row as readily as the bottom bar hides the last. `layout-short` needs the opposite render, the screen at its shortest content, which is the only length at which the action drifts into the middle.</Device>
|
|
124
|
+
|
|
125
|
+
</Check>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Lists
|
|
2
|
+
|
|
3
|
+
Most of a phone app is lists. It is the screen the user opens most, scrolls fastest, and comes back to after every interruption, and it is where three failures arrive together: jank on a device slower than the one it was built on, memory that climbs until the system kills the process, and a wall of rows that all look the same because nothing inside them was ranked.
|
|
4
|
+
|
|
5
|
+
Loading, empty, error, offline and stale belong to `heuristics/states.md`. This file is what is specific to a collection: what a row is, how many exist at once, and what the top and the bottom of the list do.
|
|
6
|
+
|
|
7
|
+
## <Rule id="list-virtualise" evidence="device" description="Rows recycle, or the list breaks on real data" />
|
|
8
|
+
|
|
9
|
+
Ten rows in a mockup and two thousand in production run the same code. A scrolling container wrapped around a mapped array constructs every row up front, keeps all of them alive, and misses the frame budget on the way, which `perf-frame` states. This is the single most reliable performance defect in generated mobile code.
|
|
10
|
+
|
|
11
|
+
Reach for the recycling primitive every time, including on a list that looks short today:
|
|
12
|
+
|
|
13
|
+
- SwiftUI: `List`, or `LazyVStack` inside a `ScrollView`.
|
|
14
|
+
- Compose: `LazyColumn`, with a `key` on each item.
|
|
15
|
+
- Flutter: `ListView.builder`, or `.separated` where the rows carry dividers.
|
|
16
|
+
- React Native: `FlatList` or `FlashList`. A `ScrollView` around a `.map()` is the defect.
|
|
17
|
+
- Mobile web: a windowing library. `content-visibility: auto` skips the layout and paint of an off-screen row but keeps it in the DOM and in the accessibility tree, so it answers the frame cost and not the memory one, and it needs `contain-intrinsic-size` beside it or the skipped rows collapse to zero height and the scroll jumps as they come back.
|
|
18
|
+
|
|
19
|
+
Three things the recycler needs before it delivers anything. A key taken from the item's own identity, never from its position, because a positional key hands one row's state to a different item as soon as the data reorders. A size hint where rows are uniform (`itemExtent`, `getItemLayout`, `contain-intrinsic-size`), so scroll geometry stops being measured row by row, on the stacks that still take one: FlashList v2 measures for itself and rejects the estimate its first version required. And a row that does not rebuild on every scroll frame, which means the work inside it is memoised and the callbacks it takes are stable.
|
|
20
|
+
|
|
21
|
+
Putting a windowed list inside another scroller running the same direction cancels the windowing outright: the outer scroller asks for the full height, so every row is built and kept, and the primitive costs more than the plain column it replaced. The gesture half of that mistake is `scroll-nest`.
|
|
22
|
+
|
|
23
|
+
## <Rule id="list-density" evidence="device" description="A wall of identical rows is a missing hierarchy, not consistency" />
|
|
24
|
+
|
|
25
|
+
Material sizes its list item by content: 56dp for one line of text, 72dp for two, 88dp for three. iOS names no tiers, only the 44pt row it grows upward from. Take whichever set the stack belongs to and pick the height the content needs, instead of padding every row up to the tallest one in the list. These are density steps rather than touch targets, and the target floor is a separate number (`touch-floor`).
|
|
26
|
+
|
|
27
|
+
Inside the row there are usually three jobs: the thing itself, what qualifies it, and its state or its metadata. Those three are not one size and not one weight (`type-weight`, `type-roles`). A row where the title, the subtitle and the timestamp share a size and a color holds three pieces of content and no answer to the question the user is actually scanning for.
|
|
28
|
+
|
|
29
|
+
Let the rows differ where the content differs. An unread item outweighs a read one, a row with a picture is taller than a row without, and a group of two does not get the treatment a group of forty needs. Forty rows carrying three things each, identical in height, weight and color, force the user to read every one, which is slower than looking and slower still while walking. The exception is the row that carries one thing: a menu of single labels, each with its chevron, is uniform because the content is uniform, and ranking there invents a difference the screen does not have. Hierarchy is owed wherever a row holds two pieces of content or more.
|
|
30
|
+
|
|
31
|
+
## <Rule id="list-separator" description="One device separates rows, not three" />
|
|
32
|
+
|
|
33
|
+
Dividers, spacing and cards all answer the same question. Choose one per list, because on a phone a line that only repeats what the gap already said is width and ink spent for nothing. The grouped iOS list is not the thing being warned about: an inset rounded section with hairline rules between its rows is a single platform device, and it stays the right default for a settings or a form list. What is assembled from parts is a card per row that also carries an internal divider, dropped into a stack that is already gapped.
|
|
34
|
+
|
|
35
|
+
- **Spacing** is the default on a column this narrow. It groups without drawing anything, and it costs no width.
|
|
36
|
+
- **Dividers** suit dense uniform rows where the eye needs a line to track along. Where they are drawn by hand, in Compose or on the web, inset them to the text rather than to the leading icon and leave none after the final row. SwiftUI and `ListView.separated` already do both, so this is a review point only on the stacks that do not.
|
|
37
|
+
- **Cards** suit rows that are genuinely separate objects carrying their own actions. One card per row across forty rows is forty containers, each spending side padding the content wanted.
|
|
38
|
+
|
|
39
|
+
## <Rule id="list-row" description="The row is one target, and every control inside it is another" />
|
|
40
|
+
|
|
41
|
+
That the row itself is a target, and how big it has to be, is `touch-floor`. What belongs to this file is what may sit inside it. A control living in the row is a second target on the same line: a favourite toggle, an overflow button, a checkbox. A chevron is not one of those, it is decoration on the row's own tap. Each real control takes its own hit area and its own dead space away from the row around it (`touch-spacing`), or the user opens a detail screen while trying to star something. Two controls is the ceiling; past that the row needs an overflow menu or a long press.
|
|
42
|
+
|
|
43
|
+
A row that navigates, and toggles, and expands, is three gestures competing over 56dp of glass held in a moving hand. Give the row one meaning and put the rest behind a control.
|
|
44
|
+
|
|
45
|
+
## <Rule id="list-swipe" description="A swipe is a shortcut, never the only route" />
|
|
46
|
+
|
|
47
|
+
Every stack draws them: `.swipeActions`, `SwipeToDismissBox`, `Dismissible`, a swipeable row. They are fast for the person who knows and invisible to everyone else.
|
|
48
|
+
|
|
49
|
+
- Each swipe action also exists somewhere visible: the row's overflow menu, the detail screen, or selection mode. A swipe-only delete does not exist for a screen reader (`touch-gestures`).
|
|
50
|
+
- Two per edge is the ceiling. A third narrows all of them at the exact moment the finger is already travelling sideways.
|
|
51
|
+
- A destructive swipe resolves into undo rather than a confirmation (`touch-destructive`). Swipes fire by accident during a scroll, which is precisely when nobody is reading a dialog.
|
|
52
|
+
- Leave the gesture findable: a partial reveal the first time, or the action drawn in the row until it has been used.
|
|
53
|
+
- The horizontal gesture and the vertical scroll begin at the same point, so the horizontal one commits past a distance threshold instead of on sideways drift. The platform's own touch slop, about 8dp on Android, is the floor for that threshold, and anything under it fires during ordinary scrolling.
|
|
54
|
+
- Drag to reorder falls under the same rule: a visible handle or a move action in the menu, not a long press nobody discovers.
|
|
55
|
+
|
|
56
|
+
## <Rule id="list-images" description="The row reserves the picture's space before the picture arrives" />
|
|
57
|
+
|
|
58
|
+
Images reach a row late, out of order, and at whatever resolution the server holds.
|
|
59
|
+
|
|
60
|
+
- **The container has fixed dimensions.** Row height comes from the layout, never from the bytes. An image that sizes itself on arrival reflows the list under a thumb already in motion, and the row somebody was about to tap slides out from under it.
|
|
61
|
+
- **The placeholder occupies the exact final box.** A neutral fill or a skeleton at that size. Not a spinner, and not a zero-height box that expands later.
|
|
62
|
+
- **The decode is scaled to the box on screen**, which is `perf-decode`.
|
|
63
|
+
|
|
64
|
+
Fixed dimensions is not the same as one aspect ratio for every list, and choosing the ratio and the crop is `icon-crop`.
|
|
65
|
+
|
|
66
|
+
## <Rule id="list-sections" description="Sections tell the user where they are" />
|
|
67
|
+
|
|
68
|
+
Past a screenful or two, a list needs structure the user can navigate by: date, status, alphabet, whatever the order actually follows.
|
|
69
|
+
|
|
70
|
+
- A section header names a group and is not a row. It does not tap and it does not borrow the row's type styles, and `list-a11y` covers what it owes a screen reader.
|
|
71
|
+
- A sticky header stays legible over whatever scrolls beneath it. An opaque fill or the platform's own material settles that outright. A scrim is allowed under `color-gradient` and then owes that rule's measurement, since the content moving underneath moves the worst point along with it.
|
|
72
|
+
- One level of grouping. Nested sections in a column this narrow produce indentation nobody can follow.
|
|
73
|
+
- Position is worth more here than anywhere else: somebody scrolls two hundred rows, opens one, and comes back to a list that has to be where they left it. What survives that trip, and the mechanism that carries it, is `state-interrupt`.
|
|
74
|
+
|
|
75
|
+
## <Rule id="list-end" description="The bottom of the list is a designed state" />
|
|
76
|
+
|
|
77
|
+
Pick one and commit to it.
|
|
78
|
+
|
|
79
|
+
- **Continuous loading** for feeds and anything browsed rather than searched. Fetch the next page about a screenful of rows before the last one, so it has landed by the time the thumb arrives, and guard the request so a fast flick cannot fire it twice.
|
|
80
|
+
- **An explicit load-more control** where the set is finite and the user is hunting for one thing. It is the honest choice whenever somebody needs to be able to stop.
|
|
81
|
+
|
|
82
|
+
Numbered pagination is a desktop control: there is nowhere on a phone to put page numbers a thumb can hit, and nobody navigates a feed by page number. A list that fetches pages says when the data has run out, with a closing marker, a total or a line of text, because the user cannot otherwise tell the end from a page that never arrived. And a page that does fail becomes a retry at the bottom, leaving the rows above it alone, rather than an error that discards what already loaded. A list holding everything it has needs none of that: the scroll reports its own end.
|
|
83
|
+
|
|
84
|
+
The last row also has to clear whatever floats above the list, whether that is a fixed bar, a tab bar, a FAB or a mini player. `layout-chrome` owns that padding and the inset that belongs in it.
|
|
85
|
+
|
|
86
|
+
## <Rule id="list-refresh" description="Pull to refresh is one path to fresh data, not the path" />
|
|
87
|
+
|
|
88
|
+
Use the platform control rather than a hand-built one (`refreshable`, `PullToRefreshBox`, `RefreshIndicator`, `RefreshControl`), so the threshold, the haptic and the animation match every other app on the device.
|
|
89
|
+
|
|
90
|
+
- It belongs only where the data changes somewhere else. A pull that re-renders a local array is theatre.
|
|
91
|
+
- The same refresh is reachable without the gesture, through a menu item or a button. Somebody driving the screen with a screen reader cannot perform the pull at all.
|
|
92
|
+
- It does not replace refreshing on return, and it is not how a user recovers from a failed load. That is the error state's retry (`state-retry`).
|
|
93
|
+
- Refreshing keeps the user's place: new items arrive without discarding the row currently under the thumb.
|
|
94
|
+
|
|
95
|
+
## <Rule id="list-select" description="Selection is a mode, and the screen says so" />
|
|
96
|
+
|
|
97
|
+
Bulk actions on a phone take over the screen, because there is no modifier key and no width for a permanent column of checkboxes.
|
|
98
|
+
|
|
99
|
+
- Entering selection is deliberate: a long press on a row, or a Select control. A normal tap never starts it.
|
|
100
|
+
- While it is on, the screen shows the count, an obvious way out, and the actions that apply. Rows select instead of navigating, and that change of meaning is visible before the first tap rather than after it.
|
|
101
|
+
- Selected is marked by a check mark, a box or a container change, never by tint alone (`color-not-alone`).
|
|
102
|
+
- The bulk action says what it did and offers undo, because one mis-tap here costs forty items instead of one (`touch-destructive`).
|
|
103
|
+
- Select all in a list that is still paging means selecting what has loaded, and the label has to admit that.
|
|
104
|
+
|
|
105
|
+
## <Rule id="list-a11y" description="A row is one stop, not four" />
|
|
106
|
+
|
|
107
|
+
A screen reader moves stop by stop and there is no pointer here to skip ahead with. A row left as its icon, then its title, then its subtitle, then its badge is four stops, so two hundred rows become eight hundred and the list stops being navigable long before it stops being correct.
|
|
108
|
+
|
|
109
|
+
- Each row is a single node reading as one sentence: `Modifier.semantics(mergeDescendants = true)` or `MergeSemantics` in Compose, `.accessibilityElement(children: .combine)` in SwiftUI, `MergeSemantics` in Flutter, `accessible` on the row in React Native. A control that stays separately tappable stays its own node, which is why two per row is already the ceiling.
|
|
110
|
+
- Every swipe and every long press is also an action on that node: `customActions` in Compose, `.accessibilityAction` in SwiftUI, `CustomSemanticsAction` in Flutter, `accessibilityActions` in React Native. The visible equivalent under `list-swipe` is what a sighted user reaches for, and this is the route a screen reader has.
|
|
111
|
+
- A section header carries the heading trait, so the reader can jump between groups instead of walking every row: `heading()`, `.accessibilityAddTraits(.isHeader)`, `Semantics(header: true)`.
|
|
112
|
+
|
|
113
|
+
<Check>
|
|
114
|
+
|
|
115
|
+
<Verify rule="list-virtualise">Every list uses the stack's recycling primitive with a stable non-positional key, and uniform rows carry a size hint where the stack takes one.</Verify>
|
|
116
|
+
<Verify rule="list-density">Row height follows the content instead of one padded maximum, and any row carrying two or more pieces of content ranks them by size, weight or color.</Verify>
|
|
117
|
+
<Verify rule="list-separator">Rows are separated by one device rather than three, and hand-drawn dividers are inset to the text with none after the last row.</Verify>
|
|
118
|
+
<Verify rule="list-row">At most two controls sit inside a row, each with its own hit area and its own clearance, and a chevron is not counted as one.</Verify>
|
|
119
|
+
<Verify rule="list-swipe">Every swipe action has a visible equivalent, at most two per edge, destructive swipes end in undo, and the gesture commits past a distance threshold rather than on drift.</Verify>
|
|
120
|
+
<Verify rule="list-images">Image containers carry fixed dimensions with a placeholder at the same size.</Verify>
|
|
121
|
+
<Verify rule="list-sections">Section headers are headings rather than rows, grouping goes one level deep, and a sticky header stays legible over the content moving under it.</Verify>
|
|
122
|
+
<Verify rule="list-end">The list loads continuously or offers a load-more control and never numbered pages, and a list that pages states where the data ends and turns a failed page into a retry at the bottom.</Verify>
|
|
123
|
+
<Verify rule="list-refresh">Pull to refresh uses the platform control, and the same refresh is reachable without the gesture.</Verify>
|
|
124
|
+
<Verify rule="list-select">Selection mode is entered deliberately, shows its count and its exit, marks selection without color alone, and offers undo.</Verify>
|
|
125
|
+
<Verify rule="list-a11y">Each row is one merged accessibility node, every swipe or long press is also an accessibility action, and section headers carry the heading trait.</Verify>
|
|
126
|
+
|
|
127
|
+
<Device>`list-virtualise` and `list-density` both pass at ten rows and fail at a thousand, so neither is answered from the file alone. Fill the list with production-sized data and scroll it on the slowest device the app supports.</Device>
|
|
128
|
+
|
|
129
|
+
</Check>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Localization
|
|
2
|
+
|
|
3
|
+
A phone carries an ordered list of languages, a separate region, a calendar and a digit preference, and the user can point one app at a language the rest of the system is not using. None of that is yours to set. The app renders in a language nobody on the team reads, at a length nobody typed, on a layout that may run the other way.
|
|
4
|
+
|
|
5
|
+
Shipping one language today is fine. Almost everything in this file costs nothing while the app has one language and is a rewrite once it has forty screens, which is the reason it belongs in the build step rather than in a later project.
|
|
6
|
+
|
|
7
|
+
## <Rule id="l10n-strings" description="No user-facing text lives in code" />
|
|
8
|
+
|
|
9
|
+
Every string a person reads comes out of the catalogue under a key: a String Catalog on iOS, `strings.xml` on Android, ARB files in Flutter, locale files in a React Native or web project. A literal sitting in a widget is what this file is here to find, and it survives review because nothing on the device gives it away: the screen looks finished until the phone is set to another language and one label stays behind in English.
|
|
10
|
+
|
|
11
|
+
- The catalogue holds whole sentences. Two literals concatenated cannot be reordered by a translator, and word order is the first thing a language changes.
|
|
12
|
+
- Accessibility labels, error text, empty states, notification copy and anything a formatter returns are all user-facing and all belong in the catalogue. So do the two that sit outside the layout and get missed for exactly that reason: the iOS usage descriptions (`perm-purpose-string`) and the Android notification channel names (`notify-channels`).
|
|
13
|
+
- Text the server composes never reaches the catalogue at all: a push payload, a mail, a message an API returns. The backend is told which language to answer in, and what it is told is the language the app resolved rather than the one the device is set to.
|
|
14
|
+
- Machine-readable strings do not: keys, URLs, analytics event names, log lines. Those stay literal.
|
|
15
|
+
- Every key carries a comment saying where it appears and what it does. A translator sees the string and nothing around it.
|
|
16
|
+
|
|
17
|
+
## <Rule id="l10n-direction" evidence="device" description="Leading and trailing, never left and right" />
|
|
18
|
+
|
|
19
|
+
Under a right to left language the layout mirrors as a whole: the back chevron, the row disclosure, the progress fill, the drawer edge, the order of everything sitting in a row. The system does this for free, but only for the attributes that describe direction rather than sides.
|
|
20
|
+
|
|
21
|
+
- iOS: leading and trailing constraints, and natural text alignment. A label pinned to the left stays on the left in Arabic.
|
|
22
|
+
- Android: `start` and `end` in place of `left` and `right` on gravity, padding, margin, drawables and relative positioning, plus `android:supportsRtl="true"` on the application element, without which none of them resolve. Compose reads `LocalLayoutDirection`, and in a custom layout `placeRelative()` mirrors where `place()` does not.
|
|
23
|
+
- Flutter: `EdgeInsetsDirectional`, `AlignmentDirectional`, and direction taken from `Directionality`.
|
|
24
|
+
- A value inserted into a translated sentence carries its own direction. A name, an ID or a file name dropped into an Arabic sentence drags the punctuation around it to the wrong end unless it is wrapped for bidirectional text.
|
|
25
|
+
- The back control mirrors with everything else, and on iOS the edge that pops the screen mirrors with it, so the interactive pop is a swipe in from the trailing edge.
|
|
26
|
+
|
|
27
|
+
## <Rule id="l10n-no-mirror" evidence="device" description="Some things are physical and do not turn around" />
|
|
28
|
+
|
|
29
|
+
Mirroring is the default. These are the exceptions, and each one gets pinned to an absolute direction on purpose:
|
|
30
|
+
|
|
31
|
+
- media transport and the timeline scrubber, which follow the recording rather than the text;
|
|
32
|
+
- clocks, and anything else running clockwise;
|
|
33
|
+
- musical notation;
|
|
34
|
+
- chart axes, which hold their orientation so the plot stays readable;
|
|
35
|
+
- photographs, illustrations and artwork, unless the image itself is carrying a direction.
|
|
36
|
+
|
|
37
|
+
An arrow decides its own case: an arrow that means forward or back mirrors, an arrow that means left or right does not. The SF Symbols names draw the same line, with `.forward` and `.backward` flipping while `.left` and `.right` stay put. On Android an asset opts in with `android:autoMirrored="true"`, and on iOS a view carries a semantic content attribute whose playback value is exactly the scrubber case.
|
|
38
|
+
|
|
39
|
+
Phone numbers are laid out left to right in every language, including the right to left ones.
|
|
40
|
+
|
|
41
|
+
## <Rule id="l10n-expansion" evidence="device" description="The label you sized is the shortest one it will ever be" />
|
|
42
|
+
|
|
43
|
+
Budget by the length of the source string, because the short ones grow the most. Up to 10 characters, expect two to three times the width. From 11 to 20, about double. From 21 to 30, three quarters again. From 31 to 50, half again. From 51 characters up it settles near a third more. Chinese and Japanese go the other way and leave a button looking half empty.
|
|
44
|
+
|
|
45
|
+
The breakage is `type-strings` and `layout-width`. What this rule adds is which strings are at risk: a tab label, a chip and a button verb are the shortest strings in the app, so they are the ones that double. Decide per label whether it wraps to a second line, steps down the scale, or moves to a stacked layout, and never let it truncate the verb (`button-label`).
|
|
46
|
+
|
|
47
|
+
## <Rule id="l10n-format" description="The locale formats it, not a pattern someone typed" />
|
|
48
|
+
|
|
49
|
+
Dates, times, numbers, currency, percentages, byte counts, measurements and durations all come from the platform formatter carrying the user's locale, a value the device already holds and the reader has already set: `formatted(.currency(code:))` and the `FormatStyle` family on iOS, `NumberFormat` and `DateFormat` on Android and in Flutter's `intl`, `Intl.NumberFormat` on the web.
|
|
50
|
+
|
|
51
|
+
- `dd/MM/yyyy` is a guess, and it is the wrong guess for the reader who takes 03/04 as April. Ask for a date style, not a pattern, and take the calendar from the locale as well (`Calendar.current`), because the year on screen in Thailand or under a Hijri calendar is not the Gregorian one. What the user types back is parsed against that same locale, so a comma typed on a German keypad is a decimal point.
|
|
52
|
+
- Digits are not universal. Android alone carries 27 Arabic locales, some preferring ASCII digits and others native ones, so a number is substituted at runtime even when its value was known while the code was being written, and a percent sign or a currency symbol is never concatenated onto the end of it.
|
|
53
|
+
- Currency is a code and an amount handed to a formatter, which decides the symbol, which side it sits on, and the separators. A hardcoded `$` is a bug in two directions at once.
|
|
54
|
+
- Units follow the region's measurement system, which is not the same setting as the language. Someone reads Japanese and lives in Germany.
|
|
55
|
+
|
|
56
|
+
## <Rule id="l10n-plurals" description="A count and a string cannot be glued together" />
|
|
57
|
+
|
|
58
|
+
Plural forms live in the platform's plural resource: `<plurals>` on Android, read through `pluralStringResource` in Compose, the plural entries in a String Catalog or stringsdict on iOS, `Intl.plural` in Flutter. Six categories exist across languages, `zero`, `one`, `two`, `few`, `many` and `other`, and which one a number selects is a fact about the language rather than about the number.
|
|
59
|
+
|
|
60
|
+
- English uses two of the six and Arabic uses all of them. An `if (count == 1)` in the app ships English grammar to every other language.
|
|
61
|
+
- The categories are grammatical, so one language never selects `zero` even when the count is zero, and another selects `other` for every count there is.
|
|
62
|
+
- If the sentence does not contain the number, it is not a plural. Use an ordinary key.
|
|
63
|
+
- Gender and any other grammatical selection go through the same resource, never through string surgery in the app.
|
|
64
|
+
|
|
65
|
+
## <Rule id="l10n-script" evidence="device" description="The face has to have the letters, and the line has to have the room" />
|
|
66
|
+
|
|
67
|
+
`type-face` picks the family. This is whether it can draw the languages the app ships.
|
|
68
|
+
|
|
69
|
+
- A bundled font usually covers Latin and stops there. Check every shipped script in the face that will actually render it, and inspect the fallback chain rather than assuming one, because a missing glyph arrives on screen as a box.
|
|
70
|
+
- Non-Latin scripts are taller and want more space between lines: Thai, Devanagari and Arabic in a Nastaliq face all clip inside a box measured against English. Vietnamese does the same without leaving the Latin alphabet, because its tone marks stack above and below the vowel.
|
|
71
|
+
- So the leading is a ratio of the font size rather than a point value, taking the ratio `type-roles` sets for that role, and the box it sits in follows its content (`layout-width`).
|
|
72
|
+
|
|
73
|
+
## <Rule id="l10n-personal" description="Names, addresses and phone numbers have no universal shape" />
|
|
74
|
+
|
|
75
|
+
- One field for the full name, in the order the person types it. Two required fields for a first and last name shut out anyone with a single name and misfile anyone whose family name comes first. They ship only where an outside format demands the split, a ticket, a KYC check or a card network, and `STACK.md` names which one.
|
|
76
|
+
- Address parts follow from the country, which is why the country is picked first. A required postcode, a dropdown of US states and a fixed city, state and zip row are one country's paper form.
|
|
77
|
+
- Deriving a city and a state from a postal code, which `form-count` asks for, is a prefill in the countries whose postal system carries it, never a field taken away from the rest. The device region is where the country guess comes from and not where the answer comes from, since people travel and ship abroad.
|
|
78
|
+
- A phone number keeps its country code and is not forced through a fixed mask. Validate it loosely and format it for display. Formatting as it is typed is `form-input`, and the mask it uses belongs to the country the number is in. The keyboard under it is `touch-keyboard`.
|
|
79
|
+
|
|
80
|
+
## <Rule id="l10n-collate" description="A to Z is not the same alphabet everywhere" />
|
|
81
|
+
|
|
82
|
+
Sorting display names by code point puts accented words after Z, splits the cases apart, and produces an order no reader recognises. Use the platform's locale-aware collator: `Collator` on Android, `localizedStandardCompare` on iOS.
|
|
83
|
+
|
|
84
|
+
- Section headers and the fast-scroll rail down a long list come from that collator, `UILocalizedIndexedCollation` where iOS builds the index for you, never from the first character of the string. Ch, Ñ and Ø are letters in their own right where the list is being read, and this is what `list-sections` is built from.
|
|
85
|
+
- Search matches without regard to case, accents or character width, so typing `jose` finds José.
|
|
86
|
+
|
|
87
|
+
## <Rule id="l10n-per-app" description="The app language and the system language are two different facts" />
|
|
88
|
+
|
|
89
|
+
Both platforms let someone point a single app at a language of its own, so the app cannot read the system language and assume that is what it is rendering in.
|
|
90
|
+
|
|
91
|
+
- Android needs `android:localeConfig` listing the shipped locales before the entry appears in system Settings, and `AppCompatDelegate.setApplicationLocales` to set it from code, with an empty list meaning back to the system default. The Settings entry itself arrives with Android 13, and below that the same call still switches the language inside the app.
|
|
92
|
+
- iOS walks the user's ordered preferred languages, takes the first one the bundle has, falls back from a regional variant to the generic language, and lands on the development region only when nothing matched. Android walks the same shape: the exact locale, then the language without its region, then another region of that language, then the next language the user listed.
|
|
93
|
+
- So store resources under the widest parent dialect the strings are correct for, and a device asking for a country you never shipped still resolves to something readable.
|
|
94
|
+
- An in-app language picker that writes to your own preference store leaves the app disagreeing with the OS about what language it is in. Where one is offered, it writes through the platform API.
|
|
95
|
+
|
|
96
|
+
## <Rule id="l10n-change" description="The language can change while the app is running" />
|
|
97
|
+
|
|
98
|
+
- The locale is read where it is used. A `Locale` captured at launch, a formatter built once inside a singleton, or a string preformatted into a cache leaves the screen rendering half in each language after the switch.
|
|
99
|
+
- Changing the app or the system language recreates the screen on Android the way a rotation does, so what the user had on it comes back with it, which is `state-interrupt`.
|
|
100
|
+
- Anything told the locale once is told it again: the push token registration, the requests that return user-facing text, and any preference the backend stores.
|
|
101
|
+
|
|
102
|
+
## <Rule id="l10n-pseudo" evidence="device" description="Run the fake languages before the real ones" />
|
|
103
|
+
|
|
104
|
+
Most of this is findable without a translator, on a device, before any string is sent out.
|
|
105
|
+
|
|
106
|
+
- An expanding pseudolocale accents and lengthens every string and brackets each one, so a clipped label and a string that never reached the catalogue both surface in the same pass. On Android that is `en-XA`, switched on for the debug build type; in Xcode it is the Double Length or the Accented pseudolanguage, picked in the scheme's App Language menu.
|
|
107
|
+
- A mirrored pseudolocale flips the direction and reverses the characters, which makes right to left testable with no Arabic or Hebrew in the binary. On Android that is `ar-XB`; Xcode offers a right-to-left pseudolanguage in the scheme's App Language menu.
|
|
108
|
+
- Android's Force RTL layout direction switch in Developer Options mirrors the layout and nothing else. It is a quick look, not the pseudolocale pass.
|
|
109
|
+
- Take the pass on the device and at the text step `layout-width` and `type-scaling` already name, which is where a translated string and a scaled one fail on top of each other.
|
|
110
|
+
|
|
111
|
+
<Check>
|
|
112
|
+
|
|
113
|
+
<Verify rule="l10n-strings">Zero user-facing literals in the diff, the usage descriptions and channel names included, whole sentences rather than concatenations, every key carries a comment, and the backend is told which language to answer in.</Verify>
|
|
114
|
+
<Verify rule="l10n-direction">No `left` or `right` in any positioning, padding, margin or alignment except on the absolute-direction cases `l10n-no-mirror` names, RTL is enabled where the platform requires it, and inserted values are wrapped for bidirectional text.</Verify>
|
|
115
|
+
<Verify rule="l10n-no-mirror">Transport controls, clocks, notation, chart axes and artwork are pinned against mirroring, directional arrows mirror, and phone numbers stay left to right.</Verify>
|
|
116
|
+
<Verify rule="l10n-expansion">Every short label has a decided behaviour for the length it will arrive at, wrapping, a step down the scale or a stacked layout, and none of them truncates the verb.</Verify>
|
|
117
|
+
<Verify rule="l10n-format">Every date, number, currency, percentage, unit and duration goes through a locale formatter carrying the locale's own calendar, with no format pattern and no concatenated symbol anywhere, and a typed value is parsed back through the same locale.</Verify>
|
|
118
|
+
<Verify rule="l10n-plurals">Every counted string reads from a plural resource, with no count compared to 1 in app code.</Verify>
|
|
119
|
+
<Verify rule="l10n-script">The typeface covers every shipped script with a fallback chain, and the leading is a ratio rather than a point value.</Verify>
|
|
120
|
+
<Verify rule="l10n-personal">One full-name field unless an outside format demands the split and `STACK.md` names it, address fields chosen after the country, and no single mask applied to every phone number.</Verify>
|
|
121
|
+
<Verify rule="l10n-collate">Lists sort and section through a locale collator, and search ignores case and accents.</Verify>
|
|
122
|
+
<Verify rule="l10n-per-app">The shipped locales are declared to the system, resources sit at the widest correct dialect, and no language picker bypasses the platform API.</Verify>
|
|
123
|
+
<Verify rule="l10n-change">No locale is cached at launch, a language change keeps what was on the screen, and everything told the locale once is told it again.</Verify>
|
|
124
|
+
<Verify rule="l10n-pseudo">Both passes were taken, the expanding one and the mirrored one, by whichever mechanism the stack provides, and the screen was seen under each.</Verify>
|
|
125
|
+
|
|
126
|
+
<Device>`l10n-direction`, `l10n-no-mirror`, `l10n-expansion`, `l10n-script` and `l10n-pseudo` are answered on a rendered screen rather than in the resource files. A catalogue can be complete, a formatter correct and a plural resource well formed while the screen itself still clips, mirrors the wrong element, or falls back to a face that has no glyphs.</Device>
|
|
127
|
+
|
|
128
|
+
</Check>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Maps
|
|
2
|
+
|
|
3
|
+
A map fills the phone's whole screen and takes the drag with it. It is also the least readable surface an app can ship: a canvas of tiles with pins on it carries nothing to a screen reader, nothing in direct sunlight, and nothing to somebody glancing down while walking. And it arrives with a contract, because every major provider requires its own credit on the tiles and most of them bill for the tiles themselves.
|
|
4
|
+
|
|
5
|
+
Here: the map surface and its gestures, the initial camera, markers and clusters, following the user, the route as text, tiles that did not arrive, the cost of holding a map, attribution, and the equivalent representation that has to exist beside it. The location ask is `perm-scope` and `perm-rationale`, the uncertainty circle drawn around the user is `sense-accuracy`, location switched off above the app is `sense-off-system`, a follow that keeps running once the user has left is `bg-location`, and how the result list beside the map reads is `list-a11y`. A map drawn by a web page inside the app takes `webview-surface-choice` for the surface it sits in and every rule below for what is on it.
|
|
6
|
+
|
|
7
|
+
## <Rule id="map-camera" description="The first frame is a decision: framed on the content, padded for the chrome, and bounded" />
|
|
8
|
+
|
|
9
|
+
One screenful and no second pane. A map that opens on the whole world is an ocean to pinch out of with one thumb, and a map that opens centred behind a sheet has spent its only screenful on a region nobody can see.
|
|
10
|
+
|
|
11
|
+
- Set the initial camera explicitly. An unset camera is a real state rather than a safe default: on Android the camera option is nullable and falls back to a position nobody picked.
|
|
12
|
+
- Frame the smallest region that holds the content the screen is about, and pass the padding for everything drawn over the map so the frame lands inside the part that is visible. The calls are `newLatLngBounds(bounds, padding)` on Android, `setVisibleMapRect(_:edgePadding:animated:)` on Apple, and `MapCameraPosition.automatic` in SwiftUI, which frames the map's content for you.
|
|
13
|
+
- The published zoom-to-detail steps on Android are 1 world, 5 continent, 10 city, 15 streets, 20 buildings. Pick the one that matches the question being asked, and expect the permitted range to vary with target, map type and screen size.
|
|
14
|
+
- A map the user is not meant to leave carries a pan bound or a minimum zoom, so one hard swipe does not lose the venue off the edge.
|
|
15
|
+
- Where no location has resolved yet, the fallback camera is written down rather than left at zero, which is a coordinate in the ocean.
|
|
16
|
+
|
|
17
|
+
## <Rule id="map-gesture-owner" description="Where a map sits inside a scrolling parent, one drag has one owner and the code names it" />
|
|
18
|
+
|
|
19
|
+
One finger, one drag, and the map takes the full width, so no margin is left for the thumb to scroll the page by. Map gestures ship enabled, scroll and rotate included, so nothing yields on its own and the content under the map becomes unreachable.
|
|
20
|
+
|
|
21
|
+
- `scroll-nest` owns the same-axis handoff in general. What is map-specific is that the map view does not join the nested-scroll chain, so the parent is told to stop intercepting: `requestDisallowInterceptTouchEvent(true)` in the Android view system and through the `AndroidView` interop in Compose.
|
|
22
|
+
- The map side, one line per stack: `MapInteractionModes` in SwiftUI, with `pan`, `zoom`, `pitch`, `rotate` and no interaction at all from an empty set; `gestureRecognizers` on Flutter's `GoogleMap`; `scrollEnabled`, `zoomEnabled`, `rotateEnabled` and `pitchEnabled` in React Native; and `gestureHandling` on mobile web, whose cooperative value scrolls the page on one finger and pans the map on two.
|
|
23
|
+
- Where the map is not the point of the screen, it is a picture. A lite-mode map or a static image still carries markers, a tap and the my-location layer with no pan and no zoom, and that is the answer for a map inside a stream.
|
|
24
|
+
|
|
25
|
+
## <Rule id="map-marker-target" evidence="device" description="A marker is a control, and where geography will not let it be big enough the escape is another route to the same place" />
|
|
26
|
+
|
|
27
|
+
A fingertip leaves a 16 to 20mm oval and a thumb pad leaves more, while a pin is drawn at a coordinate that cannot be nudged to make room. This is the one surface where the target floor and the content genuinely conflict: two places 30 metres apart sit under one fingertip at street zoom, whatever size the pins are drawn.
|
|
28
|
+
|
|
29
|
+
- The conflict is in the spacing between pins, not in the drawing. Every marker's hit area reaches the floor in `touch-floor` wherever its neighbours leave the room for it, and no marker is drawn larger than the area that answers a tap.
|
|
30
|
+
- Where they do collide, the escape is the same place reachable from a list (`map-not-alone`).
|
|
31
|
+
- The default marker tap is not inert. On Android it moves the camera and opens an info window unless the handler returns true, and the map toolbar, on by default, offers to open the place or its directions in the Google Maps app. Leaving the app on a marker tap is a decision to make, not a default to inherit.
|
|
32
|
+
- A text glyph on a pin is 2 or 3 characters. More than that is unreadable at pin size.
|
|
33
|
+
- Marker and cluster controls carry a name and a role (`a11y-name`), and a decorative overlay is hidden rather than walked (`a11y-hidden`).
|
|
34
|
+
|
|
35
|
+
## <Rule id="map-cluster" description="Neither platform clusters by itself, so hundreds of markers stay one blob until something is written" />
|
|
36
|
+
|
|
37
|
+
The screen is 320 to 440dp wide, so a set that separates cleanly on a wide map collapses into a single shape here, and every marker is a live view on a device already spending its frame budget drawing tiles (`perf-frame`).
|
|
38
|
+
|
|
39
|
+
- Opt in. MapKit clusters only where a clustering identifier is set, and that is nil by default. Android has no clustering in the map SDK at all: it takes the separate utility library, wired by pointing the camera-idle and marker-click listeners at a cluster manager.
|
|
40
|
+
- That library's defaults are the numbers to start from: 4 markers minimum per cluster, 100dp collision distance, and cluster labels bucketed at 10, 20, 50, 100, 200, 500 and 1000.
|
|
41
|
+
- A cluster prints its count, and tapping one ends somewhere a person can act: a tighter camera, or the list of what is inside it. A cluster that zooms one step and re-clusters forever is a dead end. The alternative to clustering is a declared overlap policy rather than luck, and Android's advanced markers choose between required, required and hides optional, and optional and hides lower priority.
|
|
42
|
+
|
|
43
|
+
## <Rule id="map-not-alone" evidence="device" description="The map is never the only representation, because a canvas of tiles reads as two words" />
|
|
44
|
+
|
|
45
|
+
A screen reader gets nothing from tiles. The Android default announcement for the entire map surface is the two words "Google Map", and a marker carries nothing but the content description set on `MarkerOptions.contentDescription`, which is unset by default. The phone shows one thing at a time, so there is no side panel where the equivalent already sits: it has to be a route somebody can reach.
|
|
46
|
+
|
|
47
|
+
- The map view carries an accessibility label of its own, a content description on Android and a Semantics label in Flutter, naming what it is showing and how much is on it rather than the provider's default string. Every marker built in code carries one too, because the provider sets none.
|
|
48
|
+
- Every place, route, area and count on the map is reachable as text inside the same flow: a list, a step list, a place card. How well that list reads is `list-a11y` and `a11y-collection`. What this rule owns is that it exists and is one step away.
|
|
49
|
+
- Nothing on the map means anything by hue alone (`color-not-alone`), and no map interaction is gesture-only (`a11y-gesture`).
|
|
50
|
+
- That same list is the escape `map-marker-target` leans on and the surface `map-offline` still has when the tiles do not arrive. One list answers three rules, which is why it is not optional.
|
|
51
|
+
|
|
52
|
+
## <Rule id="map-follow" description="Following the user is a mode with a visible state, a way out, and a bill" />
|
|
53
|
+
|
|
54
|
+
A camera locked to a moving position fights the thumb: every drag is undone by the next fix. It also runs the radio and holds the display for as long as it is on, the two most expensive things a phone does, while the person holding it is walking or driving.
|
|
55
|
+
|
|
56
|
+
- Following is a state the screen shows, on a stock control: `MKUserTrackingButton` or SwiftUI's `MapUserLocationButton` on Apple, the my-location button on Android. Apple ships three modes, none, follow, and follow with heading; Android ships only a one-shot recenter, so on Android the mode and its exit are written by the app.
|
|
57
|
+
- A pan by the user ends the follow instead of being snapped back. Apple resets the camera to positioned-by-user for you. Everywhere else it is a line somebody writes.
|
|
58
|
+
- Turning it off stops the location updates, not only the camera. A follow left running behind a still map is the whole battery cost with none of the benefit.
|
|
59
|
+
- Nothing holds the screen awake past the end of the follow. The flag is per window on Android and app-wide on Apple, where nothing but the app releases it, and its release on every branch including the failure branch is `perf-power`.
|
|
60
|
+
- The grant is `perm-scope` and `perm-rationale`, running usefully on an approximate grant is `perm-answers`, and agreeing with the indicator the system already drew is `sense-running`.
|
|
61
|
+
|
|
62
|
+
## <Rule id="map-legible" evidence="device" description="The map is a photograph the app did not choose, and everything drawn on it owes its own contrast" />
|
|
63
|
+
|
|
64
|
+
Held in sunlight, held at night, held at arm's length while walking. The base map is the one background in the app whose colour nobody picked, and it changes under the finger as the tiles move, so a control that measured fine over one tile fails over the next.
|
|
65
|
+
|
|
66
|
+
- Measure every control, marker, label and overlay against the tiles it can actually sit on, the pale road and the dark park and the satellite layer, not against one fixed colour. `color-contrast` is the method, and a graphical part needed to understand the content takes 3:1.
|
|
67
|
+
- A thin stroke, a light drop shadow or a scrim under the control is the fix, and it costs less than restyling the map.
|
|
68
|
+
- Choose the base map rather than inheriting it. Apple publishes two emphasis styles, default and muted, where muted desaturates the map so information-rich content on top of it stands out. On Android the colour scheme defaults to light and ignores the device setting until it is set to follow the system, and it is not kept once the map is destroyed. Dark is a second design either way (`color-dark-composed`), label weight on it is `type-dark`, and the map itself does not turn around in a right-to-left layout (`l10n-no-mirror`).
|
|
69
|
+
|
|
70
|
+
## <Rule id="map-steps" description="A route is text before it is a line, and turn-by-turn is a product rather than a feature" />
|
|
71
|
+
|
|
72
|
+
The person reading it is moving, holding the phone in one hand, and looking down for about a second at a time. A polyline is unreadable at that glance.
|
|
73
|
+
|
|
74
|
+
- Any route drawn on a map is also a numbered step list with distances and street names, in one unit system (`data-units`), formatted by the locale (`l10n-format`). An overlay that encodes a quantity instead of a route owes its scale and units to `data-chart-scale`.
|
|
75
|
+
- Do not assemble real-time turn-by-turn on a standard map SDK. Google's terms forbid combining directions, geolocation and the maps SDK into navigation substantially similar to its own app, so the two shipping answers are a dedicated navigation SDK or a handoff to the maps app, and on Android that handoff already exists in the default map toolbar.
|
|
76
|
+
|
|
77
|
+
## <Rule id="map-offline" description="Tiles that did not arrive are a state the app draws, not a cache the app builds" />
|
|
78
|
+
|
|
79
|
+
A lift, a basement, a tunnel, a car park, a metered plan. The map is the heaviest thing on the screen and the first thing to fail, and a grey grid with a pin floating on it is the app looking broken at the moment somebody is trying to work out where they are.
|
|
80
|
+
|
|
81
|
+
- A map with no tiles draws a named state instead of blank tiles, inside the four network states of `state-offline`, saying what failed (`state-error`) with a retry that keeps the camera rather than resetting it (`state-retry`). Tiles still arriving is `state-loading`; a map with nothing to put on it is `state-empty`.
|
|
82
|
+
- Everything still true offline stays drawn and carries its age (`state-stale`): the last camera, the pins, the addresses, the step list.
|
|
83
|
+
- Where the provider's terms bar it, do not build a tile cache: Google bars pre-fetching, bulk downloading and rehosting outright, with no developer offline mode behind that, only a navigation SDK holding 15 to 20 minutes of route ahead of the user. Where a provider sells an offline store, the download is a sized region the user asked for, with an expiry, rather than a background crawl.
|
|
84
|
+
- Tiles are somebody's data plan (`net-metered`), and tiles for a region nobody will look at are `net-prefetch`.
|
|
85
|
+
|
|
86
|
+
## <Rule id="map-cost" description="One map at a time, released when its screen goes, and never one per row" />
|
|
87
|
+
|
|
88
|
+
A map is the most expensive view a phone app can hold: it renders continuously, keeps tiles in memory and holds a connection open. Two of them alive at once, or one per row in a recycled list, is the shape that gets the process killed on the device the app was not built on (`perf-memory`).
|
|
89
|
+
|
|
90
|
+
- One live map instance per screen, released on the exit path and on the error path alike (`perf-power`).
|
|
91
|
+
- A map inside a list row is a static image or a lite-mode map, never a live one. `list-virtualise` recycles that row, and a live map is not a thing to recycle. A static map image is a picture and owes `icon-alt`.
|
|
92
|
+
- Reuse annotation views rather than building one per marker. Registering a view class, or dequeuing by identifier, is how the app opts in, and the map then builds a view only where no reused one is available.
|
|
93
|
+
|
|
94
|
+
## <Rule id="map-attribution" evidence="device" description="The provider's logo and legal link are drawn by the map, moved only by its own padding, and never removed, hidden or restyled" />
|
|
95
|
+
|
|
96
|
+
The map fills the screen, so the bottom edge where every provider puts its credit is exactly where the phone also puts the sheet, the recenter button, the FAB and the tab bar. Covering it is the default outcome of the layout rather than an edge case, and it is a contract term rather than a matter of taste.
|
|
97
|
+
|
|
98
|
+
- Give the map padding instead of giving the chrome a margin. Padding the map's four edges moves the zoom controls, the compass and the copyright notice inside the visible region and recentres camera movements on it; Apple's edge padding does the same for framing. Anything pinned over the map declares that padding (`layout-chrome`), a sheet stacking above it is `layout-overlays`, and the safe area under it is geometry (`layout-insets`).
|
|
99
|
+
- Keep the credit fixed to the map rather than moving it with the interface, and clear of a pull-up card at its lowest resting position, 10 points above it on Apple, whose own padding figures are 7 points at the sides and 10 above and below.
|
|
100
|
+
- Never remove, hide, resize, recolour, localise, wrap or redraw it. Google's mark runs 16dp to 19dp tall with 10dp of clear space left, right and top and 5dp below; the text form is Google Maps unchanged, on one line, at 4.5:1 against its background, and it carries an accessibility label reading Google Maps.
|
|
101
|
+
- Where the response credits a third-party data provider, that name is printed alongside the mark. The provider mark on its own is not attribution then.
|
|
102
|
+
|
|
103
|
+
## <Rule id="map-terms" description="The map arrives with a contract: the notice belongs in the app's own terms and the tiles are not the app's to keep" />
|
|
104
|
+
|
|
105
|
+
A phone app has no page footer to carry a legal line, and its binary only changes through the store, so the notice is routed to a screen somebody can reach rather than patched in later. The phone has no room for a second copy of the provider's data either, and the offline instinct it creates is exactly what the contract forbids.
|
|
106
|
+
|
|
107
|
+
- The app's own terms name the map provider and link the provider's end-user terms and privacy policy, on a surface reachable from where the version and the report route already live (`set-diagnostics`).
|
|
108
|
+
- Nothing is scraped: no pre-fetching, indexing, rehosting or bulk downloading of tiles, geocodes, directions or places, and no copying of business names, addresses or reviews into the app's own store. A place leaving the app leaves as a link or a coordinate rather than a rendered picture of the tiles, which is `share-link-not-shot`.
|
|
109
|
+
- What may be kept is narrow, and it is written down with its expiry in `off-cache-policy`: place IDs with no deletion deadline but refreshed at 12 months, latitude and longitude for at most 30 consecutive days. The empty store on a first run with no network is `off-no-cache`.
|
|
110
|
+
- A user's location is not obtained or cached without their express, prior, revocable consent, which is a promise the app makes on top of the permission grant.
|
|
111
|
+
|
|
112
|
+
<Check>
|
|
113
|
+
|
|
114
|
+
<Verify rule="map-camera">Every map sets an explicit initial camera framed on its own content, passes padding for anything overlapping it, carries a pan bound or a minimum zoom where the user is not meant to leave the area, and uses a written coordinate rather than zero for the frame before any location resolves.</Verify>
|
|
115
|
+
<Verify rule="map-gesture-owner">Every map inside a same-axis scrolling parent names the mechanism that assigns the drag on one side or the other, and every map with no gesture enabled is a static image or a lite-mode map rather than a live one.</Verify>
|
|
116
|
+
<Verify rule="map-marker-target">Marker hit areas reach the platform touch floor where spacing allows, no marker is drawn larger than its hit rect, the marker tap handler is written rather than inherited so the map toolbar is deliberately kept or deliberately disabled, and two adjacent pins at production scale are separately hittable with a thumb.</Verify>
|
|
117
|
+
<Verify rule="map-cluster">Any map whose marker list is not a fixed small set at the call site opts into clustering or a declared overlap policy, and a cluster prints its count and expands to somewhere a person can act.</Verify>
|
|
118
|
+
<Verify rule="map-not-alone">The map view and every marker built in code carry an accessibility label of their own, every place, route, area and count on the map is reachable as text one step away in the same flow, and driving that flow with the reader on reaches all of them.</Verify>
|
|
119
|
+
<Verify rule="map-follow">Following is a shown state on a stock control, a user pan ends it, turning it off stops the location updates, and no screen-awake flag outlives the follow.</Verify>
|
|
120
|
+
<Verify rule="map-legible">Controls, markers, labels and overlays are measured against the tiles they sit on rather than one fixed colour, and the emphasis style or colour scheme of the base map is set explicitly.</Verify>
|
|
121
|
+
<Verify rule="map-steps">Every route on a map is also a numbered step list with locale-formatted distances and street names, and no real-time turn-by-turn is assembled on a standard map SDK.</Verify>
|
|
122
|
+
<Verify rule="map-offline">A map with no tiles draws a named state with a retry that keeps the camera, anything still true offline stays drawn and marked stale, and no code pre-fetches or persists tiles except through an offline API the provider publishes.</Verify>
|
|
123
|
+
<Verify rule="map-cost">At most one live map instance exists per screen, it is released on the exit and error paths, no live map sits in a recycled row, and annotation views are reused.</Verify>
|
|
124
|
+
<Verify rule="map-attribution">The map is padded for every piece of chrome overlapping it, the credit moves only through the map's own padding API and no code removes, hides, resizes, recolours or redraws it, third-party data providers named in the response are printed alongside it, and the credit stays visible with every overlay at its lowest resting position.</Verify>
|
|
125
|
+
<Verify rule="map-terms">The app's terms name the map provider and link its end-user terms and privacy policy, nothing scrapes or rehosts provider content, and no stored latitude and longitude has a retention beyond 30 consecutive days.</Verify>
|
|
126
|
+
|
|
127
|
+
<Device>Four of these cannot be settled from a diff. On a device, raise the sheet to its lowest resting position and look for the provider credit under it (`map-attribution`); load production-scale markers and try to hit two adjacent pins with a thumb (`map-marker-target`); measure a control against the tile beneath it in both colour schemes and in sunlight (`map-legible`); and drive the whole map flow with the screen reader on to find out whether anything past the words "Google Map" is reachable at all (`map-not-alone`), which is the run `a11y-test` asks for.</Device>
|
|
128
|
+
|
|
129
|
+
</Check>
|