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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/dist/cli.js +105 -0
  4. package/dist/commands/build.js +35 -0
  5. package/dist/commands/detect.js +91 -0
  6. package/dist/commands/doctor.js +110 -0
  7. package/dist/commands/graph.js +106 -0
  8. package/dist/commands/install.js +76 -0
  9. package/dist/commands/lint.js +87 -0
  10. package/dist/commands/rubric.js +104 -0
  11. package/dist/commands/spec.js +300 -0
  12. package/dist/compile.js +235 -0
  13. package/dist/detect/rules.js +208 -0
  14. package/dist/detect/types.js +36 -0
  15. package/dist/emit.js +77 -0
  16. package/dist/graph.js +288 -0
  17. package/dist/heuristics.js +185 -0
  18. package/dist/lock.js +18 -0
  19. package/dist/mdx.js +84 -0
  20. package/dist/paths.js +62 -0
  21. package/dist/skill.js +69 -0
  22. package/package.json +51 -0
  23. package/src/skills/SKILL.md +125 -0
  24. package/src/skills/flow/build.md +57 -0
  25. package/src/skills/flow/firebase.md +102 -0
  26. package/src/skills/flow/init.md +116 -0
  27. package/src/skills/flow/review.md +186 -0
  28. package/src/skills/flow/spec.md +149 -0
  29. package/src/skills/heuristics/accessibility.md +124 -0
  30. package/src/skills/heuristics/ads.md +140 -0
  31. package/src/skills/heuristics/auth.md +130 -0
  32. package/src/skills/heuristics/background-work.md +129 -0
  33. package/src/skills/heuristics/buttons.md +99 -0
  34. package/src/skills/heuristics/camera.md +127 -0
  35. package/src/skills/heuristics/chat.md +125 -0
  36. package/src/skills/heuristics/colors.md +129 -0
  37. package/src/skills/heuristics/copy.md +157 -0
  38. package/src/skills/heuristics/data-display.md +124 -0
  39. package/src/skills/heuristics/feedback.md +122 -0
  40. package/src/skills/heuristics/forms.md +124 -0
  41. package/src/skills/heuristics/icons-and-imagery.md +135 -0
  42. package/src/skills/heuristics/layout.md +125 -0
  43. package/src/skills/heuristics/lists.md +129 -0
  44. package/src/skills/heuristics/localization.md +128 -0
  45. package/src/skills/heuristics/maps.md +129 -0
  46. package/src/skills/heuristics/media.md +130 -0
  47. package/src/skills/heuristics/motion.md +113 -0
  48. package/src/skills/heuristics/navigation.md +116 -0
  49. package/src/skills/heuristics/network.md +118 -0
  50. package/src/skills/heuristics/notifications.md +121 -0
  51. package/src/skills/heuristics/offline.md +124 -0
  52. package/src/skills/heuristics/onboarding.md +103 -0
  53. package/src/skills/heuristics/payments.md +138 -0
  54. package/src/skills/heuristics/performance.md +111 -0
  55. package/src/skills/heuristics/permissions.md +125 -0
  56. package/src/skills/heuristics/privacy-ui.md +112 -0
  57. package/src/skills/heuristics/scrolling.md +114 -0
  58. package/src/skills/heuristics/search.md +127 -0
  59. package/src/skills/heuristics/sense.md +128 -0
  60. package/src/skills/heuristics/settings.md +129 -0
  61. package/src/skills/heuristics/sharing.md +102 -0
  62. package/src/skills/heuristics/sound.md +95 -0
  63. package/src/skills/heuristics/splashscreen.md +111 -0
  64. package/src/skills/heuristics/states.md +120 -0
  65. package/src/skills/heuristics/touch.md +95 -0
  66. package/src/skills/heuristics/typography.md +99 -0
  67. package/src/skills/heuristics/updates.md +129 -0
  68. package/src/skills/heuristics/webviews.md +114 -0
  69. package/src/skills/heuristics/widgets.md +128 -0
  70. package/src/skills/references/capability-checks.md +59 -0
  71. package/src/skills/references/fonts.json +339 -0
  72. package/src/skills/references/icon-and-image-assets.md +103 -0
  73. package/src/skills/references/input-fields.md +82 -0
  74. package/src/skills/references/launch-surface.md +94 -0
  75. package/src/skills/references/motion-tokens.md +89 -0
  76. package/src/skills/references/navigation-containers.md +51 -0
  77. package/src/skills/references/search-controls.md +49 -0
  78. package/src/skills/references/type-scales.md +60 -0
  79. package/src/skills/references/wireframe-frame.md +209 -0
