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,124 @@
|
|
|
1
|
+
# Data display
|
|
2
|
+
|
|
3
|
+
Numbers, tables, charts, timestamps and units, in a column that starts around 320dp wide, read at a glance by someone who is walking. There is no second monitor for the detail, no hover to reveal it, and no room for a legend, so whatever a value means has to be on the glass at the moment it is read.
|
|
4
|
+
|
|
5
|
+
Two failures produce most of the damage. The first is precision the data does not have: a float printed whole, a percentage carried to four decimals, an average over six samples shown as though it were measured. The second is a desktop shape moved across intact: six columns, a legend off to one side, a chart drawn wide and then scaled down until its own labels are unreadable.
|
|
6
|
+
|
|
7
|
+
Neither platform will stop either one. Android ships no chart component and no data table component, in Material or in androidx, and iOS publishes no guidance for a sortable multicolumn table on a phone. Every grid and every chart on a phone is something someone decided to build from nothing.
|
|
8
|
+
|
|
9
|
+
## <Rule id="data-precision" evidence="device" description="Never show more digits than the value carries" />
|
|
10
|
+
|
|
11
|
+
Decide the digits from the measurement, not from the type. A step count is an integer, a body weight is one decimal, a currency is the minor units its code declares, and a ratio computed from two small integers is not a four decimal percentage no matter what the division returns. The column is narrow enough that a digit spent is a digit not spent on the label beside it.
|
|
12
|
+
|
|
13
|
+
- One rounding rule per quantity, applied in one place. The same value showing as 12.4 on the detail screen and 12 in the list is a bug report waiting to be filed, and the reader is right to file it.
|
|
14
|
+
- Where the figure is rounded and someone might act on the exact one, say it is rounded and give a route to the exact figure. There is no hover and no tooltip here, so that route is a target: a tap, a detail row, an expanded state. Implying exactness costs more than the extra tap.
|
|
15
|
+
- A figure the reader will carry somewhere else, a reference, an account number, a code, a total, is selectable or has a copy affordance on it, because the alternative is transcribing it by hand from a screen they cannot put beside anything. `share-copy` owns what confirms the copy.
|
|
16
|
+
- An amount being paid, transferred or owed is never rounded for display.
|
|
17
|
+
- A derived figure inherits the worst input's precision. Converting, averaging or summing does not create digits, which is also `data-units`.
|
|
18
|
+
- `copy-numbers` rules the figure inside a sentence, and `l10n-format` produces the separators and the symbol.
|
|
19
|
+
|
|
20
|
+
## <Rule id="data-table-shape" description="A table that does not fit becomes a different shape, not a sideways scroll" />
|
|
21
|
+
|
|
22
|
+
A horizontal scroll inside a vertical list moves the columns it hides off screen with nothing on the glass saying they are there, so the value the user came for is unreachable by anyone who does not already know to drag. That missing edge is `scroll-affordance`. There are three honest answers instead:
|
|
23
|
+
|
|
24
|
+
- **Fewer columns.** Two or three, chosen because they answer the question the screen exists for. The rest live one level down.
|
|
25
|
+
- **A row that opens a detail.** The row carries the identifier and the one number being compared, and the full record opens as its own screen. This is the answer nearly every time, and `list-row` sizes the row.
|
|
26
|
+
- **A different shape entirely.** A grouped list, a chart, or two records compared side by side with the third reachable by paging.
|
|
27
|
+
|
|
28
|
+
Column headings are nouns or short noun phrases, and a single column of figures still needs a label saying what it counts; `data-units` rules the unit that sits over a column. Figures sit at the trailing edge of their column with the decimal point in one place, `l10n-direction` deciding which edge that is and `type-strings` supplying the tabular figures that hold it there. Where a genuine grid is the product, editable or read only, it is its own screen with a leading identifier column pinned, a visible cut at the trailing edge so the row is seen to continue, and its own selection model, and `STACK.md` records it as an exception rather than a component reused elsewhere.
|
|
29
|
+
|
|
30
|
+
## <Rule id="data-chart-earns-it" description="A chart shows a relationship, or it is a number wearing a costume" />
|
|
31
|
+
|
|
32
|
+
If the user needs the values themselves, a list beats a chart: the figures are exact and the reader can move through them. A chart is for a trend over time, a comparison across categories, or a part against a whole. One value inside a known range is a labelled number or a gauge, and a gauge states its current value and both endpoints.
|
|
33
|
+
|
|
34
|
+
Use bar, line and point marks, which need no explanation. A chart shape the reader has to learn arrives with the sentence that teaches it, or it does not ship. Where several charts show the same data, keep one type, one set of colors and one layout across them, because a changed encoding reads as changed data.
|
|
35
|
+
|
|
36
|
+
## <Rule id="data-chart-scale" description="The chart carries its own axis, units and baseline" />
|
|
37
|
+
|
|
38
|
+
A phone chart is read without a legend and without a caption, so the plot area gets the full width of the column and everything else earns its space.
|
|
39
|
+
|
|
40
|
+
- Put the unit in the title or the axis label once, never on every tick. Keep vertical axis labels short, and move a long category label inside the plot area where it does not cover a mark.
|
|
41
|
+
- Use a tick sequence a reader recognises without arithmetic: 0, 5, 10 rather than 1, 6, 11.
|
|
42
|
+
- The lower bound is a decision, and it is the one that changes what the chart says. A bar chart of totals usually starts at zero. A chart of a value that never approaches zero, such as a heart rate, hides its whole story if it does.
|
|
43
|
+
- Grid lines are the smallest number that still lets a mark be estimated. Beyond that they compete with the data for the same pixels.
|
|
44
|
+
- Series are told apart by shape, pattern, position or a wide lightness gap as well as by hue, which is `color-not-alone`. Adjacent filled areas need a visible separation between them.
|
|
45
|
+
- Series are labelled where they are drawn, at the end of the line or on the bar itself, or in the headline text above the plot. A key sitting off to one side asks the reader to map it back onto the marks, and the column has no room for it anyway.
|
|
46
|
+
- Chart text scales with the user's setting like every other string on the screen. A plot drawn by hand takes its label sizes in fixed units and ignores the setting entirely, so labels stay small while everything around them grows. Where a label cannot reflow, the chart drops it rather than shrinking it, and the chart and any column of figures get looked at again at the largest accessibility step, which is `type-scaling`.
|
|
47
|
+
- A missing sample is a gap in the line, not a zero and not an interpolation across it, which is `data-empty-null-zero`.
|
|
48
|
+
|
|
49
|
+
## <Rule id="data-chart-reach" description="The point of the chart is legible before anyone touches it" />
|
|
50
|
+
|
|
51
|
+
Interaction is a way to get more, never the only way to get the essential. Put the headline figure in text above or beside the plot, so a glance answers the question and the chart explains it.
|
|
52
|
+
|
|
53
|
+
- Where marks are too small to hit, the scrub target is the whole plot area rather than the marks, and it still meets `touch-floor`.
|
|
54
|
+
- A canvas has no children to find, which is `a11y-name`. What a chart adds is where the tree comes from: on iOS the chart descriptor types, which also drive Audio Graphs, and on Android real semantics on the drawing rather than one label over the whole picture. The summarising sentence `icon-alt` asks of a chart stays and the mark or group labels sit under it: the sentence says what the chart is making of the data, the labels carry the data, and neither one replaces the other.
|
|
55
|
+
- Decide per chart whether every mark is a stop or whether groups of marks are, then write each label with the value and the context that makes it mean something, such as its date or its category. Actual values, not "rapidly" or "almost", and no ambiguous abbreviation: "June 6" and "60 minutes" rather than "6/6" and "60m". Naming a control is `a11y-name`; this is naming the data inside it.
|
|
56
|
+
- Once the marks carry those labels, hide the visible axis and tick labels from assistive technology so the same numbers are not read twice.
|
|
57
|
+
|
|
58
|
+
## <Rule id="data-time-relative" evidence="device" description="Relative time ages while it is on screen, and it has a written crossover" />
|
|
59
|
+
|
|
60
|
+
Take the string whole from the platform's relative formatter and place it on its own. Those strings are built as standalone phrases, and embedding one in a sentence is not reliably grammatical.
|
|
61
|
+
|
|
62
|
+
- The point where relative stops and an absolute date takes over is a named constant in the code, because neither platform picks it. On Android it is a threshold argument the caller passes; the iOS formatter exposes no crossover parameter at all, so the app owns the threshold there.
|
|
63
|
+
- Set the floor too. Below it a single string, "just now", instead of "0 minutes ago".
|
|
64
|
+
- A label already rendered keeps aging. Recompute it on a timer while the screen is visible and again when the app returns to the foreground, or "just now" is still on screen twenty minutes later.
|
|
65
|
+
- Decide whether "yesterday" means a calendar day or a rolling twenty four hours, and hold that decision everywhere. The Android date helper counts from midnight, so an event at 23:50 is "yesterday" ten minutes later, and whatever is written on iOS matches whichever answer was chosen.
|
|
66
|
+
- Never compute a span against a constant year. The one in the Android platform is fifty two weeks, which is 364 days, and anything built on it drifts.
|
|
67
|
+
- Where the exact moment matters, a transaction or a message, the absolute time is one tap or one long press away.
|
|
68
|
+
- Cached content saying how old it is is `state-stale`, and this rule is how that sentence is produced.
|
|
69
|
+
|
|
70
|
+
## <Rule id="data-time-instant" description="Stored as an instant, displayed in the reader's zone and clock" />
|
|
71
|
+
|
|
72
|
+
Persist an instant, plus the event's own zone where the zone is part of the fact, such as a flight or a booked appointment. Format it at the moment of display against the device's current zone, never at the moment it was fetched.
|
|
73
|
+
|
|
74
|
+
- A date with no time, a birthday or a due date, is a calendar date. Running it through a zone conversion is what moves it a day.
|
|
75
|
+
- The twelve or twenty four hour clock is a system setting the user changed on purpose. On Android the ICU formatter does not read it, so take the setting from the framework class, `android.text.format.DateFormat.is24HourFormat(context)`, and hand the matching skeleton to the ICU formatter instead of trusting its default. The two `DateFormat` classes are different types and only the framework one answers that question.
|
|
76
|
+
- The locale data behind the formatters is pinned per OS release, so the same locale produces different output on different versions. Never compare, parse or assert against a formatted string, and never route one back into storage.
|
|
77
|
+
- `l10n-format` owns the formatter and the calendar; this rule is what gets handed to it.
|
|
78
|
+
|
|
79
|
+
## <Rule id="data-units" description="One system per screen, converted once, at the edge" />
|
|
80
|
+
|
|
81
|
+
Store the canonical unit and convert only where the value is drawn. iOS ships a measurement formatter that carries the conversion, the style and the locale together; on Android the conversion is the app's own and the formatter renders only the number and its symbol. Two units from different systems on one screen is the failure that gets noticed: kilometres in the summary and miles in the row beneath it, or Celsius on the card and Fahrenheit in its detail.
|
|
82
|
+
|
|
83
|
+
- The region setting decides the system, and it is not the language setting. `l10n-format` covers that; what belongs here is that a screen picks one and holds it.
|
|
84
|
+
- A conversion does not add precision. 5 km shown as 3.10686 miles claims a measurement nobody made, which is `data-precision`.
|
|
85
|
+
- The unit stays with the figure, or once in the heading of a column where every value shares it, and is never dropped to reclaim width. If the width is the problem, the layout is the problem. `copy-numbers` rules the same figure inside a sentence.
|
|
86
|
+
- Spell out anything a screen reader would mangle, and never reverse the digits inside a number when the layout mirrors.
|
|
87
|
+
|
|
88
|
+
## <Rule id="data-date-entry" description="Match the control to the distance, not to the field type" />
|
|
89
|
+
|
|
90
|
+
Near dates go in the platform picker: a compact field or inline calendar on iOS, a docked or modal date picker on Android. Far dates get typed. A birthdate entered on a wheel is hundreds of flicks, and both platforms offer a keyboard input mode for exactly that.
|
|
91
|
+
|
|
92
|
+
- Constrain the range on the picker itself rather than validating afterwards, and use a range picker where two dates relate so the end cannot precede the start.
|
|
93
|
+
- A minute list holds sixty values by default. Where the task does not need them, use an interval that divides evenly into sixty, such as quarter hours.
|
|
94
|
+
- A duration is not a time of day, and it never goes in through a time of day picker. On iOS the countdown mode is that control and it stops at 23 hours 59 minutes. Android ships no countdown picker, so a duration is assembled from number fields or a control written for it.
|
|
95
|
+
- The time picker is constructed with the system twelve or twenty four hour setting rather than the parameter default, which on Android is twelve hour whatever the device is set to. This is the entry side of `data-time-instant`.
|
|
96
|
+
- Do not open a new screen just to show a picker, and do not build a calendar grid by hand: it will miss the locale's first day of week, its week numbering and its non Gregorian calendars.
|
|
97
|
+
- `form-input` rules that a closed value set gets a picker at all. This rule is which picker, and in which direction the entry is going.
|
|
98
|
+
|
|
99
|
+
## <Rule id="data-empty-null-zero" evidence="device" description="Zero, unknown and not applicable are three values" />
|
|
100
|
+
|
|
101
|
+
They are three different facts and they must not render as the same glyph. Zero is a measurement. Unknown is the absence of one. Not applicable means the question does not apply to this row. In a column this narrow there is no neighbouring cell to compare against and nothing to hover for an explanation, so the glyph is the entire answer the reader gets.
|
|
102
|
+
|
|
103
|
+
- Zero renders as `0` in the value's own format, with its unit. A dash where a zero belongs makes a working feature look broken.
|
|
104
|
+
- Unknown says so in a word, and where it matters, why: not synced yet, not recorded, only available on the paid plan. A blank cell is indistinguishable from a rendering failure.
|
|
105
|
+
- Not applicable is the one case where a dash is the right mark, because there is no value to report and never was one. It never shares its glyph with unknown, and where a screen uses a dash for both, unknown is the one that changes.
|
|
106
|
+
- A total or an average over an incomplete set says how many values it covers. Silently treating unknown as zero moves the average, and nothing on screen says it moved.
|
|
107
|
+
- Zero as a whole screen is a different thing again, and `state-empty` rules it.
|
|
108
|
+
|
|
109
|
+
<Check>
|
|
110
|
+
|
|
111
|
+
<Verify rule="data-precision">No figure reaches a view through a raw float or a default string conversion, each quantity's rounding lives in one named place, and anything rounded that could be acted on says so and offers a route to the exact value.</Verify>
|
|
112
|
+
<Verify rule="data-table-shape">No tabular data scrolls sideways: it is cut to two or three labelled columns, opened as a detail, or reshaped; figures align on the trailing edge; and any genuine grid is its own screen with a pinned leading column and a recorded exception.</Verify>
|
|
113
|
+
<Verify rule="data-chart-earns-it">Every chart shows a trend, a comparison or a part to whole; a single value in a range is a labelled number or a gauge; and any unfamiliar chart shape ships with the sentence that explains it.</Verify>
|
|
114
|
+
<Verify rule="data-chart-scale">Units appear once, ticks follow a recognisable sequence, the axis lower bound is a stated decision, series are labelled on the plot and separable without hue, and the chart still reads at the largest text step.</Verify>
|
|
115
|
+
<Verify rule="data-chart-reach">The headline figure is in text without interaction, the scrub target is the plot area at the touch floor, and the marks carry accessibility labels with values and context in a tree that was actually built.</Verify>
|
|
116
|
+
<Verify rule="data-time-relative">Relative strings come whole from the platform formatter, the crossover to absolute and the floor are named constants, on-screen labels are recomputed, and no span is measured against a constant year.</Verify>
|
|
117
|
+
<Verify rule="data-time-instant">Instants are stored with a zone where the zone is a fact, calendar dates are never zone converted, the clock setting is read on Android, and no formatted string is parsed or asserted against.</Verify>
|
|
118
|
+
<Verify rule="data-units">One measurement system per screen, converted at the display edge without gaining precision, and no figure reaches the screen without its unit on it or over its column.</Verify>
|
|
119
|
+
<Verify rule="data-date-entry">Near dates use the platform picker and far dates a keyboard entry mode, ranges use a range picker, minute intervals divide into sixty, durations never use a time of day picker, the time picker takes the system clock setting, and no calendar grid is hand built.</Verify>
|
|
120
|
+
<Verify rule="data-empty-null-zero">Zero, unknown and not applicable render as three different things with the dash reserved for the last of them, unknown says why where it matters, and aggregates over incomplete sets declare their coverage.</Verify>
|
|
121
|
+
|
|
122
|
+
<Device>Run `data-precision`, `data-time-relative` and `data-empty-null-zero` against real records rather than the mock ones. Seeded data has no nulls, no zeros, no thirteen digit floats and no timestamps from last year, which is exactly why the screen looks finished.</Device>
|
|
123
|
+
|
|
124
|
+
</Check>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Feedback
|
|
2
|
+
|
|
3
|
+
The app has something to say to somebody who is already holding the phone and looking at the screen. There is one screen, so every message is taken out of the content it covers, and the vehicle is the whole decision: an interruption costs the user the task, and a message that expires costs them the fact.
|
|
4
|
+
|
|
5
|
+
The ladder runs from nothing at all up to a dialog that stops everything, and the rungs are not interchangeable. A message that arrives from outside the app is `heuristics/notifications.md`, and the state a failed screen sits in is `state-error`. Wording is owned by the rules that already hold it: `button-label` for the answers, `l10n-strings` for every sentence that ships. This file owns which vehicle carries a message while the user is in the app, where it lands, and how long it lives.
|
|
6
|
+
|
|
7
|
+
One structural fact decides half of this. Android ships a transient actionable message as a component, with a host that positions it and decides what becomes of the next one. Apple ships none: it has alerts, action sheets and inline status, and nothing that slides in and leaves. Anything transient on iOS is a component somebody in this codebase has to build and maintain, which is a cost worth knowing before the design assumes one.
|
|
8
|
+
|
|
9
|
+
## <Rule id="fb-ladder" description="Four rungs, and the first one is nothing" />
|
|
10
|
+
|
|
11
|
+
- **Nothing.** The result is already on the screen. The row disappeared, the toggle moved, the total changed.
|
|
12
|
+
- **Inline.** A line inside the region it is about, which stays until it stops being true.
|
|
13
|
+
- **Transient.** A message over the content that leaves on its own, for something the interface cannot show by itself. On Android that is the snackbar host and never a `Toast`: a toast takes no action at all, so it cannot carry the retry or the undo that has to travel with the message, and in an app targeting API 31 or higher it is limited to two lines with the app icon beside them. Anything the user might act on goes to the snackbar while the app is in front, and while it is not, what arrives from outside and how it lands on return is `notify-inapp`. Every host that exists exposes exactly 1 action, so a message needing two answers has outgrown the rung; a component built here takes as many as it is handed and has to hold that count itself.
|
|
14
|
+
- **Blocking.** A dialog, for a decision that cannot be deferred.
|
|
15
|
+
|
|
16
|
+
Take the quietest rung that still does the job, and climb only with a reason. Every rung up spends more of a screen the user came here for something else. Nothing the app has to say blocks the launch: an app that puts an informational dialog of its own in front of the first screen has spent its one interruption before the user has done anything. The single system prompt a required resource is allowed there is `onboard-ask-order`. The other thing allowed there is not a message at all: where the user genuinely cannot proceed for a stated external reason, a required update or a consent that has to be given again, that is a screen of its own saying what is required and what they can do about it, never a dialog laid over a first screen they are not allowed to use.
|
|
17
|
+
|
|
18
|
+
Count the blocking dialogs one flow can raise. More than one is a design problem rather than a messaging problem, and splitting the flow is what fixes it where rewording never will.
|
|
19
|
+
|
|
20
|
+
## <Rule id="fb-silent-success" description="The screen showing the result is the confirmation" />
|
|
21
|
+
|
|
22
|
+
People expect what they did to work, so the outcome worth reporting is failure. A message reading "Saved" over a screen that already shows the saved value is decoration that lands across the bottom of the screen, which is exactly where the next tap was going.
|
|
23
|
+
|
|
24
|
+
Confirm explicitly only what the screen cannot show: money moved, something went to another person, a file left the device, an item was removed from a list the user is no longer looking at. Anything irreversible or financial takes the confirmation `state-queued` already specifies, which no transient rung can be.
|
|
25
|
+
|
|
26
|
+
## <Rule id="fb-confirm-test" description="Uncommon and irreversible, both at once" />
|
|
27
|
+
|
|
28
|
+
Destructive is not the test. Both halves have to be true before an alert stops the user: the user does this rarely, and nothing brings it back. Deleting one photo out of ten thousand is destructive, common and recoverable, so it happens and offers undo. Deleting the account is rare and final, so it interrupts.
|
|
29
|
+
|
|
30
|
+
A common action that cannot be undone is not exempt, it is a different surface: offering the user choices about something they deliberately started is the sheet case in `fb-blocking-shape`, not a lighter alert. Discarding a draft the user just chose to abandon is that case, and it is common.
|
|
31
|
+
|
|
32
|
+
On a phone the accidental destruction arrives through a fat tap or a swipe rather than through a menu, so recovery matters more than the extra question, and `touch-destructive` already puts distance between the destructive control and the frequent one. Where the destruction is what the user deliberately chose, the button carrying it out is not styled as the destructive one: it is performing their intent, and the escape beside it is what needs the emphasis.
|
|
33
|
+
|
|
34
|
+
## <Rule id="fb-undo" description="Either the work waits inside a real window, or it lands somewhere it can be fetched back from" />
|
|
35
|
+
|
|
36
|
+
Two shapes are honest and there is no third. Either the work has not committed yet and the window is the delay before it does, or it commits at once into a place the user can reach and take it back from, a trash, an archive or a recently deleted, where the restore is guaranteed to work. What is banned is the commit with nothing behind it: fire the delete, keep the Undo on screen, and undo becomes a re-create against a server that has already forgotten, which fails differently and sometimes silently.
|
|
37
|
+
|
|
38
|
+
- Write the window down in `STACK.md` and let it be the authority: a number where the host takes one, and the host's own named length where it takes only a name, in which case the window is however long that name lasts and no second number is invented beside it. The message offering undo never outlives the window, and the work commits when the window closes, when the message is dismissed, or when the user leaves the screen or backgrounds the app, whichever comes first. A visible Undo whose commit already fired, with no destination behind it, is the failure this rule opens by banning.
|
|
39
|
+
- Undo reaches as far as the action did. A delete that has already gone to the queue is past its window: `off-destructive-offline` and `state-queued`.
|
|
40
|
+
- A system gesture is not the only route. Shake and the three-finger swipe are invisible and undiscoverable, so undo is also reachable without one: the action on the message, a button in the bar or toolbar, or a named custom action on the affected node, which is what `a11y-gesture` asks for. The system gestures keep working alongside it, and nothing here redefines them.
|
|
41
|
+
- A screen where the user makes many small edits owes an undo stack, not one slot that the second edit overwrites.
|
|
42
|
+
|
|
43
|
+
## <Rule id="fb-place" description="The message lands on the smallest thing that contains it" />
|
|
44
|
+
|
|
45
|
+
`state-error` sets this scope for failures and `form-error` for fields. What this rule adds is that everything else obeys it too: a confirmation, a limit reached, a setting that took effect, all land on the smallest surface that contains the cause, and a fact about a single control never takes the whole screen. Anything that could be said next to the control is not a dialog, and a dialog raised for a fact is a dialog raised for nothing.
|
|
46
|
+
|
|
47
|
+
The transient rung is the one that cannot obey. Its surface is fixed at the bottom edge, so it answers a control in the top bar from as far away as the screen allows, and it answers the bottom bar from under the thumb that just left. `layout-overlays` owns where it stacks and `touch-feedback` keeps the result off the touch point; what is left here is the choice of rung. A message that has to name its cause to make sense is inline, not transient.
|
|
48
|
+
|
|
49
|
+
## <Rule id="fb-duration" description="The host owns the duration, and where there is no host the component declares one" />
|
|
50
|
+
|
|
51
|
+
Hosts come in two shapes and the rule differs by shape. A named host takes short, long or indefinite and nothing between, which is Compose Material3: ask it by name, and accept that an exact number cannot be expressed through it at all. A numeric host takes a duration, which is the Flutter snackbar and the Android view snackbar, the latter also accepting its two names. Even the names disagree across hosts in one design system: the view snackbar runs 1500ms and 2750ms where Compose starts at 4000ms and 10000ms. So a duration never comes from the screen. It is the host's name, or it is one number written in `STACK.md` and read from there by every call that needs one.
|
|
52
|
+
|
|
53
|
+
Where none exists, which is every hand-built bar and every transient message on iOS, the choice does not disappear, it moves: the codebase owns one component, and that component fixes the duration, the dismissal and the announcement for the whole app in `STACK.md`. A screen that passes its own milliseconds has reinvented the host badly.
|
|
54
|
+
|
|
55
|
+
- Where the host does not apply the user's timeout, apply it: `getRecommendedTimeoutMillis` on Android takes the original duration plus flags for icons, text and controls, and Compose reaches the same thing through the accessibility manager. A component built here asks the same service before it starts a timer of its own.
|
|
56
|
+
- A message offering an action does not race the person reaching for it. Compose defaults an action-bearing snackbar to indefinite, so it stays until it is used or dismissed, and code that assumes it clears itself leaves it on screen. A hand-built bar carrying an action makes that call deliberately, because nothing sets it a default.
|
|
57
|
+
- Every transient message is dismissible by the user. On the Android view system swipe to dismiss only exists when the host is a `CoordinatorLayout`, so outside one, and in anything built here, there is a close affordance or there is no way out.
|
|
58
|
+
- Nothing exists only inside it: retry, undo and the detail behind the message all keep a permanent home, which `a11y-alt-input` already requires of anything that dismisses itself on a timer. What the phone adds is the rotation, after which the bar is gone for good and the user who was mid-step never sees it again.
|
|
59
|
+
|
|
60
|
+
## <Rule id="fb-reach" description="A message that is only drawn, or only felt, was not delivered" />
|
|
61
|
+
|
|
62
|
+
The route differs by platform and both count as delivery. Where there are live regions, on Android views, Compose and Flutter, the region carrying the message is marked live, and on Android that is the only route left now that `announceForAccessibility` and the `TYPE_ANNOUNCEMENT` event are deprecated. iOS has no live region, so the message is posted as an announcement instead. `a11y-announce` holds the mechanics and the polite versus assertive call. A platform snackbar host speaks its own text. A bar built by hand out of a positioned view is drawn and never announced: its words sit in the tree, reachable by exploring for them, and nowhere in the user's ear until it takes whichever of those two routes its platform has.
|
|
63
|
+
|
|
64
|
+
Haptics accompany a message and never carry it. `touch-feedback` owns the vocabulary and `sense-haptic` the hardware and the switches under it, so the rule here is only the pairing: a success or error pattern fires alongside something visible, never instead of it, because the phone is as often on a table as in a hand. Color follows `color-not-alone`.
|
|
65
|
+
|
|
66
|
+
## <Rule id="fb-queue" description="Coalesce by cause, and drop the backlog rather than replaying it" />
|
|
67
|
+
|
|
68
|
+
Three requests in flight on a slow radio come back as three failures within a second of each other. How many may be on screen is `layout-overlays`, and iOS asks that two alerts are never up at once, so the design question here is not the visible message, it is the other two.
|
|
69
|
+
|
|
70
|
+
- Coalesce by cause. Three failures of the same kind are one message with a count, not a queue three deep. No host does this for you, and the two Android ones fail in opposite directions: the view host shows one at a time and dismisses whatever was there, so an uncoalesced message is lost without a trace, while the Compose host serialises them and suspends each call until the one in front has been dealt with, which is the stale backlog the next bullet bans and which wedges behind an action-bearing message that never times out by itself. Coalescing is code on every stack, and cancelling what is already pending is half of it.
|
|
71
|
+
- Drop what has gone stale. A message about a screen the user has already left never shows, and a backlog that plays back after they move on describes a past they cannot act on.
|
|
72
|
+
- Stacking above the bottom bar, the floating button and the inset is `layout-overlays`.
|
|
73
|
+
|
|
74
|
+
## <Rule id="fb-survives" description="A confirmation the user never saw did not happen" />
|
|
75
|
+
|
|
76
|
+
Rotation rebuilds the screen, the system reclaims the process while it is in the background, and both are ordinary on a phone. A message fired as a side effect during a build or a composition either vanishes on the rotation or fires again on every one, and both versions ship.
|
|
77
|
+
|
|
78
|
+
Hold the message as state with a consumed flag, so it survives the rebuild once and only once. A blocking dialog is state under the same rule, including whatever action it is holding: an alert reconstructed after a process death with its callback gone is a dialog whose buttons do nothing. Where the screen itself comes back is `nav-restore`, and what the app admits it lost is `state-interrupt`.
|
|
79
|
+
|
|
80
|
+
## <Rule id="fb-blocking-shape" description="If it has to block, it is small, finite and escapable" />
|
|
81
|
+
|
|
82
|
+
- Pick the surface before the wording. A yes or no about one irreversible thing is an alert. Anything offering choices about an action the user deliberately started is not: that is an action sheet (`confirmationDialog` in SwiftUI), whose stack puts the destructive choice at the top and the escape at the bottom, or, on Android, a dialog or a bottom sheet, where the choices are a row of roles rather than a stack: the confirming action, the dismissive one beside it, and a third only where a real third answer exists. Building every confirmation as an alert spends the loudest surface on the ordinary case.
|
|
83
|
+
- At most 3 buttons. On Android the builder settles it: one positive, one negative, one neutral, and no fourth slot to fill. Nothing on iOS enforces the ceiling, so there it is a rule the code keeps by itself. A fourth choice on either platform means the surface is a sheet or a screen.
|
|
84
|
+
- A cancel is present whenever one of the options destroys something, it is not the default, and it sits at the bottom of a stack or on the leading side of a row, away from the destructive one that `touch-destructive` keeps at a distance.
|
|
85
|
+
- It does not scroll. A dialog with enough content to scroll is a screen, so build the screen: scrolling under a row of buttons is an accidental tap waiting to happen.
|
|
86
|
+
- Buttons are named by their result, which is `button-label`. A dialog whose answers are yes and no makes the user reread the question to find out what they agreed to.
|
|
87
|
+
- No blocking progress. Android deprecated its progress dialog for the reason that decides this whole file, that it stopped the user touching anything while the work ran. Nothing about the other platform makes one better there. Waiting is `state-loading`, and a dialog over a dialog is `nav-modal`.
|
|
88
|
+
|
|
89
|
+
## <Rule id="fb-unprompted" description="An interruption the user did not cause starts at the bottom of the ladder" />
|
|
90
|
+
|
|
91
|
+
Every rung above answers something the user just did. A promotion, a paywall raised mid-session, a what's new sheet, a survey, a full-screen ad: nobody asked for any of it, so none of it gets the rung a real answer would.
|
|
92
|
+
|
|
93
|
+
- It takes the quietest rung that can carry it and never the blocking one. A dialog is for a decision the user cannot defer, and this is one they never opened. One surface is the stated exception, and only in the shape `ads-placement` earns: a full-screen ad closing a segment the user just finished, never standing in front of the next one, carrying the exit present in its first frame that `ads-close` requires. A rewarded ad is not an exception at all, because the user tapped the offer and it is no longer uninvited (`ads-rewarded`).
|
|
94
|
+
- It waits for a finished task. Firing at launch costs the user the reason they opened the app, and firing mid-flow costs them the flow.
|
|
95
|
+
- One tap closes it, the close is the plain one and not a trick, and the dismissal is remembered for a period written in `STACK.md` rather than asked again on the next screen.
|
|
96
|
+
- It never borrows the shape of a system message. An app promotion drawn as a permission prompt or a system alert is asking for a tap the user did not agree to give.
|
|
97
|
+
|
|
98
|
+
## <Rule id="fb-review-prompt" description="The ask for a rating is the loudest thing the app does" />
|
|
99
|
+
|
|
100
|
+
Use the system prompt and nothing else. The system rate limits it, at most 3 per app per 365 days on iOS and an unpublished quota on Play, so the app does not get to know whether anything appeared.
|
|
101
|
+
|
|
102
|
+
- No question in front of it. Asking whether the user is enjoying the app and routing only the happy ones onward is banned outright on Play, which allows nothing to be asked before or while the card is shown, and it costs the app the negative feedback it needed while spending the moment the system prompt was timing for itself.
|
|
103
|
+
- Not on a button. Both platforms say the ask does not follow from something the user tapped, and the prompt may not appear when it is called anyway, which leaves a control that does nothing. A deliberate ask opens the store listing on its write-a-review route instead.
|
|
104
|
+
- Not during a task, not during the first run, and not while something is being fixed. It goes after a moment that went well: `onboard-defer`.
|
|
105
|
+
- The card is shown as the system draws it, with nothing over it, around it, or removing it once it appears.
|
|
106
|
+
|
|
107
|
+
<Check>
|
|
108
|
+
|
|
109
|
+
<Verify rule="fb-ladder">Every message takes the quietest rung that works, no actionable Android message is a `Toast`, no transient message offers more than 1 action, and the only dialog standing in front of the first screen is the one `onboard-ask-order` allows. A flow raising more than one blocking dialog is reported as a problem with the flow rather than counted as a violation.</Verify>
|
|
110
|
+
<Verify rule="fb-silent-success">No success message duplicates a result the screen already shows, and the ones that remain are for outcomes the screen cannot show.</Verify>
|
|
111
|
+
<Verify rule="fb-confirm-test">Every alert that stops the user is both rare and irreversible, and everything else acts and offers undo.</Verify>
|
|
112
|
+
<Verify rule="fb-undo">Undo either holds the work for a window written down in `STACK.md` or commits into a place the user can restore from, the message offering it never outlives that window, and it is reachable without a system gesture.</Verify>
|
|
113
|
+
<Verify rule="fb-place">Each message sits at the smallest scope that contains its cause, nothing that fits beside a control is raised as a dialog, and a message that has to name its cause is inline rather than transient.</Verify>
|
|
114
|
+
<Verify rule="fb-duration">Every duration is the host's named length or the one number in `STACK.md`, never a value written at a call site, the user's accessibility timeout is applied, every message is dismissible, and nothing lives only inside one.</Verify>
|
|
115
|
+
<Verify rule="fb-reach">Every transient message is announced by its platform's own route rather than only drawn, and no outcome is carried by a haptic alone.</Verify>
|
|
116
|
+
<Verify rule="fb-queue">Repeats of one cause arrive as one message with a count, and a stale backlog is dropped instead of replayed.</Verify>
|
|
117
|
+
<Verify rule="fb-survives">Messages and dialogs are held as state with a consumed flag, so one rotation shows them once and not twice.</Verify>
|
|
118
|
+
<Verify rule="fb-blocking-shape">An alert is used only for a yes or no about one irreversible action, blocking surfaces carry at most 3 buttons, do not scroll, name their buttons by result, and never hold a progress bar.</Verify>
|
|
119
|
+
<Verify rule="fb-unprompted">Nothing the user did not ask for blocks or interrupts them, apart from the segment-boundary ad `ads-placement` allows: it waits for a finished task, closes in one tap, and stays closed for a stated period.</Verify>
|
|
120
|
+
<Verify rule="fb-review-prompt">The rating prompt is the system one, is not preceded by a question, is not wired to a button, and is not raised during onboarding.</Verify>
|
|
121
|
+
|
|
122
|
+
</Check>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Forms
|
|
2
|
+
|
|
3
|
+
Typing on a phone is the slowest, least accurate thing the device asks anyone to do. The field is a small box, the finger is imprecise, half the screen is keyboard, and the person is usually standing up and about to be interrupted. Every field is a chance to lose them, and the ones that lose them are rarely the hard questions: they are the field that opened the wrong keyboard, the label that vanished, and the error that arrived before the answer was finished.
|
|
4
|
+
|
|
5
|
+
The keyboard, its type per field and its return key belong to `touch-keyboard`. The emphasis and the in-flight behaviour of the submit control belong to `button-one-primary` and `button-state`. What a failure message says belongs to `state-error`, and what survives an interruption to `state-interrupt`. This file is about what the fields ask for and what happens to the answers.
|
|
6
|
+
|
|
7
|
+
Per-field keyboard types, return keys and autofill names for each stack are in `references/input-fields.md`, to be opened for one lookup.
|
|
8
|
+
|
|
9
|
+
## <Rule id="form-column" description="One field per row, at the full width of the column" />
|
|
10
|
+
|
|
11
|
+
Fields stack in a single column. Two fields side by side cost the reader on three counts, all of them mechanical: the eye leaves the vertical line it was following, each field loses half an already narrow width, and the label over a half-width field is the first thing to wrap once text scales up, per `type-scaling`.
|
|
12
|
+
|
|
13
|
+
One exception, and it is a test rather than a list. Two controls may share a row when they produce a single answer and one of them is a picker or a value of two to four characters: card expiry beside its security code, both read off the same card in one glance and both numeric; an amount beside its currency; a quantity beside its unit. The row survives only while each half still fits its label on one line at the largest accessibility text size. Where it does not, they stack.
|
|
14
|
+
|
|
15
|
+
Width carries no meaning here. On a wide form a short box hints at a short answer; in a single phone column every field is the same width, so the hint has to come from the keyboard, the mask and the maximum length instead.
|
|
16
|
+
|
|
17
|
+
## <Rule id="form-count" description="Every field is a keyboard round trip" />
|
|
18
|
+
|
|
19
|
+
An average checkout asks for eleven fields and can be answered in six to eight. Removing one field is worth more than styling all of them.
|
|
20
|
+
|
|
21
|
+
- Ask only for what cannot be derived. City and state follow from a postal code, the country from the device region, the currency from the account.
|
|
22
|
+
- Never ask for the same value twice. Confirm-email and confirm-password fields double the typing on the device where typing is worst, and a reveal toggle on the single password field does the same job better.
|
|
23
|
+
- Past six questions, split the form into labelled groups or into steps, related fields together and the personal ones last.
|
|
24
|
+
- Prefill anything already known, and leave it editable.
|
|
25
|
+
- Every hardcoded default is named in the code alongside the reason it is the default. A wrong default is worse than an empty field, because a filled field looks answered and gets scrolled past, and a default nobody can justify is an answer the user never reads.
|
|
26
|
+
|
|
27
|
+
## <Rule id="form-label" description="The label stays on screen while the field is being filled" />
|
|
28
|
+
|
|
29
|
+
A placeholder is not a label. It leaves on the first keystroke, which is exactly when someone looks back to check what they are answering, and on a phone the field and the keyboard are often the whole screen, so there is nothing above to look back at. It also reads as an answer to anyone scanning, and it is the weakest thing a screen reader can be handed. Apple's guidance allows a placeholder to stand in for the label where it is sufficient; on a form it is not, for the reasons above, and this rule overrides it.
|
|
30
|
+
|
|
31
|
+
- The label is visible the whole time the field holds a value, in one or two words. A floating label that rises on focus still counts; a hint that disappears does not.
|
|
32
|
+
- Every field is tied to its label in code, not merely positioned near it.
|
|
33
|
+
- Position follows the platform: above the field on Material and on the web, leading inside the row in an iOS grouped form.
|
|
34
|
+
- Where a placeholder remains, it shows an example of the value and is visibly lighter than entered text.
|
|
35
|
+
- Format rules, eligibility and the reason for a sensitive question go in helper text under the field, under 100 characters, and stay visible while the field is being typed into. Format hidden in a placeholder is gone at the moment it is needed.
|
|
36
|
+
|
|
37
|
+
## <Rule id="form-required" description="Mark the minority, and mark it with a word" />
|
|
38
|
+
|
|
39
|
+
If most fields are required, mark the optional ones. If most are optional, mark the required ones. Marking every row costs the reader the scan and tells them nothing.
|
|
40
|
+
|
|
41
|
+
Use the word "Optional" rather than an asterisk alone. A phone form is read one field at a time, and the legend that explains the asterisk has scrolled off the top by the second question, so the mark has to carry its meaning where it stands. An asterisk is also announced as a star. The mark goes in the label, never in the placeholder, where it disappears with everything else.
|
|
42
|
+
|
|
43
|
+
## <Rule id="form-input" description="Configure the field before the finger arrives" />
|
|
44
|
+
|
|
45
|
+
Each field declares what it holds, and the rest follows from that declaration. The keyboard type and the return key it produces are `touch-keyboard`; what the field does with what arrives is here.
|
|
46
|
+
|
|
47
|
+
- A numeric code or a card number takes a numeric keyboard over a text field, never a number field with its stepper, which also drops leading zeros.
|
|
48
|
+
- Capitalise names and street lines. Turn capitalisation and autocorrect off for email, usernames, codes and anything the dictionary will not know. Autocorrect on an email field silently substitutes a value the user believes they typed.
|
|
49
|
+
- Mask and format as they type, so spaces in a card number and separators in a phone number are the field's job rather than a rejection afterwards.
|
|
50
|
+
- Where the value set is closed, use a picker, a date picker or a segmented control. A closed set typed by hand is an error being manufactured for later.
|
|
51
|
+
- Focus a field on open only when the screen exists for that one field, such as search or a code. Anywhere else the keyboard covers the form before it has been read.
|
|
52
|
+
- On mobile web the field's own text size is a layout decision. Safari on iOS zooms the page into any field it is about to focus whose text is under 16px, and it does not zoom back out, so the user finishes the form on a page wider than the screen with the submit button off to one side. Set 16px or larger on the field itself. The viewport is the wrong lever for this: Safari has ignored `user-scalable`, `minimum-scale` and `maximum-scale` on a web page since iOS 10, precisely so that a page cannot take zoom away from the user, and where those values do still apply, which is a web view embedded in an app, what they buy is a page nobody can enlarge.
|
|
53
|
+
|
|
54
|
+
## <Rule id="form-autofill" evidence="device" description="The fastest field is the one the platform fills" />
|
|
55
|
+
|
|
56
|
+
Both platforms will fill a whole form from the password manager, the contact card, the wallet and an arriving SMS, and none of it happens unless each field declares its content type. This is the highest value line in a form and it is the line generated code leaves out.
|
|
57
|
+
|
|
58
|
+
- Declare a content type per field: email, username, current password, new password, name, address lines, postal code, country, telephone, card number, expiry, security code.
|
|
59
|
+
- Group the fields of one credential and tell the platform the form is finished when it is submitted, or nothing is offered for saving and the next visit is typed again from scratch.
|
|
60
|
+
- A one time code is one field with the one time code content type. Six boxes drawn over that one field are fine; six separate inputs break both autofill and paste, which were the only reasons the field was fast.
|
|
61
|
+
- A sign-in screen puts the saved credential, the biometric or the passkey ahead of the typed password. Typing a password on glass is the slowest path the device offers.
|
|
62
|
+
- A new password field declares itself as new, so the manager offers to generate and store one instead of watching someone invent it.
|
|
63
|
+
- Paste is never blocked on a password, a one time code or a card field, and no field strips or reformats a pasted value on arrival. Blocking paste defeats the manager every content type above was declared for.
|
|
64
|
+
|
|
65
|
+
## <Rule id="form-validate" description="Not while they are still typing" />
|
|
66
|
+
|
|
67
|
+
Per-keystroke validation reports an error on every value on its way to being right: an email address is invalid until its final character. Validate when the field loses focus, or after 500 to 1000ms without typing.
|
|
68
|
+
|
|
69
|
+
- Never validate a field on focus. An error on something untouched is an accusation.
|
|
70
|
+
- Three things validate live, because live is their whole purpose: password strength, username availability and a character counter.
|
|
71
|
+
- Accept the shapes people type. Spaces in a card number, brackets around a dialling code, a trailing space from the keyboard: normalise them rather than refusing them. A phone keyboard puts those marks there.
|
|
72
|
+
- A field that cannot hold an invalid value needs no validation at all, which is why the mask and the picker in `form-input` are the cheaper fix.
|
|
73
|
+
- Never clear a field because it failed. Retyping a value on a phone is a punishment for a typo.
|
|
74
|
+
|
|
75
|
+
## <Rule id="form-error" description="The message sits with the field it is about" />
|
|
76
|
+
|
|
77
|
+
The message goes under its field, on screen at the same time as the field, with the first failing field scrolled into view and focused when a submit fails. A summary above the form is allowed in addition to those messages and never in place of them: on its own it is a list the user has to scroll away from before they can act on it.
|
|
78
|
+
|
|
79
|
+
- What the message says and how it says it are `state-error`. What this rule adds is the room it gets: the message shares the screen with the keyboard and has about one line, so the fix has to be in the first few words.
|
|
80
|
+
- The error is not a color. It carries an icon or the message itself, per `color-not-alone`, and the label stays readable rather than being repainted red.
|
|
81
|
+
- One message per field, replacing the helper text rather than stacking above it, so the row does not grow and push the submit control off screen.
|
|
82
|
+
|
|
83
|
+
## <Rule id="form-persist" evidence="device" description="The form outlives the process" />
|
|
84
|
+
|
|
85
|
+
A phone form is interrupted by definition: the code arrives in another app, a call lands, the OS reclaims the process while the user is in their password manager. Returning to an empty form is the most expensive failure in this file.
|
|
86
|
+
|
|
87
|
+
- What survives backgrounding, process death and a configuration change, text size as much as rotation, is `state-interrupt`, and where the user lands on the way back is `nav-restore`. The rule here is the outcome, not the mechanism: the values are on screen again. Which mechanism gets them there is the stack's, and on some stacks it is already the default.
|
|
88
|
+
- The field that had focus comes back too, so the keyboard reopens on the question that was being answered.
|
|
89
|
+
- A form of six or more questions writes a draft it offers back on the next visit, or `STACK.md` records the decision not to. Losing it silently is neither.
|
|
90
|
+
|
|
91
|
+
## <Rule id="form-steps" description="A step is a screen, and back moves one step" />
|
|
92
|
+
|
|
93
|
+
Splitting a long form into steps only helps if moving back through them works the way the phone already works.
|
|
94
|
+
|
|
95
|
+
- Show which step of how many. Without it the form has no visible end, and the choice to carry on is made with nothing to base it on.
|
|
96
|
+
- System back, the Android gesture and the iOS edge swipe move one step back rather than leaving the flow. Leaving is a deliberate action with a confirmation, because it discards every answer behind it.
|
|
97
|
+
- A step re-entered still shows what was typed into it, forwards as well as back.
|
|
98
|
+
- The last step names what submitting will do, so nobody presses it to find out.
|
|
99
|
+
|
|
100
|
+
## <Rule id="form-submit" description="One action, and the input survives its failure" />
|
|
101
|
+
|
|
102
|
+
One submit per form, with its emphasis from `button-one-primary` and its in-flight state from `button-state`.
|
|
103
|
+
|
|
104
|
+
- No Clear or Reset control on a data entry form. It is a full-form undo parked next to the submit button on a surface where taps land approximately, and the price of a mis-tap is retyping all of it. A filter or search sheet is the exception: Clear all there is the way out of a filter state, and it costs one tap to rebuild.
|
|
105
|
+
- Failure keeps everything, and what a retry returns to is `state-retry`. A network error that empties the form is worse than the network error.
|
|
106
|
+
- Success says what happened and where the person now is, in place or on the screen they land on.
|
|
107
|
+
|
|
108
|
+
<Check>
|
|
109
|
+
|
|
110
|
+
<Verify rule="form-column">Every field has its own row at the full content width, and any shared row produces one answer whose halves still fit their labels at the largest text size.</Verify>
|
|
111
|
+
<Verify rule="form-count">The visible fields were counted, none asks for the same value twice, six or more questions are grouped or stepped, what is known is prefilled, and every default carries the reason it is one.</Verify>
|
|
112
|
+
<Verify rule="form-label">Every field has a label that stays visible, bound to it in code and positioned per platform, no placeholder is doing a label's job, and helper text stays under 100 characters.</Verify>
|
|
113
|
+
<Verify rule="form-required">Required or optional is marked on the minority only, as a word, in the label.</Verify>
|
|
114
|
+
<Verify rule="form-input">Capitalisation, autocorrect and any mask are set per field, closed value sets use a picker rather than free text, nothing takes focus on open except a single-field screen, and on mobile web no field's text sits under 16px.</Verify>
|
|
115
|
+
<Verify rule="form-autofill">Every field declares its autofill content type, credential fields are grouped and committed on submit, the one time code is a single field, and paste is blocked nowhere.</Verify>
|
|
116
|
+
<Verify rule="form-validate">Nothing validates per keystroke or on focus, blur or a 500 to 1000ms pause triggers it, and accepted formats are normalised rather than rejected.</Verify>
|
|
117
|
+
<Verify rule="form-error">Each message sits under its own field, names the fix in its first few words, does not depend on color, no summary stands in for those messages, and the first failure is scrolled to and focused.</Verify>
|
|
118
|
+
<Verify rule="form-persist">Values, scroll and the focused field come back after backgrounding and process death, and a form of six or more questions either drafts or records that it does not.</Verify>
|
|
119
|
+
<Verify rule="form-steps">A stepped form shows the step and the total, system back moves one step rather than out, answers survive going back, and the last step names what submitting does.</Verify>
|
|
120
|
+
<Verify rule="form-submit">One submit control, no reset outside a filter sheet, and nothing lost on failure.</Verify>
|
|
121
|
+
|
|
122
|
+
<Device>`form-persist` is answered by backgrounding the app with the form half filled and coming back, not by reading the state code. `form-autofill` is answered by triggering the platform's own fill on a device, because a content type spelled wrong fails silently and looks exactly like one spelled right.</Device>
|
|
123
|
+
|
|
124
|
+
</Check>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Icons and imagery
|
|
2
|
+
|
|
3
|
+
On a phone the icon is frequently the whole control. A glyph in a tab bar or a toolbar, beside a label that had to be shortened or was never written, is what the user aims a thumb at. And a picture reaches the screen late, over a cellular link, into a column exactly one image wide, so the space it will occupy has to exist before it does.
|
|
4
|
+
|
|
5
|
+
Both fail the same way: the set was assembled rather than chosen, and the box was sized by the bytes rather than by the layout.
|
|
6
|
+
|
|
7
|
+
Sizes, axis ranges, density buckets and asset paths are in `references/icon-and-image-assets.md`. This file is the rules.
|
|
8
|
+
|
|
9
|
+
## <Rule id="icon-one-set" description="An icon set is a set, not a collection" />
|
|
10
|
+
|
|
11
|
+
One set for the whole app, at one weight and one style. Two sets on one screen is the defect that reads from across the room: it takes no interaction to find and no expertise to name, and it is what a screen assembled out of search results looks like.
|
|
12
|
+
|
|
13
|
+
- Take the platform's set unless something rules it out. It arrives already matched to the system font, ready to scale with the text setting once it is configured to, and already carrying the variants the platform's own bars expect.
|
|
14
|
+
- A custom set is a decision rather than a leftover: one grid, one stroke width, one corner treatment, one perspective, and enough detail removed that the glyph survives at the size it is actually drawn. A custom symbol on iOS has to match the system ones in detail, optical weight, alignment and perspective, or it reads as borrowed.
|
|
15
|
+
- The selected state comes from the set's own fill, the `FILL` axis or the filled variant, not from a second file drawn by hand. On iOS the system updates that appearance itself inside standard bars and buttons. On Android and in Flutter the bar takes the state from you, so the selected destination is handed the filled variant explicitly. That the selection then reads without depending on colour is `button-tabs`.
|
|
16
|
+
- Directional glyphs turn around in a right-to-left language and a few of them must not. Which is which is `l10n-no-mirror`.
|
|
17
|
+
- Icons live in the theme, reached by name, so the whole set can be swapped at once. Twenty asset paths typed at twenty call sites is twenty places the next set will not reach.
|
|
18
|
+
- Count the icon dependencies. The answer is one, or one plus a stated reason.
|
|
19
|
+
|
|
20
|
+
## <Rule id="icon-weight" evidence="device" description="The icon is sized against the label, not against the box" />
|
|
21
|
+
|
|
22
|
+
An icon next to text is part of that line, and every property it has is borrowed from the text.
|
|
23
|
+
|
|
24
|
+
- Match the weight. An icon heavier than its label turns the picture into the heading, and the platform sets exist to make this exact: the symbol weights map one to one onto the font weights.
|
|
25
|
+
- On iOS, align on the text baseline rather than on the centre of the line box: every system symbol carries baseline information and `firstTextBaseline` uses it. Centre alignment is what makes an icon look like it is floating a pixel high. A Compose or Flutter `Icon` publishes no baseline for a parent to align to, so there the icon is centred against the line box and the residue is corrected by eye.
|
|
26
|
+
- Where the set is a variable font with an optical size axis, set that axis alongside the size, because the two do not track: Flutter pairs a default size of 24 with a default optical size of 48, so a project that has brought in Material Symbols draws a 24dp glyph at the stroke meant for a 48dp one until it sets `opticalSize`. SF Symbols answers the same need with scale, and a Compose `Icon` drawing a vector has no such axis at all.
|
|
27
|
+
- An icon that labels text grows with the user's text setting. Flutter's `applyTextScaling` is off until it is turned on, in the widget or the `IconTheme`.
|
|
28
|
+
- Asymmetric glyphs need optical centring, and the offset belongs inside the asset as padding, so that centring the box centres the picture. The correction is small and it is the difference between a toolbar that looks drawn and one that looks placed.
|
|
29
|
+
- Light artwork on a dark ground blooms. Where the set has a grade axis, take it down rather than dropping a weight.
|
|
30
|
+
|
|
31
|
+
An icon rarely carries a verb on its own, so what is written next to it is `button-label` and what is spoken instead of it is `a11y-name`. Meaningful icons owe the same contrast as any other non-text mark: `color-contrast`. And the glyph is only the drawing: the target around it is a separate object with its own floor, which is `touch-floor`.
|
|
32
|
+
|
|
33
|
+
## <Rule id="icon-no-emoji" description="An emoji is content, never an icon" />
|
|
34
|
+
|
|
35
|
+
Emoji inside a message, a reaction, or a name somebody typed is content and stays. Emoji standing in for an icon is the most reliable tell of a generated screen, and it is not a shortcut, because none of the four things an icon does survives it.
|
|
36
|
+
|
|
37
|
+
- It is drawn by the system emoji font, which is a different picture on each platform and each release. The glyph that shipped is not the glyph that appears.
|
|
38
|
+
- It ignores tint, weight and text style, so it can neither match the label beside it nor follow the theme, and it stays in colour inside a monochrome bar.
|
|
39
|
+
- Its reading is cultural and its variants carry skin tone and gender, so it does not translate and it cannot be reviewed by the person translating it.
|
|
40
|
+
- A screen reader speaks its catalogue name, so a control announces a picture instead of an action.
|
|
41
|
+
|
|
42
|
+
The same applies to a glyph pulled out of a typeface drawn for prose. A check mark, an arrow or a bullet borrowed from the body face is the emoji defect in a quieter coat. An icon font that ships as an icon set is the opposite case and is exactly what to use.
|
|
43
|
+
|
|
44
|
+
## <Rule id="icon-vector" description="Vector where the artwork allows, densities where it does not" />
|
|
45
|
+
|
|
46
|
+
- Flat artwork, meaning icons, marks, line illustrations and anything built from paths, ships as vector. One file covers every density and every size, and nothing has to be regenerated when a size changes.
|
|
47
|
+
- A tintable vector is authored in one solid colour so the theme's tint lands on it cleanly. An icon with its colours baked in cannot follow a role and cannot follow dark.
|
|
48
|
+
- Photographs stay raster. A raster used as an interface asset is authored at every density the platform asks for, which is not the same as carrying all of them in the binary: what each device downloads is `perf-size`. Authored at one density only, it is soft on a 3x screen or lands more decoded pixels in the box than the box has, which is `perf-decode`.
|
|
49
|
+
- Vector is not free at every size: a large or heavily pathed drawable costs more to draw than the bitmap it replaced, which is why Android recommends keeping an in-app vector drawable at 200 by 200 dp or under. iOS publishes no equivalent limit. The launch surface icon is outside that ceiling: it is a vector at the size the platform fixes for it, which is `splash-contents`.
|
|
50
|
+
|
|
51
|
+
## <Rule id="icon-reserve" description="The box exists before the bytes do" />
|
|
52
|
+
|
|
53
|
+
Every image whose source is a URL gets its dimensions from the layout, decided before the request is made, and those dimensions are also what it decodes to: `perf-decode`. Row images are `list-images`; this is everything else, the header, the hero, the card, the article body, the avatar.
|
|
54
|
+
|
|
55
|
+
- The box is the row rule applied off the list: `list-images`. What stands in it while the bytes are in flight is `state-loading`.
|
|
56
|
+
- Nothing below the image moves when it lands. In one column that reflow is the rest of the screen, under a thumb already on its way down.
|
|
57
|
+
- Some stacks give a bundled image its size and give a remote one nothing. In React Native a `uri` source has no intrinsic dimensions, so it needs an explicit width and height, and the fast link on the development machine hides what a slow one does.
|
|
58
|
+
- The image that never arrives is a designed state at the same dimensions, not a gap: `state-error`.
|
|
59
|
+
|
|
60
|
+
## <Rule id="icon-crop" description="The surface decides the ratio, the photo does not" />
|
|
61
|
+
|
|
62
|
+
Fix one aspect ratio per surface, once, then crop everything entering it to fill. A surface may instead offer a short fixed list of ratios and let each item pick from it, which is how a feed of user photographs works. What it may not do is take an arbitrary ratio out of the bytes, because in one column the shape of the image is the shape of the screen, and that hands the layout to whatever the last user uploaded.
|
|
63
|
+
|
|
64
|
+
- Fill and clip. Letterboxing puts bars inside the content, and stretching to fit is a defect users see and cannot name.
|
|
65
|
+
- Decide what a portrait photo loses inside a landscape frame before one arrives. A centre crop keeps the middle, and faces, text and the subject of the shot are frequently not in the middle.
|
|
66
|
+
- Fit-inside is right where the whole image is the point: a logo, a scanned document, a diagram. There the frame keeps its own background instead of leaving transparent bars.
|
|
67
|
+
- Where a crop is destructive to the user's own content, the frame is a preview and the full image is one tap away.
|
|
68
|
+
|
|
69
|
+
## <Rule id="icon-depicts" description="Artwork that depicts nothing is spending the screen" />
|
|
70
|
+
|
|
71
|
+
The blurred oval behind the form, the glow under the logo tile, the three gradient bands standing where a feed's photographs go. Each one takes the area a person looks at first and reports nothing back, and that is the shape a generated screen has: the only decision behind the biggest element was to fill it.
|
|
72
|
+
|
|
73
|
+
- Anything larger than a touch target that is not text, a control or data depicts something this product can name. The item being bought, the route being taken, the record being read, the state being waited on.
|
|
74
|
+
- A container is not the artwork. An icon centred in a rounded tile is a hero that decided nothing, and on a first screen it is the most common form of this defect.
|
|
75
|
+
- Where the real image does not exist yet, draw the subject, hold the frame it will occupy (`icon-reserve`), or leave the space. A placeholder that depicts nothing is worse than an honest gap, because it looks finished.
|
|
76
|
+
- Texture is not subject. A pattern, a grain pass or a wash over artwork that already shows something is treatment. The same wash on its own is the defect.
|
|
77
|
+
- Abstract is allowed where the product is abstract, and the test does not care about style: somebody who has not seen the app can say what the picture shows.
|
|
78
|
+
|
|
79
|
+
## <Rule id="icon-alt" description="A picture is content or it is decoration, and it says which" />
|
|
80
|
+
|
|
81
|
+
Content describes what it shows. Decoration is hidden instead of described, which is `a11y-hidden`. Nothing sits between the two, both answers compile, and a screen where every image says nothing looks identical to one where every image is right.
|
|
82
|
+
|
|
83
|
+
- The description is what the picture shows, not what the file is: `contentDescription`, `accessibilityLabel`, `semanticLabel`. In one column the picture is frequently the whole payload of the screen, so for a user who is not seeing it that sentence is the screen.
|
|
84
|
+
- Two descriptions pass every automated check and carry nothing: the file name, and the word image, photo or icon.
|
|
85
|
+
- A chart, a diagram, or the illustration holding an empty state's meaning owes the sentence it is making rather than an inventory of its parts.
|
|
86
|
+
- An icon that is the only label on a control is not this rule. What is spoken there is the action, which is `a11y-name`.
|
|
87
|
+
|
|
88
|
+
## <Rule id="icon-dark" evidence="device" description="Artwork that cannot be tinted needs a second asset" />
|
|
89
|
+
|
|
90
|
+
A single-colour glyph needs no dark variant, because it is tinted from a theme role and follows it. Everything else does.
|
|
91
|
+
|
|
92
|
+
- Illustrations, marketing art, logo lockups and anything with colour baked in ship a light file and a dark file, selected through the asset system rather than by a conditional written inside a component.
|
|
93
|
+
- The asset carrying its own opaque background is the one to hunt for. A white-backed PNG on a dark surface is a white rectangle, and it survives review because review happens in light.
|
|
94
|
+
- An inverted copy is not a dark variant. Inverting artwork shifts every hue in it, and inverting a photograph is simply wrong: `color-dark-composed`.
|
|
95
|
+
- Screenshots of the product inside the product are recaptured in dark, or they are not shown in dark.
|
|
96
|
+
|
|
97
|
+
## <Rule id="icon-avatar" evidence="device" description="The fallback is the common case" />
|
|
98
|
+
|
|
99
|
+
Most accounts have no photo, so the fallback is the state to design first and the one that will be on screen most.
|
|
100
|
+
|
|
101
|
+
- Initials from the name, or a shape generated from a stable identifier, so the same person keeps the same avatar between sessions and between devices. One shared silhouette for every user is decoration, and a list of them carries no information at all.
|
|
102
|
+
- The fallback fills the same box the photo would, so a list of people keeps its rhythm while photos load.
|
|
103
|
+
- Never a broken image frame, an alt-text box, or the platform's missing-asset glyph. At the size an avatar is actually drawn that is a dark square with a question mark in it, repeated down the list.
|
|
104
|
+
- Initials come from the display name as the locale orders it, one or two characters, and they are measured against their generated background like any other text: `l10n-personal`, `color-contrast`.
|
|
105
|
+
- The missing name is a case too. Deleted accounts, invited users who never joined, and system actors all arrive at the same component.
|
|
106
|
+
- Never a drawn human face. It puts an invented person on somebody's account, it is the same face every time the component renders, and a column of them is three strangers who look related. Initials, a generated shape, or the product's own mark.
|
|
107
|
+
|
|
108
|
+
## <Rule id="icon-app" evidence="device" description="One asset, no words, no fine detail" />
|
|
109
|
+
|
|
110
|
+
The app icon is drawn at about the size of a fingertip, beside twenty others, inside a mask the launcher picks and at whatever smaller sizes the system generates for search and settings.
|
|
111
|
+
|
|
112
|
+
- No text in it. Words in an app icon are never translated and never read out, and at the drawn size they are texture.
|
|
113
|
+
- No screenshot of the interface, no hairline strokes, no small detail. Each of them survives the 1024px master and none survives the home screen.
|
|
114
|
+
- Ship the layers unmasked and square, with no shadow, bevel, glow or highlight painted in. The system applies its own, dynamically, and a pre-lit or pre-masked layer fights it and produces jagged edges.
|
|
115
|
+
- Respect the launcher's geometry, which fails at both ends. On Android the outer band of the canvas belongs to the mask and to the parallax effect, so a mark drawn to the edge loses its edge to whatever shape that launcher applies; a mark drawn too small floats in the middle of a canvas everyone else fills. The safe box has a floor as well as a ceiling, and both are in the reference.
|
|
116
|
+
- Ship the monochrome layer on Android, and the dark and tinted appearances on iOS. Both systems generate any variant that is not supplied, so the choice is not whether the app has one, it is whether anybody drew it. An alternate app icon needs its own full set.
|
|
117
|
+
- The mark is the product's own. System symbols may not be used in an app icon or a logo, and platform hardware may not be drawn inside one.
|
|
118
|
+
|
|
119
|
+
<Check>
|
|
120
|
+
|
|
121
|
+
<Verify rule="icon-one-set">One icon set is in use, with one dependency or a stated reason for a second, every glyph on a screen comes from that set at the weight of the text beside it, and a filled selected state is that set's own fill rather than a second file.</Verify>
|
|
122
|
+
<Verify rule="icon-weight">Every icon beside text matches its weight, scales with the user's text setting, sets the optical size wherever the set carries that axis, and sits on the text baseline on iOS.</Verify>
|
|
123
|
+
<Verify rule="icon-no-emoji">No emoji and no glyph borrowed from a prose typeface stands in for an icon, a bullet, an arrow or a button mark anywhere in the interface.</Verify>
|
|
124
|
+
<Verify rule="icon-vector">Flat artwork is vector and tintable in one colour, and every raster interface asset is authored at each density the platform asks for.</Verify>
|
|
125
|
+
<Verify rule="icon-reserve">Every remote image outside a list takes its dimensions from the layout before the request is made, and nothing below it moves when it lands.</Verify>
|
|
126
|
+
<Verify rule="icon-crop">Each image surface names one aspect ratio, or a fixed short list of them, plus one fill mode, and images crop rather than stretch or letterbox.</Verify>
|
|
127
|
+
<Verify rule="icon-depicts">Every region larger than a touch target that is not text, a control or data depicts something nameable in the product, and no glow, blob, wash or abstract gradient stands where an image belongs.</Verify>
|
|
128
|
+
<Verify rule="icon-alt">Every image either describes what it shows or is hidden as decoration, and no description is a file name or the word image.</Verify>
|
|
129
|
+
<Verify rule="icon-dark">Every asset that cannot be tinted has a dark counterpart selected by the asset system, and no asset carries an opaque light background.</Verify>
|
|
130
|
+
<Verify rule="icon-avatar">The avatar has a generated fallback at the same size, stable per user, covering the missing name, carrying no drawn human face, and no path renders a broken image.</Verify>
|
|
131
|
+
<Verify rule="icon-app">The app icon carries no text, ships unmasked layers with no baked effects, keeps its mark inside the safe box, and supplies the monochrome layer on Android and the dark and tinted appearances on iOS rather than letting the system invent them.</Verify>
|
|
132
|
+
|
|
133
|
+
<Device>Check `icon-dark`, `icon-avatar` and `icon-app` on a rendered screen in dark appearance, and `icon-weight` at the largest text step. All four pass a light-theme, default-size screenshot.</Device>
|
|
134
|
+
|
|
135
|
+
</Check>
|