trunative 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/cli.js +105 -0
- package/dist/commands/build.js +35 -0
- package/dist/commands/detect.js +91 -0
- package/dist/commands/doctor.js +110 -0
- package/dist/commands/graph.js +106 -0
- package/dist/commands/install.js +76 -0
- package/dist/commands/lint.js +87 -0
- package/dist/commands/rubric.js +104 -0
- package/dist/commands/spec.js +300 -0
- package/dist/compile.js +235 -0
- package/dist/detect/rules.js +208 -0
- package/dist/detect/types.js +36 -0
- package/dist/emit.js +77 -0
- package/dist/graph.js +288 -0
- package/dist/heuristics.js +185 -0
- package/dist/lock.js +18 -0
- package/dist/mdx.js +84 -0
- package/dist/paths.js +62 -0
- package/dist/skill.js +69 -0
- package/package.json +51 -0
- package/src/skills/SKILL.md +125 -0
- package/src/skills/flow/build.md +57 -0
- package/src/skills/flow/firebase.md +102 -0
- package/src/skills/flow/init.md +116 -0
- package/src/skills/flow/review.md +186 -0
- package/src/skills/flow/spec.md +149 -0
- package/src/skills/heuristics/accessibility.md +124 -0
- package/src/skills/heuristics/ads.md +140 -0
- package/src/skills/heuristics/auth.md +130 -0
- package/src/skills/heuristics/background-work.md +129 -0
- package/src/skills/heuristics/buttons.md +99 -0
- package/src/skills/heuristics/camera.md +127 -0
- package/src/skills/heuristics/chat.md +125 -0
- package/src/skills/heuristics/colors.md +129 -0
- package/src/skills/heuristics/copy.md +157 -0
- package/src/skills/heuristics/data-display.md +124 -0
- package/src/skills/heuristics/feedback.md +122 -0
- package/src/skills/heuristics/forms.md +124 -0
- package/src/skills/heuristics/icons-and-imagery.md +135 -0
- package/src/skills/heuristics/layout.md +125 -0
- package/src/skills/heuristics/lists.md +129 -0
- package/src/skills/heuristics/localization.md +128 -0
- package/src/skills/heuristics/maps.md +129 -0
- package/src/skills/heuristics/media.md +130 -0
- package/src/skills/heuristics/motion.md +113 -0
- package/src/skills/heuristics/navigation.md +116 -0
- package/src/skills/heuristics/network.md +118 -0
- package/src/skills/heuristics/notifications.md +121 -0
- package/src/skills/heuristics/offline.md +124 -0
- package/src/skills/heuristics/onboarding.md +103 -0
- package/src/skills/heuristics/payments.md +138 -0
- package/src/skills/heuristics/performance.md +111 -0
- package/src/skills/heuristics/permissions.md +125 -0
- package/src/skills/heuristics/privacy-ui.md +112 -0
- package/src/skills/heuristics/scrolling.md +114 -0
- package/src/skills/heuristics/search.md +127 -0
- package/src/skills/heuristics/sense.md +128 -0
- package/src/skills/heuristics/settings.md +129 -0
- package/src/skills/heuristics/sharing.md +102 -0
- package/src/skills/heuristics/sound.md +95 -0
- package/src/skills/heuristics/splashscreen.md +111 -0
- package/src/skills/heuristics/states.md +120 -0
- package/src/skills/heuristics/touch.md +95 -0
- package/src/skills/heuristics/typography.md +99 -0
- package/src/skills/heuristics/updates.md +129 -0
- package/src/skills/heuristics/webviews.md +114 -0
- package/src/skills/heuristics/widgets.md +128 -0
- package/src/skills/references/capability-checks.md +59 -0
- package/src/skills/references/fonts.json +339 -0
- package/src/skills/references/icon-and-image-assets.md +103 -0
- package/src/skills/references/input-fields.md +82 -0
- package/src/skills/references/launch-surface.md +94 -0
- package/src/skills/references/motion-tokens.md +89 -0
- package/src/skills/references/navigation-containers.md +51 -0
- package/src/skills/references/search-controls.md +49 -0
- package/src/skills/references/type-scales.md +60 -0
- package/src/skills/references/wireframe-frame.md +209 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Camera capture
|
|
2
|
+
|
|
3
|
+
A capture screen is aimed. The user holds the phone in one hand while the other holds the receipt, the label, the pet or the page, at whatever distance an arm reaches, in whatever light the room has. Nothing on it can be read from a static mockup, because the only background any control has is live video nobody on the team chose.
|
|
4
|
+
|
|
5
|
+
The preview itself is `sense-preview`: its fit and crop, the shutter being inert until the session is live, the screen held awake, and the scanner's time bound and camera-free route. Whether the hardware exists at all is `sense-absent`, the system taking it back is `sense-interrupted`, and saying it is running is `sense-running`. The permission belongs to permissions.md, from `perm-inventory` through `perm-answers`, playing back what was captured is `media-system-player`, and the app switcher snapshot is `priv-switcher`.
|
|
6
|
+
|
|
7
|
+
## <Rule id="cam-system-first" description="When one photo is the whole requirement, the system's capture screen is the capture screen" />
|
|
8
|
+
|
|
9
|
+
The phone already ships a capture screen the user has fired a thousand times, with a shutter, a torch, a lens switch and a retake. Drawing a second one buys a permission, a session lifecycle and every rule below it.
|
|
10
|
+
|
|
11
|
+
- Android's image capture intent hands the whole capture to the device camera app, so the calling app needs no camera permission, unless its own manifest declares `CAMERA`, in which case the action raises a `SecurityException` until that permission is granted. Without the output extra it returns a thumbnail under the extras key `data`; with it, the full frame lands at the URI supplied. Guard the intent, either by catching `ActivityNotFoundException` or by resolving it first.
|
|
12
|
+
- On iOS `UIImagePickerController` with `sourceType` `.camera` is the system capture UI and is not deprecated. Gate it on `isSourceTypeAvailable(_:)` and `availableMediaTypes(for:)`, expect portrait only, and accept that its one sanctioned customisation is `cameraOverlayView`. Movie capture stops at 10 minutes unless `videoMaximumDuration` says otherwise.
|
|
13
|
+
- Drawing the surface yourself is for when the surface is the feature: a scanner with its own reticle, a document edge finder, a recorder showing its own level. Picking a photo that already exists is never that, which is `perm-ask-less`.
|
|
14
|
+
|
|
15
|
+
## <Rule id="cam-shutter" evidence="device" description="The shutter is the biggest target on the screen, in the bottom third, on a ground the app owns" />
|
|
16
|
+
|
|
17
|
+
The subject is in the other hand, so the phone is held and fired one-handed. And the preview underneath is a moving image the app does not control, so no control on this screen has a background to measure itself against.
|
|
18
|
+
|
|
19
|
+
- The shutter's hit rect meets the floor in `touch-floor` and is the largest target on the screen. Every capture control sits in the bottom third (`touch-reach`), spaced by `touch-spacing`, inside the safe area (`layout-insets`) and clear of the platform capture indicator's bounds (`sense-running`).
|
|
20
|
+
- Each control carries its own opaque or scrimmed ground. `color-contrast` needs a measured pair and live video supplies none, so the pair is the glyph against the ground the app drew, not against whatever is being filmed.
|
|
21
|
+
- One press, one capture. The shutter is disabled for the duration of the capture rather than queueing a second frame behind the first.
|
|
22
|
+
- On iPhone the hardware camera control draws a system overlay from the bezel, so app controls stay outside that strip in both portrait and landscape, no control appears in both the overlay and the screen, and any name shown there stays short because it follows Dynamic Type and a long one covers the viewfinder. The shutter, torch and lens controls are icon-only, so each carries a name (`a11y-name`).
|
|
23
|
+
|
|
24
|
+
## <Rule id="cam-torch" description="The torch is read from the hardware and observed while the screen is open" />
|
|
25
|
+
|
|
26
|
+
The LED is the only light the user can add, and the hardware takes it away when the phone gets warm. The front camera has no LED at all, so the same button has to mean something else or not exist.
|
|
27
|
+
|
|
28
|
+
- Presence and availability are two reads. On iOS `hasTorch` reports the hardware and `isTorchAvailable` reports whether it is usable right now, going false when the device overheats and needs to cool off; both are observable. On Android `CameraInfo.hasFlashUnit()` reports the hardware and the observed torch state reports the value, which is off wherever there is no flash unit. Bind the control's enabled state to the live value rather than reading it once at bind time.
|
|
29
|
+
- On the front camera, offer the screen flash or offer no torch. Android's screen flash mode needs a screen-flash instance set first and always behaves like on rather than auto.
|
|
30
|
+
- The torch is off when the screen opens. Android's capture flash mode defaults to off, and iOS low-light boost also defaults to off, is settable only where supported, and may drop frames as it engages, so enabling either is a decision written down rather than a switch left on.
|
|
31
|
+
- Missing hardware removes the control rather than disabling it (`sense-absent`), settled by the presence read above rather than by a second query.
|
|
32
|
+
|
|
33
|
+
## <Rule id="cam-lens-zoom" description="The lens stops and the zoom range are read from the device, and zoom past the optical range is a crop" />
|
|
34
|
+
|
|
35
|
+
One binary installs on a phone with three rear lenses and on one with a single fixed lens, so a hardcoded row of 0.5x, 1x and 2x is wrong on the cheap device. A pinch also needs two hands, which is exactly what this screen does not have.
|
|
36
|
+
|
|
37
|
+
- The range comes from the device. On iOS the zoom factor runs from 1.0, the full field of view, to the active format's maximum; on Android the zoom state carries the minimum and maximum. Every lens stop drawn is one the device reported.
|
|
38
|
+
- Zoom is a centre crop. Past the format's upscale threshold the device scales that crop up and the quality goes with it, so the top of the range is not a free stop. Setting the factor jumps; ramping is the smooth change, and the configuration lock is taken first or the set raises.
|
|
39
|
+
- A pinch has a tap or stepper equivalent (`a11y-gesture`). Android's camera controller handles tap-to-focus and pinch-to-zoom for you, while the provider API and AVFoundation hand both back to the app.
|
|
40
|
+
|
|
41
|
+
## <Rule id="cam-aim" description="The frame drawn on the glass is the region the app actually reads" />
|
|
42
|
+
|
|
43
|
+
The user is holding the phone over a barcode or a form and cannot see the buffer being analysed. A reticle that does not match the analysed rectangle trains them to aim at the wrong place, and the app gets blamed for not reading a code that was never in frame.
|
|
44
|
+
|
|
45
|
+
- The drawn frame is derived from the same rectangle handed to the analyser: the region of interest on the iOS data scanner, the analyser's crop rect on Android. One value, two consumers.
|
|
46
|
+
- Reading text or a code has a resolution floor. The smallest meaningful unit of a code needs at least 2 pixels of width, and 2 of height for a 2D code, which puts an EAN-13 frame at 190 pixels wide and a dense PDF417 nearer 1156; analysing at 1280x720 or 1920x1080 is what lets a code be read from further away. Poor focus costs accuracy on its own.
|
|
47
|
+
- On-screen guidance names the condition: too far, too dark, hold still. Repeating that it is scanning tells the user nothing they cannot see. Codes are recognised at any orientation, so nothing asks the user to turn anything.
|
|
48
|
+
- `sense-preview` already requires a target, a time bound and a route that needs no camera. Where the thing being aimed at is a page, the iOS document camera controller is the platform's own answer to all of it.
|
|
49
|
+
|
|
50
|
+
## <Rule id="cam-scan-decides" description="A scan resolves by itself, once, and the app says which formats it reads" />
|
|
51
|
+
|
|
52
|
+
The hand that would press a confirm button is the hand holding the phone steady over the code. A scanner that waits for a tap loses the aim at the moment it had it.
|
|
53
|
+
|
|
54
|
+
- A scan surface the app drew accepts on recognition rather than on a tap, and the same value is not accepted twice before the screen is left.
|
|
55
|
+
- The formats scanned are an explicit list. Detecting everything is slower and it accepts things this screen was not for.
|
|
56
|
+
- Where the app draws no overlay of its own, the platform scanner is the scanner. Android's code scanner runs inside Play services, ships its own UI and needs no camera permission from the app, though its library is unbundled so the download is a state on the screen. The iOS data scanner arrives from iOS 16 behind two separate reads, one for whether the device supports scanning and one for the grant, and still needs its purpose string (`perm-purpose-string`); its own documented interaction is a tap on the highlighted item, so a screen that wants acceptance without a tap reads the recognised-items stream instead.
|
|
57
|
+
- The confirmation is `fb-silent-success` plus the one haptic `sense-haptic` allows once a scan resolves.
|
|
58
|
+
|
|
59
|
+
## <Rule id="cam-review" description="A capture is a proposal until the user accepts it" />
|
|
60
|
+
|
|
61
|
+
The phone was moving when the shutter fired and the preview is three inches wide, so the first frame is often unusable. The iOS picker returns only once the person picks the frame just captured, and on Android the confirmation belongs to whichever camera app answers the intent, so an app drawing its own shutter is re-providing a step it removed, and without it the only way to fix a photo is to start the flow again, backing out of a form the keyboard is already covering.
|
|
62
|
+
|
|
63
|
+
- Every capture path the app draws itself has a review state carrying accept and retake. Retake returns to a live preview and discards nothing already captured, which is the same obligation `sense-interrupted` places on a take the system stopped.
|
|
64
|
+
- Accept is the only thing that commits, uploads or closes the screen. What waits behind it is `state-queued` and `net-upload`; the drawn exit from a capture screen presented as a task is `nav-modal`.
|
|
65
|
+
- A cancel that would discard captures says what is lost, and says nothing at all when nothing has been captured yet.
|
|
66
|
+
|
|
67
|
+
## <Rule id="cam-unusable" description="A capture the app will not accept is a designed screen that names which thing is wrong" />
|
|
68
|
+
|
|
69
|
+
The capture happened in a moving hand, in the light that was available, at whatever distance the user could reach. Neither platform publishes a blur, exposure or framing test, so the verdict is the app's own and the message cannot hide behind the platform's.
|
|
70
|
+
|
|
71
|
+
- Every automatic rejection names its cause: too dark, out of focus, nothing found, wrong shape. The wording is `copy-error`.
|
|
72
|
+
- The capture stays on screen while the app says so, because the user cannot tell from a vanished thumbnail what to change.
|
|
73
|
+
- Retake, plus use anyway wherever the app can still proceed. A retry that loses the frame is the second failure `state-retry` names.
|
|
74
|
+
- A scan or a detection that found nothing is a `state-empty` case rather than a `state-error` one.
|
|
75
|
+
|
|
76
|
+
## <Rule id="cam-orientation" description="Rotation and mirroring are metadata, so the bytes are normalised before anything but a platform image view sees them" />
|
|
77
|
+
|
|
78
|
+
The device is turned freely while the app's own screen is usually locked to portrait, so the sensor and the screen disagree on every capture. System galleries and platform image views apply the orientation tag; a cross-platform image component, a backend, an inference pipeline and a coordinate-based crop do not. The sideways photo therefore appears only after the bytes have left the phone.
|
|
79
|
+
|
|
80
|
+
- Every capture path reads its rotation, from the EXIF orientation tag with its 8 values or from the frame's in-memory rotation degrees, then either bakes it into the pixels or forwards it with them. iOS encodes the frame in the sensor's native landscape orientation and writes right for a portrait capture, expecting the reader to rotate 90 degrees clockwise. Getting it right matters to face detection and any other analysis, not only to display.
|
|
81
|
+
- The front-camera mirror is a deliberate flag rather than something inherited from the preview: on iOS the photo output applies it with Exif tags and the movie output with a track matrix, and on Android it is the reversed-horizontal flag on the capture metadata.
|
|
82
|
+
- A screen with a locked orientation still updates target rotation from an orientation listener, on every use case except the preview. That listener is the provider path's obligation: Android's camera controller sets target rotation itself, the same way it handles tap-to-focus and pinch-to-zoom. Ignoring it hands the analyser wrongly rotated frames.
|
|
83
|
+
- What gets stripped on the way out of the app is `share-payload-clean`.
|
|
84
|
+
|
|
85
|
+
## <Rule id="cam-bytes" description="The capture asks for a size, and the file lands where the app said it would" />
|
|
86
|
+
|
|
87
|
+
The default is the sensor's largest frame at near-lossless compression, travelling over somebody's data plan onto a phone whose storage is already full. This is the one screen where a single tap generates tens of megabytes.
|
|
88
|
+
|
|
89
|
+
- Ask for a size. Android's capture defaults to the largest available resolution, prefers 4:3, and writes JPEG at 95 in the low-latency capture mode and 100 in the quality one. State the resolution and the compression the screen actually needs: a code being read wants no more than about 2 megapixels. The decoded cost of what is kept is `perf-decode`.
|
|
90
|
+
- Name the destination. App-specific storage is unreachable by other apps and goes with the uninstall, the shared media store is for files meant to be shared, and writing into the user's photo library is a separate add-only grant (`perm-scope`, `perm-purpose-string`).
|
|
91
|
+
- Nothing the user captured reaches an analytics or advertising payload (`priv-instrument`). App Store Review Guideline 5.1.2(vi) bars data gathered from camera, photo, depth or facial mapping APIs from marketing, advertising and use-based data mining, third parties included, and 4.10 bars monetizing the camera itself.
|
|
92
|
+
- The transfer is `net-upload` and `net-metered`, and the queue it waits in is `off-queue`.
|
|
93
|
+
|
|
94
|
+
## <Rule id="cam-limited" description="On a limited photo grant the screen shows the granted set as the granted set" />
|
|
95
|
+
|
|
96
|
+
The phone is where the user's whole photo library lives, so a grant of three photos and an empty library look identical on a small grid. The user who granted three then reads the app as broken. `perm-answers` owns the branch; this rule owns what a grid the app drew over the library has to draw, and a screen that hands the job to the system picker never reaches it.
|
|
97
|
+
|
|
98
|
+
- The grid tells a limited grant apart from an empty library in its own sentence, with a route to change the selection. Those are two of the three sentences `state-empty` asks for.
|
|
99
|
+
- Under a limited grant the selection is the whole of what the app can reach, and user albums can be neither created nor listed, so album affordances are absent rather than empty. An asset the app creates joins the selection by itself.
|
|
100
|
+
- Both platforms have the state. iOS reports it only through the access-level form of the authorization status, because the older status and request pair report a limited grant as authorized. Android grants selected photos and videos through `READ_MEDIA_VISUAL_USER_SELECTED`, and an app declaring no read-media permission at all is run in a compatibility mode whose grants last one session, which is the same state with a shorter life.
|
|
101
|
+
- The selection changes at any time, so it is re-read rather than cached: from the library change notification on iOS, and on resume on Android, where the grant can move between onStart and onResume. Widening it goes through a control the app drew that re-requests the read-media permission, never a silent re-prompt (`perm-ask-less`).
|
|
102
|
+
|
|
103
|
+
## <Rule id="cam-thermal" evidence="device" description="The capture screen counts what it binds, and degrades on purpose when the phone gets hot" />
|
|
104
|
+
|
|
105
|
+
The camera is the most expensive thing on the phone and the phone is a sealed object in a warm hand. The platform takes the hardware back rather than let it cook, and a screen binding four pipelines to draw one preview reaches that point sooner.
|
|
106
|
+
|
|
107
|
+
- Bind only the pipelines the screen draws, one instance each. Android allows one preview, one video capture, one image analysis and one image capture; with extensions on, only image capture plus preview is guaranteed, video capture is unavailable and analysis may not work. Combining preview, video capture and either of the other two on mid-tier hardware can require stream sharing, which the platform states costs processing, latency and battery, and some cameras allow the combination only at a lower resolution.
|
|
108
|
+
- System pressure gets a stated degradation: a lower resolution, the torch dropped, analysis throttled. On iOS it is one of 6 interruption reasons and the only one about heat, so it does not share an answer with another app holding the camera or with the app being sent to the background. Saying the capture stopped is `sense-interrupted`, and what an open session costs is `perf-power`.
|
|
109
|
+
|
|
110
|
+
<Check>
|
|
111
|
+
|
|
112
|
+
<Verify rule="cam-system-first">Every capture entry point that needs one photo goes through the platform capture intent or picker controller, each capture surface the app draws instead has a reticle, an edge finder or a level of its own, and the intent path is guarded by a resolve or a caught `ActivityNotFoundException` while the picker path is gated on `isSourceTypeAvailable(_:)`.</Verify>
|
|
113
|
+
<Verify rule="cam-shutter">The shutter's hit rect meets the platform floor and is the largest target on the screen, every capture control sits in the bottom third clear of the safe area, the platform indicator bounds and the hardware camera control's overlay strip in both orientations, each one carries its own opaque or scrimmed ground rather than relying on the preview and its own name, and the shutter is disabled for the duration of a capture rather than queueing a second frame.</Verify>
|
|
114
|
+
<Verify rule="cam-torch">The torch control is drawn from the hardware presence query with its enabled state bound to the live availability value rather than read once, the front-camera path offers the screen flash or no torch, and the torch is off when the screen opens.</Verify>
|
|
115
|
+
<Verify rule="cam-lens-zoom">The zoom range and every lens stop come from values the device reports rather than from constants, no offered stop sits above the format's upscale threshold, a zoom change ramps rather than jumps and takes the configuration lock first, and a pinch has a tap or stepper equivalent.</Verify>
|
|
116
|
+
<Verify rule="cam-aim">The drawn reticle is derived from the same rectangle handed to the analyser, the analysed resolution is set at the call site rather than left at the pipeline default and clears 2 pixels per module for the smallest format the screen accepts, and the guidance names the condition instead of repeating that it is scanning.</Verify>
|
|
117
|
+
<Verify rule="cam-scan-decides">A scan surface the app drew accepts on recognition rather than on a tap and never accepts the same value twice, the formats detected are an explicit list, and a screen drawing no overlay of its own uses the platform scanner on the platform's own terms.</Verify>
|
|
118
|
+
<Verify rule="cam-review">Every capture path the app draws has a review state with accept and retake, retake keeps what is already captured, accept is the only thing that commits, uploads or closes, and a cancel that would discard captures names what is lost while a cancel with nothing captured is silent.</Verify>
|
|
119
|
+
<Verify rule="cam-unusable">Every automatic rejection names its cause, keeps the capture on screen while it says so, offers retake plus use-anyway wherever the app can still proceed, and a scan or detection that found nothing renders the empty state rather than the error one.</Verify>
|
|
120
|
+
<Verify rule="cam-orientation">Every capture path reads its rotation and either bakes it in or forwards it, the front-camera mirror is set explicitly on the capture output rather than inherited from the preview, and a locked-orientation screen on the provider path updates target rotation from an orientation listener.</Verify>
|
|
121
|
+
<Verify rule="cam-bytes">Every capture sets a resolution and a compression rather than accepting the sensor maximum, the destination is named at the call site as one of app-specific storage, the shared media store or the user's library under an add-only grant, and no captured image or derived face data reaches an analytics or advertising payload.</Verify>
|
|
122
|
+
<Verify rule="cam-limited">The photo grid tells a limited grant apart from an empty library in its own sentence with a route to change the selection, album affordances are absent under that status, and the granted set is re-read rather than cached: from the library change notification on iOS, on resume on Android, where widening it runs through a control the app drew that re-requests the read-media permission.</Verify>
|
|
123
|
+
<Verify rule="cam-thermal">The screen binds only the capture pipelines it draws, at most one instance of each, and system pressure is answered with a stated degradation rather than an error.</Verify>
|
|
124
|
+
|
|
125
|
+
<Device>Two of these cannot be settled from a diff. Drive the capture screen one-handed on the smallest and largest supported devices, with the platform capture indicator drawn, and measure the rendered hit rect of the shutter and of every control beside it (`cam-shutter`). Then hold a real capture session open until the device throttles, and watch what happens to the torch, the frame rate and the session (`cam-thermal`).</Device>
|
|
126
|
+
|
|
127
|
+
</Check>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Conversation
|
|
2
|
+
|
|
3
|
+
A conversation screen is one column that grows from its bottom, read in glances, with half of it under a keyboard for as long as anyone is answering. It is opened several times a day, more often from a notification than from inside the app, and every entry lands in the middle of something. Its failures are not the generic list failures: they are the screen moving under a thumb, and the message that left the composer and then quietly stopped existing.
|
|
4
|
+
|
|
5
|
+
Here: the transcript's anchor and where it opens, paging history upward, the row, arrivals, grouping and time, the composer, the state of one message, attachments, presence, the empty conversation and announcing an arrival. Recycling is `list-virtualise` and the row as a target is `list-row`. The keyboard as layout is `touch-keyboard` and `scroll-keyboard`. The write queue itself is the `off-` prefix, telling somebody a message arrived is the `notify-` prefix, and field configuration is the `form-` prefix. A link tapped in a message opens through `webview-surface-choice`, and a place attached to one is the static or lite-mode map `map-cost` requires of any recycled row.
|
|
6
|
+
|
|
7
|
+
## <Rule id="chat-anchor" description="The transcript opens at its newest end and stays there as it grows" />
|
|
8
|
+
|
|
9
|
+
The only reason anyone opened the conversation is the last message, there is no second pane holding it in view, and the container changes size under it twice: once when the keyboard arrives, and again every time the composer takes another line.
|
|
10
|
+
|
|
11
|
+
- Declare the anchor instead of scrolling to it after the first frame: SwiftUI `defaultScrollAnchor(.bottom)`, Compose `LazyColumn(reverseLayout = true)`, Flutter `ListView(reverse: true)`, React Native `FlatList` `inverted`. All four are off by default, so the anchor is one line somebody writes.
|
|
12
|
+
- A reversed Compose list already holds a conversation shorter than one screen at its bottom, and the one thing that breaks it is a `verticalArrangement` passed by hand. Bubble spacing is where that happens, so the spacing arrangement carries the alignment with it: `Arrangement.spacedBy(n.dp, Alignment.Bottom)`.
|
|
13
|
+
- React Native's `inverted` is a scale transform of -1 that `FlatList` undoes on every cell, so only what is drawn outside a cell (a sticky header, an absolutely positioned overlay, a hand-rolled inverted `ScrollView`) undoes it itself.
|
|
14
|
+
|
|
15
|
+
## <Rule id="chat-open-position" description="Re-entering the conversation lands on the boundary between read and unread" />
|
|
16
|
+
|
|
17
|
+
An interruption ended this conversation somewhere in the middle of it. The very bottom skips whatever arrived since; the top is a week of scrolling to get back.
|
|
18
|
+
|
|
19
|
+
- The boundary comes from the app's own read marker, because no platform publishes an unread anchor, and a conversation with no unread messages takes a written fallback.
|
|
20
|
+
- It is applied as the list's initial position rather than as an animated scroll after the first frame: Compose `rememberLazyListState(initialFirstVisibleItemIndex = ...)` sets it before the first layout, and SwiftUI separates the opening position from the growth response only from iOS 18, through `defaultScrollAnchor(_:for:)` with `.initialOffset` against `.sizeChanges`.
|
|
21
|
+
- `scroll-restore` owns the place keyed to the item, `nav-restore` which screen comes back, and `notify-destination` that a tap lands on what the notification named.
|
|
22
|
+
|
|
23
|
+
## <Rule id="chat-history" evidence="device" description="Older messages arrive above without moving the message being read" />
|
|
24
|
+
|
|
25
|
+
`scroll-anchor` already rules that nothing arrives above the reading position, and already names a thread loading history upward as the case that ships broken most often. What is this file's is the paging itself.
|
|
26
|
+
|
|
27
|
+
- The page is requested a written distance before the oldest row is reached, so the rows land before the thumb gets there. `list-end` pages the other direction and owns the load-more control, the end marker and the failed page.
|
|
28
|
+
- The position is held by item key or by the platform's own mechanism, never by an index: a Compose item `key` keeps the keyed row first visible across an insertion above it, React Native has `maintainVisibleContentPosition` with `minIndexForVisible` (and forbids reordering while it is on), and Flutter answers it with `CustomScrollView.center`, since `ListView.builder` anchors nothing. Scroll anchoring reaches Safari only at Safari 27, so a web transcript that supports anything older reverses its layout or owns the offset itself.
|
|
29
|
+
- The loading indicator sits above the oldest row and never replaces the transcript.
|
|
30
|
+
|
|
31
|
+
## <Rule id="chat-new-arrival" evidence="device" description="A message arriving while the user is reading further up is a pill, never a jump" />
|
|
32
|
+
|
|
33
|
+
One screenful, one thumb. A programmatic scroll issued while somebody is dragging takes the screen away from a hand that is already using it, and there is no second region to put the new message in.
|
|
34
|
+
|
|
35
|
+
- Auto-scrolling on arrival is gated on a written distance from the newest end. Inside it the transcript follows the message; past it nothing moves, which is also what `scroll-programmatic` requires of a scroll during a drag.
|
|
36
|
+
- Exactly one pill, and it carries the user to the newest message. No platform ships the control, so it is drawn: it lands by `fb-place`, and it is not transient feedback, so it stays until it is used or the user reaches the end rather than timing out under `fb-duration`.
|
|
37
|
+
- The pill states that something arrived. It carries a count only where the count is already in hand, and never fires a request to produce one.
|
|
38
|
+
|
|
39
|
+
## <Rule id="chat-grouping" description="Consecutive messages collapse into a run, and time is printed at a written interval" />
|
|
40
|
+
|
|
41
|
+
The column is too narrow to spend a line per row on a timestamp, and the only time question a glance asks is whether this arrived just now or overnight.
|
|
42
|
+
|
|
43
|
+
- A run of consecutive messages from one sender prints the sender's name and avatar at most once, at one end of the run, and a one-to-one thread prints neither. `icon-avatar` owns the fallback for the picture that is missing.
|
|
44
|
+
- One written interval decides when a timestamp or a day divider is emitted, since no platform publishes a grouping window or an interval. The divider is a section header and not a row, which is `list-sections`, and it carries the heading trait per `list-a11y`.
|
|
45
|
+
- Every time value is stored as an instant and formatted by the locale (`data-time-instant`, `l10n-format`), and anything relative takes the written crossover in `data-time-relative`.
|
|
46
|
+
|
|
47
|
+
## <Rule id="chat-row" description="One row holds a paragraph, a single character, or nothing but a file" />
|
|
48
|
+
|
|
49
|
+
One narrow column at the reader's own text size, and nothing to spill into sideways. A bubble sized by hand survives the default and breaks on the three inputs that arrive every day: a pasted wall of text, a one-emoji reply, and a photo sent with no caption.
|
|
50
|
+
|
|
51
|
+
- A maximum bubble width written into `STACK.md` as a number, because no platform publishes one. The row height is the platform target floor, which is `touch-floor`. Text wraps and no message row truncates.
|
|
52
|
+
- An attachment-only message renders without an empty text container behind it.
|
|
53
|
+
- Alignment is leading and trailing rather than left and right (`l10n-direction`), the measure follows `type-measure`, the row survives the largest text size (`type-scaling`), and the text can be selected and copied.
|
|
54
|
+
- `list-density` already forbids padding every row up to the tallest, and `list-row` caps the controls inside one row at two, which a reaction control plus an overflow already spends.
|
|
55
|
+
|
|
56
|
+
## <Rule id="chat-empty" description="An empty conversation says who it is with and what to send" />
|
|
57
|
+
|
|
58
|
+
A blank column under a name is the entire screen, and the keyboard is the only thing that would fill it, so whether it opens by itself is worth deciding rather than inheriting.
|
|
59
|
+
|
|
60
|
+
- One sentence naming the other party and the first thing worth sending, never a blank column and never a generic no-results line. `state-empty` rules the three empties and `copy-absence` the wording of text standing where content is not.
|
|
61
|
+
- A conversation that has never held a message is nothing but a composer, so it is the one chat screen that opens focused. Everywhere else the transcript is what was opened, and nothing takes focus.
|
|
62
|
+
- The first screenful still answers what this is and what to do, which is `layout-fold`.
|
|
63
|
+
|
|
64
|
+
## <Rule id="chat-composer" description="The composer grows to a written ceiling, then scrolls, and never takes the transcript's last row" />
|
|
65
|
+
|
|
66
|
+
The keyboard is half the screen. A composer with no ceiling plus a keyboard leaves a conversation with no conversation visible in it, and its height arrives without warning while somebody is typing.
|
|
67
|
+
|
|
68
|
+
- A minimum and a maximum line count, both as numbers, and it scrolls past the maximum: Compose takes `lineLimits = TextFieldLineLimits.MultiLine(min, max)` (`minLines` and `maxLines` on the legacy overload), SwiftUI pairs `TextField(axis: .vertical)` with `lineLimit(1...n)` and a field past the limit becomes scrollable, Flutter grows without limit at `maxLines: null` and is bounded only by its parent, and React Native caps with `numberOfLines`, on iOS only under the New Architecture, and publishes nothing about the growth between the minimum and that maximum. Not one of the four bounds a multiline field on its own, so the ceiling is written on all four.
|
|
69
|
+
- The composer's height is subtracted from the transcript rather than lying over its last row, through the platform's own inset and never a guessed spacer. `scroll-keyboard` owns the keyboard inset on the container, `touch-keyboard` the focused field staying visible, and `layout-chrome` the padding under anything pinned.
|
|
70
|
+
- Send is a drawn control and never the return key, which on a multiline React Native field inserts a line break by default. It is disabled while the composer holds neither text nor an attachment, so a send with nothing in it is not possible. It takes `button-target`, `button-state`, `button-label` and `a11y-name`.
|
|
71
|
+
- Unsent text survives leaving the conversation and coming back, by `form-persist` and `state-interrupt`.
|
|
72
|
+
|
|
73
|
+
## <Rule id="chat-message-state" description="The state of one message lives on that message, and a failed one keeps its place" />
|
|
74
|
+
|
|
75
|
+
The radio drops inside a single session, so a send is the likeliest thing in the app to fail, and there is one screen, so that failure has nowhere else to be shown.
|
|
76
|
+
|
|
77
|
+
- A written set of states covering at least sending, sent and failed, carried by the message and not by the screen. `state-queued` already rules that pending is a property of the item, and that an item vanishing into a sync error is the worst available outcome.
|
|
78
|
+
- A failed message stays in its position with a retry on it (`state-retry`) and does not block the messages queued behind it.
|
|
79
|
+
- A send with no network becomes a queued row in send order while the composer empties: local first per `off-write-mode`, durable with an id reused across retries so a lost reply is one message and not two per `off-queue`, marked per `off-fresh-marks`, and waiting on the failed request rather than on a connectivity callback (`state-offline`).
|
|
80
|
+
- No tick and no state is carried by color alone, which is `color-not-alone`.
|
|
81
|
+
|
|
82
|
+
## <Rule id="chat-attach" description="Attaching is one control opening the system picker, and what it produced is a row" />
|
|
83
|
+
|
|
84
|
+
The camera and the library are on this device, and the platform picker reaches the library without a permission. A hand-assembled menu is what costs: it draws its own browser over a picker the system already ships, or it asks for a permission the system route would not have needed, on the screen where somebody is trying to answer a person.
|
|
85
|
+
|
|
86
|
+
- One attach control, opening the platform's own picker. Android's photo picker grants access to the selected images and videos instead of the whole media library, ships natively from Android 13, reaches Android 11 and 12 through the Google Play services module, and falls back to `ACTION_OPEN_DOCUMENT` where it is unavailable; on iOS it is `PHPickerViewController` from iOS 14, which needs no photo library permission because it runs out of process. `perm-ask-less` and `perm-scope` settle the rest.
|
|
87
|
+
- Taking a photo now is the other source, and which surface that capture opens in is `cam-system-first`. Every further source is ruled the same way: it opens the platform's own picker, and one that costs a permission has to earn that permission before it reaches the menu.
|
|
88
|
+
- A pending attachment is a transcript row with its box reserved before the bytes arrive (`icon-reserve`, `list-images`), its pending mark from `chat-message-state`, and a transfer that outlives the screen (`net-upload`). On Android the picker's returned URI is granted only until the app stops, so that transfer takes `takePersistableUriPermission` first.
|
|
89
|
+
- What arrives from somewhere else is declared narrowly and then distrusted, which is `share-accepts`.
|
|
90
|
+
|
|
91
|
+
## <Rule id="chat-presence" description="A typing indicator and a read receipt are somebody else's data, and both answer one switch" />
|
|
92
|
+
|
|
93
|
+
The device is carried, so a read receipt states where a person was and whether they were awake, and it is usually produced by a glance at a notification rather than a deliberate open. Nothing about presence is a platform default and nothing about it is a store requirement.
|
|
94
|
+
|
|
95
|
+
- One setting governs both directions, so turning it off stops the outbound event as well as the inbound display. It shows its current value without being opened (`set-status`) and its default is picked once (`set-default-first`).
|
|
96
|
+
- The typing event is throttled to a written interval and expires on its own rather than waiting for a message to clear it. An unthrottled event publishes when each key was pressed, and wakes the other device once per keystroke to say so.
|
|
97
|
+
- The indicator does not loop next to text being read (`motion-loop`) and communicates without moving under reduced motion (`motion-reduced`). What a presence channel may cost in wakeups is `bg-wake-push`.
|
|
98
|
+
|
|
99
|
+
## <Rule id="chat-a11y" evidence="device" description="An arriving message is announced once, politely, and the transcript is walkable" />
|
|
100
|
+
|
|
101
|
+
A screen reader user cannot glance. The transcript is the whole screen, an arrival is silent unless something says so, and the composer sits between the reader and the newest row.
|
|
102
|
+
|
|
103
|
+
- The arrival is announced through a polite live region rather than one announcement per message: `ACCESSIBILITY_LIVE_REGION_POLITE` on Android, `LiveRegionMode.Polite` in Compose. `announceForAccessibility` is deprecated as of API 36 and an event sent that way may be ignored by the service. Assertive interrupts speech already running, which an arriving message is not worth.
|
|
104
|
+
- UIKit and SwiftUI publish no live region. The iOS native mechanism is a posted accessibility announcement, and from iOS 17 its priority is set on the string, where the low priority queues behind speech in progress instead of cutting it off. A web transcript uses a live region set to `aria-live="polite"` on either platform.
|
|
105
|
+
- One arrival, one announcement, naming the sender. A live region over a fast transcript announces every change and makes the screen unusable, so a burst is announced once.
|
|
106
|
+
- Each message row is one stop (`list-a11y`, `a11y-collection`, `a11y-order`), the composer and the send control both carry names (`a11y-name`), content changing without a navigation is `a11y-announce`, and switch and voice reach all of it through `a11y-alt-input`.
|
|
107
|
+
|
|
108
|
+
<Check>
|
|
109
|
+
|
|
110
|
+
<Verify rule="chat-anchor">The transcript declares an explicit bottom anchor, where a reversed list passes a vertical arrangement of its own that arrangement carries a bottom alignment, and anything drawn outside a cell in an inverted React Native list applies its own counter-transform.</Verify>
|
|
111
|
+
<Verify rule="chat-open-position">The opening position is computed from an unread boundary with a written fallback, and applied as the list's initial position rather than as a scroll after the first frame.</Verify>
|
|
112
|
+
<Verify rule="chat-history">Paging upward is triggered a written distance before the oldest row, its indicator sits above that row, and the position is held by item keys or the platform's maintain-position mechanism rather than by an index, and a page landing above the viewport leaves the row under the thumb where it was.</Verify>
|
|
113
|
+
<Verify rule="chat-new-arrival">Auto-scrolling on arrival is gated on a written distance from the newest end; past that distance exactly one pill appears, carries the user to the newest message, is dismissed by use or by reaching the end rather than by a timer, and carries a count only where the count is already in hand; and an arrival during an active drag moves nothing.</Verify>
|
|
114
|
+
<Verify rule="chat-grouping">A run of consecutive messages from one sender prints the sender's name and avatar at most once and a one-to-one thread prints neither, timestamps and day dividers are emitted on a written interval as section headers rather than rows, and every time value is formatted by the locale from a stored instant.</Verify>
|
|
115
|
+
<Verify rule="chat-row">Every message row wraps rather than truncates, holds a written maximum bubble width, renders an attachment-only message without an empty text container, aligns by leading and trailing, and its text can be copied.</Verify>
|
|
116
|
+
<Verify rule="chat-empty">A conversation with no messages renders a sentence naming the other party and the first thing to send, and the composer takes focus on open only where the conversation has never held a message.</Verify>
|
|
117
|
+
<Verify rule="chat-composer">The composer declares a minimum and a maximum line count as numbers and scrolls past the maximum, sits above the keyboard through the platform's inset mechanism, has its height subtracted from the transcript, sends from a drawn control rather than the return key with that control disabled while the composer holds neither text nor an attachment, and keeps unsent text across leaving the screen.</Verify>
|
|
118
|
+
<Verify rule="chat-message-state">Every message carries its own state from a written set covering at least sending, sent and failed; a failed one stays in place with a retry and does not block the queue; a send with no network becomes a queued row while the composer empties; and no state is carried by color alone.</Verify>
|
|
119
|
+
<Verify rule="chat-attach">One attach control opens the platform picker rather than a hand-assembled menu of sources, a camera source is scored under `cam-system-first` rather than here, and a pending attachment is a transcript row with its space reserved and a transfer that outlives the screen.</Verify>
|
|
120
|
+
<Verify rule="chat-presence">One setting governs presence in both directions, the typing event is throttled to a written interval and expires on its own, and the indicator neither loops beside the transcript nor animates under reduced motion.</Verify>
|
|
121
|
+
<Verify rule="chat-a11y">An arrival is announced once rather than once per message, through a polite live region on Android, Compose and the web and a posted announcement at low priority on iOS, the announcement names the sender and does not interrupt speech in progress, each row is one stop, and the composer and send control carry names.</Verify>
|
|
122
|
+
|
|
123
|
+
<Device>Three of these are not settled by the file. On a device, page a conversation holding more than one screenful of history and watch whether the row under the thumb moves (`chat-history`); drive an arrival while scrolled a screenful up, and again while a drag is in progress, and watch whether the screen jumps (`chat-new-arrival`); and send and receive through one whole conversation with the screen reader running, as `a11y-test` requires of any flow (`chat-a11y`).</Device>
|
|
124
|
+
|
|
125
|
+
</Check>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Color
|
|
2
|
+
|
|
3
|
+
On a phone a palette has to survive half brightness in daylight, an OLED panel, whichever theme the system is set to, and a thumb covering part of the screen. Almost everything that breaks it was decided long before any of that got tested.
|
|
4
|
+
|
|
5
|
+
Colors already written into `DESIGN.md` are settled. This file covers how they are used in code, what to derive for a role the brief left empty, and what to verify before handing the screen back.
|
|
6
|
+
|
|
7
|
+
When the screen already exists, work out three things before touching a value: which colors someone chose on purpose, which are placeholders nobody ever defended, and whether the request is a color change or an identity change. The last one is an edit to `DESIGN.md` and needs the user, not a quiet rewrite inside a component. A screen built entirely from neutrals with one blue button is usually not restraint: it is hierarchy and state that never got assigned a color.
|
|
8
|
+
|
|
9
|
+
## <Rule id="color-roles" description="Reach color through the role, not the value" />
|
|
10
|
+
|
|
11
|
+
Decide what the screen needs a color for before deciding which color: the base surface and the ones raised above it, the text that sits on each of those at both levels of emphasis, the interactive color, focus and selection, dividers and outlines, the four status meanings, and any series or scale the screen plots.
|
|
12
|
+
|
|
13
|
+
Every stack already names those:
|
|
14
|
+
|
|
15
|
+
- Material, on Android and in Flutter: `MaterialTheme.colorScheme` and `Theme.of(context).colorScheme`, each foreground taking the `on` role belonging to its background.
|
|
16
|
+
- iOS and Cupertino: the semantic system colors, or a catalog color that carries both appearances.
|
|
17
|
+
- Mobile web: custom properties resolved under `prefers-color-scheme`.
|
|
18
|
+
|
|
19
|
+
A hex written straight into a component looks like a shortcut and behaves like a bug. It stays put when the system switches to dark, it ignores Increase Contrast on iOS and high contrast text on Android, and nothing can reach it when the theme is retuned later. Retheming should touch the role table and nothing else.
|
|
20
|
+
|
|
21
|
+
## <Rule id="color-derived" description="The palette that shows up by itself is not a choice" />
|
|
22
|
+
|
|
23
|
+
Two of them show up. Indigo through violet under a gradient is the median of everything a model read, Tailwind's default button included. Warm cream with a rust accent is what appears the moment violet is forbidden: take the violet away and it comes back as cream in six screens out of ten, the same reflex in a different coat.
|
|
24
|
+
|
|
25
|
+
Neither family is banned, and the hex values are not the tell. Cream and rust pulled off film stock, in an app that edits photos, is those two colors doing work. The reflex is the same pair arriving with nothing behind it. What is banned is being unable to say, in terms of this product, why it landed there.
|
|
26
|
+
|
|
27
|
+
Look at the ground rather than at the accent. The accent is what gets swapped the moment the reflex is named, and the ground is what nobody looks at twice, so it survives the swap and gives the family away. One family is a near neutral ground whose small remaining chroma leans warm, under an almost black ink, and it stays that family whether the accent lands on terracotta, gold, olive or nothing at all. The other is an accent in the blue to violet band, on any ground. Judge the ground by chroma and not by saturation: a warm off-white reports as heavily saturated in HSL and is still an off-white.
|
|
28
|
+
|
|
29
|
+
Landing in either family is not a violation, it is a prompt to do the work twice. Derive a second full palette from the same reference, in a different key: what the reference looks like at night, in a different material, or lit differently. Put the two side by side and keep the one a stranger could tie back to this product without being told the reference. A reference that only ever yields the palette that was already there was a caption written after the fact, and the second pass is the only thing that tells a caption from a derivation.
|
|
30
|
+
|
|
31
|
+
- Ground, ink and accent follow from what the app does and who is holding it, which is `PRODUCT.md`. A category habit is not a derivation: finance is not blue, health is not green, fitness is not neon.
|
|
32
|
+
- Name the material or the reference the palette comes from, something this product actually evokes: newsprint, film stock, a transit sign, a receipt, a ledger. An adjective is not a reference, so modern, friendly and premium derive nothing. If the palette would fit another app just as well, that is decoration talking.
|
|
33
|
+
- Look at the neutrals alone before handing off. Greys that all lean toward the accent were generated from the accent instead of chosen.
|
|
34
|
+
|
|
35
|
+
## <Rule id="color-ramp-hsl" description="Move one axis at a time" />
|
|
36
|
+
|
|
37
|
+
Build the ramp in HSL: hold the hue, walk the lightness. Lightness is the axis contrast lives on, so every step becomes something you can defend. Surfaces go up, text and borders go down, and the family stays recognisable because H never moved.
|
|
38
|
+
|
|
39
|
+
HSL then fails in two places, both of which matter here:
|
|
40
|
+
|
|
41
|
+
- **Its S number does not measure colorfulness.** `#F4EFE7` reports 37% saturation and is an off-white. Whether a value counts as neutral is a question for chroma in OKLCH or LCH, or for the plain distance between the channels.
|
|
42
|
+
- **Holding S while L moves breaks both ends.** Bring saturation down as the steps approach white and black, or the extremes drift out of the family.
|
|
43
|
+
|
|
44
|
+
Where the stack supports OKLCH, work there: lightness moves without dragging colorfulness along. On Android, hand a seed color to the tonal palette generator instead of picking tones one at a time. Restating a palette that already works in a newer notation is not an improvement.
|
|
45
|
+
|
|
46
|
+
## <Rule id="color-one-accent" description="One color means touchable" />
|
|
47
|
+
|
|
48
|
+
An accent works by being scarce. iOS gives the app a single tint and expects everything interactive to wear it; Material puts the action on `primary` and keeps `tertiary` for occasional contrast. Under both, the user learns one color and stops scanning for buttons.
|
|
49
|
+
|
|
50
|
+
- Spend it on the action. A card border, an illustration or a section heading in the same color costs the user that shortcut.
|
|
51
|
+
- When a color has to be loud, give it a whole region or a whole role instead of sprinkling it in six places.
|
|
52
|
+
- Secondary text on a colored surface comes from that surface's own hue, or from opacity over it. Grey dropped onto a color reads as a rendering fault.
|
|
53
|
+
- Status colors keep their jobs. Error red used for branding spends the one color a user reads without thinking.
|
|
54
|
+
|
|
55
|
+
## <Rule id="color-variety" description="A hue per row is not a palette" />
|
|
56
|
+
|
|
57
|
+
Four badges in four pastels, three avatars in three gradients, a tint cycling by position down a list. Nobody can say what the second colour means, because it means that the item is the second one.
|
|
58
|
+
|
|
59
|
+
- Hue that varies across repeated items encodes a difference the reader can name: the category, the status, the account, the series on the chart. Otherwise every item in the set wears the same surface.
|
|
60
|
+
- Where the difference is real, the mapping is fixed and written down once, so the same category is the same colour on every screen it appears on. A colour assigned by index changes the moment the list reorders.
|
|
61
|
+
- Generated-per-item colour is legitimate in exactly one place, the avatar fallback, where it is derived from a stable identifier and stands in for a photograph: `icon-avatar`.
|
|
62
|
+
- Variety that is genuinely wanted is a job for the artwork, not for the interface. Illustrations carry as many colours as they need; the rows around them do not.
|
|
63
|
+
|
|
64
|
+
## <Rule id="color-gradient" description="A gradient has to be doing a job" />
|
|
65
|
+
|
|
66
|
+
Three qualify on mobile:
|
|
67
|
+
|
|
68
|
+
- a scrim under fixed chrome, so its labels stay legible while content scrolls beneath;
|
|
69
|
+
- the platform's own translucent material under sheets and fixed bars, at whatever thickness the OS decides;
|
|
70
|
+
- depth, distance or light inside artwork that was actually drawn.
|
|
71
|
+
|
|
72
|
+
The rest give the screen away: two hues blended in place of a logo, gradient-filled text, a gradient primary button, a gradient app background, a colored glow at zero offset standing in for elevation. A raised surface on Android steps up the tonal scale and takes a shadow where the spec gives it one. On iOS the system material exists for exactly this, and a blur rebuilt by hand renders without the vibrancy pass and ignores Reduce Transparency.
|
|
73
|
+
|
|
74
|
+
A gradient that stays brings three constraints with it:
|
|
75
|
+
|
|
76
|
+
- Its contrast is a range, not a number. Measure the worst point along the run, and remember a scrim sits over moving content, so the worst point moves too.
|
|
77
|
+
- A long ramp bands on an 8-bit panel, which is what a phone becomes once brightness drops.
|
|
78
|
+
- Translucency stacked on translucency leaves the final ratio at the mercy of whatever happens to scroll past. Opaque values can be checked; these cannot.
|
|
79
|
+
|
|
80
|
+
## <Rule id="color-dark-composed" evidence="device" description="Dark is a second design, not a switch" />
|
|
81
|
+
|
|
82
|
+
This is the part that gets done last and shows it.
|
|
83
|
+
|
|
84
|
+
- **The ground is not `#000000`.** Full black flattens every elevation cue and smears while a list scrolls on OLED. Take the platform surface roles, or start at `#121212` and build real steps above it. Full black is the cheapest for battery and the most expensive for structure, so it is a decision per surface, never a starting point.
|
|
85
|
+
- **Body text is not `#FFFFFF`.** Around `#E0E0E0`, with secondary a visible step below.
|
|
86
|
+
- **Depth arrives as the surface getting lighter,** because a shadow has nothing left to darken once the background is already dark.
|
|
87
|
+
- **Accents shed chroma.** A hue tuned against white burns against black. Take the colorfulness down and leave the hue alone.
|
|
88
|
+
- **Every pair gets measured again.** Passing in light says nothing about dark.
|
|
89
|
+
|
|
90
|
+
Light-only ships broken, and the system setting is what the app follows by default.
|
|
91
|
+
|
|
92
|
+
## <Rule id="color-contrast" evidence="device" description="Measure the pair, do not eyeball it" />
|
|
93
|
+
|
|
94
|
+
| What | Minimum |
|
|
95
|
+
|---|---|
|
|
96
|
+
| body text | 4.5:1 |
|
|
97
|
+
| text at 18pt+, or 14pt+ bold | 3:1 |
|
|
98
|
+
| icons, controls, focus and selection indicators | 3:1 |
|
|
99
|
+
|
|
100
|
+
These are floors rather than targets because of where phones get used. Sunlight lifts the black point, auto-brightness runs out, and nobody can move the sun. Pale grey on white that reads fine at a desk is gone at a bus stop.
|
|
101
|
+
|
|
102
|
+
Measure the pressed, selected, disabled and placeholder states as well, plus text sitting over an image, in both themes. The 14pt row is a weight rule as much as a size rule: drop that text to regular and it owes 4.5:1, without a single color having changed.
|
|
103
|
+
|
|
104
|
+
## <Rule id="color-not-alone" evidence="device" description="Color never carries a meaning by itself" />
|
|
105
|
+
|
|
106
|
+
Red against green is the pair that fails, and roughly one man in twelve sees them differently. Grayscale and wind-down modes take hue away from everyone else, and glare eats hue before it eats lightness.
|
|
107
|
+
|
|
108
|
+
So every status, state and series gets a second carrier: an icon, a word, a shape, a position, or a lightness gap wide enough to survive desaturation. Run a protanopia, deuteranopia and tritanopia pass over the rendered screen. The pairs that collapse are rarely the ones the token names predicted.
|
|
109
|
+
|
|
110
|
+
## <Rule id="color-dynamic" evidence="device" description="Dynamic Color, where the platform hands it over" />
|
|
111
|
+
|
|
112
|
+
On Android 12 and up, Material You builds the scheme from the user's wallpaper. Take it where it fits, keep a static scheme for older releases and for users who turn it off, and open the app under a few wallpapers to see whether it still reads as this product. A brand that only exists at its own hex value does not survive the feature.
|
|
113
|
+
|
|
114
|
+
<Check>
|
|
115
|
+
|
|
116
|
+
<Verify rule="color-roles">No component holds a raw hex, and every color arrives through a token or a platform role.</Verify>
|
|
117
|
+
<Verify rule="color-derived">The palette is derived from a named material or reference this product evokes, the same screen in a competitor's app would need a different one, and the neutral ramp is not tinted toward the accent. A palette in either family carries the second derivation it was compared against and the reason this one survived.</Verify>
|
|
118
|
+
<Verify rule="color-ramp-hsl">Ramp steps hold the hue and shed saturation toward both ends.</Verify>
|
|
119
|
+
<Verify rule="color-one-accent">The accent marks what is interactive and nothing else, and no grey sits on a colored surface.</Verify>
|
|
120
|
+
<Verify rule="color-variety">Hue that varies across repeated items encodes a difference the reader can name, the mapping is fixed rather than positional, and nothing carries a tint picked for variety.</Verify>
|
|
121
|
+
<Verify rule="color-gradient">Every gradient does work flat color cannot: no gradient-filled text, no gradient primary button, no gradient app background, and no colored glow at zero offset standing in for elevation.</Verify>
|
|
122
|
+
<Verify rule="color-dark-composed">Dark has its own ground, its own accent values and its own measurements, and the ground is not full black.</Verify>
|
|
123
|
+
<Verify rule="color-contrast">Contrast is calculated for every pair, including pressed, disabled and text over images, in both themes.</Verify>
|
|
124
|
+
<Verify rule="color-not-alone">Nothing is communicated by color alone.</Verify>
|
|
125
|
+
<Verify rule="color-dynamic">Where Dynamic Color applies there is a static fallback, and the product still reads as itself under a wallpaper-derived scheme.</Verify>
|
|
126
|
+
|
|
127
|
+
<Device>Check the last four on a rendered screen in both appearances rather than in the token table. Overlays, translucent material and anything painted over the background all land after the tokens, so the token table is the one place a light theme can look fine while dark quietly fails.</Device>
|
|
128
|
+
|
|
129
|
+
</Check>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Copy
|
|
2
|
+
|
|
3
|
+
Most of what an app says, it says in fewer than ten words, to someone reading at a glance with the other hand busy. Count the parts of the surfaces the words land on and the reason is obvious: two answers under a title on an alert, roughly two words on a settings row, one line on a locked screen that decides whether the app is opened at all. Add a keyboard covering the lower half and there is no room left for a sentence that has to be read twice. There is no hover, no tooltip and no second window either, so a word the reader does not know is where the reader stops.
|
|
4
|
+
|
|
5
|
+
This file owns wording. Whether a message exists at all belongs to the file that owns the surface it lands on. Everything here is a string in the catalogue rather than a literal in a component (`l10n-strings`), which is also what makes most of these rules searchable.
|
|
6
|
+
|
|
7
|
+
## <Rule id="copy-budget" evidence="device" description="Decide the count before writing the sentence" />
|
|
8
|
+
|
|
9
|
+
Length is a decision taken before the wording, and it is a count.
|
|
10
|
+
|
|
11
|
+
- A title holds 1 line and never wraps past 2.
|
|
12
|
+
- Body text is 1 sentence. A second one has to carry a fact the first did not.
|
|
13
|
+
- An answer on an alert or a dialog is 1 or 2 words. `button-label` owns the rest of the buttons.
|
|
14
|
+
- A notification is finished at its first line (`notify-lockscreen`).
|
|
15
|
+
|
|
16
|
+
Each fact appears once across the group. A title that names the situation and a body that restates it costs two reads and delivers one.
|
|
17
|
+
|
|
18
|
+
Count the source string, because that is the shortest form it will ever take: translation lengthens it (`l10n-expansion`) and the reader's text size stretches it again (`type-scaling`). Going under the meaning is a different defect, not a stricter version of this one. The test is whether the reader can still act after the cut.
|
|
19
|
+
|
|
20
|
+
## <Rule id="copy-first-word" description="The first two words decide whether the rest is read" />
|
|
21
|
+
|
|
22
|
+
A list row, a settings label, a section header and a notification line are all read inside a column of neighbours that begin the same way. Scanning stops at the first two words.
|
|
23
|
+
|
|
24
|
+
- Lead with the word that separates this string from its siblings, and delete the leading noun they all share.
|
|
25
|
+
- Nothing repeats the header above it. `nav-location` owns saying where the screen is, `set-shape` owns which rows exist and in what order.
|
|
26
|
+
- A row that is a destination never opens with a generic verb: Set, Change, Edit, Modify, Manage, Use, Select, Choose. The row leads somewhere, so the verb spends both scanned words saying nothing. A menu or action sheet row is the opposite case, since there the verb is the whole content, and `button-label` governs it.
|
|
27
|
+
- Neutral beats negated. Block, not Don't allow.
|
|
28
|
+
|
|
29
|
+
## <Rule id="copy-voice" description="Second person, present tense, active, and nobody is 'the user'" />
|
|
30
|
+
|
|
31
|
+
- The reader is you. Not the user, not I or my. A possessive is usually dead weight, since Favourites says what Your favourites says in one word instead of two.
|
|
32
|
+
- A control that names a thing is a noun: Notifications, not Notify me. Keep sentences for where the app is genuinely speaking to the reader.
|
|
33
|
+
- Active voice, present tense, and the subject of a failure is the thing that failed. "Messages did not load" beats "We had trouble loading your messages". We and our stand for the company or they come out, and in an error they stand for nobody.
|
|
34
|
+
- Cut please, sorry, oops, uh-oh and the exclamation mark. An alert about money, access or data owns the whole screen while it stands there, so the reader has nothing else to look at and the performance is all that is on offer.
|
|
35
|
+
- No idioms and no colloquialisms. An idiom is the first thing to break in translation and the last thing to land for anyone reading a second language.
|
|
36
|
+
- Humour is weighed rather than banned, and it costs more than it looks: it travels badly between cultures and lands on a stranger reading one line off a locked screen. Keep it out of errors, permission asks, money and destructive confirmations entirely.
|
|
37
|
+
- No gendered reference the sentence does not need. Write the plural or the role, because a language that inflects for gender has to resolve one the source string invented for no reason. A disability never stands in for a fault or a weakness.
|
|
38
|
+
|
|
39
|
+
## <Rule id="copy-tells" description="The string nobody decided the wording of" />
|
|
40
|
+
|
|
41
|
+
A string whose wording was filled in rather than chosen has a shape, and it is the same shape across every app that shipped one. All of it is searchable in the catalogue, which is what makes it a rule instead of a taste.
|
|
42
|
+
|
|
43
|
+
- **No dash.** The em and en dash are what gets reached for instead of choosing between a full stop, a comma, a colon and a pair of brackets, and a four word title has no clause to join in the first place. Neither character is on the phone keyboard, both widen a line in a column with nothing to spare, and screen readers disagree on whether to announce them at all. Rewrite the sentence rather than substituting a shorter dash: split it in two, or use the punctuation it actually wanted. The hyphen inside a compound word is a different character and stays.
|
|
44
|
+
- **Vocabulary that sells instead of saying**: seamless, effortless, unlock, elevate, supercharge, robust, powerful, leverage, empower, journey, delve, revolutionary. It clusters in empty states, paywalls and onboarding, which are precisely the screens where the reader is deciding whether to continue.
|
|
45
|
+
- **Significance the screen has not earned.** A settings row is not a milestone and a first upload is not a journey. Say what happened.
|
|
46
|
+
- **Chat leftovers**: Great question, I hope this helps, Let me know if, Here is what you need to know. They arrive in apps whose strings were drafted in a chat window, and again in any feature that renders model output as interface.
|
|
47
|
+
- **Not X but Y**, where the negative half names something nobody claimed. "Not just a list, but a workspace" spends a line of a narrow screen arguing with a reader who was not arguing.
|
|
48
|
+
|
|
49
|
+
## <Rule id="copy-error" description="Name the failure, rule on the retry, end on the fix" />
|
|
50
|
+
|
|
51
|
+
Three answers in at most two sentences, and they are owed wherever an operation the app ran has failed: a request, a write, a sync. A field rejecting what was just typed is a single instruction instead, and `form-error` owns it. `state-error` sorts the failure classes and forbids naming a cause the app did not verify, and `fb-place` decides where the message lands. This rule is the sentence itself.
|
|
52
|
+
|
|
53
|
+
- Name the thing the reader owns, not the operation that ran on it. "Your reply was not posted" leaves the reader somewhere to go; "Request failed" names machinery they never agreed to learn.
|
|
54
|
+
- One string covering every failure is the shape an app falls into by default, and it is why "Something went wrong" is still shipping. It fits everywhere and helps nowhere. Count the distinct failure sentences against the distinct moves `state-error` sorts by, not against its four classes: two classes leaving the reader the same move may share one sentence.
|
|
55
|
+
- Say whether trying again is the move, and where it is not, say what is instead. `state-retry` owns the control.
|
|
56
|
+
- End on the next step, in the same verb as the control that performs it.
|
|
57
|
+
- Instruct, never scold. "Names take letters only" beats "Don't use numbers", and both beat "Invalid name".
|
|
58
|
+
- No code inside the sentence. An identifier that support will ask for is a secondary line the reader can copy.
|
|
59
|
+
- Never spell out a settings path. Name the one thing to turn on and let the control beside it open the page (`state-permission`). A written path is wrong the moment the OS renames a screen, and its segments are the system's strings rather than the app's, so nothing translates them.
|
|
60
|
+
|
|
61
|
+
## <Rule id="copy-jargon" description="The codebase does not get to speak" />
|
|
62
|
+
|
|
63
|
+
All of this is greppable in the string catalogue, which is why it is a rule rather than an opinion.
|
|
64
|
+
|
|
65
|
+
- Mechanism words: null, undefined, NaN, exception and class names, HTTP status numbers and the phrases that go with them, token, payload, endpoint, entity, instance, config. Also sync, cache and queue anywhere the reader would say sent, saved or waiting.
|
|
66
|
+
- Vocabulary carried in from the code that nobody wants on a screen: blacklist, whitelist, kill, sanity check, master and slave, dummy. Every one has a plain replacement that is also shorter.
|
|
67
|
+
- An acronym stays only where the audience in `PRODUCT.md` already uses it. Anything else is spelled out at its first appearance on that surface, because the phone offers nowhere to look it up.
|
|
68
|
+
|
|
69
|
+
## <Rule id="copy-terms" evidence="device" description="One name per thing, and it is the reader's name for it" />
|
|
70
|
+
|
|
71
|
+
The same object gets named in a tab, a screen title, a notification and a confirmation, and no two of those are ever on screen together. The drift is invisible to whoever writes it and obvious to whoever uses the app.
|
|
72
|
+
|
|
73
|
+
- One term per concept, written into `STACK.md` with the words it replaces listed beside it so review can search for them. What is archived on one screen is not hidden on the next.
|
|
74
|
+
- The term is what the reader calls the thing, not the column it is stored in.
|
|
75
|
+
- Gesture verbs come from the device: tap, touch and hold, swipe, drag. Never click, and never tap on.
|
|
76
|
+
- A confirmation repeats the verb of the control that raised it, so the reader is not matching two words to one action.
|
|
77
|
+
- The accessible name agrees with the visible label (`a11y-alt-input`).
|
|
78
|
+
|
|
79
|
+
## <Rule id="copy-case" description="Case is picked per element type, once, and the platform overrules it in four places" />
|
|
80
|
+
|
|
81
|
+
iOS fixes four of them, and they are not the app's to choose: a button title is title case with no ending punctuation; an alert message is a complete sentence in sentence case with a full stop; a usage description is the same; an alert title takes sentence case and a full stop when it is a sentence, title case and no punctuation when it is a fragment.
|
|
82
|
+
|
|
83
|
+
Everything the platform has not fixed is the app's own decision, taken once per element type and then held everywhere: one case for titles, one for section headers, one for the lines standing in for missing content. Two screens disagreeing is the defect, not the case that was picked.
|
|
84
|
+
|
|
85
|
+
Material's own convention is sentence case for titles, headings, labels and menu items. It does not bind the way the four iOS cases do, so the app still picks one case per element type on top of it and holds it everywhere. `button-label` owns what the button says; the case it says it in is here, and the button is where the two platforms part.
|
|
86
|
+
|
|
87
|
+
Material leaves a trap underneath that. The legacy Material 2 button text appearance sets `android:textAllCaps` to true and uppercases whatever string was authored, while every Material 3 typescale sets it to false. Resolve whether the theme uppercases the label before concluding that it renders as written. Capitals are for a short label at most (`type-strings`), and a theme that uppercases is applying them to translations nobody has looked at.
|
|
88
|
+
|
|
89
|
+
## <Rule id="copy-absence" description="Text standing where content is not" />
|
|
90
|
+
|
|
91
|
+
Three strings do a different job from the sentences around them, and each has a shape of its own.
|
|
92
|
+
|
|
93
|
+
- **A hint inside a field shows the format by example**, such as `name@example.com`. The field's name there is a label that vanishes at the moment it is needed (`form-label`), and the validation rules there arrive too late to prevent anything (`form-error`). A search field is the exception, and it names the collection instead (`search-scope`).
|
|
94
|
+
- **A line where content is missing names its cause and ends on a verb.** `state-empty` separates the three empties; the two that have a next move end on the verb of the control that takes it (`search-zero`, `list-end`).
|
|
95
|
+
- **A waiting line says which operation is running**, and moves a determinate bar only where the app holds a real figure to move it with. `state-loading` owns when that line appears and what a long wait owes on top of it.
|
|
96
|
+
|
|
97
|
+
## <Rule id="copy-sample-data" description="The screen shows the product's own content" />
|
|
98
|
+
|
|
99
|
+
`you@example.com` in the field, `Enter your password` under it, three rows called Item one, Item two and Item three. Nothing on the screen came from the product, so nothing on it can be judged, and a reader learns only that somebody laid out boxes.
|
|
100
|
+
|
|
101
|
+
- Every string and figure belongs to this domain and arrives in the shape the app will really receive: a merchant with a plausible name, a price with the locale's currency and separator, a timestamp the day would produce, a distance the sport actually covers.
|
|
102
|
+
- The screen carries at least one concrete value a reader recognises. A price, a time, a name, an address, a measurement. A screen of headings and buttons with no data on it has not been designed against its content.
|
|
103
|
+
- The figures reconcile. A yearly price and a monthly price produce the saving that is printed, timestamps in a list run one way, a total is the sum of its rows, a percentage matches the bar drawn beside it. One figure contradicting the one next to it tells the reader that none of them is real.
|
|
104
|
+
- The sample covers what the layout has to survive rather than what flatters it: the long name, the zero, the negative, the empty list, the year-old record. `type-strings` owns the length side of this, `data-empty-null-zero` the difference between nothing and zero.
|
|
105
|
+
- A hint inside a field is a format example and never the content, which is `copy-absence` and `form-label`.
|
|
106
|
+
|
|
107
|
+
## <Rule id="copy-numbers" description="The figure, not the word" />
|
|
108
|
+
|
|
109
|
+
A digit is scanned and a spelled-out number is read, and scanning is all a phone gets.
|
|
110
|
+
|
|
111
|
+
- So the figure is written as a numeral: 3 photos, not three photos.
|
|
112
|
+
- Anything destructive states the exact count and the object it acts on, so the reader can check it against what they selected. `touch-destructive` places the control and `fb-confirm-test` decides whether a confirmation exists at all.
|
|
113
|
+
- The locale formats it and pluralises it (`l10n-format`, `l10n-plurals`), and `data-precision` rules how many digits a figure carries and where the rounding lives.
|
|
114
|
+
- The unit travels with the figure and is not dropped to save width. Figures stacked in a column line up on tabular figures instead (`type-strings`); this rule is the figure inside a sentence.
|
|
115
|
+
|
|
116
|
+
## <Rule id="copy-claims" description="A number that describes the product is derived, not typed" />
|
|
117
|
+
|
|
118
|
+
Copy that states how much the product gives (how many items, how much storage, how many devices, how long the trial runs, what the limit is) is a promise, and it is checkable against the code. Typed into the view as part of the sentence, it is correct exactly once: on the day it was written.
|
|
119
|
+
|
|
120
|
+
The failure is quiet and it compounds. The constant changes, the string does not, and now the screen advertises a figure the app no longer delivers. Worse, the same figure was copied into the store listing, the marketing site and the onboarding, and none of those is read again when the constant moves.
|
|
121
|
+
|
|
122
|
+
What it looks like in a real codebase: the same quantity living as a literal inside the screen's text, as a constant in the view model, and as a different constant in the layer that actually computes it. Three values, all reachable, none agreeing, and the one the user reads is the one nobody owns.
|
|
123
|
+
|
|
124
|
+
- The figure comes from the same constant the behaviour uses, interpolated into the string, so a change moves both together. Pluralisation still goes through `l10n-plurals`.
|
|
125
|
+
- Where it genuinely cannot be derived, it lives in one named place that the domain code also reads, never inline in a component.
|
|
126
|
+
- A cap the product enforces is stated as the cap, not as the theoretical maximum. Advertising a ceiling that a limit elsewhere prevents anyone from reaching is a false claim, and it is the version that reaches a store reviewer.
|
|
127
|
+
- Store listing text, screenshots and onboarding repeat these figures. When one changes, they are part of the change.
|
|
128
|
+
|
|
129
|
+
Grep the view layer for digits inside display strings. Each hit is a claim, and each claim has an owner in the code or it is a defect.
|
|
130
|
+
|
|
131
|
+
## <Rule id="copy-rationale" description="The permission sentence has a punctuation rule, and one place where longer wins" />
|
|
132
|
+
|
|
133
|
+
`perm-rationale` owns the screen and `perm-purpose-string` owns what the sentence claims. Three things sit on top of those.
|
|
134
|
+
|
|
135
|
+
- The iOS usage description is one complete sentence in sentence case, active, ending in a full stop. App review reads it before any user does.
|
|
136
|
+
- On the app's own screen in front of the system dialog, the control that opens that dialog reads Continue or Next. What else stands on that screen is `perm-rationale`'s call. A word resembling the dialog's own accept trains the finger to answer before the eye has read the alert, which lands one second later on the same piece of glass.
|
|
137
|
+
- A disclosure about data being collected is the one string where clarity outranks the budget: why it is wanted, what is taken and how it is used, on the app's screen before the dialog rather than behind a link to a policy. It stands on its own, with nothing unrelated to that collection folded in beside it, and a thirteen year old is the reading level it aims at.
|
|
138
|
+
|
|
139
|
+
<Check against="string">
|
|
140
|
+
|
|
141
|
+
<Verify rule="copy-budget">No title wraps past 2 lines, no body runs past 1 sentence without a new fact in it, and no alert answer exceeds 2 words.</Verify>
|
|
142
|
+
<Verify rule="copy-first-word">0 destination rows open with a generic verb, and 0 strings in a scannable column repeat their header or lead with a negated term.</Verify>
|
|
143
|
+
<Verify rule="copy-voice">0 occurrences of "the user", "I", "my", "please", "sorry", "oops" and "!" in user-facing strings, "we" only where the sentence is about the company, and 0 gendered references the sentence does not need.</Verify>
|
|
144
|
+
<Verify rule="copy-tells">0 em dashes and 0 en dashes in the string catalogue, 0 sales vocabulary anywhere in the interface, and 0 chat leftovers.</Verify>
|
|
145
|
+
<Verify rule="copy-error">Every string reporting a failed operation carries all 3 answers, the distinct failure sentences match the distinct moves rather than the 4 classes, and 0 of them lead with a code, blame the reader or spell out a settings path.</Verify>
|
|
146
|
+
<Verify rule="copy-jargon">0 mechanism words, exception names or HTTP numbers in the string catalogue, sync, cache and queue only where the reader uses the word too, and every acronym either known to the audience or spelled out once.</Verify>
|
|
147
|
+
<Verify rule="copy-terms">1 term per concept across the catalogue, listed in `STACK.md`, with 0 uses of "click" and 0 confirmations whose verb differs from the control that raised them.</Verify>
|
|
148
|
+
<Verify rule="copy-case">1 case style per element type across every screen, the 4 iOS-fixed cases correct, whether the theme uppercases the label answered before the authored string is trusted (on Android Views that is `android:textAllCaps`), and 0 strings longer than a short label authored in capitals.</Verify>
|
|
149
|
+
<Verify rule="copy-absence">Field hints show a format, each line standing in for missing content names its cause and the 2 empties with a next move end on the verb of the control that takes it, and 0 waiting lines claim unmeasured progress.</Verify>
|
|
150
|
+
<Verify rule="copy-sample-data">Every string and figure belongs to the product's domain, the screen shows at least one concrete value a reader would recognise, and the figures on it reconcile with each other.</Verify>
|
|
151
|
+
<Verify rule="copy-numbers">Numbers are numerals, destructive strings state the exact count and object, and formatting and plurals come from the locale.</Verify>
|
|
152
|
+
<Verify rule="copy-claims">Every figure that describes what the product delivers is interpolated from the constant the behaviour uses, or lives in one named place the domain also reads, and no advertised ceiling is unreachable because of a limit elsewhere.</Verify>
|
|
153
|
+
<Verify rule="copy-rationale">Every usage description is 1 sentence-case sentence ending in a full stop, the control that opens the system dialog reads Continue or Next, and any data disclosure says why, what and how before the dialog with nothing unrelated bundled into it.</Verify>
|
|
154
|
+
|
|
155
|
+
<Device>Read the strings in the running app, not in the catalogue. Terminology drift only shows up when the screens are walked in the order the user walks them, and a string that is correct in the file can still be the wrong length once the device's text size is applied to it.</Device>
|
|
156
|
+
|
|
157
|
+
</Check>
|