@@ -0,0 +1,186 @@
1
+ # Review
2
+
3
+ Runs after every build, on the code that was just written, and again on request over a finished screen. Reviewing your own output is the point: the build step optimizes for getting the screen working, and this step optimizes for finding where it fails in a hand.
4
+
5
+ One grader, one scale, one set of rule ids. What changes between a run on a diff and a run on a screen is not the standard, it is how much of the screen was actually seen, and the report says so.
6
+
7
+ ## The scale
8
+
9
+ Every rule in scope gets a number from 1 to 5. The same words on every rule, on every run, because a rule that invents its own wording for a 3 makes two runs incomparable.
10
+
11
+ - **1**: broken. What the rule exists to prevent is on the screen and the person using the app meets it.
12
+ - **3**: met on the ordinary path, and nothing beyond it was asked.
13
+ - **5**: met on the ordinary path and on the edges the rule itself names, with the evidence to say so.
14
+ - **2** and **4** are the gaps. A 2 is something in place that does not hold: met on one screen and dropped on the next, or met for the default case while the rule's own case is the exception. A 4 is a 5 with one thing outstanding.
15
+
16
+ A **violation** is a 1 or a 2, and that is the whole of the gate: the build loop continues while any rule is at 1 or 2. Nothing at 3 or above blocks a build, which does not make it finished.
17
+
18
+ Two answers that are not scores:
19
+
20
+ - **`n/a`**, with the reason in the row: the feature does not exist here, the platform does not have it, or `STACK.md` records the exception. Not available for the nine rules under **Always in scope** in `SKILL.md`. An `n/a` written in a screen brief is a claim the brief's author made before the code existed, so it is checked here like any other claim, and it never arrives as an exemption. Exceptions accepted on purpose live in `STACK.md`, which is the only file that grants one.
21
+ - **`unrun`**: the rule applied, nothing was checked, and the run knows it. A rule you did not check is never a pass.
22
+
23
+ Anchors are not a curve. Most rules on a screen built with this skill land at 3 and 4, a 5 is earned by evidence rather than by the absence of a complaint, and a screen with no 1s and no 2s is a screen that ships, which is a lower bar than a good screen.
24
+
25
+ ## 1. Re-read the code
26
+
27
+ Review the actual code, not your memory of writing it. Open the files that changed.
28
+
29
+ After a build that is the diff. On request it is the screen: the widget, view or composable that renders it plus whatever it pushes and presents, opened on a device or a simulator.
30
+
31
+ ## 2. Scope and checklist
32
+
33
+ The scope is every base file plus the extra files this screen touches, settled by your own pass over the Extra table in `SKILL.md` rather than by what the build says it opened. The nine always-in-scope rules sit inside the base files and are never `n/a`. Write the scope down before grading. A scope chosen once the scores are in is a scope chosen to flatter them.
34
+
35
+ An identity the build had to settle because `DESIGN.md` did not carry one is part of the scope, not a note beside it. It is graded by `color-derived`, `type-face`, `layout-shape` and `icon-depicts`, against the five lines the build wrote, and a screen whose identity would fit any other product in the category scores a 1 on the first of them however clean the rest of the code is.
36
+
37
+ Name the screen first, the same way build does, and derive the scope from the name. A build that named it wrong took the wrong files with it, and a review that inherits the build's list inherits the mistake. Where your pass reaches a file the build never opened, its rules are in scope all the same and every one of them is `unrun` until it is checked: a rule nobody looked at is not a rule that passed.
38
+
39
+ A screen brief at `.trunative/screens/<name>.md` does not change that. Its `scope` is what the spec step decided and what the rubric was generated from, and it may widen your scope and never shrink it. Where your own pass reaches a file the brief left out, grade it and report the difference as drift.
40
+
41
+ ```sh
42
+ npx trunative rubric --only forms --only search
43
+ ```
44
+
45
+ Base is in every rubric. `--only` adds extras, by file stem, by rule prefix such as `form-`, or by a single rule id, and repeats. `--format=ids` prints the ids alone, which is what a diff against the previous run reads to catch a rename.
46
+
47
+ The checklist is generated, never written by hand:
48
+
49
+ - Do not add a row. A rule that should exist belongs in `heuristics/`, and the next run picks it up with no second edit.
50
+ - Do not delete a row. A rule that does not apply is `n/a` with a reason, which is a different fact from a rule nobody looked at.
51
+ - Do not grade a rule that is not on the checklist. An id that is not there was renamed or removed, and a score for it is a score for nothing.
52
+
53
+ Each row carries the rule's own Check line, which is the criterion. Grade against that sentence, not against a memory of the heuristic. A row marked `[device]` is a rule the file cannot settle from the source, which decides the evidence in step 3.
54
+
55
+ ## 3. Grade
56
+
57
+ Every row gets a score, one line of finding, and its evidence:
58
+
59
+ - `source`: the code was read. The claim is about intent.
60
+ - `device`: the screen was driven. The claim is about the app.
61
+ - `source+device`: both, and they agreed. Where they disagree the device wins, and the disagreement is a finding.
62
+
63
+ A rule the checklist prints `[device]` on may not take `source`: its own file has already said a diff cannot settle it, so a number from the source alone is a guess wearing a score. The paragraph that file closes its Check section with, printed under the group as `Not from a diff`, says why. Those rules are `device`, `source+device`, or `unrun`. A rule without the mark can be graded from the source, and is still worth seeing on a device.
64
+
65
+ A screen is never closed on `source` alone. Render it, look at it, and keep the capture beside the report. What the file hides and the picture shows in a second: the last row of content sitting under a pinned bar, the primary action stranded in the middle of an empty frame, a hero occupying a third of the height and depicting nothing, placeholder text standing in as the content. None of that is arguable from code, and all of it is what a person sees first.
66
+
67
+ <If stack="web">
68
+ ```sh
69
+ chrome --headless --window-size=402,874 --screenshot=screen.png file:///absolute/path/screen.html
70
+ ```
71
+ </If>
72
+
73
+ <If stack="flutter,react-native,swiftui,compose">
74
+ ```sh
75
+ xcrun simctl io booted screenshot screen.png # iOS simulator
76
+ adb exec-out screencap -p > screen.png # Android emulator or device
77
+ ```
78
+ </If>
79
+
80
+ <If stack="other">
81
+ Capture the running screen the way this platform captures one, and scroll to the end of the content before the capture.
82
+ </If>
83
+
84
+ Scroll to the end of the content before capturing, or the collision `layout-chrome` exists to catch stays hidden.
85
+
86
+ When the app is running on a device, split the work in two and keep the halves apart, because a grader who has already read the measurements grades the measurements:
87
+
88
+ **Judging.** Reads the source, drives the screen, and fills in every row.
89
+
90
+ **Measuring.** Produces numbers and captures, no scores at all, each keyed to a rule id. Start with the one measurement that needs no device:
91
+
92
+ ```sh
93
+ npx trunative detect lib/screens/checkout_screen.dart
94
+ ```
95
+
96
+ It reads the files and answers the part of a `Check` line a file can settle, reporting per rule id. Three things follow from that and none of them is optional: every finding is `source` evidence, so it never settles a rule marked `[device]`; a finding is a place to look rather than a score; and its silence proves nothing, so a rule it did not answer stays with the grader. It exits 2 when it finds something, which is not a failure.
97
+
98
+ Then the measurements that do need a device: both appearances on the narrowest and widest device class, the largest accessibility text step on the narrowest, hit area bounds read from the inspector rather than estimated from a screenshot, the primary flow completed with the screen reader on, the screen with the network off and after a process kill the system would have made itself, and real records rather than seed data, meaning a null, a zero, a long string, an old timestamp and an empty list.
99
+
100
+ How the two are kept apart depends on the harness you are running in:
101
+
102
+ <If agent="claude">
103
+ Run them as sub-agents, spawned in one message so they work at the same time and neither reads the other's output.
104
+ </If>
105
+
106
+ <If agent="other">
107
+ Run them as sub-agents when this harness has them, spawned so that neither reads the other's output. When it does not, finish the judging pass and record it, then measure.
108
+ </If>
109
+
110
+ Then reconcile: the judged score stands unless a measurement contradicts it, and every score a measurement moved is printed with both numbers. The report is one table, not one pass after the other. When they could not be kept apart, the report says so on its first line.
111
+
112
+ ## 4. The numbers
113
+
114
+ The maximum is 5 times the number of rules actually scored from 1 to 5. Nothing else is in it.
115
+
116
+ - The denominator is never the size of the scope. A scope of 74 rules with 9 `n/a` and 5 `unrun` scores out of 300, not out of 440.
117
+ - Report coverage beside the total: scored, `n/a` and `unrun`, out of the scope. A run that skipped the device work does not come out ahead of one that did it.
118
+ - Record which ids were `n/a` and which were `unrun`. A later comparison against a run that hid them is a comparison of two different measurements.
119
+
120
+ Bands read off the percentage, since the maximum moves with the scope:
121
+
122
+ | Percentage | Band |
123
+ |---|---|
124
+ | 90 and above | Nothing structural left. |
125
+ | 75 to 89 | Solid, with named gaps. |
126
+ | 60 to 74 | It works, and the edges do not. |
127
+ | 40 to 59 | Structural work before polish. |
128
+ | Under 40 | Not designed for a phone yet. |
129
+
130
+ ## 5. Report
131
+
132
+ The report goes in the response, in this order:
133
+
134
+ 1. **Header.** What was reviewed, the scope, the total, the percentage, the band and the coverage.
135
+ 2. **The table.** Columns: rule, score, evidence, finding. Print every rule at 4 or below and every one of the always-in-scope nine, then one line per file for the rest: file, rules scored, average, lowest.
136
+ 3. **Violations.** Every rule at 1 or 2, ordered by what it costs the person using the app and not by how easy it is to fix. Each names the rule id, the file and line, what the user meets, and the fix. Say plainly when the screen is unusable one-handed, loses work on interruption, or has no failure state, and do not bury it under smaller findings.
137
+ 4. **Spec drift.** Only when the screen has a brief, and unscored. Each entry quotes what the brief says and what the code does: a hierarchy in a different order, a `primary_action` whose label is not the one on screen, a state declared and not implemented, a scope the brief left out. Where the divergence also breaks a rule, name the id that already scores it, such as `state-offline` for a declared state that is not there, and do not score it twice. Drift is a fact about two files disagreeing, and the moment it carries a number this file has two graders in it.
138
+ 5. **What moved.** Scores a measurement changed, with both numbers.
139
+
140
+ If a violation is a deliberate exception recorded in `STACK.md`, it is `n/a` with that exception as the reason, not a 1 defended in prose.
141
+
142
+ Name the element, say what it costs, give the fix. Nothing in the report is an invitation to look into something later.
143
+
144
+ ## 6. Record
145
+
146
+ A run with nothing `unrun` is a complete measurement and gets archived. A run that left rules unchecked prints its table and stops here, naming the rules that blocked the archive, because a partial run in the history makes every later comparison lie.
147
+
148
+ Write the report to `.trunative/review/<slug>-<YYYY-MM-DD-HHmm>.md`, one file per run, kept in version control. The slug comes from the primary file's project-relative path: lowercase it, replace every run of characters that is not a letter or a digit with a single hyphen, and drop a leading and trailing one, so `lib/screens/checkout_screen.dart` becomes `lib-screens-checkout-screen-dart`. It is computed the same way on every run and never invented, because the trend reads it.
149
+
150
+ Frontmatter, machine readable:
151
+
152
+ ```yaml
153
+ ---
154
+ target: lib/screens/checkout_screen.dart
155
+ slug: lib-screens-checkout-screen-dart
156
+ spec: .trunative/screens/checkout.md
157
+ date: 2026-09-08T14:22
158
+ skill: sha256:6f0a...
159
+ scope: [touch, forms, states, layout, typography, colors, motion, accessibility]
160
+ total: 236
161
+ max: 300
162
+ percent: 79
163
+ scored: 60
164
+ na: [pay-restore: no purchases on this screen, ads-report: no Android build]
165
+ ---
166
+ ```
167
+
168
+ `skill` is the hash in `.trunative/skill.lock`. It says which set of rules produced the numbers, which is how a later run knows the rulebook moved under it. `spec` is the screen brief this run graded against, omitted when the screen has none, so a later comparison can tell a screen that changed from a screen whose declared intent changed.
169
+
170
+ Then read the newest five archived runs with the same slug and print one line:
171
+
172
+ > Trend for `lib-screens-checkout-screen-dart`: 61%, 74%, 79% (236/300 this run).
173
+
174
+ The comparison is only honest when the measurement is the same, so name any of these that changed since the previous run, on the same line: the skill hash moved, so some of the difference is rules rather than the screen; the scope changed, and which files joined or left; or the `n/a` set changed, since a rule that was excused last time and scored now moved the total on its own. The scope may grow between runs and may not silently shrink: a later run covering fewer files is not a better run.
175
+
176
+ First run: there is nothing to compare against. Say so in one line, name it the baseline, and name the file the next run will read.
177
+
178
+ ## 7. Loop
179
+
180
+ - Any rule at 1 or 2: go back to `flow/build.md` with this report and fix them, then review again.
181
+ - Drift where the code is right and the brief is stale: correct the brief and carry on. Drift where the brief is right and the code left the approved structure: that is hierarchy, actions, states or navigation moving without anyone deciding it, so go back to `flow/spec.md`, not to build.
182
+ - Nothing below 3: stop. Report what was built, what was checked, what was excused and what was not seen.
183
+
184
+ Two consecutive reviews finding the same violation means the fix is not working. Say so and ask the user, instead of looping a third time.
185
+
186
+ Do not fix and grade in the same pass. A grader that edits the code it just graded has no second opinion left for the next run.
@@ -0,0 +1,149 @@
1
+ # Spec
2
+
3
+ Settles what is on the screen before anything draws it. Runs between init and build, whenever the task establishes or changes structure.
4
+
5
+ Structure is four things: hierarchy, actions, states, navigation. A new screen, a new component with state of its own, or a new flow settles all four at once, so it always comes through here: nothing is being changed because everything is being decided, and that is the maximum case rather than an exemption. An existing screen comes through when the task moves any of the four.
6
+
7
+ The step ends with two things, and neither is optional: a wireframe rendered and looked at, and a question the user answered. A spec step that wrote a brief and went on to code did not run.
8
+
9
+ ```text
10
+ "Build the login screen." spec, new screen
11
+ "Add a settings screen to the app." spec, new screen
12
+ "Move People above Recently Viewed." spec
13
+ "Add Google sign-in." spec
14
+ "This screen has no empty state." spec
15
+ "Send the row to a detail screen." spec
16
+
17
+ "Make the button blue." build
18
+ "Four more points of padding." build
19
+ "The transition is too slow." build
20
+ "Rename the view model." build
21
+ ```
22
+
23
+ Write the verdict in one line before anything else, naming which of the four the task touches or naming that it touches none. A verdict nobody wrote is a step nobody ran, and it fails in one direction only: writing code is the attractor, so the unwritten answer is always "straight to build".
24
+
25
+ The distinction is the whole economy of this step. A brief demanded for every cosmetic edit is ceremony people route around, and a brief skipped on a structural edit is a decision that dies with the session.
26
+
27
+ ## 1. The screen brief
28
+
29
+ One file per screen, at `.trunative/screens/<name>.md`. It is the fourth brief: `PRODUCT.md` is the product, `DESIGN.md` is the identity, `STACK.md` is the codebase, and this is one screen.
30
+
31
+ ### Frontmatter
32
+
33
+ ```yaml
34
+ ---
35
+ target: lib/screens/memories_screen.dart
36
+ primary_action: "Explore Memories"
37
+ states:
38
+ loading: skeleton cards in the rail, same row height as the real ones
39
+ empty: no memories yet, offers creating the first
40
+ error: retry without losing the active filter
41
+ offline: cached memories, carrying their age
42
+ partial: n/a, the rail loads whole or fails
43
+ permission: n/a, nothing here is behind a grant
44
+ scope:
45
+ open: [lists, navigation, media, scrolling, offline]
46
+ closed:
47
+ forms: there is no input field, the filter is a chip
48
+ search: filtering by person is not a text query
49
+ ---
50
+ ```
51
+
52
+ Four fields. Nothing joins them without a consumer already reading it.
53
+
54
+ **`target`** is the file that implements the screen, relative to the project root. It is what links the brief to everything else: `flow/review.md` computes its archive slug from that path, and `npx trunative detect` reads it. There is no `id` field, because the target is the identity and the filename is a convenience.
55
+
56
+ **`primary_action`** is the label the user reads, in quotes, not an identifier. `button-label`, `copy-first-word` and `copy-case` all judge the visible string, and the comparison against the code only works on the string. Exactly one: a screen with two primary actions is two screens, and `button-one-primary` is what says so.
57
+
58
+ **`states`** carries the six keys `state-set` names, always all six: `loading`, `empty`, `error`, `offline`, `partial`, `permission`. Each value is one line saying what the screen shows, or `n/a` followed by the reason. A missing key, an empty value, or an `n/a` with no reason is a defect in the brief.
59
+
60
+ The keys are fixed because a free list is a loophole. A screen that picks its own five states declares five, ships five and reads as complete, while the rule asks for six. Fixed keys make the absence something somebody had to write down and sign.
61
+
62
+ A screen driving a camera, a microphone, location, a motion sensor or a radio owes the five further states in `sense-states` on top of these, in the body.
63
+
64
+ **`scope`** names files from the Extra table in `SKILL.md`, by the stem or by the rule prefix beside it, whichever the row put in front of you: `localization` and `l10n` are the same row. Never a rule id, and never one of the nine always in scope, which are not optional and so are never declared. `open` is what build reads and what `npx trunative rubric --only` takes. `closed` carries the rows a reader would expect to be open, each with the sentence `SKILL.md` requires. Not the whole table: the near misses, which are the only ones anybody argues about later.
65
+
66
+ ### Body
67
+
68
+ In this order, and nothing else:
69
+
70
+ 1. **Purpose.** One sentence, the job the screen does, from `PRODUCT.md`.
71
+ 2. **Hierarchy.** Numbered, top to bottom, in reading order.
72
+ 3. **Components.** What is on the screen, named as the thing it is.
73
+ 4. **Primary action.** The action and where the thumb reaches it.
74
+ 5. **Interactions.** One line per gesture, as target then result.
75
+ 6. **Navigation.** Where this screen leads and how it is reached, when either changed.
76
+ 7. **States.** A subsection per state that needs more than its frontmatter line.
77
+
78
+ ### The ceiling
79
+
80
+ Two tests, and the brief fails on either:
81
+
82
+ - If every line of this screen's code were deleted, could the intent and the structure be rebuilt from this file?
83
+ - Does the file contain a number with a unit? Padding, radius, font size, width, a hex colour and a shadow belong in `DESIGN.md`. A brief that starts carrying them is a layout format wearing a brief's name.
84
+
85
+ ## 2. A screen that already exists
86
+
87
+ Nobody reconstructs a codebase by hand. When a screen has no brief and a structural change arrives, derive the brief from the implementation first, the same way init derives the project briefs from the code, then make the change against it.
88
+
89
+ Derive what the code shows and nothing more. A state the code does not implement is not written as implemented: it is the `n/a` that has no reason, which is exactly the finding that sends the screen back to build.
90
+
91
+ ## 3. The wireframe
92
+
93
+ The brief preserves the decision. It does not let anyone see it. A hierarchy list reads in thirty seconds and hides what an image gives away at once: the primary action stranded in an empty frame, the last row of content under the pinned bar, a hero eating a third of the height, a density that does not fit. `flow/review.md` says the same thing when it refuses to close a screen on source evidence alone.
94
+
95
+ So the brief gets rendered before it gets approved, and the rendering is a page the agent writes.
96
+
97
+ Write `.trunative/screens/<name>.wireframe.html` from the brief. The frame, the greyscale palette and the placeholder conventions are in `references/wireframe-frame.md`, which is a skeleton to copy rather than a thing to reinvent per screen.
98
+
99
+ - Greyscale, outlines, placeholders, a cross for an image, one system typeface, hierarchy by size.
100
+ - Never a brand colour, a gradient, a shadow, a real photograph, an icon set, or any token from `DESIGN.md`. The wireframe settles structure, and a wireframe carrying identity collects an approval nobody asked for.
101
+ - HTML even when the project ships Flutter, Compose or SwiftUI. Writing the wireframe in the project's stack is writing the screen twice, which is the cost this step exists to avoid.
102
+ - The default state always. One more frame per state whose composition is genuinely different, which in practice is the empty one. Never all six: six frames cost more than the screen.
103
+
104
+ Then render it and look at it yourself, before anyone else does:
105
+
106
+ ```sh
107
+ chrome --headless --window-size=393,852 --screenshot=wireframe.png .trunative/screens/<name>.wireframe.html
108
+ ```
109
+
110
+ <If agent="claude">
111
+ Open the PNG with the Read tool and read it as a picture. That pass is the cheap half of this step: the collisions `flow/review.md` can only catch on a device show up here for the price of one HTML file.
112
+ </If>
113
+
114
+ <If agent="codex,antigravity,opencode,other">
115
+ Open the PNG however this harness shows an image, and read it as a picture. That pass is the cheap half of this step: the collisions `flow/review.md` can only catch on a device show up here for the price of one HTML file. When the harness cannot show you an image at all, say so in the hand-off and leave the judgement to the user rather than claiming the frame was checked.
116
+ </If>
117
+
118
+ What the wireframe settles: the first screenful (`layout-fold`), content colliding with fixed chrome (`layout-chrome`), grouping and density (`layout-grouping`), reach for the primary action (`touch-reach`), and whether the real words fit (`copy-budget`).
119
+
120
+ What it does not settle, and does not try: contrast, the dark appearance, text at the accessibility steps, measured hit areas, the screen reader, motion. Those are the nine always in scope, they are answered on a device in `flow/review.md`, and they are why the gate below approves structure rather than a finished screen.
121
+
122
+ The wireframe is scaffolding, not a second contract. After approval nothing reads it: review does not open it, drift does not consult it, and no check validates it. It stays in the repository as the record of what was approved, and a later structural change rewrites it from the brief instead of patching it. One contract, brief against code, and no second surface to diverge.
123
+
124
+ ## 4. The gate
125
+
126
+ Show the frame and four lines, and ask once:
127
+
128
+ ```text
129
+ Memories
130
+
131
+ Hierarchy title, filters, recent memories, people, navigation
132
+ Primary "Explore Memories", bottom third
133
+ States loading, empty, error, offline; partial and permission n/a
134
+ Scope lists, navigation, media, scrolling, offline
135
+
136
+ Approve?
137
+ ```
138
+
139
+ The approval is about hierarchy, actions, states and navigation. It is not about colour, typeface, spacing or polish, and an answer about those goes to `DESIGN.md` rather than into the brief.
140
+
141
+ Ask the user, and never grant it yourself. Writing the brief, drawing the frame and deciding it looks right is this step doing its own homework, not the gate: the gate is the one part of the step the user sees, and a step reporting "approved" with no question asked has skipped exactly that part.
142
+
143
+ One question per session, which is not none. The first structural task of a session always asks. After it, the brief is still written and the frame still drawn, and the question comes back when a new structure contradicts one already approved.
144
+
145
+ When the harness cannot show the user a picture, say so and put the four lines in front of them anyway. A gate answered on the text alone is weaker than one answered on the frame, and it is still the user answering.
146
+
147
+ ## 5. Hand off
148
+
149
+ Say what the brief settled, which extra files its scope opened and which near misses it closed, and what the wireframe pass caught. Then run `flow/build.md`.
@@ -0,0 +1,124 @@
1
+ # Accessibility
2
+
3
+ Everything else in this repository describes what the screen looks like. This file describes what is underneath it: the name of each control, the order they are reached in, and what the app says when something changes. That layer is the whole interface for someone using a screen reader, a switch, or their voice, and it is invisible in a screenshot, so it is the layer that gets shipped empty.
4
+
5
+ A phone sharpens every part of it. There is no width for labels, so controls become icons with no text at all. There is no keyboard, so actions become gestures that a reader user cannot perform. One screen shows at a time, so content is swapped in place rather than loaded as a new page, and nothing announces the swap. And the settings that change the interface live in the operating system, apply to every app at once, and are already on when the app launches.
6
+
7
+ Four neighbours carry pieces of this and are not repeated here: `color-not-alone`, `touch-floor`, `touch-spacing` and `type-scaling`. `list-a11y` is a fifth of a different kind: it is these rules applied to a list row, so the two are read together.
8
+
9
+ ## <Rule id="a11y-name" description="Every control carries a name, a role and a value" />
10
+
11
+ Three separate things, and the last two are the ones that go missing.
12
+
13
+ The **name** says the action and its object, in the words the user would use. It is not the icon, not the asset, not the glyph identifier. A control announced as `ic_chevron_right` or `star.fill` is not a poorly named control, it is an unusable one. `button-label` governs the visible label; this is what gets read when there is no visible label at all, which on a phone is most of the toolbar.
14
+
15
+ - Leave the control type out of the name. The role already carries it, so "Add button" is announced as "Add button button".
16
+ - Leave the surrounding context out too. Inside a player, "Play", not "Play song".
17
+ - No two controls a voice user could address carry the same name. Three buttons all called "More" give a voice user nothing to say and a switch user nothing to aim at. Two rows that genuinely hold the same words are not that failure.
18
+
19
+ The **role** arrives free with a real control and has to be declared on anything built from a container plus a tap handler: `Role` in Compose, a trait in SwiftUI, the `button`/`header`/`slider` flags in Flutter, `accessibilityRole` in React Native.
20
+
21
+ The **value** is the current state, and it changes while the name does not: `stateDescription` in Compose, `accessibilityValue` in SwiftUI, `Semantics(value:)` in Flutter. A real control brings its own: a `Switch` reports checked or not checked and a `Toggle` reports on or off with nobody writing a line, so declaring a value over that is noise. Two cases are not covered. A container plus a tap handler standing in for a control reports no state at all. And a default can be right and still misleading, which is what an override is for: a mute button announced as "selected" is describing the widget rather than the sound. The same trap catches any control whose name should change with its state: a play control that keeps the name it was built with is lying half the time.
22
+
23
+ Anything drawn onto a canvas (a chart, a custom picker, a signature field) has no child elements and is a single blank node until semantics are written for it by hand, with `ExploreByTouchHelper` on Android views or a semantics tree in the declarative kits.
24
+
25
+ ## <Rule id="a11y-hidden" description="Decoration is hidden, not described" />
26
+
27
+ Every node in the tree is a stop the user has to step through. An icon sitting beside the label it duplicates, a divider, a background image, a chevron that only says the row opens: hide each one rather than naming it. `contentDescription = null` in Compose, `.accessibilityHidden(true)` in SwiftUI, `ExcludeSemantics` in Flutter. What matters is the outcome: a node that produces no semantics of its own, a spacer, a divider drawn as a background, already costs nothing and needs no declaration written over it.
28
+
29
+ The opposite failure is just as common and reads worse: decoration given a description of its own picture, so the reader says "grey rounded rectangle with a blue circle". Text needs nothing, because it announces itself. And a loading placeholder is decoration until real content replaces it, so the shimmer blocks are hidden and the arrival is announced once (`state-loading`, `a11y-announce`).
30
+
31
+ ## <Rule id="a11y-order" evidence="device" description="Reading order is the order, and grouping decides how many stops" />
32
+
33
+ Traversal order comes from the layout tree, walked in the reading direction of the content. It breaks wherever drawing order and layout order disagree: an absolutely positioned element, a bar drawn after the content it sits above, a floating button declared last in the file, an overlay stacked on top of the screen it belongs to.
34
+
35
+ Fix it by fixing the declaration order. An override is the second answer: in Compose that is `isTraversalGroup` on the parent with `traversalIndex` on the children, and `traversalIndex` alone does nothing without the group flag on the parent.
36
+
37
+ Grouping is the other half of the same rule. A card holding an image, a title, two lines of body and a badge is one thing to the user and six stops to a reader. Merge it so it reads as one sentence. `list-a11y` covers list rows; the identical problem appears in a card, a stat block, a labelled value pair, and a field with its helper text and its error.
38
+
39
+ The screen title is announced first on arrival, so a screen whose title exists only inside a custom header view arrives in silence. Give it a title the system knows about: the navigation title where the stack has one, and `paneTitle` in Compose on a screen whose header is a composable of your own. Headings are what a reader jumps between instead of walking every element, and the trait that makes one is `list-a11y`.
40
+
41
+ ## <Rule id="a11y-collection" evidence="device" description="A collection says how long it is and where in it you are" />
42
+
43
+ A phone has no scrollbar, and a recycling list keeps roughly a screenful of nodes alive at a time (`list-virtualise`). So a reader user walking it is told neither how many items exist nor which one this is, and there is nothing on screen to answer either question.
44
+
45
+ A list, a grid or a carousel declares itself as a collection and each child declares its position in it, so the reader announces item three of two hundred. The platform list primitives do it on their own; anything assembled by hand does it explicitly, with `collectionInfo = CollectionInfo(rowCount, columnCount)` on the container and `collectionItemInfo = CollectionItemInfo(...)` on each child in Compose, and `IndexedSemantics` in Flutter. The total is how many items the data holds, not how many rows are realised: a count that follows the recycler tells the user the list is shrinking while they walk it.
46
+
47
+ ## <Rule id="a11y-announce" evidence="device" description="Content that changes without a navigation has to say so" />
48
+
49
+ Almost nothing on a phone is a page load. A filter narrows the list in place, a total recalculates, a field turns red, a banner slides in at the top. A sighted user catches all of it in peripheral vision. A reader user is three stops away and is told nothing at all.
50
+
51
+ - Mark the region that changes and let the platform speak it: `liveRegion = LiveRegionMode.Polite` in Compose, an announcement notification on iOS, `Semantics(liveRegion: true)` in Flutter.
52
+ - Polite by default. Assertive cuts off whatever the user is listening to, so it is reserved for the thing that stops them: a failure, a payment result, a session ending.
53
+ - Four that must never fire: one per keystroke inside a field, one per frame of a progress bar, one per row of an incoming page, one per tick of a countdown. Announce the outcome, never the process.
54
+ - The announcement is the same sentence the screen shows, which means it is a translated string (`l10n-strings`) and, for a failed field, the message `form-error` already placed beside it.
55
+ - An announcement is heard once, and the field keeps failing after it. So the field declares the failure on its own node, in those same words, and somebody who reaches it a minute later hears why instead of hearing the label alone: `error("...")` in the Compose semantics, the value and the traits on iOS.
56
+
57
+ ## <Rule id="a11y-focus" evidence="device" description="Focus moves where the screen moved, and comes back" />
58
+
59
+ - When a sheet, dialog or cover opens, focus moves into it and cannot leave. On a phone the new surface covers the whole screen, so a reader that can still walk the layer beneath is reading a screen the user cannot see. The platform primitives settle it by presenting in their own context or window: `.sheet` and `.fullScreenCover` in SwiftUI, `Dialog` and `ModalBottomSheet` in Compose. An overlay stacked by hand inside a `ZStack` or a `Box` does not, so there the layer beneath is made inert by hand: `.accessibilityHidden(true)` in SwiftUI, `Modifier.clearAndSetSemantics {}` in Compose, `ExcludeSemantics` in Flutter, `accessibilityViewIsModal` on iOS with `importantForAccessibility="no-hide-descendants"` on Android in React Native.
60
+ - On dismissal, focus returns to the control that opened it. Dropping the user at the top of the screen makes them traverse the whole thing again to get back to where they were.
61
+ - The way out has to be reachable from inside the surface, which is `nav-modal` stated for a user who cannot perform the dismissing gesture.
62
+ - Focus never moves unless the user asked it to. Taking it on load talks over the screen title, and taking it on every state change makes the screen impossible to read. Two moves are asked for and stay: onto the single field a screen exists for, such as search or a code (`form-input`), and onto the first failing field after a submit (`form-error`).
63
+
64
+ ## <Rule id="a11y-gesture" description="A gesture is never the only route" />
65
+
66
+ A swipe, a long press, a drag to reorder, a pinch, anything with two fingers: none of these can be performed by someone using a reader, a switch, or a keyboard. Each one needs a named route to the same result, and where that route comes from depends on what drew the gesture.
67
+
68
+ - Where the primitive already projects the gesture into the tree, the work is naming it. `Modifier.combinedClickable(onLongClickLabel = "Pin conversation")` puts the verb into the long press the platform is already offering, and a platform swipe row carries its actions with whatever labels they were given. An action left unnamed is announced as a generic one, and that is the defect rather than a missing declaration.
69
+ - Where nothing projects it, a swipe layer assembled by hand, a drag to reorder, a pinch, the route is a custom action on the same node, named with the verb: `customActions` in Compose, `.accessibilityAction(named:)` in SwiftUI, `CustomSemanticsAction` in Flutter, `accessibilityActions` in React Native. `list-a11y` is this rule applied to a row.
70
+ - A value that is dragged (a slider, a reorder handle, a rating) gets the adjustable action instead, so it moves one step at a time.
71
+ - Custom multi-finger gestures have no route at all. Use the simplest gesture that works, and keep the visible equivalent that `list-swipe` and `list-refresh` already require.
72
+
73
+ ## <Rule id="a11y-alt-input" description="Switch and voice reach the app through names and targets" />
74
+
75
+ A switch moves one highlight through everything focusable, in order, one press per step. Voice control acts on whatever the user can read out loud off the screen.
76
+
77
+ - The spoken name matches the visible words. A button reading "Send" whose name is "Submit your message" cannot be spoken to. Where the two genuinely have to differ, the visible words are added rather than swapped in: `accessibilityInputLabels(_:)` is the iOS hook, and on Android there is no separate field, so the visible words go inside the description itself.
78
+ - Anything that takes the highlight is something the user can act on. Decoration left in the tree (`a11y-hidden`) turns a five-step screen into a twenty-step one, and each of those steps is a physical press.
79
+ - The highlight has to be visible, and it is the one piece of this that gets deleted on purpose. `outline: none` on mobile web, and any focus style suppressed because it looked wrong under a finger, leaves the switch and keyboard user pressing forward with nothing on screen saying where they are. Replace the default indicator with a better one; never remove it and leave nothing.
80
+ - Every action is reachable by stepping, in a finite number of steps. A control that only appears mid-drag, or only under a long press with no custom action, does not exist for this user.
81
+ - Nothing that carries the only copy of something dismisses itself on a timer: a toast holding an error message, a snackbar holding the only undo, a code that expires while the highlight is still walking toward the field. Stepping across a screen takes several times as long as tapping it. Prefer an explicit dismissal.
82
+
83
+ ## <Rule id="a11y-settings" description="The system settings are people, not options" />
84
+
85
+ Larger text, bold text, increased contrast, reduced motion, reduced transparency. Each one is switched on by a user who needed it, and each one is already on before the app launches.
86
+
87
+ - Larger text and bold text are `type-scaling` and `type-weight`. Both are honoured by taking every style from the theme rather than typing a size or a weight into a component, and the size the interface has to survive is the largest step `type-scaling` names.
88
+ - Reduced motion belongs to `motion.md` in full. Read the setting, honour it, and do not restate it here.
89
+ - Increased contrast is read as `colorSchemeContrast` in SwiftUI or `isDarkerSystemColorsEnabled` in UIKit, where the symbol name does not contain the word, and as `UiModeManager.getContrast()` from API 34 on Android. On iOS the semantic system colors answer it on their own; on Android the response has to be written, and either way a hex typed into a component cannot follow it (`color-roles`).
90
+ - Reduced transparency is an iOS setting with no Android counterpart, so an Android-only build answers it as not applicable. On iOS it is honoured by the platform's own material and ignored by a blur rebuilt by hand, which is one more reason `color-gradient` sends you to the system one.
91
+ - All of these are live values, not launch-time facts. Somebody will change one while the app is open, from the accessibility shortcut, and the screen has to follow: `addContrastChangeListener` on Android, the matching change notification on iOS.
92
+
93
+ ## <Rule id="a11y-media" description="Nothing is carried by audio alone" />
94
+
95
+ Phones are used muted, in public, and by people who cannot hear them.
96
+
97
+ - One of these the system already answers, which is the half a diff can be checked against: `isClosedCaptioningEnabled` on iOS and `CaptioningManager.isEnabled()` on Android say captions are wanted. Read it at launch and on its change notification, and start the player the way it says. Whether video may start by itself is `motion-autoplay`.
98
+ - The rest is designed rather than read. Video carrying speech or meaningful sound carries captions, with the control to turn them on inside the player rather than buried in settings.
99
+ - Sound is never the only signal. A success chime, an error beep, a haptic with no visible change: pair every one with something the screen shows (`touch-feedback`).
100
+ - Audio that starts on its own is gated by `motion-autoplay` and `sound-unasked`, and the control to stop it is reachable in one step.
101
+
102
+ ## <Rule id="a11y-test" evidence="device" description="Drive one whole flow with the reader on" />
103
+
104
+ - Run the platform scanner first, on every screen that changed. It catches the missing name, the small target and the low contrast, and it catches none of the four things above it: wrong order, wrong name, missing announcement, missing route.
105
+ - Then use the app without looking at it. One complete flow, start to finish, stepping forward through every element with the screen reader on. Count the stops on the busiest screen: a card or a row costs one stop plus one for each separately tappable control it carries, which `list-row` caps at two. Above that, the merge `a11y-order` and `list-a11y` ask for did not happen.
106
+ - Run it once more at the largest text size, and once with the reader off using a switch or a hardware keyboard.
107
+
108
+ <Check>
109
+
110
+ <Verify rule="a11y-name">Every interactive element has a name that is not its icon or asset, a declared role, and a declared value wherever the platform's own is missing or misleading, and no two controls a voice user could address share a name.</Verify>
111
+ <Verify rule="a11y-hidden">No decorative element produces a stop, and nothing decorative carries a description of its own appearance.</Verify>
112
+ <Verify rule="a11y-order">Traversal follows declaration order, any `traversalIndex` sits under an `isTraversalGroup` and carries the reason the order itself could not be fixed, each composite reads as one stop, and the screen has a title the system knows about.</Verify>
113
+ <Verify rule="a11y-collection">Every list, grid and carousel declares its collection and each child its position, with a total taken from the data rather than from the realised rows.</Verify>
114
+ <Verify rule="a11y-announce">Every in-place content change has a live region or an announcement, polite unless it stops the user, none fires per keystroke, per frame, per row or per tick, and a failed field declares the failure on its own node.</Verify>
115
+ <Verify rule="a11y-focus">Modals contain focus and return it to the opening control on dismissal, a hand-built overlay makes the layer beneath inert, and focus moves nowhere else except onto a single-field screen or the first failure after a submit.</Verify>
116
+ <Verify rule="a11y-gesture">Every swipe, long press, drag and multi-finger gesture reaches the same result through a named action: the primitive's own label where it projects one, a custom action where nothing does.</Verify>
117
+ <Verify rule="a11y-alt-input">Spoken names match visible labels, nothing decorative takes the highlight, the focus indicator is visible and was never merely removed, every action is reachable by stepping, and nothing holding the only copy of something dismisses on a timer.</Verify>
118
+ <Verify rule="a11y-settings">Increased contrast is read and honoured on each platform it exists on, reduced transparency on iOS is left to the system material, and every setting is read live rather than cached at launch.</Verify>
119
+ <Verify rule="a11y-media">The system captions preference is read, video with speech has captions, no signal is audio or haptic only, and anything that starts on its own can be stopped in one step.</Verify>
120
+ <Verify rule="a11y-test">The scanner was run on the changed screens and one full flow was completed with the screen reader on.</Verify>
121
+
122
+ <Device>The last line is not answerable from a diff. `a11y-order`, `a11y-collection`, `a11y-announce` and `a11y-focus` are only half answerable from one: the tree they describe exists at runtime, so a file can show the intent and only a running screen shows the result.</Device>
123
+
124
+ </Check>